diff --git a/meteor/__mocks__/_setupMocks.ts b/meteor/__mocks__/_setupMocks.ts index b9e7936792..8cf580f95d 100644 --- a/meteor/__mocks__/_setupMocks.ts +++ b/meteor/__mocks__/_setupMocks.ts @@ -39,3 +39,6 @@ afterEach(() => { // Expect all log messages that have been explicitly supressed, to have been handled: SupressLogMessages.expectAllMessagesToHaveBeenHandled() }) + +// @ts-expect-error mock meteor runtime config +global.__meteor_runtime_config__ = {} diff --git a/meteor/package.json b/meteor/package.json index a8e2e0efea..9b50675aec 100644 --- a/meteor/package.json +++ b/meteor/package.json @@ -57,6 +57,7 @@ "indexof": "0.0.1", "koa": "^2.15.3", "koa-bodyparser": "^4.4.1", + "koa-mount": "^4.0.0", "koa-static": "^5.0.0", "meteor-node-stubs": "^1.2.12", "moment": "^2.30.1", @@ -85,6 +86,7 @@ "@types/jest": "^29.5.14", "@types/koa": "^2.15.0", "@types/koa-bodyparser": "^4.3.12", + "@types/koa-mount": "^4", "@types/koa-static": "^4.0.4", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", diff --git a/meteor/server/api/peripheralDevice.ts b/meteor/server/api/peripheralDevice.ts index e20cdae255..9d073e9f4b 100644 --- a/meteor/server/api/peripheralDevice.ts +++ b/meteor/server/api/peripheralDevice.ts @@ -69,6 +69,7 @@ import KoaRouter from '@koa/router' import bodyParser from 'koa-bodyparser' import { assertConnectionHasOneOfPermissions } from '../security/auth' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { getRootSubpath } from '../lib' const apmNamespace = 'peripheralDevice' export namespace ServerPeripheralDeviceAPI { @@ -680,7 +681,7 @@ peripheralDeviceRouter.get('/:deviceId/oauthResponse', async (ctx) => { .catch(logger.error) } - ctx.redirect(`/settings/peripheralDevice/${deviceId}`) + ctx.redirect(`${getRootSubpath()}/settings/peripheralDevice/${deviceId}`) } catch (e) { ctx.response.type = 'text/plain' ctx.response.status = 500 diff --git a/meteor/server/api/rest/api.ts b/meteor/server/api/rest/api.ts index f7c2b7d076..4496cd493b 100644 --- a/meteor/server/api/rest/api.ts +++ b/meteor/server/api/rest/api.ts @@ -11,6 +11,7 @@ import { peripheralDeviceRouter } from '../peripheralDevice' import { blueprintsRouter } from '../blueprints/http' import { createLegacyApiRouter } from './v0/index' import { heapSnapshotPrivateApiRouter } from '../heapSnapshot' +import { getRootSubpath } from '../../lib' const LATEST_REST_API = 'v1.0' @@ -34,7 +35,7 @@ apiRouter.use( ) async function redirectToLatest(ctx: koa.ParameterizedContext, _next: koa.Next): Promise { - ctx.redirect(`/api/${LATEST_REST_API}`) + ctx.redirect(`${getRootSubpath()}/api/${LATEST_REST_API}`) ctx.status = 307 } diff --git a/meteor/server/api/rest/koa.ts b/meteor/server/api/rest/koa.ts index 673e9c3174..1420eb7d05 100644 --- a/meteor/server/api/rest/koa.ts +++ b/meteor/server/api/rest/koa.ts @@ -1,15 +1,17 @@ import Koa from 'koa' import cors from '@koa/cors' import KoaRouter from '@koa/router' +import KoaMount from 'koa-mount' import { WebApp } from 'meteor/webapp' import { Meteor } from 'meteor/meteor' import { getRandomString } from '@sofie-automation/corelib/dist/lib' import _ from 'underscore' -import { public_dir } from '../../lib' +import { getRootSubpath, public_dir } from '../../lib' import staticServe from 'koa-static' import { logger } from '../../logging' import { PackageInfo } from '../../coreSystem' import { profiler } from '../profiler' +import fs from 'fs/promises' declare module 'http' { interface IncomingMessage { @@ -78,28 +80,30 @@ Meteor.startup(() => { // serve the webui through koa // This is to avoid meteor injecting anything into the served html - const webuiServer = staticServe(public_dir) - koaApp.use(webuiServer) + const webuiServer = staticServe(public_dir, { + index: false, // Performed manually + }) + koaApp.use(KoaMount(getRootSubpath() || '/', webuiServer)) logger.debug(`Serving static files from ${public_dir}`) - // Serve the meteor runtime config - rootRouter.get('/meteor-runtime-config.js', async (ctx) => { - const versionExtended: string = PackageInfo.versionExtended || PackageInfo.version // package version - - ctx.body = `window.__meteor_runtime_config__ = (${JSON.stringify({ - // @ts-expect-error missing types for internal meteor detail - ...__meteor_runtime_config__, - sofieVersionExtended: versionExtended, - })})` - }) + if (Meteor.isDevelopment) { + // Serve the meteor runtime config. In production, this gets baked into the html + rootRouter.get(getRootSubpath() + '/meteor-runtime-config.js', async (ctx) => { + ctx.body = getExtendedMeteorRuntimeConfig() + }) + } koaApp.use(rootRouter.routes()).use(rootRouter.allowedMethods()) koaApp.use(async (ctx, next) => { if (ctx.method !== 'GET') return next() + // Ensure the path is scoped to the root subpath + const rootSubpath = getRootSubpath() + if (!ctx.path.startsWith(rootSubpath)) return next() + // Don't use the fallback for certain paths - if (ctx.path.startsWith('/assets/')) return next() + if (ctx.path.startsWith(rootSubpath + '/assets/')) return next() // Don't use the fallback for anything handled by another router // This does not feel efficient, but koa doesn't appear to have any shared state between the router handlers @@ -107,19 +111,55 @@ Meteor.startup(() => { if (ctx.path.startsWith(bindPath)) return next() } - // fallback to the root file - ctx.path = '/' - return webuiServer(ctx, next) + // fallback to serving html + return serveIndexHtml(ctx, next) }) }) +function getExtendedMeteorRuntimeConfig() { + const versionExtended: string = PackageInfo.versionExtended || PackageInfo.version // package version + + return `window.__meteor_runtime_config__ = (${JSON.stringify({ + // @ts-expect-error missing types for internal meteor detail + ...__meteor_runtime_config__, + sofieVersionExtended: versionExtended, + })})` +} + +async function serveIndexHtml(ctx: Koa.ParameterizedContext, next: Koa.Next) { + try { + // Read the file + const indexFileBuffer = await fs.readFile(public_dir + '/index.html', 'utf8') + const indexFileStr = indexFileBuffer.toString() + + const rootPath = getRootSubpath() + + // Perform various runtime modifications, to ensure paths have the correct absolute prefix + let modifiedFile = indexFileStr + modifiedFile = modifiedFile.replace( + // Replace the http load with injected js, to avoid risk of issues where this load fails and the app gets confused + '', + `` + ) + modifiedFile = modifiedFile.replaceAll('href="/', `href="${rootPath}/`) + modifiedFile = modifiedFile.replaceAll('href="./', `href="${rootPath}/`) + modifiedFile = modifiedFile.replaceAll('src="./', `src="${rootPath}/`) + + ctx.body = modifiedFile + } catch (e) { + return next() + } +} + export function bindKoaRouter(koaRouter: KoaRouter, bindPath: string): void { + const bindPathWithPrefix = getRootSubpath() + bindPath + // Track this path as having a router - let bindPathFull = bindPath + let bindPathFull = bindPathWithPrefix if (!bindPathFull.endsWith('/')) bindPathFull += '/' boundRouterPaths.push(bindPathFull) - rootRouter.use(bindPath, koaRouter.routes()).use(bindPath, koaRouter.allowedMethods()) + rootRouter.use(bindPathWithPrefix, koaRouter.routes()).use(bindPathWithPrefix, koaRouter.allowedMethods()) } const REVERSE_PROXY_COUNT = process.env.HTTP_FORWARDED_COUNT ? parseInt(process.env.HTTP_FORWARDED_COUNT) : 0 diff --git a/meteor/server/lib.ts b/meteor/server/lib.ts index 1c944e58a2..8670f91c58 100644 --- a/meteor/server/lib.ts +++ b/meteor/server/lib.ts @@ -33,6 +33,13 @@ export const public_dir = Meteor.isProduction : // In development, find the webui package and use its public directory path.join(process.cwd(), '../../../../../../packages/webui/public') +export function getRootSubpath(): string { + // @ts-expect-error Untyped meteor export + const settings: any = __meteor_runtime_config__ + + return settings.ROOT_URL_PATH_PREFIX || '' +} + /** * Get the i18next locale object for a given `languageCode`. If the translations file can not be found or it can't be * parsed, it will return an empty object. diff --git a/meteor/server/webmanifest.ts b/meteor/server/webmanifest.ts index fa3f1a5dfa..d55777c02e 100644 --- a/meteor/server/webmanifest.ts +++ b/meteor/server/webmanifest.ts @@ -8,7 +8,7 @@ import { logger } from './logging' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' import { RundownPlaylists, Rundowns, Studios } from './collections' -import { getLocale, Translations } from './lib' +import { getLocale, getRootSubpath, Translations } from './lib' import { generateTranslation } from './lib/tempLib' import { ITranslatableMessage } from '@sofie-automation/blueprints-integration' import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage' @@ -21,20 +21,6 @@ import { bindKoaRouter } from './api/rest/koa' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' const appShortName = 'Sofie' -const SOFIE_DEFAULT_ICONS: ManifestImageResource[] = [ - { - src: '/icons/mstile-144x144.png', - sizes: '144x144', - purpose: 'monochrome', - type: 'image/png', - }, - { - src: '/icons/maskable-96x96.png', - sizes: '96x96', - purpose: 'maskable', - type: 'image/png', - }, -] const t = generateTranslation @@ -48,6 +34,21 @@ function getShortcutsForStudio( studio: Pick, studioCount: number ): ShortcutItem[] { + const SOFIE_DEFAULT_ICONS: ManifestImageResource[] = [ + { + src: getRootSubpath() + '/icons/mstile-144x144.png', + sizes: '144x144', + purpose: 'monochrome', + type: 'image/png', + }, + { + src: getRootSubpath() + '/icons/maskable-96x96.png', + sizes: '96x96', + purpose: 'maskable', + type: 'image/png', + }, + ] + const multiStudio = studioCount > 1 return [ { @@ -61,7 +62,7 @@ function getShortcutsForStudio( : t('Active Rundown') ), icons: SOFIE_DEFAULT_ICONS, - url: `/activeRundown/${studio._id}`, + url: getRootSubpath() + `/activeRundown/${studio._id}`, }, { id: `${studio._id}_prompter`, @@ -74,7 +75,7 @@ function getShortcutsForStudio( : t('Prompter') ), icons: SOFIE_DEFAULT_ICONS, - url: `/prompter/${studio._id}`, + url: getRootSubpath() + `/prompter/${studio._id}`, }, { id: `${studio._id}_countdowns`, @@ -83,7 +84,7 @@ function getShortcutsForStudio( multiStudio ? t('{{studioName}}: Presenter screen', { studioName: studio.name }) : t('Presenter screen') ), icons: SOFIE_DEFAULT_ICONS, - url: `/countdowns/${studio._id}/presenter`, + url: getRootSubpath() + `/countdowns/${studio._id}/presenter`, }, ] } @@ -111,31 +112,31 @@ async function getWebManifest(languageCode: string): Promise 0 ? shortcuts : undefined, protocol_handlers: [ { protocol: 'web+nrcs', - url: '/url/nrcs?q=%s', + url: getRootSubpath() + '/url/nrcs?q=%s', }, ], } @@ -261,14 +262,14 @@ async function webNrcsRundownRoute(ctx: Koa.ParameterizedContext, parsedUrl: URL // we couldn't find the External ID for Rundown/Rundown Playlist logger.debug(`NRCS URL: External ID not found "${externalId}"`) ctx.body = `Could not find requested object: "${externalId}", see the full list` - ctx.redirect('/') + ctx.redirect(`${getRootSubpath()}/`) ctx.response.status = 303 return } logger.debug(`NRCS URL: External ID found "${externalId}" in "${rundownPlaylist._id}"`) ctx.body = `Requested object found in Rundown Playlist "${rundownPlaylist._id}"` - ctx.redirect(`/rundown/${rundownPlaylist._id}`) + ctx.redirect(`${getRootSubpath()}/rundown/${rundownPlaylist._id}`) } Meteor.startup(() => { diff --git a/meteor/tsconfig-base.json b/meteor/tsconfig-base.json index d57507f4db..61cb4e8352 100644 --- a/meteor/tsconfig-base.json +++ b/meteor/tsconfig-base.json @@ -4,10 +4,12 @@ /* At the time of writing we are not ready for stricter rules */ "strict": true, + "target": "es2022", + "skipLibCheck": true, "sourceMap": true, "allowJs": false, - "lib": ["dom", "es6", "dom.iterable", "scripthost", "es2017", "es2018", "es2019", "ES2020.Promise"], + "lib": ["dom", "es2022", "dom.iterable", "scripthost"], "paths": { "meteor/*": [ diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 54ee1a473e..045c8564ec 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1434,6 +1434,15 @@ __metadata: languageName: node linkType: hard +"@types/koa-mount@npm:^4": + version: 4.0.5 + resolution: "@types/koa-mount@npm:4.0.5" + dependencies: + "@types/koa": "npm:*" + checksum: 10/2b794f618b44e5a6810a829fd6fc29c17dd6b7adde81d26f270215197c01184434922db7078502947f48144344471129f533a3db2b0d86b836c60fddc32e2a3c + languageName: node + linkType: hard + "@types/koa-send@npm:*": version: 4.1.6 resolution: "@types/koa-send@npm:4.1.6" @@ -2235,6 +2244,7 @@ __metadata: "@types/jest": "npm:^29.5.14" "@types/koa": "npm:^2.15.0" "@types/koa-bodyparser": "npm:^4.3.12" + "@types/koa-mount": "npm:^4" "@types/koa-static": "npm:^4.0.4" "@types/koa__cors": "npm:^5.0.0" "@types/koa__router": "npm:^12.0.4" @@ -2267,6 +2277,7 @@ __metadata: jest: "npm:^29.7.0" koa: "npm:^2.15.3" koa-bodyparser: "npm:^4.4.1" + koa-mount: "npm:^4.0.0" koa-static: "npm:^5.0.0" legally: "npm:^3.5.10" meteor-node-stubs: "npm:^1.2.12" @@ -3572,6 +3583,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.0.1": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 + languageName: node + linkType: hard + "debuglog@npm:^1.0.1": version: 1.0.1 resolution: "debuglog@npm:1.0.1" @@ -6818,6 +6841,16 @@ __metadata: languageName: node linkType: hard +"koa-mount@npm:^4.0.0": + version: 4.0.0 + resolution: "koa-mount@npm:4.0.0" + dependencies: + debug: "npm:^4.0.1" + koa-compose: "npm:^4.1.0" + checksum: 10/c7e8c5cca4d2ccc4742e63c81b86b44f0290075148897b5d633acdd137e90f554c60c232fbc62e843eaedb913b67c5a49367c1142e290b8cfd9c28eb4a0480ec + languageName: node + linkType: hard + "koa-send@npm:^5.0.0": version: 5.0.1 resolution: "koa-send@npm:5.0.1" @@ -7760,7 +7793,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d diff --git a/packages/webui/src/client/lib/ui/icons/notifications.tsx b/packages/webui/src/client/lib/ui/icons/notifications.tsx index e4e6dd26ed..7f839ac370 100644 --- a/packages/webui/src/client/lib/ui/icons/notifications.tsx +++ b/packages/webui/src/client/lib/ui/icons/notifications.tsx @@ -1,4 +1,5 @@ import { JSX } from 'react' +import { relativeToSiteRootUrl } from '../../../url' export function CriticalIcon(): JSX.Element { return ( @@ -167,7 +168,7 @@ export function WarningIconSmall(): JSX.Element { // } export function WarningIconSmallWorkingOnIt(): JSX.Element { - return Warning + return Warning } export function InformationIconSmall(): JSX.Element { diff --git a/packages/webui/src/client/styles/_header.scss b/packages/webui/src/client/styles/_header.scss index 38f060715f..94dbad0ccd 100644 --- a/packages/webui/src/client/styles/_header.scss +++ b/packages/webui/src/client/styles/_header.scss @@ -18,7 +18,7 @@ color: #fff; } .sofie-logo { - background: url('/images/sofie-logo.svg') center center no-repeat; + background: var(--sofie-logo-url) center center no-repeat; background-size: contain; width: 2em; height: 2em; diff --git a/packages/webui/src/client/ui/App.tsx b/packages/webui/src/client/ui/App.tsx index 73d5ad43d0..a9067add46 100644 --- a/packages/webui/src/client/ui/App.tsx +++ b/packages/webui/src/client/ui/App.tsx @@ -39,6 +39,7 @@ import { DocumentTitleProvider } from '../lib/DocumentTitleProvider' import { catchError, firstIfArray, isRunningInPWA } from '../lib/lib' import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import { useUserPermissions, UserPermissionsContext } from './UserPermissions' +import { relativeToSiteRootUrl, ROOT_URL_PATH_PREFIX } from '../url' const NullComponent = () => null @@ -147,8 +148,14 @@ export const App: React.FC = function App() { return ( - -
+ +
{/* Header switch - render the usual header for all pages but the rundown view */} diff --git a/packages/webui/src/client/ui/FloatingInspectors/NoraFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/NoraFloatingInspector.tsx index 8bb6d2f7db..37300f731a 100644 --- a/packages/webui/src/client/ui/FloatingInspectors/NoraFloatingInspector.tsx +++ b/packages/webui/src/client/ui/FloatingInspectors/NoraFloatingInspector.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useImperativeHandle } from 'react' import _ from 'underscore' import { getNoraContentSteps } from '../SegmentContainer/PieceMultistepChevron' import Escape from './../../lib/Escape' +import { relativeToSiteRootUrl } from '../../url' interface IPropsHeader { noraContent: NoraContent | undefined @@ -206,7 +207,7 @@ export class NoraPreviewRenderer extends React.Component<{}, IStateHeader> { ref={this._setRootElement} >
- + {rendererUrl && (