Compare commits

..

14 Commits

Author SHA1 Message Date
Carlos Valente 9c910c079d bump version to 3.10.4 2025-01-19 20:04:52 +01:00
Carlos Valente 420cef8b0d chore: add html files to docker 2025-01-19 19:57:10 +01:00
Carlos Valente b4c7b84f92 refactor: handle trailing slash in URL preset 2025-01-19 19:57:10 +01:00
Carlos Valente 0450eb9169 refactor: set token from query params 2025-01-19 19:57:10 +01:00
Carlos Valente 7fe5223af9 feat: add auth to server requests 2025-01-19 19:57:10 +01:00
arc-alex ce8d534953 feat: optional welcome modal
welcome modal is persisted in app state

created endpoints to set the visibility

added UI to support feature

Co-authored-by: Carlos Valente
<34649812+cpvalente@users.noreply.github.com>
2025-01-18 20:24:46 +01:00
Carlos Valente 4a6fbc2a92 refactor: add timezone data to session 2025-01-17 20:05:56 +01:00
Carlos Valente 50ebcf4e14 refactor: review welcome modal design 2025-01-17 20:04:00 +01:00
Carlos Valente f0f7d1cede docs: add homebrew links 2025-01-17 20:02:44 +01:00
Carlos Valente d2bdb1ea03 docs: add links to sponsor 2025-01-17 20:02:44 +01:00
Carlos Valente 9be6febcda chore: upgrade playwright 2025-01-17 19:57:01 +01:00
Carlos Valente aa929fd0dc refactor: create utility for nested templates 2025-01-12 10:20:24 +01:00
Carlos Valente 7baa6a4ab9 refactor: create mock data utilities 2025-01-12 10:20:24 +01:00
Carlos Valente 082fe8b8d7 chore: write feature spec 2025-01-12 10:14:44 +01:00
39 changed files with 561 additions and 101 deletions
+1
View File
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
COPY --from=builder /app/apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/ COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
+5
View File
@@ -2,6 +2,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime) ![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli) ![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente) [![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -12,7 +13,11 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat. We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.10.3", "version": "3.10.4",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.10.3", "version": "3.10.4",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+7
View File
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> { export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data); return axios.post(settingsPath, data);
} }
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
+8 -1
View File
@@ -28,6 +28,13 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
return { isValid: true, message: 'ok' }; return { isValid: true, message: 'ok' };
}; };
/**
* Utility removes trailing slash from a string
*/
function removeTrailingSlash(text: string): string {
return text.replace(/\/$/, '');
}
/** /**
* Gets the URL to send a preset to * Gets the URL to send a preset to
* @param location * @param location
@@ -38,7 +45,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
const currentURL = location.pathname.substring(1); const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect // we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0]; const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) { if (foundPreset) {
return generateUrlFromPreset(foundPreset); return generateUrlFromPreset(foundPreset);
} }
+3
View File
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/'; export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment // resolve environment
export const appVersion = version; export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
@@ -1,5 +1,12 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals'; import {
buyMeACoffeeUrl,
discordUrl,
documentationUrl,
githubSponsorUrl,
githubUrl,
websiteUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion'; import AppVersion from './AppVersion';
@@ -9,22 +16,29 @@ export default function AboutPanel() {
<> <>
<Panel.Header>About Ontime</Panel.Header> <Panel.Header>About Ontime</Panel.Header>
<Panel.Section> <Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader> <Panel.Card>
<Panel.Paragraph> <Panel.SubHeader>Ontime</Panel.SubHeader>
Free, open-source software for managing rundowns and event timers <Panel.Paragraph>
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink> Free, open-source software for managing rundowns and event timers
</Panel.Paragraph> <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Section> </Panel.Paragraph>
<Panel.Section> <Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
And trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader> <Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink> <ExternalLink href={discordUrl}>Discord server</ExternalLink>
</Panel.Section> </Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
</Panel.Section>
</> </>
); );
} }
@@ -24,6 +24,10 @@
display: flex; display: flex;
gap: 1rem; gap: 1rem;
justify-content: end; justify-content: end;
:first-child {
margin-right: auto;
}
} }
.tableContainer { .tableContainer {
@@ -38,14 +42,20 @@
tbody { tbody {
background-color: $gray-1300; background-color: $gray-1300;
tr { tr {
&:has(:hover) { &:hover:not(.current) {
background-color: $gray-1350; background-color: $gray-1350;
} }
.current {
&:hover {
background-color: $blue-900;
}
}
} }
} }
tr { tr {
height: 2rem; height: 2rem;
cursor: pointer;
} }
th, th,
@@ -62,4 +72,8 @@
.current { .current {
background-color: $blue-700; background-color: $blue-700;
&:hover {
background-color: $blue-900;
}
} }
@@ -1,10 +1,12 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react'; import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db'; import { loadDemo, loadProject } from '../../../common/api/db';
import { postShowWelcomeDialog } from '../../../common/api/settings';
import { invalidateAllCaches } from '../../../common/api/utils'; import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../common/components/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals'; import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import * as Editor from '../editor-utils/EditorUtils';
import ImportProjectButton from './composite/ImportProjectButton'; import ImportProjectButton from './composite/ImportProjectButton';
import WelcomeProjectList from './composite/WelcomeProjectList'; import WelcomeProjectList from './composite/WelcomeProjectList';
@@ -68,30 +70,37 @@ export default function Welcome(props: WelcomeProps) {
</div> </div>
<div className={style.column}> <div className={style.column}>
<div className={style.header}>Welcome to Ontime</div> <div className={style.header}>Welcome to Ontime</div>
<Editor.Title>Select project</Editor.Title>
<div className={style.tableContainer}> <div className={style.tableContainer}>
<table className={style.table}> <table className={style.table}>
<thead> <thead>
<tr> <tr>
<th>Project Name</th> <th>Project Name</th>
<th>Last Used</th> <th>Last Used</th>
<th />
</tr> </tr>
</thead> </thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} /> <WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table> </table>
</div> </div>
<div className={style.buttonRow}>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
</div> </div>
</div> </div>
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
<Checkbox
size='sm'
variant='ontime-ondark'
checked
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
>
Show this modal on next startup
</Checkbox>
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -1,5 +1,3 @@
import { Button } from '@chakra-ui/react';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList'; import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import style from '../Welcome.module.scss'; import style from '../Welcome.module.scss';
@@ -18,26 +16,16 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
{data.reorderedProjectFiles.map((project) => { {data.reorderedProjectFiles.map((project) => {
if (project.filename === data.lastLoadedProject) { if (project.filename === data.lastLoadedProject) {
return ( return (
<tr className={style.current} key={project.filename}> <tr className={style.current} key={project.filename} onClick={onClose}>
<td>{project.filename}</td> <td>{project.filename}</td>
<td>Loaded from last session</td> <td>Loaded from last session</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Continue
</Button>
</td>
</tr> </tr>
); );
} }
return ( return (
<tr key={project.filename}> <tr key={project.filename} onClick={() => loadProject(project.filename)}>
<td>{project.filename}</td> <td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td> <td>{new Date(project.updatedAt).toLocaleString()}</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
Load
</Button>
</td>
</tr> </tr>
); );
})} })}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "3.10.3", "version": "3.10.4",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+3 -1
View File
@@ -2,10 +2,12 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.10.3", "version": "3.10.4",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"express": "^4.21.1", "express": "^4.21.1",
+12 -5
View File
@@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js'; import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null; let instance: SocketServer | null = null;
@@ -34,7 +35,7 @@ export class SocketServer implements IAdapter {
private wss: WebSocketServer | null; private wss: WebSocketServer | null;
private readonly clients: Map<string, Client>; private readonly clients: Map<string, Client>;
private lastConnection: Date | null = null; private lastConnection: Date | null = null;
private isFirstEditor = true; private shouldShowWelcome = true;
constructor() { constructor() {
if (instance) { if (instance) {
@@ -47,10 +48,16 @@ export class SocketServer implements IAdapter {
this.wss = null; this.wss = null;
} }
init(server: Server, prefix?: string) { init(server: Server, showWelcome: boolean, prefix?: string) {
this.shouldShowWelcome = showWelcome;
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD }); this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => { this.wss.on('connection', (ws, req) => {
authenticateSocket(ws, req, (error) => {
if (error) {
ws.close(1008, 'Unauthorized');
}
});
const clientId = generateId(); const clientId = generateId();
this.clients.set(clientId, { this.clients.set(clientId, {
@@ -136,8 +143,8 @@ export class SocketServer implements IAdapter {
previousData.path = payload; previousData.path = payload;
this.clients.set(clientId, previousData); this.clients.set(clientId, previousData);
if (payload.includes('editor') && this.isFirstEditor) { if (payload.includes('editor') && this.shouldShowWelcome) {
this.isFirstEditor = false; this.shouldShowWelcome = false;
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: 'dialog', type: 'dialog',
@@ -7,6 +7,7 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js'; import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js'; import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js';
const startedAt = new Date(); const startedAt = new Date();
@@ -24,7 +25,7 @@ export async function getSessionStats(): Promise<SessionStats> {
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null, lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
projectName, projectName,
playback, playback,
timezone: startedAt.getTimezoneOffset(), timezone: getTimezoneLabel(startedAt),
}; };
} }
@@ -6,6 +6,7 @@ import type { Request, Response } from 'express';
import { isDocker } from '../../externals.js'; import { isDocker } from '../../externals.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { extractPin } from './settings.utils.js'; import { extractPin } from './settings.utils.js';
@@ -65,3 +66,8 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
res.status(400).send({ message }); res.status(400).send({ message });
} }
} }
export async function postWelcomeDialog(req: Request, res: Response) {
const show = await appState.setShowWelcomeDialog(req.body.show);
res.status(200).send({ show });
}
@@ -1,8 +1,10 @@
import express from 'express'; import express from 'express';
import { getSettings, postSettings } from './settings.controller.js'; import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
import { validateSettings } from './settings.validation.js'; import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
export const router = express.Router(); export const router = express.Router();
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
router.get('/', getSettings); router.get('/', getSettings);
router.post('/', validateSettings, postSettings); router.post('/', validateSettings, postSettings);
@@ -1,6 +1,18 @@
import { body, validationResult } from 'express-validator'; import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings/welcomedialog
*/
export const validateWelcomeDialog = [
body('show').exists().isBoolean(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/** /**
* @description Validates object for POST /ontime/settings * @description Validates object for POST /ontime/settings
*/ */
+22 -19
View File
@@ -5,16 +5,18 @@ import express from 'express';
import http, { Server } from 'http'; import http, { Server } from 'http';
import cors from 'cors'; import cors from 'cors';
import serverTiming from 'server-timing'; import serverTiming from 'server-timing';
import cookieParser from 'cookie-parser';
// import utils
import { publicDir, srcDir } 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';
// Import middleware configuration // Import middleware configuration
import { bodyParser } from './middleware/bodyParser.js'; import { bodyParser } from './middleware/bodyParser.js';
import { compressedStatic } from './middleware/staticGZip.js'; import { compressedStatic } from './middleware/staticGZip.js';
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.js';
// import utils
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';
// Import Routers // Import Routers
import { appRouter } from './api-data/index.js'; import { appRouter } from './api-data/index.js';
@@ -44,6 +46,7 @@ import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js'; import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './config/config.js'; import { timerConfig } from './config/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js'; import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
console.log('\n'); console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`); consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -72,19 +75,18 @@ if (!isProduction) {
} }
app.disable('x-powered-by'); app.disable('x-powered-by');
// setup cors for all routes
app.use(cors());
// enable pre-flight cors
app.options('*', cors());
// Implement middleware // Implement middleware
app.use(cors()); // setup cors for all routes
app.options('*', cors()); // enable pre-flight cors
app.use(bodyParser); app.use(bodyParser);
app.use(prefix, compressedStatic); app.use(cookieParser());
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
// Implement route endpoints // Implement route endpoints
app.use(`${prefix}/data`, appRouter); // router for application data app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/api`, integrationRouter); // router for integrations app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
// serve static external files // serve static external files
app.use(`${prefix}/external`, express.static(publicDir.externalDir)); app.use(`${prefix}/external`, express.static(publicDir.externalDir));
@@ -94,9 +96,9 @@ app.use(`${prefix}/external`, (req, res) => {
}); });
app.use(`${prefix}/user`, express.static(publicDir.userDir)); app.use(`${prefix}/user`, express.static(publicDir.userDir));
app.get(`${prefix}/*`, (_req, res) => { // Base route for static files
res.sendFile(srcFiles.clientIndexHtml); app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
}); app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
// Implement catch all // Implement catch all
app.use((_error, response) => { app.use((_error, response) => {
@@ -166,8 +168,9 @@ export const startServer = async (
// the express server must be started before the socket otherwise the on error event listener will not attach properly // the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort); const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort }); await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog();
socket.init(expressServer, prefix); socket.init(expressServer, showWelcome, prefix);
/** /**
* Module initialises the services and provides initial payload for the store * Module initialises the services and provides initial payload for the store
+2
View File
@@ -15,6 +15,8 @@ export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker'; export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest); export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD); export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const password = process.env.SESSION_PASSWORD;
/** /**
* Updates the router prefix in the index.html file * 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 * This is only needed in the cloud environment where the client is not at the root segment
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<meta name="description" content="Login page for Ontime application" />
<title>Ontime Login</title>
<style>
body,
html {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana,
sans-serif;
background: #101010;
text-align: center;
box-sizing: border-box;
}
h1 {
padding-top: 30vh;
font-size: 3rem;
color: #ff7597;
font-weight: 400;
}
input {
all: unset;
text-align: start;
padding-left: 0.5rem;
background-color: #fffffa;
color: black;
height: 2rem;
border-radius: 2px;
margin-right: 0.5rem;
}
input:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
color: #262626;
}
button {
all: unset;
color: #779be7;
background: #2d2d2d;
height: 2rem;
padding-inline: 2rem;
border-radius: 2px;
cursor: pointer;
}
button:hover {
background: #404040;
}
button:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
}
</style>
</head>
<body>
<form method="post" class="form" autocomplete="off">
<h1>Welcome to Ontime</h1>
<input type="hidden" name="redirect" value="" id="redirect" />
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button type="submit">Login</button>
</form>
</body>
<script>
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect');
if (redirect) {
// Validate redirect URL to prevent open redirect attacks
const isValidRedirect = redirect.startsWith('/') && !redirect.startsWith('//');
if (isValidRedirect) {
document.getElementById('redirect').value = redirect;
}
}
</script>
</html>
+131
View File
@@ -0,0 +1,131 @@
import { LogOrigin } from 'ontime-types';
import express, { type Request, type Response, type NextFunction } from 'express';
import type { IncomingMessage } from 'node:http';
import type { WebSocket } from 'ws';
import { parse as parseCookie } from 'cookie';
import { hashPassword } from '../utils/hash.js';
import { srcFiles } from '../setup/index.js';
import { logger } from '../classes/Logger.js';
import { password } from '../externals.js';
import { noopMiddleware } from './noop.js';
export const hasPassword = Boolean(password);
const hashedPassword = hasPassword ? hashPassword(password) : '';
/**
* List of public assets that can be accessed without authentication
* should match the files in client/public
*/
const publicAssets = new Set([
'/favicon.ico',
'/manifest.json',
'/ontime-logo.png',
'/robots.txt',
'/site.webmanifest',
]);
export const loginRouter = express.Router();
// serve static files at root
loginRouter.use('/', express.static(srcFiles.login));
// verify password and set cookies + redirect appropriately
loginRouter.post('/', (req, res) => {
res.clearCookie('token');
const { password: reqPassword, redirect } = req.body;
if (hashPassword(reqPassword) === hashedPassword) {
setSessionCookie(res, hashedPassword);
res.redirect(redirect || '/');
return;
}
res.status(401).send('Unauthorized');
});
/**
* Express middleware to authenticate requests
* @param {string} prefix - Prefix is used for the client hashes in Ontime Cloud
*/
export function makeAuthenticateMiddleware(prefix: string) {
// we dont need to initialise the authenticate middleware if there is no password
if (!hasPassword) {
return { authenticate: noopMiddleware, authenticateAndRedirect: noopMiddleware };
}
function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.query.token || req.cookies?.token;
if (token && token === hashedPassword) {
return next();
}
res.status(401).send('Unauthorized');
}
function authenticateAndRedirect(req: Request, res: Response, next: NextFunction) {
// Allow access to specific public assets without authentication
if (publicAssets.has(req.originalUrl)) {
return next();
}
// we shouldnt be here in the login route
if (req.originalUrl.startsWith('/login')) {
return next();
}
// we expect the token to be in the cookies
if (req.cookies?.token === hashedPassword) {
return next();
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) {
setSessionCookie(res, hashedPassword);
return next();
}
res.redirect(`${prefix}/login?redirect=${req.originalUrl}`);
}
return { authenticate, authenticateAndRedirect };
}
/**
* Middleware to authenticate a WebSocket connection with a token in the cookie
*/
export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (error?: Error) => void) {
if (!hasPassword) {
return next();
}
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token === hashedPassword) {
return next();
}
}
// check if token is in the params
const url = new URL(req.url || '', `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
logger.warning(LogOrigin.Client, 'Unauthorized WebSocket connection attempt');
return next(new Error('Unauthorized'));
}
function setSessionCookie(res: Response, token: string) {
res.cookie('token', token, {
httpOnly: false, // allow websocket to access cookie
secure: true,
path: '/', // allow cookie to be accessed from any path
sameSite: 'strict',
});
}
+5
View File
@@ -0,0 +1,5 @@
import type { Request, Response, NextFunction } from 'express';
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
next();
}
@@ -8,6 +8,7 @@ import { shouldCrashDev } from '../../utils/development.js';
interface AppState { interface AppState {
lastLoadedProject?: string; lastLoadedProject?: string;
showWelcomeDialog?: boolean;
} }
const adapter = new JSONFile<AppState>(publicFiles.appState); const adapter = new JSONFile<AppState>(publicFiles.appState);
@@ -34,3 +35,19 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
config.data.lastLoadedProject = filename; config.data.lastLoadedProject = filename;
await config.write(); await config.write();
} }
export async function getShowWelcomeDialog(): Promise<boolean> {
// in test environment, we do not want the dialog
if (isTest) return false;
await config.read();
return config.data.showWelcomeDialog ?? true; // default to true
}
export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
if (isTest) return;
config.data.showWelcomeDialog = show;
await config.write();
return show;
}
@@ -3,6 +3,7 @@ import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
const baseEvent = { const baseEvent = {
type: SupportedEvent.Event, type: SupportedEvent.Event,
skip: false, skip: false,
revision: 1,
}; };
/** /**
+2
View File
@@ -87,6 +87,8 @@ export const srcFiles = {
userReadme: join(srcDir.root, config.user, 'README.md'), userReadme: join(srcDir.root, config.user, 'README.md'),
/** Path to bundled CSS readme */ /** Path to bundled CSS readme */
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'), cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
}; };
/** /**
+9
View File
@@ -0,0 +1,9 @@
import { createHash } from 'node:crypto';
/**
* Creates a hash of the password that is URL safe
* @link https://stackoverflow.com/questions/17639645/websafe-encoding-of-hashed-string-in-nodejs
*/
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('base64url');
}
+16 -2
View File
@@ -1,5 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
export const timeFormat = 'HH:mm'; export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss'; export const timeFormatSeconds = 'HH:mm:ss';
@@ -46,3 +45,18 @@ export function parseExcelDate(excelDate: unknown): number {
return 0; return 0;
} }
export function getTimezoneLabel(date: Date): string {
const tz = date.getTimezoneOffset();
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
// timezone offset is inverted
const sign = tz < 0 ? '+' : '-';
const abs = Math.abs(tz);
// convert minutes to hours
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`;
}
+57
View File
@@ -0,0 +1,57 @@
# Automations
The automation feature's purpose is to integrate ontime into users' workflows.
Ontime has a large amount of production information, which users need considerable effort to maintain. We want to allow tools so that:
- allow distribution of Ontime's and other production data
- allow surfacing Ontime events
- allow synchronizing with other tools
## Previous
Previous iterations imposed limitations on the number of integrations and target devices to evaluate performance concerns.
The feature was not as used as we had hoped, and users often escalated the integration to tools like Companion.
I believe this to be in part from a lack of clarity on the feature and the limitations imposed
- Lack of explicit filtering logic made the process hard to reason
- Users met limitations on target devices earlier than expected. We would want users to meet these limitations only when the project grew over a size where a show controller would be needed
- Building the template strings was complex and poorly documented
## Overview
- The user should be able to create as many automations as they want
- Automations are triggered by lifecycle events
- Each automation should go through a user-defined filtering process
- Each automation should be able to target multiple devices and multiple protocols
- We leverage HTTP / OSC as the main protocols ~~and add support for triggering Companion button presses~~
- To allow easier debugging and "learn" workflows, users should be able to test a message before saving the automation
- We should have inline documentation for the template strings
> ⚠️ **Companion module** \
> We have opted for removing the requirement for companion module integration. \
> We did not see as much friction that could be solved with a custom integration as initially thought. Triggering a button press in companion is done trivially over either OSC or HTTP and companion users are familiar with the process.
## Implementation details
- To simplify the usage of template strings, we will generate a list of template strings at runtime from the user project file
- To allow easier implementation and extensions, we want to keep the automations separate from triggers
```ts
type Automation = {
id: AutomationId;
name: string;
filter: Filter[];
output: Output[];
};
type Trigger = {
id: string;
event: TimerLifecycle;
automations: AutomationId[];
};
```
### Extension
- Users have expressed a desire to have automation triggered by the lifecycle of a specific event. By keeping the trigger separate from the automation, we will allow this to be implemented in the future.
-6
View File
@@ -8,9 +8,6 @@ const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
test('project file upload', async ({ page }) => { test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); 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: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
@@ -41,9 +38,6 @@ test('project file upload', async ({ page }) => {
test('project file download', async ({ page }) => { test('project file download', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); 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: 'toggle settings' }).click(); await page.getByRole('button', { name: 'toggle settings' }).click();
await page.getByRole('button', { name: 'Project', exact: true }).click(); await page.getByRole('button', { name: 'Project', exact: true }).click();
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.10.3", "version": "3.10.4",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
@@ -38,7 +38,7 @@
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules" "cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.42.1", "@playwright/test": "^1.49.1",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
@@ -15,7 +15,7 @@ export interface SessionStats {
lastRequest: MaybeString; lastRequest: MaybeString;
projectName: string; projectName: string;
playback: Playback; playback: Playback;
timezone: number; timezone: string;
} }
export interface GetInfo { export interface GetInfo {
+5 -2
View File
@@ -44,10 +44,11 @@ export {
millisToSeconds, millisToSeconds,
secondsInMillis, secondsInMillis,
} from './src/date-utils/conversionUtils.js'; } from './src/date-utils/conversionUtils.js';
export { isTimeString } from './src/date-utils/isTimeString.js'; export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
export { export {
formatFromMillis, formatFromMillis,
millisToString, millisToString,
pad,
removeLeadingZero, removeLeadingZero,
removeSeconds, removeSeconds,
removeTrailingZero, removeTrailingZero,
@@ -63,7 +64,9 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
export { deepmerge } from './src/externals/deepmerge.js'; export { deepmerge } from './src/externals/deepmerge.js';
// array utils // array utils
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/array-utils/arrayUtils.js'; export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
// object utils
export { getPropertyFromPath } from './src/common/objectUtils.js';
// generic utilities // generic utilities
export { getErrorMessage } from './src/generic/generic.js'; export { getErrorMessage } from './src/generic/generic.js';
+18
View File
@@ -0,0 +1,18 @@
/**
* Extracts a value from a nested object using a dot-separated path
*/
export function getPropertyFromPath<T extends object>(path: string, obj: T): any | undefined {
const keys = path.split('.');
let result: any = obj;
// iterate through variable parts, and look for the property in the given object
for (const key of keys) {
if (result && typeof result === 'object' && key in result) {
result = result[key];
} else {
return undefined;
}
}
return result;
}
@@ -0,0 +1,17 @@
import { getPropertyFromPath } from './objectUtils';
describe('getNestedFromTemplate', () => {
it('should return the value of a nested property', () => {
expect(getPropertyFromPath('a', { a: 1 })).toBe(1);
expect(getPropertyFromPath('a.b', { a: { b: 1 } })).toBe(1);
expect(getPropertyFromPath('a.b.c', { a: { b: { c: 1 } } })).toBe(1);
});
it('should guard against values that do not exist', () => {
expect(getPropertyFromPath('c', {})).toBe(undefined);
expect(getPropertyFromPath('c', { a: 1 })).toBe(undefined);
expect(getPropertyFromPath('a.c', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('c.a', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('a.b.b', { a: { b: { c: 1 } } })).toBeUndefined();
});
});
@@ -2,7 +2,7 @@ import type { MaybeNumber } from 'ontime-types';
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js'; import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
function pad(val: number): string { export function pad(val: number): string {
return String(val).padStart(2, '0'); return String(val).padStart(2, '0');
} }
+43 -16
View File
@@ -39,8 +39,8 @@ importers:
.: .:
devDependencies: devDependencies:
'@playwright/test': '@playwright/test':
specifier: ^1.42.1 specifier: ^1.49.1
version: 1.42.1 version: 1.49.1
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 20.14.10 version: 20.14.10
@@ -288,6 +288,12 @@ importers:
'@googleapis/sheets': '@googleapis/sheets':
specifier: ^5.0.5 specifier: ^5.0.5
version: 5.0.5 version: 5.0.5
cookie:
specifier: ^1.0.2
version: 1.0.2
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors: cors:
specifier: ^2.8.5 specifier: ^2.8.5
version: 2.8.5 version: 2.8.5
@@ -1680,9 +1686,9 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.42.1': '@playwright/test@1.49.1':
resolution: {integrity: sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==} resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==}
engines: {node: '>=16'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@popperjs/core@2.11.8': '@popperjs/core@2.11.8':
@@ -2717,6 +2723,10 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6: cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
@@ -2724,6 +2734,14 @@ packages:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
copy-to-clipboard@3.3.3: copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
@@ -4196,14 +4214,14 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
playwright-core@1.42.1: playwright-core@1.49.1:
resolution: {integrity: sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==} resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==}
engines: {node: '>=16'} engines: {node: '>=18'}
hasBin: true hasBin: true
playwright@1.42.1: playwright@1.49.1:
resolution: {integrity: sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==} resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==}
engines: {node: '>=16'} engines: {node: '>=18'}
hasBin: true hasBin: true
plist@3.1.0: plist@3.1.0:
@@ -6597,9 +6615,9 @@ snapshots:
'@pkgr/core@0.1.1': {} '@pkgr/core@0.1.1': {}
'@playwright/test@1.42.1': '@playwright/test@1.49.1':
dependencies: dependencies:
playwright: 1.42.1 playwright: 1.49.1
'@popperjs/core@2.11.8': {} '@popperjs/core@2.11.8': {}
@@ -7799,10 +7817,19 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {} cookie-signature@1.0.6: {}
cookie@0.7.1: {} cookie@0.7.1: {}
cookie@0.7.2: {}
cookie@1.0.2: {}
copy-to-clipboard@3.3.3: copy-to-clipboard@3.3.3:
dependencies: dependencies:
toggle-selection: 1.0.6 toggle-selection: 1.0.6
@@ -9494,11 +9521,11 @@ snapshots:
picomatch@2.3.1: {} picomatch@2.3.1: {}
playwright-core@1.42.1: {} playwright-core@1.49.1: {}
playwright@1.42.1: playwright@1.49.1:
dependencies: dependencies:
playwright-core: 1.42.1 playwright-core: 1.49.1
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2