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:
Carlos Valente
2025-01-26 16:51:28 +01:00
committed by GitHub
parent d34280c03d
commit 0f57689750
19 changed files with 305 additions and 44 deletions
+13
View File
@@ -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');
});
});
+17 -8
View File
@@ -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();
}
+1 -1
View File
@@ -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();
},
];
+2 -4
View File
@@ -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;
+1
View File
@@ -57,6 +57,7 @@ export type {
AuthenticationStatus,
NetworkInterface,
GetInfo,
GetUrl,
ProjectFileList,
ProjectFile,
ErrorResponse,