mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +00:00
feat: UI access for custom views (#2020)
* style: consistent casing on menu * refactor: bundle demo from code * feat: allow uploading custom views
This commit is contained in:
@@ -4,7 +4,7 @@ import { type ErrorResponse, RefetchKey } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||
import { defaultCss } from '../../bundle/bundledCss.js';
|
||||
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
|
||||
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { TranslationObject } from 'ontime-types';
|
||||
|
||||
import { defaultCss } from '../../bundle/bundledCss.js';
|
||||
import { publicFiles } from '../../setup/index.js';
|
||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||
|
||||
/**
|
||||
* Reads the user's css file
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CustomViewError } from '../customViews.errors.js';
|
||||
import { isValidCustomViewSlug, resolveCustomViewDirectory, validateHtmlContent } from '../customViews.service.js';
|
||||
|
||||
describe('isValidCustomViewSlug()', () => {
|
||||
it('accepts valid slugs', () => {
|
||||
expect(isValidCustomViewSlug('a')).toBe(true);
|
||||
expect(isValidCustomViewSlug('my-view-1')).toBe(true);
|
||||
expect(isValidCustomViewSlug('example123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty or too long', () => {
|
||||
expect(isValidCustomViewSlug('')).toBe(false);
|
||||
expect(isValidCustomViewSlug('a'.repeat(64))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid characters', () => {
|
||||
expect(isValidCustomViewSlug('Hello')).toBe(false);
|
||||
expect(isValidCustomViewSlug('my_view')).toBe(false);
|
||||
expect(isValidCustomViewSlug('my view')).toBe(false);
|
||||
expect(isValidCustomViewSlug('../escape')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects leading or trailing hyphens', () => {
|
||||
expect(isValidCustomViewSlug('-start')).toBe(false);
|
||||
expect(isValidCustomViewSlug('end-')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCustomViewDirectory()', () => {
|
||||
it('throws CustomViewError on invalid slugs', () => {
|
||||
expect(() => resolveCustomViewDirectory('Hello-World')).toThrow(CustomViewError);
|
||||
expect(() => resolveCustomViewDirectory('../escape')).toThrow(CustomViewError);
|
||||
expect(() => resolveCustomViewDirectory('')).toThrow(CustomViewError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateHtmlContent()', () => {
|
||||
it('accepts valid HTML with doctype', () => {
|
||||
expect(() => validateHtmlContent('<!DOCTYPE html><html><body>hello</body></html>')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid HTML starting with html tag', () => {
|
||||
expect(() => validateHtmlContent('<html><body>hello</body></html>')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts HTML with inline script and style', () => {
|
||||
const html = '<!DOCTYPE html><html><head><style>body{}</style></head><body><script>alert(1)</script></body></html>';
|
||||
expect(() => validateHtmlContent(html)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects content that is not HTML', () => {
|
||||
expect(() => validateHtmlContent('just some text')).toThrow(CustomViewError);
|
||||
expect(() => validateHtmlContent('{"json": true}')).toThrow(CustomViewError);
|
||||
});
|
||||
|
||||
it('rejects external script imports', () => {
|
||||
const html = '<!DOCTYPE html><html><body><script src="https://cdn.example.com/app.js"></script></body></html>';
|
||||
expect(() => validateHtmlContent(html)).toThrow('External scripts are not allowed');
|
||||
});
|
||||
|
||||
it('rejects external stylesheets', () => {
|
||||
const html = '<!DOCTYPE html><html><head><link rel="stylesheet" href="styles.css"></head><body></body></html>';
|
||||
expect(() => validateHtmlContent(html)).toThrow('External stylesheets are not allowed');
|
||||
});
|
||||
|
||||
it('rejects iframes', () => {
|
||||
const html = '<!DOCTYPE html><html><body><iframe src="https://example.com"></iframe></body></html>';
|
||||
expect(() => validateHtmlContent(html)).toThrow('Iframes are not allowed');
|
||||
});
|
||||
|
||||
it('allows link tags that are not stylesheets', () => {
|
||||
const html = '<!DOCTYPE html><html><head><link rel="icon" href="data:,"></head><body></body></html>';
|
||||
expect(() => validateHtmlContent(html)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Response } from 'express';
|
||||
import type { ErrorResponse } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
export class CustomViewError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CustomViewError';
|
||||
}
|
||||
}
|
||||
|
||||
export function handleCustomViewsError(error: unknown, res: Response<ErrorResponse>) {
|
||||
if (error instanceof CustomViewError) {
|
||||
res.status(error.statusCode).send({ message: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send({ message: getErrorMessage(error) });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import type { ErrorResponse } from 'ontime-types';
|
||||
|
||||
import { customViewMaxFileSize } from './customViews.service.js';
|
||||
|
||||
const allowedMimeTypes = new Set(['text/html', 'application/xhtml+xml', 'application/octet-stream']);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: customViewMaxFileSize,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (allowedMimeTypes.has(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Unsupported file type "${file.mimetype}"`));
|
||||
}
|
||||
},
|
||||
}).single('indexHtml');
|
||||
|
||||
export function uploadCustomViewFile(req: Request, res: Response<ErrorResponse>, next: NextFunction) {
|
||||
upload(req, res, (error: unknown) => {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
res.status(413).send({ message: `File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
res.status(400).send({ message: 'Unexpected upload field. Use "indexHtml"' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
res.status(400).send({ message: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).send({ message: 'Could not process upload request' });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { type CustomViewsListResponse, type ErrorResponse, type MessageResponse } from 'ontime-types';
|
||||
|
||||
import { handleCustomViewsError } from './customViews.errors.js';
|
||||
import { uploadCustomViewFile } from './customViews.middleware.js';
|
||||
import {
|
||||
deleteCustomView,
|
||||
getCustomViewDownloadPath,
|
||||
listCustomViews,
|
||||
restoreDemoView,
|
||||
uploadCustomView,
|
||||
} from './customViews.service.js';
|
||||
import { validateCustomViewSlugParam } from './customViews.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', async (_req: Request, res: Response<CustomViewsListResponse | ErrorResponse>) => {
|
||||
try {
|
||||
const views = await listCustomViews();
|
||||
res.status(200).send({ views });
|
||||
} catch (error) {
|
||||
handleCustomViewsError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/restore-demo', async (_req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
||||
try {
|
||||
const view = await restoreDemoView();
|
||||
res.status(201).send({ message: `Restored demo view "${view.slug}"` });
|
||||
} catch (error) {
|
||||
handleCustomViewsError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/:slug/upload',
|
||||
validateCustomViewSlugParam,
|
||||
uploadCustomViewFile,
|
||||
async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
||||
try {
|
||||
const view = await uploadCustomView(req.params.slug, req.file);
|
||||
res.status(201).send({ message: `Uploaded custom view "${view.slug}"` });
|
||||
} catch (error) {
|
||||
handleCustomViewsError(error, res);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/:slug/download', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
|
||||
try {
|
||||
const pathToFile = await getCustomViewDownloadPath(req.params.slug);
|
||||
const fileName = `${req.params.slug}-index.html`;
|
||||
|
||||
res.download(pathToFile, fileName, (error: Error | null) => {
|
||||
if (error && !res.headersSent) {
|
||||
res.status(500).send({ message: 'Could not download custom view' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
handleCustomViewsError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:slug', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
|
||||
try {
|
||||
await deleteCustomView(req.params.slug);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleCustomViewsError(error, res);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { type CustomViewSummary } from 'ontime-types';
|
||||
|
||||
import { defaultDemoHtml } from '../../bundle/bundledDemoHtml.js';
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
import {
|
||||
createDirectory,
|
||||
deleteDirectory,
|
||||
ensureDirectory,
|
||||
fileIsReadable,
|
||||
isNodeError,
|
||||
readDirectoryEntries,
|
||||
replaceDirectory,
|
||||
statIfExists,
|
||||
writeToFile,
|
||||
} from '../../utils/fileManagement.js';
|
||||
import { CustomViewError } from './customViews.errors.js';
|
||||
|
||||
/**
|
||||
* Patterns that indicate external resource loading.
|
||||
* Custom views must be self-contained single HTML files with only inline CSS and JavaScript.
|
||||
*/
|
||||
const forbiddenHtmlPatterns: { pattern: RegExp; message: string }[] = [
|
||||
{ pattern: /<script[^>]+src\s*=/i, message: 'External scripts are not allowed. Use inline <script> instead.' },
|
||||
{
|
||||
pattern: /<link[^>]+rel\s*=\s*["']?stylesheet["']?/i,
|
||||
message: 'External stylesheets are not allowed. Use inline <style> instead.',
|
||||
},
|
||||
{ pattern: /<iframe[\s>]/i, message: 'Iframes are not allowed.' },
|
||||
];
|
||||
|
||||
export function validateHtmlContent(content: string): void {
|
||||
const htmlDoctype = /^\s*<!doctype\s+html[\s>]/i;
|
||||
const htmlTag = /^\s*<html[\s>]/i;
|
||||
if (!htmlDoctype.test(content) && !htmlTag.test(content)) {
|
||||
throw new CustomViewError(
|
||||
'File does not appear to be valid HTML. Expected <!DOCTYPE html> or <html> at the start.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { pattern, message } of forbiddenHtmlPatterns) {
|
||||
if (pattern.test(content)) {
|
||||
throw new CustomViewError(message, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowedSlugChars = /^[a-z0-9-]+$/;
|
||||
export function isValidCustomViewSlug(slug: string): boolean {
|
||||
if (typeof slug !== 'string') return false;
|
||||
if (slug.length < 1 || slug.length > 63) return false;
|
||||
if (!allowedSlugChars.test(slug)) return false;
|
||||
if (slug.startsWith('-') || slug.endsWith('-')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveCustomViewDirectory(slug: string): string {
|
||||
if (!isValidCustomViewSlug(slug)) {
|
||||
throw new CustomViewError('Invalid name. Use lowercase letters, numbers, and dashes only.', 400);
|
||||
}
|
||||
|
||||
return resolve(publicDir.externalDir, slug);
|
||||
}
|
||||
|
||||
export function getPathToCustomView(slug: string): string {
|
||||
return join(resolveCustomViewDirectory(slug), customViewIndexFilename);
|
||||
}
|
||||
|
||||
export interface CustomViewUploadFile {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export const customViewMaxFileSize = 4_000_000; // 4MB
|
||||
export const customViewIndexFilename = 'index.html';
|
||||
export function validateCustomViewUpload(file: CustomViewUploadFile | undefined): CustomViewUploadFile {
|
||||
if (!file) {
|
||||
throw new CustomViewError('File not found', 422);
|
||||
}
|
||||
|
||||
const fileName = file.originalname.trim().toLowerCase();
|
||||
if (fileName !== customViewIndexFilename) {
|
||||
throw new CustomViewError('Only index.html uploads are supported', 400);
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
throw new CustomViewError('Uploaded file is empty', 400);
|
||||
}
|
||||
|
||||
if (file.size > customViewMaxFileSize) {
|
||||
throw new CustomViewError(`File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded`, 413);
|
||||
}
|
||||
|
||||
const content = file.buffer.toString('utf-8');
|
||||
validateHtmlContent(content);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function listCustomViews(): Promise<CustomViewSummary[]> {
|
||||
ensureDirectory(publicDir.externalDir);
|
||||
|
||||
const entries = await readDirectoryEntries(publicDir.externalDir);
|
||||
const views: CustomViewSummary[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || !isValidCustomViewSlug(entry.name)) continue;
|
||||
|
||||
const indexStats = await statIfExists(getPathToCustomView(entry.name));
|
||||
if (!indexStats?.isFile()) continue;
|
||||
|
||||
views.push({ slug: entry.name });
|
||||
}
|
||||
|
||||
return views.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
export async function uploadCustomView(
|
||||
slug: string,
|
||||
file: CustomViewUploadFile | undefined,
|
||||
): Promise<CustomViewSummary> {
|
||||
const uploadFile = validateCustomViewUpload(file);
|
||||
ensureDirectory(publicDir.externalDir);
|
||||
|
||||
const viewDirectory = resolveCustomViewDirectory(slug);
|
||||
const indexFile = getPathToCustomView(slug);
|
||||
|
||||
try {
|
||||
await createDirectory(viewDirectory);
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'EEXIST') {
|
||||
throw new CustomViewError(`Name "${slug}" already exists`, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeToFile(indexFile, uploadFile.buffer);
|
||||
return { slug };
|
||||
} catch (error) {
|
||||
await deleteDirectory(viewDirectory);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomViewDownloadPath(slug: string): Promise<string> {
|
||||
const indexFile = getPathToCustomView(slug);
|
||||
|
||||
if (!(await fileIsReadable(indexFile))) {
|
||||
throw new CustomViewError(`Custom view "${slug}" not found`, 404);
|
||||
}
|
||||
|
||||
return indexFile;
|
||||
}
|
||||
|
||||
const demoViewSlug = 'demo';
|
||||
export async function restoreDemoView(): Promise<CustomViewSummary> {
|
||||
ensureDirectory(publicDir.externalDir);
|
||||
|
||||
const viewDirectory = resolveCustomViewDirectory(demoViewSlug);
|
||||
const indexFile = getPathToCustomView(demoViewSlug);
|
||||
|
||||
await replaceDirectory(viewDirectory);
|
||||
await writeToFile(indexFile, defaultDemoHtml, { encoding: 'utf-8' });
|
||||
|
||||
return { slug: demoViewSlug };
|
||||
}
|
||||
|
||||
export async function deleteCustomView(slug: string): Promise<void> {
|
||||
await deleteDirectory(resolveCustomViewDirectory(slug));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { param } from 'express-validator';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { isValidCustomViewSlug } from './customViews.service.js';
|
||||
|
||||
export const validateCustomViewSlugParam = [
|
||||
param('slug')
|
||||
.isString()
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.customSanitizer((value: string) => value.toLowerCase())
|
||||
.custom((value: string) => isValidCustomViewSlug(value))
|
||||
.withMessage('Invalid name. Use lowercase letters, numbers, and dashes only.'),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
@@ -3,6 +3,7 @@ import express from 'express';
|
||||
import { router as assetsRouter } from './assets/assets.router.js';
|
||||
import { router as automationsRouter } from './automation/automation.router.js';
|
||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||
import { router as customViewsRouter } from './custom-views/customViews.router.js';
|
||||
import { router as dbRouter } from './db/db.router.js';
|
||||
import { router as excelRouter } from './excel/excel.router.js';
|
||||
import { router as projectRouter } from './project-data/projectData.router.js';
|
||||
@@ -18,6 +19,7 @@ export const appRouter = express.Router();
|
||||
|
||||
appRouter.use('/automations', automationsRouter);
|
||||
appRouter.use('/custom-fields', customFieldsRouter);
|
||||
appRouter.use('/custom-views', customViewsRouter);
|
||||
appRouter.use('/db', dbRouter);
|
||||
appRouter.use('/project', projectRouter);
|
||||
appRouter.use('/rundowns', rundownsRouter);
|
||||
|
||||
Reference in New Issue
Block a user