mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +00:00
feat: generate authenticated url (#1471)
* refactor: extract info component * feat: generate authenticated url * refactor: add companion select * refactor: ensure behaviour across environments
This commit is contained in:
@@ -12,3 +12,16 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
return res.data.url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.infoLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
background-color: $gray-1100;
|
||||
border-radius: 2px;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
export default function Info({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className={style.infoLabel}>
|
||||
<IoAlertCircle />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,23 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
background-color: $gray-1100;
|
||||
border-radius: 2px;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
import Info from '../info/Info';
|
||||
|
||||
import { ViewOption } from './types';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
@@ -132,12 +132,7 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
{viewSettings.overrideStyles && (
|
||||
<div className={style.infoLabel}>
|
||||
<IoAlertCircle />
|
||||
This view style is being modified by a custom CSS file. <br />
|
||||
</div>
|
||||
)}
|
||||
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>}
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||
{viewOptions.map((section) => (
|
||||
<ViewParamsSection
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { linkToOtherHost } from '../linkUtils';
|
||||
|
||||
describe('linkToOTherHost', () => {
|
||||
it('should handle electron links', () => {
|
||||
const serverUrl = 'http://localhost:4001';
|
||||
const baseUri = '';
|
||||
const destination = linkToOtherHost('192.168.10.166', 'path', serverUrl, baseUri);
|
||||
expect(destination).toBe('http://192.168.10.166:4001/path');
|
||||
});
|
||||
|
||||
it('should handle ontime cloud links', () => {
|
||||
const serverUrl = 'https://cloud.getontime.no/user-hash';
|
||||
const baseUri = 'user-hash';
|
||||
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
|
||||
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
|
||||
});
|
||||
});
|
||||
@@ -18,23 +18,32 @@ export function openLink(url: string) {
|
||||
|
||||
/**
|
||||
* Handles opening external links
|
||||
* @param event
|
||||
* @param location
|
||||
* serverUrl and baseURI are used for testing
|
||||
*/
|
||||
export function handleLinks(event: MouseEvent, location: string) {
|
||||
export function handleLinks(
|
||||
event: MouseEvent,
|
||||
location: string,
|
||||
externalServerUrl: string = serverURL,
|
||||
externalBaseURI: string = baseURI,
|
||||
) {
|
||||
// we handle the link manually
|
||||
event.preventDefault();
|
||||
|
||||
const destination = new URL(serverURL);
|
||||
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
|
||||
const destination = new URL(externalServerUrl);
|
||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
|
||||
openLink(destination.toString());
|
||||
}
|
||||
|
||||
export function linkToOtherHost(host: string, path?: string) {
|
||||
const destination = new URL(serverURL);
|
||||
export function linkToOtherHost(
|
||||
host: string,
|
||||
path?: string,
|
||||
externalServerUrl: string = serverURL,
|
||||
externalBaseURI: string = baseURI,
|
||||
) {
|
||||
const destination = new URL(externalServerUrl);
|
||||
destination.hostname = host;
|
||||
if (path) {
|
||||
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${path}` : path;
|
||||
}
|
||||
return destination.toString();
|
||||
}
|
||||
|
||||
@@ -75,4 +75,4 @@ function resolveBaseURI(): string {
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.qrCode {
|
||||
padding: 0.5rem;
|
||||
background: $ui-white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
|
||||
import { serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
interface GenerateLinkFormOptions {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
lock: boolean;
|
||||
authenticate: boolean;
|
||||
}
|
||||
|
||||
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm() {
|
||||
const { data: infoData } = useInfo();
|
||||
const { data: urlPresetData } = useUrlPresets();
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (options: GenerateLinkFormOptions) => {
|
||||
try {
|
||||
setFormState('loading');
|
||||
const baseUrl = linkToOtherHost(options.baseUrl);
|
||||
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
setFormState('success');
|
||||
setTimeout(() => {
|
||||
setFormState('pending');
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
setFormState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Info>
|
||||
<Panel.Paragraph>
|
||||
You can generate a link to share with your team or to use in automation (such as companion).
|
||||
</Panel.Paragraph>
|
||||
</Info>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Host IP' description='Which IP address will be used' />
|
||||
<Select variant='ontime' size='sm' {...register('baseUrl')}>
|
||||
{infoData.networkInterfaces.map((nif) => {
|
||||
return (
|
||||
<option key={nif.name} value={nif.address}>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='URL Preset'
|
||||
description='Which preset will the link point to (will default to /timer if none is given)'
|
||||
/>
|
||||
<Select variant='ontime' size='sm' {...register('path')}>
|
||||
<option key='timer' value='timer'>
|
||||
Timer
|
||||
</option>
|
||||
<option key='companion' value=''>
|
||||
Companion
|
||||
</option>
|
||||
{urlPresetData.map((preset) => {
|
||||
return (
|
||||
<option key={preset.alias} value={preset.alias}>
|
||||
{`Preset: ${preset.alias}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Lock navigation'
|
||||
description='Prevent showing navigation (will only work for non production URLs)'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' {...register('lock')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch variant='ontime' size='lg' {...register('authenticate')} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
isLoading={formState === 'loading'}
|
||||
type='submit'
|
||||
style={{ alignSelf: 'end' }}
|
||||
>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<ExternalLink href={url}>{url}</ExternalLink>
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -8,21 +8,34 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
|
||||
import GenerateLinkForm from './GenerateLinkForm';
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
import LogExport from './NetworkLogExport';
|
||||
|
||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
<Panel.Section>
|
||||
{isDockerImage && <OntimeCloudStats />}
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
{isDockerImage && (
|
||||
<Panel.Section>
|
||||
<OntimeCloudStats />
|
||||
</Panel.Section>
|
||||
)}
|
||||
<div ref={linkRef}>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
<GenerateLinkForm />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</div>
|
||||
<div ref={logRef}>
|
||||
<LogExport />
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,10 @@ const staticOptions = [
|
||||
label: 'Network',
|
||||
split: true,
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__link',
|
||||
label: 'Share link',
|
||||
},
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Event log',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { ErrorResponse, GetInfo, SessionStats } from 'ontime-types';
|
||||
import { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -24,3 +24,18 @@ export async function getInfo(_req: Request, res: Response<GetInfo | ErrorRespon
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateUrl(req: Request, res: Response<GetUrl | ErrorResponse>) {
|
||||
try {
|
||||
const url = sessionService.generateAuthenticatedUrl(
|
||||
req.body.baseUrl,
|
||||
req.body.path,
|
||||
req.body.lock,
|
||||
req.body.authenticate,
|
||||
);
|
||||
res.status(200).send({ url: url.toString() });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import express from 'express';
|
||||
|
||||
import { getInfo, getSessionStats } from './session.controller.js';
|
||||
import { getInfo, getSessionStats, generateUrl } from './session.controller.js';
|
||||
import { validateGenerateUrl } from './session.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', getSessionStats);
|
||||
router.get('/info', getInfo);
|
||||
router.post('/url', validateGenerateUrl, generateUrl);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getLastLoadedProject } from '../../services/app-state-service/AppStateS
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||
import { getTimezoneLabel } from '../../utils/time.js';
|
||||
import { password } from '../../externals.js';
|
||||
import { hashPassword } from '../../utils/hash.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
@@ -46,3 +48,20 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
publicDir: publicDir.root,
|
||||
};
|
||||
}
|
||||
|
||||
export const hasPassword = Boolean(password);
|
||||
export const hashedPassword = hasPassword ? hashPassword(password as string) : undefined;
|
||||
|
||||
/**
|
||||
* Generates a pre-authenticated URL by injecting a token in the URL params
|
||||
*/
|
||||
export function generateAuthenticatedUrl(baseUrl: string, path: string, lock: boolean, authenticate: boolean): URL {
|
||||
const url = new URL(path, baseUrl);
|
||||
if (authenticate && hashedPassword) {
|
||||
url.searchParams.append('token', hashedPassword);
|
||||
}
|
||||
if (lock) {
|
||||
url.searchParams.append('locked', 'true');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { body, validationResult } from 'express-validator';
|
||||
|
||||
export const validateGenerateUrl = [
|
||||
body('baseUrl').exists().isString().notEmpty().trim(),
|
||||
body('path').exists().isString().trim(),
|
||||
body('lock').exists().isBoolean(),
|
||||
body('authenticate').exists().isBoolean(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
@@ -8,11 +8,9 @@ import { parse as parseCookie } from 'cookie';
|
||||
import { hashPassword } from '../utils/hash.js';
|
||||
import { srcFiles } from '../setup/index.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { password } from '../externals.js';
|
||||
import { noopMiddleware } from './noop.js';
|
||||
import { hashedPassword, hasPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
export const hasPassword = Boolean(password);
|
||||
const hashedPassword = hasPassword ? hashPassword(password) : '';
|
||||
import { noopMiddleware } from './noop.js';
|
||||
|
||||
/**
|
||||
* List of public assets that can be accessed without authentication
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface GetInfo {
|
||||
publicDir: string;
|
||||
}
|
||||
|
||||
export interface GetUrl {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type ProjectFile = {
|
||||
filename: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -57,6 +57,7 @@ export type {
|
||||
AuthenticationStatus,
|
||||
NetworkInterface,
|
||||
GetInfo,
|
||||
GetUrl,
|
||||
ProjectFileList,
|
||||
ProjectFile,
|
||||
ErrorResponse,
|
||||
|
||||
Reference in New Issue
Block a user