mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9c2a9cfd |
@@ -24,7 +24,6 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
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/html/ ./html/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
@@ -13,11 +12,7 @@
|
||||
- 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 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>
|
||||
- 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?
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.10.4",
|
||||
"version": "3.10.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.10.4",
|
||||
"version": "3.10.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -19,10 +19,3 @@ export async function getSettings(): Promise<Settings> {
|
||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -28,13 +28,6 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
|
||||
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
|
||||
* @param location
|
||||
@@ -45,7 +38,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
|
||||
const currentURL = location.pathname.substring(1);
|
||||
|
||||
// we need to check if the whole url here is an alias, so we can redirect
|
||||
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
|
||||
if (foundPreset) {
|
||||
return generateUrlFromPreset(foundPreset);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
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
|
||||
export const appVersion = version;
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import {
|
||||
buyMeACoffeeUrl,
|
||||
discordUrl,
|
||||
documentationUrl,
|
||||
githubSponsorUrl,
|
||||
githubUrl,
|
||||
websiteUrl,
|
||||
} from '../../../../externals';
|
||||
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AppVersion from './AppVersion';
|
||||
@@ -16,29 +9,22 @@ export default function AboutPanel() {
|
||||
<>
|
||||
<Panel.Header>About Ontime</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||
<Panel.Paragraph>
|
||||
Free, open-source software for managing rundowns and event timers
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<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>Ontime</Panel.SubHeader>
|
||||
<Panel.Paragraph>
|
||||
Free, open-source software for managing rundowns and event timers
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<AppVersion />
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,6 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: end;
|
||||
|
||||
:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
@@ -42,20 +38,14 @@
|
||||
tbody {
|
||||
background-color: $gray-1300;
|
||||
tr {
|
||||
&:hover:not(.current) {
|
||||
&:has(:hover) {
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
.current {
|
||||
&:hover {
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -72,8 +62,4 @@
|
||||
|
||||
.current {
|
||||
background-color: $blue-700;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||
|
||||
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
||||
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||
import * as Editor from '../editor-utils/EditorUtils';
|
||||
|
||||
import ImportProjectButton from './composite/ImportProjectButton';
|
||||
import WelcomeProjectList from './composite/WelcomeProjectList';
|
||||
@@ -70,37 +68,30 @@ export default function Welcome(props: WelcomeProps) {
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<div className={style.header}>Welcome to Ontime</div>
|
||||
<Editor.Title>Select project</Editor.Title>
|
||||
<div className={style.tableContainer}>
|
||||
<table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project Name</th>
|
||||
<th>Last Used</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
||||
</table>
|
||||
</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 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>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||
|
||||
import style from '../Welcome.module.scss';
|
||||
@@ -16,16 +18,26 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
|
||||
{data.reorderedProjectFiles.map((project) => {
|
||||
if (project.filename === data.lastLoadedProject) {
|
||||
return (
|
||||
<tr className={style.current} key={project.filename} onClick={onClose}>
|
||||
<tr className={style.current} key={project.filename}>
|
||||
<td>{project.filename}</td>
|
||||
<td>Loaded from last session</td>
|
||||
<td>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
||||
Continue
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
|
||||
<tr key={project.filename}>
|
||||
<td>{project.filename}</td>
|
||||
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
||||
<td>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
|
||||
Load
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "3.10.4",
|
||||
"version": "3.10.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.10.4",
|
||||
"version": "3.10.3",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.21.1",
|
||||
|
||||
@@ -25,7 +25,6 @@ import { eventStore } from '../stores/EventStore.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||
|
||||
let instance: SocketServer | null = null;
|
||||
|
||||
@@ -35,7 +34,7 @@ export class SocketServer implements IAdapter {
|
||||
private wss: WebSocketServer | null;
|
||||
private readonly clients: Map<string, Client>;
|
||||
private lastConnection: Date | null = null;
|
||||
private shouldShowWelcome = true;
|
||||
private isFirstEditor = true;
|
||||
|
||||
constructor() {
|
||||
if (instance) {
|
||||
@@ -48,16 +47,10 @@ export class SocketServer implements IAdapter {
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
init(server: Server, showWelcome: boolean, prefix?: string) {
|
||||
this.shouldShowWelcome = showWelcome;
|
||||
init(server: Server, prefix?: string) {
|
||||
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
|
||||
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
authenticateSocket(ws, req, (error) => {
|
||||
if (error) {
|
||||
ws.close(1008, 'Unauthorized');
|
||||
}
|
||||
});
|
||||
this.wss.on('connection', (ws) => {
|
||||
const clientId = generateId();
|
||||
|
||||
this.clients.set(clientId, {
|
||||
@@ -143,8 +136,8 @@ export class SocketServer implements IAdapter {
|
||||
previousData.path = payload;
|
||||
this.clients.set(clientId, previousData);
|
||||
|
||||
if (payload.includes('editor') && this.shouldShowWelcome) {
|
||||
this.shouldShowWelcome = false;
|
||||
if (payload.includes('editor') && this.isFirstEditor) {
|
||||
this.isFirstEditor = false;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
|
||||
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||
import { getTimezoneLabel } from '../../utils/time.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
@@ -25,7 +24,7 @@ export async function getSessionStats(): Promise<SessionStats> {
|
||||
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
|
||||
projectName,
|
||||
playback,
|
||||
timezone: getTimezoneLabel(startedAt),
|
||||
timezone: startedAt.getTimezoneOffset(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Request, Response } from 'express';
|
||||
import { isDocker } from '../../externals.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.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';
|
||||
|
||||
@@ -66,8 +65,3 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
|
||||
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,10 +1,8 @@
|
||||
import express from 'express';
|
||||
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
|
||||
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
||||
import { getSettings, postSettings } from './settings.controller.js';
|
||||
import { validateSettings } from './settings.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
|
||||
|
||||
router.get('/', getSettings);
|
||||
router.post('/', validateSettings, postSettings);
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { body, validationResult } from 'express-validator';
|
||||
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
|
||||
*/
|
||||
|
||||
+19
-22
@@ -5,18 +5,16 @@ import express from 'express';
|
||||
import http, { Server } from 'http';
|
||||
import cors from 'cors';
|
||||
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 { bodyParser } from './middleware/bodyParser.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 { appRouter } from './api-data/index.js';
|
||||
@@ -46,7 +44,6 @@ import { clearUploadfolder } from './utils/upload.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
import { timerConfig } from './config/config.js';
|
||||
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
||||
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
@@ -75,18 +72,19 @@ if (!isProduction) {
|
||||
}
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Implement middleware
|
||||
app.use(cors()); // setup cors for all routes
|
||||
app.options('*', cors()); // enable pre-flight cors
|
||||
// setup cors for all routes
|
||||
app.use(cors());
|
||||
|
||||
// enable pre-flight cors
|
||||
app.options('*', cors());
|
||||
|
||||
// Implement middleware
|
||||
app.use(bodyParser);
|
||||
app.use(cookieParser());
|
||||
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
|
||||
app.use(prefix, compressedStatic);
|
||||
|
||||
// Implement route endpoints
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, 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(`${prefix}/external`, express.static(publicDir.externalDir));
|
||||
@@ -96,9 +94,9 @@ app.use(`${prefix}/external`, (req, res) => {
|
||||
});
|
||||
app.use(`${prefix}/user`, express.static(publicDir.userDir));
|
||||
|
||||
// Base route for static files
|
||||
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
|
||||
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
|
||||
app.get(`${prefix}/*`, (_req, res) => {
|
||||
res.sendFile(srcFiles.clientIndexHtml);
|
||||
});
|
||||
|
||||
// Implement catch all
|
||||
app.use((_error, response) => {
|
||||
@@ -168,9 +166,8 @@ export const startServer = async (
|
||||
// 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);
|
||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||
const showWelcome = await getShowWelcomeDialog();
|
||||
|
||||
socket.init(expressServer, showWelcome, prefix);
|
||||
socket.init(expressServer, prefix);
|
||||
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
|
||||
@@ -15,8 +15,6 @@ 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);
|
||||
export const password = process.env.SESSION_PASSWORD;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,131 +0,0 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
|
||||
next();
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { shouldCrashDev } from '../../utils/development.js';
|
||||
|
||||
interface AppState {
|
||||
lastLoadedProject?: string;
|
||||
showWelcomeDialog?: boolean;
|
||||
}
|
||||
|
||||
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
||||
@@ -35,19 +34,3 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
|
||||
config.data.lastLoadedProject = filename;
|
||||
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,7 +3,6 @@ import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
|
||||
const baseEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
revision: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,8 +87,6 @@ export const srcFiles = {
|
||||
userReadme: join(srcDir.root, config.user, 'README.md'),
|
||||
/** Path to bundled CSS readme */
|
||||
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
||||
/** Path to login */
|
||||
login: join(srcDir.root, 'html/login.html'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
|
||||
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
|
||||
|
||||
export const timeFormat = 'HH:mm';
|
||||
export const timeFormatSeconds = 'HH:mm:ss';
|
||||
@@ -45,18 +46,3 @@ export function parseExcelDate(excelDate: unknown): number {
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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.
|
||||
@@ -8,6 +8,9 @@ const fileToDownload = 'e2e/tests/fixtures/tmp/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();
|
||||
@@ -38,6 +41,9 @@ test('project file upload', async ({ page }) => {
|
||||
test('project file download', 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: 'toggle settings' }).click();
|
||||
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.10.4",
|
||||
"version": "3.10.3",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -38,7 +38,7 @@
|
||||
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SessionStats {
|
||||
lastRequest: MaybeString;
|
||||
projectName: string;
|
||||
playback: Playback;
|
||||
timezone: string;
|
||||
timezone: number;
|
||||
}
|
||||
|
||||
export interface GetInfo {
|
||||
|
||||
@@ -44,11 +44,10 @@ export {
|
||||
millisToSeconds,
|
||||
secondsInMillis,
|
||||
} from './src/date-utils/conversionUtils.js';
|
||||
export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
|
||||
export { isTimeString } from './src/date-utils/isTimeString.js';
|
||||
export {
|
||||
formatFromMillis,
|
||||
millisToString,
|
||||
pad,
|
||||
removeLeadingZero,
|
||||
removeSeconds,
|
||||
removeTrailingZero,
|
||||
@@ -64,9 +63,7 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
|
||||
export { deepmerge } from './src/externals/deepmerge.js';
|
||||
|
||||
// array utils
|
||||
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
|
||||
// object utils
|
||||
export { getPropertyFromPath } from './src/common/objectUtils.js';
|
||||
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/array-utils/arrayUtils.js';
|
||||
|
||||
// generic utilities
|
||||
export { getErrorMessage } from './src/generic/generic.js';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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';
|
||||
|
||||
export function pad(val: number): string {
|
||||
function pad(val: number): string {
|
||||
return String(val).padStart(2, '0');
|
||||
}
|
||||
|
||||
|
||||
Generated
+16
-43
@@ -39,8 +39,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.49.1
|
||||
version: 1.49.1
|
||||
specifier: ^1.42.1
|
||||
version: 1.42.1
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 20.14.10
|
||||
@@ -288,12 +288,6 @@ importers:
|
||||
'@googleapis/sheets':
|
||||
specifier: ^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:
|
||||
specifier: ^2.8.5
|
||||
version: 2.8.5
|
||||
@@ -1686,9 +1680,9 @@ packages:
|
||||
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@playwright/test@1.49.1':
|
||||
resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==}
|
||||
engines: {node: '>=18'}
|
||||
'@playwright/test@1.42.1':
|
||||
resolution: {integrity: sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
'@popperjs/core@2.11.8':
|
||||
@@ -2723,10 +2717,6 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||
|
||||
@@ -2734,14 +2724,6 @@ packages:
|
||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
||||
|
||||
@@ -4214,14 +4196,14 @@ packages:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
playwright-core@1.49.1:
|
||||
resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==}
|
||||
engines: {node: '>=18'}
|
||||
playwright-core@1.42.1:
|
||||
resolution: {integrity: sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.49.1:
|
||||
resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==}
|
||||
engines: {node: '>=18'}
|
||||
playwright@1.42.1:
|
||||
resolution: {integrity: sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
plist@3.1.0:
|
||||
@@ -6615,9 +6597,9 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.1.1': {}
|
||||
|
||||
'@playwright/test@1.49.1':
|
||||
'@playwright/test@1.42.1':
|
||||
dependencies:
|
||||
playwright: 1.49.1
|
||||
playwright: 1.42.1
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
@@ -7817,19 +7799,10 @@ snapshots:
|
||||
|
||||
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@0.7.1: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.0.2: {}
|
||||
|
||||
copy-to-clipboard@3.3.3:
|
||||
dependencies:
|
||||
toggle-selection: 1.0.6
|
||||
@@ -9521,11 +9494,11 @@ snapshots:
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
playwright-core@1.49.1: {}
|
||||
playwright-core@1.42.1: {}
|
||||
|
||||
playwright@1.49.1:
|
||||
playwright@1.42.1:
|
||||
dependencies:
|
||||
playwright-core: 1.49.1
|
||||
playwright-core: 1.42.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user