Compare commits

...

22 Commits

Author SHA1 Message Date
Carlos Valente 4c6acc4013 refactor: improve active styles for navigation buttons 2024-12-13 11:24:03 +01:00
Carlos Valente 26a24449de refactor: prevent modal close on click outside 2024-12-13 10:40:16 +01:00
Carlos Valente a1fb642441 bump version to 3.9.5 2024-12-13 10:40:16 +01:00
Carlos Valente b64b154330 refactor: disable shutdown 2024-12-13 10:39:58 +01:00
Carlos Valente 5032cbf65a refactor: prevent instantiating unavailable services 2024-12-13 10:39:58 +01:00
Carlos Valente 466360f9d1 chore: note service limitations 2024-12-13 10:39:58 +01:00
Carlos Valente ef2ea9a1da fix: recover edit menu and shortcuts 2024-12-13 10:39:45 +01:00
Carlos Valente fb83f48752 refactor: maintain an isOnline flag in client 2024-12-13 10:39:30 +01:00
Carlos Valente c1d53b0e55 refactor: improve empty state for project info 2024-12-13 10:12:06 +01:00
Alex Christoffer Rasmussen 7915cc822d Dockersafe rename (#1370)
* add dockerSafeRename function

* replace all rename functions

* add explanation to function
2024-12-10 23:27:51 +01:00
Carlos Valente 7dbf64d100 chore: restructure directory 2024-12-10 15:53:53 +01:00
Carlos Valente 6ffbf2af9d chore: move files to new structure 2024-12-10 15:53:53 +01:00
Carlos Valente fa7ec623f1 bump version to 3.9.4 2024-12-08 08:52:18 +01:00
Carlos Valente bfb9b51073 fix: secure protocol resolution 2024-12-08 08:52:18 +01:00
Carlos Valente 8bc3f5a56c Fix links (#1366)
* chore: remove unused code

* bump version to 3.9.3

* chore: use constant links

* refactor: build links

* chore: ignore ontime-data
2024-12-06 16:48:35 +01:00
Carlos Valente acf1afb5e9 fix: prevent overflow 2024-12-06 14:54:41 +01:00
Carlos Valente d8e2a8d092 bump version to 3.9.2 2024-12-04 21:41:49 +01:00
Carlos Valente 8e587128a1 refactor: ensure element height 2024-12-04 21:41:49 +01:00
Alex Christoffer Rasmussen 607bc40673 create addTime function in simple timer (#1363)
* create addTime function in simple timer
2024-12-04 20:25:49 +01:00
Carlos Valente e6aa7404f7 chore: correct documentation 2024-12-04 19:49:49 +01:00
Carlos Valente d622a7738f refactor: remove deprecated field 2024-12-04 19:49:49 +01:00
Carlos Valente b9ba416366 refactor: add prefix to relative assets
refactor: add prefix to server entrypoints
refactor: add prefix to express router
refactor: add prefix to router basename
2024-12-04 19:49:49 +01:00
71 changed files with 515 additions and 293 deletions
+1
View File
@@ -39,6 +39,7 @@ dist/
# docker utils
ontime-db
ontime-external/
ontime-data/
# versioning file
**/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.9.1",
"version": "3.9.5",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+25 -29
View File
@@ -1,31 +1,27 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<base href="/">
<link rel='icon' href='/favicon.ico' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='theme-color' content='#101010' />
<meta name='ontime' content='ontime - time keeping for live events' />
<link rel='apple-touch-icon' href='/ontime-logo.png' />
<link
rel='icon'
type='image/png'
href='ontime-logo.png'
/>
<link rel='manifest' href='/site.webmanifest' />
<link rel='manifest' href='/manifest.json' />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.9.1",
"version": "3.9.5",
"private": true,
"type": "module",
"dependencies": {
@@ -39,7 +39,7 @@
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"test": "vitest",
+2 -1
View File
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket();
@@ -19,7 +20,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
+1 -1
View File
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
+1 -1
View File
@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -17,7 +17,7 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost, serverPort } from '../../../externals';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent';
@@ -25,7 +25,7 @@ import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={style.link}
className={cx([style.link, fullscreen && style.current])}
tabIndex={0}
role='button'
onClick={toggle}
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
className={cx([style.link, mirror && style.current])}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
return null;
}
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
@@ -0,0 +1,20 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -0,0 +1,20 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
+9 -3
View File
@@ -16,11 +16,17 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
/**
* Allows patching a property of the runtime store
* @param key
* @param value
*/
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
const state = runtimeStore.getState();
state[key] = value;
runtimeStore.setState({ ...state });
}
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
+40
View File
@@ -0,0 +1,40 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event: MouseEvent, location: string) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
}
return destination.toString();
}
+36 -21
View File
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -39,12 +39,14 @@ export const connectSocket = () => {
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname + location.search);
setOnlineStatus(true);
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect');
@@ -73,8 +75,8 @@ export const connectSocket = () => {
switch (type) {
case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntime('ping', offset);
updateDevTools({ ping: offset }, ['PING']);
patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset });
break;
}
case 'client-id': {
@@ -131,64 +133,65 @@ export const connectSocket = () => {
break;
}
case 'ontime': {
runtimeStore.setState(payload as RuntimeStore);
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
patchRuntime(serverPayload);
updateDevTools(serverPayload);
break;
}
case 'ontime-clock': {
patchRuntime('clock', payload);
patchRuntimeProperty('clock', payload);
updateDevTools({ clock: payload });
break;
}
case 'ontime-timer': {
patchRuntime('timer', payload);
patchRuntimeProperty('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
patchRuntime('onAir', payload);
patchRuntimeProperty('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
patchRuntime('message', payload);
patchRuntimeProperty('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
patchRuntime('runtime', payload);
patchRuntimeProperty('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
patchRuntime('eventNow', payload);
patchRuntimeProperty('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
patchRuntimeProperty('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload);
patchRuntimeProperty('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
patchRuntime('eventNext', payload);
patchRuntimeProperty('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
patchRuntime('publicEventNext', payload);
patchRuntimeProperty('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
patchRuntime('auxtimer1', payload);
patchRuntimeProperty('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break;
}
@@ -232,11 +235,23 @@ export const socketSendJson = (type: string, payload?: unknown) => {
);
};
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
function updateDevTools(newData: Partial<RuntimeStore>) {
if (!isProduction) {
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
...oldData,
...newData,
}));
}
}
/**
* Allows setting the status of the client
* We leverage the ping as an indication of the client's online status
* @example ping < 0 - client is offline
* @example ping > 0 -> client is online
*/
function setOnlineStatus(status: boolean) {
const derivedPing = status ? 1 : -1;
patchRuntimeProperty('ping', derivedPing);
updateDevTools({ ping: derivedPing });
}
+56 -9
View File
@@ -1,8 +1,9 @@
import { version } from '../../../package.json';
/**
* This file contains a list of constants that may need to be resolved at runtime
*/
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
@@ -16,13 +17,59 @@ export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD);
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve protocol
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// resolve entrypoint URLs
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? window.location.port : STATIC_PORT;
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
/**
* Resolve base
* @example '' for electron
* @example '/client-hash' for cloud
*/
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// generate ws url
if (protocol === 'ws') {
// ensure we remain in a secure context
const isSecure = window.location.protocol === 'https:';
url.protocol = isSecure ? 'wss' : 'ws';
}
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
return base;
}
@@ -10,6 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils';
@@ -106,6 +107,9 @@ export default function OscIntegrations() {
</Button>
</div>
</Panel.SubHeader>
{isOntimeCloud && (
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
)}
<Panel.Divider />
@@ -2,8 +2,8 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost, serverPort } from '../../../../externals';
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost } from '../../../../externals';
import style from './NetworkInterfaces.module.scss';
@@ -14,11 +14,11 @@ export default function InfoNif() {
return (
<div className={style.interfaces}>
{data.networkInterfaces?.map((nif) => {
{data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
const address = `http://${nif.address}:${serverPort}`;
return (
<CopyTag
key={nif.name}
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket';
import { isOntimeCloud } from '../../../../externals';
import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -19,7 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<>
<Panel.Header>Network</Panel.Header>
<Panel.Section>
{isOntimeCloud && <OntimeCloudStats />}
{isDockerImage && <OntimeCloudStats />}
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
placeholder={websiteUrl}
autoComplete='off'
{...register('publicUrl')}
/>
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input
variant='ontime-filled'
size='sm'
placeholder='http://docs.getontime.no'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
@@ -10,6 +10,7 @@ import { postProjectData, uploadProjectLogo } from '../../../../common/api/proje
import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -203,7 +204,7 @@ export default function ProjectData() {
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
placeholder={websiteUrl}
autoComplete='off'
{...register('publicUrl')}
/>
@@ -225,7 +226,7 @@ export default function ProjectData() {
<Input
variant='ontime-filled'
size='sm'
placeholder='http://docs.getontime.no'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
@@ -11,7 +11,7 @@ import {
} from '@chakra-ui/react';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import { isLocalhost } from '../../../../externals';
import { isLocalhost, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function ShutdownPanel() {
@@ -24,18 +24,28 @@ export default function ShutdownPanel() {
onClose();
};
const canShutdown = isElectron || isLocalhost;
return (
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
{isOntimeCloud ? (
<Panel.Highlight>
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Panel.Highlight>
) : (
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
)}
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
Shutdown ontime
</Button>
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
{!canShutdown && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
)}
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
@@ -49,7 +59,7 @@ export default function ShutdownPanel() {
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
Cancel
</Button>
<Button colorScheme='red' onClick={sendShutdown} ml={4}>
<Button colorScheme='red' onClick={sendShutdown} disabled={!canShutdown}>
Shutdown
</Button>
</AlertDialogFooter>
@@ -1,41 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`;
@@ -27,6 +27,7 @@
}
.tableContainer {
height: 350px;
max-height: 350px;
overflow-y: auto;
}
@@ -53,7 +53,7 @@ export default function Welcome(props: WelcomeProps) {
};
return (
<Modal isOpen onClose={handleClose} variant='ontime'>
<Modal isOpen onClose={handleClose} closeOnOverlayClick={false} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'>
<ModalCloseButton />
@@ -149,6 +149,7 @@ $orange-active: #f60;
grid-area: schedule;
font-family: monospace;
margin-right: 2vw;
overflow: hidden;
}
.onAir {
@@ -11,7 +11,7 @@ import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import useColumnManager from './useColumnManager';
import style from './Cuesheet.module.scss';
@@ -11,16 +11,16 @@ import { useCuesheet } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../overview/Overview';
import { CuesheetOverview } from '../../features/overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
import styles from './CuesheetWrapper.module.scss';
import styles from './CuesheetPage.module.scss';
export default function CuesheetWrapper() {
export default function CuesheetPage() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields();
@@ -1,11 +1,11 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import CuesheetWrapper from './CuesheetWrapper';
import CuesheetPage from './CuesheetPage';
export default function ProtectedCuesheet() {
return (
<ProtectRoute permission='operator'>
<CuesheetWrapper />
<CuesheetPage />
</ProtectRoute>
);
}
@@ -1,4 +1,6 @@
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
import { ProjectData } from 'ontime-types';
import { makeCSV, makeTable, parseField } from '../cuesheet.utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
@@ -27,6 +29,7 @@ describe('parseField()', () => {
});
it('returns an empty string on undefined fields', () => {
// @ts-expect-error -- testing user data with missing fields
expect(parseField('title')).toBe('');
});
@@ -51,6 +54,7 @@ describe('makeTable()', () => {
const headerData = {
title: 'test title',
description: 'test description',
projectLogo: 'test logo',
};
const tableData = [
{
@@ -66,8 +70,48 @@ describe('makeTable()', () => {
lighting: { label: 'test' },
};
const table = makeTable(headerData, tableData, customFields);
expect(table).toMatchSnapshot();
// @ts-expect-error -- testing user data with missing fields
const table = makeTable(headerData as ProjectData, tableData, customFields);
expect(table).not.toContain('test logo');
expect(table).toMatchInlineSnapshot(`
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`);
});
});
@@ -6,7 +6,7 @@ import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon'
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import { useCuesheetSettings } from '../store/cuesheetSettingsStore';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
@@ -1,6 +1,6 @@
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import ClockTime from '../../../features/viewers/common/clock-time/ClockTime';
import RunningTime from '../../../features/viewers/common/running-time/RunningTime';
import style from './CuesheetTableHeader.module.scss';
@@ -3,7 +3,7 @@ import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import { useCuesheetSettings } from '../store/cuesheetSettingsStore';
import style from './CuesheetTableSettings.module.scss';
@@ -4,10 +4,10 @@ import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime';
import RunningTime from '../../features/viewers/common/running-time/RunningTime';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import style from './Cuesheet.module.scss';
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings {
interface CuesheetSettingsStore {
showSettings: boolean;
showIndexColumn: boolean;
followSelected: boolean;
@@ -36,7 +36,7 @@ enum CuesheetKeys {
Seconds = 'ontime-cuesheet-hide-sceconds',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
export const useCuesheetSettings = create<CuesheetSettingsStore>()((set) => ({
showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
@@ -1,6 +1,7 @@
import { ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
@@ -16,7 +17,7 @@ interface ProjectInfoProps {
isMirrored: boolean;
}
export default function ProjectInfoProps(props: ProjectInfoProps) {
export default function ProjectInfo(props: ProjectInfoProps) {
const { general, isMirrored } = props;
useWindowTitle('Project info');
@@ -25,6 +26,25 @@ export default function ProjectInfoProps(props: ProjectInfoProps) {
return <Empty text='No data found' />;
}
if (!general) {
return (
<>
<ViewParamsEditor viewOptions={projectInfoOptions} />
return <EmptyPage text='No data found' />;
</>
);
}
const isEmpty = Object.values(general).every((value) => !value);
if (isEmpty) {
return (
<>
<ViewParamsEditor viewOptions={projectInfoOptions} />
<EmptyPage text='The project has no data yet' />;
</>
);
}
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor viewOptions={projectInfoOptions} />
+2 -1
View File
@@ -11,6 +11,7 @@ const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development';
export default defineConfig({
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
plugins: [
react(),
svgrPlugin(),
@@ -33,7 +34,7 @@ export default defineConfig({
}),
compression({
algorithm: 'brotliCompress',
exclude: /\.(html)$/, // Exclude HTML files from compression so we can change the base property at runtime
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
}),
],
server: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.9.1",
"version": "3.9.5",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+18 -1
View File
@@ -29,6 +29,7 @@ function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, sho
const template = [
...(isMac ? [makeMacMenu(askToQuit)] : []),
makeFileMenu(serverUrl, redirectWindow, showDialog, download),
makeEditMenu(),
makeViewMenu(clientUrl),
makeSettingsMenu(redirectWindow),
makeHelpMenu(redirectWindow),
@@ -61,6 +62,22 @@ function makeMacMenu(askToQuit) {
};
}
/**
* Utility function generates the edit menu
* @returns {Object}
*/
function makeEditMenu() {
return {
label: 'Edit',
submenu: [
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
],
};
}
/**
* Utility function generates the file menu
* @param {string} serverUrl - base url for the application
@@ -253,7 +270,7 @@ function makeSettingsMenu(redirectWindow) {
click: () => redirectWindow('/editor?settings=network__log'),
},
{
label: 'Manage cleints',
label: 'Manage clients',
click: () => redirectWindow('/editor?settings=network__clients'),
},
],
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.9.1",
"version": "3.9.5",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+2 -2
View File
@@ -47,8 +47,8 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server: Server) {
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
init(server: Server, prefix?: string) {
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => {
const clientId = generateId();
+38 -27
View File
@@ -6,11 +6,11 @@ import expressStaticGzip from 'express-static-gzip';
import http, { type Server } from 'http';
import cors from 'cors';
import serverTiming from 'server-timing';
import { extname, resolve } from 'path';
import { extname } from 'node:path';
// import utils
import { publicDir, srcDir } from './setup/index.js';
import { environment, isProduction, updateRouterPrefix } from './externals.js';
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
@@ -53,8 +53,14 @@ if (!canLog) {
console.log(`Ontime public directory at ${publicDir.root} `);
}
// calls an update to the client router prefix
updateRouterPrefix();
/**
* When running in Ontime cloud, the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This means:
* - changing the base path in the index.html file
* - prepending all express routes with the given prefix
*/
const prefix = updateRouterPrefix();
// Create express APP
const app = express();
@@ -75,20 +81,20 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/data', appRouter); // router for application data
app.use('/api', integrationRouter); // router for integrations
app.use(`${prefix}/data`, appRouter); // router for application data
app.use(`${prefix}/api`, integrationRouter); // router for integrations
// serve static external files
app.use('/external', express.static(publicDir.externalDir));
app.use('/user', express.static(publicDir.userDir));
// if the user reaches to the root, we show a 404
app.use('/external', (req, res) => {
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
app.use(`${prefix}/external`, (req, res) => {
// if the user reaches to the root, we show a 404
res.status(404).send(`${req.originalUrl} not found`);
});
app.use(`${prefix}/user`, express.static(publicDir.userDir));
// serve static - react, in dev/test mode we fetch the React app from module
app.use(
prefix,
expressStaticGzip(srcDir.clientDir, {
enableBrotli: true,
orderPreference: ['br'],
@@ -111,8 +117,8 @@ app.use(
}),
);
app.get('*', (_req, res) => {
res.sendFile(resolve(srcDir.clientDir, 'index.html'));
app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(srcFiles.clientIndexHtml);
});
// Implement catch all
@@ -176,7 +182,7 @@ export const startServer = async (
const { serverPort } = getDataProvider().getSettings();
expressServer = http.createServer(app);
socket.init(expressServer);
socket.init(expressServer, prefix);
/**
* Module initialises the services and provides initial payload for the store
@@ -221,10 +227,10 @@ export const startServer = async (
expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces();
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
consoleSuccess(`Local: http://localhost:${serverPort}${prefix}/editor`);
for (const key in nif) {
const address = nif[key].address;
consoleSuccess(`Network: http://${address}:${serverPort}/editor`);
consoleSuccess(`Network: http://${address}:${serverPort}${prefix}/editor`);
}
});
@@ -243,16 +249,6 @@ export const startIntegrations = async () => {
// if a config is not provided, we use the persisted one
const { osc, http } = getDataProvider().getData();
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
if (http) {
logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...');
try {
@@ -262,6 +258,21 @@ export const startIntegrations = async () => {
logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`);
}
}
if (isOntimeCloud) {
logger.info(LogOrigin.Tx, 'Skipping OSC in Cloud environment...');
return;
}
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
};
/**
@@ -37,6 +37,14 @@ export class SimpleTimer {
return this.state;
}
public addTime(millis: number): SimpleTimerState {
this.state.duration += millis;
// the value of current will be overridden when update is called,
// but if we are in pause or stop state it will not be changed so we do it here
this.state.current += millis;
return this.state;
}
public setDirection(direction: SimpleDirection, timeNow: number): SimpleTimerState {
// if we are playing, we need to reset the targets
if (this.state.playback === SimplePlayback.Start) {
@@ -177,5 +177,60 @@ describe('SimpleTimer count-down', () => {
playback: SimplePlayback.Start,
});
});
test('adding time affects final result', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
timer.update(300);
expect(timer.state).toMatchObject({ current: 1700, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
test('adding time affects paused timer', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.pause(200);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.start(300);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
});
test('adding time affects stopped timer, but returns to initial valuses when stopped again', () => {
timer.reset();
timer.setTime(1000);
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
timer.addTime(1000);
expect(timer.state).toMatchObject({ current: 2000, duration: 2000 });
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
});
});
+11 -9
View File
@@ -3,7 +3,8 @@
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { srcFiles } from './setup/index.js';
// =================================================
// resolve running environment
@@ -13,25 +14,26 @@ export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
/**
* Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
*/
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX) {
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX): string {
if (!prefix) {
return;
return '';
}
const indexFile = resolve('.', 'client', 'index.html');
try {
const data = readFileSync(indexFile, { encoding: 'utf-8', flag: 'r' }).replace(
/<base href="[^"]*">/g,
`<base href="${prefix}" />`,
const data = readFileSync(srcFiles.clientIndexHtml, { encoding: 'utf-8', flag: 'r' }).replace(
'<base href="/" />',
`<base href="/${prefix}/" />`,
);
writeFileSync(indexFile, data, { encoding: 'utf-8', flag: 'w' });
writeFileSync(srcFiles.clientIndexHtml, data, { encoding: 'utf-8', flag: 'w' });
} catch (_error) {
/** unhandled */
}
return `/${prefix}`;
}
@@ -1,4 +1,4 @@
import { SimpleDirection, SimpleTimerState } from 'ontime-types';
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -59,7 +59,11 @@ export class AuxTimerService {
@broadcastReturn
addTime(millis: number) {
return this.timer.setTime(this.timer.state.current + millis);
if (this.timer.state.playback === SimplePlayback.Start) {
this.timer.addTime(millis);
return this.timer.update(this.getTime());
}
return this.timer.addTime(millis);
}
@broadcastReturn
@@ -1,12 +1,13 @@
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename } from 'fs/promises';
import { copyFile } from 'fs/promises';
import { logger } from '../../classes/Logger.js';
import { publicDir } from '../../setup/index.js';
import {
appendToName,
dockerSafeRename,
ensureDirectory,
generateUniqueFileName,
getFileNameFromPath,
@@ -93,7 +94,7 @@ async function handleCorruptedFile(filePath: string, fileName: string): Promise<
// and make a new file with the recovered data
const newPath = appendToName(filePath, '(recovered)');
await rename(filePath, newPath);
await dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath);
}
@@ -231,7 +232,7 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
}
const pathToRenamed = getPathToProject(newFilename);
await rename(projectFilePath, pathToRenamed);
await dockerSafeRename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile);
@@ -1,11 +1,16 @@
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
import { existsSync } from 'fs';
import { copyFile, readFile, rename, stat } from 'fs/promises';
import { copyFile, readFile, stat } from 'fs/promises';
import { extname, join } from 'path';
import { publicDir } from '../../setup/index.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import {
dockerSafeRename,
ensureDirectory,
getFilesFromFolder,
removeFileExtension,
} from '../../utils/fileManagement.js';
/**
* Handles the upload of a new project file
@@ -14,13 +19,13 @@ import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../
*/
export async function handleUploaded(filePath: string, name: string) {
const newFilePath = join(publicDir.projectsDir, name);
await rename(filePath, newFilePath);
await dockerSafeRename(filePath, newFilePath);
}
export async function handleImageUpload(filePath: string, name: string): Promise<string> {
ensureDirectory(publicDir.logoDir);
const newFilePath = join(publicDir.logoDir, name);
await rename(filePath, newFilePath);
await dockerSafeRename(filePath, newFilePath);
return name;
}
@@ -86,7 +91,7 @@ export async function copyCorruptFile(filePath: string, name: string): Promise<v
*/
export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(publicDir.corruptDir, name);
return rename(filePath, newPath);
return dockerSafeRename(filePath, newPath);
}
/**
+2
View File
@@ -77,6 +77,8 @@ export const srcDir = {
} as const;
export const srcFiles = {
/** Path to start index.html */
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
/** Path to bundled CSS */
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
/** Path to bundled external readme */
@@ -1,33 +0,0 @@
import { cleanURL } from '../url.js';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
const test = ' http://testing';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('has no trailing spaces', () => {
const test = 'http://testing ';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('doesnt contain spaces', () => {
const test = 'http://t e s t i n g';
const expected = 'http://t%20e%20s%20t%20i%20n%20g';
expect(cleanURL(test)).toBe(expected);
});
it('only contains allowed characters', () => {
const test = 'http://<>[]{}|^';
const expected = 'http://';
expect(cleanURL(test)).toBe(expected);
});
it('begins with http://', () => {
const test = 'ontime.com';
const expected = 'http://ontime.com';
expect(cleanURL(test)).toBe(expected);
});
});
+11 -2
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync } from 'fs';
import { readdir, copyFile } from 'fs/promises';
import { existsSync, mkdirSync, PathLike } from 'fs';
import { readdir, copyFile, unlink } from 'fs/promises';
import { basename, extname, join, parse } from 'path';
/**
@@ -105,3 +105,12 @@ export async function copyDirectory(src: string, dest: string) {
}
}
}
/**
* workaround avoids origin errors in docker deployments
* EXDEV cross-device link not permitted
*/
export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
await copyFile(oldPath, newPath);
await unlink(oldPath);
}
-20
View File
@@ -1,20 +0,0 @@
/**
* @description Cleans given url
* @param {string} url - URL to be checked
* @returns {string} Sanitized url
*/
export const cleanURL = (url: string): string => {
// trim whitespaces
let sanitised = url.trim();
// clear any whitespaces
sanitised = sanitised.split(' ').join('%20');
// contain only allowed characters
sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, '');
// starts with http://
if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`;
return sanitised;
};
-2
View File
@@ -1,5 +1,3 @@
version: "3"
services:
ontime:
container_name: ontime
+4
View File
@@ -4,6 +4,10 @@ const fileToUpload = 'e2e/tests/fixtures/test-db.json';
test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.9.1",
"version": "3.9.5",
"description": "Time keeping for live events",
"keywords": [
"ontime",