mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 19:33:46 +00:00
feat: cuesheet sharing
feat: locked presets refactor: simplify locked param refactor: create share links
This commit is contained in:
committed by
Carlos Valente
parent
1695b4dc68
commit
6c6f5c2c0f
@@ -104,13 +104,14 @@ type old_URLPreset = {
|
||||
/**
|
||||
* migrates a url presets from v3 to v4
|
||||
* - pathAndParams split into a target and search
|
||||
*
|
||||
*/
|
||||
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
|
||||
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
|
||||
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
|
||||
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
|
||||
const [target, search] = pathAndParams.split('?');
|
||||
return { enabled, alias, target, search };
|
||||
return { enabled, alias, target, search, options: {} } as URLPreset;
|
||||
});
|
||||
return newURLPreset;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AutomationSettings,
|
||||
CustomFields,
|
||||
EndAction,
|
||||
OntimeView,
|
||||
ProjectData,
|
||||
Rundown,
|
||||
Settings,
|
||||
@@ -202,16 +203,18 @@ describe('v3 to v4', () => {
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'clock',
|
||||
target: 'timer',
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'minimal',
|
||||
target: 'timer',
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
const newUrlPreset = v3.migrateURLPresets(oldDb);
|
||||
@@ -249,10 +252,10 @@ describe('v3 to v4', () => {
|
||||
colour: '#E80000',
|
||||
},
|
||||
};
|
||||
const { customFields, translationTable } = v3.migrateCustomFields(oldDb)!;
|
||||
expect(customFields).toEqual(expectCustomFields);
|
||||
|
||||
expect(translationTable).toEqual(
|
||||
const parsedData = v3.migrateCustomFields(oldDb);
|
||||
expect(parsedData).not.toBeUndefined();
|
||||
expect(parsedData?.customFields).toEqual(expectCustomFields);
|
||||
expect(parsedData?.translationTable).toEqual(
|
||||
new Map([
|
||||
['song', 'Song_and_Dance'],
|
||||
['artist', 'Artist_and_Host'],
|
||||
|
||||
@@ -1,50 +1,150 @@
|
||||
import { generateAuthenticatedUrl } from '../session.service.js';
|
||||
import { generateShareUrl } from '../session.service.js';
|
||||
|
||||
describe('generateAuthenticatedUrl()', () => {
|
||||
describe('for local IP addresses', () => {
|
||||
it('generates a link without locking or authentication', () => {
|
||||
const localhostNotLocked = generateAuthenticatedUrl('http://localhost:3000', 'timer', false, false);
|
||||
const localhostNotLocked = generateShareUrl('http://localhost:3000', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
});
|
||||
expect(localhostNotLocked.toString()).toBe('http://localhost:3000/timer');
|
||||
});
|
||||
|
||||
it('generates a link with IP locking enabled', () => {
|
||||
const ipLocked = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, false);
|
||||
expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?locked=true');
|
||||
it('generates a link with navigation locking enabled', () => {
|
||||
const ipLocked = generateShareUrl('http://192.168.10.173:4001', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: true,
|
||||
authenticate: false,
|
||||
});
|
||||
expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?n=1');
|
||||
});
|
||||
|
||||
it('generates a link with authentication token and IP locking', () => {
|
||||
const withAuth = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, true, undefined, '1234');
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&locked=true');
|
||||
it('generates a link with authentication token and navigation locking', () => {
|
||||
const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: true,
|
||||
authenticate: true,
|
||||
hash: '1234',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&n=1');
|
||||
});
|
||||
|
||||
it('generates a link to an unlocked preset', () => {
|
||||
const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
preset: 'minimal',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/minimal');
|
||||
});
|
||||
|
||||
it('generates a link to an unlocked preset without navigation', () => {
|
||||
const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: true,
|
||||
authenticate: false,
|
||||
preset: 'minimal',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/minimal?n=1');
|
||||
});
|
||||
|
||||
it('generates a link to a locked preset', () => {
|
||||
const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
|
||||
lockConfig: true,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
preset: 'minimal',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/minimal');
|
||||
});
|
||||
|
||||
it('generates a link to a locked preset', () => {
|
||||
const withAuth = generateShareUrl('http://192.168.10.173:4001', 'cuesheet', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
preset: 'some-cuesheet-preset',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for ontime-cloud URLs', () => {
|
||||
it('generates a link without locking or authentication', () => {
|
||||
const cloudNotLocked = generateAuthenticatedUrl(
|
||||
'https://cloud.getontime.no/userhash',
|
||||
'timer',
|
||||
false,
|
||||
false,
|
||||
'prefix',
|
||||
);
|
||||
const cloudNotLocked = generateShareUrl('https://cloud.getontime.no/userhash', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
prefix: 'prefix',
|
||||
});
|
||||
expect(cloudNotLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer');
|
||||
});
|
||||
|
||||
it('generates a link with IP locking enabled', () => {
|
||||
const ipLocked = generateAuthenticatedUrl('https://cloud.getontime.no/prefix', 'timer', true, false, 'prefix');
|
||||
expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?locked=true');
|
||||
it('generates a link with navigation locking enabled', () => {
|
||||
const ipLocked = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: true,
|
||||
authenticate: false,
|
||||
prefix: 'prefix',
|
||||
});
|
||||
expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?n=1');
|
||||
});
|
||||
|
||||
it('generates a link with authentication token and IP locking', () => {
|
||||
const withAuth = generateAuthenticatedUrl(
|
||||
'https://cloud.getontime.no/prefix',
|
||||
'timer',
|
||||
true,
|
||||
true,
|
||||
'prefix',
|
||||
'1234',
|
||||
);
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&locked=true');
|
||||
it('generates a link with authentication token and navigation locking', () => {
|
||||
const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: true,
|
||||
authenticate: true,
|
||||
prefix: 'prefix',
|
||||
hash: '1234',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&n=1');
|
||||
});
|
||||
|
||||
it('generates a link to an unlocked preset', () => {
|
||||
const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
preset: 'minimal',
|
||||
prefix: 'prefix',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/minimal');
|
||||
});
|
||||
|
||||
it('generates a link to an unlocked preset without navigation', () => {
|
||||
const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
|
||||
lockConfig: false,
|
||||
lockNav: true,
|
||||
authenticate: false,
|
||||
preset: 'minimal',
|
||||
prefix: 'prefix',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/minimal?n=1');
|
||||
});
|
||||
|
||||
it('generates a link to a locked preset', () => {
|
||||
const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
|
||||
lockConfig: true,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
prefix: 'prefix',
|
||||
preset: 'minimal',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/preset/minimal');
|
||||
});
|
||||
|
||||
it('generates a link to a locked preset', () => {
|
||||
const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'cuesheet', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: false,
|
||||
prefix: 'prefix',
|
||||
preset: 'some-cuesheet-preset',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/preset/some-cuesheet-preset');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,12 +29,12 @@ router.get('/info', async (_req: Request, res: Response<GetInfo | ErrorResponse>
|
||||
|
||||
router.post('/url', validateGenerateUrl, (req: Request, res: Response<GetUrl | ErrorResponse>) => {
|
||||
try {
|
||||
const url = sessionService.generateAuthenticatedUrl(
|
||||
req.body.baseUrl,
|
||||
req.body.path,
|
||||
req.body.lock,
|
||||
req.body.authenticate,
|
||||
);
|
||||
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
||||
authenticate: req.body.authenticate,
|
||||
lockConfig: req.body.lockConfig,
|
||||
lockNav: req.body.lockNav,
|
||||
preset: req.body.preset,
|
||||
});
|
||||
res.status(200).send({ url: url.toString() });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetInfo, SessionStats } from 'ontime-types';
|
||||
import { GetInfo, LinkOptions, OntimeView, SessionStats } from 'ontime-types';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
@@ -57,22 +57,25 @@ export const hashedPassword = hasPassword ? hashPassword(password as string) : u
|
||||
/**
|
||||
* Generates a pre-authenticated URL by injecting a token in the URL params
|
||||
*/
|
||||
export function generateAuthenticatedUrl(
|
||||
export function generateShareUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
prefix = routerPrefix,
|
||||
hash = hashedPassword,
|
||||
canonicalPath: string,
|
||||
{ authenticate, lockConfig, lockNav, preset, prefix = routerPrefix, hash = hashedPassword }: LinkOptions,
|
||||
): URL {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = prefix ? `${prefix}/${path}` : path;
|
||||
|
||||
// if the config is locked and we are in a preset, we hide the canonical path
|
||||
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
|
||||
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
|
||||
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
|
||||
|
||||
if (authenticate && hash) {
|
||||
url.searchParams.append('token', hash);
|
||||
}
|
||||
if (lock) {
|
||||
url.searchParams.append('locked', 'true');
|
||||
|
||||
if (lockNav) {
|
||||
url.searchParams.append('n', '1');
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio
|
||||
|
||||
export const validateGenerateUrl = [
|
||||
body('baseUrl').isString().trim().notEmpty(),
|
||||
body('path').isString().trim(),
|
||||
body('lock').isBoolean(),
|
||||
body('path').isString().trim().notEmpty(),
|
||||
|
||||
body('authenticate').isBoolean(),
|
||||
body('lockConfig').isBoolean(),
|
||||
body('lockNav').isBoolean(),
|
||||
body('preset').optional().isString().trim().notEmpty(),
|
||||
body('prefix').optional().isString().trim().notEmpty(),
|
||||
body('hash').optional().isString().trim().notEmpty(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -16,16 +16,17 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
|
||||
const newPresets: URLPreset[] = [];
|
||||
|
||||
for (const preset of data.urlPresets) {
|
||||
if (!preset.alias || !preset.search || !preset.target) {
|
||||
if (!preset.alias || !preset.target) {
|
||||
emitError?.(`Invalid URL preset: ${JSON.stringify(preset)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPreset = {
|
||||
const newPreset: URLPreset = {
|
||||
enabled: preset.enabled ?? false,
|
||||
alias: preset.alias,
|
||||
target: preset.target,
|
||||
search: preset.search,
|
||||
search: preset.search ?? '',
|
||||
options: preset?.options,
|
||||
};
|
||||
newPresets.push(newPreset);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
options: req.body.options,
|
||||
};
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
@@ -50,7 +51,7 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
|
||||
};
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error(`Changing alias is not permitted`);
|
||||
throw new Error('Changing alias is not permitted');
|
||||
}
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
|
||||
@@ -14,6 +14,10 @@ export const validateNewPreset = [
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -25,6 +29,10 @@ export const validateUpdatePreset = [
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user