diff --git a/.gitignore b/.gitignore index deb50432a..b3e2b74b5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ dist/ # bundled assets translations.json override.css +**/external/demo/index.html # working stuff **/TODO.md diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 00d9e6536..e20042509 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -7,6 +7,7 @@ export const APP_SERVER_PORT = ['appServerPort']; export const APP_VERSION = ['appVersion']; export const AUTOMATION = ['automation']; export const CUSTOM_FIELDS = ['customFields']; +export const CUSTOM_VIEWS = ['customViews']; export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; export const PROJECT_RUNDOWNS = ['projectRundowns']; diff --git a/apps/client/src/common/api/customViews.ts b/apps/client/src/common/api/customViews.ts new file mode 100644 index 000000000..5a917263b --- /dev/null +++ b/apps/client/src/common/api/customViews.ts @@ -0,0 +1,59 @@ +import axios from 'axios'; +import { type CustomViewsListResponse, type MessageResponse } from 'ontime-types'; + +import { apiEntryUrl } from './constants'; +import type { RequestOptions } from './requestOptions'; +import { axiosConfig } from './requestTimeouts'; +import { downloadBlob } from './utils'; + +const customViewsPath = `${apiEntryUrl}/custom-views`; + +export async function getCustomViews(options?: RequestOptions): Promise { + const response = + await axios.get(customViewsPath, { + signal: options?.signal, + timeout: options?.timeout ?? axiosConfig.shortTimeout, + }) + return response.data; +} + +export async function uploadCustomView(slug: string, file: File, options?: RequestOptions): Promise { + const formData = new FormData(); + formData.append('indexHtml', file); + + return ( + await axios.post(`${customViewsPath}/${encodeURIComponent(slug)}/upload`, formData, { + signal: options?.signal, + timeout: options?.timeout ?? axiosConfig.longTimeout, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + ).data; +} + +export async function downloadCustomView(slug: string, options?: RequestOptions): Promise { + const response = await axios.get(`${customViewsPath}/${encodeURIComponent(slug)}/download`, { + signal: options?.signal, + timeout: options?.timeout ?? axiosConfig.longTimeout, + responseType: 'blob', + }); + + downloadBlob(response.data, `${slug}-index.html`); +} + +export async function restoreDemoView(options?: RequestOptions): Promise { + return ( + await axios.post(`${customViewsPath}/restore-demo`, null, { + signal: options?.signal, + timeout: options?.timeout ?? axiosConfig.longTimeout, + }) + ).data; +} + +export async function deleteCustomView(slug: string, options?: RequestOptions): Promise { + await axios.delete(`${customViewsPath}/${encodeURIComponent(slug)}`, { + signal: options?.signal, + timeout: options?.timeout ?? axiosConfig.longTimeout, + }); +} diff --git a/apps/client/src/common/hooks-query/useCustomViews.ts b/apps/client/src/common/hooks-query/useCustomViews.ts new file mode 100644 index 000000000..799c54bcb --- /dev/null +++ b/apps/client/src/common/hooks-query/useCustomViews.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { type CustomViewsListResponse } from 'ontime-types'; + +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { CUSTOM_VIEWS } from '../api/constants'; +import { getCustomViews } from '../api/customViews'; + +const placeholderCustomViews: CustomViewsListResponse = { + views: [], +}; + +export default function useCustomViews() { + const { data, status, refetch } = useQuery({ + queryKey: CUSTOM_VIEWS, + queryFn: ({ signal }) => getCustomViews({ signal }), + placeholderData: (previousData, _previousQuery) => previousData, + refetchInterval: queryRefetchIntervalSlow, + }); + + return { data: data ?? placeholderCustomViews, status, refetch }; +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx new file mode 100644 index 000000000..015fc4dab --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx @@ -0,0 +1,114 @@ +import { ChangeEvent, FormEvent, useMemo, useRef, useState } from 'react'; +import { IoCloudUploadOutline } from 'react-icons/io5'; + +import { uploadCustomView } from '../../../../common/api/customViews'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Button from '../../../../common/components/buttons/Button'; +import Input from '../../../../common/components/input/input/Input'; +import * as Panel from '../../panel-utils/PanelUtils'; +import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils'; + +import style from './CustomViews.module.scss'; + +interface CustomViewFormProps { + onComplete: () => void; + onClose: () => void; +} + +export default function CustomViewForm({ onComplete, onClose }: CustomViewFormProps) { + const [slug, setSlug] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [slugDirty, setSlugDirty] = useState(false); + const [fileDirty, setFileDirty] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const normalisedSlug = useMemo(() => slug.trim().toLowerCase(), [slug]); + const previewUrl = getViewUrl(normalisedSlug); + const slugError = useMemo(() => getSlugError(normalisedSlug), [normalisedSlug]); + const fileError = useMemo(() => getFileError(selectedFile), [selectedFile]); + const canUpload = Boolean(normalisedSlug && selectedFile) && !slugError && !fileError && !isUploading; + + const handleSelectFile = (event: ChangeEvent) => { + setSelectedFile(event.target.files?.[0] ?? null); + setFileDirty(true); + setError(null); + }; + + const handleUpload = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedFile || slugError || fileError) return; + + try { + setIsUploading(true); + setError(null); + await uploadCustomView(normalisedSlug, selectedFile); + onComplete(); + } catch (err) { + setError(maybeAxiosError(err)); + } finally { + setIsUploading(false); + } + }; + + return ( + + + +
+
1. Choose a name
+ Name + { + setSlug(event.target.value); + setSlugDirty(true); + }} + placeholder='my-view' + aria-label='Custom view name' + autoCapitalize='off' + autoComplete='off' + fluid + /> + + Use lowercase letters, numbers, and dashes. Example: my-view + + + Preview URL: {previewUrl} + + {slugDirty && slugError && {slugError}} +
+ +
+
2. Select index.html
+ Upload file + + + + {selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'} + + + Accepted: index.html only, maximum {maxUploadLabel}. + {fileDirty && fileError && {fileError}} +
+ + {error && {error}} + + + + + +
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.module.scss new file mode 100644 index 000000000..85682ac9a --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.module.scss @@ -0,0 +1,51 @@ +.uploadForm { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.step { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stepTitle { + font-weight: 600; + color: $gray-200; +} + +.filePicker { + min-height: 2rem; +} + +.fileName { + color: $gray-300; + font-size: calc(1rem - 2px); +} + +.missing { + color: $warning-orange; +} + +.urlCell { + font-size: calc(1rem - 2px); + color: $gray-300; + user-select: all; + white-space: nowrap; +} + +.actionsHeader { + text-align: right; +} + +.actionsCell { + width: 1%; + white-space: nowrap; + text-align: right; +} + +.actionsGroup { + width: 100%; + justify-content: flex-end; +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx new file mode 100644 index 000000000..5412e2d10 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { IoAdd } from 'react-icons/io5'; + +import { restoreDemoView } from '../../../../common/api/customViews'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Button from '../../../../common/components/buttons/Button'; +import Info from '../../../../common/components/info/Info'; +import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; +import useCustomViews from '../../../../common/hooks-query/useCustomViews'; +import * as Panel from '../../panel-utils/PanelUtils'; +import CustomViewForm from './CustomViewForm'; +import CustomViewsList from './CustomViewsList'; +import { customViewsDocs } from './customViews.utils'; + +export default function CustomViews() { + const { data, refetch, status } = useCustomViews(); + const [isUploadOpen, setIsUploadOpen] = useState(false); + const [actionError, setActionError] = useState(null); + + const hasDemoView = data.views.some((view) => view.slug === 'demo'); + + const handleUploadComplete = async () => { + setIsUploadOpen(false); + await refetch(); + }; + + const handleRestoreDemo = async () => { + try { + setActionError(null); + await restoreDemoView(); + await refetch(); + } catch (error) { + setActionError(maybeAxiosError(error)); + } + }; + + return ( + + + + Custom views + + + + + + + + + + Upload one index.html per view to /external/<name>/. +
+ External imports are not allowed, include all assets inside the html file. + See the docs +
+
+ + + + + {isUploadOpen && setIsUploadOpen(false)} />} + + {actionError && {actionError}} + + setIsUploadOpen(true)} + onMutate={() => refetch()} + onError={setActionError} + /> + + +
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViewsList.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewsList.tsx new file mode 100644 index 000000000..c43ec6dac --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewsList.tsx @@ -0,0 +1,39 @@ +import { CustomViewSummary } from 'ontime-types'; + +import * as Panel from '../../panel-utils/PanelUtils'; +import CustomViewsListItem from './CustomViewsListItem'; + +import style from './CustomViews.module.scss'; + +interface CustomViewsListProps { + views: CustomViewSummary[]; + onOpenUpload: () => void; + onMutate: () => void; + onError: (message: string) => void; +} + +export default function CustomViewsList({ views, onOpenUpload, onMutate, onError }: CustomViewsListProps) { + return ( + + + + Name + URL + + + + + {views.length === 0 && } + {views.map((view, index) => ( + + ))} + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViewsListItem.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewsListItem.tsx new file mode 100644 index 000000000..0cd8671a9 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewsListItem.tsx @@ -0,0 +1,75 @@ +import { IoDownloadOutline, IoOpenOutline, IoTrash } from 'react-icons/io5'; + +import { deleteCustomView, downloadCustomView } from '../../../../common/api/customViews'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import IconButton from '../../../../common/components/buttons/IconButton'; +import { handleLinks } from '../../../../common/utils/linkUtils'; +import * as Panel from '../../panel-utils/PanelUtils'; +import { getViewUrl } from './customViews.utils'; + +import style from './CustomViews.module.scss'; + +interface CustomViewsListItemProps { + slug: string; + index: number; + onMutate: () => void; + onError: (message: string) => void; +} + +export default function CustomViewsListItem({ slug, index, onMutate, onError }: CustomViewsListItemProps) { + const handlePreview = () => { + handleLinks(`external/${encodeURIComponent(slug)}/`); + }; + + const handleDownload = async () => { + try { + await downloadCustomView(slug); + } catch (error) { + onError(maybeAxiosError(error)); + } + }; + + const handleDelete = async () => { + try { + await deleteCustomView(slug); + onMutate(); + } catch (error) { + onError(maybeAxiosError(error)); + } + }; + + return ( + + {slug} + {getViewUrl(slug)} + + + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/customViews.utils.ts b/apps/client/src/features/app-settings/panel/manage-panel/customViews.utils.ts new file mode 100644 index 000000000..4f5915692 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/customViews.utils.ts @@ -0,0 +1,39 @@ +import { baseURI, serverURL } from '../../../../externals'; + +export const customViewsDocs = 'https://docs.getontime.no/features/custom-views/'; +const customViewSlugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const maxUploadBytes = 4_000_000; +export const maxUploadLabel = `${maxUploadBytes / 1_000_000}MB`; + +export function getSlugError(slug: string): string | null { + if (!slug) { + return 'Name is required.'; + } + if (!customViewSlugPattern.test(slug)) { + return 'Use lowercase letters, numbers, and dashes only.'; + } + return null; +} + +export function getFileError(file: File | null): string | null { + if (!file) { + return 'index.html is required.'; + } + if (file.name.toLowerCase() !== 'index.html') { + return 'Only index.html uploads are supported.'; + } + if (file.size === 0) { + return 'Uploaded file is empty.'; + } + if (file.size > maxUploadBytes) { + return `File size limit (${maxUploadLabel}) exceeded.`; + } + return null; +} + +export function getViewUrl(slug: string): string { + const url = new URL(serverURL); + const path = slug ? `external/${encodeURIComponent(slug)}/` : 'external/'; + url.pathname = baseURI ? `${baseURI}/${path}` : `/${path}`; + return url.toString(); +} diff --git a/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx b/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx index f96493400..ccbf86ffa 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx @@ -2,6 +2,7 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import { isDocker } from '../../../../externals'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; +import CustomViews from '../manage-panel/CustomViews'; import GeneralSettings from './GeneralSettings'; import ProjectData from './ProjectData'; import ServerPortSettings from './ServerPortSettings'; @@ -10,8 +11,9 @@ import ViewSettings from './ViewSettings'; export default function SettingsPanel({ location }: PanelBaseProps) { const dataRef = useScrollIntoView('data', location); const generalRef = useScrollIntoView('general', location); - const portRef = useScrollIntoView('port', location); const viewRef = useScrollIntoView('view', location); + const customViewsRef = useScrollIntoView('custom-views', location); + const portRef = useScrollIntoView('port', location); return ( <> @@ -25,6 +27,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
+
+ +
{!isDocker && (
diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 42d86c8e0..90687d089 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -19,7 +19,8 @@ const staticOptions = [ { id: 'settings__data', label: 'Project data' }, { id: 'settings__general', label: 'General settings' }, { id: 'settings__view', label: 'View settings' }, - { id: 'settings__port', label: 'Server Port' }, + { id: 'settings__custom-views', label: 'Custom views' }, + { id: 'settings__port', label: 'Server port' }, ], }, { diff --git a/apps/server/package.json b/apps/server/package.json index 6cd814d56..b997549ee 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -48,7 +48,7 @@ "dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts", "lint": "oxlint --quiet --type-aware", "typecheck": "tsc -p tsconfig.app.json --noEmit", - "prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleCss.ts && tsx --tsconfig tsconfig.app.json ./scripts/bundleTranslation.ts", + "prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleDefaults.ts", "build": "node esbuild.js", "test": "cross-env IS_TEST=true vitest", "test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism", diff --git a/apps/server/scripts/bundleCss.ts b/apps/server/scripts/bundleCss.ts deleted file mode 100644 index db16db31b..000000000 --- a/apps/server/scripts/bundleCss.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'path'; - -import { defaultCss } from '../src/user/styles/bundledCss'; - -/** - * Script to write contents of bundledCss to override.css - */ -async function bundleCss() { - try { - const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles'); - const cssFile = path.resolve(stylesDir, 'override.css'); - - await writeFile(cssFile, defaultCss, { encoding: 'utf8' }); - } catch (error) { - console.error('Failed writing to CSS file: ', error); - } -} - -bundleCss(); diff --git a/apps/server/scripts/bundleDefaults.ts b/apps/server/scripts/bundleDefaults.ts new file mode 100644 index 000000000..9091ebf38 --- /dev/null +++ b/apps/server/scripts/bundleDefaults.ts @@ -0,0 +1,31 @@ +import path from 'path'; + +import { defaultCss } from '../src/bundle/bundledCss'; +import { defaultDemoHtml } from '../src/bundle/bundledDemoHtml'; +import { defaultTranslation } from '../src/bundle/bundledTranslations'; +import { ensureDirectory, writeToFile } from '../src/utils/fileManagement'; + +const srcDir = path.resolve(process.cwd(), 'src'); + +const bundles = [ + { file: path.resolve(srcDir, 'user', 'styles', 'override.css'), content: defaultCss, label: 'CSS' }, + { + file: path.resolve(srcDir, 'user', 'translations', 'translations.json'), + content: defaultTranslation, + label: 'Translation', + }, + { file: path.resolve(srcDir, 'external', 'demo', 'index.html'), content: defaultDemoHtml, label: 'Demo HTML' }, +]; + +async function bundleDefaults() { + for (const { file, content, label } of bundles) { + try { + ensureDirectory(path.dirname(file)); + await writeToFile(file, content, { encoding: 'utf8' }); + } catch (error) { + console.error(`Failed writing ${label} file: `, error); + } + } +} + +bundleDefaults(); diff --git a/apps/server/scripts/bundleTranslation.ts b/apps/server/scripts/bundleTranslation.ts deleted file mode 100644 index 343c443e5..000000000 --- a/apps/server/scripts/bundleTranslation.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'path'; - -import { defaultTranslation } from '../src/user/translations/bundledTranslations.js'; - -/** - * Script to write contents of default translation to translation.json - */ -async function bundleTranslation() { - try { - const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations'); - const translationsFile = path.resolve(translationDir, 'translations.json'); - - await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' }); - } catch (error) { - console.error('Failed writing to translations file: ', error); - } -} - -bundleTranslation(); diff --git a/apps/server/src/api-data/assets/assets.router.ts b/apps/server/src/api-data/assets/assets.router.ts index c664edccc..22e54b22a 100644 --- a/apps/server/src/api-data/assets/assets.router.ts +++ b/apps/server/src/api-data/assets/assets.router.ts @@ -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'; diff --git a/apps/server/src/api-data/assets/assets.service.ts b/apps/server/src/api-data/assets/assets.service.ts index 17f31e649..f89b6a8ad 100644 --- a/apps/server/src/api-data/assets/assets.service.ts +++ b/apps/server/src/api-data/assets/assets.service.ts @@ -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 diff --git a/apps/server/src/api-data/custom-views/__tests__/customViews.service.test.ts b/apps/server/src/api-data/custom-views/__tests__/customViews.service.test.ts new file mode 100644 index 000000000..15569468c --- /dev/null +++ b/apps/server/src/api-data/custom-views/__tests__/customViews.service.test.ts @@ -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('hello')).not.toThrow(); + }); + + it('accepts valid HTML starting with html tag', () => { + expect(() => validateHtmlContent('hello')).not.toThrow(); + }); + + it('accepts HTML with inline script and style', () => { + const 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 = ''; + expect(() => validateHtmlContent(html)).toThrow('External scripts are not allowed'); + }); + + it('rejects external stylesheets', () => { + const html = ''; + expect(() => validateHtmlContent(html)).toThrow('External stylesheets are not allowed'); + }); + + it('rejects iframes', () => { + const html = ''; + expect(() => validateHtmlContent(html)).toThrow('Iframes are not allowed'); + }); + + it('allows link tags that are not stylesheets', () => { + const html = ''; + expect(() => validateHtmlContent(html)).not.toThrow(); + }); +}); diff --git a/apps/server/src/api-data/custom-views/customViews.errors.ts b/apps/server/src/api-data/custom-views/customViews.errors.ts new file mode 100644 index 000000000..ff25d8d20 --- /dev/null +++ b/apps/server/src/api-data/custom-views/customViews.errors.ts @@ -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) { + if (error instanceof CustomViewError) { + res.status(error.statusCode).send({ message: error.message }); + return; + } + + res.status(500).send({ message: getErrorMessage(error) }); +} diff --git a/apps/server/src/api-data/custom-views/customViews.middleware.ts b/apps/server/src/api-data/custom-views/customViews.middleware.ts new file mode 100644 index 000000000..7b8afab4c --- /dev/null +++ b/apps/server/src/api-data/custom-views/customViews.middleware.ts @@ -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, 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' }); + }); +} diff --git a/apps/server/src/api-data/custom-views/customViews.router.ts b/apps/server/src/api-data/custom-views/customViews.router.ts new file mode 100644 index 000000000..50e9fe2a8 --- /dev/null +++ b/apps/server/src/api-data/custom-views/customViews.router.ts @@ -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) => { + try { + const views = await listCustomViews(); + res.status(200).send({ views }); + } catch (error) { + handleCustomViewsError(error, res); + } +}); + +router.post('/restore-demo', async (_req: Request, res: Response) => { + 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) => { + 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) => { + 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) => { + try { + await deleteCustomView(req.params.slug); + res.status(204).send(); + } catch (error) { + handleCustomViewsError(error, res); + } +}); diff --git a/apps/server/src/api-data/custom-views/customViews.service.ts b/apps/server/src/api-data/custom-views/customViews.service.ts new file mode 100644 index 000000000..8d137b375 --- /dev/null +++ b/apps/server/src/api-data/custom-views/customViews.service.ts @@ -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: /]+src\s*=/i, message: 'External scripts are not allowed. Use inline + +`; diff --git a/apps/server/src/user/translations/bundledTranslations.ts b/apps/server/src/bundle/bundledTranslations.ts similarity index 100% rename from apps/server/src/user/translations/bundledTranslations.ts rename to apps/server/src/bundle/bundledTranslations.ts diff --git a/apps/server/src/external/README.md b/apps/server/src/external/README.md index 401b25594..02942aea8 100644 --- a/apps/server/src/external/README.md +++ b/apps/server/src/external/README.md @@ -8,3 +8,409 @@ http://:/external/ ``` https://docs.getontime.no/features/custom-views/ + +## Demo + +This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime. + +Here, we subscribe to the websocket and display all the data received in a grid. + +Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment. + +### Getting the data + +To subscribe to the websocket you will need: + +- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001` +- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on. + +#### Example + +- Ontime URL: `https://cloud.getontime.no/stage-123` +- Ontime token: `token-from-share` + +```js +// use wss since we are connecting to an https address +const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`; + +/** + * Connects to the websocket server + * NOTE: this demo does not handle reconnections or errors + * @param {string} socketUrl + */ +const connectSocket = (socketUrl) => { + const websocket = new WebSocket(socketUrl); + + websocket.onmessage = (event) => { + // all objects from ontime are structured with tag and payload + const { tag, payload } = JSON.parse(event.data); + + // runtime-data is sent on connect, with the full state + // runtime-patch is sent on every change to the state + if (tag === 'runtime-data') { + handleOntimePayload(payload); + } + }; +}; +``` + +### Runtime data + +`runtime-data` contains a patch of all the data in the server +you would need to create a function that parses the patch and extract the data you need + +In our case, we simply map the data to a DOM element with the same ID as the field name. + +[See the docs](https://docs.getontime.no/api/data/runtime-data/). + +#### Example of handling the payload + +```js +const handleOntimePayload = (payload) => { + // 1. apply the patch into your local copy of the data + localData = { ...localData, ...payload }; + + // 2. update the UI with the new data + // ... timer data + if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock)); + if ('timer' in payload) updateDOM('timer', formatObject(payload.timer)); + // ... rundown data + if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown)); + // ... runtime + if ('offset' in payload) updateDOM('offset', formatObject(payload.offset)); + // ... relevant entries + if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow)); + if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext)); + if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag)); + if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow)); + // ... messages service + if ('message' in payload) updateDOM('message', formatObject(payload.message)); + // ... extra timers + if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1)); + if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2)); + if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3)); +}; +``` + +#### Payload example + +See below what the payload looks like. +Note: all timer values are in milliseconds. + +```jsonc +{ + /** Current server clock value */ + "clock": 37816011, + + /** + * Gathers the current running timer state + */ + "timer": { + /** Additional time added to the running timer, can be negative */ + "addedTime": 0, + /** Current running timer countdown */ + "current": 3574976, + /** Total duration of the running event */ + "duration": 3600000, + /** Time elapsed since the timer started */ + "elapsed": 25024, + /** Timestamp of the expected finish time */ + "expectedFinish": 41391285, + /** Current phase of the running event */ + "phase": "default", + /** Timer's playback state */ + "playback": "play", + /** Secondary timer, used to count to an event start in roll mode */ + "secondaryTimer": null, + /** Timestamp when the timer started */ + "startedAt": 37791285, + }, + + /** + * Offset represents our current position in relation to the planned time + * a positive value means that we have added extra time to the expected end + * aka behind schedule + */ + "offset": { + /** Current absolute offset: accounts for planned times */ + "absolute": 40394840, + /** Current relative offset: only counts for generated offset since start */ + "relative": -35997119, + /** Currently selected offset mode */ + "mode": "absolute", + /** Timestamp of the expected start of the next flag */ + "expectedFlagStart": 80594840, + /** Timestamp of the expected end of the current group */ + "expectedGroupEnd": 83594840, + /** Timestamp of the expected end of the loaded rundown */ + "expectedRundownEnd": 90794840, + }, + + /** Data object describes rundown schedule and the current progress */ + "rundown": { + /** Index of the currently selected event */ + "selectedEventIndex": 1, + /** Total number of events */ + "numEvents": 7, + /** Timestamp of the rundown's planned start time */ + "plannedStart": 0, + /** Timestamp of the rundown's planned end time */ + "plannedEnd": 50400000, + /** Timestamp of when the rundown was actually started */ + "actualStart": 76391959, + }, + + /** Data of currently loaded event */ + "eventNow": { + /** Unique identifier for the event */ + "id": "9bf60f", + /** Entry type */ + "type": "event", + /** Whether the event is flagged */ + "flag": false, + /** Title of the event */ + "title": "Pre-show Countdown", + /** Timestamp of the planned start time */ + "timeStart": 36000000, + /** Timestamp of the planned end time */ + "timeEnd": 39600000, + /** Planned event duration */ + "duration": 3600000, + /** Strategy for time management */ + "timeStrategy": "lock-end", + /** Whether the event is linked to the start of the previous */ + "linkStart": false, + /** Action to take at the end of the event */ + "endAction": "none", + /** Type of timer used for the event */ + "timerType": "count-down", + /** Whether the timer counts to the end */ + "countToEnd": false, + /** Whether the event is skipped */ + "skip": false, + /** Note associated with the event */ + "note": "Music plays, holding slide on screens", + /** Colour code for the event */ + "colour": "#77C785", + /** Current delay inherited from the rundown schedule */ + "delay": 0, + /** Day offset for the event */ + "dayOffset": 0, + /** Time gap between events */ + "gap": 0, + /** Cue number for the event */ + "cue": "1", + /** Parent group ID */ + "parent": "7eaf99", + /** Revision number for the entry */ + "revision": 0, + /** Warning time */ + "timeWarning": 600000, + /** Danger time */ + "timeDanger": 300000, + /** Custom fields for the event */ + "custom": { "Custom_Field": "Put additional info here" }, + /** Triggers associated with the event */ + "triggers": [], + }, + + /** Upcoming event data */ + "eventNext": { + /** Unique identifier for the event */ + "id": "c2697f", + /** Entry type */ + "type": "event", + /** Whether the event is flagged */ + "flag": false, + /** Title of the event */ + "title": "Welcome", + /** Timestamp of the planned start time */ + "timeStart": 39600000, + /** Timestamp of the planned end time */ + "timeEnd": 40200000, + /** Planned event duration */ + "duration": 600000, + /** Strategy for time management */ + "timeStrategy": "lock-duration", + /** Whether the event is linked to the start of the previous */ + "linkStart": true, + /** Action to take at the end of the event */ + "endAction": "none", + /** Type of timer used for the event */ + "timerType": "count-down", + /** Whether the timer counts to the end */ + "countToEnd": false, + /** Whether the event is skipped */ + "skip": false, + /** Note associated with the event */ + "note": "Emma Thompson", + /** Colour code for the event */ + "colour": "#FFCC78", + /** Current delay inherited from the rundown schedule */ + "delay": 0, + /** Day offset for the event */ + "dayOffset": 0, + /** Time gap between events */ + "gap": 0, + /** Cue number for the event */ + "cue": "1.1", + /** Parent group ID */ + "parent": "7eaf99", + /** Revision number for the entry */ + "revision": 0, + /** Warning time */ + "timeWarning": 120000, + /** Danger time */ + "timeDanger": 60000, + /** Custom fields for the event */ + "custom": {}, + /** Triggers associated with the event */ + "triggers": [], + }, + + /** Data of currently targetted flag event */ + "eventFlag": { + /** Unique identifier for the event */ + "id": "fa593e", + /** Entry type */ + "type": "event", + /** Whether the event is flagged */ + "flag": true, + /** Title of the event */ + "title": "Session 1", + /** Timestamp of the planned start time */ + "timeStart": 40200000, + /** Timestamp of the planned end time */ + "timeEnd": 43200000, + /** Planned event duration */ + "duration": 3000000, + /** Strategy for time management */ + "timeStrategy": "lock-duration", + /** Whether the event is linked to the start of the previous */ + "linkStart": true, + /** Action to take at the end of the event */ + "endAction": "none", + /** Type of timer used for the event */ + "timerType": "count-down", + /** Whether the timer counts to the end */ + "countToEnd": false, + /** Whether the event is skipped */ + "skip": false, + /** Note associated with the event */ + "note": "Liam Carter, Sophia Patel + PowerPoint", + /** Colour code for the event */ + "colour": "#77C785", + /** Current delay inherited from the rundown schedule */ + "delay": 0, + /** Day offset for the event */ + "dayOffset": 0, + /** Time gap between events */ + "gap": 0, + /** Cue number for the event */ + "cue": "1.2", + /** Parent group ID */ + "parent": "7eaf99", + /** Revision number for the entry */ + "revision": 0, + /** Warning time */ + "timeWarning": 120000, + /** Danger time */ + "timeDanger": 60000, + /** Custom fields for the event */ + "custom": {}, + /** Triggers associated with the event */ + "triggers": [], + }, + + /** Current group data */ + "groupNow": { + /** Unique identifier for the group */ + "id": "7eaf99", + /** Entry type */ + "type": "group", + /** Title of the group */ + "title": "Morning Sessions", + /** Note associated with the group */ + "note": "", + /** ID of entries nested in the group */ + "entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"], + /** Optional, user defined target duration */ + "targetDuration": null, + /** Colour code for the group */ + "colour": "#339E4E", + /** Custom fields for the group */ + "custom": {}, + /** Revision number for the entry */ + "revision": 0, + /** Timestamp of the first event's planned start time */ + "timeStart": 36000000, + /** Timestamp of the last event's planned end time */ + "timeEnd": 43200000, + /** Accumulated events duration */ + "duration": 7200000, + /** Whether the first event has its start time linked */ + "isFirstLinked": false, + }, + + /** Message object with data */ + "message": { + /** Timer view message data */ + "timer": { + /** Text associated with the timer view */ + "text": "", + /** Whether the message is visible */ + "visible": false, + /** Whether the timer view is blinking */ + "blink": false, + /** Whether the timer view is blacked out */ + "blackout": false, + /** Secondary source for the view */ + "secondarySource": null, + }, + /** Secondary message text */ + "secondary": "", + }, + + /** Auxiliary timer 1 */ + "auxtimer1": { + /** Duration of the timer */ + "duration": 300000, + /** Current timer value */ + "current": 300000, + /** Playback state (e.g., play, pause, stop) */ + "playback": "stop", + /** Direction of the timer */ + "direction": "count-down", + }, + + /** Auxiliary timer 2 */ + "auxtimer2": { + /** Duration of the timer */ + "duration": 300000, + /** Current timer value */ + "current": 300000, + /** Playback state (e.g., play, pause, stop) */ + "playback": "stop", + /** Direction of the timer */ + "direction": "count-down", + }, + + /** Auxiliary timer 3 */ + "auxtimer3": { + /** Duration of the timer */ + "duration": 300000, + /** Current timer value */ + "current": 300000, + /** Playback state (e.g., play, pause, stop) */ + "playback": "stop", + /** Direction of the timer */ + "direction": "count-down", + }, +} +``` + +## Links + +- [Ontime Documentation](https://docs.getontime.no) +- [GitHub Repository](https://github.com/getontime/ontime) +- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/) diff --git a/apps/server/src/external/demo/README.md b/apps/server/src/external/demo/README.md deleted file mode 100644 index 233bfb982..000000000 --- a/apps/server/src/external/demo/README.md +++ /dev/null @@ -1,405 +0,0 @@ -## Demo - -This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime. - -Here, we subscribe to the websocket and display all the data received in a grid. - -Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment. - -### Getting the data - -To subscribe to the websocket you will need: - -- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001` -- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on. - -#### Example - -- Ontime URL: `https://cloud.getontime.no/stage-123` -- Ontime token: `token-from-share` - -```js -// use wss since we are connecting to an https address -const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`; - -/** - * Connects to the websocket server - * NOTE: this demo does not handle reconnections or errors - * @param {string} socketUrl - */ -const connectSocket = (socketUrl) => { - const websocket = new WebSocket(socketUrl); - - websocket.onmessage = (event) => { - // all objects from ontime are structured with tag and payload - const { tag, payload } = JSON.parse(event.data); - - // runtime-data is sent on connect, with the full state - // runtime-patch is sent on every change to the state - if (tag === 'runtime-data') { - handleOntimePayload(payload); - } - }; -}; -``` - -### Runtime data - -`runtime-data` contains a patch of all the data in the server -you would need to create a function that parses the patch and extract the data you need - -In our case, we simply map the data to a DOM element with the same ID as the field name. - -[See the docs](https://docs.getontime.no/api/data/runtime-data/). - -#### Example of handling the payload - -```js -const handleOntimePayload = (payload) => { - // 1. apply the patch into your local copy of the data - localData = { ...localData, ...payload }; - - // 2. update the UI with the new data - // ... timer data - if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock)); - if ('timer' in payload) updateDOM('timer', formatObject(payload.timer)); - // ... rundown data - if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown)); - // ... runtime - if ('offset' in payload) updateDOM('offset', formatObject(payload.offset)); - // ... relevant entries - if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow)); - if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext)); - if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag)); - if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow)); - // ... messages service - if ('message' in payload) updateDOM('message', formatObject(payload.message)); - // ... extra timers - if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1)); - if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2)); - if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3)); -}; -``` - -#### Payload example - -See below what the payload looks like. -Note: all timer values are in milliseconds. - -```jsonc -{ - /** Current server clock value */ - "clock": 37816011, - - /** - * Gathers the current running timer state - */ - "timer": { - /** Additional time added to the running timer, can be negative */ - "addedTime": 0, - /** Current running timer countdown */ - "current": 3574976, - /** Total duration of the running event */ - "duration": 3600000, - /** Time elapsed since the timer started */ - "elapsed": 25024, - /** Timestamp of the expected finish time */ - "expectedFinish": 41391285, - /** Current phase of the running event */ - "phase": "default", - /** Timer's playback state */ - "playback": "play", - /** Secondary timer, used to count to an event start in roll mode */ - "secondaryTimer": null, - /** Timestamp when the timer started */ - "startedAt": 37791285, - }, - - /** - * Offset represents our current position in relation to the planned time - * a positive value means that we have added extra time to the expected end - * aka behind schedule - */ - "offset": { - /** Current absolute offset: accounts for planned times */ - "absolute": 40394840, - /** Current relative offset: only counts for generated offset since start */ - "relative": -35997119, - /** Currently selected offset mode */ - "mode": "absolute", - /** Timestamp of the expected start of the next flag */ - "expectedFlagStart": 80594840, - /** Timestamp of the expected end of the current group */ - "expectedGroupEnd": 83594840, - /** Timestamp of the expected end of the loaded rundown */ - "expectedRundownEnd": 90794840, - }, - - /** Data object describes rundown schedule and the current progress */ - "rundown": { - /** Index of the currently selected event */ - "selectedEventIndex": 1, - /** Total number of events */ - "numEvents": 7, - /** Timestamp of the rundown's planned start time */ - "plannedStart": 0, - /** Timestamp of the rundown's planned end time */ - "plannedEnd": 50400000, - /** Timestamp of when the rundown was actually started */ - "actualStart": 76391959, - }, - - /** Data of currently loaded event */ - "eventNow": { - /** Unique identifier for the event */ - "id": "9bf60f", - /** Entry type */ - "type": "event", - /** Whether the event is flagged */ - "flag": false, - /** Title of the event */ - "title": "Pre-show Countdown", - /** Timestamp of the planned start time */ - "timeStart": 36000000, - /** Timestamp of the planned end time */ - "timeEnd": 39600000, - /** Planned event duration */ - "duration": 3600000, - /** Strategy for time management */ - "timeStrategy": "lock-end", - /** Whether the event is linked to the start of the previous */ - "linkStart": false, - /** Action to take at the end of the event */ - "endAction": "none", - /** Type of timer used for the event */ - "timerType": "count-down", - /** Whether the timer counts to the end */ - "countToEnd": false, - /** Whether the event is skipped */ - "skip": false, - /** Note associated with the event */ - "note": "Music plays, holding slide on screens", - /** Colour code for the event */ - "colour": "#77C785", - /** Current delay inherited from the rundown schedule */ - "delay": 0, - /** Day offset for the event */ - "dayOffset": 0, - /** Time gap between events */ - "gap": 0, - /** Cue number for the event */ - "cue": "1", - /** Parent group ID */ - "parent": "7eaf99", - /** Revision number for the entry */ - "revision": 0, - /** Warning time */ - "timeWarning": 600000, - /** Danger time */ - "timeDanger": 300000, - /** Custom fields for the event */ - "custom": { "Custom_Field": "Put additional info here" }, - /** Triggers associated with the event */ - "triggers": [], - }, - - /** Upcoming event data */ - "eventNext": { - /** Unique identifier for the event */ - "id": "c2697f", - /** Entry type */ - "type": "event", - /** Whether the event is flagged */ - "flag": false, - /** Title of the event */ - "title": "Welcome", - /** Timestamp of the planned start time */ - "timeStart": 39600000, - /** Timestamp of the planned end time */ - "timeEnd": 40200000, - /** Planned event duration */ - "duration": 600000, - /** Strategy for time management */ - "timeStrategy": "lock-duration", - /** Whether the event is linked to the start of the previous */ - "linkStart": true, - /** Action to take at the end of the event */ - "endAction": "none", - /** Type of timer used for the event */ - "timerType": "count-down", - /** Whether the timer counts to the end */ - "countToEnd": false, - /** Whether the event is skipped */ - "skip": false, - /** Note associated with the event */ - "note": "Emma Thompson", - /** Colour code for the event */ - "colour": "#FFCC78", - /** Current delay inherited from the rundown schedule */ - "delay": 0, - /** Day offset for the event */ - "dayOffset": 0, - /** Time gap between events */ - "gap": 0, - /** Cue number for the event */ - "cue": "1.1", - /** Parent group ID */ - "parent": "7eaf99", - /** Revision number for the entry */ - "revision": 0, - /** Warning time */ - "timeWarning": 120000, - /** Danger time */ - "timeDanger": 60000, - /** Custom fields for the event */ - "custom": {}, - /** Triggers associated with the event */ - "triggers": [], - }, - - /** Data of currently targetted flag event */ - "eventFlag": { - /** Unique identifier for the event */ - "id": "fa593e", - /** Entry type */ - "type": "event", - /** Whether the event is flagged */ - "flag": true, - /** Title of the event */ - "title": "Session 1", - /** Timestamp of the planned start time */ - "timeStart": 40200000, - /** Timestamp of the planned end time */ - "timeEnd": 43200000, - /** Planned event duration */ - "duration": 3000000, - /** Strategy for time management */ - "timeStrategy": "lock-duration", - /** Whether the event is linked to the start of the previous */ - "linkStart": true, - /** Action to take at the end of the event */ - "endAction": "none", - /** Type of timer used for the event */ - "timerType": "count-down", - /** Whether the timer counts to the end */ - "countToEnd": false, - /** Whether the event is skipped */ - "skip": false, - /** Note associated with the event */ - "note": "Liam Carter, Sophia Patel + PowerPoint", - /** Colour code for the event */ - "colour": "#77C785", - /** Current delay inherited from the rundown schedule */ - "delay": 0, - /** Day offset for the event */ - "dayOffset": 0, - /** Time gap between events */ - "gap": 0, - /** Cue number for the event */ - "cue": "1.2", - /** Parent group ID */ - "parent": "7eaf99", - /** Revision number for the entry */ - "revision": 0, - /** Warning time */ - "timeWarning": 120000, - /** Danger time */ - "timeDanger": 60000, - /** Custom fields for the event */ - "custom": {}, - /** Triggers associated with the event */ - "triggers": [], - }, - - /** Current group data */ - "groupNow": { - /** Unique identifier for the group */ - "id": "7eaf99", - /** Entry type */ - "type": "group", - /** Title of the group */ - "title": "Morning Sessions", - /** Note associated with the group */ - "note": "", - /** ID of entries nested in the group */ - "entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"], - /** Optional, user defined target duration */ - "targetDuration": null, - /** Colour code for the group */ - "colour": "#339E4E", - /** Custom fields for the group */ - "custom": {}, - /** Revision number for the entry */ - "revision": 0, - /** Timestamp of the first event's planned start time */ - "timeStart": 36000000, - /** Timestamp of the last event's planned end time */ - "timeEnd": 43200000, - /** Accumulated events duration */ - "duration": 7200000, - /** Whether the first event has its start time linked */ - "isFirstLinked": false, - }, - - /** Message object with data */ - "message": { - /** Timer view message data */ - "timer": { - /** Text associated with the timer view */ - "text": "", - /** Whether the message is visible */ - "visible": false, - /** Whether the timer view is blinking */ - "blink": false, - /** Whether the timer view is blacked out */ - "blackout": false, - /** Secondary source for the view */ - "secondarySource": null, - }, - /** Secondary message text */ - "secondary": "", - }, - - /** Auxiliary timer 1 */ - "auxtimer1": { - /** Duration of the timer */ - "duration": 300000, - /** Current timer value */ - "current": 300000, - /** Playback state (e.g., play, pause, stop) */ - "playback": "stop", - /** Direction of the timer */ - "direction": "count-down", - }, - - /** Auxiliary timer 2 */ - "auxtimer2": { - /** Duration of the timer */ - "duration": 300000, - /** Current timer value */ - "current": 300000, - /** Playback state (e.g., play, pause, stop) */ - "playback": "stop", - /** Direction of the timer */ - "direction": "count-down", - }, - - /** Auxiliary timer 3 */ - "auxtimer3": { - /** Duration of the timer */ - "duration": 300000, - /** Current timer value */ - "current": 300000, - /** Playback state (e.g., play, pause, stop) */ - "playback": "stop", - /** Direction of the timer */ - "direction": "count-down", - }, -} -``` - -## Links - -- [Ontime Documentation](https://docs.getontime.no) -- [GitHub Repository](https://github.com/getontime/ontime) -- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/) diff --git a/apps/server/src/external/demo/app.js b/apps/server/src/external/demo/app.js deleted file mode 100644 index 9cdb525e4..000000000 --- a/apps/server/src/external/demo/app.js +++ /dev/null @@ -1,157 +0,0 @@ -/*eslint-env browser*/ -/** - * This is a very minimal example for a websocket client - * You could use this as a starting point to creating your own interfaces - */ - -// Data that the user needs to provide depending on the Ontime URL -const isSecure = window.location.protocol === 'https:'; -const userProvidedSocketUrl = `${isSecure ? 'wss' : 'ws'}://${window.location.host}${getStageHash()}/ws`; - -connectSocket(); - -let reconnectTimeout; -const reconnectInterval = 1000; -let reconnectAttempts = 0; - -/** - * Connects to the websocket server - * @param {string} socketUrl - */ -function connectSocket(socketUrl = userProvidedSocketUrl) { - const websocket = new WebSocket(socketUrl); - - websocket.onopen = () => { - clearTimeout(reconnectTimeout); - reconnectAttempts = 0; - console.warn('WebSocket connected'); - }; - - websocket.onclose = () => { - console.warn('WebSocket disconnected'); - reconnectTimeout = setTimeout(() => { - console.warn(`WebSocket: attempting reconnect ${reconnectAttempts}`); - if (websocket && websocket.readyState === WebSocket.CLOSED) { - reconnectAttempts += 1; - connectSocket(); - } - }, reconnectInterval); - }; - websocket.onerror = (error) => { - console.error('WebSocket error:', error); - }; - - websocket.onmessage = (event) => { - // all objects from ontime are structured with tag and payload - const { tag, payload } = JSON.parse(event.data); - - /** - * runtime-data is sent - * - on connect with the full state - * - and then on every update with a patch - */ - if (tag === 'runtime-data') { - handleOntimePayload(payload); - } - }; -} - -let localData = {}; -/** - * Handles the ontime payload updates - * @param {object} payload - The payload object containing the updates - */ -function handleOntimePayload(payload) { - // 1. apply the patch into your local copy of the data - localData = { ...localData, ...payload }; - - // 2. update the UI with the new data - // ... timer data - if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock)); - if ('timer' in payload) updateDOM('timer', formatObject(payload.timer)); - // ... rundown data - if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown)); - // ... runtime - if ('offset' in payload) updateDOM('offset', formatObject(payload.offset)); - // ... relevant entries - if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow)); - if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext)); - if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag)); - if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow)); - // ... messages service - if ('message' in payload) updateDOM('message', formatObject(payload.message)); - // ... extra timers - if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1)); - if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2)); - if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3)); -} - -/** - * Updates the DOM with a given payload - * @param {string} field - The runtime data field - * @param {object} payload - The patch object for the field - */ -function updateDOM(field, payload) { - const domElement = document.getElementById(field); - if (domElement) { - domElement.innerText = payload; - } -} - -// Time constants used for calculating times -const millisToSeconds = 1000; -const millisToMinutes = 1000 * 60; -const millisToHours = 1000 * 60 * 60; - -/** - * Formats a timer value into a human-readable string - * @param {number} number - The timer value in milliseconds - * @returns {string} The formatted timer string - */ -function formatTimer(number) { - if (number == null) { - return '--:--:--'; - } - const millis = Math.abs(number); - const isNegative = number < 0; - return `${isNegative ? '-' : ''}${leftPad(millis / millisToHours)}:${leftPad( - (millis % millisToHours) / millisToMinutes, - )}:${leftPad((millis % millisToMinutes) / millisToSeconds)}`; - - /** - * Pads a number with leading zeros - * @param {number} number - The number to pad - * @returns {string} The padded number string - */ - function leftPad(val) { - return Math.floor(val).toString().padStart(2, '0'); - } -} - -/** - * Stringifies an object into a pretty string - * @param {object} data - The data object to format - * @returns {string} The formatted data string - */ -function formatObject(data) { - return JSON.stringify(data, null, 2); -} - -/** - * Utility to handle a demo deployed in an ontime stage - * You can likely ignore this in your app - * - * an url looks like - * https://cloud.getontime.no/stage-hash/external/demo/ -> /stage-hash - * @returns {string} - The stage hash if the app is running in an ontime stage - */ -function getStageHash() { - const href = window.location.href; - if (!href.includes('getontime.no')) { - return ''; - } - - const hash = href.split('/'); - const stageHash = hash.at(3); - return stageHash ? `/${stageHash}` : ''; -} diff --git a/apps/server/src/external/demo/index.html b/apps/server/src/external/demo/index.html deleted file mode 100644 index 298320d3c..000000000 --- a/apps/server/src/external/demo/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - ontime demo - - - - -
-
- Ontime logo -

Ontime demo

-
-
- Last message received at - - -
- -
- -
-
-
- Timer -
-
Current timer values
- - -
-
-
- Rundown -
-
Progress of the current rundown
- - -
-
-
- Offset -
-
Runtime offset and timings for upcoming targets
- - -
-
-
-
-
- Event now -
-
Currently loaded event
- - -
-
-
- Event next -
-
Next scheduled event
- - -
-
-
-
-
- Group now -
-
Currently active group
- - -
-
-
- Event flag -
-
Currently targeted flag
- - -
-
-
-
-
- Message -
-
Messaging feature
- - -
-
-
- Aux timers -
-
Auxiliary Timer 1
- - -
-
-
Auxiliary Timer 2
- - -
-
-
Auxiliary Timer 3
- - -
-
-
-
- - - - diff --git a/apps/server/src/external/demo/styles.css b/apps/server/src/external/demo/styles.css deleted file mode 100644 index 5f6a310a1..000000000 --- a/apps/server/src/external/demo/styles.css +++ /dev/null @@ -1,91 +0,0 @@ -body { - margin: 0; - padding: 0; - max-width: 100vw; - overflow-x: hidden; - font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; - font-size: 14px; - line-height: 1.4; - background: #f6f6f6; - color: #222; -} - -.container { - display: flex; - flex-direction: row; - gap: 12px; - padding: 10px 12px; -} - -.container .column { - flex: 1; - display: flex; - flex-direction: column; - gap: 10px; -} - -.title-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - background: #eaeaea; -} - -.logo-title { - display: flex; - align-items: center; - gap: 7px; -} - -.logo-title img { - width: 28px; - height: 28px; - object-fit: contain; -} - -.card { - padding: 10px 12px; - background: #fff; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); -} - -.card summary.title { - border-bottom: 1px solid #ccc; - padding-bottom: 2px; - margin-bottom: 2px; -} - -h1.title, -summary.title { - font-size: 0.95em; - font-weight: 600; - margin: 0; - user-select: none; -} - -summary.title { - cursor: pointer; -} - -code { - font-size: 0.75em; - font-family: monospace; - background: #f4f4f4; - border-radius: 4px; - padding: 1.5px 3px; - display: inline-block; - white-space: pre; - width: 100%; -} - -figure { - margin: 0; - padding: 0; -} - -figcaption.description { - color: #555; - font-size: 0.75em; - font-style: italic; -} diff --git a/apps/server/src/setup/loadTranslations.ts b/apps/server/src/setup/loadTranslations.ts index 0349f2391..5de4ebed4 100644 --- a/apps/server/src/setup/loadTranslations.ts +++ b/apps/server/src/setup/loadTranslations.ts @@ -1,6 +1,6 @@ import { copyFileSync, existsSync, writeFileSync } from 'fs'; -import { defaultTranslation } from '../user/translations/bundledTranslations.js'; +import { defaultTranslation } from '../bundle/bundledTranslations.js'; import { ensureDirectory } from '../utils/fileManagement.js'; import { publicDir, publicFiles, srcFiles } from './index.js'; diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index da63bb133..7071447b4 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -1,5 +1,5 @@ -import { PathLike, constants, existsSync, mkdirSync } from 'fs'; -import { copyFile, readdir, unlink } from 'fs/promises'; +import { type Dirent, PathLike, type Stats, constants, existsSync, mkdirSync } from 'fs'; +import { access, copyFile, mkdir, readdir, rm, stat, unlink, writeFile } from 'fs/promises'; import { basename, join, parse } from 'path'; import { consoleError } from './console.js'; @@ -152,3 +152,77 @@ export const deleteFile = async (filePath: string) => { console.error('Could not delete file:', error); }); }; + +export function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +/** + * Returns file stats, or null if the path does not exist. + * Re-throws any error that is not ENOENT. + */ +export async function statIfExists(filePath: string): Promise { + try { + return await stat(filePath); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return null; + } + throw error; + } +} + +/** + * Recursively deletes a directory and all its contents. + * No-op if the directory does not exist. + */ +export async function deleteDirectory(directoryPath: string): Promise { + await rm(directoryPath, { recursive: true, force: true }); +} + +/** + * Removes a directory if it exists and creates it fresh. + */ +export async function replaceDirectory(directoryPath: string): Promise { + await rm(directoryPath, { recursive: true, force: true }); + await mkdir(directoryPath, { recursive: true }); +} + +/** + * Creates a directory. Throws if it already exists. + */ +export async function createDirectory(directoryPath: string): Promise { + await mkdir(directoryPath); +} + +/** + * Returns directory entries with file type information. + */ +export async function readDirectoryEntries(directoryPath: string): Promise { + return readdir(directoryPath, { withFileTypes: true }); +} + +/** + * Writes content to a file, creating it if it does not exist. + */ +export async function writeToFile( + filePath: string, + content: string | Buffer, + options?: { encoding?: BufferEncoding }, +): Promise { + await writeFile(filePath, content, options); +} + +/** + * Returns true if the file exists and is readable, false if it does not exist. + * Re-throws any error that is not ENOENT. + */ +export async function fileIsReadable(filePath: string): Promise { + try { + await access(filePath, constants.R_OK); + return true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return false; + throw error; + } +} diff --git a/e2e/tests/features/214-custom-views.spec.ts b/e2e/tests/features/214-custom-views.spec.ts new file mode 100644 index 000000000..96c5a99c6 --- /dev/null +++ b/e2e/tests/features/214-custom-views.spec.ts @@ -0,0 +1,55 @@ +import { readFile } from 'fs/promises'; + +import { expect, test } from '@playwright/test'; + +const baseURL = 'http://localhost:4001'; +const apiURL = `${baseURL}/data/custom-views`; +const testSlug = 'e2e-test-view'; +const fixtureFile = 'e2e/tests/fixtures/custom-view-test.html'; + +test.describe('custom views', () => { + test.afterEach(async ({ request }) => { + try { + await request.delete(`${apiURL}/${testSlug}`); + } catch { + /** nothing to do here */ + } + }); + + test('upload, list, serve, and delete a custom view', async ({ page, request }) => { + // 1. Upload a custom view via the API + const fileContent = await readFile(fixtureFile); + const response = await request.post(`${apiURL}/${testSlug}/upload`, { + multipart: { + indexHtml: { + name: 'index.html', + mimeType: 'text/html', + buffer: fileContent, + }, + }, + }); + expect(response.status()).toBe(201); + + // 2. Verify the view appears in the listing + const listResponse = await request.get(apiURL); + expect(listResponse.ok()).toBeTruthy(); + const listData = await listResponse.json(); + const uploadedView = listData.views.find((v: { slug: string }) => v.slug === testSlug); + expect(uploadedView).toBeDefined(); + + // 3. Navigate to the custom view URL and verify it renders + await page.goto(`${baseURL}/external/${testSlug}/`); + await expect(page.getByTestId('custom-view-heading')).toBeVisible(); + await expect(page.getByTestId('custom-view-heading')).toHaveText('Custom View E2E Test'); + + // 4. Delete the view + const deleteResponse = await request.delete(`${apiURL}/${testSlug}`); + expect(deleteResponse.status()).toBe(204); + + // 5. Verify the view is removed from the listing + const listAfterDelete = await request.get(apiURL); + const dataAfterDelete = await listAfterDelete.json(); + const deletedView = dataAfterDelete.views.find((v: { slug: string }) => v.slug === testSlug); + expect(deletedView).toBeUndefined(); + }); +}); diff --git a/e2e/tests/fixtures/custom-view-test.html b/e2e/tests/fixtures/custom-view-test.html new file mode 100644 index 000000000..5278e3fff --- /dev/null +++ b/e2e/tests/fixtures/custom-view-test.html @@ -0,0 +1,14 @@ + + + + + Custom View E2E Test + + + +

Custom View E2E Test

+

This is a test custom view for end-to-end testing.

+ + diff --git a/packages/types/src/api/custom-views/customViews.type.ts b/packages/types/src/api/custom-views/customViews.type.ts new file mode 100644 index 000000000..474aa6fa3 --- /dev/null +++ b/packages/types/src/api/custom-views/customViews.type.ts @@ -0,0 +1,7 @@ +export interface CustomViewSummary { + slug: string; +} + +export interface CustomViewsListResponse { + views: CustomViewSummary[]; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 719a754d3..896a551d0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -91,6 +91,7 @@ export type { RundownSummary, } from './api/rundown-controller/BackendResponse.type.js'; export type { LinkOptions } from './api/session-controller/BackendResponse.type.js'; +export type { CustomViewSummary, CustomViewsListResponse } from './api/custom-views/customViews.type.js'; // web socket export { MessageTag } from './api/websocket/data.type.js';