diff --git a/apps/client/src/common/api/session.ts b/apps/client/src/common/api/session.ts index 898bc50b8..2449cf18e 100644 --- a/apps/client/src/common/api/session.ts +++ b/apps/client/src/common/api/session.ts @@ -12,3 +12,16 @@ export async function getInfo(): Promise { const res = await axios.get(`${sessionPath}/info`); return res.data; } + +/** + * HTTP request to get a pre-authenticated URL + */ +export async function generateUrl( + baseUrl: string, + path: string, + lock: boolean, + authenticate: boolean, +): Promise { + const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate }); + return res.data.url; +} diff --git a/apps/client/src/common/components/info/Info.module.scss b/apps/client/src/common/components/info/Info.module.scss new file mode 100644 index 000000000..e981c9f35 --- /dev/null +++ b/apps/client/src/common/components/info/Info.module.scss @@ -0,0 +1,16 @@ +.infoLabel { + display: flex; + align-items: center; + gap: $element-spacing; + padding: 1rem; + margin-bottom: 1rem; + + background-color: $gray-1100; + border-radius: 2px; + font-size: $inner-section-text-size; + + svg { + font-size: 1.5rem; + color: $info-blue; + } +} diff --git a/apps/client/src/common/components/info/Info.tsx b/apps/client/src/common/components/info/Info.tsx new file mode 100644 index 000000000..7449bdeb3 --- /dev/null +++ b/apps/client/src/common/components/info/Info.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react'; +import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle'; + +import style from './Info.module.scss'; + +export default function Info({ children }: PropsWithChildren) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss index 919e0d8ef..a6451ac84 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss @@ -8,23 +8,6 @@ } } -.infoLabel { - display: flex; - align-items: center; - gap: $element-spacing; - padding: 1rem; - margin-bottom: 1rem; - - background-color: $gray-1100; - border-radius: 2px; - font-size: $inner-section-text-size; - - svg { - font-size: 1.5rem; - color: $info-blue; - } -} - .sectionList { display: flex; flex-direction: column; diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index a91ff18d5..8cc0633d6 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -11,9 +11,9 @@ import { DrawerOverlay, useDisclosure, } from '@chakra-ui/react'; -import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle'; import useViewSettings from '../../../common/hooks-query/useViewSettings'; +import Info from '../info/Info'; import { ViewOption } from './types'; import ViewParamsSection from './ViewParamsSection'; @@ -132,12 +132,7 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) { - {viewSettings.overrideStyles && ( -
- - This view style is being modified by a custom CSS file.
-
- )} + {viewSettings.overrideStyles && This view style is being modified by a custom CSS file.}
{viewOptions.map((section) => ( { + it('should handle electron links', () => { + const serverUrl = 'http://localhost:4001'; + const baseUri = ''; + const destination = linkToOtherHost('192.168.10.166', 'path', serverUrl, baseUri); + expect(destination).toBe('http://192.168.10.166:4001/path'); + }); + + it('should handle ontime cloud links', () => { + const serverUrl = 'https://cloud.getontime.no/user-hash'; + const baseUri = 'user-hash'; + const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri); + expect(destination).toBe('https://cloud.getontime.no/user-hash/path'); + }); +}); diff --git a/apps/client/src/common/utils/linkUtils.ts b/apps/client/src/common/utils/linkUtils.ts index 4dd1b78fb..d54ee75c3 100644 --- a/apps/client/src/common/utils/linkUtils.ts +++ b/apps/client/src/common/utils/linkUtils.ts @@ -18,23 +18,32 @@ export function openLink(url: string) { /** * Handles opening external links - * @param event - * @param location + * serverUrl and baseURI are used for testing */ -export function handleLinks(event: MouseEvent, location: string) { +export function handleLinks( + event: MouseEvent, + location: string, + externalServerUrl: string = serverURL, + externalBaseURI: string = baseURI, +) { // we handle the link manually event.preventDefault(); - const destination = new URL(serverURL); - destination.pathname = baseURI ? `${baseURI}/${location}` : location; + const destination = new URL(externalServerUrl); + destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location; openLink(destination.toString()); } -export function linkToOtherHost(host: string, path?: string) { - const destination = new URL(serverURL); +export function linkToOtherHost( + host: string, + path?: string, + externalServerUrl: string = serverURL, + externalBaseURI: string = baseURI, +) { + const destination = new URL(externalServerUrl); destination.hostname = host; if (path) { - destination.pathname = baseURI ? `${baseURI}/${path}` : path; + destination.pathname = externalBaseURI ? `${externalBaseURI}/${path}` : path; } return destination.toString(); } diff --git a/apps/client/src/externals.ts b/apps/client/src/externals.ts index 6b986bc54..67269ffc2 100644 --- a/apps/client/src/externals.ts +++ b/apps/client/src/externals.ts @@ -75,4 +75,4 @@ function resolveBaseURI(): string { } return base; -} +} \ No newline at end of file diff --git a/apps/client/src/features/app-settings/panel/network-panel/GenerateLinkForm.module.scss b/apps/client/src/features/app-settings/panel/network-panel/GenerateLinkForm.module.scss new file mode 100644 index 000000000..ae0a0cf65 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/network-panel/GenerateLinkForm.module.scss @@ -0,0 +1,5 @@ +.qrCode { + padding: 0.5rem; + background: $ui-white; + border-radius: 3px; +} diff --git a/apps/client/src/features/app-settings/panel/network-panel/GenerateLinkForm.tsx b/apps/client/src/features/app-settings/panel/network-panel/GenerateLinkForm.tsx new file mode 100644 index 000000000..876ef3d33 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/network-panel/GenerateLinkForm.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import QRCode from 'react-qr-code'; +import { Button, Select, Switch } from '@chakra-ui/react'; + +import { generateUrl } from '../../../../common/api/session'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import ExternalLink from '../../../../common/components/external-link/ExternalLink'; +import Info from '../../../../common/components/info/Info'; +import useInfo from '../../../../common/hooks-query/useInfo'; +import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; +import copyToClipboard from '../../../../common/utils/copyToClipboard'; +import { preventEscape } from '../../../../common/utils/keyEvent'; +import { linkToOtherHost } from '../../../../common/utils/linkUtils'; +import { serverURL } from '../../../../externals'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import style from './GenerateLinkForm.module.scss'; + +interface GenerateLinkFormOptions { + baseUrl: string; + path: string; + lock: boolean; + authenticate: boolean; +} + +type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error'; + +export default function GenerateLinkForm() { + const { data: infoData } = useInfo(); + const { data: urlPresetData } = useUrlPresets(); + const [formState, setFormState] = useState('pending'); + const [url, setUrl] = useState(serverURL); + + const { + handleSubmit, + register, + setError, + formState: { errors }, + } = useForm({ + mode: 'onChange', + resetOptions: { + keepDirtyValues: true, + }, + }); + + const onSubmit = async (options: GenerateLinkFormOptions) => { + try { + setFormState('loading'); + const baseUrl = linkToOtherHost(options.baseUrl); + const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate); + await copyToClipboard(url); + setUrl(url); + setFormState('success'); + setTimeout(() => { + setFormState('pending'); + }, 4000); + } catch (error) { + const message = maybeAxiosError(error); + setError('root', { message }); + setFormState('error'); + } + }; + + return ( + preventEscape(event)}> + {errors.root && {errors.root.message}} + + + You can generate a link to share with your team or to use in automation (such as companion). + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {url} +
+
+
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx index 8661a1e35..2c7df656a 100644 --- a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx +++ b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx @@ -8,21 +8,34 @@ import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import ClientControlPanel from '../client-control-panel/ClientControlPanel'; +import GenerateLinkForm from './GenerateLinkForm'; import InfoNif from './NetworkInterfaces'; import LogExport from './NetworkLogExport'; export default function NetworkLogPanel({ location }: PanelBaseProps) { + const linkRef = useScrollIntoView('link', location); const clientsRef = useScrollIntoView('clients', location); const logRef = useScrollIntoView('log', location); return ( <> Network - - {isDockerImage && } - Ontime is streaming on the following network interfaces - - + {isDockerImage && ( + + + + )} +
+ + + Share Ontime Link + + Ontime is streaming on the following network interfaces + + + + +
diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 045549540..6acaa1b9b 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -60,6 +60,10 @@ const staticOptions = [ label: 'Network', split: true, secondary: [ + { + id: 'network__link', + label: 'Share link', + }, { id: 'network__log', label: 'Event log', diff --git a/apps/server/src/api-data/session/session.controller.ts b/apps/server/src/api-data/session/session.controller.ts index ffbb16821..8d2ba9957 100644 --- a/apps/server/src/api-data/session/session.controller.ts +++ b/apps/server/src/api-data/session/session.controller.ts @@ -1,5 +1,5 @@ import { getErrorMessage } from 'ontime-utils'; -import { ErrorResponse, GetInfo, SessionStats } from 'ontime-types'; +import { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types'; import type { Request, Response } from 'express'; @@ -24,3 +24,18 @@ export async function getInfo(_req: Request, res: Response) { + try { + const url = sessionService.generateAuthenticatedUrl( + req.body.baseUrl, + req.body.path, + req.body.lock, + req.body.authenticate, + ); + res.status(200).send({ url: url.toString() }); + } catch (error) { + const message = getErrorMessage(error); + res.status(500).send({ message }); + } +} diff --git a/apps/server/src/api-data/session/session.router.ts b/apps/server/src/api-data/session/session.router.ts index f6dbff659..3f37c3db6 100644 --- a/apps/server/src/api-data/session/session.router.ts +++ b/apps/server/src/api-data/session/session.router.ts @@ -1,8 +1,10 @@ import express from 'express'; -import { getInfo, getSessionStats } from './session.controller.js'; +import { getInfo, getSessionStats, generateUrl } from './session.controller.js'; +import { validateGenerateUrl } from './session.validation.js'; export const router = express.Router(); router.get('/', getSessionStats); router.get('/info', getInfo); +router.post('/url', validateGenerateUrl, generateUrl); diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts index 729461e2e..0e7d53ad7 100644 --- a/apps/server/src/api-data/session/session.service.ts +++ b/apps/server/src/api-data/session/session.service.ts @@ -8,6 +8,8 @@ import { getLastLoadedProject } from '../../services/app-state-service/AppStateS import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { getNetworkInterfaces } from '../../utils/network.js'; import { getTimezoneLabel } from '../../utils/time.js'; +import { password } from '../../externals.js'; +import { hashPassword } from '../../utils/hash.js'; const startedAt = new Date(); @@ -46,3 +48,20 @@ export async function getInfo(): Promise { publicDir: publicDir.root, }; } + +export const hasPassword = Boolean(password); +export const hashedPassword = hasPassword ? hashPassword(password as string) : undefined; + +/** + * Generates a pre-authenticated URL by injecting a token in the URL params + */ +export function generateAuthenticatedUrl(baseUrl: string, path: string, lock: boolean, authenticate: boolean): URL { + const url = new URL(path, baseUrl); + if (authenticate && hashedPassword) { + url.searchParams.append('token', hashedPassword); + } + if (lock) { + url.searchParams.append('locked', 'true'); + } + return url; +} diff --git a/apps/server/src/api-data/session/session.validation.ts b/apps/server/src/api-data/session/session.validation.ts new file mode 100644 index 000000000..2c9fe44ca --- /dev/null +++ b/apps/server/src/api-data/session/session.validation.ts @@ -0,0 +1,15 @@ +import type { Request, Response, NextFunction } from 'express'; +import { body, validationResult } from 'express-validator'; + +export const validateGenerateUrl = [ + body('baseUrl').exists().isString().notEmpty().trim(), + body('path').exists().isString().trim(), + body('lock').exists().isBoolean(), + body('authenticate').exists().isBoolean(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/middleware/authenticate.ts b/apps/server/src/middleware/authenticate.ts index 08075ed37..32fb312a7 100644 --- a/apps/server/src/middleware/authenticate.ts +++ b/apps/server/src/middleware/authenticate.ts @@ -8,11 +8,9 @@ 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'; +import { hashedPassword, hasPassword } from '../api-data/session/session.service.js'; -export const hasPassword = Boolean(password); -const hashedPassword = hasPassword ? hashPassword(password) : ''; +import { noopMiddleware } from './noop.js'; /** * List of public assets that can be accessed without authentication diff --git a/packages/types/src/api/ontime-controller/BackendResponse.type.ts b/packages/types/src/api/ontime-controller/BackendResponse.type.ts index 4034a226c..577094682 100644 --- a/packages/types/src/api/ontime-controller/BackendResponse.type.ts +++ b/packages/types/src/api/ontime-controller/BackendResponse.type.ts @@ -24,6 +24,10 @@ export interface GetInfo { publicDir: string; } +export interface GetUrl { + url: string; +} + export type ProjectFile = { filename: string; updatedAt: string; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6bb4326c8..396c13594 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -57,6 +57,7 @@ export type { AuthenticationStatus, NetworkInterface, GetInfo, + GetUrl, ProjectFileList, ProjectFile, ErrorResponse,