diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..92ef32946a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# The build context is the repository root, and every Dockerfile here installs +# its own dependencies. Shipping the working tree's node_modules would send +# well over a gigabyte to the daemon and then be overwritten by `npm ci` anyway. +**/node_modules +**/dist +**/.git + +# Test fixtures and desktop build output from the encoder submodule: large, +# and nothing in an image needs them. +luminary-media-convert/test-media +luminary-media-convert/app-electron/bin +luminary-media-convert/app-electron/release +luminary-media-convert/api/work + +# NOT .env: the deploy workflow writes app/.env immediately before `docker build`, +# and the PWA plugin reads its icon paths from it. Excluding it here would fail +# the staging build with an undefined icon path. +**/*.log diff --git a/.github/workflows/app-deploy-staging.yml b/.github/workflows/app-deploy-staging.yml index 90fef19961..c0947ec7f3 100644 --- a/.github/workflows/app-deploy-staging.yml +++ b/.github/workflows/app-deploy-staging.yml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + submodules: true - name: Create .env file run: | diff --git a/.github/workflows/app-unit-tests.yml b/.github/workflows/app-unit-tests.yml index 6a643cb39d..2ae4491846 100644 --- a/.github/workflows/app-unit-tests.yml +++ b/.github/workflows/app-unit-tests.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + submodules: true - name: Setup Node.js uses: actions/setup-node@v6 @@ -31,6 +33,14 @@ jobs: cache: "npm" cache-dependency-path: app/package-lock.json + - name: Build the encoder's player libraries + # The submodule's packages ship only dist/, which is gitignored — a fresh + # checkout has sources and no entry points, and Vite fails with "Failed to + # resolve entry for package". ci:libs installs the five library workspaces + # without electron; build:libs builds them. Same as both Dockerfiles. + run: npm run ci:libs; npm run build:libs; + working-directory: luminary-media-convert + - name: Build shared dependencies run: npm ci; npm run build; working-directory: shared diff --git a/.github/workflows/cms-deploy-staging.yml b/.github/workflows/cms-deploy-staging.yml index ce042d1da2..9a2930e351 100644 --- a/.github/workflows/cms-deploy-staging.yml +++ b/.github/workflows/cms-deploy-staging.yml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + submodules: true - name: Create .env file run: | diff --git a/.github/workflows/cms-unit-tests.yml b/.github/workflows/cms-unit-tests.yml index ecbad3f278..96d3fee95d 100644 --- a/.github/workflows/cms-unit-tests.yml +++ b/.github/workflows/cms-unit-tests.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + submodules: true - name: Setup Node.js uses: actions/setup-node@v6 @@ -31,6 +33,14 @@ jobs: cache: "npm" cache-dependency-path: cms/package-lock.json + - name: Build the encoder's player libraries + # The submodule's packages ship only dist/, which is gitignored — a fresh + # checkout has sources and no entry points, and Vite fails with "Failed to + # resolve entry for package". ci:libs installs the five library workspaces + # without electron; build:libs builds them. Same as both Dockerfiles. + run: npm run ci:libs; npm run build:libs; + working-directory: luminary-media-convert + - name: Build shared dependencies run: npm ci; npm run build; working-directory: shared diff --git a/.github/workflows/e2e-local-stack.yml b/.github/workflows/e2e-local-stack.yml index 428310f24b..1ef01c20ee 100644 --- a/.github/workflows/e2e-local-stack.yml +++ b/.github/workflows/e2e-local-stack.yml @@ -36,6 +36,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + submodules: true - name: Setup Node.js uses: actions/setup-node@v6 @@ -49,6 +51,13 @@ jobs: cms/package-lock.json playwright-tests/package-lock.json + - name: Build the encoder's player libraries + # The submodule's packages ship only dist/, which is gitignored — a fresh + # checkout has sources and no entry points, and Vite fails with "Failed to + # resolve entry for package". Same as the unit-test workflows. + run: npm run ci:libs; npm run build:libs; + working-directory: luminary-media-convert + - name: Start CouchDB run: | ./scripts/start-couchdb-in-ci.sh diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..e80a5b07ac --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "luminary-media-convert"] + path = luminary-media-convert + url = https://github.com/bccsa/luminary-media-convert.git + branch = main diff --git a/CLAUDE.md b/CLAUDE.md index badc4afde3..8cfbe5d81e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,9 +61,24 @@ These are the seams that bite when you change one side and forget the other: ## Comment style -Comments explain **why** code is added, in ~1–3 small sentences — neat and quick to read. They must not describe domain-specific / Luminary-internal concepts in depth, describe a problem or bug, narrate how logic previously worked ("previously", "used to", "the old approach", "now"), reference issue/ticket/spec/phase numbers, or run into multi-paragraph rationale (that belongs in PR descriptions, ADRs, or commit messages). Apply this to inline `//`, block `/* */`, JSDoc `/** */`, and `` in `.vue`. Don't rewrite pre-existing comments unless asked; only apply it to comments you're adding or already changing. +Comments capture *why* code exists or why it's written a certain way — not *what* it does (the code already says that) and not architecture or design rationale. A comment that reads like a paragraph of prose, enumerates everywhere a concept is "excluded," or reproduces a doc is a signal it belongs in an ADR (`docs/adr/`), the package docs, or the datamodel — not inline. -Comments should stay relevant to the repo they're in — don't explain how something outside this repo (a separate packaging/deploy pipeline, another service, an external tool) consumes or wraps the code. This repo has one source of truth for its own behavior; what happens to the build afterward belongs in that other project's docs, not here. +- **No comment when the *why* is obvious or the code is self-explanatory.** Comments don't have to be everywhere. +- **Keep a comment to a tldr — one or two short lines of *why*.** If more is needed, the explanation goes in docs and the comment shrinks to a one-line pointer. +- **Don't trim past clarity.** Brevity is not the goal; a readable *why* is. Keep the subject/referent — a dangling fragment like `// Never replicated` is useless because *replicated to what?* Say `// Sidecars are never replicated to clients`. If dropping a word loses what the comment is about, keep the word. +- **Never reproduce documentation in a comment.** A pointer is fine; re-explaining the contents is not. +- **Never point a code comment at a `temp_` doc** (see Development docs below) — those are scaffolding, not a source of truth the code should depend on. If the *why* needs to live in code, write it as JSDoc, not as a link to a temp doc. +- **JSDoc on exported APIs:** a one-line *why/what-it-is*. Reserve longer treatment for the docs. +- Don't rewrite pre-existing comments unless asked; match the surrounding file's convention. + +## Development docs + +Working/proposal docs that exist only to develop a feature — not to document the final product — are **temporary scaffolding**. Treat them as such: + +- **Prefix their filenames with `temp_`** (e.g. `docs/temp_sidecar-...md`) so they're trivially findable and removable once the feature lands. +- **Never reference a `temp_` doc from code or tests.** Code comments and JSDoc must stand on their own; a `temp_` doc will be deleted, so linking to it rots immediately. +- When the feature ships, either delete the `temp_` docs (if the substance now lives in code/ADRs) or promote the durable parts into a permanent doc/ADR and drop the `temp_` prefix. +- Anything that genuinely needs to be documented *in code* uses **JSDoc** (`/** */`), neatly — not `//` prose paragraphs. ## When changes span multiple packages diff --git a/api/src/app.module.ts b/api/src/app.module.ts index ff1ea16511..9933847503 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -15,8 +15,11 @@ import { QueryController } from "./endpoints/query.controller"; import { FtsSearchService } from "./endpoints/ftsSearch.service"; import { FtsSearchController } from "./endpoints/ftsSearch.controller"; import { StorageStatusController } from "./endpoints/storageStatus.controller"; +import { EncoderConfigController } from "./endpoints/encoderConfig.controller"; +import { SidecarController } from "./endpoints/sidecar.controller"; import { AuthIdentityService } from "./auth/authIdentity.service"; import { QueryRateLimiterService } from "./ratelimit/queryRateLimiter.service"; +import { SidecarRateLimiterService } from "./ratelimit/sidecarRateLimiter.service"; let winstonTransport: winston.transport; if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { @@ -57,6 +60,8 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { QueryController, FtsSearchController, StorageStatusController, + EncoderConfigController, + SidecarController, ], providers: [ DbService, @@ -64,6 +69,7 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { S3Service, QueryService, QueryRateLimiterService, + SidecarRateLimiterService, FtsSearchService, ChangeRequestService, AuthIdentityService, diff --git a/api/src/changeRequests/aclValidation.spec.ts b/api/src/changeRequests/aclValidation.spec.ts index 9367f36c71..32d669962b 100644 --- a/api/src/changeRequests/aclValidation.spec.ts +++ b/api/src/changeRequests/aclValidation.spec.ts @@ -146,4 +146,13 @@ describe("validateAcl", () => { expect(result).toHaveLength(0); }); + + it("should reject an ACL entry for DocType.Sidecar (never replicable, never grantable)", () => { + // Sidecar is absent from availablePermissionsPerDocType — the load-bearing + // non-replication guarantee. An entry is stripped. + const acl = [createEntry(DocType.Sidecar, "g1", [AclPermission.View, AclPermission.CmsView])]; + const result = validateAcl(acl); + + expect(result).toHaveLength(0); + }); }); diff --git a/api/src/changeRequests/aclValidation.ts b/api/src/changeRequests/aclValidation.ts index 9bb8274677..0fe14bfffd 100644 --- a/api/src/changeRequests/aclValidation.ts +++ b/api/src/changeRequests/aclValidation.ts @@ -72,7 +72,9 @@ const availablePermissionsPerDocType = { ], }; -// Valid DocTypes that can be used for ACL assignments +// Derived from the map keys, NOT the DocType enum — so omitting Crypto/Sidecar +// here is load-bearing: no ACL can grant view on them, so no client joins a +// crypto-*/sidecar-* room. const validDocTypes = Object.keys(availablePermissionsPerDocType) as DocType[]; /** diff --git a/api/src/changeRequests/documentProcessing/deleteMediaCollection.spec.ts b/api/src/changeRequests/documentProcessing/deleteMediaCollection.spec.ts new file mode 100644 index 0000000000..ebfbe02694 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/deleteMediaCollection.spec.ts @@ -0,0 +1,121 @@ +import { resolveCollectionPrefix } from "./deleteMediaCollection"; + +/** A real collection URL: MinIO, where the bucket name is part of the public path. */ +const PUBLIC = "http://localhost:9000/media"; +const SESSION = "c5829f07-4ba8-42ed-a449-80d83e6c0b53"; +const HLS = `${PUBLIC}/${SESSION}/master.m3u8`; + +const prefixOf = (r: ReturnType) => + "prefix" in r ? r.prefix : undefined; +const refusalOf = (r: ReturnType) => + "refusal" in r ? r.refusal : undefined; + +describe("resolveCollectionPrefix", () => { + describe("a URL stored relative to the bucket", () => { + it("needs no public URL at all — the path is already the key", () => { + const r = resolveCollectionPrefix( + "/c5829f07-4ba8-42ed-a449-80d83e6c0b53/master.m3u8", + undefined, + ); + expect("prefix" in r && r.prefix).toBe( + "c5829f07-4ba8-42ed-a449-80d83e6c0b53", + ); + }); + + it("still refuses a folder the encoder did not write", () => { + const r = resolveCollectionPrefix("/shared-folder/master.m3u8", undefined); + expect("refusal" in r && r.refusal).toContain("not a session id"); + }); + + it("still refuses the bucket root", () => { + const r = resolveCollectionPrefix("/master.m3u8", undefined); + expect("refusal" in r && r.refusal).toContain("bucket root"); + }); + }); + + describe("resolves a collection this API wrote", () => { + it("strips the bucket's public base and the master filename", () => { + expect(prefixOf(resolveCollectionPrefix(HLS, PUBLIC))).toBe(SESSION); + }); + + it("tolerates a trailing slash on the configured public URL", () => { + expect(prefixOf(resolveCollectionPrefix(HLS, `${PUBLIC}/`))).toBe(SESSION); + expect(prefixOf(resolveCollectionPrefix(HLS, `${PUBLIC}///`))).toBe(SESSION); + }); + + it("keeps a nested path prefix intact", () => { + // pathPrefix on the session puts the collection in a subfolder. + const url = `${PUBLIC}/tenant-a/videos/${SESSION}/master.m3u8`; + expect(prefixOf(resolveCollectionPrefix(url, PUBLIC))).toBe( + `tenant-a/videos/${SESSION}`, + ); + }); + + it("ignores a query string or fragment", () => { + expect(prefixOf(resolveCollectionPrefix(`${HLS}?v=2`, PUBLIC))).toBe(SESSION); + expect(prefixOf(resolveCollectionPrefix(`${HLS}#top`, PUBLIC))).toBe(SESSION); + }); + + it("handles a bucket published at a bare host", () => { + const base = "https://cdn.example.com"; + expect( + prefixOf(resolveCollectionPrefix(`${base}/${SESSION}/master.m3u8`, base)), + ).toBe(SESSION); + }); + }); + + describe("refuses anything it cannot prove it wrote", () => { + it("refuses a URL in a different bucket", () => { + const other = "https://someone-elses-cdn.example.com/media"; + expect(refusalOf(resolveCollectionPrefix(`${other}/${SESSION}/master.m3u8`, PUBLIC))) + .toMatch(/not in this bucket/); + }); + + it("refuses a bucket whose name merely prefixes another", () => { + // The separator is part of the match, or `…/media` would claim + // `…/media-archive//master.m3u8`. + const url = `${PUBLIC}-archive/${SESSION}/master.m3u8`; + expect(refusalOf(resolveCollectionPrefix(url, PUBLIC))).toMatch(/not in this bucket/); + }); + + it("refuses a URL that is not a master playlist", () => { + expect(refusalOf(resolveCollectionPrefix(`${PUBLIC}/${SESSION}/`, PUBLIC))) + .toMatch(/master playlist/); + expect( + refusalOf( + resolveCollectionPrefix(`${PUBLIC}/${SESSION}/stream/playlist.m3u8`, PUBLIC), + ), + ).toMatch(/master playlist/); + }); + + it("refuses the bucket root", () => { + expect(refusalOf(resolveCollectionPrefix(`${PUBLIC}/master.m3u8`, PUBLIC))) + .toMatch(/bucket root/); + }); + + it("refuses a path that tries to climb out", () => { + const url = `${PUBLIC}/../other-tenant/${SESSION}/master.m3u8`; + expect(refusalOf(resolveCollectionPrefix(url, PUBLIC))).toMatch(/suspicious path/); + }); + + it("refuses a folder that is not a session id", () => { + // The case the tick box makes possible: hlsUrl is editable, so someone + // can paste a URL naming a folder shared with other content. + const url = `${PUBLIC}/shared-videos/master.m3u8`; + expect(refusalOf(resolveCollectionPrefix(url, PUBLIC))).toMatch(/not a session id/); + }); + + it("refuses when either side is missing", () => { + expect(refusalOf(resolveCollectionPrefix(undefined, PUBLIC))).toMatch(/no media URL/); + expect(refusalOf(resolveCollectionPrefix(HLS, undefined))).toMatch(/no public URL/); + expect(refusalOf(resolveCollectionPrefix("", PUBLIC))).toMatch(/no media URL/); + }); + }); + + it("never returns a prefix with a leading or trailing slash", () => { + // The caller appends '/' to scope the listing; a stray slash would widen it. + const prefix = prefixOf(resolveCollectionPrefix(HLS, PUBLIC))!; + expect(prefix.startsWith("/")).toBe(false); + expect(prefix.endsWith("/")).toBe(false); + }); +}); diff --git a/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts b/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts new file mode 100644 index 0000000000..fc042f268f --- /dev/null +++ b/api/src/changeRequests/documentProcessing/deleteMediaCollection.ts @@ -0,0 +1,193 @@ +import { MediaDto } from "../../dto/MediaDto"; +import { DbService } from "../../db/db.service"; +import { S3Service } from "../../s3/s3.service"; +import { isBucketRelative, isInOurStorage } from "./mediaUrl"; + +/** + * Where a collection lives in its bucket, or why we will not touch it. + * + * A refusal is not an error: it is the safe answer for a URL we cannot prove we + * wrote, and the caller reports it as a warning rather than failing the request. + */ +export type PrefixResolution = { prefix: string } | { refusal: string }; + +/** + * The encoder names every collection prefix after its session id. + * + * Checked because `hlsUrl` is an editable field: someone can paste a URL naming a + * shared folder, and "delete everything under it" would then be a data-loss bug + * wearing a tick box. A collection this API did not produce is one it must not + * remove — and every collection the encoder has ever written satisfies this. + */ +const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** What the encoder publishes at the root of a collection. */ +export const MASTER = "/master.m3u8"; + +/** Where a bucket publishes its objects, and what to call it in a warning. */ +export type Bucket = { publicUrl?: string; name?: string }; + +export async function loadBucket( + bucketId: string, + db: DbService, +): Promise<{ bucket: Bucket } | { error: string }> { + try { + const result = await db.getDoc(bucketId); + if (!result.docs?.length) return { error: `bucket ${bucketId} no longer exists` }; + return { bucket: result.docs[0] }; + } catch (error) { + return { error: error.message }; + } +} + +/** + * Turn a published `hlsUrl` into the object prefix holding that collection. + * + * The guard falls out of the arithmetic rather than being bolted on: the only way + * to get an object key from a public URL is to strip the bucket's own public base + * from it, so a URL that does not start with that base cannot be resolved at all. + * That is precisely the "never delete a prefix we did not create" rule, and unlike + * a marker object it also protects every collection already in a bucket. + */ +export function resolveCollectionPrefix( + hlsUrl: string | undefined, + publicUrl: string | undefined, +): PrefixResolution { + if (!hlsUrl) return { refusal: "the document has no media URL" }; + + // Query strings and fragments are addressing, not location. + const url = hlsUrl.split(/[?#]/)[0]; + + // A relative URL is already a key: it says where in this document's bucket + // the collection is and nothing else, so there is no public base to strip + // and no way for the two to disagree. + let key: string; + if (isBucketRelative(url)) { + key = url.slice(1); + } else { + if (!publicUrl) { + return { refusal: "the bucket has no public URL configured" }; + } + const base = publicUrl.replace(/\/+$/, ""); + // The separator has to be part of the match, or a bucket published at + // `https://cdn/media` would claim URLs belonging to `https://cdn/media-archive`. + if (!url.startsWith(`${base}/`)) { + return { + refusal: `the media URL is not in this bucket (expected it to start with ${base}/)`, + }; + } + key = url.slice(base.length + 1); + } + + // Named before the suffix check below, which would otherwise report a master + // at the bucket root as "not a master playlist" — true but unhelpful for the + // one input where being clear matters most. + if (key === MASTER.slice(1)) return { refusal: "the media URL names the bucket root" }; + + if (!key.endsWith(MASTER)) { + return { + refusal: `the media URL does not name a master playlist (expected it to end with ${MASTER})`, + }; + } + + const prefix = key.slice(0, -MASTER.length); + if (!prefix) return { refusal: "the media URL names the bucket root" }; + + // A traversal cannot reach outside the bucket, but it can certainly reach a + // sibling prefix, and there is no legitimate reason for one to be here. + const segments = prefix.split("/"); + if (segments.some((s) => s === "" || s === "." || s === "..")) { + return { refusal: `the media URL has a suspicious path (${prefix})` }; + } + + const last = segments[segments.length - 1]; + if (!SESSION_ID.test(last)) { + return { + refusal: + `the media URL was not written by the encoder — its last folder ` + + `(${last}) is not a session id, so this API did not create it`, + }; + } + + return { prefix }; +} + +/** + * Delete the collection a document points at, if we can prove we wrote it. + * + * Best-effort by design, matching how images are handled: the caller is deleting a + * document, and refusing to do that because a bucket was unreachable would be + * worse than leaving objects behind. Everything that goes wrong comes back as a + * warning the CMS shows, and every key removed is logged first — the first real + * deletion in any bucket should be auditable after the fact. + */ +export async function deleteMediaCollection( + media: MediaDto | undefined, + bucketId: string | undefined, + db: DbService, +): Promise { + const warnings: string[] = []; + + if (!media?.hlsUrl) return warnings; + if (!bucketId) { + warnings.push( + "Media files were not deleted: the document has no storage bucket. " + + "Please remove them on the storage provider.", + ); + return warnings; + } + + const loaded = await loadBucket(bucketId, db); + if ("error" in loaded) { + warnings.push( + `Media files were not deleted: ${loaded.error}. ` + + "Please remove them on the storage provider.", + ); + return warnings; + } + const bucket = loaded.bucket; + + // Media hosted elsewhere has nothing here to delete, and a warning would send the + // operator looking for files their bucket never held. + if (!isInOurStorage(media.hlsUrl, [bucket.publicUrl])) return warnings; + + const resolved = resolveCollectionPrefix(media.hlsUrl, bucket.publicUrl); + if ("refusal" in resolved) { + warnings.push( + `Media files were not deleted because ${resolved.refusal}. ` + + "Please remove them on the storage provider if they are no longer needed.", + ); + return warnings; + } + + try { + const s3 = await S3Service.create(bucketId, db); + const keys = await s3.listObjectsUnder(`${resolved.prefix}/`); + + if (keys.length === 0) { + // Already gone, or never uploaded. Not worth a warning: the caller + // asked for the files to be absent and they are. + return warnings; + } + + // Named before removal, so a deletion that turns out to be wrong can be + // reconstructed from the log rather than guessed at. + console.log( + `Deleting ${keys.length} media object(s) under ${resolved.prefix}/ ` + + `in bucket ${bucket.name ?? bucketId}: ${keys.join(", ")}`, + ); + + // Batched: removeObjects takes a list, and a collection can run to + // hundreds of objects. 1000 is the S3 API's own limit per call. + for (let i = 0; i < keys.length; i += 1000) { + await s3.removeObjects(keys.slice(i, i + 1000)); + } + } catch (error) { + warnings.push( + `Some media files could not be deleted from storage: ${error.message}. ` + + "Please check the storage provider.", + ); + } + + return warnings; +} diff --git a/api/src/changeRequests/documentProcessing/mediaUrl.spec.ts b/api/src/changeRequests/documentProcessing/mediaUrl.spec.ts new file mode 100644 index 0000000000..a5c6df0ed4 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/mediaUrl.spec.ts @@ -0,0 +1,105 @@ +import { isInOurStorage, isBucketRelative, toAbsoluteMediaUrl, toStoredMediaUrl } from "./mediaUrl"; + +const BASE = "https://cdn.example.com/media"; +const REL = "/c5829f07-4ba8-42ed-a449-80d83e6c0b53/master.m3u8"; + +describe("media URL storage form", () => { + describe("toStoredMediaUrl", () => { + it("strips the bucket's public URL", () => { + expect(toStoredMediaUrl(`${BASE}${REL}`, BASE)).toBe(REL); + }); + + it("tolerates a trailing slash on the bucket", () => { + expect(toStoredMediaUrl(`${BASE}${REL}`, `${BASE}/`)).toBe(REL); + }); + + it("leaves a YouTube URL alone", () => { + const yt = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + expect(toStoredMediaUrl(yt, BASE)).toBe(yt); + }); + + it("leaves an HLS master on someone else's CDN alone", () => { + const other = "https://other.example.com/stream/master.m3u8"; + expect(toStoredMediaUrl(other, BASE)).toBe(other); + }); + + it("does not claim a bucket that merely shares a prefix", () => { + // `…/media` must not swallow `…/media-archive`. + const neighbour = "https://cdn.example.com/media-archive/x/master.m3u8"; + expect(toStoredMediaUrl(neighbour, BASE)).toBe(neighbour); + }); + + it("is idempotent — storing an already-relative URL changes nothing", () => { + expect(toStoredMediaUrl(REL, BASE)).toBe(REL); + }); + + it("returns the input when the bucket has no public URL", () => { + const abs = `${BASE}${REL}`; + expect(toStoredMediaUrl(abs, undefined)).toBe(abs); + }); + }); + + describe("toAbsoluteMediaUrl", () => { + it("joins a relative URL onto the bucket", () => { + expect(toAbsoluteMediaUrl(REL, BASE)).toBe(`${BASE}${REL}`); + }); + + it("leaves an external URL untouched", () => { + const yt = "https://youtu.be/dQw4w9WgXcQ"; + expect(toAbsoluteMediaUrl(yt, BASE)).toBe(yt); + }); + + it("cannot resolve a relative URL without a bucket", () => { + expect(toAbsoluteMediaUrl(REL, undefined)).toBeUndefined(); + }); + + it("round-trips", () => { + const abs = `${BASE}${REL}`; + expect(toAbsoluteMediaUrl(toStoredMediaUrl(abs, BASE), BASE)).toBe(abs); + }); + }); + + it("recognises the stored form", () => { + expect(isBucketRelative(REL)).toBe(true); + expect(isBucketRelative(`${BASE}${REL}`)).toBe(false); + expect(isBucketRelative(undefined)).toBe(false); + }); +}); + +/** + * What decides whether `mediaBucketId` is required. A bucket is how a URL is + * stored relative, how a bucket change migrates the files, and how deleting the + * document finds them — all meaningless for a collection that is not ours. + */ +describe("isInOurStorage", () => { + const BUCKETS = ["https://cdn.example.com/media", "http://test.com/media"]; + + it("claims a bucket-relative URL, which is nothing without a bucket", () => { + expect(isInOurStorage("/abc/master.m3u8", [])).toBe(true); + }); + + it("claims an absolute URL under a configured bucket", () => { + expect(isInOurStorage("https://cdn.example.com/media/abc/master.m3u8", BUCKETS)).toBe(true); + }); + + it("disclaims a YouTube link", () => { + expect(isInOurStorage("https://www.youtube.com/watch?v=rExcQ5nm_yU", BUCKETS)).toBe(false); + }); + + it("disclaims an HLS master on someone else's CDN", () => { + expect(isInOurStorage("https://elsewhere.example/x/master.m3u8", BUCKETS)).toBe(false); + }); + + it("does not let one bucket claim another whose name it prefixes", () => { + // The separator is part of the match, exactly as in toStoredMediaUrl. + expect(isInOurStorage("https://cdn.example.com/media-archive/x.m3u8", BUCKETS)).toBe(false); + }); + + it("ignores a bucket with no public URL rather than matching everything", () => { + expect(isInOurStorage("https://elsewhere.example/x.m3u8", [undefined, ""])).toBe(false); + }); + + it("is false for no URL at all", () => { + expect(isInOurStorage(undefined, BUCKETS)).toBe(false); + }); +}); diff --git a/api/src/changeRequests/documentProcessing/mediaUrl.ts b/api/src/changeRequests/documentProcessing/mediaUrl.ts new file mode 100644 index 0000000000..37cd70ecee --- /dev/null +++ b/api/src/changeRequests/documentProcessing/mediaUrl.ts @@ -0,0 +1,86 @@ +/** + * The shape a media URL is stored in. + * + * A collection this API can reach lives in a bucket the document already names + * through `mediaBucketId`, so repeating the bucket's public URL in `hlsUrl` + * stores the same fact twice — and the two then drift the moment a bucket is + * renamed, re-pointed at a new CDN, or the collection is moved. Stored relative, + * the URL says where inside the bucket the collection is and nothing more, and + * the bucket says where that is on the internet. + * + * Anything not under the bucket is left exactly as given: a YouTube link or an + * HLS master on someone else's CDN has no bucket to be relative to. + */ + +/** Whether a stored URL is a path inside the document's own bucket. */ +export function isBucketRelative(url: string | undefined): boolean { + return typeof url === "string" && url.startsWith("/"); +} + +/** + * The form to store, given what the user or the encoder supplied. + * + * Returns the input unchanged when there is no bucket to measure against or the + * URL is not inside it, so calling this on an external URL is safe and calling + * it twice does nothing the second time. + */ +export function toStoredMediaUrl( + hlsUrl: string | undefined, + publicUrl: string | undefined, +): string | undefined { + if (!hlsUrl || isBucketRelative(hlsUrl) || !publicUrl) return hlsUrl; + + const base = publicUrl.replace(/\/+$/, ""); + // The separator is part of the match, or a bucket published at + // `https://cdn/media` would claim `https://cdn/media-archive/...`. + if (!hlsUrl.startsWith(`${base}/`)) return hlsUrl; + + return hlsUrl.slice(base.length); +} + +/** + * The absolute URL a player should fetch, given what is stored. + * + * The inverse of {@link toStoredMediaUrl}, and the same courtesy in reverse: an + * already-absolute URL is returned untouched, so a consumer can call this on + * every media URL without asking which kind it holds. + */ +export function toAbsoluteMediaUrl( + stored: string | undefined, + publicUrl: string | undefined, +): string | undefined { + if (!stored || !isBucketRelative(stored)) return stored; + if (!publicUrl) return undefined; + return `${publicUrl.replace(/\/+$/, "")}${stored}`; +} + +/** + * Whether this URL names a collection in one of our own buckets. + * + * A bucket-relative URL is ours by construction — it is nothing without a + * bucket to measure it against. An absolute one has to be asked: it is ours if + * it sits under some configured bucket's public URL, and external otherwise. + * + * The distinction is what decides whether `mediaBucketId` is required. It is + * not bookkeeping: the bucket is how a URL is stored relative, how a bucket + * change migrates the files, and how deleting the document finds them. A + * YouTube link and an HLS master on someone else's CDN have none of that, and + * demanding a bucket for them records a bucket that does not own anything. + * + * The separator is part of the match, for the same reason it is in + * {@link toStoredMediaUrl}: a bucket published at `https://cdn/media` must not + * claim `https://cdn/media-archive/...`. + */ +export function isInOurStorage( + hlsUrl: string | undefined, + publicUrls: (string | undefined)[], +): boolean { + if (!hlsUrl) return false; + if (isBucketRelative(hlsUrl)) return true; + + return publicUrls.some((publicUrl) => { + if (!publicUrl) return false; + const base = publicUrl.replace(/\/+$/, ""); + return hlsUrl.startsWith(`${base}/`); + }); +} diff --git a/api/src/changeRequests/documentProcessing/migrateMediaCollection.spec.ts b/api/src/changeRequests/documentProcessing/migrateMediaCollection.spec.ts new file mode 100644 index 0000000000..586972c579 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/migrateMediaCollection.spec.ts @@ -0,0 +1,278 @@ +import { migrateMediaCollection } from "./migrateMediaCollection"; +import { S3Service } from "../../s3/s3.service"; +import { DbService } from "../../db/db.service"; +import { MediaDto } from "../../dto/MediaDto"; + +jest.mock("../../s3/s3.service", () => ({ S3Service: { create: jest.fn() } })); + +const SESSION = "c5829f07-4ba8-42ed-a449-80d83e6c0b53"; +const OLD_BASE = "http://old.example.com/media"; +const NEW_BASE = "http://new.example.com/media"; +const OLD_URL = `${OLD_BASE}/${SESSION}/master.m3u8`; + +const KEYS = [ + `${SESSION}/master.m3u8`, + `${SESSION}/stream_1080p/playlist.m3u8`, + `${SESSION}/media/v0_0.m4s`, +]; + +/** Buckets keyed by id, as `db.getDoc` would return them. */ +const stubDb = (buckets: Record) => + ({ + getDoc: jest.fn(async (id: string) => + buckets[id] ? { docs: [buckets[id]] } : { docs: [] }, + ), + }) as unknown as DbService; + +const defaultDb = () => + stubDb({ + "bucket-old": { publicUrl: OLD_BASE, name: "old-bucket" }, + "bucket-new": { publicUrl: NEW_BASE, name: "new-bucket" }, + }); + +/** + * A pair of fake buckets. Sizes are recorded per key so a truncated copy can be + * simulated by returning a different size from the destination. + */ +const stubS3 = ( + opts: { + keys?: string[]; + sizes?: Record; + destinationSizes?: Record; + putRejects?: string; + removeRejects?: boolean; + } = {}, +) => { + const keys = opts.keys ?? KEYS; + const sizes = opts.sizes ?? Object.fromEntries(keys.map((k) => [k, 100])); + + const source = { + listObjectsUnder: jest.fn().mockResolvedValue(keys), + statObject: jest.fn(async (k: string) => ({ + size: sizes[k], + metaData: { "content-type": "video/iso.segment" }, + })), + getObject: jest.fn(async (k: string) => `stream:${k}`), + removeObjects: opts.removeRejects + ? jest.fn().mockRejectedValue(new Error("bucket is read-only")) + : jest.fn().mockResolvedValue(undefined), + }; + + const destination = { + putStream: jest.fn(async (k: string) => { + if (opts.putRejects === k) throw new Error("connection reset"); + }), + statObject: jest.fn(async (k: string) => ({ + size: (opts.destinationSizes ?? sizes)[k], + })), + }; + + (S3Service.create as jest.Mock).mockImplementation(async (bucketId: string) => + bucketId === "bucket-old" ? source : destination, + ); + + return { source, destination }; +}; + +const media = (): MediaDto => ({ hlsUrl: OLD_URL }) as MediaDto; + +const migrate = (m: MediaDto, db: DbService) => + migrateMediaCollection(m, OLD_URL, "bucket-old", "bucket-new", db); + +describe("migrateMediaCollection", () => { + beforeEach(() => jest.clearAllMocks()); + + it("copies every object, then repoints the document at the new bucket", async () => { + const { source, destination } = stubS3(); + const m = media(); + + const result = await migrate(m, defaultDb()); + + expect(result.failed).toBe(false); + expect(destination.putStream).toHaveBeenCalledTimes(KEYS.length); + expect(m.hlsUrl).toBe(`${NEW_BASE}/${SESSION}/master.m3u8`); + expect(source.removeObjects).toHaveBeenCalledWith(KEYS); + }); + + it("preserves each object's key, so the playlists' relative paths still resolve", async () => { + // Media playlists reference segments as `../media/_.m4s`. Renaming + // anything on the way across would break playback silently. + const { destination } = stubS3(); + + await migrate(media(), defaultDb()); + + expect(destination.putStream.mock.calls.map((c) => c[0])).toEqual(KEYS); + }); + + it("streams rather than buffering, and passes the source's size and type", async () => { + const { destination } = stubS3({ sizes: Object.fromEntries(KEYS.map((k) => [k, 512])) }); + + await migrate(media(), defaultDb()); + + expect(destination.putStream).toHaveBeenCalledWith( + KEYS[0], + `stream:${KEYS[0]}`, + 512, + "video/iso.segment", + ); + }); + + it("does not delete the source or move the URL when a copy fails", async () => { + // The guarantee that matters: a failed migration leaves a whole, reachable + // collection where the document already says it is. + const { source } = stubS3({ putRejects: KEYS[1] }); + const m = media(); + + const result = await migrate(m, defaultDb()); + + expect(result.failed).toBe(true); + expect(source.removeObjects).not.toHaveBeenCalled(); + expect(m.hlsUrl).toBe(OLD_URL); + }); + + it("treats a truncated copy as a failure", async () => { + const { source } = stubS3({ + sizes: { [KEYS[0]]: 100, [KEYS[1]]: 100, [KEYS[2]]: 100 }, + destinationSizes: { [KEYS[0]]: 100, [KEYS[1]]: 40, [KEYS[2]]: 100 }, + }); + const m = media(); + + const result = await migrate(m, defaultDb()); + + expect(result.failed).toBe(true); + expect(result.warnings.join(" ")).toContain(KEYS[1]); + expect(source.removeObjects).not.toHaveBeenCalled(); + expect(m.hlsUrl).toBe(OLD_URL); + }); + + it("succeeds when the copy worked but the originals could not be removed", async () => { + // Leftovers cost storage; they do not break playback, and the document + // already points at the new bucket. + stubS3({ removeRejects: true }); + const m = media(); + + const result = await migrate(m, defaultDb()); + + expect(result.failed).toBe(false); + expect(m.hlsUrl).toBe(`${NEW_BASE}/${SESSION}/master.m3u8`); + expect(result.warnings.join(" ")).toContain("could not be removed"); + }); + + it("refuses a URL it cannot prove the encoder wrote", async () => { + stubS3(); + const m = { hlsUrl: `${OLD_BASE}/shared-folder/master.m3u8` } as MediaDto; + + const result = await migrateMediaCollection( + m, + `${OLD_BASE}/shared-folder/master.m3u8`, + "bucket-old", + "bucket-new", + defaultDb(), + ); + + expect(result.failed).toBe(true); + expect(result.warnings.join(" ")).toContain("not a session id"); + }); + + it("refuses when the destination has no public URL to publish under", async () => { + stubS3(); + const db = stubDb({ + "bucket-old": { publicUrl: OLD_BASE, name: "old" }, + "bucket-new": { name: "new" }, + }); + + const result = await migrate(media(), db); + + expect(result.failed).toBe(true); + expect(result.warnings.join(" ")).toContain("no public URL"); + expect(S3Service.create).not.toHaveBeenCalled(); + }); + + it("fails when a bucket document has gone", async () => { + stubS3(); + const db = stubDb({ "bucket-old": { publicUrl: OLD_BASE } }); + + const result = await migrate(media(), db); + + expect(result.failed).toBe(true); + expect(result.warnings.join(" ")).toContain("no longer exists"); + }); + + it("reports an empty source rather than repointing at nothing", async () => { + stubS3({ keys: [] }); + const m = media(); + + const result = await migrate(m, defaultDb()); + + expect(result.failed).toBe(true); + expect(m.hlsUrl).toBe(OLD_URL); + }); + + it("leaves a hand-edited URL alone instead of moving files under it", async () => { + // Changing the URL and the bucket together is repointing the document, not + // asking for a migration. + stubS3(); + const m = { hlsUrl: "http://elsewhere/x/master.m3u8" } as MediaDto; + + const result = await migrateMediaCollection( + m, + OLD_URL, + "bucket-old", + "bucket-new", + defaultDb(), + ); + + expect(result.failed).toBe(false); + expect(m.hlsUrl).toBe("http://elsewhere/x/master.m3u8"); + expect(S3Service.create).not.toHaveBeenCalled(); + }); + + it("leaves external media alone and lets the bucket change stand", async () => { + // A YouTube link or a master on someone else's CDN is not ours to move, + // and the bucket change is about where future output goes. Failing here + // would revert a deliberate change and warn about files that were never + // going anywhere. + stubS3(); + const yt = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + const m = { hlsUrl: yt } as MediaDto; + + const result = await migrateMediaCollection( + m, + yt, + "bucket-old", + "bucket-new", + defaultDb(), + ); + + expect(result.failed).toBe(false); + expect(result.warnings).toEqual([]); + expect(m.hlsUrl).toBe(yt); + expect(S3Service.create).not.toHaveBeenCalled(); + }); + + it("still moves media that is in the old bucket", async () => { + // The guard above must not swallow the case it sits in front of. + const { source } = stubS3(); + const m = media(); + + const result = await migrate(m, defaultDb()); + + expect(result.failed).toBe(false); + expect(source.removeObjects).toHaveBeenCalled(); + }); + + it("does nothing for a document that never had media", async () => { + stubS3(); + + const result = await migrateMediaCollection( + {} as unknown as MediaDto, + undefined, + "bucket-old", + "bucket-new", + defaultDb(), + ); + + expect(result.failed).toBe(false); + expect(S3Service.create).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts b/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts new file mode 100644 index 0000000000..9e41b73cb2 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/migrateMediaCollection.ts @@ -0,0 +1,156 @@ +import { MediaDto } from "../../dto/MediaDto"; +import { DbService } from "../../db/db.service"; +import { S3Service } from "../../s3/s3.service"; +import { MASTER, loadBucket, resolveCollectionPrefix } from "./deleteMediaCollection"; +import { isBucketRelative, isInOurStorage } from "./mediaUrl"; + +/** The S3 API's own ceiling on keys per delete call. */ +const DELETE_BATCH = 1000; + +/** + * Move a media collection from one bucket to another, then point the document at + * its new home. + * + * Ordering is the whole design. Copy everything, prove every object arrived, only + * then rewrite `hlsUrl`, and only then delete the source. A collection is not a set + * of independent files — a master playlist without its segments is a broken video — + * so this deliberately does not follow the per-file "upload then delete" of + * `migrateImagesBetweenBuckets`, where a partial result costs one thumbnail. + * + * On any failure the caller reverts `mediaBucketId`, which is what keeps the + * document honest: `mediaBucketId` and `hlsUrl` must always name the same bucket, + * or a later delete cannot resolve the collection and the files leak. + */ +export async function migrateMediaCollection( + media: MediaDto, + previousHlsUrl: string | undefined, + oldBucketId: string, + newBucketId: string, + db: DbService, +): Promise<{ failed: boolean; warnings: string[] }> { + const warnings: string[] = []; + + if (!previousHlsUrl) return { failed: false, warnings }; + + // A URL edited in the same save as a bucket change is the user repointing the + // document by hand, not asking for a move. Moving files then overwriting their + // edit would undo a deliberate action. + if (media.hlsUrl && media.hlsUrl !== previousHlsUrl) { + warnings.push( + "The media URL and the storage bucket were changed together, so no files were " + + "moved. Change the bucket on its own if you want the existing files migrated.", + ); + return { failed: false, warnings }; + } + + const oldResult = await loadBucket(oldBucketId, db); + if ("error" in oldResult) { + warnings.push(`Media files were not moved: ${oldResult.error}.`); + return { failed: true, warnings }; + } + const newResult = await loadBucket(newBucketId, db); + if ("error" in newResult) { + warnings.push(`Media files were not moved: ${newResult.error}.`); + return { failed: true, warnings }; + } + + const oldBucket = oldResult.bucket; + const newBucket = newResult.bucket; + + // Only an absolute URL has to be rebuilt, and only that needs the + // destination's public URL. + if (!newBucket.publicUrl && !isBucketRelative(previousHlsUrl)) { + warnings.push( + "Media files were not moved: the destination bucket has no public URL configured, " + + "so the new media URL cannot be built.", + ); + return { failed: true, warnings }; + } + + // External media is not ours to move, and a bucket change is about where future + // output goes; calling it a failed migration would revert a deliberate change. + if (!isInOurStorage(previousHlsUrl, [oldBucket.publicUrl])) return { failed: false, warnings }; + + const resolved = resolveCollectionPrefix(previousHlsUrl, oldBucket.publicUrl); + if ("refusal" in resolved) { + warnings.push(`Media files were not moved because ${resolved.refusal}.`); + return { failed: true, warnings }; + } + const prefix = resolved.prefix; + + try { + const source = await S3Service.create(oldBucketId, db); + const destination = await S3Service.create(newBucketId, db); + + const keys = await source.listObjectsUnder(`${prefix}/`); + if (keys.length === 0) { + warnings.push( + `Media files were not moved: nothing was found under ${prefix}/ in ` + + `${oldBucket.name ?? oldBucketId}.`, + ); + return { failed: true, warnings }; + } + + // Copy first, whole collection, streaming each object. Sequential on + // purpose: these are large objects and the point is to keep memory flat. + for (const key of keys) { + const stat = await source.statObject(key); + const stream = await source.getObject(key); + const contentType = + (stat.metaData && stat.metaData["content-type"]) || "application/octet-stream"; + + await destination.putStream(key, stream, stat.size, contentType); + + // Verified per object rather than at the end: the size is the one thing + // a truncated copy gets wrong, and checking it here names the object + // that failed instead of reporting the collection as generally bad. + const copied = await destination.statObject(key); + if (copied.size !== stat.size) { + throw new Error( + `${key} copied as ${copied.size} bytes but the source is ${stat.size}`, + ); + } + } + + // Only now is the new location real. A relative URL already names a path inside + // whichever bucket the document points at; only the legacy absolute form moves. + if (!isBucketRelative(media.hlsUrl)) { + media.hlsUrl = `${newBucket.publicUrl.replace(/\/+$/, "")}/${prefix}${MASTER}`; + } + + // Last, and its failure is not the migration's failure: the files are in + // the new bucket and the document points at them. Leftovers in the old + // bucket cost storage, not playback. + try { + console.log( + `Moved ${keys.length} media object(s) under ${prefix}/ from ` + + `${oldBucket.name ?? oldBucketId} to ${newBucket.name ?? newBucketId}; ` + + "removing the originals", + ); + for (let i = 0; i < keys.length; i += DELETE_BATCH) { + await source.removeObjects(keys.slice(i, i + DELETE_BATCH)); + } + } catch (error) { + warnings.push( + `Media files were copied to ${newBucket.name ?? newBucketId} but the originals ` + + `could not be removed from ${oldBucket.name ?? oldBucketId}: ${error.message}. ` + + "Please remove them on the storage provider.", + ); + } + + warnings.push( + `Successfully moved ${keys.length} media file(s) from ` + + `${oldBucket.name ?? oldBucketId} to ${newBucket.name ?? newBucketId}.`, + ); + return { failed: false, warnings }; + } catch (error) { + // Nothing was deleted and the URL was not rewritten. Copies already made are + // left: a retry overwrites them, and deleting on the way out risks objects we + // did not put there. + warnings.push( + `Media migration failed: ${error.message}. The files were left in ` + + `${oldBucket.name ?? oldBucketId}.`, + ); + return { failed: true, warnings }; + } +} diff --git a/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts b/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts index ddf28f8195..fbf38ce6b2 100644 --- a/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts +++ b/api/src/changeRequests/documentProcessing/processMediaDto.spec.ts @@ -1,393 +1,142 @@ import { processMedia } from "./processMediaDto"; -import { S3Service } from "../../s3/s3.service"; import { createTestingModule } from "../../test/testingModule"; -import * as fs from "fs"; -import * as path from "path"; -import { v4 as uuidv4 } from "uuid"; import { MediaDto } from "../../dto/MediaDto"; -import { MediaPreset, MediaType, DocType, StorageType } from "../../enums"; +import { PostDto } from "../../dto/PostDto"; import { DbService } from "../../db/db.service"; -import { storeCryptoData } from "../../util/encryption"; -import { s3TestConfig, createTestCredentials } from "../../test/s3TestConfig"; +import { DocType, SidecarType } from "../../enums"; +import { maskKeyHex } from "../../util/maskKey"; +import { sidecarId, getSidecar, deleteSidecarsForParent } from "../../sidecar/sidecar.service"; +import { getHlsKeySidecar } from "../../sidecar/hlsEncryptionKey"; + +const HLS_URL = "https://cdn.example.com/media/post-1/master.m3u8"; +const HLS_KEY = "0123456789abcdef0123456789abcdef"; + +function makePost(id: string): PostDto { + return { + _id: id, + type: DocType.Post, + memberOf: ["group-public-content"], + updatedBy: "user-test", + postType: "blog" as any, + } as PostDto; +} describe("processMediaDto", () => { let db: DbService; - let s3Service: S3Service; - let testBucketId: string; - let testBucket: string; - const resMedia: MediaDto[] = []; - - const testCredentials = createTestCredentials(); beforeAll(async () => { const module = await createTestingModule("process-media-dto"); db = module.dbService; - - testBucket = `test-media-${uuidv4()}`; - testBucketId = `storage-test-${uuidv4()}`; - testCredentials.bucketName = testBucket; - - // Create encrypted credentials for the test bucket - const encryptedCredId = await storeCryptoData(db, testCredentials); - - // Create a bucket document - const bucketDoc = { - _id: testBucketId, - type: DocType.Storage, - name: "Test Media Bucket", - mimeTypes: ["audio/*"], - publicUrl: `${s3TestConfig.publicUrl}/${testBucket}`, - storageType: StorageType.Media, - credential_id: encryptedCredId, - memberOf: ["group-super-admins"], - updatedTimeUtc: Date.now(), - }; - - await db.upsertDoc(bucketDoc); - - // Create S3Service instance and create the bucket - s3Service = await S3Service.create(testBucketId, db); - await s3Service.makeBucket(); - }); - - afterAll(async () => { - // Cleanup uploaded media files - const removeFiles = Array.from( - new Set( - resMedia.flatMap((r) => - r.fileCollections.map((f) => f.fileUrl.split("/").pop()!).filter(Boolean), - ), - ), - ); - if (removeFiles.length > 0) { - try { - await s3Service.removeObjects(removeFiles); - } catch { - // Ignore errors during cleanup - } - } - try { - await s3Service.removeBucket(); - } catch { - // Ignore errors if bucket is not empty or doesn't exist - } - - // Clean up storage document - if (testBucketId) { - const storageDoc = (await db.getDoc(testBucketId)).docs[0]; - if (storageDoc) { - storageDoc.deleteReq = 1; - await db.upsertDoc(storageDoc); - } - } - - S3Service.clearCache(); - }); - - it("should be defined", () => { - expect(processMedia).toBeDefined(); }); - it("can process and upload a media file", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - const warnings = await processMedia(media, undefined, db, testBucketId); - expect(warnings.warnings.length).toBe(0); - - // Check if files are uploaded (allow informational warnings like S3_PUBLIC_ACCESS_URL not configured) - const files = media.fileCollections.map((f) => f.fileUrl.split("/").pop()!); - expect(files.length).toBeGreaterThan(0); - - for (const file of files) { - const exists = await s3Service.objectExists(file); - expect(exists).toBe(true); + afterEach(async () => { + // Clean up the sidecars each test writes (deterministic ids per parent). + for (const id of ["post-store", "post-readback", "post-same", "post-a", "post-b"]) { + await deleteSidecarsForParent(db, id); } - resMedia.push(media); }); - it("can delete a removed media from S3", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - const originalFiles = media.fileCollections.map((f) => f.fileUrl.split("/").pop()!); + it("stores a submitted HLS key as a masked sidecar and keeps only the reference", async () => { + const parent = makePost("post-store"); + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; - // Simulate removing the media - const prevMedia = JSON.parse(JSON.stringify(media)) as MediaDto; - media.fileCollections = []; + const warnings = await processMedia(media, parent, db); - // Process with previous media - await processMedia(media, prevMedia, db, testBucketId); - - // Check if removed files are gone - for (const file of originalFiles) { - const exists = await s3Service.objectExists(file); - expect(exists).toBe(false); - } + expect(warnings).toEqual([]); + // hlsKey_id is the deterministic sidecar id, not a random crypto-doc id. + expect(media.hlsKey_id).toBe(sidecarId(parent._id, SidecarType.HlsEncryptionKey)); + // The plaintext key must not survive onto the document. + expect(media.hlsKey).toBeUndefined(); + expect(media.hlsUrl).toBe(HLS_URL); }); - it("discards user-added file collection objects", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); + it("stores a key that can be read back masked, not plaintext, and not as a crypto envelope", async () => { + const parent = makePost("post-readback"); + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.fileCollections.push({ - languageId: "invalid", - fileUrl: "http://example.com/invalid.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }); + await processMedia(media, parent, db); - await processMedia(media2, media, db, testBucketId); + const stored = await getHlsKeySidecar(db, parent._id); + expect(stored).toBeDefined(); - // Check if the client-added file collection is removed - expect(media2.fileCollections.length).toBe(1); + const seed = sidecarId(parent._id, SidecarType.HlsEncryptionKey); + // Masked: equals maskKeyHex(seed, key), and is not the raw key. + expect(stored!.maskedKeyHex).toBe(maskKeyHex(seed, HLS_KEY)); + expect(stored!.maskedKeyHex).not.toBe(HLS_KEY); - resMedia.push(media); + // Regression guard (ADR 0019): the stored payload is a sidecar, not a + // CryptoDto envelope — there is no `data.encrypted` AES-256-CBC blob. + const raw = await getSidecar(db, parent._id, SidecarType.HlsEncryptionKey); + expect(raw!.type).toBe(DocType.Sidecar); + expect(raw!.data).not.toHaveProperty("encrypted"); }); - it("should allow uploading media for different languages independently", async () => { - // First, upload media for English - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - expect(media.fileCollections.length).toBe(1); - expect(media.fileCollections[0].languageId).toBe("lang-eng"); + it("is idempotent per parent: the same parent gets the same sidecar id, replaced not duplicated", async () => { + const parent = makePost("post-same"); + const first: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; + const second: MediaDto = { hlsUrl: HLS_URL, hlsKey: "fedcba9876543210fedcba9876543210" }; - const englishFileUrl = media.fileCollections[0].fileUrl; + await processMedia(first, parent, db); + const firstId = first.hlsKey_id; + await processMedia(second, parent, db); - // Now upload media for Spanish, keeping the English media - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-spa", - }, - ]; + // Deterministic id → same id, not a new document each time. + expect(second.hlsKey_id).toBe(firstId); - await processMedia(media2, media, db, testBucketId); + // And the stored payload is the second key (replace, not append). + const stored = await getHlsKeySidecar(db, parent._id); + const seed = sidecarId(parent._id, SidecarType.HlsEncryptionKey); + expect(stored!.maskedKeyHex).toBe(maskKeyHex(seed, "fedcba9876543210fedcba9876543210")); - // Should have both English and Spanish media - expect(media2.fileCollections.length).toBe(2); - expect(media2.fileCollections.find((f) => f.languageId === "lang-eng")).toBeDefined(); - expect(media2.fileCollections.find((f) => f.languageId === "lang-spa")).toBeDefined(); - expect(media2.fileCollections.find((f) => f.languageId === "lang-eng")?.fileUrl).toBe( - englishFileUrl, - ); - - // Verify both files exist in S3 - for (const fileCollection of media2.fileCollections) { - const filename = fileCollection.fileUrl.split("/").pop()!; - const exists = await s3Service.objectExists(filename); - expect(exists).toBe(true); - } - - resMedia.push(media2); + const res = await db.getDoc(sidecarId(parent._id, SidecarType.HlsEncryptionKey)); + expect(res.docs).toHaveLength(1); }); - it("should replace media when uploading for same language", async () => { - // First, upload media for English - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - expect(media.fileCollections.length).toBe(1); - - const firstFileUrl = media.fileCollections[0].fileUrl; + it("gives different parents different sidecar ids", async () => { + const a = makePost("post-a"); + const b = makePost("post-b"); + const mediaA: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; + const mediaB: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; - // Upload a new media for English (should replace the old one) - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; + await processMedia(mediaA, a, db); + await processMedia(mediaB, b, db); - await processMedia(media2, media, db, testBucketId); - - // Should still have only one media file (the new one) - expect(media2.fileCollections.length).toBe(1); - expect(media2.fileCollections[0].languageId).toBe("lang-eng"); - expect(media2.fileCollections[0].fileUrl).not.toBe(firstFileUrl); - - resMedia.push(media2); + expect(mediaA.hlsKey_id).not.toBe(mediaB.hlsKey_id); }); - it("should warn when parentBucketId is not provided for upload", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; + it("leaves an unencrypted collection alone", async () => { + const parent = makePost("post-store"); + const media: MediaDto = { hlsUrl: HLS_URL }; - // Call without parentBucketId (undefined) - const result = await processMedia(media, undefined, db, undefined); + const warnings = await processMedia(media, parent, db); - // Should have a warning about missing bucket - expect(result.warnings.length).toBeGreaterThan(0); + expect(warnings).toEqual([]); + expect(media.hlsKey_id).toBeUndefined(); }); - it("should warn when bucket document is not found", async () => { - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - ]; + it("keeps an existing key reference when no new key is submitted", async () => { + const parent = makePost("post-store"); + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey_id: "sidecar-existing" }; - // Call with a non-existent bucket ID - const result = await processMedia(media, undefined, db, "nonexistent-bucket-id"); + await processMedia(media, parent, db); - // Should have warnings about bucket not found - expect(result.warnings.length).toBeGreaterThan(0); + expect(media.hlsKey_id).toBe("sidecar-existing"); }); - it("should warn when db is not provided for file deletion", async () => { - const media = new MediaDto(); - media.fileCollections = []; - - const prevMedia = new MediaDto(); - prevMedia.fileCollections = [ - { - languageId: "lang-eng", - fileUrl: `http://localhost:9000/test/some-file-key`, - bitrate: 128, - mediaType: MediaType.Audio, - }, - ]; + it("drops the key rather than persisting it in plain text when storing fails", async () => { + const parent = makePost("post-store"); + const media: MediaDto = { hlsUrl: HLS_URL, hlsKey: HLS_KEY }; + const failingDb = { + upsertDoc: () => Promise.reject(new Error("database unavailable")), + } as unknown as DbService; - // Call with no db and no parentBucketId - files to delete but no way to delete them - const result = await processMedia(media, prevMedia, undefined as any, undefined); - - expect(result.warnings.some((w) => w.includes("cannot be automatically deleted"))).toBe( - true, + await expect(processMedia(media, parent, failingDb)).rejects.toThrow( + /Failed to store the HLS key/, ); - }); - - it("should delete media file from S3 when removed from fileCollections", async () => { - // First, upload media for English and Spanish - const media = new MediaDto(); - media.fileCollections = []; - media.uploadData = [ - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-eng", - }, - { - fileData: fs.readFileSync( - path.resolve(__dirname + "/../../test/" + "silence.wav"), - ) as unknown as ArrayBuffer, - preset: MediaPreset.Default, - mediaType: MediaType.Audio, - languageId: "lang-spa", - }, - ]; - await processMedia(media, undefined, db, testBucketId); - expect(media.fileCollections.length).toBe(2); - - const englishFile = media.fileCollections.find((f) => f.languageId === "lang-eng"); - const spanishFile = media.fileCollections.find((f) => f.languageId === "lang-spa"); - expect(englishFile).toBeDefined(); - expect(spanishFile).toBeDefined(); - - const englishKey = englishFile!.fileUrl.split("/").pop()!; - const spanishKey = spanishFile!.fileUrl.split("/").pop()!; - - // Verify both files exist in S3 - expect(await s3Service.objectExists(englishKey)).toBe(true); - expect(await s3Service.objectExists(spanishKey)).toBe(true); - - // Remove English media from fileCollections (simulate user deletion) - const media2 = JSON.parse(JSON.stringify(media)) as MediaDto; - media2.fileCollections = media2.fileCollections.filter((f) => f.languageId !== "lang-eng"); - - await processMedia(media2, media, db, testBucketId); - - // Should only have Spanish media now - expect(media2.fileCollections.length).toBe(1); - expect(media2.fileCollections[0].languageId).toBe("lang-spa"); - - // Verify English file is deleted from S3 - expect(await s3Service.objectExists(englishKey)).toBe(false); - - // Verify Spanish file still exists in S3 - expect(await s3Service.objectExists(spanishKey)).toBe(true); - resMedia.push(media2); + // The whole point of the finally: a key that could not be stored must + // not reach the document by way of the caller's error handling. + expect(media.hlsKey).toBeUndefined(); + expect(media.hlsKey_id).toBeUndefined(); }); -}); +}); \ No newline at end of file diff --git a/api/src/changeRequests/documentProcessing/processMediaDto.ts b/api/src/changeRequests/documentProcessing/processMediaDto.ts index b6572455d9..25d2908169 100644 --- a/api/src/changeRequests/documentProcessing/processMediaDto.ts +++ b/api/src/changeRequests/documentProcessing/processMediaDto.ts @@ -1,451 +1,65 @@ import { MediaDto } from "../../dto/MediaDto"; -import { MediaUploadDataDto } from "../../dto/MediaUploadDataDto"; -import { MediaFileDto } from "../../dto/MediaFileDto"; -import { v4 as uuidv4 } from "uuid"; -import { S3Service } from "../../s3/s3.service"; +import { PostDto } from "../../dto/PostDto"; +import { TagDto } from "../../dto/TagDto"; import { DbService } from "../../db/db.service"; -import { StorageDto } from "../../dto/StorageDto"; -import { DocType } from "../../enums"; -import { getAudioFormatInfo } from "../../s3-audio/audioFormatDetection"; +import { SidecarType } from "../../enums"; +import { maskKeyHex } from "../../util/maskKey"; +import { sidecarId } from "../../sidecar/sidecar.service"; +import { HlsEncryptionKeyData, upsertHlsKeySidecar } from "../../sidecar/hlsEncryptionKey"; +import { toStoredMediaUrl } from "./mediaUrl"; /** - * Migrates all media files from one bucket to another - * Supports migration between different S3 systems (e.g., MinIO to AWS S3, or different MinIO instances) - * Each bucket uses its own credentials and endpoint, enabling cross-system transfers - * Only deletes from old bucket if migration is successful + * Processes the media object on a content parent document. * - * @param media - The media DTO containing file collections to migrate - * @param oldBucketId - The ID of the source bucket - * @param newBucketId - The ID of the destination bucket - * @param db - Database service to retrieve bucket configurations - * @returns Object with migration failure status and warnings - */ -async function migrateMediaBetweenBuckets( - media: MediaDto, - oldBucketId: string, - newBucketId: string, - db: DbService, -): Promise<{ failed: boolean; warnings: string[] }> { - const warnings: string[] = []; - - try { - // Create S3Service instances for each bucket - const oldS3Service = await S3Service.create(oldBucketId, db); - const newS3Service = await S3Service.create(newBucketId, db); - - // Get all media files to migrate - const allFiles = media.fileCollections; - - if (allFiles.length === 0) { - warnings.push("No media files to migrate."); - return { failed: false, warnings }; - } - - const oldBucketName = oldS3Service.getBucketName(); - const newBucketName = newS3Service.getBucketName(); - - let successfulMigrations = 0; - let failedMigrations = 0; - - // Migrate each file - for (const fileCollection of allFiles) { - try { - // Extract filename from URL - const urlParts = fileCollection.fileUrl.split("/"); - const filename = urlParts[urlParts.length - 1]; - - // Download from old bucket - const fileStream = await oldS3Service.getObject(filename); - const chunks: Uint8Array[] = []; - - // Collect all chunks - await new Promise((resolve, reject) => { - fileStream.on("data", (chunk: Uint8Array) => chunks.push(chunk)); - fileStream.on("end", () => resolve()); - fileStream.on("error", (err) => reject(err)); - }); - - const fileBuffer = Buffer.concat(chunks); - - // Get metadata from old bucket - const stat = await oldS3Service.getClient().statObject(oldBucketName, filename); - const metadata = stat.metaData || { "Content-Type": "audio/mpeg" }; - - // Upload to new bucket - await newS3Service.uploadFile( - filename, - fileBuffer, - metadata["Content-Type"] || "audio/mpeg", - ); - - // Delete from old bucket only after successful upload - await oldS3Service.getClient().removeObject(oldBucketName, filename); - - successfulMigrations++; - } catch (error) { - failedMigrations++; - warnings.push( - `Failed to migrate media file from bucket ${oldBucketName} to ${newBucketName}: ${error.message}`, - ); - } - } - - if (successfulMigrations > 0) { - warnings.push( - `Successfully migrated ${successfulMigrations} media file(s) from bucket ${oldBucketName} to ${newBucketName}`, - ); - } - - if (failedMigrations > 0) { - warnings.push( - `Failed to migrate ${failedMigrations} media file(s). These files remain in the old bucket.`, - ); - } - - // Migration is considered failed if ANY files failed to migrate - return { failed: failedMigrations > 0, warnings }; - } catch (error) { - warnings.push(`Media migration failed: ${error.message}`); - return { failed: true, warnings }; - } -} - -/** - * Processes an embedded media upload by uploading to S3 - * Requires bucket-specific credentials configured at the post/tag level - * Bucket ID is passed from the parent post/tag document for consistency - * Returns object with migration failure status and warnings + * Media is an HLS collection produced by the Luminary Media Convert desktop app. + * That app writes to the storage bucket itself, so there is nothing to upload here — + * the document carries a URL to a collection this API never handles the bytes of on + * the way in. + * + * What does need handling is the decryption key. It arrives once, on the change + * request that first saves the collection, and is stored as a masked sidecar so + * the raw key never rests on the content document or in a log line. The sidecar + * carries the parent's `memberOf` so the permission system gates it. See ADR 0019 + * (docs/adr/0019-hls-encryption-keys-as-non-replicated-sidecars.md). + * + * Moving and removing the collection are the caller's, in `processPostTagDto`: + * `migrateMediaCollection` on a bucket change and `deleteMediaCollection` when the + * document is deleted and the user asked for the files to go with it. */ export async function processMedia( media: MediaDto, - prevMedia: MediaDto | undefined, + parent: PostDto | TagDto, db: DbService, - parentBucketId?: string, - prevParentBucketId?: string, -): Promise<{ migrationFailed: boolean; warnings: string[] }> { +): Promise { const warnings: string[] = []; - let migrationFailed = false; - - try { - // Detect bucket change and migrate media if needed - if ( - prevMedia && - prevParentBucketId && - parentBucketId && - prevParentBucketId !== parentBucketId && - media.fileCollections.length > 0 - ) { - const migrationResult = await migrateMediaBetweenBuckets( - media, - prevParentBucketId, - parentBucketId, - db, - ); - warnings.push(...migrationResult.warnings); - migrationFailed = migrationResult.failed; - } - - if (prevMedia) { - // Track files to delete from S3 - const filesToDelete: string[] = []; - - // Strategy: The client sends ALL fileCollections it wants to keep - // We need to: - // 1. Delete files that are not in the client's list - // 2. Discard invalid files the client may have added - // 3. Replace files when uploading for the same language - - const languagesBeingUploaded = - media.uploadData?.map((u) => u.languageId).filter(Boolean) || []; - - // Get fileUrls from previous media (valid files) - const prevFileUrls = new Set(prevMedia.fileCollections.map((c) => c.fileUrl)); - - // Get fileUrls that the client is keeping (only keep if they were in prevMedia) - // BUT exclude files for languages that are being uploaded (they'll be replaced) - const keptFileUrls = new Set( - media.fileCollections - .filter((c) => { - // Only keep if it was in prevMedia - if (!prevFileUrls.has(c.fileUrl)) return false; - - // Don't keep if its language is being replaced by an upload - if (languagesBeingUploaded.includes(c.languageId)) return false; - - return true; - }) - .map((c) => c.fileUrl), - ); - - // Check each previous file collection - prevMedia.fileCollections.forEach((collection) => { - // If the file is not in the kept list, mark it for deletion - if (!keptFileUrls.has(collection.fileUrl)) { - // Extract key from URL - const urlParts = collection.fileUrl.split("/"); - const key = urlParts[urlParts.length - 1]; - if (key && key.length > 0) { - filesToDelete.push(key); - } - } - }); - - // Delete files from S3 using the parent bucket ID - if (filesToDelete.length > 0 && db && parentBucketId) { - try { - const result = await db.getDoc(parentBucketId); - if (!result.docs || result.docs.length === 0) { - warnings.push( - `Bucket ${parentBucketId} not found. Cannot delete ${ - filesToDelete.length - } files. Manual cleanup required for: ${filesToDelete.join(", ")}`, - ); - } else { - const bucketS3Service = await S3Service.create(parentBucketId, db); - - // Delete files from the bucket - for (const key of filesToDelete) { - try { - await bucketS3Service - .getClient() - .removeObject(bucketS3Service.getBucketName(), key); - } catch (error) { - warnings.push( - `Failed to delete ${key} from bucket ${bucketS3Service.getBucketName()}: ${ - error.message - }`, - ); - } - } - } - } catch (error) { - warnings.push( - `Failed to connect to bucket ${parentBucketId}: ${error.message}. Cannot delete ${filesToDelete.length} files.`, - ); - } - } else if (filesToDelete.length > 0 && (!db || !parentBucketId)) { - warnings.push( - `Warning: ${ - filesToDelete.length - } old media files cannot be automatically deleted without ${ - !db ? "database access" : "parent bucket ID" - }. ` + `Please manually clean up files on the storage provider`, - ); - } - - // Start with only valid files that the client is keeping - media.fileCollections = media.fileCollections.filter((c) => - keptFileUrls.has(c.fileUrl), - ); - } - - // Upload new files - if (media.uploadData) { - if (!db) { - warnings.push("Unable to upload media - system configuration error."); - return { migrationFailed, warnings }; - } - - if (!parentBucketId) { - warnings.push("Parent bucket ID is required for media uploads."); - return { migrationFailed, warnings }; - } - - const promises: Promise<{ success: boolean; warnings: string[] }>[] = []; - media.uploadData?.forEach((uploadData) => { - promises.push(processMediaUpload(uploadData, media, db, parentBucketId)); - }); - - const results = await Promise.all(promises); - - // Collect all warnings from uploads - results.forEach((result) => { - warnings.push(...result.warnings); - }); - - // If any uploads failed completely, we should clean up any successful uploads - const successfulUploads = results.filter((r) => r.success).length; - const failedUploads = results.filter((r) => !r.success).length; - - if (failedUploads > 0) { - warnings.push(`${failedUploads} of ${results.length} media uploads failed`); - - if (successfulUploads === 0) { - warnings.push("All media uploads failed - no media were processed"); - } - } - - delete media.uploadData; // Remove upload data after processing - } - } catch (error) { - warnings.push(`Media processing failed: ${error.message}`); - } - - return { migrationFailed, warnings }; -} - -async function processMediaUpload( - uploadData: MediaUploadDataDto, - media: MediaDto, - db: DbService, - bucketId: string, -): Promise<{ success: boolean; warnings: string[] }> { - const warnings: string[] = []; - - try { - let preset = uploadData?.preset || "default"; - if (preset != "default" && preset != "audio" && preset != "speech") { - preset = "default"; - } - - // Bucket ID is required - if (!bucketId) { - warnings.push( - "No bucket specified for media upload. Each post/tag must specify a target bucket with proper credentials.", - ); - return { success: false, warnings }; - } - - // Look up the bucket and create bucket-specific S3 client - let storage: StorageDto; + // Stored relative to the bucket the document already names, so the two + // cannot disagree later. External URLs are left alone — see mediaUrl.ts. + if (media.hlsUrl && parent.mediaBucketId) { try { - const bucketDocs = await db.getDocsByType(DocType.Storage); - const foundBucket = bucketDocs.docs.find( - (doc: any) => doc._id === bucketId, - ) as StorageDto; - - if (!foundBucket || !foundBucket.name) { - warnings.push( - `Bucket with ID ${bucketId} not found. Please configure a storage bucket with proper credentials before uploading media.`, - ); - return { success: false, warnings }; - } - - storage = foundBucket; - - // Validate file type against bucket's allowed mimeTypes (if specified) - // Use audio format detection to determine mimetype - if (storage.mimeTypes && storage.mimeTypes.length > 0) { - // Parse metadata to determine format - let detectedMimetype = "audio/mpeg"; // default - - try { - const { parseBuffer } = await import("music-metadata"); - const metadata = await parseBuffer(new Uint8Array(uploadData.fileData)); - const formatInfo = getAudioFormatInfo(metadata); - detectedMimetype = formatInfo.mime; - } catch { - // Fall back to default - } - - const isAllowed = storage.mimeTypes.some((allowedType) => { - // Support wildcards like "audio/*" - if (allowedType.endsWith("/*")) { - const prefix = allowedType.slice(0, -2); - return detectedMimetype.startsWith(prefix + "/"); - } - // Exact match - return detectedMimetype === allowedType; - }); - - if (!isAllowed) { - warnings.push( - `File type "${detectedMimetype}" is not allowed for bucket "${ - storage.name - }". Allowed types: ${storage.mimeTypes.join(", ")}`, - ); - return { success: false, warnings }; - } - } - - // Create bucket-specific S3 service with bucket's credentials - const s3Service = await S3Service.create(bucketId, db); - - // Process and upload the media file - const uploadResult = await uploadMediaFile(uploadData, s3Service, media, storage); - warnings.push(...uploadResult.warnings); - - if (!uploadResult.success) { - return { success: false, warnings }; - } - - return { success: true, warnings }; + const result = await db.getDoc(parent.mediaBucketId); + const publicUrl = result.docs?.[0]?.publicUrl; + media.hlsUrl = toStoredMediaUrl(media.hlsUrl, publicUrl) as string; } catch (error) { - warnings.push( - `Failed to connect to bucket ${bucketId}: ${error.message}. Please ensure the bucket has valid credentials configured.`, - ); - return { success: false, warnings }; + // Not fatal: an absolute URL still plays, and the next save + // normalises it once the bucket is readable again. + warnings.push(`Could not normalise the media URL: ${error.message}`); } - } catch (error) { - warnings.push(`Media upload failed: ${error.message}`); - return { success: false, warnings }; } -} -async function uploadMediaFile( - uploadData: MediaUploadDataDto, - s3Service: S3Service, - media: MediaDto, - storage: StorageDto, -): Promise<{ success: boolean; warnings: string[] }> { - const warnings: string[] = []; + if (!media.hlsKey) return warnings; try { - // Parse metadata to infer bitrate and format info - let formatInfo = { ext: "", mime: "application/octet-stream", isValidAudio: false }; - let bitrate = 0; - const u8 = new Uint8Array(uploadData.fileData); - - try { - const { parseBuffer } = await import("music-metadata"); - const metadata = await parseBuffer(u8); - - // Use robust format detection - formatInfo = getAudioFormatInfo(metadata); - bitrate = Math.round(metadata.format.bitrate || 0); - } catch { - // Fall back; format/bitrate unknown in this environment - } - - // Fallback to generic audio if we couldn't determine format - if (!formatInfo.ext) { - formatInfo.ext = "mp3"; // Use mp3 as safe default extension - formatInfo.mime = "audio/mpeg"; // safe default - } - - // Include file extension in the key for proper MIME type handling - const key = `${uuidv4()}-default.${formatInfo.ext}`; - - // Upload original buffer as-is - const buf = Buffer.from(u8); - - await s3Service.uploadFile(key, buf, formatInfo.mime); - - // Validate upload accessibility - const validateRes = await s3Service.objectExists(key); - if (!validateRes) { - warnings.push("Media file uploaded but not accessible"); - } - - // Construct the public URL using the bucket's publicUrl from StorageDto - // Remove trailing slash from publicUrl if present - const baseUrl = storage.publicUrl.replace(/\/$/, ""); - const fileUrl = `${baseUrl}/${key}`; - - const file = new MediaFileDto(); - file.languageId = uploadData.languageId; - file.fileUrl = fileUrl; - file.bitrate = bitrate; - file.mediaType = uploadData.mediaType; - - media.fileCollections.push(file); - - return { success: true, warnings }; + const seed = sidecarId(parent._id, SidecarType.HlsEncryptionKey); + const data: HlsEncryptionKeyData = { maskedKeyHex: maskKeyHex(seed, media.hlsKey) }; + media.hlsKey_id = await upsertHlsKeySidecar(db, parent, data); } catch (error) { - return { - success: false, - warnings: [`Failed to upload media file: ${error.message}\n`], - }; + throw new Error(`Failed to store the HLS key: ${error.message}`); + } finally { + // Dropped whether or not it was stored, and before the caller can catch: + // a key that failed to store must not reach the document either. + delete media.hlsKey; } + + return warnings; } diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.deleteMedia.spec.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.deleteMedia.spec.ts new file mode 100644 index 0000000000..a629b0f095 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.deleteMedia.spec.ts @@ -0,0 +1,140 @@ +import processPostTagDto from "./processPostTagDto"; +import { deleteMediaCollection } from "./deleteMediaCollection"; +import { deleteImage, processImage } from "./processImageDto"; +import { processMedia } from "./processMediaDto"; +import { DbService } from "../../db/db.service"; +import { PostDto } from "../../dto/PostDto"; + +jest.mock("./deleteMediaCollection", () => ({ deleteMediaCollection: jest.fn() })); +jest.mock("./processImageDto", () => ({ processImage: jest.fn(), deleteImage: jest.fn() })); +jest.mock("./processMediaDto", () => ({ processMedia: jest.fn() })); + +/** + * A database stub, so the one branch under test does not need CouchDB. + * + * The delete path returns as soon as it has cascaded to the children, so + * `getContentByParentId` and `upsertDoc` are all it reaches. + */ +const stubDb = () => + ({ + getContentByParentId: jest.fn().mockResolvedValue({ docs: [] }), + upsertDoc: jest.fn().mockResolvedValue({ id: "x" }), + getDocs: jest.fn().mockResolvedValue({ docs: [] }), + getDoc: jest.fn().mockResolvedValue({ docs: [] }), + // The delete path also drops the document's sidecars (ADR 0019). + deleteDoc: jest.fn().mockResolvedValue(undefined), + }) as unknown as DbService; + +const HLS = "http://localhost:9000/media/c5829f07-4ba8-42ed-a449-80d83e6c0b53/master.m3u8"; + +/** The document as saved, which is the authority on where the files are. */ +const saved = () => + ({ + _id: "post-1", + type: "post", + memberOf: ["group-public-content"], + tags: [], + publishDateVisible: true, + postType: "blog", + mediaBucketId: "bucket-media", + media: { hlsUrl: HLS, fileCollections: [] }, + }) as unknown as PostDto; + +/** The delete request, carrying the user's answer from the confirmation. */ +const deleteRequest = (deleteFiles?: boolean) => { + const doc = saved(); + doc.deleteReq = 1; + if (deleteFiles !== undefined) doc.media!.deleteFiles = deleteFiles; + return doc; +}; + +describe("processPostTagDto — deleting media files from storage", () => { + beforeEach(() => { + jest.clearAllMocks(); + (deleteMediaCollection as jest.Mock).mockResolvedValue([]); + (deleteImage as jest.Mock).mockResolvedValue([]); + (processImage as jest.Mock).mockResolvedValue({ warnings: [] }); + (processMedia as jest.Mock).mockResolvedValue([]); + }); + + it("leaves storage alone when the user did not opt in", async () => { + // The guarantee that matters most: deleting a document must never remove + // anyone's video unless they asked for it in the confirmation. + await processPostTagDto(deleteRequest(), saved(), stubDb()); + + expect(deleteMediaCollection).not.toHaveBeenCalled(); + }); + + it("leaves storage alone when the box was explicitly unticked", async () => { + await processPostTagDto(deleteRequest(false), saved(), stubDb()); + + expect(deleteMediaCollection).not.toHaveBeenCalled(); + }); + + it("deletes the collection when asked", async () => { + const db = stubDb(); + await processPostTagDto(deleteRequest(true), saved(), db); + + expect(deleteMediaCollection).toHaveBeenCalledWith( + expect.objectContaining({ hlsUrl: HLS }), + "bucket-media", + db, + ); + }); + + it("takes the location from the saved document, not the incoming one", async () => { + // A hlsUrl edited in the same breath as the delete must not redirect the + // deletion at something else. Intent comes from the request; the target + // comes from what was actually saved. + const incoming = deleteRequest(true); + incoming.media!.hlsUrl = "http://localhost:9000/media/somewhere-else/master.m3u8"; + + await processPostTagDto(incoming, saved(), stubDb()); + + expect(deleteMediaCollection).toHaveBeenCalledWith( + expect.objectContaining({ hlsUrl: HLS }), + "bucket-media", + expect.anything(), + ); + }); + + it("does not call it at all for a document with no media", async () => { + const doc = deleteRequest(); + delete doc.media; + + await processPostTagDto(doc, saved(), stubDb()); + + expect(deleteMediaCollection).not.toHaveBeenCalled(); + }); + + it("reports what storage could not remove, without failing the delete", async () => { + (deleteMediaCollection as jest.Mock).mockResolvedValueOnce([ + "Media files were not deleted: bucket is unreachable", + ]); + + const warnings = await processPostTagDto(deleteRequest(true), saved(), stubDb()); + + expect(warnings.some((w) => w.includes("unreachable"))).toBe(true); + }); + + it("says nothing when the media is hosted elsewhere", async () => { + // Nothing in this bucket to delete, and no instruction to go looking. + (deleteMediaCollection as jest.Mock).mockResolvedValueOnce([]); + const incoming = deleteRequest(true); + incoming.media!.hlsUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + const saved_ = saved(); + saved_.media!.hlsUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + + const warnings = await processPostTagDto(incoming, saved_, stubDb()); + + expect(warnings).toEqual([]); + }); + + it("still cascades the delete to the child content documents", async () => { + // The media work must not displace what the delete path is actually for. + const db = stubDb(); + await processPostTagDto(deleteRequest(true), saved(), db); + + expect(db.getContentByParentId).toHaveBeenCalledWith("post-1"); + }); +}); diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.migrateMedia.spec.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.migrateMedia.spec.ts new file mode 100644 index 0000000000..bda0a0e565 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.migrateMedia.spec.ts @@ -0,0 +1,120 @@ +import processPostTagDto from "./processPostTagDto"; +import { migrateMediaCollection } from "./migrateMediaCollection"; +import { deleteMediaCollection } from "./deleteMediaCollection"; +import { processImage } from "./processImageDto"; +import { processMedia } from "./processMediaDto"; +import { DbService } from "../../db/db.service"; +import { PostDto } from "../../dto/PostDto"; + +jest.mock("./migrateMediaCollection", () => ({ migrateMediaCollection: jest.fn() })); +jest.mock("./deleteMediaCollection", () => ({ deleteMediaCollection: jest.fn() })); +jest.mock("./processImageDto", () => ({ processImage: jest.fn(), deleteImage: jest.fn() })); +jest.mock("./processMediaDto", () => ({ processMedia: jest.fn() })); + +const stubDb = () => + ({ + getContentByParentId: jest.fn().mockResolvedValue({ docs: [] }), + upsertDoc: jest.fn().mockResolvedValue({ id: "x" }), + getDocs: jest.fn().mockResolvedValue({ docs: [] }), + getDoc: jest.fn().mockResolvedValue({ docs: [] }), + }) as unknown as DbService; + +const HLS = "http://old.example.com/media/c5829f07-4ba8-42ed-a449-80d83e6c0b53/master.m3u8"; + +const post = (bucketId: string, hlsUrl = HLS) => + ({ + _id: "post-1", + type: "post", + memberOf: ["group-public-content"], + tags: [], + publishDateVisible: true, + postType: "blog", + mediaBucketId: bucketId, + media: { hlsUrl }, + }) as unknown as PostDto; + +describe("processPostTagDto — migrating media between buckets", () => { + beforeEach(() => { + jest.clearAllMocks(); + (migrateMediaCollection as jest.Mock).mockResolvedValue({ failed: false, warnings: [] }); + (processImage as jest.Mock).mockResolvedValue({ warnings: [] }); + (processMedia as jest.Mock).mockResolvedValue([]); + (deleteMediaCollection as jest.Mock).mockResolvedValue([]); + }); + + it("migrates when the bucket changes, from the saved URL", async () => { + const db = stubDb(); + const incoming = post("bucket-new"); + + await processPostTagDto(incoming, post("bucket-old"), db); + + expect(migrateMediaCollection).toHaveBeenCalledWith( + incoming.media, + HLS, + "bucket-old", + "bucket-new", + db, + ); + }); + + it("does not migrate when the bucket is unchanged", async () => { + await processPostTagDto(post("bucket-old"), post("bucket-old"), stubDb()); + + expect(migrateMediaCollection).not.toHaveBeenCalled(); + }); + + it("does not migrate on first save, when there is nothing to move from", async () => { + await processPostTagDto(post("bucket-new"), undefined, stubDb()); + + expect(migrateMediaCollection).not.toHaveBeenCalled(); + }); + + it("reverts the bucket when the migration fails, so the files stay reachable", async () => { + // The invariant: mediaBucketId and hlsUrl must name the same bucket. If the + // move failed, the files are still in the old one, so the document has to be. + (migrateMediaCollection as jest.Mock).mockResolvedValue({ + failed: true, + warnings: ["Media migration failed: connection reset."], + }); + const incoming = post("bucket-new"); + + const warnings = await processPostTagDto(incoming, post("bucket-old"), stubDb()); + + expect(incoming.mediaBucketId).toBe("bucket-old"); + expect(warnings.some((w) => w.includes("Reverted to previous bucket"))).toBe(true); + expect(warnings.some((w) => w.includes("connection reset"))).toBe(true); + }); + + it("keeps the new bucket when the migration succeeded", async () => { + const incoming = post("bucket-new"); + + await processPostTagDto(incoming, post("bucket-old"), stubDb()); + + expect(incoming.mediaBucketId).toBe("bucket-new"); + }); + + it("still stores the key after migrating", async () => { + // The migration must not displace what the media path is otherwise for. + const incoming = post("bucket-new"); + + await processPostTagDto(incoming, post("bucket-old"), stubDb()); + + // processMedia reads the bucket off the parent (mediaBucketId) rather than + // taking it as a separate argument. + expect(processMedia).toHaveBeenCalledWith( + incoming.media, + expect.objectContaining({ _id: "post-1", mediaBucketId: "bucket-new" }), + expect.anything(), + ); + }); + + it("does not migrate on a delete request", async () => { + // A delete removes files; it does not move them somewhere first. + const incoming = post("bucket-new"); + incoming.deleteReq = 1; + + await processPostTagDto(incoming, post("bucket-old"), stubDb()); + + expect(migrateMediaCollection).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.sidecar.spec.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.sidecar.spec.ts new file mode 100644 index 0000000000..bb10bd3fc3 --- /dev/null +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.sidecar.spec.ts @@ -0,0 +1,244 @@ +import { DbService } from "../../db/db.service"; +import { PostDto } from "../../dto/PostDto"; +import { ChangeReqDto } from "../../dto/ChangeReqDto"; +import { MediaDto } from "../../dto/MediaDto"; +import { createTestingModule } from "../../test/testingModule"; +import { PermissionSystem } from "../../permissions/permissions.service"; +import { processChangeRequest } from "../processChangeRequest"; +import { changeRequest_content } from "../../test/changeRequestDocuments"; +import { DocType, SidecarType } from "../../enums"; +import { sidecarId, deleteSidecarsForParent } from "../../sidecar/sidecar.service"; +import { getHlsKeySidecar } from "../../sidecar/hlsEncryptionKey"; +import { maskKeyHex } from "../../util/maskKey"; + +// Unmocked counterpart to processPostTagDto.spec.ts: these tests round-trip through +// the real processMedia write path, which the mocked spec cannot exercise. Covers +// sidecar lifecycle and deletion (ADR 0019). + +const HLS_URL = "https://cdn.example.com/media/master.m3u8"; +const KEY_A = "0123456789abcdef0123456789abcdef"; +const KEY_B = "fedcba9876543210fedcba9876543210"; + +const PARENT_IDS = [ + "post-del-with-key", + "post-del-no-key", + "post-clear-key", + "post-remove-media", + "post-write-wins", + "post-overwrite", + "post-unrelated-save", + "post-content-delete", +]; + +function postCr(id: string, media?: MediaDto): ChangeReqDto { + const doc: PostDto = { + _id: id, + type: DocType.Post, + memberOf: ["group-public-content"], + tags: [], + publishDateVisible: true, + postType: "blog", + image: `img-${id}`, + } as PostDto; + if (media) { + doc.media = media; + doc.mediaBucketId = "media-bucket"; + } + return { doc }; +} + +describe("processPostTagDto — sidecar lifecycle", () => { + let db: DbService; + + beforeAll(async () => { + const testingModule = await createTestingModule("process-post-tag-dto-sidecar"); + db = testingModule.dbService; + PermissionSystem.upsertGroups((await db.getGroups()).docs); + }); + + afterEach(async () => { + for (const id of PARENT_IDS) { + await deleteSidecarsForParent(db, id); + } + }); + + it("deletes a Post's key sidecar when the Post is deleted", async () => { + const id = "post-del-with-key"; + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + expect(await getHlsKeySidecar(db, id)).toBeDefined(); + + const del = postCr(id); + del.doc.deleteReq = 1; + await processChangeRequest("test-user", del, ["group-super-admins"], db); + + expect((await db.getDoc(id)).docs).toHaveLength(0); + expect(await getHlsKeySidecar(db, id)).toBeUndefined(); + }); + + it("deletes a Post with no sidecar and warns nothing about sidecars", async () => { + const id = "post-del-no-key"; + // No hlsKey submitted → no sidecar is written. + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL }), + ["group-super-admins"], + db, + ); + + const del = postCr(id); + del.doc.deleteReq = 1; + const result = await processChangeRequest("test-user", del, ["group-super-admins"], db); + + expect(result.result.ok).toBe(true); + expect((result.warnings ?? []).filter((w) => w.includes("sidecar"))).toEqual([]); + expect((await db.getDoc(id)).docs).toHaveLength(0); + }); + + it("removes the sidecar when the key field is cleared", async () => { + const id = "post-clear-key"; + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + expect(await getHlsKeySidecar(db, id)).toBeDefined(); + + // Key field cleared: media present, no hlsKey_id, no hlsKey. + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL }), + ["group-super-admins"], + db, + ); + + expect(await getHlsKeySidecar(db, id)).toBeUndefined(); + const post = (await db.getDoc(id)).docs[0] as PostDto; + expect(post.media?.hlsKey_id).toBeUndefined(); + }); + + it("removes the sidecar when the whole media object is removed", async () => { + const id = "post-remove-media"; + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + expect(await getHlsKeySidecar(db, id)).toBeDefined(); + + // No media at all — the case a check inside processMedia (gated on + // doc.media) would never see, which is why the removal check lives here. + await processChangeRequest("test-user", postCr(id), ["group-super-admins"], db); + + expect(await getHlsKeySidecar(db, id)).toBeUndefined(); + }); + + it("keeps the new key when a change request both drops hlsKey_id and submits a fresh hlsKey", async () => { + const id = "post-write-wins"; + const seed = sidecarId(id, SidecarType.HlsEncryptionKey); + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + + // hlsKey_id absent (would trigger deletion) but a fresh hlsKey is present — + // processMedia rewrites the sidecar first, so the write wins over the delete. + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_B }), + ["group-super-admins"], + db, + ); + + const stored = await getHlsKeySidecar(db, id); + expect(stored).toBeDefined(); + expect(stored!.maskedKeyHex).toBe(maskKeyHex(seed, KEY_B)); + expect(stored!.maskedKeyHex).not.toBe(maskKeyHex(seed, KEY_A)); + }); + + it("overwrites in place: a new key over an old one leaves one sidecar at the same id", async () => { + const id = "post-overwrite"; + const seed = sidecarId(id, SidecarType.HlsEncryptionKey); + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + const firstId = ((await db.getDoc(id)).docs[0] as PostDto).media!.hlsKey_id; + + // Second save carries the old hlsKey_id and a new hlsKey — replace, not append. + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey_id: firstId, hlsKey: KEY_B }), + ["group-super-admins"], + db, + ); + + const stored = await getHlsKeySidecar(db, id); + expect(stored!.maskedKeyHex).toBe(maskKeyHex(seed, KEY_B)); + // Deterministic id → exactly one document, not two. + expect((await db.getDoc(seed)).docs).toHaveLength(1); + }); + + it("leaves the sidecar untouched when saving an unrelated field on a Post that has a key", async () => { + const id = "post-unrelated-save"; + const seed = sidecarId(id, SidecarType.HlsEncryptionKey); + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + const before = await getHlsKeySidecar(db, id); + expect(before).toBeDefined(); + + // Re-save carrying the existing hlsKey_id, changing only an unrelated field. + // Regression guard (ADR 0019): if hlsKey_id were not @Expose'd, + // instanceToPlain would drop it on write and the "reference disappeared" + // condition would delete the key on every unrelated save. + const update = postCr(id, { hlsUrl: HLS_URL, hlsKey_id: seed }); + update.doc.showComingSoon = true; + await processChangeRequest("test-user", update, ["group-super-admins"], db); + + const after = await getHlsKeySidecar(db, id); + expect(after).toBeDefined(); + expect(after!.maskedKeyHex).toBe(before!.maskedKeyHex); + const post = (await db.getDoc(id)).docs[0] as PostDto; + expect(post.media?.hlsKey_id).toBe(seed); + }); + + it("does not touch the Post's sidecar when a child Content translation is deleted", async () => { + const id = "post-content-delete"; + await processChangeRequest( + "test-user", + postCr(id, { hlsUrl: HLS_URL, hlsKey: KEY_A }), + ["group-super-admins"], + db, + ); + expect(await getHlsKeySidecar(db, id)).toBeDefined(); + + const content = changeRequest_content(); + content.doc._id = "content-del-translation"; + content.doc.parentId = id; + content.doc.language = "lang-eng"; + await processChangeRequest("test-user", content, ["group-super-admins"], db); + + const delContent = JSON.parse(JSON.stringify(content)) as ChangeReqDto; + delContent.doc.deleteReq = 1; + await processChangeRequest("test-user", delContent, ["group-super-admins"], db); + + expect((await db.getDoc("content-del-translation")).docs).toHaveLength(0); + // Sidecars hang off the Post/Tag, not Content — a translation delete must not + // reach the key. + expect(await getHlsKeySidecar(db, id)).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts index 8c43e0cb12..7f9cc7919f 100644 --- a/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.spec.ts @@ -5,7 +5,7 @@ import { PermissionSystem } from "../../permissions/permissions.service"; import { processChangeRequest } from "../processChangeRequest"; import { changeRequest_content, changeRequest_post } from "../../test/changeRequestDocuments"; import { ChangeReqDto } from "../../dto/ChangeReqDto"; -import { DocType, MediaType } from "../../enums"; +import { DocType } from "../../enums"; import { deleteImage, processImage } from "./processImageDto"; import { processMedia } from "./processMediaDto"; @@ -27,13 +27,25 @@ describe("processPostTagDto", () => { const testingModule = await createTestingModule("process-post-tag-dto"); db = testingModule.dbService; PermissionSystem.upsertGroups((await db.getGroups()).docs); + + // Whether a media URL needs a bucket named is answered against the + // configured buckets, so the tests below need one to be under. + await db.upsertDoc({ + _id: "media-bucket", + type: DocType.Storage, + memberOf: ["group-super-admins"], + name: "Media", + storageType: "media", + publicUrl: "http://test.com/media", + updatedTimeUtc: Date.now(), + } as any); }); beforeEach(() => { jest.clearAllMocks(); (deleteImage as jest.Mock).mockResolvedValue([]); (processImage as jest.Mock).mockResolvedValue({ warnings: [] }); - (processMedia as jest.Mock).mockResolvedValue({ warnings: [] }); + (processMedia as jest.Mock).mockResolvedValue([]); }); it("should cascade Post/Tag delete request to content documents", async () => { @@ -95,7 +107,6 @@ describe("processPostTagDto", () => { await processChangeRequest("test-user", postCr, ["group-super-admins"], db); await processChangeRequest("test-user", contentCr, ["group-super-admins"], db); - let contentRes = await db.getDoc(contentCr.doc._id); expect(contentRes.docs[0].parentAlwaysOffline).toBeUndefined(); @@ -316,14 +327,7 @@ describe("processPostTagDto", () => { changeRequest.doc._id = "post-blog6"; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket-id"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { - languageId: "lang-eng", - fileUrl: "http://test.com/test-audio.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }, - ], + hlsUrl: "http://test.com/media/post-blog6/master.m3u8", }; // This should not throw an error even though prevDoc is undefined @@ -343,14 +347,7 @@ describe("processPostTagDto", () => { changeRequest.doc.deleteReq = 1; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket-id"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { - languageId: "lang-eng", - fileUrl: "test-audio.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }, - ], + hlsUrl: "http://test.com/media/post-blog7/master.m3u8", }; // This should not throw an error even though prevDoc is undefined @@ -389,21 +386,17 @@ describe("processPostTagDto", () => { expect(result.warnings).toContain("Image cleanup warning"); }); - it("warns when media processing returns warnings during deletion", async () => { - (processMedia as jest.Mock).mockResolvedValueOnce({ - warnings: ["Media cleanup warning"], - }); - + it("does not process media when a document is deleted", async () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-delete-med-warn"; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "http://test.com/media/post-delete-med-warn/master.m3u8", }; changeRequest.doc.deleteReq = 1; + (processMedia as jest.Mock).mockClear(); + const result = await processChangeRequest( "test-user", changeRequest, @@ -411,7 +404,10 @@ describe("processPostTagDto", () => { db, ); - expect(result.warnings).toContain("Media cleanup warning"); + // The collection is written by the encoder, not by this API, and nothing + // here knows which objects belong to it — so deletion leaves it alone. + expect(processMedia).not.toHaveBeenCalled(); + expect(result.result.ok).toBe(true); }); it("calls processImage without bucketId when imageBucketId is not specified", async () => { @@ -502,13 +498,14 @@ describe("processPostTagDto", () => { ); }); - it("throws when mediaBucketId is not specified for non-deletion", async () => { + it("throws when a collection in our own storage does not name its bucket", async () => { + // Under a configured bucket's public URL, so it is ours: saved without + // naming the bucket it would be stored un-relative, and would break the + // moment that bucket was renamed or re-pointed. const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-no-med-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "http://test.com/media/post-no-med-bucket/master.m3u8", }; delete (changeRequest.doc as PostDto).mediaBucketId; @@ -517,61 +514,72 @@ describe("processPostTagDto", () => { ).rejects.toThrow("Bucket is not specified for media processing"); }); - it("reverts mediaBucketId when media migration fails", async () => { - // First create the post with old bucket + it("throws when a bucket-relative URL does not name its bucket", async () => { + // Nothing without one: the URL is a path and says nothing about where. const changeRequest = changeRequest_post(); - changeRequest.doc._id = "post-med-migrate-fail"; - (changeRequest.doc as PostDto).mediaBucketId = "old-media-bucket"; + changeRequest.doc._id = "post-relative-no-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "/post-relative-no-bucket/master.m3u8", }; - (processMedia as jest.Mock).mockResolvedValueOnce({ warnings: [] }); - await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); + delete (changeRequest.doc as PostDto).mediaBucketId; - // Now update with new bucket that fails migration - (processMedia as jest.Mock).mockResolvedValueOnce({ - warnings: [], - migrationFailed: true, - }); - (changeRequest.doc as PostDto).mediaBucketId = "new-media-bucket"; - const result = await processChangeRequest( - "test-user", - changeRequest, - ["group-super-admins"], - db, - ); + await expect( + processChangeRequest("test-user", changeRequest, ["group-super-admins"], db), + ).rejects.toThrow("Bucket is not specified for media processing"); + }); + + it("saves a YouTube link with no bucket, which is what the Video field is for", async () => { + // A YouTube link has no bucket to be relative to and nothing here to + // migrate or delete. Demanding one refused a URL an editor is meant to be + // able to type by hand. + const changeRequest = changeRequest_post(); + changeRequest.doc._id = "post-youtube-no-bucket"; + (changeRequest.doc as PostDto).media = { + hlsUrl: "https://www.youtube.com/watch?v=rExcQ5nm_yU", + }; + delete (changeRequest.doc as PostDto).mediaBucketId; - expect(result.warnings.some((w) => w.includes("Media migration failed"))).toBe(true); + await expect( + processChangeRequest("test-user", changeRequest, ["group-super-admins"], db), + ).resolves.not.toThrow(); }); - it("reverts mediaBucketId when processMedia throws an error", async () => { - // First create the post with old bucket + it("saves an HLS master on someone else's CDN with no bucket", async () => { + const changeRequest = changeRequest_post(); + changeRequest.doc._id = "post-external-hls"; + (changeRequest.doc as PostDto).media = { + hlsUrl: "https://cdn.example.com/somebody-else/master.m3u8", + }; + delete (changeRequest.doc as PostDto).mediaBucketId; + + await expect( + processChangeRequest("test-user", changeRequest, ["group-super-admins"], db), + ).resolves.not.toThrow(); + }); + + it("fails the change request when the key store throws (no silent key loss)", async () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-med-throw"; - (changeRequest.doc as PostDto).mediaBucketId = "old-media-bucket"; + (changeRequest.doc as PostDto).mediaBucketId = "media-bucket"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { languageId: "en", fileUrl: "test.mp3", bitrate: 128, mediaType: MediaType.Audio }, - ], + hlsUrl: "http://test.com/media/post-med-throw/master.m3u8", + hlsKey: "0123456789abcdef0123456789abcdef", }; - (processMedia as jest.Mock).mockResolvedValueOnce({ warnings: [] }); - await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); - // Now update with new bucket where processMedia throws - (processMedia as jest.Mock).mockRejectedValueOnce(new Error("Media processing failed")); - (changeRequest.doc as PostDto).mediaBucketId = "new-media-bucket"; - const result = await processChangeRequest( - "test-user", - changeRequest, - ["group-super-admins"], - db, - ); + (processMedia as jest.Mock).mockRejectedValueOnce(new Error("key store unavailable")); - expect(result.warnings.some((w) => w.includes("Bucket media processing failed"))).toBe( - true, - ); + // A key that could not be stored must fail the change request, not become a + // warning: the plaintext key existed only for this request (processMedia has + // already dropped it), so saving the Post with an `hlsUrl` and no `hlsKey_id` + // would leave an unplayable, unrecoverable collection. The save fails so the + // editor still holds the key and can retry. See ADR 0019. + await expect( + processChangeRequest("test-user", changeRequest, ["group-super-admins"], db), + ).rejects.toThrow("key store unavailable"); + + // The Post was not saved. + const res = await db.getDoc("post-med-throw"); + expect(res.docs).toHaveLength(0); }); it("copies tag properties to content documents for Tag type", async () => { @@ -609,33 +617,29 @@ describe("processPostTagDto", () => { } }); - it("can remove media from S3 when a post/tag document is marked for deletion", async () => { + it("passes the media object, the parent doc, and the db to processMedia on save", async () => { const changeRequest = changeRequest_post(); changeRequest.doc._id = "post-blog8"; (changeRequest.doc as PostDto).mediaBucketId = "test-bucket-id"; (changeRequest.doc as PostDto).media = { - fileCollections: [ - { - languageId: "lang-eng", - fileUrl: "test-audio.mp3", - bitrate: 128, - mediaType: MediaType.Audio, - }, - ], + hlsUrl: "http://test.com/media/post-blog8/master.m3u8", + hlsKey: "0123456789abcdef0123456789abcdef", }; - await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); + (processMedia as jest.Mock).mockClear(); - // Mark the post document for deletion - const deleteRequest = JSON.parse(JSON.stringify(changeRequest)) as ChangeReqDto; - deleteRequest.doc.deleteReq = 1; - await processChangeRequest("test-user", deleteRequest, ["group-super-admins"], db); + await processChangeRequest("test-user", changeRequest, ["group-super-admins"], db); + // The parent passed in is the class-transformer-instantiated PostDto inside + // processPostTagDto, not the raw changeRequest.doc fixture, so match it by its + // identifying fields rather than by deep equality against the fixture. expect(processMedia).toHaveBeenCalledWith( - { fileCollections: [] }, // Empty fileCollections to remove the media from S3 (changeRequest.doc as PostDto).media, + expect.objectContaining({ + _id: "post-blog8", + type: DocType.Post, + }), db, - (changeRequest.doc as PostDto).mediaBucketId, ); }); }); diff --git a/api/src/changeRequests/documentProcessing/processPostTagDto.ts b/api/src/changeRequests/documentProcessing/processPostTagDto.ts index dfbd06fd45..ea6804a5b7 100644 --- a/api/src/changeRequests/documentProcessing/processPostTagDto.ts +++ b/api/src/changeRequests/documentProcessing/processPostTagDto.ts @@ -2,9 +2,18 @@ import { ContentDto } from "../../dto/ContentDto"; import { PostDto } from "../../dto/PostDto"; import { TagDto } from "../../dto/TagDto"; import { DbService } from "../../db/db.service"; -import { DocType, Uuid } from "../../enums"; +import { DocType, SidecarType, Uuid } from "../../enums"; import { deleteImage, processImage } from "./processImageDto"; import { processMedia } from "./processMediaDto"; +import { deleteMediaCollection } from "./deleteMediaCollection"; +import { migrateMediaCollection } from "./migrateMediaCollection"; +import { isInOurStorage } from "./mediaUrl"; +import { StorageDto } from "../../dto/StorageDto"; +import { + deleteSidecar, + deleteSidecarsForParent, + syncSidecarMemberOf, +} from "../../sidecar/sidecar.service"; /** * Process Post / Tag DTO @@ -35,17 +44,20 @@ export default async function processPostTagDto( warnings.push(...imageWarnings); } - // Remove medias from S3 - if (doc.media) { - const mediaResult = await processMedia( - { fileCollections: [] }, - prevDoc?.media, - db, - prevDoc?.mediaBucketId, // Delete from the bucket where files currently exist + // Opt-in from the delete confirmation: irreversible, and the collection may be + // referenced somewhere this API cannot see. prevDoc knows where the files are. + if (doc.media?.deleteFiles) { + warnings.push( + ...(await deleteMediaCollection(prevDoc?.media, prevDoc?.mediaBucketId, db)), ); - if (mediaResult && mediaResult.warnings && mediaResult.warnings.length > 0) { - warnings.push(...mediaResult.warnings); - } + } + + // Sidecars go with their parent (hard delete, no DeleteCmd). Warn rather than + // block the delete, as deleteImage does: an orphan is unreadable anyway. + try { + await deleteSidecarsForParent(db, doc._id); + } catch (error) { + warnings.push(`Failed to delete sidecars for ${doc._id}: ${error.message}`); } return warnings; // no need to process further @@ -104,44 +116,49 @@ export default async function processPostTagDto( delete (doc as any).image; // Remove the legacy image field } - // Process media uploads if (doc.media) { - let mediaWarnings: string[] = []; - - // Check if bucket is specified for this upload - if (!doc.mediaBucketId) { - throw new Error("Bucket is not specified for media processing."); + // A collection in our own storage must name its bucket: that is how the URL is + // stored relative, migrated and deleted. External media has no bucket to name. + if (doc.media.hlsUrl && !doc.mediaBucketId) { + const buckets = await db.getDocsByType(DocType.Storage); + const publicUrls = buckets.docs.map((b: StorageDto) => b.publicUrl); + + if (isInOurStorage(doc.media.hlsUrl, publicUrls)) { + throw new Error("Bucket is not specified for media processing."); + } } - // Use the new bucket processing with db service for bucket lookup - try { - const result = await processMedia( + // A bucket change takes the files with it: `mediaBucketId` and `hlsUrl` must + // name the same bucket, or a later delete cannot find the collection. + if (prevDoc?.mediaBucketId && prevDoc.mediaBucketId !== doc.mediaBucketId) { + const migration = await migrateMediaCollection( doc.media, - prevDoc?.media, - db, + prevDoc.media?.hlsUrl, + prevDoc.mediaBucketId, doc.mediaBucketId, - prevDoc?.mediaBucketId, // Pass previous bucket ID for migration + db, ); - mediaWarnings = result.warnings; + warnings.push(...migration.warnings); - // If migration failed, revert to the old bucket ID to keep files accessible - if (result.migrationFailed && prevDoc?.mediaBucketId) { + if (migration.failed) { doc.mediaBucketId = prevDoc.mediaBucketId; warnings.push( "Media migration failed. Reverted to previous bucket configuration to ensure files remain accessible.", ); } - } catch (error) { - // If processing throws an error, also revert bucket ID - if (prevDoc?.mediaBucketId && doc.mediaBucketId !== prevDoc.mediaBucketId) { - doc.mediaBucketId = prevDoc.mediaBucketId; - } - mediaWarnings.push(`Bucket media processing failed: ${error.message}`); } - if (mediaWarnings && mediaWarnings.length > 0) { - warnings.push(...mediaWarnings); - } + // Deliberately not caught: the plaintext key exists only for this request, so a + // saved `hlsUrl` without its `hlsKey_id` would be unrecoverable. Failing the + // change request leaves the editor holding the key to retry. + warnings.push(...(await processMedia(doc.media, doc, db))); + } + + // Outside `if (doc.media)`: removing the whole media object removes the key too. + // A fresh `hlsKey` in the same request is a replacement processMedia has already + // stored at the same sidecar id (ADR 0019). + if (prevDoc?.media?.hlsKey_id && !doc.media?.hlsKey_id && !doc.media?.hlsKey) { + await deleteSidecar(db, doc._id, SidecarType.HlsEncryptionKey); } // Get content documents that are children of the Post / Tag document @@ -177,6 +194,10 @@ export default async function processPostTagDto( await db.upsertDoc(contentDoc); } + // Re-stamp the parent's memberOf onto its sidecars (same groups as the parent); + // run on every save, not just media changes. + await syncSidecarMemberOf(db, doc); + // tag caching to the taggedDocs / parentTaggedDocs property of tag / content documents. This is done to improve client query performance. const addedTags = prevDoc ? doc.tags.filter((tag) => !prevDoc.tags.includes(tag)) : doc.tags; const removedTags = prevDoc ? prevDoc.tags.filter((tag) => !doc.tags.includes(tag)) : []; diff --git a/api/src/changeRequests/validateChangeRequest.spec.ts b/api/src/changeRequests/validateChangeRequest.spec.ts index 84296181ab..696248fc5b 100644 --- a/api/src/changeRequests/validateChangeRequest.spec.ts +++ b/api/src/changeRequests/validateChangeRequest.spec.ts @@ -97,6 +97,116 @@ describe("validateChangeRequest", () => { expect(result.error).toContain("Invalid document type"); }); + // An explicit deny is the only barrier between a client-authored sidecar and + // CouchDB — the type check no longer rejects it once Sidecar is a valid enum member. + describe("sidecar change requests are rejected", () => { + const sidecarId = "sidecar-post-sidecar-test-hlsEncryptionKey"; + + afterEach(async () => { + // Clean up any sidecar that leaked through a regression. + await db.deleteDoc(sidecarId).catch(() => {}); + await db.deleteDoc("post-sidecar-test").catch(() => {}); + }); + + it("rejects doc.type: 'sidecar' as an invalid document type", async () => { + const changeRequest = { + doc: { + _id: sidecarId, + type: "sidecar", + memberOf: ["group-test"], + parentId: "post-sidecar-test", + parentType: "post", + sidecarType: "hlsEncryptionKey", + data: { maskedKeyHex: "0".repeat(32) }, + }, + }; + + const result = await validateChangeRequest(changeRequest, ["group-test"], db); + + expect(result.validated).toBe(false); + expect(result.error).toContain("Invalid document type"); + }); + + it("rejects a well-formed sidecar body and writes no document", async () => { + // Assert the doc is absent, not just the error — the risk is db.upsertDoc + // running after a validation pass. + const changeRequest = { + doc: { + _id: sidecarId, + type: "sidecar", + memberOf: ["group-test"], + parentId: "post-sidecar-test", + parentType: "post", + sidecarType: "hlsEncryptionKey", + data: { maskedKeyHex: "0".repeat(32) }, + }, + }; + + const result = await validateChangeRequest(changeRequest, ["group-test"], db); + + expect(result.validated).toBe(false); + const stored = await db.getDoc(sidecarId); + expect(stored.docs).toHaveLength(0); + }); + + it("rejects a deleteReq naming a sidecar and leaves the sidecar intact", async () => { + // Create a real sidecar via the server-side path, then attempt a + // client deleteReq against it. + const { upsertSidecar } = await import("../sidecar/sidecar.service"); + await db.upsertDoc({ + _id: "post-sidecar-test", + type: "post", + memberOf: ["group-test"], + postType: "blog", + } as any); + await upsertSidecar( + db, + { + _id: "post-sidecar-test", + type: DocType.Post, + memberOf: ["group-test"], + updatedBy: "user-test", + } as any, + "hlsEncryptionKey" as any, + { maskedKeyHex: "0".repeat(32) }, + ); + const before = await db.getDoc(sidecarId); + expect(before.docs).toHaveLength(1); + + const changeRequest = { + doc: { + _id: sidecarId, + type: "sidecar", + deleteReq: 1, + }, + }; + + const result = await validateChangeRequest(changeRequest, ["group-test"], db); + + expect(result.validated).toBe(false); + const after = await db.getDoc(sidecarId); + expect(after.docs).toHaveLength(1); // survived + }); + + it("rejects a non-Sidecar doc squatting on the reserved 'sidecar-' _id prefix", async () => { + const changeRequest = { + doc: { + _id: sidecarId, // real DocType.Sidecar id, submitted as a Post + type: "post", + memberOf: ["group-test"], + postType: "blog", + }, + }; + + const result = await validateChangeRequest(changeRequest, ["group-test"], db); + + expect(result.validated).toBe(false); + expect(result.error).toContain("reserved"); + const stored = await db.getDoc(sidecarId); + expect(stored.docs).toHaveLength(0); + }); + }); + it("fails validation for invalid document data", async () => { const changeRequest = { doc: { @@ -283,9 +393,7 @@ describe("validateChangeRequest", () => { expect(result.validatedData.acl[0].groupId).toBe("group-public-content"); }); - it("validates a post with valid audio upload data for multiple languages", async () => { - const audioFile = fs.readFileSync(path.resolve(__dirname + "/../test/" + "silence.wav")); - + it("validates a post with an HLS media collection", async () => { const changeRequest = { id: 42, doc: { @@ -296,27 +404,8 @@ describe("validateChangeRequest", () => { tags: [], publishDateVisible: true, media: { - fileCollections: [], - uploadData: [ - { - fileData: audioFile, - preset: "default", - mediaType: "audio", - languageId: "lang-eng", - }, - { - fileData: audioFile, - preset: "default", - mediaType: "audio", - languageId: "lang-spa", - }, - { - fileData: audioFile, - preset: "default", - mediaType: "audio", - languageId: "lang-fra", - }, - ], + hlsUrl: "https://cdn.example.com/media/post-test/master.m3u8", + hlsKey: "0123456789abcdef0123456789abcdef", }, }, }; @@ -327,7 +416,7 @@ describe("validateChangeRequest", () => { expect(result.error).toBe(undefined); }); - it("fails validation for post with invalid audio upload data", async () => { + it("fails validation for a malformed hlsKey", async () => { const changeRequest = { id: 42, doc: { @@ -338,15 +427,10 @@ describe("validateChangeRequest", () => { tags: [], publishDateVisible: true, media: { - fileCollections: [], - uploadData: [ - { - fileData: Buffer.from("not an audio file"), - preset: "default", - mediaType: "audio", - languageId: "lang-eng", - }, - ], + hlsUrl: "https://cdn.example.com/media/post-test/master.m3u8", + // Not valid hex, and too short: masking this would silently + // produce a broken sidecar rather than a validation error. + hlsKey: "not-valid-hex", }, }, }; @@ -354,7 +438,28 @@ describe("validateChangeRequest", () => { const result = await validateChangeRequest(changeRequest, ["group-super-admins"], db); expect(result.validated).toBe(false); - expect(result.error).toContain("isAudio"); + expect(result.error).toContain("hlsKey"); + }); + + it("fails validation for media with no playlist URL", async () => { + const changeRequest = { + id: 42, + doc: { + _id: "post-test", + type: "post", + memberOf: ["group-super-admins"], + postType: "blog", + tags: [], + publishDateVisible: true, + // A key with nothing to decrypt is not a media object. + media: { hlsKey: "0123456789abcdef0123456789abcdef" }, + }, + }; + + const result = await validateChangeRequest(changeRequest, ["group-super-admins"], db); + + expect(result.validated).toBe(false); + expect(result.error).toContain("hlsUrl"); }); it("rejects a redirect whose slug has published content", async () => { diff --git a/api/src/changeRequests/validateChangeRequest.ts b/api/src/changeRequests/validateChangeRequest.ts index 388a0e390d..70ff96af18 100644 --- a/api/src/changeRequests/validateChangeRequest.ts +++ b/api/src/changeRequests/validateChangeRequest.ts @@ -65,6 +65,27 @@ export async function validateChangeRequest( }; } + // Sidecars are server-side only; deny explicitly. DocType.Sidecar is a valid + // enum member so the type check above won't reject it, and class-validator's + // forbidUnknownValues could flip silently on a dependency bump. + if (changeRequest.doc.type === DocType.Sidecar) { + return { + validated: false, + error: `Submitted "${changeRequest.doc.type}" document validation failed:\nInvalid document type`, + }; + } + + // Sidecar _ids are deterministic (sidecar--, sidecar.service.ts) + // and otherwise client-chosen on create. Without this guard a non-Sidecar doc could squat + // a victim's future sidecar id, permanently breaking that write with an opaque + // "Document type change not allowed" error pointing nowhere near the cause. + if (typeof changeRequest.doc._id === "string" && changeRequest.doc._id.startsWith("sidecar-")) { + return { + validated: false, + error: `Submitted "${changeRequest.doc.type}" document validation failed:\nDocument id may not use the reserved "sidecar-" prefix`, + }; + } + if (changeRequest.doc.type == DocType.Redirect) { const currentDoc = changeRequest.doc as RedirectDto; const slugIsUnique = await dbService.checkUniqueSlug( diff --git a/api/src/configuration.spec.ts b/api/src/configuration.spec.ts index c844556638..9f3a3835a2 100644 --- a/api/src/configuration.spec.ts +++ b/api/src/configuration.spec.ts @@ -121,4 +121,36 @@ describe("configuration", () => { expect(config.query.rateLimit.freeStrikes).toBe(5); expect(config.query.expensiveDocsExamined).toBe(2000); }); + + it("should default the sidecar rate-limit config to enabled", () => { + delete process.env.SIDECAR_RATE_LIMIT_READ_ENABLED; + delete process.env.SIDECAR_RATE_LIMIT_READ_FREE_STRIKES; + delete process.env.SIDECAR_RATE_LIMIT_PROBE_ENABLED; + delete process.env.SIDECAR_RATE_LIMIT_PROBE_FREE_STRIKES; + + const config = configuration(); + expect(config.sidecar.rateLimit.read).toEqual({ + enabled: true, + freeStrikes: 30, + baseBackoffMs: 2000, + maxBackoffMs: 60000, + strikeDecayMs: 2000, + }); + expect(config.sidecar.rateLimit.probe).toEqual({ + enabled: true, + freeStrikes: 10, + baseBackoffMs: 5000, + maxBackoffMs: 300000, + strikeDecayMs: 60000, + }); + }); + + it("should allow the sidecar rate limiters to be disabled and tuned via env vars", () => { + process.env.SIDECAR_RATE_LIMIT_READ_ENABLED = "false"; + process.env.SIDECAR_RATE_LIMIT_PROBE_FREE_STRIKES = "3"; + + const config = configuration(); + expect(config.sidecar.rateLimit.read.enabled).toBe(false); + expect(config.sidecar.rateLimit.probe.freeStrikes).toBe(3); + }); }); diff --git a/api/src/configuration.ts b/api/src/configuration.ts index e269a080d7..c02f920a6a 100644 --- a/api/src/configuration.ts +++ b/api/src/configuration.ts @@ -1,3 +1,5 @@ +import { RateLimiterConfig } from "./ratelimit/rateLimiter.service"; + export type DatabaseConfig = { connectionString: string; database: string; @@ -8,23 +10,6 @@ export type SyncConfig = { tolerance: number; }; -export type QueryRateLimitConfig = { - /** - * Master switch for the per-identity expensive-query rate limiter. Ships OFF — - * enable per environment only after the expensive-query logs show sane thresholds. - * Environment variable: QUERY_RATE_LIMIT_ENABLED (default false). - */ - enabled: boolean; - /** Expensive-query strikes tolerated before the first block. QUERY_RATE_LIMIT_FREE_STRIKES (default 3). */ - freeStrikes: number; - /** First block duration in ms; doubles per extra strike. QUERY_RATE_LIMIT_BASE_BACKOFF_MS (default 5000). */ - baseBackoffMs: number; - /** Cap on a single block window in ms. QUERY_RATE_LIMIT_MAX_BACKOFF_MS (default 300000). */ - maxBackoffMs: number; - /** One strike forgiven per this many ms. QUERY_RATE_LIMIT_STRIKE_DECAY_MS (default 600000). */ - strikeDecayMs: number; -}; - export type QueryConfig = { /** * Maximum `limit` accepted on a POST /query request, enforced centrally for every @@ -55,7 +40,30 @@ export type QueryConfig = { */ expensiveExaminedRatio: number; /** Per-identity expensive-query rate limiter (default off). */ - rateLimit: QueryRateLimitConfig; + rateLimit: RateLimiterConfig; +}; + +export type SidecarRateLimitConfig = { + /** + * Bounds successful key fetches — the harvesting-mitigation limiter described in + * ADR 0019 (docs/adr/0019-hls-encryption-keys-as-non-replicated-sidecars.md). Unlike the query + * limiter, this defaults ON: /sidecar hands out decryption keys, and the absence of a + * batch/listing parameter is only meaningful if a caller can't substitute a fast loop of single + * requests. + * Environment variable: SIDECAR_RATE_LIMIT_READ_ENABLED (default true). + */ + read: RateLimiterConfig; + /** + * Bounds repeated 403/404 responses (parent-id / permission probing). Lower ceiling than + * `read` since the endpoint's 404-for-both rule already makes probing uninformative (ADR 0019) + * — this limiter is a backstop, not the primary defense. Defaults ON. + * Environment variable: SIDECAR_RATE_LIMIT_PROBE_ENABLED (default true). + */ + probe: RateLimiterConfig; +}; + +export type SidecarConfig = { + rateLimit: SidecarRateLimitConfig; }; export type ValidationConfig = { @@ -114,6 +122,7 @@ export type Configuration = { database?: DatabaseConfig; sync?: SyncConfig; query?: QueryConfig; + sidecar?: SidecarConfig; imageProcessing?: ImageProcessingConfig; socketIo?: SocketIoConfig; validation?: ValidationConfig; @@ -143,6 +152,30 @@ export default () => strikeDecayMs: parseInt(process.env.QUERY_RATE_LIMIT_STRIKE_DECAY_MS, 10) || 600000, }, } as QueryConfig, + sidecar: { + rateLimit: { + read: { + enabled: process.env.SIDECAR_RATE_LIMIT_READ_ENABLED !== "false", + freeStrikes: parseInt(process.env.SIDECAR_RATE_LIMIT_READ_FREE_STRIKES, 10) || 30, + baseBackoffMs: + parseInt(process.env.SIDECAR_RATE_LIMIT_READ_BASE_BACKOFF_MS, 10) || 2000, + maxBackoffMs: + parseInt(process.env.SIDECAR_RATE_LIMIT_READ_MAX_BACKOFF_MS, 10) || 60000, + strikeDecayMs: + parseInt(process.env.SIDECAR_RATE_LIMIT_READ_STRIKE_DECAY_MS, 10) || 2000, + }, + probe: { + enabled: process.env.SIDECAR_RATE_LIMIT_PROBE_ENABLED !== "false", + freeStrikes: parseInt(process.env.SIDECAR_RATE_LIMIT_PROBE_FREE_STRIKES, 10) || 10, + baseBackoffMs: + parseInt(process.env.SIDECAR_RATE_LIMIT_PROBE_BASE_BACKOFF_MS, 10) || 5000, + maxBackoffMs: + parseInt(process.env.SIDECAR_RATE_LIMIT_PROBE_MAX_BACKOFF_MS, 10) || 300000, + strikeDecayMs: + parseInt(process.env.SIDECAR_RATE_LIMIT_PROBE_STRIKE_DECAY_MS, 10) || 60000, + }, + }, + } as SidecarConfig, imageProcessing: { imageQuality: parseInt(process.env.S3_IMG_QUALITY, 10) || 80, } as ImageProcessingConfig, diff --git a/api/src/db/db.service.ts b/api/src/db/db.service.ts index bd9d8b1cf3..f663f168a9 100644 --- a/api/src/db/db.service.ts +++ b/api/src/db/db.service.ts @@ -479,10 +479,14 @@ export class DbService extends EventEmitter { return await this.deleteDoc(doc._id); } else { - // Generate delete command if the document's memberOf field has changed + // Emit a DeleteCmd when memberOf changes so clients evict the old-group copy. + // Group carries its own ACL (no memberOf); Sidecar is never replicated to + // clients (nothing to evict), and a DeleteCmd would leak key-group membership + // into deleteCmd-* rooms. if ( existing && doc.type !== DocType.Group && + doc.type !== DocType.Sidecar && (existing as _contentBaseDto).memberOf && doc.memberOf && !isDeepStrictEqual( diff --git a/api/src/db/db.upgrade.spec.ts b/api/src/db/db.upgrade.spec.ts index 966892aa91..82fa66954c 100644 --- a/api/src/db/db.upgrade.spec.ts +++ b/api/src/db/db.upgrade.spec.ts @@ -50,6 +50,10 @@ jest.mock("./schemaUpgrade/v20", () => ({ __esModule: true, default: jest.fn().mockResolvedValue(undefined), })); +jest.mock("./schemaUpgrade/v21", () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); import { upgradeDbSchema } from "./db.upgrade"; import initSchemaVersion from "./schemaUpgrade/initSchemaVersion"; @@ -65,6 +69,7 @@ import v17 from "./schemaUpgrade/v17"; import v18 from "./schemaUpgrade/v18"; import v19 from "./schemaUpgrade/v19"; import v20 from "./schemaUpgrade/v20"; +import v21 from "./schemaUpgrade/v21"; describe("upgradeDbSchema", () => { const mockDb = {} as any; @@ -89,6 +94,7 @@ describe("upgradeDbSchema", () => { expect(v18).toHaveBeenCalledWith(mockDb); expect(v19).toHaveBeenCalledWith(mockDb); expect(v20).toHaveBeenCalledWith(mockDb); + expect(v21).toHaveBeenCalledWith(mockDb); }); it("should re-throw error and log it when an upgrade function fails", async () => { diff --git a/api/src/db/db.upgrade.ts b/api/src/db/db.upgrade.ts index 35b2e4e6e6..1f203e06e4 100644 --- a/api/src/db/db.upgrade.ts +++ b/api/src/db/db.upgrade.ts @@ -12,6 +12,7 @@ import v17 from "./schemaUpgrade/v17"; import v18 from "./schemaUpgrade/v18"; import v19 from "./schemaUpgrade/v19"; import v20 from "./schemaUpgrade/v20"; +import v21 from "./schemaUpgrade/v21"; // Re-exported for convenience so callers can read the fresh-DB baseline version from this module. export { FRESH_DB_SCHEMA_VERSION } from "./schemaUpgrade/freshDbSchemaVersion"; @@ -37,6 +38,7 @@ export async function upgradeDbSchema(db: DbService) { await v18(db); await v19(db); await v20(db); + await v21(db); } catch (error) { console.error("Database schema upgrade failed:", error); throw error; // Re-throw to prevent schema version from being updated diff --git a/api/src/db/schemaUpgrade/README.md b/api/src/db/schemaUpgrade/README.md index efa3bbf246..4e2351b259 100644 --- a/api/src/db/schemaUpgrade/README.md +++ b/api/src/db/schemaUpgrade/README.md @@ -134,7 +134,7 @@ Schema upgrades can be safely removed when: ### Current Baseline -**Current Schema Version**: 20 (as of 2026-08-21) +**Current Schema Version**: 21 (as of 2026-09-01) All production databases are expected to be at version 10 or higher. Historical upgrades v1-v9 have been removed as they are no longer needed. @@ -188,3 +188,7 @@ Backfills the new `CmsView` ACL permission (GitHub #160). CmsView gates CMS-scop Backfills `CmsView` on existing ACL entries that hold a CMS-only permission (`Edit`, `Delete`, `Assign`, `Translate`, `Publish`), matching the new auto-assign rule in `changeRequests/aclValidation.ts` and its CMS mirror `cms/src/components/groups/permissions.ts`. Those permissions previously auto-assigned `View`, which granted app-facing visibility as a side effect of a CMS-only permission change; `CmsView` is what they actually imply, and `View` is now an independent toggle. Entries holding `View` alone are genuine app-consumer grants and are deliberately left untouched, so `CmsView` stays a real, narrowable permission (ADR 0013). `group-public-users` is skipped entirely — it is effectively the anonymous group, and its broad seeded `edit`/`delete`/`publish` grants would otherwise expose drafts and expired content to anyone opening the CMS; its one intended `CmsView` grant (AuthProvider) was made by v19. Idempotent (only pushes `CmsView` where missing), safe to re-run including on fresh DBs and via `npm run seed`. Uses `insertDoc` to preserve `updatedTimeUtc`: the granted access takes effect via the server-recomputed AccessMap delivered on connect. The CMS-managed "default affinity" recommendation feature (`DocType.DefaultAffinity`) followed the same ACL-administration path instead of an upgrade script: `group-super-admins`/`group-public-content` get the `DefaultAffinity` ACL entries directly in their seed fixtures (fresh DBs only — existing deployed DBs need it granted via ACL administration), and the singleton doc (`api/src/util/defaultAffinity.ts`) is created lazily by the CMS on first save rather than backfilled (`cms/src/composables/useDefaultAffinity.ts`'s `saveDoc`). + +### v21 — Legacy `video` field moved to `media.hlsUrl` (2026-09-01) + +Moves the legacy per-language `ContentDto.video` URL onto the parent's `media.hlsUrl` (`_contentParentDto.media`, `MediaDto.hlsUrl`). The CMS video editor now writes exclusively to `parent.media.hlsUrl`, and the app already prefers `parentMedia.hlsUrl` over `content.video`, so `video` is dead weight once a parent has an `hlsUrl` and a stale leftover otherwise. For each Post/Tag with no `media.hlsUrl`, the first non-empty `video` found among its child Content docs is copied onto `parent.media.hlsUrl` (a parent can only hold one collection, so any other distinct value among the remaining children is logged and dropped); `video` is then deleted from every child that had it, since a per-child value is no longer read anywhere once `parentMedia.hlsUrl` exists. Uses `upsertDoc`, bumping `updatedTimeUtc` on every doc it touches so clients re-sync the new shape. diff --git a/api/src/db/schemaUpgrade/v21.spec.ts b/api/src/db/schemaUpgrade/v21.spec.ts new file mode 100644 index 0000000000..a12d2f28ea --- /dev/null +++ b/api/src/db/schemaUpgrade/v21.spec.ts @@ -0,0 +1,111 @@ +import v21 from "./v21"; +import { DocType } from "../../enums"; + +describe("v21 — legacy video field moved to media.hlsUrl", () => { + function mockDb(version: number, docsByType: Record, contentByParent: Record) { + const upserted: any[] = []; + const db = { + getSchemaVersion: jest.fn().mockResolvedValue(version), + setSchemaVersion: jest.fn().mockResolvedValue(undefined), + getDocsByType: jest.fn(async (docType: DocType) => ({ + docs: docsByType[docType] ?? [], + })), + getContentByParentId: jest.fn(async (parentId: string) => ({ + docs: contentByParent[parentId] ?? [], + })), + upsertDoc: jest.fn(async (doc: any) => { + upserted.push(doc); + }), + } as any; + return { db, upserted }; + } + + function post(id: string, media?: any) { + return { _id: id, type: DocType.Post, ...(media !== undefined ? { media } : {}) }; + } + + function content(id: string, parentId: string, video?: string): any { + return { _id: id, type: DocType.Content, parentId, ...(video ? { video } : {}) }; + } + + it("copies the child's video onto the parent's media.hlsUrl and clears it from the child", async () => { + const p = post("post-1"); + const c = content("content-1", "post-1", "https://example.com/master.m3u8"); + const { db, upserted } = mockDb(20, { [DocType.Post]: [p], [DocType.Tag]: [] }, { "post-1": [c] }); + + await v21(db); + + expect(p.media).toEqual({ fileCollections: [], hlsUrl: "https://example.com/master.m3u8" }); + expect(c.video).toBeUndefined(); + expect(c.parentMedia).toEqual(p.media); + expect(upserted).toContain(p); + expect(upserted).toContain(c); + expect(db.setSchemaVersion).toHaveBeenCalledWith(21); + }); + + it("stamps parentMedia on the siblings that never had a video of their own", async () => { + const p = post("post-1"); + const withVideo = content("content-en", "post-1", "https://example.com/a.m3u8"); + const without = content("content-fr", "post-1"); + const { db, upserted } = mockDb(20, { [DocType.Post]: [p], [DocType.Tag]: [] }, { + "post-1": [withVideo, without], + }); + + await v21(db); + + expect(without.parentMedia).toEqual(p.media); + expect(upserted).toContain(without); + }); + + it("leaves an existing parent hlsUrl untouched but still clears the child's video", async () => { + const p = post("post-1", { fileCollections: [], hlsUrl: "https://example.com/existing.m3u8" }); + const c = content("content-1", "post-1", "https://example.com/stale.m3u8"); + const { db, upserted } = mockDb(20, { [DocType.Post]: [p], [DocType.Tag]: [] }, { "post-1": [c] }); + + await v21(db); + + expect(p.media.hlsUrl).toBe("https://example.com/existing.m3u8"); + expect(c.video).toBeUndefined(); + expect(c.parentMedia).toBeUndefined(); + expect(upserted).not.toContain(p); + expect(upserted).toContain(c); + }); + + it("keeps the first distinct video value across languages and drops the rest", async () => { + const p = post("post-1"); + const c1 = content("content-1", "post-1", "https://example.com/a.m3u8"); + const c2 = content("content-2", "post-1", "https://example.com/b.m3u8"); + const { db } = mockDb(20, { [DocType.Post]: [p], [DocType.Tag]: [] }, { + "post-1": [c1, c2], + }); + + await v21(db); + + expect(p.media.hlsUrl).toBe("https://example.com/a.m3u8"); + expect(c1.video).toBeUndefined(); + expect(c2.video).toBeUndefined(); + }); + + it("skips parents with no video anywhere among their content", async () => { + const p = post("post-1"); + const c = content("content-1", "post-1"); + const { db, upserted } = mockDb(20, { [DocType.Post]: [p], [DocType.Tag]: [] }, { "post-1": [c] }); + + await v21(db); + + expect(p.media).toBeUndefined(); + expect(upserted).toHaveLength(0); + }); + + it("is a no-op when the schema version is not 20", async () => { + const p = post("post-1"); + const c = content("content-1", "post-1", "https://example.com/a.m3u8"); + const { db, upserted } = mockDb(18, { [DocType.Post]: [p], [DocType.Tag]: [] }, { "post-1": [c] }); + + await v21(db); + + expect(db.getDocsByType).not.toHaveBeenCalled(); + expect(db.setSchemaVersion).not.toHaveBeenCalled(); + expect(upserted).toHaveLength(0); + }); +}); diff --git a/api/src/db/schemaUpgrade/v21.ts b/api/src/db/schemaUpgrade/v21.ts new file mode 100644 index 0000000000..c9afd3c7dc --- /dev/null +++ b/api/src/db/schemaUpgrade/v21.ts @@ -0,0 +1,93 @@ +import { DbService } from "../db.service"; +import { DocType } from "../../enums"; +import { ContentDto } from "../../dto/ContentDto"; + +/** + * Upgrade the database schema from version 20 to 21: the legacy per-language + * `ContentDto.video` URL moves onto the parent's `media.hlsUrl`. + * + * A parent holds one collection, so the first child's value wins and any other + * distinct value is logged and dropped. `video` is then cleared from every child, + * and `parentMedia` stamped on them as a change request would. + */ +export default async function (db: DbService) { + try { + const schemaVersion = await db.getSchemaVersion(); + if (schemaVersion !== 20) { + console.info( + `Skipping schema upgrade v21: current version is ${schemaVersion}, expected 20`, + ); + return; + } + + console.info(`Upgrading database schema from version ${schemaVersion} to 21`); + + const stats = { + parentsScanned: 0, + parentsUpdated: 0, + childrenCleared: 0, + valuesDropped: 0, + }; + + for (const docType of [DocType.Post, DocType.Tag]) { + const { docs: parents } = await db.getDocsByType(docType); + + for (const parent of parents) { + stats.parentsScanned++; + + const { docs } = await db.getContentByParentId(parent._id); + const children = docs as ContentDto[]; + const withVideo = children.filter((c) => c.video); + if (!withVideo.length) continue; + + let parentUpdated = false; + if (!parent.media?.hlsUrl) { + if (!parent.media) parent.media = { fileCollections: [] }; + parent.media.hlsUrl = withVideo[0].video; + + const distinctValues = new Set(withVideo.map((c) => c.video)); + if (distinctValues.size > 1) { + stats.valuesDropped += distinctValues.size - 1; + console.warn( + `Parent ${parent._id} had ${distinctValues.size} distinct legacy video URLs across its content languages; kept "${withVideo[0].video}" on media.hlsUrl, dropped the rest.`, + ); + } + + parent.updatedTimeUtc = Date.now(); + await db.upsertDoc(parent); + stats.parentsUpdated++; + parentUpdated = true; + } + + // `parentMedia` is only ever stamped by a change request, so a migrated + // parent's children must get it here or the app shows no video until + // the parent is next saved. + for (const child of children) { + const hadVideo = Boolean(child.video); + if (!hadVideo && !parentUpdated) continue; + + delete child.video; + if (parentUpdated) { + child.parentMedia = parent.media; + child.parentMediaBucketId = parent.mediaBucketId; + } + child.updatedTimeUtc = Date.now(); + await db.upsertDoc(child); + if (hadVideo) stats.childrenCleared++; + } + } + } + + console.info( + `Video-field migration: scanned ${stats.parentsScanned} parent(s); moved a value onto ${stats.parentsUpdated} parent(s)' media.hlsUrl; cleared the legacy video field on ${stats.childrenCleared} content doc(s); ${stats.valuesDropped} distinct value(s) dropped (a parent can only hold one hlsUrl).`, + ); + + await db.setSchemaVersion(21); + console.info( + `Database schema upgrade from version ${schemaVersion} to 21 completed successfully`, + ); + } catch (error) { + console.error("Database schema upgrade to version 21 failed:", error); + throw error; + } +} diff --git a/api/src/dto/MediaDto.ts b/api/src/dto/MediaDto.ts index b2e8e05938..88a5cbd955 100644 --- a/api/src/dto/MediaDto.ts +++ b/api/src/dto/MediaDto.ts @@ -1,28 +1,46 @@ import "reflect-metadata"; // https://stackoverflow.com/questions/72009995/typeerror-reflect-getmetadata-is-not-a-function -import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"; -import { Expose, Type } from "class-transformer"; -import { MediaFileDto } from "./MediaFileDto"; -import { MediaUploadDataDto } from "./MediaUploadDataDto"; +import { IsBoolean, IsOptional, IsString, Matches } from "class-validator"; +import { Expose } from "class-transformer"; +import { Uuid } from "../enums"; /** * Database structured Media object */ export class MediaDto { - @IsOptional() @IsString() @Expose() - hlsUrl?: string; + hlsUrl: string; - @IsArray() - @ValidateNested({ each: true }) - @Type(() => MediaFileDto) // This throws an exception on validation failure, so we need to catch the error on validation. The message is less user-friendly but at least the validator fails and will protect our data. + /** + * ID of the sidecar document holding this collection's (optional) decryption key. + * The key itself is never on this document — clients fetch it from GET /sidecar. + */ + @IsOptional() + @IsString() @Expose() - fileCollections: MediaFileDto[] = []; + hlsKey_id?: Uuid; + /** + * Write-only: an encryption key submitted with a newly added HLS URL. Stored as a + * masked sidecar and dropped before the document is written, so it never rests here. + */ @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => MediaUploadDataDto) // This throws an exception on validation failure, so we need to catch the error on validation. The message is less user-friendly but at least the validator fails and will protect our data. - @Expose() - uploadData?: MediaUploadDataDto[]; + @IsString() + @Matches(/^[0-9a-f]{32}$/i, { message: "hlsKey must be a 32-character hex string (AES-128)" }) + @Expose({ toClassOnly: true }) + hlsKey?: string; + + /** + * Write-only: the user asked, in the delete confirmation, for the files in + * storage to go with the document. + * + * Carried on the document rather than as a separate call because a delete *is* + * a change request — the whole document arrives with `deleteReq` set, so the + * intent travels with the thing it applies to and cannot be separated from it + * in flight. Never persisted, like `hlsKey` above. + */ + @IsOptional() + @IsBoolean() + @Expose({ toClassOnly: true }) + deleteFiles?: boolean; } diff --git a/api/src/dto/SidecarDto.ts b/api/src/dto/SidecarDto.ts new file mode 100644 index 0000000000..5016342fcc --- /dev/null +++ b/api/src/dto/SidecarDto.ts @@ -0,0 +1,26 @@ +import { IsDefined, IsEnum, IsString } from "class-validator"; +import { Expose } from "class-transformer"; +import { _contentBaseDto } from "./_contentBaseDto"; +import { DocType, SidecarType, Uuid } from "../enums"; + +/** A parent-scoped payload, never replicated to clients. `memberOf` is copied + * from the parent so the permission system gates it. */ +export class SidecarDto extends _contentBaseDto { + @IsString() + @Expose() + parentId: Uuid; + + /** Post or Tag — permissions are checked against this type's groups. */ + @IsEnum(DocType) + @Expose() + parentType: DocType.Post | DocType.Tag; + + @IsEnum(SidecarType) + @Expose() + sidecarType: SidecarType; + + /** `unknown` (not `any`) forces narrowing; shape is set by `sidecarType`. */ + @IsDefined() + @Expose() + data: unknown; +} \ No newline at end of file diff --git a/api/src/dto/StorageDto.ts b/api/src/dto/StorageDto.ts index c0fe2644a5..0c3f571e2f 100644 --- a/api/src/dto/StorageDto.ts +++ b/api/src/dto/StorageDto.ts @@ -1,9 +1,46 @@ -import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator"; +import { + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from "class-validator"; import { Expose, Type } from "class-transformer"; import { _contentBaseDto } from "./_contentBaseDto"; import { S3CredentialDto } from "./S3CredentialDto"; import { StorageType, Uuid } from "../enums"; +/** + * Encode settings a media bucket applies to every encode written into it. + * All optional: an absent field means the encoder's own default. + */ +export class MediaEncodeSettingsDto { + @IsOptional() + @IsBoolean() + @Expose() + /** Encrypt the HLS output with AES-128. Absent = encrypted. */ + encrypted?: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + /** Byte-range HLS: one chunk file per rendition, split at chunkSizeMB. Absent = on. */ + byteRange?: boolean; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(10240) + @Expose() + /** Max size of one byte-range chunk file in MB, video and audio alike. Absent = encoder default. */ + chunkSizeMB?: number; +} + /** * Description of an S3 bucket / storage location used by the application. */ @@ -44,4 +81,11 @@ export class StorageDto extends _contentBaseDto { @Expose() /* Optional ID of EncryptedStorageDto document that holds encrypted S3CredentialDto data */ credential_id?: Uuid; + + @IsOptional() + @ValidateNested() + @Type(() => MediaEncodeSettingsDto) + @Expose() + /** Only meaningful on media buckets. */ + mediaSettings?: MediaEncodeSettingsDto; } diff --git a/api/src/endpoints/encoderConfig.controller.spec.ts b/api/src/endpoints/encoderConfig.controller.spec.ts new file mode 100644 index 0000000000..3a358c7b7e --- /dev/null +++ b/api/src/endpoints/encoderConfig.controller.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import * as request from "supertest"; +import { EncoderConfigController } from "./encoderConfig.controller"; +import { DbService } from "../db/db.service"; +import { AuthGuard } from "../auth/auth.guard"; +import * as permissionsService from "../permissions/permissions.service"; + +jest.mock("../validation/apiVersion", () => ({ + validateApiVersion: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../util/encryption", () => ({ + retrieveCryptoData: jest.fn().mockResolvedValue({ + endpoint: "https://s3.example.com", + accessKey: "key", + secretKey: "secret", + bucketName: "media", + }), +})); + +/** + * The response is shaped as the encoder's session body, so the bucket's encode + * settings must come out in the encoder's own field names — and their absence + * must leave the encoder's defaults in charge rather than spelling them out. + */ +describe("EncoderConfigController", () => { + let app: INestApplication; + const mockGetDoc = jest.fn(); + + const bucket = (mediaSettings?: object) => ({ + docs: [ + { + _id: "bucket-1", + name: "Media", + memberOf: ["group-editors"], + publicUrl: "https://cdn.example.com/media", + credential_id: "cred-1", + ...(mediaSettings !== undefined && { mediaSettings }), + }, + ], + }); + + beforeAll(async () => { + const testingModule: TestingModule = await Test.createTestingModule({ + controllers: [EncoderConfigController], + providers: [{ provide: DbService, useValue: { getDoc: mockGetDoc } }], + }) + .overrideGuard(AuthGuard) + .useValue({ + canActivate: (context: any) => { + const req = context.switchToHttp().getRequest(); + req.user = { groups: ["group-editors"], userId: "user-123" }; + return true; + }, + }) + .compile(); + + app = testingModule.createNestApplication(); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + mockGetDoc.mockReset(); + jest.spyOn(permissionsService.PermissionSystem, "verifyAccess").mockReturnValue(true); + }); + + const get = () => + request(app.getHttpServer()).get( + "/storage/encoderconfig?bucketId=bucket-1&apiVersion=0.0.0", + ); + + it("encrypts by default, which was the behaviour before it became a setting", async () => { + mockGetDoc.mockResolvedValue(bucket()); + + const res = await get().expect(200); + + expect(res.body.encryption).toEqual({ required: true }); + }); + + it("passes an explicit opt-out of encryption through", async () => { + mockGetDoc.mockResolvedValue(bucket({ encrypted: false })); + + const res = await get().expect(200); + + expect(res.body.encryption).toEqual({ required: false }); + }); + + it("omits the byte-range fields when unset, leaving the encoder's defaults in charge", async () => { + mockGetDoc.mockResolvedValue(bucket()); + + const res = await get().expect(200); + + expect(res.body).not.toHaveProperty("byteRange"); + expect(res.body).not.toHaveProperty("byteRangeMaxFileSizeMB"); + expect(res.body).not.toHaveProperty("audioByteRangeMaxFileSizeMB"); + }); + + it("sends one chunk size as both the video and the audio limit", async () => { + mockGetDoc.mockResolvedValue(bucket({ byteRange: true, chunkSizeMB: 100 })); + + const res = await get().expect(200); + + expect(res.body.byteRange).toBe(true); + expect(res.body.byteRangeMaxFileSizeMB).toBe(100); + expect(res.body.audioByteRangeMaxFileSizeMB).toBe(100); + }); + + it("still hands out the credentials and public URL beside the settings", async () => { + mockGetDoc.mockResolvedValue(bucket({ encrypted: false })); + + const res = await get().expect(200); + + expect(res.body.s3.bucket).toBe("media"); + expect(res.body.publicBaseUrl).toBe("https://cdn.example.com/media"); + }); +}); diff --git a/api/src/endpoints/encoderConfig.controller.ts b/api/src/endpoints/encoderConfig.controller.ts new file mode 100644 index 0000000000..cc36588c5c --- /dev/null +++ b/api/src/endpoints/encoderConfig.controller.ts @@ -0,0 +1,155 @@ +import { + Controller, + Get, + Header, + Query, + UseGuards, + Req, + HttpException, + HttpStatus, +} from "@nestjs/common"; +import { AuthGuard } from "../auth/auth.guard"; +import { DbService } from "../db/db.service"; +import { validateApiVersion } from "../validation/apiVersion"; +import { PermissionSystem } from "../permissions/permissions.service"; +import { AclPermission, DocType } from "../enums"; +import { S3CredentialDto } from "../dto/S3CredentialDto"; +import { retrieveCryptoData } from "../util/encryption"; +import { FastifyRequest } from "fastify"; + +/** + * Everything the local media encoder needs to write a collection to a bucket and + * to publish a URL for it. Shaped for the encoder's `POST /api/cms/sessions` body + * rather than for our own storage model, so the CMS forwards it without reshaping. + */ +export type EncoderConfigResponseDto = { + s3: { + endPoint: string; + port: number; + useSSL: boolean; + bucket: string; + accessKey: string; + secretKey: string; + }; + publicBaseUrl: string; + encryption: { required: boolean }; + byteRange?: boolean; + byteRangeMaxFileSizeMB?: number; + audioByteRangeMaxFileSizeMB?: number; +}; + +/** + * Hands out the S3 credentials for a media bucket. + * + * The encoder runs on the editor's own machine and uploads straight to the bucket, + * so it needs real credentials — there is no path where the server does the upload + * on its behalf. Credentials are stored encrypted and are not replicated to + * clients, which is why they are fetched here rather than read off the Storage + * document the CMS already holds. + * + * Gated on `Assign` rather than `View`: assigning a bucket is the right to publish + * into it, which is exactly what these credentials confer. `View` is what the + * status endpoint needs to render a connectivity dot, and is held far more widely. + */ +@Controller("storage") +export class EncoderConfigController { + constructor(private readonly dbService: DbService) {} + + @Get("encoderconfig") + @UseGuards(AuthGuard) + // Live bucket credentials must not sit in a browser or proxy cache. + @Header("Cache-Control", "no-store") + async getEncoderConfig( + @Query("bucketId") bucketId: string, + @Query("apiVersion") apiVersion: string, + @Req() request: FastifyRequest, + ): Promise { + await validateApiVersion(apiVersion); + + const userDetails = request.user; + + if (!bucketId) { + throw new HttpException("bucketId query parameter is required", HttpStatus.BAD_REQUEST); + } + + const bucketResult = await this.dbService.getDoc(bucketId); + if (!bucketResult.docs || bucketResult.docs.length === 0) { + throw new HttpException( + `Bucket configuration not found: ${bucketId}`, + HttpStatus.NOT_FOUND, + ); + } + + const bucket = bucketResult.docs[0]; + + const hasPermission = PermissionSystem.verifyAccess( + bucket.memberOf, + DocType.Storage, + AclPermission.Assign, + userDetails.groups, + ); + + if (!hasPermission) { + throw new HttpException( + "Insufficient permissions to encode to this bucket", + HttpStatus.FORBIDDEN, + ); + } + + if (!bucket.credential_id) { + throw new HttpException( + `No credentials configured for bucket: ${bucket.name}`, + HttpStatus.CONFLICT, + ); + } + + if (!bucket.publicUrl) { + throw new HttpException( + `No public URL configured for bucket: ${bucket.name}. The encoder needs one to ` + + "publish a playable address for the collection it writes.", + HttpStatus.CONFLICT, + ); + } + + const credentials = await retrieveCryptoData( + this.dbService, + bucket.credential_id, + ); + + if (!credentials?.accessKey || !credentials?.secretKey || !credentials?.bucketName) { + throw new HttpException( + `Stored credentials for bucket ${bucket.name} are incomplete`, + HttpStatus.CONFLICT, + ); + } + + // The encoder takes host, port and TLS as separate fields; we store one URL. + // Split the same way S3Service does, so both reach the same endpoint. + const url = new URL(credentials.endpoint); + const useSSL = url.protocol === "https:"; + + // The bucket's encode settings, in the encoder's own field names. Encryption + // defaults to on — that was the behaviour before it became a setting — and + // the byte-range fields are omitted when unset, leaving the encoder's + // defaults in charge. One chunk size covers video and audio. + const settings = bucket.mediaSettings ?? {}; + + return { + s3: { + endPoint: url.hostname, + port: parseInt(url.port) || (useSSL ? 443 : 80), + useSSL, + bucket: credentials.bucketName, + accessKey: credentials.accessKey, + secretKey: credentials.secretKey, + }, + publicBaseUrl: bucket.publicUrl, + encryption: { required: settings.encrypted !== false }, + ...(settings.byteRange !== undefined && { byteRange: settings.byteRange }), + ...(settings.chunkSizeMB !== undefined && { + byteRangeMaxFileSizeMB: settings.chunkSizeMB, + audioByteRangeMaxFileSizeMB: settings.chunkSizeMB, + }), + }; + } +} diff --git a/api/src/endpoints/ftsSearch.service.spec.ts b/api/src/endpoints/ftsSearch.service.spec.ts index d0415d0917..c263d07852 100644 --- a/api/src/endpoints/ftsSearch.service.spec.ts +++ b/api/src/endpoints/ftsSearch.service.spec.ts @@ -123,6 +123,23 @@ describe("FtsSearchService", () => { ); }); + it("returns nothing for types: [sidecar] and never routes to the aux path", async () => { + // Sidecar must not be bulk-searchable; fails loudly if a sidecar aux config + // is ever added. + // Grant Sidecar (per-type group check) + Language (non-cms accessibleLanguages + // guard needs a language to see, else 403 before the dispatch). + (permissions.PermissionSystem.accessMapToGroups as jest.Mock).mockReturnValueOnce({ + [DocType.Sidecar]: ["g1"], + [DocType.Language]: [LANG_GROUP], + } as any); + + const res = await service.search(makeReq({ types: [DocType.Sidecar] as any }), mockUser); + + expect(res).toEqual([]); + expect(dbService.ftsAuxTrigramCandidates).not.toHaveBeenCalled(); + expect(dbService.ftsAuxTrigramDf).not.toHaveBeenCalled(); + }); + it("rejects a status filter unless cms=true", async () => { await expect( service.search(makeReq({ status: PublishStatus.Draft }), mockUser), diff --git a/api/src/endpoints/query.service.spec.ts b/api/src/endpoints/query.service.spec.ts index 33d0b10318..28544ac2ab 100644 --- a/api/src/endpoints/query.service.spec.ts +++ b/api/src/endpoints/query.service.spec.ts @@ -954,4 +954,39 @@ describe("QueryService", () => { expect(res.docs[0]).toHaveProperty("title", "secret title"); }); }); + + // Internal doc types must never be bulk-extractable via /query; the gate sits + // after selector expansion so nesting can't evade it. + describe("internal doc-type gate", () => { + it("rejects type: sidecar with 403", async () => { + const query = makeQuery((s) => { + (s as any).type = DocType.Sidecar; + }); + + await expect(service.query(query, mockUser)).rejects.toEqual( + new HttpException("Forbidden", HttpStatus.FORBIDDEN), + ); + }); + + it("rejects type: deleteCmd with docType: sidecar with 403 (enumeration prevention)", async () => { + const query = makeQuery((s) => { + (s as any).type = DocType.DeleteCmd; + (s as any).docType = DocType.Sidecar; + }); + + await expect(service.query(query, mockUser)).rejects.toEqual( + new HttpException("Forbidden", HttpStatus.FORBIDDEN), + ); + }); + + it("rejects a nested $and-wrapped type: sidecar with 403 (post-expansion placement)", async () => { + const query = makeQuery((s) => { + (s as any).$and = [{ type: DocType.Sidecar }]; + }); + + await expect(service.query(query, mockUser)).rejects.toEqual( + new HttpException("Forbidden", HttpStatus.FORBIDDEN), + ); + }); + }); }); diff --git a/api/src/endpoints/query.service.ts b/api/src/endpoints/query.service.ts index 97c849aa56..f44394e1e1 100644 --- a/api/src/endpoints/query.service.ts +++ b/api/src/endpoints/query.service.ts @@ -97,14 +97,17 @@ export class QueryService { // Doc-type gate. `type`/`docType` are extracted post-expansion, so this catches // nested selectors, hybridQuery's unrestricted selector, AND the // BYPASS_TEMPLATE_VALIDATION escape hatch. Unknown types already fail closed - // (empty viewGroups → Forbidden); this just returns a clearer error. Crypto docs - // (encrypted S3 credentials) are strictly internal and never queryable. + // (empty viewGroups → Forbidden); this just returns a clearer error. if (!(Object.values(DocType) as string[]).includes(type)) throw new HttpException( `'${type}' is not a valid document type`, HttpStatus.BAD_REQUEST, ); - if (type === DocType.Crypto || docType === DocType.Crypto) + // Crypto + Sidecar are internal — never bulk-readable (Sidecar is served + // one-at-a-time via GET /sidecar). Check docType too: `type:"deleteCmd", + // docType:"sidecar"` would otherwise enumerate which parents have keys. + const internalTypes: string[] = [DocType.Crypto, DocType.Sidecar]; + if (internalTypes.includes(type) || internalTypes.includes(docType)) throw new HttpException("Forbidden", HttpStatus.FORBIDDEN); if (type === DocType.DeleteCmd && !docType) throw new HttpException( diff --git a/api/src/endpoints/sidecar.controller.spec.ts b/api/src/endpoints/sidecar.controller.spec.ts new file mode 100644 index 0000000000..6da560eae7 --- /dev/null +++ b/api/src/endpoints/sidecar.controller.spec.ts @@ -0,0 +1,464 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import * as request from "supertest"; +import { SidecarController } from "./sidecar.controller"; +import { DbService } from "../db/db.service"; +import { AuthGuard } from "../auth/auth.guard"; +import { createTestingModule } from "../test/testingModule"; +import { PermissionSystem } from "../permissions/permissions.service"; +import { processChangeRequest } from "../changeRequests/processChangeRequest"; +import { DocType, PublishStatus, SidecarType } from "../enums"; +import { PostDto } from "../dto/PostDto"; +import { ContentDto } from "../dto/ContentDto"; +import { ChangeReqDto } from "../dto/ChangeReqDto"; +import { MediaDto } from "../dto/MediaDto"; +import { SidecarDto } from "../dto/SidecarDto"; +import { sidecarId } from "../sidecar/sidecar.service"; +import { maskKeyHex } from "../util/maskKey"; +import { SidecarRateLimiterService } from "../ratelimit/sidecarRateLimiter.service"; + +// CouchDB-backed: exercises real processChangeRequest writes and the real +// PermissionSystem (ADR 0019). User-run. + +const HLS_URL = "https://cdn.example.com/media/master.m3u8"; +const KEY = "0123456789abcdef0123456789abcdef"; +const NOW = Date.now(); +const PAST = NOW - 60_000; +const FUTURE = NOW + 60 * 60 * 1000; + +function postCr(id: string, media?: MediaDto): ChangeReqDto { + const doc: PostDto = { + _id: id, + type: DocType.Post, + memberOf: ["group-public-content"], + tags: [], + publishDateVisible: true, + postType: "blog", + image: `img-${id}`, + } as PostDto; + if (media) { + doc.media = media; + doc.mediaBucketId = "media-bucket"; + } + return { doc }; +} + +function contentCr( + id: string, + parentId: string, + opts: { + status?: PublishStatus; + publishDate?: number; + expiryDate?: number; + language?: string; + } = {}, +): ChangeReqDto { + return { + doc: { + _id: id, + type: DocType.Content, + memberOf: ["group-public-content"], + parentId, + language: opts.language ?? "lang-eng", + status: opts.status ?? PublishStatus.Published, + slug: id, + title: id, + publishDate: opts.status === PublishStatus.Draft ? undefined : (opts.publishDate ?? PAST), + expiryDate: opts.expiryDate, + } as ContentDto, + }; +} + +describe("SidecarController", () => { + let app: INestApplication; + let dbService: DbService; + let requestUser: { groups: string[]; userId?: string }; + let rateLimiter: { + checkRead: jest.Mock; + recordReadStrike: jest.Mock; + checkProbe: jest.Mock; + recordProbeStrike: jest.Mock; + }; + + beforeAll(async () => { + const testingModule = await createTestingModule("sidecar-controller"); + dbService = testingModule.dbService; + PermissionSystem.upsertGroups((await dbService.getGroups()).docs); + + rateLimiter = { + checkRead: jest.fn().mockReturnValue({ allowed: true, retryAfterMs: 0 }), + recordReadStrike: jest.fn(), + checkProbe: jest.fn().mockReturnValue({ allowed: true, retryAfterMs: 0 }), + recordProbeStrike: jest.fn(), + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [SidecarController], + providers: [ + { provide: DbService, useValue: dbService }, + { provide: SidecarRateLimiterService, useValue: rateLimiter }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ + canActivate: (context: any) => { + const req = context.switchToHttp().getRequest(); + req.user = requestUser; + return true; + }, + }) + .compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + const admin = ["group-super-admins"]; + + await processChangeRequest( + "test-user", + postCr("post-sc-live", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-live", "post-sc-live"), + admin, + dbService, + ); + + await processChangeRequest("test-user", postCr("post-sc-no-sidecar"), admin, dbService); + await processChangeRequest( + "test-user", + contentCr("content-sc-no-sidecar", "post-sc-no-sidecar"), + admin, + dbService, + ); + + await processChangeRequest( + "test-user", + postCr("post-sc-draft", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-draft", "post-sc-draft", { status: PublishStatus.Draft }), + admin, + dbService, + ); + + await processChangeRequest( + "test-user", + postCr("post-sc-scheduled", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-scheduled", "post-sc-scheduled", { publishDate: FUTURE }), + admin, + dbService, + ); + + await processChangeRequest( + "test-user", + postCr("post-sc-expired", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-expired", "post-sc-expired", { expiryDate: PAST }), + admin, + dbService, + ); + + await processChangeRequest( + "test-user", + postCr("post-sc-mixed", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-mixed-draft", "post-sc-mixed", { status: PublishStatus.Draft }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-mixed-live", "post-sc-mixed"), + admin, + dbService, + ); + + await processChangeRequest( + "test-user", + postCr("post-sc-wrong-lang", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-wrong-lang", "post-sc-wrong-lang", { language: "lang-fra" }), + admin, + dbService, + ); + + await processChangeRequest("test-user", postCr("post-sc-no-content"), admin, dbService); + + await processChangeRequest( + "test-user", + postCr("post-sc-corrupt", { hlsUrl: HLS_URL, hlsKey: KEY }), + admin, + dbService, + ); + await processChangeRequest( + "test-user", + contentCr("content-sc-corrupt", "post-sc-corrupt"), + admin, + dbService, + ); + // Overwrite the sidecar written above with a payload that fails isHlsEncryptionKeyData. + const corrupt = new SidecarDto(); + corrupt._id = sidecarId("post-sc-corrupt", SidecarType.HlsEncryptionKey); + corrupt.type = DocType.Sidecar; + corrupt.parentId = "post-sc-corrupt"; + corrupt.parentType = DocType.Post; + corrupt.sidecarType = SidecarType.HlsEncryptionKey; + corrupt.memberOf = ["group-public-content"]; + corrupt.data = { notAKey: true }; + await dbService.upsertDoc(corrupt); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + requestUser = { groups: ["group-public-users"], userId: "test-user" }; + }); + + function get(query: Record) { + return request(app.getHttpServer()) + .get("/sidecar") + .query({ apiVersion: "0.0.0", ...query }) + .set("Authorization", "Bearer fake-token"); + } + + it("returns the masked key for a permitted caller whose parent has live published content", async () => { + const res = await get({ parentId: "post-sc-live", sidecarType: SidecarType.HlsEncryptionKey }); + + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(Object.keys(res.body).sort()).toEqual( + ["data", "parentId", "sidecarId", "sidecarType"].sort(), + ); + const expectedId = sidecarId("post-sc-live", SidecarType.HlsEncryptionKey); + expect(res.body.sidecarId).toBe(expectedId); + expect(res.body.parentId).toBe("post-sc-live"); + expect(res.body.sidecarType).toBe(SidecarType.HlsEncryptionKey); + // Round-trip: unmasking with the returned sidecarId recovers the original key. + expect(maskKeyHex(res.body.sidecarId, res.body.data.maskedKeyHex)).toBe(KEY); + }); + + it("returns 404 for a caller without View on the parent's groups", async () => { + requestUser = { groups: [], userId: "test-user" }; + const res = await get({ parentId: "post-sc-live", sidecarType: SidecarType.HlsEncryptionKey }); + expect(res.status).toBe(404); + }); + + it("returns 404 for an unknown parentId", async () => { + const res = await get({ + parentId: "post-sc-does-not-exist", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + + it("returns 404 for a parentId naming a Content document rather than a Post/Tag", async () => { + const res = await get({ + parentId: "content-sc-live", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + + it("returns 404 for a known, available parent with no sidecar of that type", async () => { + const res = await get({ + parentId: "post-sc-no-sidecar", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + + it("returns 400 when parentId is missing", async () => { + const res = await get({ sidecarType: SidecarType.HlsEncryptionKey }); + expect(res.status).toBe(400); + }); + + it("returns 400 for an unknown sidecarType", async () => { + const res = await get({ parentId: "post-sc-live", sidecarType: "nonsense" }); + expect(res.status).toBe(400); + }); + + it("returns 409 when the stored sidecar payload fails the type's guard", async () => { + const res = await get({ + parentId: "post-sc-corrupt", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(409); + }); + + describe("availability check", () => { + it("refuses a parent whose only content is a draft", async () => { + const res = await get({ + parentId: "post-sc-draft", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + + it("refuses a parent whose only content is scheduled (publishDate in the future)", async () => { + const res = await get({ + parentId: "post-sc-scheduled", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + + it("refuses a parent whose only content has expired", async () => { + const res = await get({ + parentId: "post-sc-expired", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + + it("admits a parent with one draft and one live child (any-child rule)", async () => { + const res = await get({ + parentId: "post-sc-mixed", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(200); + }); + + it("admits a parent whose live child is in a language the caller didn't ask for", async () => { + const res = await get({ + parentId: "post-sc-wrong-lang", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(200); + }); + + it("refuses a parent with no Content children at all", async () => { + const res = await get({ + parentId: "post-sc-no-content", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + }); + }); + + describe("cms mode", () => { + it("admits a draft-only parent when the caller holds CmsView (bypasses the availability gate)", async () => { + requestUser = { groups: ["group-super-admins"], userId: "test-user" }; + const res = await get({ + parentId: "post-sc-draft", + sidecarType: SidecarType.HlsEncryptionKey, + cms: "true", + }); + expect(res.status).toBe(200); + }); + + it("still 404s a caller with neither View nor CmsView", async () => { + requestUser = { groups: [], userId: "test-user" }; + const res = await get({ + parentId: "post-sc-draft", + sidecarType: SidecarType.HlsEncryptionKey, + cms: "true", + }); + expect(res.status).toBe(404); + }); + + it("404s a caller holding only View (no CmsView) even for a live parent", async () => { + requestUser = { groups: ["group-public-users"], userId: "test-user" }; + const res = await get({ + parentId: "post-sc-live", + sidecarType: SidecarType.HlsEncryptionKey, + cms: "true", + }); + expect(res.status).toBe(404); + }); + }); + + describe("rate limiting", () => { + afterEach(() => { + rateLimiter.checkRead.mockReturnValue({ allowed: true, retryAfterMs: 0 }); + rateLimiter.checkProbe.mockReturnValue({ allowed: true, retryAfterMs: 0 }); + }); + + it("returns 429 with Retry-After when the read limiter denies the request", async () => { + rateLimiter.checkRead.mockReturnValueOnce({ allowed: false, retryAfterMs: 4200 }); + const res = await get({ parentId: "post-sc-live", sidecarType: SidecarType.HlsEncryptionKey }); + expect(res.status).toBe(429); + expect(res.headers["retry-after"]).toBe("5"); // ceil(4200/1000) + }); + + it("returns 429 when the probe limiter denies, even for a request that would otherwise succeed", async () => { + rateLimiter.checkProbe.mockReturnValueOnce({ allowed: false, retryAfterMs: 1000 }); + const res = await get({ parentId: "post-sc-live", sidecarType: SidecarType.HlsEncryptionKey }); + expect(res.status).toBe(429); + expect(res.headers["retry-after"]).toBe("1"); + }); + + it("records a read strike on a successful fetch, keyed by the caller's identity", async () => { + rateLimiter.recordReadStrike.mockClear(); + rateLimiter.recordProbeStrike.mockClear(); + await get({ parentId: "post-sc-live", sidecarType: SidecarType.HlsEncryptionKey }); + expect(rateLimiter.recordReadStrike).toHaveBeenCalledWith("test-user"); + expect(rateLimiter.recordProbeStrike).not.toHaveBeenCalledWith("test-user"); + }); + + it("records a probe strike, not a read strike, for a 404 permission denial", async () => { + requestUser = { groups: [], userId: "test-user" }; + rateLimiter.recordReadStrike.mockClear(); + rateLimiter.recordProbeStrike.mockClear(); + const res = await get({ parentId: "post-sc-live", sidecarType: SidecarType.HlsEncryptionKey }); + expect(res.status).toBe(404); + expect(rateLimiter.recordProbeStrike).toHaveBeenCalledWith("test-user"); + expect(rateLimiter.recordReadStrike).not.toHaveBeenCalled(); + }); + + it("records a probe strike, not a read strike, for a 404", async () => { + rateLimiter.recordReadStrike.mockClear(); + rateLimiter.recordProbeStrike.mockClear(); + const res = await get({ + parentId: "post-sc-does-not-exist", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(404); + expect(rateLimiter.recordProbeStrike).toHaveBeenCalledWith("test-user"); + expect(rateLimiter.recordReadStrike).not.toHaveBeenCalled(); + }); + + it("does not record a probe strike for a 400 (missing parentId)", async () => { + rateLimiter.recordProbeStrike.mockClear(); + const res = await get({ sidecarType: SidecarType.HlsEncryptionKey }); + expect(res.status).toBe(400); + expect(rateLimiter.recordProbeStrike).not.toHaveBeenCalled(); + }); + + it("does not record a probe strike for a 409 (corrupt payload)", async () => { + rateLimiter.recordProbeStrike.mockClear(); + const res = await get({ + parentId: "post-sc-corrupt", + sidecarType: SidecarType.HlsEncryptionKey, + }); + expect(res.status).toBe(409); + expect(rateLimiter.recordProbeStrike).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/src/endpoints/sidecar.controller.ts b/api/src/endpoints/sidecar.controller.ts new file mode 100644 index 0000000000..27af545c41 --- /dev/null +++ b/api/src/endpoints/sidecar.controller.ts @@ -0,0 +1,142 @@ +import { + Controller, + Get, + Header, + HttpException, + HttpStatus, + Query, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { FastifyReply, FastifyRequest } from "fastify"; +import { AuthGuard } from "../auth/auth.guard"; +import { DbService } from "../db/db.service"; +import { validateApiVersion } from "../validation/apiVersion"; +import { PermissionSystem } from "../permissions/permissions.service"; +import { AclPermission, DocType, SidecarType, Uuid } from "../enums"; +import { getSidecar, isParentAvailable, sidecarId } from "../sidecar/sidecar.service"; +import { isHlsEncryptionKeyData } from "../sidecar/hlsEncryptionKey"; +import { SidecarRateLimiterService } from "../ratelimit/sidecarRateLimiter.service"; + +export type SidecarResponseDto = { + sidecarId: Uuid; + parentId: Uuid; + sidecarType: SidecarType; + data: unknown; +}; + +/** + * Serves sidecar payloads (currently: the HLS decryption key) one parent at a time. + * No batch parameter and no listing endpoint by design, so bulk extraction costs one + * authorised request per parent. See ADR 0019 + * (docs/adr/0019-hls-encryption-keys-as-non-replicated-sidecars.md). + */ +@Controller("sidecar") +export class SidecarController { + constructor( + private readonly dbService: DbService, + private readonly rateLimiter: SidecarRateLimiterService, + ) {} + + @Get() + @UseGuards(AuthGuard) + @Header("Cache-Control", "no-store") + async getSidecar( + @Query("parentId") parentId: string, + @Query("sidecarType") sidecarType: string, + @Query("cms") cms: string, + @Query("apiVersion") apiVersion: string, + @Req() request: FastifyRequest, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + await validateApiVersion(apiVersion); + + // Same identity used by both limiters (ADR 0019) — read bounds + // successful key fetches, probe bounds repeated 403/404s. Both gate pre-execution; a + // blocked identity is rejected before doing any work. + const identityKey = request.user?.userId ?? `anon:${request.ip}`; + + const readGate = this.rateLimiter.checkRead(identityKey); + if (!readGate.allowed) { + reply.header("Retry-After", String(Math.ceil(readGate.retryAfterMs / 1000))); + throw new HttpException("Too many key requests; retry later", HttpStatus.TOO_MANY_REQUESTS); + } + const probeGate = this.rateLimiter.checkProbe(identityKey); + if (!probeGate.allowed) { + reply.header("Retry-After", String(Math.ceil(probeGate.retryAfterMs / 1000))); + throw new HttpException("Too many failed requests; retry later", HttpStatus.TOO_MANY_REQUESTS); + } + + // 403/404 below are the probe limiter's target (parent-id / permission probing); 400 and + // 409 are not — a caller can't learn anything about a parent it doesn't already know from + // those, so they don't strike. + const probeFail = (status: HttpStatus, message: string): never => { + this.rateLimiter.recordProbeStrike(identityKey); + throw new HttpException(message, status); + }; + + if (!parentId) { + throw new HttpException("parentId query parameter is required", HttpStatus.BAD_REQUEST); + } + if (!sidecarType || !Object.values(SidecarType).includes(sidecarType as SidecarType)) { + throw new HttpException( + "sidecarType query parameter is required and must be a known sidecar type", + HttpStatus.BAD_REQUEST, + ); + } + + const isCms = cms === "true"; + const userDetails = request.user; + + // 404 for "no such parent", "no permission", and "no sidecar" so the response can't + // be used to probe which parent IDs exist or which groups a caller lacks (ADR 0019). + const parent = (await this.dbService.getDoc(parentId)).docs?.[0]; + if (!parent || (parent.type !== DocType.Post && parent.type !== DocType.Tag)) { + probeFail(HttpStatus.NOT_FOUND, "Not found"); + } + + // Same cms ? CmsView : View split as /query and /fts (GitHub #160): CmsView is the + // editor's grant and covers drafts, so a CMS caller doesn't also need View. + const hasPermission = PermissionSystem.verifyAccess( + parent.memberOf, + parent.type, + isCms ? AclPermission.CmsView : AclPermission.View, + userDetails.groups, + ); + if (!hasPermission) { + probeFail(HttpStatus.NOT_FOUND, "Not found"); + } + + // A View grant is permanent; publication state is not (ADR 0019). The CMS is + // exempt so an editor can preview ahead of publish, as in query.service.ts. + if (!isCms) { + const available = await isParentAvailable(this.dbService, parentId, Date.now()); + if (!available) { + probeFail(HttpStatus.NOT_FOUND, "Not found"); + } + } + + const sidecar = await getSidecar(this.dbService, parentId, sidecarType as SidecarType); + if (!sidecar) { + probeFail(HttpStatus.NOT_FOUND, "Not found"); + } + + switch (sidecarType as SidecarType) { + case SidecarType.HlsEncryptionKey: + if (!isHlsEncryptionKeyData(sidecar.data)) { + throw new HttpException("Sidecar payload is corrupt", HttpStatus.CONFLICT); + } + break; + } + + this.rateLimiter.recordReadStrike(identityKey); + + return { + sidecarId: sidecarId(parentId, sidecarType as SidecarType), + parentId, + sidecarType: sidecarType as SidecarType, + data: sidecar.data, + }; + } +} diff --git a/api/src/enums.ts b/api/src/enums.ts index 453975900c..d19487a3cb 100644 --- a/api/src/enums.ts +++ b/api/src/enums.ts @@ -27,6 +27,14 @@ export enum DocType { // CMS-editable global baseline affinity profile (singleton). Delivered at // login to seed a client-local recommendation profile (cold start). DefaultAffinity = "defaultAffinity", + Sidecar = "sidecar", +} + +/** + * Discriminator for the SidecarDto `data` payload shape. + */ +export enum SidecarType { + HlsEncryptionKey = "hlsEncryptionKey", } /** diff --git a/api/src/ratelimit/queryRateLimiter.service.ts b/api/src/ratelimit/queryRateLimiter.service.ts index bb3c9caf0e..1fa92b2d54 100644 --- a/api/src/ratelimit/queryRateLimiter.service.ts +++ b/api/src/ratelimit/queryRateLimiter.service.ts @@ -1,40 +1,30 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { QueryRateLimitConfig } from "../configuration"; -import { StrikeLimiter } from "./strikeLimiter"; +import { RateLimiterConfig, RateLimiterService } from "./rateLimiter.service"; /** - * Nest wrapper around {@link StrikeLimiter} for the POST /query path. Reads + * Nest wrapper around {@link RateLimiterService} for the POST /query path. Reads * `query.rateLimit.*` config and is a no-op (always allows, never strikes) when * `enabled` is false — which is the default, so this ships dark until an operator * opts in per environment after observing the expensive-query logs. */ @Injectable() export class QueryRateLimiterService { - private readonly enabled: boolean; - private readonly limiter?: StrikeLimiter; + private readonly limiter: RateLimiterService; - constructor(private readonly configService: ConfigService) { - const cfg = this.configService.get("query.rateLimit"); - this.enabled = !!cfg?.enabled; - if (this.enabled) { - this.limiter = new StrikeLimiter({ - freeStrikes: cfg.freeStrikes, - baseBackoffMs: cfg.baseBackoffMs, - maxBackoffMs: cfg.maxBackoffMs, - strikeDecayMs: cfg.strikeDecayMs, - }); - } + constructor(configService: ConfigService) { + this.limiter = new RateLimiterService( + configService.get("query.rateLimit"), + ); } /** Pre-execution gate. Allows everything when disabled. */ check(key: string): { allowed: boolean; retryAfterMs: number } { - if (!this.enabled || !this.limiter) return { allowed: true, retryAfterMs: 0 }; return this.limiter.check(key); } /** Post-execution strike for an expensive query. No-op when disabled. */ recordStrike(key: string): void { - if (this.enabled && this.limiter) this.limiter.recordStrike(key); + this.limiter.recordStrike(key); } } diff --git a/api/src/ratelimit/rateLimiter.service.spec.ts b/api/src/ratelimit/rateLimiter.service.spec.ts new file mode 100644 index 0000000000..c70672e27e --- /dev/null +++ b/api/src/ratelimit/rateLimiter.service.spec.ts @@ -0,0 +1,57 @@ +import { RateLimiterService } from "./rateLimiter.service"; + +describe("RateLimiterService", () => { + it("is a no-op when disabled (config enabled=false)", () => { + const svc = new RateLimiterService({ + enabled: false, + freeStrikes: 1, + baseBackoffMs: 5000, + maxBackoffMs: 300000, + strikeDecayMs: 600000, + }); + for (let i = 0; i < 100; i++) svc.recordStrike("u"); + expect(svc.check("u")).toEqual({ allowed: true, retryAfterMs: 0 }); + }); + + it("is a no-op when config is absent", () => { + const svc = new RateLimiterService(undefined); + svc.recordStrike("u"); + expect(svc.check("u").allowed).toBe(true); + }); + + it("enforces backoff when enabled", () => { + const svc = new RateLimiterService({ + enabled: true, + freeStrikes: 1, + baseBackoffMs: 5000, + maxBackoffMs: 300000, + strikeDecayMs: 600000, + }); + svc.recordStrike("u"); // within free allowance + expect(svc.check("u").allowed).toBe(true); + svc.recordStrike("u"); // past free allowance → blocked + const r = svc.check("u"); + expect(r.allowed).toBe(false); + expect(r.retryAfterMs).toBeGreaterThan(0); + }); + + it("buckets identities independently of any other RateLimiterService instance", () => { + const a = new RateLimiterService({ + enabled: true, + freeStrikes: 0, + baseBackoffMs: 5000, + maxBackoffMs: 300000, + strikeDecayMs: 600000, + }); + const b = new RateLimiterService({ + enabled: true, + freeStrikes: 0, + baseBackoffMs: 5000, + maxBackoffMs: 300000, + strikeDecayMs: 600000, + }); + a.recordStrike("u"); + expect(a.check("u").allowed).toBe(false); + expect(b.check("u").allowed).toBe(true); + }); +}); diff --git a/api/src/ratelimit/rateLimiter.service.ts b/api/src/ratelimit/rateLimiter.service.ts new file mode 100644 index 0000000000..207bf6e462 --- /dev/null +++ b/api/src/ratelimit/rateLimiter.service.ts @@ -0,0 +1,49 @@ +import { StrikeLimiter } from "./strikeLimiter"; + +export type RateLimiterConfig = { + /** Master switch. When false (or config absent), every call is a no-op that always allows. */ + enabled: boolean; + /** Strikes tolerated before the first block (e.g. 3 → blocks start on the 4th). */ + freeStrikes: number; + /** First block duration (ms); doubles per extra strike up to maxBackoffMs. */ + baseBackoffMs: number; + /** Cap on a single block window (ms). */ + maxBackoffMs: number; + /** One strike is forgiven per this many ms elapsed since the last update. */ + strikeDecayMs: number; +}; + +/** + * Config-gated, per-identity {@link StrikeLimiter} wrapper. Not a Nest `@Injectable()` itself — + * an endpoint that needs one or more independently-bucketed limiters constructs them from its own + * config slice inside its own Nest service (see `QueryRateLimiterService`, + * `SidecarRateLimiterService`), so hammering one endpoint's limiter never blocks callers on + * another's. + */ +export class RateLimiterService { + private readonly enabled: boolean; + private readonly limiter?: StrikeLimiter; + + constructor(cfg: RateLimiterConfig | undefined) { + this.enabled = !!cfg?.enabled; + if (this.enabled) { + this.limiter = new StrikeLimiter({ + freeStrikes: cfg.freeStrikes, + baseBackoffMs: cfg.baseBackoffMs, + maxBackoffMs: cfg.maxBackoffMs, + strikeDecayMs: cfg.strikeDecayMs, + }); + } + } + + /** Pre-execution gate. Allows everything when disabled. */ + check(key: string): { allowed: boolean; retryAfterMs: number } { + if (!this.enabled || !this.limiter) return { allowed: true, retryAfterMs: 0 }; + return this.limiter.check(key); + } + + /** Post-execution strike. No-op when disabled. */ + recordStrike(key: string): void { + if (this.enabled && this.limiter) this.limiter.recordStrike(key); + } +} diff --git a/api/src/ratelimit/sidecarRateLimiter.service.spec.ts b/api/src/ratelimit/sidecarRateLimiter.service.spec.ts new file mode 100644 index 0000000000..43817efbb5 --- /dev/null +++ b/api/src/ratelimit/sidecarRateLimiter.service.spec.ts @@ -0,0 +1,62 @@ +import { ConfigService } from "@nestjs/config"; +import { SidecarRateLimiterService } from "./sidecarRateLimiter.service"; + +const RATE_LIMIT_KEY = "sidecar.rateLimit"; + +function makeService(rateLimit: any): SidecarRateLimiterService { + const configService = { + get: (key: string) => (key === RATE_LIMIT_KEY ? rateLimit : undefined), + } as unknown as ConfigService; + return new SidecarRateLimiterService(configService); +} + +const disabled = { enabled: false, freeStrikes: 0, baseBackoffMs: 0, maxBackoffMs: 0, strikeDecayMs: 0 }; +const oneFreeStrike = { + enabled: true, + freeStrikes: 1, + baseBackoffMs: 5000, + maxBackoffMs: 300000, + strikeDecayMs: 600000, +}; + +describe("SidecarRateLimiterService", () => { + it("is a no-op on both limiters when disabled", () => { + const svc = makeService({ read: disabled, probe: disabled }); + for (let i = 0; i < 100; i++) { + svc.recordReadStrike("u"); + svc.recordProbeStrike("u"); + } + expect(svc.checkRead("u")).toEqual({ allowed: true, retryAfterMs: 0 }); + expect(svc.checkProbe("u")).toEqual({ allowed: true, retryAfterMs: 0 }); + }); + + it("is a no-op when config is absent", () => { + const svc = makeService(undefined); + svc.recordReadStrike("u"); + svc.recordProbeStrike("u"); + expect(svc.checkRead("u").allowed).toBe(true); + expect(svc.checkProbe("u").allowed).toBe(true); + }); + + it("buckets the read and probe limiters independently", () => { + const svc = makeService({ read: oneFreeStrike, probe: oneFreeStrike }); + + svc.recordReadStrike("u"); // within free allowance + svc.recordReadStrike("u"); // past free allowance → blocks read only + expect(svc.checkRead("u").allowed).toBe(false); + expect(svc.checkProbe("u").allowed).toBe(true); + + svc.recordProbeStrike("u"); + svc.recordProbeStrike("u"); // past free allowance → blocks probe too + expect(svc.checkProbe("u").allowed).toBe(false); + }); + + it("buckets identities independently within a limiter", () => { + const svc = makeService({ read: oneFreeStrike, probe: oneFreeStrike }); + + svc.recordReadStrike("a"); + svc.recordReadStrike("a"); + expect(svc.checkRead("a").allowed).toBe(false); + expect(svc.checkRead("b").allowed).toBe(true); + }); +}); diff --git a/api/src/ratelimit/sidecarRateLimiter.service.ts b/api/src/ratelimit/sidecarRateLimiter.service.ts new file mode 100644 index 0000000000..92b5743aad --- /dev/null +++ b/api/src/ratelimit/sidecarRateLimiter.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SidecarRateLimitConfig } from "../configuration"; +import { RateLimiterService } from "./rateLimiter.service"; + +/** + * Two independently-bucketed limiters for GET /sidecar (ADR 0019, + * docs/adr/0019-hls-encryption-keys-as-non-replicated-sidecars.md): `read` bounds successful key + * fetches — the harvesting risk, since a caller with View on a large group could otherwise walk + * every parent id it already holds from sync and pull the whole encrypted library at HTTP speed — + * and `probe` bounds repeated 403/404s (parent-id / permission probing) at a lower ceiling. Both + * default ON, unlike the query limiter: /sidecar hands out secrets, /query does not. + */ +@Injectable() +export class SidecarRateLimiterService { + private readonly read: RateLimiterService; + private readonly probe: RateLimiterService; + + constructor(configService: ConfigService) { + const cfg = configService.get("sidecar.rateLimit"); + this.read = new RateLimiterService(cfg?.read); + this.probe = new RateLimiterService(cfg?.probe); + } + + /** Pre-execution gate for a successful-read request. */ + checkRead(key: string): { allowed: boolean; retryAfterMs: number } { + return this.read.check(key); + } + + /** Post-execution strike after a request the caller could see (200). */ + recordReadStrike(key: string): void { + this.read.recordStrike(key); + } + + /** Pre-execution gate for the probe (403/404) limiter. */ + checkProbe(key: string): { allowed: boolean; retryAfterMs: number } { + return this.probe.check(key); + } + + /** Post-execution strike after a 403 or 404 response. */ + recordProbeStrike(key: string): void { + this.probe.recordStrike(key); + } +} diff --git a/api/src/s3/s3.service.ts b/api/src/s3/s3.service.ts index 4010673bcd..a0869ba76c 100644 --- a/api/src/s3/s3.service.ts +++ b/api/src/s3/s3.service.ts @@ -1,4 +1,5 @@ import * as Minio from "minio"; +import { Readable } from "stream"; import { S3CredentialDto } from "../dto/S3CredentialDto"; import { DbService } from "../db/db.service"; import { retrieveCryptoData, decryptObject } from "../util/encryption"; @@ -283,6 +284,30 @@ export class S3Service { return this.client.putObject(this.bucketName, key, file, file.length, metadata); } + /** + * Uploads a stream of a known length, without holding the object in memory. + * + * `uploadFile` above takes a Buffer, which is right for an image and wrong for + * media: a byte-range chunk chain is capped at 500 MB by default, so buffering + * one to copy it would trade a bounded stream for an unbounded heap. The size is + * required because S3 needs either a length or a multipart upload, and the + * source's own `statObject` already knows it. + */ + public async putStream(key: string, stream: Readable, size: number, mimetype: string) { + this.touch(); + return this.client.putObject(this.bucketName, key, stream, size, { + "Content-Type": mimetype, + }); + } + + /** + * Size and metadata of a single object, without fetching its body. + */ + public async statObject(key: string): Promise { + this.touch(); + return this.client.statObject(this.bucketName, key); + } + /** * Removes objects from a bucket */ @@ -331,6 +356,27 @@ export class S3Service { return this.client.listObjects(this.bucketName); } + /** + * Every object key under a prefix, recursively. + * + * `listObjects` above returns the whole bucket as a stream, which cannot answer + * "what belongs to this collection" — and a caller that means to delete needs + * an exact list, not a stream it might abandon half-read. Pass the prefix with + * its trailing slash: `foo` would also match `foo-archive/…`. + */ + public async listObjectsUnder(prefix: string): Promise { + this.touch(); + return new Promise((resolve, reject) => { + const keys: string[] = []; + const stream = this.client.listObjectsV2(this.bucketName, prefix, true); + stream.on("data", (item) => { + if (item.name) keys.push(item.name); + }); + stream.on("end", () => resolve(keys)); + stream.on("error", reject); + }); + } + /** * Check if an S3 service is reachable */ diff --git a/api/src/sidecar/hlsEncryptionKey.ts b/api/src/sidecar/hlsEncryptionKey.ts new file mode 100644 index 0000000000..9a5f2744bc --- /dev/null +++ b/api/src/sidecar/hlsEncryptionKey.ts @@ -0,0 +1,43 @@ +import { DbService } from "../db/db.service"; +import { PostDto } from "../dto/PostDto"; +import { TagDto } from "../dto/TagDto"; +import { SidecarType, Uuid } from "../enums"; +import { getSidecar, upsertSidecar } from "./sidecar.service"; + +/** Masked AES-128 HLS key. Stored XOR-masked with SHA-256(sidecar _id)[0..15] + * so the stored form is not the raw key; the client re-derives the mask. */ +export type HlsEncryptionKeyData = { + /** AES-128 key, hex, masked. */ + maskedKeyHex: string; +}; + +/** Type guard for a DB-read payload: 32 lowercase hex chars = 16-byte AES-128 key. */ +export function isHlsEncryptionKeyData(data: unknown): data is HlsEncryptionKeyData { + const d = data as HlsEncryptionKeyData; + return ( + typeof d?.maskedKeyHex === "string" && /^[0-9a-f]{32}$/.test(d.maskedKeyHex) + ); +} + +/** Write (or replace) the masked HLS key sidecar for a Post/Tag. */ +export async function upsertHlsKeySidecar( + db: DbService, + parent: PostDto | TagDto, + data: HlsEncryptionKeyData, +): Promise { + return upsertSidecar(db, parent, SidecarType.HlsEncryptionKey, data); +} + +/** Read the HLS key for a parent. undefined = absent; throws on a corrupt payload (→ 409 at the endpoint). */ +export async function getHlsKeySidecar( + db: DbService, + parentId: Uuid, +): Promise { + const sidecar = await getSidecar(db, parentId, SidecarType.HlsEncryptionKey); + if (!sidecar) return undefined; + if (!isHlsEncryptionKeyData(sidecar.data)) + throw new Error( + `Corrupt hlsEncryptionKey sidecar for ${parentId}: data failed isHlsEncryptionKeyData`, + ); + return sidecar.data; +} \ No newline at end of file diff --git a/api/src/sidecar/sidecar.service.spec.ts b/api/src/sidecar/sidecar.service.spec.ts new file mode 100644 index 0000000000..fabb98137a --- /dev/null +++ b/api/src/sidecar/sidecar.service.spec.ts @@ -0,0 +1,222 @@ +import "reflect-metadata"; +import { createTestingModule } from "../test/testingModule"; +import { DbService } from "../db/db.service"; +import { DocType, SidecarType } from "../enums"; +import { PostDto } from "../dto/PostDto"; +import { DeleteCmdDto } from "../dto/DeleteCmdDto"; +import { + deleteSidecar, + deleteSidecarsForParent, + getSidecar, + sidecarId, + syncSidecarMemberOf, + upsertSidecar, +} from "./sidecar.service"; +import { getHlsKeySidecar, isHlsEncryptionKeyData } from "./hlsEncryptionKey"; + +// Pure unit tests for the key guard and ID scheme — no DB, runnable by anyone. +describe("sidecar (pure unit)", () => { + describe("sidecarId", () => { + it("is deterministic and shaped sidecar--", () => { + expect(sidecarId("post-abc", SidecarType.HlsEncryptionKey)).toBe( + "sidecar-post-abc-hlsEncryptionKey", + ); + }); + + it("is stable across calls for the same inputs", () => { + expect(sidecarId("post-abc", SidecarType.HlsEncryptionKey)).toBe( + sidecarId("post-abc", SidecarType.HlsEncryptionKey), + ); + }); + }); + + describe("isHlsEncryptionKeyData", () => { + it("accepts a 32-char lowercase hex maskedKeyHex", () => { + expect(isHlsEncryptionKeyData({ maskedKeyHex: "0123456789abcdef0123456789abcdef" })).toBe( + true, + ); + }); + + it("rejects a hex string of the wrong length", () => { + expect(isHlsEncryptionKeyData({ maskedKeyHex: "0123456789abcdef" })).toBe(false); + expect(isHlsEncryptionKeyData({ maskedKeyHex: "0".repeat(33) })).toBe(false); + }); + + it("rejects uppercase hex", () => { + expect(isHlsEncryptionKeyData({ maskedKeyHex: "AB".repeat(16) })).toBe(false); + }); + + it("rejects non-hex characters", () => { + expect(isHlsEncryptionKeyData({ maskedKeyHex: "z".repeat(32) })).toBe(false); + }); + + it("rejects a non-string maskedKeyHex", () => { + expect(isHlsEncryptionKeyData({ maskedKeyHex: 123 })).toBe(false); + }); + + it("rejects undefined / null / missing field", () => { + expect(isHlsEncryptionKeyData(undefined)).toBe(false); + expect(isHlsEncryptionKeyData(null)).toBe(false); + expect(isHlsEncryptionKeyData({})).toBe(false); + }); + }); +}); + +// DB-dependent tests — require a running CouchDB, so the user runs them. +// `npm test -- src/sidecar/sidecar.service.spec.ts` +describe("sidecar.service (CouchDB)", () => { + let db: DbService; + + beforeAll(async () => { + db = (await createTestingModule("sidecar")).dbService; + }); + + function makePost(overrides: Partial = {}): PostDto { + return { + _id: "post-test", + type: DocType.Post, + memberOf: ["group-test"], + updatedBy: "user-test", + postType: "blog" as any, + ...overrides, + } as PostDto; + } + + describe("upsertSidecar", () => { + afterEach(async () => { + await deleteSidecarsForParent(db, "post-test"); + }); + + it("writes a doc with the deterministic _id and the parent's memberOf", async () => { + const parent = makePost({ memberOf: ["group-a", "group-b"] }); + const id = await upsertSidecar(db, parent, SidecarType.HlsEncryptionKey, { + maskedKeyHex: "0".repeat(32), + }); + + expect(id).toBe(sidecarId("post-test", SidecarType.HlsEncryptionKey)); + + const stored = await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey); + expect(stored).toBeDefined(); + expect(stored!.type).toBe(DocType.Sidecar); + expect(stored!.parentId).toBe("post-test"); + expect(stored!.parentType).toBe(DocType.Post); + expect(stored!.sidecarType).toBe(SidecarType.HlsEncryptionKey); + expect([...stored!.memberOf].sort()).toEqual(["group-a", "group-b"]); + expect(stored!.updatedBy).toBe("user-test"); + }); + + it("replaces rather than duplicates on a second write for the same parent+type", async () => { + const parent = makePost(); + await upsertSidecar(db, parent, SidecarType.HlsEncryptionKey, { + maskedKeyHex: "1".repeat(32), + }); + await upsertSidecar(db, parent, SidecarType.HlsEncryptionKey, { + maskedKeyHex: "2".repeat(32), + }); + + const stored = await getHlsKeySidecar(db, "post-test"); + expect(stored).toBeDefined(); + expect(stored!.maskedKeyHex).toBe("2".repeat(32)); + + // Deterministic id → exactly one document, not two. + const res = await db.getDoc(sidecarId("post-test", SidecarType.HlsEncryptionKey)); + expect(res.docs).toHaveLength(1); + }); + + }); + + describe("getSidecar", () => { + afterEach(async () => { + await deleteSidecarsForParent(db, "post-test"); + }); + + it("returns undefined for an absent sidecar", async () => { + expect(await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey)).toBeUndefined(); + }); + }); + + describe("getHlsKeySidecar", () => { + afterEach(async () => { + await deleteSidecarsForParent(db, "post-test"); + }); + + it("throws on a corrupt payload (guard fails)", async () => { + // Write a sidecar with a bad payload directly through the generic core, + // bypassing the typed wrapper, to simulate a corrupt/incompatible doc. + await upsertSidecar(db, makePost(), SidecarType.HlsEncryptionKey, { + maskedKeyHex: "not-valid-hex", + }); + + await expect(getHlsKeySidecar(db, "post-test")).rejects.toThrow(/Corrupt/); + }); + }); + + describe("deleteSidecarsForParent", () => { + it("removes every sidecar of a parent", async () => { + await upsertSidecar(db, makePost(), SidecarType.HlsEncryptionKey, { + maskedKeyHex: "0".repeat(32), + }); + await deleteSidecarsForParent(db, "post-test"); + expect(await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey)).toBeUndefined(); + }); + + it("is a no-op when no sidecar exists", async () => { + await expect(deleteSidecarsForParent(db, "post-absent")).resolves.toBeUndefined(); + }); + }); + + describe("deleteSidecar", () => { + it("removes one sidecar by parent+type", async () => { + await upsertSidecar(db, makePost(), SidecarType.HlsEncryptionKey, { + maskedKeyHex: "0".repeat(32), + }); + await deleteSidecar(db, "post-test", SidecarType.HlsEncryptionKey); + expect(await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey)).toBeUndefined(); + }); + }); + + describe("syncSidecarMemberOf", () => { + afterEach(async () => { + await deleteSidecarsForParent(db, "post-test"); + }); + + it("updates a sidecar's memberOf when the parent's memberOf changes", async () => { + await upsertSidecar(db, makePost({ memberOf: ["group-a"] }), SidecarType.HlsEncryptionKey, { + maskedKeyHex: "0".repeat(32), + }); + + await syncSidecarMemberOf(db, makePost({ memberOf: ["group-a", "group-b"] })); + + const stored = await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey); + expect([...stored!.memberOf].sort()).toEqual(["group-a", "group-b"]); + }); + + it("does not churn the sidecar when memberOf is unchanged", async () => { + await upsertSidecar(db, makePost({ memberOf: ["group-a"] }), SidecarType.HlsEncryptionKey, { + maskedKeyHex: "0".repeat(32), + }); + const before = await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey); + const revBefore = before!._rev; + + // Same memberOf → no rewrite. + await syncSidecarMemberOf(db, makePost({ memberOf: ["group-a"] })); + + const after = await getSidecar(db, "post-test", SidecarType.HlsEncryptionKey); + expect(after!._rev).toBe(revBefore); + }); + + it("produces no DeleteCmd with docType 'sidecar' on a memberOf change", async () => { + await upsertSidecar(db, makePost({ memberOf: ["group-a"] }), SidecarType.HlsEncryptionKey, { + maskedKeyHex: "0".repeat(32), + }); + + await syncSidecarMemberOf(db, makePost({ memberOf: ["group-a", "group-b"] })); + + const allDocs = await db.getDocsByType(DocType.DeleteCmd); + const sidecarDeleteCmds = allDocs.docs.filter( + (d) => (d as DeleteCmdDto).docType === DocType.Sidecar, + ); + expect(sidecarDeleteCmds).toHaveLength(0); + }); + }); +}); \ No newline at end of file diff --git a/api/src/sidecar/sidecar.service.ts b/api/src/sidecar/sidecar.service.ts new file mode 100644 index 0000000000..f41b858965 --- /dev/null +++ b/api/src/sidecar/sidecar.service.ts @@ -0,0 +1,108 @@ +import { isDeepStrictEqual } from "util"; +import { DbService } from "../db/db.service"; +import { SidecarDto } from "../dto/SidecarDto"; +import { PostDto } from "../dto/PostDto"; +import { TagDto } from "../dto/TagDto"; +import { ContentDto } from "../dto/ContentDto"; +import { DocType, PublishStatus, SidecarType, Uuid } from "../enums"; + +// Generic mechanism — never import a payload type here; typed wrappers live in +// sidecar/.ts so a registry swap stays contained. + +/** + * Deterministic sidecar ID: `sidecar--`. Centralized so + * a future index move is contained. + */ +export function sidecarId(parentId: Uuid, sidecarType: SidecarType): Uuid { + return `sidecar-${parentId}-${sidecarType}`; +} + +/** + * Create or replace the sidecar for (parent, type). Idempotent (deterministic + * _id); copies the parent's memberOf + updatedBy. + */ +export async function upsertSidecar( + db: DbService, + parent: PostDto | TagDto, + sidecarType: SidecarType, + data: unknown, +): Promise { + // memberOf is always non-empty — enforced by _contentBaseDto's @ArrayNotEmpty + // at parent creation, so no re-check here. + const id = sidecarId(parent._id, sidecarType); + const sidecar = new SidecarDto(); + sidecar._id = id; + sidecar.type = DocType.Sidecar; + sidecar.parentId = parent._id; + sidecar.parentType = parent.type as DocType.Post | DocType.Tag; + sidecar.sidecarType = sidecarType; + sidecar.memberOf = parent.memberOf; + sidecar.data = data; + sidecar.updatedBy = parent.updatedBy; // identity that submitted the key + + await db.upsertDoc(sidecar); + return id; +} + +/** Read one sidecar. undefined = absent (not an error). */ +export async function getSidecar( + db: DbService, + parentId: Uuid, + sidecarType: SidecarType, +): Promise { + const res = await db.getDoc(sidecarId(parentId, sidecarType)); + if (!res.docs?.length) return undefined; + return res.docs[0] as SidecarDto; +} + +/** Hard-delete via db.deleteDoc (not deleteReq — clients never held a sidecar, so no eviction broadcast). */ +export async function deleteSidecar( + db: DbService, + parentId: Uuid, + sidecarType: SidecarType, +): Promise { + await db.deleteDoc(sidecarId(parentId, sidecarType)); +} + +/** Hard-delete every sidecar of a parent (cascade). Primary-key deletes by SidecarType — no index needed. */ +export async function deleteSidecarsForParent(db: DbService, parentId: Uuid): Promise { + for (const type of Object.values(SidecarType)) { + await db.deleteDoc(sidecarId(parentId, type)); + } +} + +/** + * True when a non-CMS caller could currently receive at least one of this parent's Content + * documents. Mirrors the /fts non-CMS filter (the stricter of the two — it refuses scheduled + * content, which /query does not). Keys for unreleased or expired content must not be + * retrievable: the encrypted segments already sit at a public URL, so the key is the only + * thing withholding them. + */ +export async function isParentAvailable(db: DbService, parentId: Uuid, now: number): Promise { + const { docs } = await db.getContentByParentId(parentId); + return (docs as ContentDto[]).some( + (c) => + c.status === PublishStatus.Published && + c.publishDate != null && + c.publishDate <= now && + (c.expiryDate == null || c.expiryDate > now), + ); +} + +/** Re-stamp the parent's memberOf onto sidecars whose memberOf differs; skip unchanged to avoid churn. */ +export async function syncSidecarMemberOf(db: DbService, parent: PostDto | TagDto): Promise { + for (const type of Object.values(SidecarType)) { + const res = await db.getDoc(sidecarId(parent._id, type)); + if (!res.docs?.length) continue; + const sidecar = res.docs[0] as SidecarDto; + if ( + !isDeepStrictEqual( + [...sidecar.memberOf].sort(), + [...parent.memberOf].sort(), + ) + ) { + sidecar.memberOf = parent.memberOf; + await db.upsertDoc(sidecar); + } + } +} \ No newline at end of file diff --git a/api/src/socketio.spec.ts b/api/src/socketio.spec.ts index b4b094ed3b..d7d64b2f70 100644 --- a/api/src/socketio.spec.ts +++ b/api/src/socketio.spec.ts @@ -142,6 +142,53 @@ describe("Socketio", () => { }, 8000); }, 15000); + // A sidecar write must produce no `data` event on any connection (no room to + // join + fan-out early-return). + it("never broadcasts a sidecar document update", (done) => { + const client = connectClient(); + const sidecarDocId = "sidecar-socketio-test-hlsEncryptionKey"; + let gotSidecar = false; + + client.on("connect", () => { + // Requesting sidecar rooms must join nothing — no ACL can grant it. + client.emit("clientConfigReq", { docTypes: [{ type: "sidecar" }] }); + }); + + client.on("clientConfig", () => { + setTimeout(async () => { + try { + await db.upsertDoc({ + _id: sidecarDocId, + type: "sidecar", + memberOf: ["group-super-admins"], + parentId: "post-socketio-test", + parentType: "post", + sidecarType: "hlsEncryptionKey", + data: { maskedKeyHex: "0".repeat(32) }, + updatedTimeUtc: Date.now(), + } as any); + } catch { + // Ignore if it already exists + } + }, 200); + }); + + client.on("data", (data: any) => { + if (data.docs && data.docs.some((d) => d._id === sidecarDocId)) { + gotSidecar = true; + } + }); + + // Wait long enough for the changes feed to deliver the update if it were + // going to, then assert it never arrived. + setTimeout(() => { + expect(gotSidecar).toBe(false); + client.disconnect(); + db.deleteDoc(sidecarDocId).catch(() => {}); + done(); + }, 3000); + }, 15000); + it("should handle database update for document without type", (done) => { const client = connectClient(); diff --git a/api/src/socketio.ts b/api/src/socketio.ts index 6742a8a172..e49e9f48e5 100644 --- a/api/src/socketio.ts +++ b/api/src/socketio.ts @@ -179,6 +179,11 @@ export class Socketio implements OnGatewayInit { return; } + // Sidecars are never replicated to clients — return explicitly rather + // than rely on an empty room set, so a future ACL change can't quietly + // fan them out. + if (update.type === DocType.Sidecar) return; + // We are using a socket.io room per document type per group. Change documents are broadcasted to the document-group rooms of the documents they reference. // Content documents are broadcasted to their parent document-group rooms. let refDoc = update; diff --git a/api/src/util/maskKey.spec.ts b/api/src/util/maskKey.spec.ts new file mode 100644 index 0000000000..d455ae416c --- /dev/null +++ b/api/src/util/maskKey.spec.ts @@ -0,0 +1,35 @@ +import { maskKeyHex } from "./maskKey"; + +describe("maskKeyHex", () => { + // Shared test vector — the same (seed, key) → masked literal is asserted in + // cms/src/util/mediaEncoder.spec.ts and shared/src/util/unmaskKeyHex.spec.ts. A + // divergence between the implementations fails a test here rather than a video + // in the player. + it("matches the shared test vector", () => { + const seed = "sidecar-post-abc-hlsEncryptionKey"; + const keyHex = "000102030405060708090a0b0c0d0e0f"; + + expect(maskKeyHex(seed, keyHex)).toBe("98ceb55553113bf2fdd5a74b3fa6e8d8"); + }); + + it("is its own inverse, so masking twice returns the input", () => { + const seed = "session-xyz"; + const keyHex = "ffeeddccbbaa99887766554433221100"; + + const once = maskKeyHex(seed, keyHex); + expect(maskKeyHex(seed, once)).toBe(keyHex); + }); + + it("produces a different key for a different seed, so keys cannot be crossed", () => { + const keyHex = "0f0e0d0c0b0a09080706050403020100"; + const masked = maskKeyHex("seed-a", keyHex); + + expect(maskKeyHex("seed-b", masked)).not.toBe(keyHex); + }); + + it("returns 16 bytes (32 hex chars) for a 16-byte key", () => { + const keyHex = "112233445566778899aabbccddeeff00"; + + expect(maskKeyHex("s", keyHex)).toHaveLength(32); + }); +}); \ No newline at end of file diff --git a/api/src/util/maskKey.ts b/api/src/util/maskKey.ts new file mode 100644 index 0000000000..facd0522a5 --- /dev/null +++ b/api/src/util/maskKey.ts @@ -0,0 +1,16 @@ +import { createHash } from "crypto"; + +/** + * XOR `keyHex` with SHA-256(seed)[0..15]. Self-inverse: applying it twice returns the input, + * so this is both the mask (API write path) and the unmask (client read path, browser copy). + * + * Deliberate duplication of `shared/src/util/unmaskKeyHex.ts` — the API cannot import from + * shared. The shared test vector in both specs catches divergence. + * Seed is the sidecar `_id`; the mask is obscurity, not a secret (see ADR 0019, + * docs/adr/0019-hls-encryption-keys-as-non-replicated-sidecars.md). + */ +export function maskKeyHex(seed: string, keyHex: string): string { + const mask = createHash("sha256").update(seed).digest().subarray(0, 16); + const key = Buffer.from(keyHex, "hex"); + return Buffer.from(key.map((byte, i) => byte ^ mask[i % mask.length])).toString("hex"); +} \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile index 5e6f6f1ad9..618e05f239 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -6,6 +6,19 @@ COPY ./shared ./ RUN npm ci RUN npm run build +# Build the encoder's player libraries. They ship only dist/, and app depends on +# player-web-legacy by path (file:../luminary-media-convert/player-web-legacy), so this +# has to happen before app installs. The submodule's own workspace links resolve +# player-core and hls-core underneath it. +WORKDIR /luminary-media-convert +COPY ./luminary-media-convert ./ +# ci:libs, not ci: a plain install pulls in electron and electron-builder and +# downloads the Electron binary — around 500 MB to produce five small +# libraries and a desktop app this image will never run. The workspace list +# lives beside build:libs in the submodule, so the two cannot drift. +RUN npm run ci:libs +RUN npm run build:libs + # Build the app WORKDIR /app COPY ./app/package*.json ./ diff --git a/app/package-lock.json b/app/package-lock.json index 240c767918..9f41927b6a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,25 +8,21 @@ "hasInstallScript": true, "dependencies": { "@headlessui/vue": "^1.7.19", + "@luminary-media-converter/player-web-legacy": "file:../luminary-media-convert/player-web-legacy", "@sentry/vue": "^7.110.1", "@vueuse/core": "^10.9.0", "dexie": "^4.0.11", "dotenv": "^16.4.5", "inapp-spy": "^5.0.10", - "iso-639-2": "^3.0.2", "lodash-es": "^4.17.21", "luminary-shared": "file:../shared", "luxon": "^3.4.4", - "m3u8-parser": "^7.2.0", "oidc-client-ts": "^3.5.0", "pinia": "^2.1.7", "rand-seed": "^2.1.7", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^3.4.17", "thumbhash": "^0.1.1", - "video.js": "^8.10.0", - "videojs-mobile-ui": "^1.1.1", - "videojs-youtube": "^3.0.1", "vue": "^3.5.13", "vue-i18n": "^10.0.8", "vue-router": "^4.2.5" @@ -40,9 +36,7 @@ "@types/jsdom": "^21.1.6", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", - "@types/m3u8-parser": "^7.2.2", "@types/node": "^18.19.3", - "@types/videojs-mobile-ui": "^0.8.3", "@unhead/vue": "^1.11.20", "@vitejs/plugin-vue": "^4.5.2", "@vitest/coverage-v8": "^1.6.1", @@ -73,6 +67,7 @@ "wait-for-expect": "^3.0.2" } }, + "../luminary-media-convert/player-web-legacy": {}, "../shared": { "name": "luminary-shared", "version": "0.0.3", @@ -1710,6 +1705,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2617,6 +2613,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@luminary-media-converter/player-web-legacy": { + "resolved": "../luminary-media-convert/player-web-legacy", + "link": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3374,13 +3374,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/m3u8-parser": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/@types/m3u8-parser/-/m3u8-parser-7.2.5.tgz", - "integrity": "sha512-e4aoWGzTV+tQRGN1CfJhp1jt5yIHW0X0Lt2rjflDh/cTXYY9nDLRehX7S5iu+gyoaqVLDletBtPcDcPcQBKaeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -3426,23 +3419,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/video.js": { - "version": "7.3.58", - "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz", - "integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/videojs-mobile-ui": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@types/videojs-mobile-ui/-/videojs-mobile-ui-0.8.3.tgz", - "integrity": "sha512-0PqZblRCggDP6GRMkQA707ZKWi3qFbw5jeLEF6FxqtAQGFrRJOCj1Tuk70n7dnA/CMKy7gwZfskqvCOvgnFzZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/video.js": "*" - } - }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -3715,54 +3691,6 @@ "vue": ">=2.7 || >=3" } }, - "node_modules/@videojs/http-streaming": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz", - "integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.1.1", - "aes-decrypter": "^4.0.2", - "global": "^4.4.0", - "m3u8-parser": "^7.2.0", - "mpd-parser": "^1.3.1", - "mux.js": "7.1.0", - "video.js": "^7 || ^8" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - }, - "peerDependencies": { - "video.js": "^8.19.0" - } - }, - "node_modules/@videojs/vhs-utils": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz", - "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "global": "^4.4.0" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, - "node_modules/@videojs/xhr": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz", - "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "global": "~4.4.0", - "is-function": "^1.0.1" - } - }, "node_modules/@vitejs/plugin-vue": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", @@ -4189,15 +4117,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -4251,18 +4170,6 @@ "node": ">=0.4.0" } }, - "node_modules/aes-decrypter": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz", - "integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.1.1", - "global": "^4.4.0", - "pkcs7": "^1.0.4" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5424,11 +5331,6 @@ "node": ">=16" } }, - "node_modules/dom-walk": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" - }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -6588,16 +6490,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/global": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", - "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", - "license": "MIT", - "dependencies": { - "min-document": "^2.19.0", - "process": "^0.11.10" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -7198,12 +7090,6 @@ "node": ">=8" } }, - "node_modules/is-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", - "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", - "license": "MIT" - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7516,16 +7402,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/iso-639-2": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/iso-639-2/-/iso-639-2-3.0.2.tgz", - "integrity": "sha512-tna50aWwcGTIn81S9MzD1NSovHYTpFgmPVszHiLF5Vg/xmXAJ9XAkMOB9a8TH9Vi7qwf/x/8NJy2F+lM5OEwAw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -8061,17 +7937,6 @@ "node": ">=12" } }, - "node_modules/m3u8-parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz", - "integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.1.1", - "global": "^4.4.0" - } - }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -8200,14 +8065,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-document": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", - "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", - "dependencies": { - "dom-walk": "^0.1.0" - } - }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -8263,21 +8120,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mpd-parser": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz", - "integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.0.0", - "@xmldom/xmldom": "^0.8.3", - "global": "^4.4.0" - }, - "bin": { - "mpd-to-m3u8-json": "bin/parse.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8336,23 +8178,6 @@ "node": "*" } }, - "node_modules/mux.js": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz", - "integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.11.2", - "global": "^4.4.0" - }, - "bin": { - "muxjs-transmux": "bin/transmux.js" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -9058,18 +8883,6 @@ "node": ">= 6" } }, - "node_modules/pkcs7": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", - "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.5.5" - }, - "bin": { - "pkcs7": "bin/cli.js" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -9424,15 +9237,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -11357,85 +11161,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/video.js": { - "version": "8.23.4", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", - "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "^3.17.2", - "@videojs/vhs-utils": "^4.1.1", - "@videojs/xhr": "2.7.0", - "aes-decrypter": "^4.0.2", - "global": "4.4.0", - "m3u8-parser": "^7.2.0", - "mpd-parser": "^1.3.1", - "mux.js": "^7.0.1", - "videojs-contrib-quality-levels": "4.1.0", - "videojs-font": "4.2.0", - "videojs-vtt.js": "0.15.5" - } - }, - "node_modules/videojs-contrib-quality-levels": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz", - "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==", - "license": "Apache-2.0", - "dependencies": { - "global": "^4.4.0" - }, - "engines": { - "node": ">=16", - "npm": ">=8" - }, - "peerDependencies": { - "video.js": "^8" - } - }, - "node_modules/videojs-font": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz", - "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==", - "license": "Apache-2.0" - }, - "node_modules/videojs-mobile-ui": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/videojs-mobile-ui/-/videojs-mobile-ui-1.1.1.tgz", - "integrity": "sha512-q7vx74++bqu2763Tc/GG4qFcMt42emC8uXe/z+zFVpBIiysgAf89AgorE6m30YHWtVJWgbRIyzFVYNOxCk9qow==", - "license": "MIT", - "dependencies": { - "global": "^4.4.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6" - }, - "peerDependencies": { - "video.js": "^8" - } - }, - "node_modules/videojs-vtt.js": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", - "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", - "license": "Apache-2.0", - "dependencies": { - "global": "^4.3.1" - } - }, - "node_modules/videojs-youtube": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/videojs-youtube/-/videojs-youtube-3.0.1.tgz", - "integrity": "sha512-0gKgag7Zno/dDwIdk+h48ODKDulR4IW62RxGE81PrMwi0OX/wUcKO6m1j+DFYI+7qjtWMZTKnbtQoHGxvUrFQg==", - "license": "MIT", - "dependencies": { - "video.js": "5.x || 6.x || 7.x || 8.x" - }, - "peerDependencies": { - "video.js": "5.x || 6.x || 7.x || 8.x" - } - }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/app/package.json b/app/package.json index b363bfd769..5c1bf83b33 100644 --- a/app/package.json +++ b/app/package.json @@ -20,25 +20,21 @@ }, "dependencies": { "@headlessui/vue": "^1.7.19", + "@luminary-media-converter/player-web-legacy": "file:../luminary-media-convert/player-web-legacy", "@sentry/vue": "^7.110.1", "@vueuse/core": "^10.9.0", "dexie": "^4.0.11", "dotenv": "^16.4.5", "inapp-spy": "^5.0.10", - "iso-639-2": "^3.0.2", "lodash-es": "^4.17.21", "luminary-shared": "file:../shared", "luxon": "^3.4.4", - "m3u8-parser": "^7.2.0", "oidc-client-ts": "^3.5.0", "pinia": "^2.1.7", "rand-seed": "^2.1.7", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^3.4.17", "thumbhash": "^0.1.1", - "video.js": "^8.10.0", - "videojs-mobile-ui": "^1.1.1", - "videojs-youtube": "^3.0.1", "vue": "^3.5.13", "vue-i18n": "^10.0.8", "vue-router": "^4.2.5" @@ -52,9 +48,7 @@ "@types/jsdom": "^21.1.6", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", - "@types/m3u8-parser": "^7.2.2", "@types/node": "^18.19.3", - "@types/videojs-mobile-ui": "^0.8.3", "@unhead/vue": "^1.11.20", "@vitejs/plugin-vue": "^4.5.2", "@vitest/coverage-v8": "^1.6.1", diff --git a/app/src/components/content/ContentTile.vue b/app/src/components/content/ContentTile.vue index 83dab93791..b45b04849e 100644 --- a/app/src/components/content/ContentTile.vue +++ b/app/src/components/content/ContentTile.vue @@ -9,6 +9,7 @@ import { computed } from "vue"; import { useI18n } from "vue-i18n"; import { cmsLanguages, cmsDefaultLanguage } from "@/globalConfig"; import { sessionNow } from "@/util/sessionNow"; +import { hasVideoSource, videoSourceFor } from "@/util/videoSource"; const { t } = useI18n(); @@ -53,9 +54,9 @@ const publishDateText = computed(() => { ).toLocaleString(DateTime.DATETIME_MED); }); -const hasVideo = computed(() => Boolean(props.content.video)); +const hasVideo = computed(() => hasVideoSource(props.content)); const hasAudio = computed( - () => !props.content.video && Boolean(props.content.parentMedia?.fileCollections?.length), + () => !hasVideo.value && Boolean(props.content.parentMedia?.fileCollections?.length), ); const mediaIconClass = computed(() => @@ -81,8 +82,9 @@ const isComingSoon = computed(() => { const mediaProgress = computed(() => { if (!props.showProgress) return 0; - const mediaIds = props.content.video - ? [props.content.video] + const videoSource = videoSourceFor(props.content); + const mediaIds = videoSource + ? [videoSource] : (props.content.parentMedia?.fileCollections ?? []).map((f) => f.fileUrl); for (const mediaId of mediaIds) { diff --git a/app/src/components/content/VideoPlayer.css b/app/src/components/content/VideoPlayer.css deleted file mode 100644 index a72457322f..0000000000 --- a/app/src/components/content/VideoPlayer.css +++ /dev/null @@ -1,204 +0,0 @@ -.video-js { - @apply !bg-transparent; -} - -.video-player { - @apply aspect-video !bg-transparent; -} - -.video-player .vjs-tech { - @apply md:!rounded-lg; -} - -/* Ensure YouTube iframe also gets the proper styling */ -.video-player .vjs-tech iframe { - @apply md:!rounded-lg; -} - -.video-player .vjs-poster img { - @apply object-cover md:!rounded-lg; -} - -.video-player .vjs-big-play-button { - @apply !border-none bg-zinc-800/60 !text-3xl; -} - -.video-player .vjs-big-play-button span { - @apply !text-5xl; -} - -/* Hide VideoJS big play button & PiP for YouTube player via the plugin-added class */ -.video-player .video-js.vjs-youtube .vjs-big-play-button { - display: none !important; - visibility: hidden !important; - opacity: 0 !important; -} - -/* Hide Picture-in-Picture button for YouTube videos */ -.video-player .video-js.vjs-youtube .vjs-picture-in-picture-control { - display: none !important; - visibility: hidden !important; - opacity: 0 !important; -} - -/* Ensure the YouTube iframe fills the tech element and inherits rounded corners */ -.video-player .vjs-tech iframe { - width: 100% !important; - height: 100% !important; - display: block !important; - border-radius: inherit !important; -} - -/* Position skip buttons next to the big play button */ -.video-player .vjs-skip-forward-10, -.video-player .vjs-skip-backward-10 { - @apply !absolute flex !h-14 !w-14 items-center justify-center rounded-full !border-none text-white !outline-none !important; - @apply top-2/4 !-translate-y-3/4 !important; -} - -.video-player .vjs-skip-backward-10 { - @apply left-[calc(50%-100px)]; -} - -.video-player .vjs-skip-forward-10 { - @apply right-[calc(50%-100px)]; -} - -.video-player .vjs-skip-backward-10 span, -.video-player .vjs-skip-forward-10 span { - @apply !text-xl; -} - -.video-player :is(.vjs-live) .vjs-skip-backward-10 { - @apply !hidden; -} - -.video-player :is(.vjs-live) .vjs-skip-forward-10 { - @apply !hidden; -} - -.video-player .vjs-control-bar { - @apply !relative !h-full !w-full md:rounded-lg; - background-color: rgba(0, 0, 0, 0.3) !important; -} - -.video-player :is(.vjs-has-started) .vjs-control-bar { - @apply block; -} - -.video-player .vjs-control { - @apply !float-left !h-11 !w-11 !text-sm !outline-none; -} - -.video-player .vjs-play-control { - @apply !absolute !inset-0 !m-auto !h-24 !w-24 !text-3xl; -} - -.video-player .vjs-progress-control { - @apply !absolute !bottom-0 !left-0 !pl-2 !pr-0; - width: calc(100% - 40px) !important; - touch-action: none; -} - -.video-player .vjs-live-control { - @apply !absolute !bottom-0 !left-0 !pl-2 !pr-0; -} - -.video-player .vjs-live-display { - @apply p-3 pl-2; -} - -.video-player :is(.vjs-live) .vjs-playback-rate { - @apply !hidden; -} - -.video-player .vjs-slider { - @apply !rounded-full; - touch-action: none; -} - -.video-player .vjs-slider-bar { - @apply !rounded-full; -} - -.audio-mode .vjs-play-progress { - @apply !rounded-full; -} - -.audio-mode .vjs-load-progress { - @apply !rounded-full; -} - -.video-player .vjs-fullscreen-control { - @apply !absolute !bottom-0 !right-0; -} - -.video-player .vjs-menu { - @apply !left-11 !top-0 !outline-none; -} - -.video-player .vjs-menu-content { - @apply !top-4 z-10 !h-fit rounded-md !bg-zinc-50 shadow-lg dark:!bg-zinc-600 dark:text-slate-100; -} - -.video-player .vjs-selected { - @apply !bg-zinc-300 font-bold focus:!bg-zinc-300 dark:!bg-zinc-500 focus:dark:!bg-zinc-500; -} - -.video-player .vjs-menu-item-text { - @apply text-zinc-900 dark:text-slate-100; -} - -.video-player .video-js :not(.vjs-selected) { - @apply focus:!bg-transparent; -} - -.video-player .vjs-menu-button { - @apply !outline-none; -} - -.video-player .vjs-menu-item { - @apply !p-3 !text-sm !outline-none; - width: 100% !important; -} - -.video-player .vjs-volume-control { - @apply hidden md:block; /* Hidden on mobile (sm) and shown on medium (md) and up */ -} - -/* Hide VideoJS loading spinner */ -.video-player .vjs-loading-spinner { - display: none !important; -} - -.video-player .vjs-waiting .vjs-loading-spinner { - display: none !important; -} - -/* Hide loading spinner on all states */ -.video-player .vjs-loading-spinner, -.video-player .vjs-loading-spinner:before, -.video-player .vjs-loading-spinner:after { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; -} - -/* Also hide any potential buffering indicators */ -.video-player .vjs-waiting { - background: none !important; -} - -.video-player .vjs-waiting:before, -.video-player .vjs-waiting:after { - display: none !important; -} - -/* iOS Safari renders the video-js tech element 1px lower than its absolutely-positioned - .video-player container, leaving a 1px gap at the top and an overhang at the bottom - (issue #813). Same iOS-Safari feature-query technique used in LHighlightable.vue. */ -@supports (-webkit-touch-callout: none) { - .video-player { - transform: translateY(-1px); - } -} diff --git a/app/src/components/content/VideoPlayer.spec.ts b/app/src/components/content/VideoPlayer.spec.ts index a06363d7a6..12cd4abf33 100644 --- a/app/src/components/content/VideoPlayer.spec.ts +++ b/app/src/components/content/VideoPlayer.spec.ts @@ -1,392 +1,270 @@ import "fake-indexeddb/auto"; -import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { mount } from "@vue/test-utils"; +import { computed } from "vue"; +import waitForExpect from "wait-for-expect"; import VideoPlayer from "./VideoPlayer.vue"; import { mockEnglishContentDto } from "@/tests/mockdata"; -import waitForExpect from "wait-for-expect"; -import { computed } from "vue"; + +/** + * What is left to test here is Luminary's half of playback: which URL is played, + * where the key comes from, and what a resume point and a finished video mean. + * + * The player itself — control bar, auto-hide, keep-alive, rotation, audio-track + * selection, audio-only mode, the YouTube branch — belongs to + * `player-web-legacy` and is tested there. Reaching through this component to + * assert on it would be testing someone else's library through a keyhole. + */ +const seekMock = vi.hoisted(() => vi.fn()); +const playMock = vi.hoisted(() => vi.fn(() => Promise.resolve(true))); +const pauseMock = vi.hoisted(() => vi.fn()); +const enterFullscreenMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); +const exitFullscreenMock = vi.hoisted(() => vi.fn()); +const fetchHlsKeyMock = vi.hoisted(() => vi.fn()); + +// Built inside the factory: vi.mock is hoisted above the imports, so a stub +// defined at module scope is not there yet when the factory runs. +vi.mock("@luminary-media-converter/player-web-legacy", async () => { + const { defineComponent, h } = await import("vue"); + return { + LuminaryPlayer: defineComponent({ + name: "LuminaryPlayer", + props: { + source: { type: Object, required: true }, + preferredLanguage: { type: String, default: undefined }, + }, + emits: ["loadedmetadata", "timeupdate", "ended"], + setup(_props, { expose }) { + expose({ + seek: seekMock, + play: playMock, + pause: pauseMock, + enterFullscreen: enterFullscreenMock, + exitFullscreen: exitFullscreenMock, + }); + return () => h("div", { class: "luminary-player-stub" }); + }, + }), + }; +}); vi.mock("@/composables/useBucketInfo", () => ({ - useBucketInfo: () => ({ - bucketBaseUrl: computed(() => "https://bucket.example.com"), - }), + useBucketInfo: () => ({ bucketBaseUrl: computed(() => "https://bucket.example.com") }), })); -// Mock YouTube utilities -vi.mock("@/util/youtube", () => ({ - isYouTubeUrl: vi.fn((url: string) => { - if (!url) return false; - const youtubeRegex = - /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/; - return youtubeRegex.test(url); - }), - convertToVideoJSYouTubeUrl: vi.fn((url: string) => { - // Extract video ID and return in VideoJS format - const match = url.match( - /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/, - ); - if (match) { - return `https://www.youtube.com/watch?v=${match[1]}`; - } - return url; - }), +vi.mock("luminary-shared", async (importOriginal) => ({ + ...(await importOriginal()), + fetchHlsKey: fetchHlsKeyMock, })); -const posterMock = vi.hoisted(() => vi.fn()); -const srcMock = vi.hoisted(() => vi.fn()); -const disposeMock = vi.hoisted(() => vi.fn()); -const audioOnlyModeMock = vi.hoisted(() => vi.fn()); -const audioPosterModeMock = vi.hoisted(() => vi.fn()); -const eventCallbacks = vi.hoisted(() => new Map()); - -vi.mock("video.js", () => { - // Create a mock DOM element - const mockEl = document.createElement("div"); - mockEl.className = "video-js"; - - const defaultFunction = () => { - return { - browser: { - IS_SAFARI: false, - }, - poster: posterMock, - src: srcMock, - mobileUi: vi.fn(), - on: vi.fn((event: string | string[], callback: Function) => { - const events = Array.isArray(event) ? event : [event]; - events.forEach((e) => { - if (!eventCallbacks.has(e)) eventCallbacks.set(e, []); - eventCallbacks.get(e)!.push(callback); - }); - }), - one: vi.fn((event: string, callback: Function) => { - if (!eventCallbacks.has(event)) eventCallbacks.set(event, []); - eventCallbacks.get(event)!.push(callback); - }), - audioTracks: vi.fn(() => []), - el: vi.fn(() => mockEl), - userActive: vi.fn(), - paused: vi.fn(() => true), - currentTime: vi.fn(), - duration: vi.fn(() => 0), - play: vi.fn(), - pause: vi.fn(), - ready: vi.fn((callback) => { - if (callback) callback(); - return { - on: vi.fn((event: string | string[], callback: Function) => { - const events = Array.isArray(event) ? event : [event]; - events.forEach((e) => { - if (!eventCallbacks.has(e)) eventCallbacks.set(e, []); - eventCallbacks.get(e)!.push(callback); - }); - }), - }; - }), - off: vi.fn(), - dispose: disposeMock, - isFullscreen: vi.fn(() => false), - requestFullscreen: vi.fn(), - exitFullscreen: vi.fn(), - audioOnlyMode: audioOnlyModeMock, - audioPosterMode: audioPosterModeMock, - }; - }; - defaultFunction.browser = { - IS_SAFARI: false, - }; +const setMediaProgressMock = vi.hoisted(() => vi.fn()); +const getMediaProgressMock = vi.hoisted(() => vi.fn(() => 0)); +const removeMediaProgressMock = vi.hoisted(() => vi.fn()); +vi.mock("@/contentProgress", () => ({ + setMediaProgress: setMediaProgressMock, + getMediaProgress: getMediaProgressMock, + removeMediaProgress: removeMediaProgressMock, +})); - return { - default: defaultFunction, - }; -}); +const recordAffinityMock = vi.hoisted(() => vi.fn()); +vi.mock("@/recommendation/affinityStore", () => ({ recordAffinity: recordAffinityMock })); +vi.mock("@/recommendation/defaultAffinityStore", () => ({ + affinityConfig: computed(() => ({ eventWeight: { completion: 5 } })), +})); +const markSeenMock = vi.hoisted(() => vi.fn()); +vi.mock("@/recommendation/seenStore", () => ({ markSeen: markSeenMock })); -vi.mock("@/globalConfig", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - queryParams: new URLSearchParams(), - appLanguagesPreferredAsRef: { value: [{ languageCode: "en" }] }, - }; -}); +const RELATIVE = "/media/abc/master.m3u8"; +const ABSOLUTE = "https://bucket.example.com/media/abc/master.m3u8"; -vi.mock("@/contentProgress", async (importOriginal) => { - const actual = (await importOriginal()) as Record; +function content(overrides: Record = {}) { return { - ...actual, - getMediaProgress: vi.fn(() => 0), - setMediaProgress: vi.fn(), - removeMediaProgress: vi.fn(), - }; -}); - -vi.mock("./extractAndBuildAudioMaster", () => ({ - extractAndBuildAudioMaster: vi - .fn() - .mockResolvedValue("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=128000\naudio.m3u8"), -})); + ...mockEnglishContentDto, + parentMediaBucketId: "bucket-1", + parentMedia: { hlsUrl: RELATIVE }, + video: undefined, + ...overrides, + } as any; +} -function triggerPlayerEvent(event: string, ...args: any[]) { - const callbacks = eventCallbacks.get(event) || []; - callbacks.forEach((cb: Function) => cb(...args)); +async function mountPlayer(overrides: Record = {}) { + const wrapper = mount(VideoPlayer, { + props: { content: content(overrides), language: "en" }, + global: { stubs: { LImage: true } }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + return wrapper; } -// Mock HTMLMediaElement methods not implemented in jsdom -beforeAll(() => { - HTMLMediaElement.prototype.pause = vi.fn(); - HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); - HTMLMediaElement.prototype.load = vi.fn(); +const stub = (wrapper: any) => wrapper.findComponent({ name: "LuminaryPlayer" }); + +beforeEach(() => { + vi.clearAllMocks(); + getMediaProgressMock.mockReturnValue(0); + fetchHlsKeyMock.mockResolvedValue(undefined); }); describe("VideoPlayer", () => { - beforeEach(() => { - eventCallbacks.clear(); - }); + it("resolves a bucket-relative URL to a fetchable one", async () => { + const wrapper = await mountPlayer(); - afterEach(() => { - vi.clearAllMocks(); + expect(stub(wrapper).props("source").masterUrl).toBe(ABSOLUTE); }); - it("renders the poster image for regular video", async () => { - const content = { - ...mockEnglishContentDto, - // VideoPlayer reads `content.video`; mock data only defines parentMedia.hlsUrl - video: mockEnglishContentDto.parentMedia!.hlsUrl!, - }; - - const wrapper = mount(VideoPlayer, { - props: { - language: "lang-eng", - content: content, - }, - }); + it("renders no player when the document carries no video", async () => { + const wrapper = await mountPlayer({ parentMedia: undefined }); - await waitForExpect(() => { - expect(srcMock).toHaveBeenCalledWith(expect.objectContaining({ src: content.video })); - }); + expect(stub(wrapper).exists()).toBe(false); + }); - await waitForExpect(() => { - // Check that the default poster image is set to a transparent pixel - // In Vite, image imports resolve to paths starting with '/' or as data URLs - expect(posterMock).toHaveBeenCalled(); - const posterArg = posterMock.mock.calls[0]?.[0]; - expect(posterArg).toBeTruthy(); - // The px.png import should resolve to a string path containing 'px' and '.png' - expect(typeof posterArg).toBe("string"); - expect(posterArg).toMatch(/px.*\.png|\.png.*px/i); - }); + it("passes the viewer's language through for audio-track selection", async () => { + const wrapper = await mountPlayer(); - await waitForExpect(() => { - expect(wrapper.html()).toContain( - content.parentImageData?.fileCollections[0].imageFiles[0].filename, - ); - }); + expect(stub(wrapper).props("preferredLanguage")).toBe("en"); }); - it("handles YouTube videos correctly", async () => { - const youtubeContent = { - ...mockEnglishContentDto, - video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - }; + describe("the decryption key", () => { + const KEY_HEX = "000102030405060708090a0b0c0d0e0f"; - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: youtubeContent, - }, + it("is fetched and handed to the player when the media is encrypted", async () => { + fetchHlsKeyMock.mockResolvedValue(KEY_HEX); + + const wrapper = await mountPlayer({ + parentMedia: { hlsUrl: RELATIVE, hlsKey_id: "sidecar-1" }, + }); + + await waitForExpect(() => expect(stub(wrapper).props("source").keyHex).toBe(KEY_HEX)); + expect(fetchHlsKeyMock).toHaveBeenCalledWith(mockEnglishContentDto.parentId); }); - await waitForExpect(() => { - expect(srcMock).toHaveBeenCalledWith( - expect.objectContaining({ - src: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - type: "video/youtube", - }), + it("is in hand before the player is given the source, so the stream loads once", async () => { + // Handing over the URL first and the key a tick later makes the player + // load, fail on the key, and load again. + let resolveKey!: (key: string) => void; + fetchHlsKeyMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveKey = resolve)), ); - }); - }); - it("sets the poster to a transparent pixel", async () => { - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, - }); + const wrapper = await mountPlayer({ + parentMedia: { hlsUrl: RELATIVE, hlsKey_id: "sidecar-1" }, + }); + expect(stub(wrapper).exists()).toBe(false); - await waitForExpect(() => { - expect(posterMock).toHaveBeenCalled(); + resolveKey(KEY_HEX); + await waitForExpect(() => expect(stub(wrapper).props("source").keyHex).toBe(KEY_HEX)); }); - }); - it("registers event handlers via on()", async () => { - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, - }); + it("is not asked for when the media is not encrypted", async () => { + // Unencrypted is the common case; a request per video would be waste. + await mountPlayer(); - await waitForExpect(() => { - // Should register multiple event handlers - expect(eventCallbacks.has("loadeddata")).toBe(true); + expect(fetchHlsKeyMock).not.toHaveBeenCalled(); }); - }); - it("sets HLS source for regular video", async () => { - const contentWithVideo = { - ...mockEnglishContentDto, - video: "https://example.com/stream.m3u8", - }; + it("still plays when the key cannot be had", async () => { + // "Not encrypted" and "not yours to have" are the same answer here: + // play what the playlists give, and let playback fail if it must. + fetchHlsKeyMock.mockResolvedValue(undefined); - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: contentWithVideo, - }, - }); + const wrapper = await mountPlayer({ + parentMedia: { hlsUrl: RELATIVE, hlsKey_id: "sidecar-1" }, + }); - await waitForExpect(() => { - expect(srcMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: "application/x-mpegURL", - src: "https://example.com/stream.m3u8", - }), - ); + expect(stub(wrapper).props("source").masterUrl).toBe(ABSOLUTE); + expect(stub(wrapper).props("source").keyHex).toBeUndefined(); }); }); - it("registers ended event handler that removes progress", async () => { - const { removeMediaProgress } = await import("@/contentProgress"); + describe("resume position", () => { + it("saves the position once past the resume threshold", async () => { + const wrapper = await mountPlayer(); - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, - }); + stub(wrapper).vm.$emit("timeupdate", 90, 600); - await waitForExpect(() => { - expect(eventCallbacks.has("ended")).toBe(true); + expect(setMediaProgressMock).toHaveBeenCalledWith( + ABSOLUTE, + mockEnglishContentDto._id, + 90, + 600, + ); }); - // Trigger ended event - triggerPlayerEvent("ended"); + it("does not save a position too early to be worth resuming", async () => { + const wrapper = await mountPlayer(); - expect(removeMediaProgress).toHaveBeenCalled(); - }); + stub(wrapper).vm.$emit("timeupdate", 42, 600); - it("registers pause event handler", async () => { - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, + expect(setMediaProgressMock).not.toHaveBeenCalled(); }); - await waitForExpect(() => { - expect(eventCallbacks.has("pause")).toBe(true); - }); + it("does not save a position in a live stream", async () => { + const wrapper = await mountPlayer(); - // Trigger pause - should not throw - triggerPlayerEvent("pause"); - }); + stub(wrapper).vm.$emit("timeupdate", 90, Infinity); - it("registers play event handler", async () => { - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, + expect(setMediaProgressMock).not.toHaveBeenCalled(); }); - await waitForExpect(() => { - expect(eventCallbacks.has("play")).toBe(true); - }); + it("restores a saved position slightly behind where the viewer left", async () => { + getMediaProgressMock.mockReturnValue(300); + const wrapper = await mountPlayer(); - triggerPlayerEvent("play"); - }); + stub(wrapper).vm.$emit("loadedmetadata"); - it("saves progress on timeupdate when currentTime > 60", async () => { - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, + expect(seekMock).toHaveBeenCalledWith(270); }); - await waitForExpect(() => { - expect(eventCallbacks.has("timeupdate")).toBe(true); - }); + it("does not seek for a position not worth resuming", async () => { + getMediaProgressMock.mockReturnValue(30); + const wrapper = await mountPlayer(); - triggerPlayerEvent("timeupdate"); + stub(wrapper).vm.$emit("loadedmetadata"); - // Note: the actual player mock's currentTime/duration are from the vi.mock - // which returns 0/0, so setMediaProgress won't be called for currentTime < 60 - // This test verifies the handler is registered + expect(seekMock).not.toHaveBeenCalled(); + }); }); - it("restores progress on ready event when progress > 60", async () => { - const { getMediaProgress } = await import("@/contentProgress"); - vi.mocked(getMediaProgress).mockReturnValue(120); + describe("finishing a video", () => { + it("clears the resume point and records the engagement", async () => { + const wrapper = await mountPlayer(); - mount(VideoPlayer, { - props: { - language: "lang-eng", - content: mockEnglishContentDto, - }, - }); + stub(wrapper).vm.$emit("ended"); - await waitForExpect(() => { - expect(eventCallbacks.has("ready")).toBe(true); + expect(removeMediaProgressMock).toHaveBeenCalledWith(ABSOLUTE, mockEnglishContentDto._id); + expect(recordAffinityMock).toHaveBeenCalledWith(mockEnglishContentDto.parentTags, 5); + expect(markSeenMock).toHaveBeenCalledWith(mockEnglishContentDto._id); + expect(exitFullscreenMock).toHaveBeenCalled(); }); - triggerPlayerEvent("ready"); + it("detects the end from the position when `ended` never arrives", async () => { + // The normal case on YouTube, whose tech is known to drop the event. + const wrapper = await mountPlayer(); - expect(getMediaProgress).toHaveBeenCalled(); - }); - - it("disposes player on unmount", async () => { - const contentWithVideo = { - ...mockEnglishContentDto, - video: "https://example.com/stream.m3u8", - }; + stub(wrapper).vm.$emit("timeupdate", 599.5, 600); - const wrapper = mount(VideoPlayer, { - props: { - language: "lang-eng", - content: contentWithVideo, - }, + expect(markSeenMock).toHaveBeenCalledTimes(1); }); - // Wait for the async onMounted to fully complete (requestAnimationFrame + dynamic imports) - await waitForExpect(() => { - expect(eventCallbacks.has("ended")).toBe(true); - }); + it("counts a completion once, however it was detected", async () => { + // Otherwise the near-end fallback fires on every tick of the last + // second, and affinity is counted several times for one viewing. + const wrapper = await mountPlayer(); - wrapper.unmount(); + stub(wrapper).vm.$emit("timeupdate", 599.2, 600); + stub(wrapper).vm.$emit("timeupdate", 599.6, 600); + stub(wrapper).vm.$emit("ended"); - expect(disposeMock).toHaveBeenCalled(); - }); + expect(recordAffinityMock).toHaveBeenCalledTimes(1); + }); - it("hides audio toggle for YouTube videos", async () => { - const youtubeContent = { - ...mockEnglishContentDto, - video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - }; + it("arms again for the next playthrough", async () => { + const wrapper = await mountPlayer(); - const wrapper = mount(VideoPlayer, { - props: { - language: "lang-eng", - content: youtubeContent, - }, - }); + stub(wrapper).vm.$emit("ended"); + stub(wrapper).vm.$emit("loadedmetadata"); + stub(wrapper).vm.$emit("ended"); - // AudioVideoToggle should not be rendered for YouTube - await waitForExpect(() => { - expect(wrapper.findComponent({ name: "AudioVideoToggle" }).exists()).toBe(false); + expect(recordAffinityMock).toHaveBeenCalledTimes(2); }); - - wrapper.unmount(); }); }); diff --git a/app/src/components/content/VideoPlayer.vue b/app/src/components/content/VideoPlayer.vue index 993335b307..74ea45522c 100644 --- a/app/src/components/content/VideoPlayer.vue +++ b/app/src/components/content/VideoPlayer.vue @@ -1,20 +1,24 @@ + removeMediaProgress(url, props.content._id); - + diff --git a/app/src/components/content/audioTrackLanguage.spec.ts b/app/src/components/content/audioTrackLanguage.spec.ts deleted file mode 100644 index 8f6463137d..0000000000 --- a/app/src/components/content/audioTrackLanguage.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { matchTrackLanguage, selectAudioTrackIndex } from "./audioTrackLanguage"; - -describe("matchTrackLanguage", () => { - it("matches a 3-letter code (as Android / Chrome reports)", () => { - expect(matchTrackLanguage("eng", "en")).toBe(true); - expect(matchTrackLanguage("deu", "de")).toBe(true); - }); - - it("matches the alternate 3-letter code some languages have", () => { - // A few languages have two 3-letter codes, e.g. French "fra" or "fre", - // German "deu" or "ger". Both should match the 2-letter app code. - expect(matchTrackLanguage("fre", "fr")).toBe(true); - expect(matchTrackLanguage("ger", "de")).toBe(true); - }); - - it("matches a 2-letter code (as iOS Safari reports) — #1808", () => { - expect(matchTrackLanguage("en", "en")).toBe(true); - expect(matchTrackLanguage("fr", "fr")).toBe(true); - }); - - it("ignores a country suffix like -US on the track code", () => { - expect(matchTrackLanguage("en-US", "en")).toBe(true); - expect(matchTrackLanguage("pt-BR", "pt")).toBe(true); - }); - - it("ignores upper/lower case", () => { - expect(matchTrackLanguage("ENG", "en")).toBe(true); - expect(matchTrackLanguage("EN", "EN")).toBe(true); - }); - - it("returns false when the languages are different", () => { - expect(matchTrackLanguage("eng", "fr")).toBe(false); - expect(matchTrackLanguage("en", "de")).toBe(false); - }); - - it("returns false for empty or missing input", () => { - expect(matchTrackLanguage(null, "en")).toBe(false); - expect(matchTrackLanguage("eng", null)).toBe(false); - expect(matchTrackLanguage(undefined, undefined)).toBe(false); - expect(matchTrackLanguage("", "en")).toBe(false); - }); -}); - -describe("selectAudioTrackIndex", () => { - it("returns the index of the matching track", () => { - expect(selectAudioTrackIndex(["fra", "eng", "spa"], "en")).toBe(1); - expect(selectAudioTrackIndex(["en-US", "fr-FR"], "fr")).toBe(1); - }); - - // Regression: when no track matches the app language the caller must NOT disable every - // track (that leaves the player with no audio and it stalls after ~5s once the buffer drains). - it("returns -1 when no track matches — keep the current track", () => { - expect(selectAudioTrackIndex(["fra", "spa"], "en")).toBe(-1); - expect(selectAudioTrackIndex([null, undefined, ""], "en")).toBe(-1); - expect(selectAudioTrackIndex([], "en")).toBe(-1); - }); -}); diff --git a/app/src/components/content/audioTrackLanguage.ts b/app/src/components/content/audioTrackLanguage.ts deleted file mode 100644 index a27c515f8a..0000000000 --- a/app/src/components/content/audioTrackLanguage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as iso from "iso-639-2"; - -/** Whether an audio track's language matches the language the user picked in the app. */ -export function matchTrackLanguage( - trackLanguage: string | null | undefined, - languageCode: string | null | undefined, -): boolean { - if (!trackLanguage || !languageCode) return false; - - // Lowercase and drop any country suffix: "en-US" -> "en", "ENG" -> "eng". - const normalize = (value: string) => value.trim().toLowerCase().split("-")[0]; - const track = normalize(trackLanguage); - const target = normalize(languageCode); - if (!track || !target) return false; - - // iOS Safari reports 2-letter codes (e.g. "en"), so compare directly first. - if (track === target) return true; - - // Android / Chrome report 3-letter codes (e.g. "eng", or the alternate "fre"); - // convert to the 2-letter app code. A language can have two 3-letter codes. - return iso.iso6392TTo1[track] === target || iso.iso6392BTo1[track] === target; -} - -/** - * Index of the audio track to enable for `languageCode`, or -1 when none matches. Callers - * MUST treat -1 as "keep the current track" — disabling every track leaves the player with no - * audio and it stalls once the buffer drains. - */ -export function selectAudioTrackIndex( - trackLanguages: (string | null | undefined)[], - languageCode: string | null | undefined, -): number { - return trackLanguages.findIndex((lang) => matchTrackLanguage(lang, languageCode)); -} diff --git a/app/src/components/content/extractAndBuildAudioMaster.spec.ts b/app/src/components/content/extractAndBuildAudioMaster.spec.ts deleted file mode 100644 index 366bfcd534..0000000000 --- a/app/src/components/content/extractAndBuildAudioMaster.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { extractAndBuildAudioMaster } from "./extractAndBuildAudioMaster"; - -const MANIFEST_URL = "https://cdn.example.com/media/master.m3u8"; - -const createManifest = (options: { - audioGroups?: Array<{ - groupId: string; - name: string; - language: string; - uri: string; - channels?: string; - isDefault?: boolean; - }>; - playlists?: Array<{ - audio: string; - uri: string; - bandwidth?: number; - codecs?: string; - }>; -}) => { - const lines = ["#EXTM3U", "#EXT-X-VERSION:4"]; - - for (const track of options.audioGroups || []) { - const attrs = [ - `TYPE=AUDIO`, - `GROUP-ID="${track.groupId}"`, - track.channels ? `CHANNELS="${track.channels}"` : "", - `NAME="${track.name}"`, - `LANGUAGE="${track.language}"`, - `DEFAULT=${track.isDefault ? "YES" : "NO"}`, - `AUTOSELECT=${track.isDefault ? "YES" : "NO"}`, - `URI="${track.uri}"`, - ] - .filter(Boolean) - .join(","); - lines.push(`#EXT-X-MEDIA:${attrs}`); - } - - for (const pl of options.playlists || []) { - const attrs = [ - `AUDIO="${pl.audio}"`, - `BANDWIDTH=${pl.bandwidth || 128000}`, - pl.codecs ? `CODECS="${pl.codecs}"` : "", - ] - .filter(Boolean) - .join(","); - lines.push(`#EXT-X-STREAM-INF:${attrs}`); - lines.push(pl.uri); - } - - return lines.join("\n"); -}; - -describe("extractAndBuildAudioMaster", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("builds audio master from manifest with one audio group", async () => { - const manifest = createManifest({ - audioGroups: [ - { - groupId: "audio-group", - name: "English", - language: "en", - uri: "audio/en.m3u8", - channels: "2", - }, - ], - playlists: [ - { - audio: "audio-group", - uri: "audio/en.m3u8", - bandwidth: 96000, - codecs: "mp4a.40.2", - }, - ], - }); - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL); - - expect(result).toContain("#EXTM3U"); - expect(result).toContain("#EXT-X-MEDIA:"); - expect(result).toContain("#EXT-X-STREAM-INF:"); - expect(result).toContain('GROUP-ID="audio-group"'); - expect(result).toContain('NAME="English"'); - expect(result).toContain('LANGUAGE="en"'); - expect(result).toContain('CHANNELS="2"'); - }); - - it("resolves relative URIs to absolute based on manifest URL", async () => { - const manifest = createManifest({ - audioGroups: [ - { - groupId: "audio", - name: "Track", - language: "en", - uri: "tracks/audio_en.m3u8", - }, - ], - }); - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL); - - expect(result).toContain("https://cdn.example.com/media/tracks/audio_en.m3u8"); - }); - - it("marks selected track as DEFAULT=YES by language", async () => { - // Use a raw manifest to ensure correct parsing - const manifest = [ - "#EXTM3U", - "#EXT-X-VERSION:4", - '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="en.m3u8"', - '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=NO,URI="fr.m3u8"', - ].join("\n"); - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL, { - language: "fr", - label: "French", - }); - - const lines = result.split("\n"); - const englishMedia = lines.find((l) => l.includes('NAME="English"')); - const frenchMedia = lines.find((l) => l.includes('NAME="French"')); - - expect(englishMedia).toContain("DEFAULT=NO"); - expect(frenchMedia).toContain("DEFAULT=YES"); - }); - - it("marks selected track as DEFAULT=YES by label", async () => { - const manifest = createManifest({ - audioGroups: [ - { - groupId: "audio", - name: "English", - language: "en", - uri: "en.m3u8", - }, - { - groupId: "audio", - name: "French", - language: "fr", - uri: "fr.m3u8", - }, - ], - }); - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL, { label: "French" }); - - const lines = result.split("\n"); - const frenchMedia = lines.find((l) => l.includes('NAME="French"')); - - expect(frenchMedia).toContain("DEFAULT=YES"); - }); - - it("infers bandwidth based on channel count", async () => { - const manifest = createManifest({ - audioGroups: [ - { - groupId: "audio", - name: "Stereo", - language: "en", - uri: "stereo.m3u8", - channels: "2", - }, - { - groupId: "audio", - name: "Mono", - language: "en", - uri: "mono.m3u8", - channels: "1", - }, - ], - }); - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL); - - const lines = result.split("\n"); - const stereoStreamInf = lines.find( - (l, i) => l.includes("STREAM-INF") && lines[i + 1]?.includes("stereo"), - ); - const monoStreamInf = lines.find( - (l, i) => l.includes("STREAM-INF") && lines[i + 1]?.includes("mono"), - ); - - expect(stereoStreamInf).toContain("BANDWIDTH=96000"); - expect(monoStreamInf).toContain("BANDWIDTH=48000"); - }); - - it("uses matched playlist codecs when available", async () => { - const manifest = createManifest({ - audioGroups: [ - { - groupId: "audio", - name: "Track", - language: "en", - uri: "track.m3u8", - }, - ], - playlists: [ - { - audio: "audio", - uri: "track.m3u8", - bandwidth: 64000, - codecs: "mp4a.40.5", - }, - ], - }); - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL); - - expect(result).toContain('CODECS="mp4a.40.5"'); - expect(result).toContain("BANDWIDTH=64000"); - }); - - it("handles manifest with no audio groups", async () => { - const manifest = "#EXTM3U\n#EXT-X-VERSION:4\n"; - - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - text: () => Promise.resolve(manifest), - }), - ); - - const result = await extractAndBuildAudioMaster(MANIFEST_URL); - - expect(result).toContain("#EXTM3U"); - expect(result).not.toContain("#EXT-X-MEDIA:"); - expect(result).not.toContain("#EXT-X-STREAM-INF:"); - }); -}); diff --git a/app/src/components/content/extractAndBuildAudioMaster.ts b/app/src/components/content/extractAndBuildAudioMaster.ts deleted file mode 100644 index 424c426d68..0000000000 --- a/app/src/components/content/extractAndBuildAudioMaster.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Parser } from "m3u8-parser"; - -/** - * Extracts and builds an audio master playlist from a given HLS manifest URL. - * - * @param {string} originalUrl - The URL of the original HLS manifest file. - * @param {Object} selectedTrack - Optional object with label and/or language of the track to mark as default - * @returns {Promise} - A Promise that resolves to the generated audio master playlist as a string. - */ -export const extractAndBuildAudioMaster = async ( - originalUrl: string, - selectedTrack?: { label?: string; language?: string } | null, -): Promise => { - // Fetch the original HLS manifest - const response = await fetch(originalUrl); - - // Read the manifest file content as text. - const manifestText = await response.text(); - - // Parse the manifest using m3u8-parser - const parser = new Parser(); - - // Push the manifest text into the parser for processing. - parser.push(manifestText); - - // Finalize the parsing process. - parser.end(); - - // Retrieve the parsed manifest object from the parser. - const parsedManifest = parser.manifest; - - // Get the directory of the manifest for resolving relative URIs - const manifestDir = originalUrl.substring(0, originalUrl.lastIndexOf("/") + 1); - - // Extract audio media groups and playlists from the manifest - const audioMedia = parsedManifest.mediaGroups?.AUDIO || {}; - - // Ensure audio media groups are in the expected format - const playlists = parsedManifest.playlists || []; - - // Initialize an array to store the lines of the new audio master playlist. - const lines: string[] = ["#EXTM3U", "#EXT-X-VERSION:4", "#EXT-X-INDEPENDENT-SEGMENTS"]; - - // Map to store the mapping of audio group/name to CHANNELS value (e.g., "2" for stereo, "1" for mono) - const channelMap = new Map(); - - // Extract CHANNELS values from raw text - // Extract all #EXT-X-MEDIA lines and parse out GROUP-ID, NAME, and CHANNELS attributes - const mediaLines = manifestText.split("\n").filter((line) => line.startsWith("#EXT-X-MEDIA")); - for (const line of mediaLines) { - const groupIdMatch = /GROUP-ID="([^"]+)"/.exec(line); - const nameMatch = /NAME="([^"]+)"/.exec(line); - const channelsMatch = /CHANNELS="([^"]+)"/.exec(line); - - // If all attributes are found, store the CHANNELS value in the channelMap using "group|name" as the key - if (groupIdMatch && nameMatch && channelsMatch) { - const key = `${groupIdMatch[1]}|${nameMatch[1]}`; - channelMap.set(key, channelsMatch[1]); - } - } - - // Iterate through each audio group in the media groups. - for (const group in audioMedia) { - const variants = audioMedia[group]; - - // Iterate through each audio variant in the group. - for (const name in variants) { - const track: any = (variants as Record)[name]; - - // Check if the audio track has a URI defined. - if (track.uri) { - // Resolve the absolute URI of the audio track. - const absoluteTrackUri = new URL(track.uri, manifestDir).toString(); - - // Normalize name and group for consistent keying (e.g., remove extra spaces) - const normalize = (val: string) => val.trim().toLowerCase(); - const channelKey = `${normalize(group)}|${normalize(name)}`; - - // Find the matching entry in the channelMap for this group/name - const matchedChannel = Array.from(channelMap.entries()).find(([key]) => { - return key.trim().toLowerCase() === channelKey; - }); - - // If a matching CHANNELS value is found, assign it to the track - if (matchedChannel) { - track.channels = matchedChannel[1]; - } - - // Determine if this track should be the default based on selectedTrack parameter - let isDefault = track.default; - let isAutoSelect = track.autoselect; - - if (selectedTrack) { - const langMatch = track.language === selectedTrack.language; - const labelMatch = - track.label === selectedTrack.label || name === selectedTrack.label; - - if (langMatch || labelMatch) { - // This is the selected track - mark it as default - isDefault = true; - isAutoSelect = true; - } else { - // Not the selected track - ensure it's not default - isDefault = false; - isAutoSelect = false; - } - } - - // Add an EXT-X-MEDIA tag for the audio track to the playlist. - const mediaAttributes = [ - `TYPE=AUDIO`, - `GROUP-ID="${group}"`, - track.channels !== undefined && track.channels !== null - ? `CHANNELS="${String(track.channels)}"` - : null, - `NAME="${name}"`, - `LANGUAGE="${track.language}"`, - `DEFAULT=${isDefault ? "YES" : "NO"}`, - `AUTOSELECT=${isAutoSelect ? "YES" : "NO"}`, - `URI="${absoluteTrackUri}"`, - ].filter(Boolean); // Remove nulls - - // Join the attributes into a single string - lines.push(`#EXT-X-MEDIA:${mediaAttributes.join(",")}`); - - // Get the original relative URI (without query params) - const relativeTrackUri = track.uri.split("?")[0]; - - // Find the matching playlist for this audio group - const matched = playlists.find( - (p) => - p.attributes?.AUDIO === group && (p as any).uri?.includes(relativeTrackUri), - ); - - // Infer bandwidth based on group name - // Use the channels attribute if available, otherwise assume stereo - const channels = track.channels ? String(track.channels) : "2"; - const isStereo = channels === "2"; - const isMono = channels === "1"; - - const bandwidth = - matched?.attributes?.BANDWIDTH ?? - (isStereo ? 96000 : isMono ? 48000 : 96000 + Math.floor(Math.random() * 64000)); - - // Use the matched playlist's codecs or a default value - const codecs = - typeof matched?.attributes?.CODECS === "string" - ? matched.attributes.CODECS - : "mp4a.40.2"; - - // Add the EXT-X-STREAM-INF line for this audio track - const streamInfLine = `#EXT-X-STREAM-INF:AUDIO="${group}",BANDWIDTH=${bandwidth},CODECS="${codecs}"`; - - lines.push(streamInfLine); - lines.push(absoluteTrackUri); - } - } - } - - // Join all lines to form the final playlist string - return lines.join("\n"); -}; diff --git a/app/src/components/content/px.png b/app/src/components/content/px.png deleted file mode 100644 index e2b648bd94..0000000000 Binary files a/app/src/components/content/px.png and /dev/null differ diff --git a/app/src/components/form/AudioVideoToggle.spec.ts b/app/src/components/form/AudioVideoToggle.spec.ts deleted file mode 100644 index 90ceefd267..0000000000 --- a/app/src/components/form/AudioVideoToggle.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mount } from "@vue/test-utils"; -import AudioVideoToggle from "./AudioVideoToggle.vue"; - -describe("AudioVideoToggle", () => { - it("emits an event on toggle", async () => { - const wrapper = mount(AudioVideoToggle, { - props: { modelValue: false }, - }); - - await wrapper.find("svg").trigger("click"); - - expect(wrapper.emitted("update:modelValue")?.length).toBe(1); - expect(wrapper.emitted("update:modelValue")![0]).toEqual([true]); - }); -}); diff --git a/app/src/components/form/AudioVideoToggle.vue b/app/src/components/form/AudioVideoToggle.vue deleted file mode 100644 index 45b3bb6dc3..0000000000 --- a/app/src/components/form/AudioVideoToggle.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/app/src/pages/SingleContent/SingleContent.vue b/app/src/pages/SingleContent/SingleContent.vue index 4f05e3f2cf..4df612d022 100644 --- a/app/src/pages/SingleContent/SingleContent.vue +++ b/app/src/pages/SingleContent/SingleContent.vue @@ -69,6 +69,7 @@ import ContinueReadingPrompt from "@/components/content/ContinueReadingPrompt.vu import LHighlightable from "@/components/common/LHighlightable.vue"; import DropdownMenu from "@/components/common/DropdownMenu.vue"; import { markPageReady } from "@/util/renderState"; +import { hasVideoSource } from "@/util/videoSource"; import { computeEstimatedReadingMinutes, resolveReadingSpeedWpm } from "@/util/readingTime"; import { resolveArticleScrollContainer, @@ -808,7 +809,7 @@ watch([isLoading, content, is404], async () => { :ignoreTop="true" > ({ }), })); +// The real player has no jsdom-compatible serving strategy; this test isn't about playback. +vi.mock("@luminary-media-converter/player-web-legacy", async () => { + const { defineComponent, h } = await import("vue"); + return { + LuminaryPlayer: defineComponent({ + name: "LuminaryPlayer", + props: { source: { type: Object, required: true }, preferredLanguage: {} }, + emits: ["loadedmetadata", "timeupdate", "ended"], + setup() { + return () => h("div", { class: "luminary-player-stub" }); + }, + }), + }; +}); + describe("SingleContent 404 Page", () => { let consoleErrorSpy: { mockRestore: () => void } | undefined; diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.backstop.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.backstop.spec.ts index fd65cf1c2d..54184015c5 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.backstop.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.backstop.spec.ts @@ -75,6 +75,21 @@ vi.mock("vue-i18n", () => ({ }), })); +// The real player has no jsdom-compatible serving strategy; this test isn't about playback. +vi.mock("@luminary-media-converter/player-web-legacy", async () => { + const { defineComponent, h } = await import("vue"); + return { + LuminaryPlayer: defineComponent({ + name: "LuminaryPlayer", + props: { source: { type: Object, required: true }, preferredLanguage: {} }, + emits: ["loadedmetadata", "timeupdate", "ended"], + setup() { + return () => h("div", { class: "luminary-player-stub" }); + }, + }), + }; +}); + describe("SingleContent cold-start backstop", () => { beforeEach(async () => { await db.docs.clear(); diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.retention.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.retention.spec.ts index 348196d90e..64227ea12f 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.retention.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.retention.spec.ts @@ -106,6 +106,21 @@ vi.mock("vue-i18n", () => ({ }), })); +// The real player has no jsdom-compatible serving strategy; this test isn't about playback. +vi.mock("@luminary-media-converter/player-web-legacy", async () => { + const { defineComponent, h } = await import("vue"); + return { + LuminaryPlayer: defineComponent({ + name: "LuminaryPlayer", + props: { source: { type: Object, required: true }, preferredLanguage: {} }, + emits: ["loadedmetadata", "timeupdate", "ended"], + setup() { + return () => h("div", { class: "luminary-player-stub" }); + }, + }), + }; +}); + describe("SingleContent retention touch", () => { beforeEach(async () => { await db.docs.clear(); diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts index 296cadc779..c94c5c9d49 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.spec.ts @@ -115,32 +115,18 @@ vi.mock("@/router", () => ({ vi.mock("@/auth", async () => (await import("@/tests/mockAuth")).createAuthMock()); -// Mock video.js to prevent initialization errors -vi.mock("video.js", () => { - const mockVideoPlayer = { - poster: vi.fn(), - src: vi.fn(), - mobileUi: vi.fn(), - on: vi.fn(), - userActive: vi.fn(), - requestFullscreen: vi.fn(), - isFullscreen: vi.fn(() => false), - pause: vi.fn(), - play: vi.fn(), - dispose: vi.fn(), - off: vi.fn(), - currentTime: vi.fn(), - duration: vi.fn(), - audioTracks: vi.fn(() => []), // Mock audioTracks method - }; - - const defaultFunction = () => mockVideoPlayer; - defaultFunction.browser = { - IS_SAFARI: false, - }; - +// The real player has no jsdom-compatible serving strategy; these tests aren't about playback. +vi.mock("@luminary-media-converter/player-web-legacy", async () => { + const { defineComponent, h } = await import("vue"); return { - default: defaultFunction, + LuminaryPlayer: defineComponent({ + name: "LuminaryPlayer", + props: { source: { type: Object, required: true }, preferredLanguage: {} }, + emits: ["loadedmetadata", "timeupdate", "ended"], + setup() { + return () => h("div", { class: "luminary-player-stub" }); + }, + }), }; }); @@ -677,11 +663,16 @@ describe("SingleContent", () => { // Open the language dropdown (click the DropdownMenu trigger that has the toggle handler) const dropdownMenu = wrapper!.findComponent(DropdownMenu); await dropdownMenu.find("[role='button']").trigger("click"); - await nextTick(); - // Options are in the dropdown panel (role=menu) - const options = wrapper!.findAll("[role='menu'] button"); - expect(options.length, "translation options should be at least 2").toBeGreaterThan(1); + // Waited for, not ticked past: the options come from translations loaded + // out of IndexedDB, the same asynchronous source the two waits above + // exist for. A single `nextTick` wins that race on a fast machine and + // loses it on a slower one. + let options = wrapper!.findAll("[role='menu'] button"); + await waitForExpect(() => { + options = wrapper!.findAll("[role='menu'] button"); + expect(options.length, "translation options should be at least 2").toBeGreaterThan(1); + }); // Choose the French option explicitly if present, otherwise pick the second option const frenchOption = diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts index 71000d0bb3..79049f8860 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.ssr.spec.ts @@ -105,7 +105,6 @@ vi.mock("@/composables/useBucketInfo", () => ({ useBucketInfo: () => ({ bucketBaseUrl: computed(() => "") }), })); -vi.mock("video.js", () => ({ default: vi.fn() })); // Stub every presentational child that is not part of the query chain under test. // RelatedContent and ReadMore stay real — their queries and rendered output are the diff --git a/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts b/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts index f3324e1314..24af3b9cf4 100644 --- a/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts +++ b/app/src/pages/SingleContent/__tests__/SingleContent.ssrrtext.spec.ts @@ -105,7 +105,6 @@ vi.mock("@/router", () => ({ markInternalNavigation: vi.fn(), })); -vi.mock("video.js", () => ({ default: vi.fn() })); function passthrough(name: string) { return defineComponent({ name, inheritAttrs: false, setup: (_, { slots }) => () => h("div", slots.default?.()) }); diff --git a/app/src/sync.spec.ts b/app/src/sync.spec.ts index 92b1eafa4f..4f408177a8 100644 --- a/app/src/sync.spec.ts +++ b/app/src/sync.spec.ts @@ -60,6 +60,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); }); @@ -131,6 +132,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initAuthLangSync(); @@ -157,6 +159,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initAuthLangSync(); @@ -183,6 +186,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initAuthLangSync(); @@ -214,6 +218,7 @@ describe("sync.ts", () => { [DocType.DeleteCmd]: [], [DocType.Storage]: [], [DocType.Crypto]: [], + [DocType.Sidecar]: [], [DocType.AuthProvider]: ["group1"], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], @@ -245,6 +250,7 @@ describe("sync.ts", () => { [DocType.DeleteCmd]: [], [DocType.Storage]: [], [DocType.Crypto]: [], + [DocType.Sidecar]: [], [DocType.AuthProvider]: ["group1"], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], @@ -283,6 +289,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initAuthLangSync(); @@ -310,6 +317,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initAuthLangSync(); @@ -373,6 +381,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initSync(); @@ -408,6 +417,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initSync(); @@ -443,6 +453,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initSync(); @@ -490,6 +501,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initSync(); @@ -525,6 +537,7 @@ describe("sync.ts", () => { [DocType.AuthProvider]: [], [DocType.AutoGroupMappings]: [], [DocType.DefaultAffinity]: [], + [DocType.Sidecar]: [], }); initSync(); diff --git a/app/src/types/videojs-mobile-ui.d.ts b/app/src/types/videojs-mobile-ui.d.ts deleted file mode 100644 index 2eb87b8048..0000000000 --- a/app/src/types/videojs-mobile-ui.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "videojs-mobile-ui"; diff --git a/app/src/types/videojs-youtube.d.ts b/app/src/types/videojs-youtube.d.ts deleted file mode 100644 index d87e864c33..0000000000 --- a/app/src/types/videojs-youtube.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "videojs-youtube"; diff --git a/app/src/util/videoSource.spec.ts b/app/src/util/videoSource.spec.ts new file mode 100644 index 0000000000..b903deeee7 --- /dev/null +++ b/app/src/util/videoSource.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { videoSourceFor, hasVideoSource, resolveVideoSource } from "./videoSource"; +import type { ContentDto } from "luminary-shared"; + +const content = (video?: string, hlsUrl?: string) => + ({ + video, + parentMedia: hlsUrl ? { hlsUrl, fileCollections: [] } : undefined, + }) as unknown as ContentDto; + +describe("videoSourceFor", () => { + it("prefers the encoded collection over a hand-entered URL", () => { + // The case that matters: a post that once had a link and has since been + // encoded. The link is a leftover the CMS no longer even lets you edit. + const source = videoSourceFor(content("https://youtube.com/watch?v=x", "https://cdn/m.m3u8")); + + expect(source).toBe("https://cdn/m.m3u8"); + }); + + it("falls back to the typed URL when nothing has been encoded", () => { + expect(videoSourceFor(content("https://youtube.com/watch?v=x"))).toBe( + "https://youtube.com/watch?v=x", + ); + }); + + it("uses the encoded collection when there is no typed URL", () => { + expect(videoSourceFor(content(undefined, "https://cdn/m.m3u8"))).toBe( + "https://cdn/m.m3u8", + ); + }); + + it("returns undefined when the post has no video at all", () => { + expect(videoSourceFor(content())).toBeUndefined(); + }); + + it("treats an empty string as no video rather than as a source", () => { + expect(videoSourceFor(content("", "https://cdn/m.m3u8"))).toBe("https://cdn/m.m3u8"); + expect(videoSourceFor(content(""))).toBeUndefined(); + }); + + it("tolerates missing content", () => { + expect(videoSourceFor(undefined)).toBeUndefined(); + expect(videoSourceFor(null)).toBeUndefined(); + }); +}); + +describe("hasVideoSource", () => { + it("is true for an encoded collection with no typed URL", () => { + expect(hasVideoSource(content(undefined, "https://cdn/m.m3u8"))).toBe(true); + }); + + it("is true for a typed URL with nothing encoded", () => { + expect(hasVideoSource(content("https://youtube.com/watch?v=x"))).toBe(true); + }); + + it("is false when neither is set", () => { + expect(hasVideoSource(content())).toBe(false); + }); +}); + +describe("resolveVideoSource", () => { + const BASE = "https://cdn.example.com/media"; + const REL = "/c5829f07-4ba8-42ed-a449-80d83e6c0b53/master.m3u8"; + + it("joins a stored relative URL onto the bucket", () => { + const content = { parentMedia: { hlsUrl: REL } } as any; + expect(resolveVideoSource(content, BASE)).toBe(`${BASE}${REL}`); + }); + + it("tolerates a trailing slash on the bucket", () => { + const content = { parentMedia: { hlsUrl: REL } } as any; + expect(resolveVideoSource(content, `${BASE}/`)).toBe(`${BASE}${REL}`); + }); + + it("leaves an external URL alone — YouTube has no bucket", () => { + const yt = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + const content = { video: yt } as any; + expect(resolveVideoSource(content, BASE)).toBe(yt); + }); + + it("returns undefined while the bucket is still loading", () => { + // The honest answer: the URL is not knowable yet, and returning the + // bare path would have the browser fetch it from the app's own origin. + const content = { parentMedia: { hlsUrl: REL } } as any; + expect(resolveVideoSource(content, undefined)).toBeUndefined(); + }); + + it("still prefers the encoded collection over a typed-in URL", () => { + const content = { + video: "https://example.com/old.m3u8", + parentMedia: { hlsUrl: REL }, + } as any; + expect(resolveVideoSource(content, BASE)).toBe(`${BASE}${REL}`); + }); +}); diff --git a/app/src/util/videoSource.ts b/app/src/util/videoSource.ts new file mode 100644 index 0000000000..322e88b596 --- /dev/null +++ b/app/src/util/videoSource.ts @@ -0,0 +1,29 @@ +import { type ContentDto, toAbsoluteMediaUrl } from "luminary-shared"; + +/** + * The video a content document should play. The encoded collection on the parent + * wins over the legacy per-language `video` URL, which the CMS no longer edits. + */ +export function videoSourceFor( + content: Pick | undefined | null, +): string | undefined { + return content?.parentMedia?.hlsUrl || content?.video || undefined; +} + +/** Whether this content has a video to play at all. */ +export function hasVideoSource( + content: Pick | undefined | null, +): boolean { + return Boolean(videoSourceFor(content)); +} + +/** + * The URL a player should actually fetch: the stored source resolved against the + * document's media bucket. Undefined while the bucket is still loading. + */ +export function resolveVideoSource( + content: Pick | undefined | null, + bucketBaseUrl: string | undefined, +): string | undefined { + return toAbsoluteMediaUrl(videoSourceFor(content), bucketBaseUrl); +} diff --git a/app/src/util/youtube.spec.ts b/app/src/util/youtube.spec.ts deleted file mode 100644 index 55d7d73293..0000000000 --- a/app/src/util/youtube.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { isYouTubeUrl, extractYouTubeId, convertToVideoJSYouTubeUrl } from "./youtube"; - -describe("YouTube Utilities", () => { - const testVideoId = "dQw4w9WgXcQ"; - const validUrls = [ - `https://www.youtube.com/watch?v=${testVideoId}`, - `https://youtube.com/watch?v=${testVideoId}`, - `https://youtu.be/${testVideoId}`, - `https://www.youtube.com/embed/${testVideoId}`, - `https://youtube.com/v/${testVideoId}`, - `www.youtube.com/watch?v=${testVideoId}`, - `youtube.com/watch?v=${testVideoId}`, - `youtu.be/${testVideoId}`, - ]; - - const invalidUrls = [ - "https://vimeo.com/123456789", - "https://example.com/video.mp4", - "not-a-url", - "", - "https://youtube.com/watch?v=inv", - ]; - - describe("isYouTubeUrl", () => { - it("should correctly identify valid YouTube URLs", () => { - validUrls.forEach((url) => { - expect(isYouTubeUrl(url)).toBe(true); - }); - }); - - it("should correctly reject invalid URLs", () => { - invalidUrls.forEach((url) => { - expect(isYouTubeUrl(url)).toBe(false); - }); - }); - - it("should handle edge cases", () => { - expect(isYouTubeUrl("")).toBe(false); - expect(isYouTubeUrl("null")).toBe(false); - }); - }); - - describe("extractYouTubeId", () => { - it("should extract video ID from valid YouTube URLs", () => { - validUrls.forEach((url) => { - expect(extractYouTubeId(url)).toBe(testVideoId); - }); - }); - - it("should return null for invalid URLs", () => { - invalidUrls.forEach((url) => { - expect(extractYouTubeId(url)).toBe(null); - }); - }); - - it("should handle URLs with additional parameters", () => { - expect( - extractYouTubeId( - `https://www.youtube.com/watch?v=${testVideoId}&t=30s&list=PLz123`, - ), - ).toBe(testVideoId); - }); - }); - - describe("convertToVideoJSYouTubeUrl", () => { - it("should convert various YouTube URL formats to VideoJS format", () => { - const expectedUrl = `https://www.youtube.com/watch?v=${testVideoId}`; - - validUrls.forEach((url) => { - expect(convertToVideoJSYouTubeUrl(url)).toBe(expectedUrl); - }); - }); - - it("should return original URL if not a YouTube URL", () => { - const nonYouTubeUrl = "https://example.com/video.mp4"; - expect(convertToVideoJSYouTubeUrl(nonYouTubeUrl)).toBe(nonYouTubeUrl); - }); - }); -}); diff --git a/app/src/util/youtube.ts b/app/src/util/youtube.ts deleted file mode 100644 index 12686a5819..0000000000 --- a/app/src/util/youtube.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Detects if a URL is a YouTube video URL - * @param url - The URL to check - * @returns boolean indicating if it's a YouTube URL - */ -export function isYouTubeUrl(url: string): boolean { - if (!url) return false; - - const youtubeRegex = - /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/; - return youtubeRegex.test(url); -} - -/** - * Extracts YouTube video ID from various YouTube URL formats - * @param url - The YouTube URL - * @returns YouTube video ID or null if invalid - */ -export function extractYouTubeId(url: string): string | null { - if (!url) return null; - - // Match YouTube URL formats and extract video ID - const regex = - /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/; - const match = url.match(regex); - return match ? match[1] : null; -} - -/** - * Converts a YouTube URL to the format expected by VideoJS YouTube plugin - * @param url - The original YouTube URL - * @returns VideoJS-compatible YouTube URL - */ -export function convertToVideoJSYouTubeUrl(url: string): string { - const videoId = extractYouTubeId(url); - if (!videoId) return url; // Return original if not a valid YouTube URL - - // add options for youtube - return `https://www.youtube.com/watch?v=${videoId}`; -} diff --git a/app/test-coverage.md b/app/test-coverage.md deleted file mode 100644 index e084d8b426..0000000000 --- a/app/test-coverage.md +++ /dev/null @@ -1,147 +0,0 @@ -# Test Coverage Report - -## Summary - -- **Overall Statement Coverage**: 90.31% -- **Test Framework**: Vitest + Vue Test Utils -- **Test Runner**: `npx vitest run --coverage` - -## Coverage Table - -| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | -|---------------------------------------|---------|----------|---------|---------|-------------------------------------------| -| **All files** | 90.31 | 84.66 | 70.18 | 90.31 | | -| .eslintrc.cjs | 0 | 0 | 0 | 0 | 1-15 | -| dynamic-import-helper.js | 100 | 66.66 | 100 | 100 | 1 | -| App.vue | 87.59 | 100 | 40 | 87.59 | 28-30,35-36,90-98,102,106-107 | -| docsIndex.ts | 100 | 100 | 100 | 100 | | -| globalConfig.ts | 92.61 | 79.06 | 94.44 | 92.61 | 268-269,302,405 | -| i18n.ts | 96.73 | 95.45 | 100 | 96.73 | 85,89-90 | -| sync.ts | 92.81 | 88.88 | 100 | 92.81 | 139-140,146-155 | -| BasePage.vue | 100 | 100 | 100 | 100 | | -| IgnorePagePadding.vue | 100 | 100 | 100 | 100 | | -| LoadingSpinner.vue | 100 | 100 | 100 | 100 | | -| contentByTag.ts | 98.93 | 85.18 | 100 | 98.93 | 47 | -| PinnedTopics.vue | 100 | 100 | 100 | 100 | | -| UnpinnedTopics.vue | 100 | 100 | 100 | 100 | | -| ContinueListening.vue | 50.9 | 50 | 100 | 50.9 | 16-37,49-53 | -| ContinueWatching.vue | 97.95 | 85.71 | 75 | 97.95 | 34-35 | -| HomePageNewest.vue | 100 | 100 | 100 | 100 | | -| HomePagePinned.vue | 100 | 100 | 100 | 100 | | -| PinnedVideo.vue | 100 | 100 | 100 | 100 | | -| UnpinnedVideo.vue | 100 | 100 | 100 | 100 | | -| LButton.vue | 100 | 0 | 100 | 100 | 77 | -| DropdownMenu.vue | 100 | 100 | 100 | 100 | | -| LCard.vue | 97.4 | 83.33 | 100 | 97.4 | 21-22 | -| LDialog.vue | 100 | 80 | 0 | 100 | 41 | -| LHighlightable.vue | 57.57 | 75.6 | 58.82 | 57.57 | See "Partially Testable" section | -| LTeleport.vue | 86.36 | 0 | 100 | 86.36 | 15-17 | -| AudioPlayer.vue | 100 | 100 | 100 | 100 | | -| ContentTile.vue | 99.46 | 87.5 | 100 | 99.46 | 35 | -| CopyrightBanner.vue | 100 | 75 | 100 | 100 | 19 | -| HorizontalContentTileCollection.vue | 93.7 | 77.27 | 71.42 | 93.7 | 39-42,71-72,93-94 | -| RelatedContent.vue | 100 | 100 | 100 | 100 | | -| VideoPlayer.vue | 59.13 | 65.95 | 55.55 | 59.13 | See "Partially Testable" section | -| extractAndBuildAudioMaster.ts | 100 | 96.96 | 100 | 100 | 143 | -| AudioVideoToggle.vue | 100 | 100 | 100 | 100 | | -| BaseModal.vue | 100 | 100 | 100 | 100 | | -| LModal.vue | 100 | 100 | 50 | 100 | | -| ImageModal.vue | 72.42 | 86.84 | 47.82 | 72.42 | See "Partially Testable" section | -| LImage.vue | 98.11 | 87.5 | 100 | 98.11 | 59-60 | -| LImageProvider.vue | 89.08 | 77.39 | 42.85 | 89.08 | 153-158,212,235-240,243,320-333 | -| DesktopMenu.vue | 97.05 | 100 | 0 | 97.05 | 12-13 | -| LanguageModal.vue | 94.08 | 86.66 | 25 | 94.08 | 39-40,44,49-55 | -| MobileMenu.vue | 96.7 | 83.33 | 50 | 96.7 | 16,29-30 | -| PrivacyPolicyModal.vue | 67.58 | 75.4 | 62.5 | 67.58 | See "Partially Testable" section | -| ProfileMenu.vue | 97.08 | 93.1 | 50 | 97.08 | 57-60,131-133 | -| SearchModal.vue | 89.31 | 82.65 | 83.87 | 89.31 | 577-584,611-612,832-838,842-846 | -| ThemeSelectorModal.vue | 97.46 | 91.66 | 40 | 97.46 | 36-37 | -| TopBar.vue | 92.42 | 70 | 50 | 92.42 | 41-42,50-52,68,76-79 | -| navigationItems.ts | 97.72 | 50 | 100 | 97.72 | 42 | -| NotificationBanner.vue | 94.64 | 92.85 | 0 | 94.64 | 61-66 | -| NotificationBannerManager.vue | 100 | 100 | 100 | 100 | | -| NotificationBottom.vue | 98.52 | 72.72 | 100 | 98.52 | 19 | -| NotificationBottomManager.vue | 100 | 100 | 100 | 100 | | -| NotificationToast.vue | 100 | 86.66 | 100 | 100 | | -| NotificationToastManager.vue | 100 | 100 | 100 | 100 | | -| VerticalTagViewer.vue | 98.9 | 42.85 | 100 | 98.9 | 21 | -| useAuthWithPrivacyPolicy.ts | 100 | 100 | 100 | 100 | | -| useBucketInfo.ts | 100 | 100 | 100 | 100 | | -| useHighlightState.ts | 100 | 100 | 100 | 100 | | -| useSearchOverlay.ts | 93.1 | 100 | 75 | 93.1 | 20-21 | -| BookmarksPage.vue | 100 | 92.3 | 100 | 100 | 15 | -| ExplorePage.vue | 100 | 100 | 100 | 100 | | -| HomePage.vue | 100 | 100 | 100 | 100 | | -| NotFoundPage.vue | 100 | 100 | 100 | 100 | | -| SettingsPage.vue | 100 | 100 | 100 | 100 | | -| VideoPage.vue | 100 | 100 | 100 | 100 | | -| SingleContent.vue | 87.72 | 83.97 | 65.21 | 87.72 | 540-541,563-566,678-680,799-801 | -| examplePlugin.ts | 100 | 100 | 100 | 100 | | -| router/index.ts | 98.16 | 100 | 40 | 98.16 | 17-18 | -| notification.ts | 97.5 | 92.3 | 100 | 97.5 | 41-42 | -| mockdata.ts | 100 | 100 | 100 | 100 | | -| isLangSwitch.ts | 100 | 100 | 100 | 100 | | -| loadFallbackImages.ts | 100 | 100 | 100 | 100 | | -| mangoIsPublished.ts | 100 | 100 | 100 | 100 | | -| pluginLoader.ts | 92.3 | 92.3 | 100 | 92.3 | 29-31 | -| watchEffectOnce.ts | 100 | 100 | 100 | 100 | | -| youtube.ts | 100 | 100 | 100 | 100 | | - -## Untestable / Excluded Files - -### `.eslintrc.cjs` (0% coverage) -Configuration file, not executable application code. Should be excluded from coverage reports via `vitest.config.ts` coverage exclusions. - -### `ContinueListening.vue` — query logic unreachable -The `useDexieLiveQueryWithDeps` callback (lines 14-37) contains `contentIds` hardcoded as an empty array (`const contentIds: string[] = []`), so the query always returns `[]` and the filtering/sorting logic on lines 17-37 is unreachable. This is documented with a TODO in the source: _"Replace with central watch/listen/read service when implemented (separate ticket)"_. The component's rendering logic is tested (it correctly renders nothing). - -## Partially Testable Files - -### `VideoPlayer.vue` (59% coverage) -Lines 430-535 (`watch(audioMode)`) involve complex Video.js player API interactions that are difficult to test in jsdom: -- Audio playlist generation with base64 encoding -- Track restoration logic during mode switches -- Player source switching between audio/video modes -- `play()` promise handling - -**Why**: Video.js is a browser-only library. The mock captures event registrations and basic API calls, but complex multi-step flows (mode switching with `one("loadedmetadata")` + `one("canplay")` callbacks, `btoa()` encoding of audio manifests) require a real browser player instance. - -**What IS tested**: YouTube detection, HLS source setting, poster image, event handler registration (ended/pause/play/timeupdate/ready), progress save/restore, player disposal on unmount. - -### `LHighlightable.vue` (58% coverage) -Lines involving DOM mutations and browser Selection API: -- `removeHighlight()` partial removal (lines 213-293): TreeWalker-based text node splitting/reconstruction -- Touch event handlers (lines 391-393): `handleSelectStart` is a no-op function -- Some paths in `wrapTextNodes` with complex multi-node selections - -**Why**: jsdom's Selection/Range API is limited — `getBoundingClientRect()` must be manually mocked, and TreeWalker behavior with DOM mutations differs from real browsers. - -**What IS tested**: Color application via selection, highlight removal (full), save/restore to IndexedDB, error handling, context menu prevention, long-press detection, event listener lifecycle. - -### `ImageModal.vue` (72% coverage) -Uncovered lines involve: -- Mouse drag when zoomed (lines 213-222): `onMouseMove`/`onMouseUp` handlers -- Touch event branches (lines 242-244): `TouchEvent` instanceof check in `onDblClick` - -**Why**: Touch events require constructing realistic `TouchEvent` objects with `changedTouches`/`touches` arrays, which jsdom doesn't fully support. - -**What IS tested**: Ctrl+wheel zoom, double-click zoom toggle, keyboard navigation, swipe via arrows, mobile defaults, zoom reset on image change. - -### `PrivacyPolicyModal.vue` (68% coverage) -Lines 108-200 contain an `h()` render function inside a `watch()` inside a `setTimeout(2000)` that creates banner notification action buttons. The VNode tree is complex with conditional rendering. - -**Why**: Testing `h()` render function output requires extracting the notification's `actions()` callback from the Pinia store and rendering/inspecting the returned VNodes, which is indirect. - -**What IS tested**: Banner notification creation after 2s delay, banner removal on acceptance, modal button rendering (accept/necessaryOnly/moreInfo), status computation, pending login behavior. - -## Files at 100% Coverage - -The following files have full test coverage: -- All page components (BookmarksPage, ExplorePage, HomePage, NotFoundPage, SettingsPage, VideoPage) -- All composables (useAuthWithPrivacyPolicy, useBucketInfo, useHighlightState) -- Core utilities (isLangSwitch, loadFallbackImages, mangoIsPublished, watchEffectOnce, youtube) -- Form components (AudioVideoToggle, BaseModal, LModal) -- Content components (AudioPlayer, RelatedContent, extractAndBuildAudioMaster) -- Navigation sub-components (PinnedTopics, UnpinnedTopics, PinnedVideo, UnpinnedVideo) -- Notification managers (BannerManager, BottomManager, ToastManager) -- BasePage, IgnorePagePadding, LoadingSpinner, DropdownMenu diff --git a/cms/Dockerfile b/cms/Dockerfile index c9447bc818..91bee4dbdf 100644 --- a/cms/Dockerfile +++ b/cms/Dockerfile @@ -6,6 +6,19 @@ COPY ./shared ./ RUN npm ci RUN npm run build +# Build the encoder's player libraries. They ship only dist/, and cms depends on +# player-web-legacy by path (file:../luminary-media-convert/player-web-legacy), so this +# has to happen before cms installs. The submodule's own workspace links resolve +# player-core and hls-core underneath it. +WORKDIR /luminary-media-convert +COPY ./luminary-media-convert ./ +# ci:libs, not ci: a plain install pulls in electron and electron-builder and +# downloads the Electron binary — around 500 MB to produce five small +# libraries and a desktop app this image will never run. The workspace list +# lives beside build:libs in the submodule, so the two cannot drift. +RUN npm run ci:libs +RUN npm run build:libs + # Build the CMS WORKDIR /cms COPY ./cms/package*.json ./ diff --git a/cms/package-lock.json b/cms/package-lock.json index 85d74aaabe..9e6d2018c6 100644 --- a/cms/package-lock.json +++ b/cms/package-lock.json @@ -7,6 +7,7 @@ "name": "luminary-cms", "dependencies": { "@dagrejs/dagre": "^3.0.0", + "@luminary-media-converter/player-web-legacy": "file:../luminary-media-convert/player-web-legacy", "@sentry/vue": "^7.110.1", "@tailwindcss/forms": "^0.5.10", "@tiptap/extension-link": "^2.26.1", @@ -73,6 +74,32 @@ "wait-for-expect": "^3.0.2" } }, + "../luminary-media-convert/player-web-legacy": { + "name": "@luminary-media-converter/player-web-legacy", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@luminary-media-converter/player-core": "file:../player-core", + "iso-639-2": "^3.0.2", + "video.js": "8.23.4", + "videojs-mobile-ui": "1.1.1", + "videojs-youtube": "3.0.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "@vue/test-utils": "^2.4.0", + "jsdom": "^26.0.0", + "typescript": "~5.7.0", + "vite": "^6.0.0", + "vite-plugin-css-injected-by-js": "^3.5.0", + "vitest": "^3.0.0", + "vue": "^3.5.0", + "vue-tsc": "^2.2.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "../shared": { "name": "luminary-shared", "version": "0.0.3", @@ -2590,6 +2617,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@luminary-media-converter/player-web-legacy": { + "resolved": "../luminary-media-convert/player-web-legacy", + "link": true + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", diff --git a/cms/package.json b/cms/package.json index 8399d0bdd8..66d5dfb064 100644 --- a/cms/package.json +++ b/cms/package.json @@ -32,6 +32,7 @@ "lodash-es": "^4.17.21", "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0", + "@luminary-media-converter/player-web-legacy": "file:../luminary-media-convert/player-web-legacy", "luminary-shared": "file:../shared", "luxon": "^3.4.4", "oidc-client-ts": "^3.5.0", diff --git a/cms/src/components/content/EditContent.spec.ts b/cms/src/components/content/EditContent.spec.ts index 9cba1649a0..8842abca93 100644 --- a/cms/src/components/content/EditContent.spec.ts +++ b/cms/src/components/content/EditContent.spec.ts @@ -1134,7 +1134,7 @@ describe("EditContent.vue", () => { }); describe("dirty state on load", () => { - // Regression tests for a bug where MediaEditor / ImageEditor auto-selected the + // Regression tests for a bug where MediaBucketSelect / ImageEditor auto-selected the // single available storage bucket on mount and wrote it only to editableParent, // which made the diff against existingParent flag a phantom dirty state every // time a legacy doc (no bucket IDs persisted) was opened. @@ -1330,6 +1330,39 @@ describe("EditContent.vue", () => { expect(wrapper.findComponent(IncomingChangesModal).props("open")).toBe(true); }, 15000); + it("does not call the API's own mirror of the parent an incoming change", async () => { + // Saving a parent whose media changed makes the API re-stamp `parentMedia` + // onto every content child. That is this editor's own save coming back, not + // someone else's edit, and an encode makes it happen mid-session. + const wrapper = mount(EditContent, { + props: { + docType: DocType.Post, + id: mockData.mockPostDto._id, + languageCode: "eng", + tagOrPostType: PostType.Blog, + }, + }); + + await waitForExpect(() => { + expect(wrapper.find('input[name="title"]').exists()).toBe(true); + }); + + await wrapper.find('input[name="title"]').setValue("My local title"); + await waitForExpect(() => { + expect(wrapper.find('[data-test="revert-changes-button"]').exists()).toBe(true); + }); + + await applyRemoteContentUpdate({ + parentMedia: { + hlsUrl: "/post-blog1/master.m3u8", + fileCollections: [], + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(wrapper.find('[data-test="incoming-changes-banner"]').exists()).toBe(false); + }, 15000); + it("does not show the banner when a remote change arrives with no local edits", async () => { const wrapper = mount(EditContent, { props: { diff --git a/cms/src/components/content/EditContent.vue b/cms/src/components/content/EditContent.vue index 515317da92..aa834bcea0 100644 --- a/cms/src/components/content/EditContent.vue +++ b/cms/src/components/content/EditContent.vue @@ -25,7 +25,6 @@ import { DocumentIcon, TagIcon } from "@heroicons/vue/24/solid"; import { computed, ref, watch } from "vue"; import EditContentText from "@/components/content/EditContentText.vue"; import EditContentBasic from "@/components/content/EditContentBasic.vue"; -import EditContentVideo from "@/components/content/EditContentVideo.vue"; import EditContentParentValidation from "@/components/content/EditContentParentValidation.vue"; import EmptyState from "@/components/EmptyState.vue"; import LoadingBar from "@/components/LoadingBar.vue"; @@ -97,6 +96,16 @@ const { } = source; const showDeleteModal = ref(false); +/** + * Whether the delete should take the media files in storage with it. + * + * Off by default and reset whenever the dialog opens: an irreversible option that + * remembers a previous "yes" is one someone eventually triggers without meaning to. + */ +const deleteMediaFiles = ref(false); +watch(showDeleteModal, (open) => { + if (open) deleteMediaFiles.value = false; +}); // Concurrent-edit conflict UI: banner + read-only diff modal (ticket #932). Detection is driven by // `hasIncomingChanges` (toEditable `isModified` under the hood); "Use server version" reloads so the @@ -246,14 +255,14 @@ const revertChanges = () => { ); }; -const deleteParent = async () => { +const deleteParent = async (deleteMediaFiles = false) => { if (!editableParent.value) return; if (!canDelete.value) { notify("error", "Insufficient Permissions", "You do not have delete permission"); return; } - const deleted = await source.deleteParent(); + const deleted = await source.deleteParent(deleteMediaFiles); if (!deleted) { notify( "error", @@ -552,28 +561,11 @@ watch(isLgScreen, (isLg) => { - - - @@ -695,7 +687,7 @@ watch(isLgScreen, (isLg) => { :description="`Are you sure you want to delete this ${props.tagOrPostType} and all the translations? This action cannot be undone.`" :primaryAction=" async () => { - await deleteParent(); + await deleteParent(deleteMediaFiles); showDeleteModal = false; } " @@ -704,7 +696,25 @@ watch(isLgScreen, (isLg) => { secondaryButtonText="Cancel" context="danger" :showClosingButton="false" - /> + > + + + { - it("can display an audio thumbnail", async () => { - const parent = ref({ ...mockData.mockPostDto }); - const wrapper = mount(EditContentMedia, { - props: { - docType: DocType.Post, - tagOrPostType: PostType.Blog, - disabled: false, - newDocument: true, // Keep expanded - }, - global: { - stubs: { - MediaEditor: { - template: - '
{{ JSON.stringify(parent.media?.fileCollections) }}
', - props: ["parent", "disabled"], - }, - }, +const encoder = { + availability: ref("available"), + encoderVersion: ref("0.0.1"), + busy: ref(false), + status: ref(undefined), + progress: ref(undefined), + error: ref(undefined), + sessionId: ref(undefined), + outdated: ref(false), + refreshAvailability: vi.fn().mockResolvedValue(true), + watchForEncoder: vi.fn(), + start: vi.fn(), + resume: vi.fn().mockResolvedValue(false), + stop: vi.fn(), +}; + +vi.mock("@/composables/useMediaEncoder", () => ({ useMediaEncoder: () => encoder })); + +vi.mock("@/composables/storageSelection", () => ({ + storageSelection: () => ({ + autoSelectMediaBucket: ref("bucket-1"), + effectiveMediaBucketId: (persisted?: string) => persisted ?? "bucket-1", + mediaBuckets: ref([{ _id: "bucket-1", name: "media" }]), + hasMediaBuckets: ref(true), + getBucketById: () => undefined, + }), +})); + +import EditContentMedia from "./EditContentMedia.vue"; + +const parent = () => ({ ...mockData.mockPostDto }) as PostDto; + +const mountSection = (props = {}) => + mount(EditContentMedia, { + props: { disabled: false, title: "Episode 12", parent: parent(), ...props }, + global: { + stubs: { + EditContentVideo: { template: "
" }, + MediaAudioList: { template: "
" }, + MediaBucketSelect: { template: "
" }, }, + }, + }); + +const settle = () => new Promise((resolve) => setTimeout(resolve)); + +beforeEach(() => { + vi.clearAllMocks(); + encoder.availability.value = "available"; + encoder.busy.value = false; + encoder.status.value = undefined; + encoder.error.value = undefined; +}); + +/** + * The section owns the encode because it owns the document: the button and the + * progress are two ends of one thing that used to sit in two different cards. + */ +describe("EditContentMedia", () => { + it("holds the whole media job in one section", async () => { + const wrapper = mountSection(); + await settle(); + + expect(wrapper.find('[data-test="encode-media-button"]').exists()).toBe(true); + expect(wrapper.find('[data-test="bucket-stub"]').exists()).toBe(true); + expect(wrapper.find('[data-test="audio-stub"]').exists()).toBe(true); + }); + + it("names where to get the app in the help text", async () => { + // Findable when no notice is showing — an editor who has not tried to + // encode yet has nothing else pointing at the download. + const wrapper = mountSection(); + await settle(); + + await wrapper.find('[aria-label="Media help"]').trigger("click"); + + const link = wrapper.find('[data-test="media-help-download"]'); + expect(link.exists()).toBe(true); + expect(link.attributes("href")).toContain("releases"); + }); + + it("shows the video fields only once a translation is selected", async () => { + expect(mountSection().find('[data-test="video-stub"]').exists()).toBe(false); + expect(mountSection({ showVideo: true }).find('[data-test="video-stub"]').exists()).toBe( + true, + ); + }); + + it("starts an encode for this document and title", async () => { + const wrapper = mountSection(); + await settle(); + + await wrapper.find('[data-test="encode-media-button"]').trigger("click"); + + expect(encoder.start).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: mockData.mockPostDto._id, + title: "Episode 12", + mediaBucketId: "bucket-1", + }), + ); + }); + + it("records the auto-selected bucket on the document when an encode starts", async () => { + const doc = parent(); + doc.mediaBucketId = undefined; + const wrapper = mountSection({ parent: doc }); + await settle(); + + await wrapper.find('[data-test="encode-media-button"]').trigger("click"); + + expect(wrapper.props("parent")!.mediaBucketId).toBe("bucket-1"); + }); + + it("writes the playback URL and key onto the document as soon as they exist", async () => { + const wrapper = mountSection(); + await settle(); + await wrapper.find('[data-test="encode-media-button"]').trigger("click"); + + const { onMediaReady } = encoder.start.mock.calls[0][0]; + onMediaReady({ hlsUrl: "https://cdn/master.m3u8", hlsKey: "abc" }, mockData.mockPostDto._id); + await settle(); + + expect(wrapper.props("parent")!.media?.hlsUrl).toBe("https://cdn/master.m3u8"); + expect(wrapper.props("parent")!.media?.hlsKey).toBe("abc"); + }); + + it("refuses a result for a document the editor has since left", async () => { + // The encoder's trust prompt can hold start() open for as long as the user + // likes; by the time it answers, this section may be showing another post. + const wrapper = mountSection(); + await settle(); + await wrapper.find('[data-test="encode-media-button"]').trigger("click"); + const { onMediaReady } = encoder.start.mock.calls[0][0]; + + await wrapper.setProps({ parent: { ...parent(), _id: "post-2", media: undefined } as PostDto }); + onMediaReady({ hlsUrl: "https://cdn/master.m3u8", hlsKey: "abc" }, mockData.mockPostDto._id); + await settle(); + + expect(wrapper.props("parent")!.media).toBeUndefined(); + }); + + it("keeps the audio already on the document when the encoder writes a video", async () => { + const wrapper = mountSection(); + await settle(); + await wrapper.find('[data-test="encode-media-button"]').trigger("click"); + + const before = wrapper.props("parent")!.media?.fileCollections; + encoder.start.mock.calls[0][0].onMediaReady( + { hlsUrl: "https://cdn/master.m3u8" }, + mockData.mockPostDto._id, + ); + await settle(); + + expect(wrapper.props("parent")!.media?.fileCollections).toEqual(before); + }); +}); + +describe("EditContentMedia resume", () => { + it("asks whether this document already has an encode running", async () => { + mountSection(); + await settle(); + + expect(encoder.resume).toHaveBeenCalledWith( + expect.objectContaining({ documentId: mockData.mockPostDto._id }), + ); + }); + + it("follows the editor to another document rather than the one it was built for", async () => { + const wrapper = mountSection(); + await settle(); + encoder.resume.mockClear(); + + await wrapper.setProps({ parent: { ...parent(), _id: "post-2" } as PostDto }); + await settle(); + + expect(encoder.resume).toHaveBeenCalledWith( + expect.objectContaining({ documentId: "post-2" }), + ); + }); + + it("writes back a URL recovered from a resumed session", async () => { + encoder.resume.mockImplementation(async ({ documentId, onMediaReady }: any) => { + onMediaReady({ hlsUrl: "https://cdn/resumed.m3u8" }, documentId); + return true; }); - // Set the v-model value - await wrapper.setProps({ parent: parent.value }); + const wrapper = mountSection(); + await settle(); + + expect(wrapper.props("parent")!.media?.hlsUrl).toBe("https://cdn/resumed.m3u8"); + }); +}); + +describe("EditContentMedia status", () => { + it("puts progress in the body, with the width of the section", async () => { + encoder.status.value = "encoding"; + encoder.progress.value = 42; + + const wrapper = mountSection(); + await settle(); + + expect(wrapper.find('[data-test="encoder-progress-bar"]').exists()).toBe(true); + }); + + it("explains an unreachable encoder below the control rather than replacing it", async () => { + encoder.availability.value = "unavailable"; + + const wrapper = mountSection(); + await settle(); - // The component should be expanded since newDocument is true - expect(wrapper.html()).toContain("audio-en.mp3"); - expect(wrapper.html()).toContain("audio-fr.mp3"); + expect(wrapper.find('[data-test="encoder-unavailable"]').exists()).toBe(true); + expect(wrapper.find('[data-test="encode-media-button"]').exists()).toBe(true); }); }); diff --git a/cms/src/components/content/EditContentMedia.vue b/cms/src/components/content/EditContentMedia.vue index 7028042e52..30bb72aeea 100644 --- a/cms/src/components/content/EditContentMedia.vue +++ b/cms/src/components/content/EditContentMedia.vue @@ -1,169 +1,181 @@ diff --git a/cms/src/components/content/EditContentVideo.spec.ts b/cms/src/components/content/EditContentVideo.spec.ts index 2176ab0b07..d1b707a65f 100644 --- a/cms/src/components/content/EditContentVideo.spec.ts +++ b/cms/src/components/content/EditContentVideo.spec.ts @@ -2,17 +2,28 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import { mount } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; -import { type ContentDto, accessMap } from "luminary-shared"; +import { type ContentParentDto, accessMap } from "luminary-shared"; import * as mockData from "@/tests/mockdata"; import { setActivePinia } from "pinia"; import { ref } from "vue"; import EditContentVideo from "./EditContentVideo.vue"; -import LInput from "../forms/LInput.vue"; + +const HLS_URL = "https://example.com/media/post/master.m3u8"; + +const parentWith = (media?: Partial>) => + ref({ + ...mockData.mockPostDto, + media: media ? ({ fileCollections: [], ...media } as any) : undefined, + } as ContentParentDto); + +const mountVideo = (parent: ReturnType) => + mount(EditContentVideo, { + props: { disabled: false, parent: parent.value }, + }); describe("EditContentVideo.vue", () => { beforeAll(async () => { setActivePinia(createTestingPinia()); - accessMap.value = mockData.fullAccessToAllContentMap; }); @@ -20,60 +31,146 @@ describe("EditContentVideo.vue", () => { vi.clearAllMocks(); }); - it("displays the video field, when it is defined", async () => { - const content = ref({ - ...mockData.mockEnglishContentDto, - video: "https://example.com/video.mp4", - }); - const wrapper = mount(EditContentVideo, { - props: { - disabled: false, - content: content.value, - }, - }); + it("displays the video card", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); - const videoContent = wrapper.find('div[data-test="videoContent"]'); - expect(videoContent.exists()).toBe(true); + expect(wrapper.find('div[data-test="videoContent"]').exists()).toBe(true); }); - it("displays video URL in the text input", async () => { - const content = ref({ - ...mockData.mockEnglishContentDto, - video: "https://example.com/video.mp4", - }); + it("shows the playlist URL from media", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); - const wrapper = mount(EditContentVideo, { - props: { - disabled: false, - content: content.value, - }, - }); + const input = wrapper.find("input[name='video']").element as HTMLInputElement; + expect(input.value).toBe(HLS_URL); + }); - // Find the input field within LInput - const videoInputWrapper = wrapper.find("input[name='video']"); - const videoInput = videoInputWrapper.element as HTMLInputElement; + it("writes an edited URL back to media", async () => { + const parent = parentWith({ hlsUrl: HLS_URL }); + const wrapper = mountVideo(parent); - // Check if the input value is correctly set - expect(videoInput.value).toBe("https://example.com/video.mp4"); + await wrapper.find("input[name='video']").setValue("https://example.com/new.m3u8"); + + expect(parent.value.media?.hlsUrl).toBe("https://example.com/new.m3u8"); }); - it("can update the video input field", async () => { - const content = ref({ - ...mockData.mockEnglishContentDto, - video: "https://example.com/video.mp4", - }); - const wrapper = mount(EditContentVideo, { - props: { - disabled: false, - content: content.value, - }, - }); + it("creates media on a document that has none, rather than dropping the edit", async () => { + const parent = parentWith(); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='video']").setValue(HLS_URL); + + expect(parent.value.media?.hlsUrl).toBe(HLS_URL); + }); + + it("writes an entered encryption key to media", async () => { + const parent = parentWith({ hlsUrl: HLS_URL }); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='hlsKey']").setValue("0123456789abcdef"); + + expect(parent.value.media?.hlsKey).toBe("0123456789abcdef"); + }); + + it("clears the key rather than storing an empty string", async () => { + const parent = parentWith({ hlsUrl: HLS_URL, hlsKey: "0123456789abcdef" }); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='hlsKey']").setValue(""); + + expect(parent.value.media?.hlsKey).toBeUndefined(); + }); + + it("says a key is saved when the document holds only its reference", async () => { + // After a save the key itself is gone — the API keeps it in a sidecar and + // returns its id — so an empty field must not read as "no key". + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL, hlsKey_id: "sidecar-1" })); + + expect(wrapper.find('[data-test="video-key-note"]').text()).toContain( + "encryption key is saved", + ); + }); + + it("asks the exact question before replacing a saved key", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL, hlsKey_id: "crypto-1" })); - const videoInputWrapper = wrapper.findComponent(LInput).find("input[name='video']"); - const videoInput = videoInputWrapper.element as HTMLInputElement; + await wrapper.find("input[name='hlsKey']").setValue("beefbeefbeefbeef"); - await videoInputWrapper.setValue("https://example.com/new-video.mp4"); + expect(wrapper.text()).toContain("Replace the encryption key?"); + expect(wrapper.text()).toContain("unplayable"); + }); + + it("does not ask when there is no saved key", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); + + await wrapper.find("input[name='hlsKey']").setValue("beefbeefbeefbeef"); + + expect(wrapper.text()).not.toContain("Replace the encryption key?"); + }); + + it("offers to enter a new key once one is saved", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL, hlsKey_id: "crypto-1" })); + + expect(wrapper.find("input[name='hlsKey']").attributes("placeholder")).toBe( + "Enter new encryption key", + ); + }); + + it("clears the field when the replacement is declined", async () => { + // Declining has to leave nothing behind: a half-typed key that reached + // the document would replace the saved one at the next save. + const parent = parentWith({ hlsUrl: HLS_URL, hlsKey_id: "crypto-1" }); + const wrapper = mountVideo(parent); + + await wrapper.find("input[name='hlsKey']").setValue("beefbeefbeefbeef"); + expect(parent.value.media?.hlsKey).toBe("beefbeefbeefbeef"); + + const cancel = wrapper.findAll("button").find((b) => b.text().includes("Cancel")); + await cancel!.trigger("click"); + + expect(parent.value.media?.hlsKey).toBeUndefined(); + }); + + it("warns before a saved key is replaced", async () => { + // The one edit on this form that cannot be undone: the media was + // encrypted with the old key and nothing keeps a copy of it. + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL, hlsKey_id: "crypto-1" })); + expect(wrapper.find('[data-test="video-key-warning"]').exists()).toBe(false); + + await wrapper.find("input[name='hlsKey']").setValue("beefbeefbeefbeef"); + + expect(wrapper.find('[data-test="video-key-warning"]').text()).toContain("unplayable"); + }); + + it("does not warn about a key the encoder wrote, which replaces nothing", async () => { + // The encoder writes hlsKey on every encode. Treating that as a + // replacement puts a permanent red paragraph under every encoded video. + const wrapper = mountVideo( + parentWith({ hlsUrl: HLS_URL, hlsKey_id: "crypto-1", hlsKey: "beefbeefbeefbeef" }), + ); + + expect(wrapper.find('[data-test="video-key-warning"]').exists()).toBe(false); + }); + + it("does not warn when there is no saved key to replace", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); + + await wrapper.find("input[name='hlsKey']").setValue("beefbeefbeefbeef"); + + expect(wrapper.find('[data-test="video-key-warning"]').exists()).toBe(false); + }); + + it("explains when no key is needed", async () => { + const wrapper = mountVideo(parentWith({ hlsUrl: HLS_URL })); + + expect(wrapper.find('[data-test="video-key-note"]').text()).toContain("encrypted"); + }); + + it("disables both fields when the user cannot edit", async () => { + const wrapper = mount(EditContentVideo, { + props: { disabled: true, parent: parentWith({ hlsUrl: HLS_URL }).value }, + }); - expect(videoInput.value).toBe("https://example.com/new-video.mp4"); + expect(wrapper.find("input[name='video']").attributes("disabled")).toBeDefined(); + expect(wrapper.find("input[name='hlsKey']").attributes("disabled")).toBeDefined(); }); }); diff --git a/cms/src/components/content/EditContentVideo.vue b/cms/src/components/content/EditContentVideo.vue index 9c13a010b6..26874f87ae 100644 --- a/cms/src/components/content/EditContentVideo.vue +++ b/cms/src/components/content/EditContentVideo.vue @@ -1,24 +1,114 @@ + + diff --git a/cms/src/components/content/__tests__/EditContent/EditContent.delete.spec.ts b/cms/src/components/content/__tests__/EditContent/EditContent.delete.spec.ts index cd6c02a90f..97978c565d 100644 --- a/cms/src/components/content/__tests__/EditContent/EditContent.delete.spec.ts +++ b/cms/src/components/content/__tests__/EditContent/EditContent.delete.spec.ts @@ -152,4 +152,105 @@ describe("EditContent - Delete Operations", () => { }); }); }); + + describe("deleting the media files with the document", () => { + /** Open the post's delete dialog: dropdown → delete → dialog. */ + const openDeleteDialog = async (wrapper: any) => { + let chevron: any; + await waitForExpect(() => { + chevron = wrapper.find('[data-test="dropdown-trigger"]'); + expect(chevron.exists()).toBe(true); + }); + await chevron!.trigger("click"); + + let deleteButton: any; + await waitForExpect(() => { + deleteButton = wrapper.find('[data-test="delete-button"]'); + expect(deleteButton.exists()).toBe(true); + }); + await deleteButton!.trigger("click"); + }; + + const confirm = async (wrapper: any) => { + let primary: any; + await waitForExpect(() => { + primary = wrapper.find('[data-test="modal-primary-button"]'); + expect(primary.exists()).toBe(true); + }); + await primary!.trigger("click"); + }; + + const queuedDelete = async () => { + const changes = await db.localChanges.where({ docId: mockPostDto._id }).toArray(); + return changes.find((c: any) => c.doc?.deleteReq)?.doc as any; + }; + + it("offers the option, unticked, for a document with media", async () => { + const wrapper = mount(EditContent, { + props: { + id: mockPostDto._id, + languageCode: "eng", + docType: DocType.Post, + tagOrPostType: PostType.Blog, + }, + }); + + await openDeleteDialog(wrapper); + + await waitForExpect(() => { + const option = wrapper.find('[data-test="delete-media-files"]'); + expect(option.exists()).toBe(true); + // Irreversible, so never pre-ticked. + expect((option.find("input").element as HTMLInputElement).checked).toBe(false); + }); + }); + + it("does not ask the API to touch storage when the option is left alone", async () => { + const wrapper = mount(EditContent, { + props: { + id: mockPostDto._id, + languageCode: "eng", + docType: DocType.Post, + tagOrPostType: PostType.Blog, + }, + }); + + await openDeleteDialog(wrapper); + await confirm(wrapper); + + await waitForExpect(async () => { + const doc = await queuedDelete(); + expect(doc).toBeTruthy(); + expect(doc.media?.deleteFiles).toBeFalsy(); + }); + }); + + it("asks the API to delete the files when the option is ticked", async () => { + const wrapper = mount(EditContent, { + props: { + id: mockPostDto._id, + languageCode: "eng", + docType: DocType.Post, + tagOrPostType: PostType.Blog, + }, + }); + + await openDeleteDialog(wrapper); + + let option: any; + await waitForExpect(() => { + option = wrapper.find('[data-test="delete-media-files"]'); + expect(option.exists()).toBe(true); + }); + await option!.find("input").setValue(true); + + await confirm(wrapper); + + await waitForExpect(async () => { + const doc = await queuedDelete(); + expect(doc).toBeTruthy(); + expect(doc.media.deleteFiles).toBe(true); + }); + }); + }); }); diff --git a/cms/src/components/content/composables/useEditContentSource.ts b/cms/src/components/content/composables/useEditContentSource.ts index 85961627d0..46c017ea13 100644 --- a/cms/src/components/content/composables/useEditContentSource.ts +++ b/cms/src/components/content/composables/useEditContentSource.ts @@ -62,7 +62,7 @@ export type UseEditContentSource = { /** Persist the parent + edited content children and re-baseline the dirty state. */ save: () => Promise; /** Delete the parent document (does not mark content children for deletion). */ - deleteParent: () => Promise; + deleteParent: (deleteMediaFiles?: boolean) => Promise; /** Revert the parent and all content children to their last-saved state. */ revert: () => void; /** @@ -158,6 +158,33 @@ export function useEditContentSource(options: UseEditContentSourceOptions): UseE const { remove: removeParent } = parentEditable; const contentEditable = toEditable(contentSource, { persistOffline: true, + // Server-owned fields, re-stamped on every parent save. Without back-patching, + // this editor's own save comes back as "changed remotely by someone else". + backPatchFields: [ + "memberOf", + "parentTags", + "parentType", + "parentImageData", + "parentImageBucketId", + "parentMedia", + "parentMediaBucketId", + "parentPostType", + "parentTagType", + "parentPinned", + "parentTaggedDocs", + "parentPublishDateVisible", + "parentShowComingSoon", + "parentAlwaysOffline", + "parentUseVerticalTileLayout", + "parentAuthorType", + "parentLinkDates", + "availableTranslations", + "fts", + "ftsTokenCount", + "wordCount", + "previousSlugs", + "statusChangeDeleteCmdId", + ], // Optional fields: the UI writes "" / undefined where the DB simply omits the key. Strip // every empty own-key so typing-then-clearing any optional field reads clean again — both // sides are filtered before the dirty compare, so this is symmetric and needs no per-field @@ -289,9 +316,8 @@ export function useEditContentSource(options: UseEditContentSourceOptions): UseE // Concurrent-edit conflict: the server value diverged from our baseline while we hold unsaved // edits. toEditable's `isModified` is true only in that case (an unedited doc's incoming change // flows in silently), so this is exactly "someone else pushed a change to what I'm editing". - // ponytail: a server round-trip that adds server-owned fields (fts, availableTranslations) right - // after our own save can transiently read as incoming if the user resumes editing before it lands - // — isEqualBase (shared) doesn't normalize those and we can't tune it. Narrow; not worth solving. + // The server-owned fields a save round-trip rewrites are back-patched above, so they + // never reach this comparison — without that, every save came back as someone else's edit. const isIncomingChange = computed( () => (id: Uuid) => parentEditable.isModified.value(id) || contentEditable.isModified.value(id), @@ -318,9 +344,19 @@ export function useEditContentSource(options: UseEditContentSourceOptions): UseE ); }; - const deleteParent = async (): Promise => { + /** + * Delete the parent and, when asked, the media files in storage with it. + * + * The flag is set on the document rather than passed alongside it because a + * delete is a change request: the whole document goes to the API with + * `deleteReq` set, and `media.deleteFiles` is a write-only field the API reads + * and never stores. Nothing to undo afterwards — the document is gone either + * way, and the field never reaches the database. + */ + const deleteParent = async (deleteMediaFiles = false): Promise => { const parent = editableParent.value; if (!parent) return false; + if (deleteMediaFiles && parent.media) parent.media.deleteFiles = true; const res = await removeParent(parent._id); return res?.ack === AckStatus.Accepted; }; diff --git a/cms/src/components/media/EncodeMediaButton.spec.ts b/cms/src/components/media/EncodeMediaButton.spec.ts new file mode 100644 index 0000000000..3ca3d8b914 --- /dev/null +++ b/cms/src/components/media/EncodeMediaButton.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { mount } from "@vue/test-utils"; +import EncodeMediaButton from "./EncodeMediaButton.vue"; + +const mountButton = (props = {}) => + mount(EncodeMediaButton, { + props: { + availability: "available" as const, + busy: false, + documentId: "post-1", + hasBucket: true, + ...props, + }, + }); + +const button = (wrapper: ReturnType) => + wrapper.find('[data-test="encode-media-button"]'); + +describe("EncodeMediaButton", () => { + it("asks its owner to encode rather than starting one itself", async () => { + const wrapper = mountButton(); + + await button(wrapper).trigger("click"); + + expect(wrapper.emitted("encode")).toHaveLength(1); + }); + + it("stays one control in every state, so the header never changes shape", () => { + for (const availability of ["available", "unavailable", "browser-unsupported"] as const) { + expect(button(mountButton({ availability })).exists()).toBe(true); + } + }); +}); + +/** + * A control disabled for five different reasons and explaining none of them is a + * support question every time, so each reason names itself. + */ +describe("EncodeMediaButton disabled reasons", () => { + const reasonFor = (props: Record) => + button(mountButton(props)).attributes("title"); + + it("says the document must be saved first", () => { + expect(reasonFor({ documentId: undefined })).toContain("Save the document"); + }); + + it("says there is no bucket to write to", () => { + expect(reasonFor({ hasBucket: false })).toContain("storage bucket"); + }); + + it("says the encoder is not running", () => { + expect(reasonFor({ availability: "unavailable" })).toContain("not running"); + }); + + it("says the browser cannot reach it", () => { + expect(reasonFor({ availability: "browser-unsupported" })).toContain("did not answer"); + }); + + it("says the editor lacks permission", () => { + expect(reasonFor({ disabled: true })).toContain("permission"); + }); + + it("explains nothing when it is ready to run", () => { + expect(reasonFor({})).toContain("Encode video"); + expect(button(mountButton()).attributes("disabled")).toBeUndefined(); + }); + + it("does not emit while disabled", async () => { + const wrapper = mountButton({ documentId: undefined }); + + await button(wrapper).trigger("click"); + + expect(wrapper.emitted("encode")).toBeUndefined(); + }); +}); + +describe("EncodeMediaButton while starting", () => { + it("is disabled with a reason once a session is being opened", () => { + const wrapper = mountButton({ busy: true }); + + expect(button(wrapper).attributes("disabled")).toBeDefined(); + expect(button(wrapper).attributes("title")).toContain("Starting"); + }); +}); diff --git a/cms/src/components/media/EncodeMediaButton.vue b/cms/src/components/media/EncodeMediaButton.vue new file mode 100644 index 0000000000..60c21d229c --- /dev/null +++ b/cms/src/components/media/EncodeMediaButton.vue @@ -0,0 +1,56 @@ + + + diff --git a/cms/src/components/media/EncodeStatus.spec.ts b/cms/src/components/media/EncodeStatus.spec.ts new file mode 100644 index 0000000000..1e2fa05071 --- /dev/null +++ b/cms/src/components/media/EncodeStatus.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from "vitest"; +import { mount } from "@vue/test-utils"; +import EncodeStatus from "./EncodeStatus.vue"; + +const mountStatus = (props = {}) => + mount(EncodeStatus, { + props: { availability: "available" as const, ...props }, + }); + +describe("EncodeStatus progress", () => { + it("gives a running encode a bar, not a line of small grey text", () => { + const wrapper = mountStatus({ status: "encoding", progress: 42 }); + const bar = wrapper.find('[data-test="encoder-progress-bar"]'); + + expect(bar.exists()).toBe(true); + expect(bar.attributes("style")).toContain("42%"); + expect(wrapper.find('[data-test="encoder-status"]').text()).toContain("Encoding 42%"); + }); + + /* + * Segments upload as they are packed, so the session status stays "encoding" + * until only the playlists are left — which read as "Encoding" here while the + * encoder's own window showed an upload bar moving. + */ + describe("once encoding is done and only the upload is moving", () => { + it("says Uploading, with the upload's own percentage", () => { + const wrapper = mountStatus({ + status: "encoding", + progress: 100, + pipelineProgress: { encoding: 100, uploading: 53 }, + }); + + expect(wrapper.find('[data-test="encoder-status"]').text()).toContain("Uploading 53%"); + expect(wrapper.find('[data-test="encoder-progress-bar"]').attributes("style")).toContain( + "53%", + ); + }); + + it("still says Encoding while the two are running together", () => { + // Encoding is the honest answer while it is still producing segments. + const wrapper = mountStatus({ + status: "encoding", + progress: 62, + pipelineProgress: { encoding: 62, uploading: 20 }, + }); + + expect(wrapper.find('[data-test="encoder-status"]').text()).toContain("Encoding 62%"); + }); + + it("hands back to the session status once the upload has finished", () => { + const wrapper = mountStatus({ + status: "encoding", + progress: 100, + pipelineProgress: { encoding: 100, uploading: 100 }, + }); + + expect(wrapper.find('[data-test="encoder-status"]').text()).toContain("Encoding"); + }); + + it("is unaffected by an encoder that sends no pipeline detail", () => { + // Older encoders, and any frame before the pipeline starts reporting. + const wrapper = mountStatus({ status: "encoding", progress: 42 }); + + expect(wrapper.find('[data-test="encoder-status"]').text()).toContain("Encoding 42%"); + }); + }); + + it("translates the encoder's statuses out of snake_case", () => { + const wrapper = mountStatus({ status: "uploading_to_s3", progress: 10 }); + + expect(wrapper.find('[data-test="encoder-status"]').text()).toContain("Uploading 10%"); + }); + + it("shows a finished encode without a redundant percentage", () => { + const wrapper = mountStatus({ status: "completed", progress: 100 }); + const text = wrapper.find('[data-test="encoder-status"]').text(); + + expect(text).toContain("Encoded"); + expect(text).not.toContain("100%"); + expect(wrapper.find('[data-test="encoder-progress-bar"]').exists()).toBe(false); + }); + + it("says leaving the page is safe, which it now is", () => { + const wrapper = mountStatus({ status: "encoding", progress: 5 }); + + expect(wrapper.find('[data-test="encoder-leave-hint"]').exists()).toBe(true); + }); + + it("does not promise that about a finished encode", () => { + const wrapper = mountStatus({ status: "completed" }); + + expect(wrapper.find('[data-test="encoder-leave-hint"]').exists()).toBe(false); + }); + + it("shows nothing at all before a session has started", () => { + expect(mountStatus().find('[data-test="encoder-status"]').exists()).toBe(false); + }); +}); + +/** + * One notice idiom for every reason the encoder cannot be used, with the message + * readable rather than truncated into a title attribute. + */ +describe("EncodeStatus notices", () => { + it("offers a launch link when the encoder is merely closed", () => { + const wrapper = mountStatus({ availability: "unavailable" }); + + expect(wrapper.find('[data-test="encoder-unavailable"]').exists()).toBe(true); + expect(wrapper.find('[data-test="encoder-launch"]').exists()).toBe(true); + }); + + it("asks for the encoder to be opened before blaming the browser", () => { + // Whether a non-Chromium browser genuinely cannot reach the encoder is + // unverified (#1979). Not running is the likelier cause and the one the + // editor can act on, so it leads and the launch link is offered. + const wrapper = mountStatus({ availability: "browser-unsupported" }); + const notice = wrapper.find('[data-test="encoder-browser-unsupported"]'); + + expect(notice.text()).toContain("did not answer"); + expect(wrapper.find('[data-test="encoder-launch"]').exists()).toBe(true); + }); + + it("still names Chrome as the browser it is known to work in", () => { + const wrapper = mountStatus({ availability: "browser-unsupported" }); + + expect(wrapper.find('[data-test="encoder-browser-unsupported"]').text()).toContain( + "Chrome", + ); + }); + + it("says the notice updates itself, rather than asking for another try", () => { + // The owner polls while the encoder is missing, so an editor who opens the + // app — from this link or from the Dock — does not have to do anything else. + const wrapper = mountStatus({ availability: "unavailable" }); + + expect(wrapper.find('[data-test="encoder-unavailable"]').text()).toContain( + "updates on its own", + ); + }); + + it("shows a failure in full rather than truncated into a tooltip", () => { + const message = "Luminary Media Convert has not been allowed to work with this site."; + const wrapper = mountStatus({ availability: "available", error: message }); + + expect(wrapper.find('[data-test="encoder-error"]').text()).toContain(message); + }); + + it("leads with the failure rather than the availability behind it", () => { + const wrapper = mountStatus({ availability: "unavailable", error: "Something broke" }); + + expect(wrapper.find('[data-test="encoder-error"]').exists()).toBe(true); + expect(wrapper.find('[data-test="encoder-unavailable"]').exists()).toBe(false); + }); + + it("offers the download when the encoder cannot be reached at all", () => { + // The one notice a first-time editor sees. Before this it only said + // "Open it" — a protocol link nothing has registered on a machine that + // has never had the app, so the click did nothing and there was no way + // to get it from here. + const wrapper = mountStatus({ availability: "unavailable" }); + const download = wrapper.find('[data-test="encoder-download"]'); + + expect(download.exists()).toBe(true); + expect(download.attributes("href")).toContain("releases"); + }); + + it("offers the download when the browser cannot reach it either", () => { + const wrapper = mountStatus({ availability: "browser-unsupported" }); + + expect(wrapper.find('[data-test="encoder-download"]').exists()).toBe(true); + }); + + it("opens the download away from the CMS, without handing it the opener", () => { + const download = mountStatus({ availability: "unavailable" }).find( + '[data-test="encoder-download"]', + ); + + expect(download.attributes("target")).toBe("_blank"); + expect(download.attributes("rel")).toContain("noopener"); + }); + + it("says nothing when the encoder is simply there", () => { + expect(mountStatus().find('[data-test="media-notice"]').exists()).toBe(false); + }); + + it("asks for a new download when the encoder is outdated", () => { + const wrapper = mountStatus({ outdated: true }); + const notice = wrapper.find('[data-test="encoder-outdated"]'); + + expect(notice.text()).toContain("outdated"); + expect(wrapper.find('[data-test="encoder-download"]').attributes("href")).toContain( + "releases", + ); + }); + + it("does not say 'not running' about an encoder that answered but is old", () => { + // Outdated is only ever set when the health check succeeded, so the two + // notices cannot truthfully show together. + const wrapper = mountStatus({ availability: "unavailable", outdated: true }); + + expect(wrapper.find('[data-test="encoder-outdated"]').exists()).toBe(true); + expect(wrapper.find('[data-test="encoder-unavailable"]').exists()).toBe(false); + }); +}); diff --git a/cms/src/components/media/EncodeStatus.vue b/cms/src/components/media/EncodeStatus.vue new file mode 100644 index 0000000000..862538f43f --- /dev/null +++ b/cms/src/components/media/EncodeStatus.vue @@ -0,0 +1,172 @@ + + + diff --git a/cms/src/components/media/MediaAudioList.spec.ts b/cms/src/components/media/MediaAudioList.spec.ts new file mode 100644 index 0000000000..c0b6dc3d00 --- /dev/null +++ b/cms/src/components/media/MediaAudioList.spec.ts @@ -0,0 +1,79 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { ref } from "vue"; + +vi.mock("luminary-shared", async (importOriginal) => ({ + ...(await importOriginal()), + useSharedHybridQuery: () => + ref([ + { _id: "lang-en", name: "English", languageCode: "en" }, + { _id: "lang-fr", name: "Français", languageCode: "fr" }, + ]), +})); + +import MediaAudioList from "./MediaAudioList.vue"; + +const parentWith = (collections: unknown[]) => + ({ _id: "post-1", media: { fileCollections: collections } }) as any; + +const mountList = (collections: unknown[]) => + mount(MediaAudioList, { props: { parent: parentWith(collections) } }); + +const FR = { fileUrl: "audio-fr.mp3", languageId: "lang-fr" }; +const EN = { fileUrl: "audio-en.mp3", languageId: "lang-en" }; + +/** + * Audio can no longer be added from the CMS, but the app still plays what is on a + * document, so an editor has to be able to see what a reader hears. + */ +describe("MediaAudioList", () => { + it("names each track's language rather than leaving a two-letter badge to carry it", () => { + const text = mountList([EN, FR]).text(); + + expect(text).toContain("English"); + expect(text).toContain("Français"); + }); + + it("lists tracks by language name, whatever order the document holds them in", () => { + const names = mountList([FR, EN]) + .findAll('[data-test="audio-track"]') + .map((row) => row.text()); + + expect(names[0]).toContain("English"); + expect(names[1]).toContain("Français"); + }); + + it("says what the list is, since nothing here can change it", () => { + expect(mountList([EN]).find('[data-test="audio-note"]').text()).toContain("Read-only"); + }); + + it("offers no delete, because the one that was here never deleted anything", () => { + const wrapper = mountList([EN]); + + expect(wrapper.html()).not.toContain("Delete"); + expect(wrapper.find('[data-test="audio-delete"]').exists()).toBe(false); + }); + + it("gives every track a play control", () => { + expect(mountList([EN, FR]).findAll('[data-test="audio-play"]')).toHaveLength(2); + }); + + it("disables the control for a file that will not load", async () => { + const wrapper = mountList([EN]); + + await wrapper.find("audio").trigger("error"); + + expect(wrapper.find('[data-test="audio-play"]').attributes("disabled")).toBeDefined(); + }); + + it("names an unknown language rather than rendering a blank row", () => { + const wrapper = mountList([{ fileUrl: "a.mp3", languageId: "lang-gone" }]); + + expect(wrapper.find('[data-test="audio-track"]').text()).toContain("Unknown language"); + }); + + it("is absent entirely on a document with no audio", () => { + expect(mountList([]).find('[data-test="audio-list"]').exists()).toBe(false); + }); +}); diff --git a/cms/src/components/media/MediaAudioList.vue b/cms/src/components/media/MediaAudioList.vue new file mode 100644 index 0000000000..5711a15137 --- /dev/null +++ b/cms/src/components/media/MediaAudioList.vue @@ -0,0 +1,134 @@ + + + diff --git a/cms/src/components/media/MediaBucketSelect.spec.ts b/cms/src/components/media/MediaBucketSelect.spec.ts new file mode 100644 index 0000000000..f2aa53082a --- /dev/null +++ b/cms/src/components/media/MediaBucketSelect.spec.ts @@ -0,0 +1,114 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { nextTick } from "vue"; +import * as mockData from "@/tests/mockdata"; +import MediaBucketSelect from "./MediaBucketSelect.vue"; +import { type ContentParentDto } from "luminary-shared"; + +const mockMediaBuckets = vi.hoisted(() => { + const { ref } = require("vue"); + return ref([ + { + _id: "bucket-media", + name: "Media Storage", + publicUrl: "http://localhost:9000/media", + storageType: "media", + }, + ]); +}); + +vi.mock("@/composables/storageSelection", () => { + const { ref: _ref, computed: _computed } = require("vue"); + return { + storageSelection: () => ({ + imageBuckets: _ref([]), + mediaBuckets: mockMediaBuckets, + getBucketById: (id: string | null) => + id ? mockMediaBuckets.value.find((b: any) => b._id === id) || null : null, + hasImageBuckets: _ref(false), + hasMediaBuckets: _computed(() => mockMediaBuckets.value.length > 0), + autoSelectImageBucket: _ref(null), + autoSelectMediaBucket: _computed(() => + mockMediaBuckets.value.length === 1 ? mockMediaBuckets.value[0]._id : null, + ), + effectiveMediaBucketId: (persisted?: string) => + persisted ?? + (mockMediaBuckets.value.length === 1 ? mockMediaBuckets.value[0]._id : undefined), + }), + }; +}); + +const ONE_BUCKET = [mockMediaBuckets.value[0]]; +const TWO_BUCKETS = [ + mockMediaBuckets.value[0], + { _id: "bucket-media-2", name: "Second bucket", storageType: "media" }, +]; + +describe("MediaBucketSelect", () => { + let parent: ContentParentDto; + + beforeEach(() => { + parent = { ...mockData.mockCategoryDto }; + mockMediaBuckets.value = ONE_BUCKET; + }); + + const mountSelect = () => mount(MediaBucketSelect, { props: { parent, disabled: false } }); + + it("hides the selector when there is only one bucket", () => { + expect(mountSelect().find('[data-test="bucket-select"]').exists()).toBe(false); + }); + + it("names the bucket it will encode into, which nothing said before", () => { + expect(mountSelect().find('[data-test="bucket-single"]').text()).toContain("Media Storage"); + }); + + it("offers a selector when there is more than one", () => { + mockMediaBuckets.value = TWO_BUCKETS; + + expect(mountSelect().find('[data-test="bucket-select"]').exists()).toBe(true); + }); + + it("auto-selects a single bucket without dirtying the document", () => { + mountSelect(); + + // Writing it here would mark an untouched legacy document as edited. + expect(parent.mediaBucketId).toBeUndefined(); + }); + + it("clears a bucket that no longer exists", async () => { + parent.mediaBucketId = "bucket-that-was-deleted"; + + mountSelect(); + await new Promise((r) => setTimeout(r, 0)); + + expect(parent.mediaBucketId).toBeUndefined(); + }); + + it("warns when several buckets exist and none is chosen", async () => { + mockMediaBuckets.value = TWO_BUCKETS; + const wrapper = mountSelect(); + await nextTick(); + + expect(wrapper.find('[data-test="bucket-problem"]').text()).toContain( + "Choose a storage bucket", + ); + }); + + it("warns when no buckets are configured at all", async () => { + mockMediaBuckets.value = []; + const wrapper = mountSelect(); + await nextTick(); + + expect(wrapper.find('[data-test="bucket-problem"]').text()).toContain( + "No storage buckets are configured", + ); + }); + + it("says nothing when a bucket is settled", async () => { + const wrapper = mountSelect(); + await nextTick(); + + expect(wrapper.find('[data-test="bucket-problem"]').exists()).toBe(false); + }); +}); diff --git a/cms/src/components/media/MediaBucketSelect.vue b/cms/src/components/media/MediaBucketSelect.vue new file mode 100644 index 0000000000..b4491ab693 --- /dev/null +++ b/cms/src/components/media/MediaBucketSelect.vue @@ -0,0 +1,98 @@ + + + diff --git a/cms/src/components/media/MediaEditor.spec.ts b/cms/src/components/media/MediaEditor.spec.ts deleted file mode 100644 index c8ded41f53..0000000000 --- a/cms/src/components/media/MediaEditor.spec.ts +++ /dev/null @@ -1,327 +0,0 @@ -import "fake-indexeddb/auto"; -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { mount } from "@vue/test-utils"; -import * as mockData from "@/tests/mockdata"; -import MediaEditor from "./MediaEditor.vue"; -import { MediaType, MediaPreset, type ContentParentDto, db } from "luminary-shared"; -import LDialog from "../common/LDialog.vue"; - -// Mock browser APIs -global.URL.createObjectURL = vi.fn(() => "mocked-url"); -global.URL.revokeObjectURL = vi.fn(); - -// Mock FileReader with proper event handling -class MockFileReader { - result: ArrayBuffer | null = null; - onload: ((event: any) => void) | null = null; - - readAsArrayBuffer(file: File) { - const buffer = new ArrayBuffer(file.size || 1024); - this.result = buffer; - if (this.onload) { - this.onload({ target: { result: buffer } }); - } - } -} -global.FileReader = MockFileReader as any; - -// Mock storageSelection composable -const mockMediaBuckets = vi.hoisted(() => { - const { ref } = require("vue"); - return ref([ - { - _id: "bucket-media", - name: "Media Storage", - publicUrl: "http://localhost:9000/media", - storageType: "media", - mimeTypes: ["audio/*"], - }, - ]); -}); - -vi.mock("@/composables/storageSelection", () => { - const { ref: _ref, computed: _computed } = require("vue"); - return { - storageSelection: () => ({ - imageBuckets: _ref([]), - mediaBuckets: mockMediaBuckets, - getBucketById: (id: string | null) => - id ? mockMediaBuckets.value.find((b: any) => b._id === id) || null : null, - hasImageBuckets: _ref(false), - hasMediaBuckets: _computed(() => mockMediaBuckets.value.length > 0), - autoSelectImageBucket: _ref(null), - autoSelectMediaBucket: _computed(() => - mockMediaBuckets.value.length === 1 ? mockMediaBuckets.value[0]._id : null, - ), - }), - }; -}); - -vi.mock("@/globalConfig", async (importOriginal) => { - const { ref } = await import("vue"); - const actual = await importOriginal(); - return { - ...(actual as any), - cmsLanguageIdAsRef: ref("lang-eng"), - isSmallScreen: ref(false), - isMobileScreen: ref(false), - }; -}); - -describe("MediaEditor.vue", () => { - let parent: ContentParentDto; - - beforeEach(async () => { - parent = { ...mockData.mockCategoryDto }; - - await db.docs.bulkPut([ - mockData.mockLanguageDtoEng, - mockData.mockLanguageDtoFra, - mockData.mockLanguageDtoSwa, - ]); - }); - - afterEach(async () => { - await db.docs.clear(); - }); - - it("shows generic empty state message when no media exists", async () => { - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - expect(wrapper.text()).toContain("No audio files uploaded yet."); - }); - - it("shows thumbnail area when media exists", async () => { - parent.media = { - fileCollections: [ - { - fileUrl: "https://example.com/audio-fr.mp3", - languageId: mockData.mockLanguageDtoFra._id, - bitrate: 128000, - mediaType: MediaType.Audio, - }, - ], - uploadData: [], - }; - - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - const thumbnailArea = wrapper.find('[data-test="thumbnail-area"]'); - expect(thumbnailArea.exists()).toBe(true); - }); - - it("respects file input constraints", async () => { - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - const fileInput = wrapper.find('[data-test="audio-upload"]'); - expect(fileInput.exists()).toBe(true); - expect(fileInput.attributes("multiple")).toBeUndefined(); - expect(fileInput.attributes("accept")).toContain("audio/*"); - }); - - it("shows empty message when media has empty fileCollections and no uploadData", async () => { - parent.media = { - fileCollections: [], - uploadData: [], - }; - - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - expect(wrapper.text()).toContain("No audio files uploaded yet."); - }); - - it("exposes handleFiles method", () => { - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - expect((wrapper.vm as any).handleFiles).toBeDefined(); - }); - - it("shows upload data thumbnails when uploadData exists", () => { - parent.media = { - fileCollections: [], - uploadData: [ - { - languageId: mockData.mockLanguageDtoEng._id, - fileData: new ArrayBuffer(100), - mediaType: MediaType.Audio, - preset: MediaPreset.Speech, - }, - ], - }; - - const wrapper = mount(MediaEditor, { - props: { - parent: parent, - disabled: false, - }, - }); - - const thumbnailArea = wrapper.find('[data-test="thumbnail-area"]'); - expect(thumbnailArea.exists()).toBe(true); - }); - - // New tests covering storageSelection and language selection - - it("handleFiles shows error when no bucket is selected", async () => { - // Use multiple buckets so auto-select doesn't kick in - const origBuckets = [...mockMediaBuckets.value]; - mockMediaBuckets.value = [ - ...origBuckets, - { _id: "bucket-media-2", name: "Second Media", publicUrl: "http://test2.com", storageType: "media", mimeTypes: ["audio/*"] }, - ]; - - parent.mediaBucketId = undefined; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, - }); - - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); - - expect(wrapper.text()).toContain("Please select a storage bucket"); - - mockMediaBuckets.value = origBuckets; - }); - - it("handleFiles shows error when no buckets are configured", async () => { - const origBuckets = [...mockMediaBuckets.value]; - mockMediaBuckets.value = []; - parent.mediaBucketId = undefined; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, - }); - - await wrapper.vm.$nextTick(); - expect(wrapper.text()).toContain("No storage buckets configured"); - - mockMediaBuckets.value = origBuckets; - }); - - it("handleFiles opens language selector when bucket is configured", async () => { - parent.mediaBucketId = "bucket-media"; - parent.media = { fileCollections: [], uploadData: [] }; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, - }); - - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); - - // Language selector dialog should open - const dialogs = wrapper.findAllComponents(LDialog); - const languageDialog = dialogs.find((d) => d.props("title") === "Select Language for Audio"); - expect(languageDialog).toBeDefined(); - expect(languageDialog?.props("open")).toBe(true); - }); - - it("cancelLanguageSelection resets state", async () => { - parent.mediaBucketId = "bucket-media"; - parent.media = { fileCollections: [], uploadData: [] }; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, - }); - - // Open language selector - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); - - // Cancel - const dialogs = wrapper.findAllComponents(LDialog); - const languageDialog = dialogs.find((d) => d.props("title") === "Select Language for Audio"); - const cancelAction = languageDialog?.props("secondaryAction") as Function; - cancelAction(); - await wrapper.vm.$nextTick(); - - expect(languageDialog?.props("open")).toBe(false); - }); - - it("auto-selects the single available bucket without mutating parent on mount", async () => { - parent.mediaBucketId = undefined; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, - }); - - // Opening the editor must not write to the parent — that was the - // phantom-dirty bug. The auto-selection is resolved internally via - // effectiveMediaBucketId so the upload picker and validation behave - // as if the single bucket is selected. - expect(parent.mediaBucketId).toBeUndefined(); - expect(wrapper.text()).not.toContain("Please select a storage bucket"); - - // A genuine upload action persists the auto-selected bucket onto the - // parent, so the file lands in the right place. - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { - 0: mockFile, - length: 1, - item: (i: number) => (i === 0 ? mockFile : null), - }; - (wrapper.vm as any).handleFiles(fileList); - await wrapper.vm.$nextTick(); - - expect(parent.mediaBucketId).toBe("bucket-media"); - }); - - it("language selector has a select element for choosing language", async () => { - parent.mediaBucketId = "bucket-media"; - parent.media = { fileCollections: [], uploadData: [] }; - - const wrapper = mount(MediaEditor, { - props: { parent, disabled: false }, - }); - - // Open language selector - const mockFile = new File(["audio"], "test.mp3", { type: "audio/mp3" }); - const fileList = { 0: mockFile, length: 1, item: (i: number) => (i === 0 ? mockFile : null) }; - - const component = wrapper.vm as any; - component.handleFiles(fileList); - await wrapper.vm.$nextTick(); - - // Language select should be present in the dialog - const selects = wrapper.findAll("#language-select"); - expect(selects.length).toBeGreaterThan(0); - }); -}); diff --git a/cms/src/components/media/MediaEditor.vue b/cms/src/components/media/MediaEditor.vue deleted file mode 100644 index 3e5033d71a..0000000000 --- a/cms/src/components/media/MediaEditor.vue +++ /dev/null @@ -1,586 +0,0 @@ - - - diff --git a/cms/src/components/media/MediaEditorThumbnail.spec.ts b/cms/src/components/media/MediaEditorThumbnail.spec.ts deleted file mode 100644 index ec66b57765..0000000000 --- a/cms/src/components/media/MediaEditorThumbnail.spec.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { mount } from "@vue/test-utils"; -import MediaEditorThumbnail from "./MediaEditorThumbnail.vue"; -import LDialog from "@/components/common/LDialog.vue"; -import LBadge from "@/components/common/LBadge.vue"; -import { MediaPreset, MediaType, type MediaFileDto, type MediaUploadDataDto } from "luminary-shared"; - -// Mock URL.createObjectURL / revokeObjectURL -global.URL.createObjectURL = vi.fn(() => "blob:mock-audio-url"); -global.URL.revokeObjectURL = vi.fn(); - -// Mock HTMLAudioElement play/pause -const mockPlay = vi.fn().mockResolvedValue(undefined); -const mockPause = vi.fn(); - -Object.defineProperty(HTMLAudioElement.prototype, "play", { value: mockPlay, writable: true }); -Object.defineProperty(HTMLAudioElement.prototype, "pause", { value: mockPause, writable: true }); - -const mockMediaFile: MediaFileDto = { - fileUrl: "https://example.com/audio-en.mp3", - languageId: "lang-eng", - mediaType: "audio" as any, - bitrate: 128000, -}; - -const mockUploadData: MediaUploadDataDto = { - languageId: "lang-eng", - fileData: new ArrayBuffer(100), - mediaType: MediaType.Audio, - preset: MediaPreset.Speech, -}; - -describe("MediaEditorThumbnail", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders with a language badge for existing media", () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - }, - }); - - const badge = wrapper.findComponent(LBadge); - expect(badge.exists()).toBe(true); - expect(badge.text()).toContain("ENG"); - }); - - it("renders with a language badge for upload data", () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaUploadData: mockUploadData, - languageCode: "ENG", - }, - }); - - const badge = wrapper.findComponent(LBadge); - expect(badge.exists()).toBe(true); - expect(badge.text()).toContain("ENG"); - }); - - it("shows delete confirmation dialog", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - disabled: true, - }, - }); - - // Hover and click trash - await wrapper.find(".group").trigger("mouseover"); - const trashIcon = wrapper.find('[title="Delete file version"]'); - if (trashIcon.exists()) { - await trashIcon.trigger("click"); - - const dialog = wrapper.findComponent(LDialog); - expect(dialog.props("open")).toBe(true); - expect(dialog.props("title")).toBe("Delete file version"); - } - }); - - it("emits deleteFileCollection when confirmed for existing media", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - disabled: true, - }, - }); - - // Open and confirm delete - await wrapper.find(".group").trigger("mouseover"); - const trashIcon = wrapper.find('[title="Delete file version"]'); - if (trashIcon.exists()) await trashIcon.trigger("click"); - - const dialog = wrapper.findComponent(LDialog); - const primaryAction = dialog.props("primaryAction") as Function; - primaryAction(); - - expect(wrapper.emitted("deleteFileCollection")).toBeTruthy(); - expect(wrapper.emitted("deleteFileCollection")![0]).toEqual([mockMediaFile]); - }); - - it("emits deleteUploadData when confirmed for upload data", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaUploadData: mockUploadData, - languageCode: "ENG", - disabled: true, - }, - }); - - await wrapper.find(".group").trigger("mouseover"); - const trashIcon = wrapper.find('[title="Delete file version"]'); - if (trashIcon.exists()) await trashIcon.trigger("click"); - - const dialog = wrapper.findComponent(LDialog); - const primaryAction = dialog.props("primaryAction") as Function; - primaryAction(); - - expect(wrapper.emitted("deleteUploadData")).toBeTruthy(); - expect(wrapper.emitted("deleteUploadData")![0]).toEqual([mockUploadData]); - }); - - it("closes dialog on cancel", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - disabled: true, - }, - }); - - await wrapper.find(".group").trigger("mouseover"); - const trashIcon = wrapper.find('[title="Delete file version"]'); - if (trashIcon.exists()) await trashIcon.trigger("click"); - - const dialog = wrapper.findComponent(LDialog); - const secondaryAction = dialog.props("secondaryAction") as Function; - secondaryAction(); - - expect(dialog.props("open")).toBe(false); - }); - - it("has an audio element with correct src for existing media", () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - }, - }); - - const audio = wrapper.find("audio"); - expect(audio.exists()).toBe(true); - expect(audio.attributes("src")).toBe("https://example.com/audio-en.mp3"); - }); - - it("creates blob URL for upload data audio", () => { - mount(MediaEditorThumbnail, { - props: { - mediaUploadData: mockUploadData, - languageCode: "ENG", - }, - }); - - expect(global.URL.createObjectURL).toHaveBeenCalled(); - }); - - it("renders nothing when neither mediaFile nor mediaUploadData is provided", () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - languageCode: "ENG", - }, - }); - - // Should render the outer div but no .group child - expect(wrapper.find(".group").exists()).toBe(false); - }); - - it("togglePlay starts playback when clicking on existing media", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - }, - }); - - await wrapper.find(".group").trigger("click"); - expect(mockPlay).toHaveBeenCalled(); - }); - - it("togglePlay pauses when already playing", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - }, - }); - - // First click to start playing - await wrapper.find(".group").trigger("click"); - // Wait for the play promise to resolve - await vi.waitFor(() => expect(mockPlay).toHaveBeenCalled()); - - // Second click to pause - await wrapper.find(".group").trigger("click"); - expect(mockPause).toHaveBeenCalled(); - }); - - it("resets playing state when audio ends", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - }, - }); - - // Start playing - await wrapper.find(".group").trigger("click"); - await vi.waitFor(() => expect(mockPlay).toHaveBeenCalled()); - - // Trigger ended event on audio element - const audio = wrapper.find("audio"); - await audio.trigger("ended"); - - // After ended, clicking should play again (not pause) - mockPlay.mockClear(); - await wrapper.find(".group").trigger("click"); - expect(mockPlay).toHaveBeenCalled(); - }); - - it("pauses when another instance starts playing via global event", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaFile: mockMediaFile, - languageCode: "ENG", - }, - }); - - // Start playing this instance - await wrapper.find(".group").trigger("click"); - await vi.waitFor(() => expect(mockPlay).toHaveBeenCalled()); - - // Dispatch global event from a different audio element - const otherAudioEl = document.createElement("audio"); - window.dispatchEvent( - new CustomEvent("cms-audio-thumbnail-play", { detail: otherAudioEl }), - ); - - expect(mockPause).toHaveBeenCalled(); - }); - - it("togglePlay works for upload data", async () => { - const wrapper = mount(MediaEditorThumbnail, { - props: { - mediaUploadData: mockUploadData, - languageCode: "ENG", - }, - }); - - await wrapper.find(".group").trigger("click"); - expect(mockPlay).toHaveBeenCalled(); - }); -}); diff --git a/cms/src/components/media/MediaEditorThumbnail.vue b/cms/src/components/media/MediaEditorThumbnail.vue deleted file mode 100644 index fffb223222..0000000000 --- a/cms/src/components/media/MediaEditorThumbnail.vue +++ /dev/null @@ -1,251 +0,0 @@ - - - diff --git a/cms/src/components/media/MediaNotice.vue b/cms/src/components/media/MediaNotice.vue new file mode 100644 index 0000000000..7cbdde91ee --- /dev/null +++ b/cms/src/components/media/MediaNotice.vue @@ -0,0 +1,50 @@ + + + diff --git a/cms/src/components/modals/LModal.vue b/cms/src/components/modals/LModal.vue index 73fa0a502d..88bf7e4f49 100644 --- a/cms/src/components/modals/LModal.vue +++ b/cms/src/components/modals/LModal.vue @@ -9,21 +9,30 @@ type Props = { heading: string; noDivider?: boolean; largeModal?: boolean; + /** Wide, but only as tall as its content — `largeModal` fixes the height. */ + wide?: boolean; stickToEdges?: boolean; noPadding?: boolean; transparentHeader?: boolean; showClosingButton?: boolean; // When true, the modal cannot be dismissed by clicking outside of it or pressing Escape. preventClose?: boolean; + /** + * Ignore backdrop clicks only. For content a stray click should not throw away — + * Escape and the close controls still work, so nothing is trapped. + */ + preventBackdropClose?: boolean; beforeClose?: () => boolean; }; const props = withDefaults(defineProps(), { largeModal: false, + wide: false, noDivider: false, noPadding: false, transparentHeader: false, showClosingButton: true, preventClose: false, + preventBackdropClose: false, }); const isVisible = defineModel("isVisible"); @@ -34,8 +43,9 @@ const tryClose = () => { }; // Dismiss attempts via the backdrop or the Escape key; blocked when preventClose is set. -const tryDismiss = () => { +const tryDismiss = (viaBackdrop = false) => { if (props.preventClose) return; + if (viaBackdrop && props.preventBackdropClose) return; tryClose(); }; @@ -51,10 +61,12 @@ const breakpoints = useBreakpoints(breakpointsTailwind); const isMobileScreen = breakpoints.smaller("sm"); const isFullscreen = computed(() => { - if (props.stickToEdges || !props.stickToEdges) { + if (isMobileScreen.value && props.stickToEdges) { return "h-[100dvh] w-[100vw] max-w-none rounded-none"; } else if (props.largeModal) { return "h-[90dvh] w-full max-w-5xl lg:h-[80dvh]"; + } else if (props.wide) { + return "max-h-[90dvh] w-full max-w-3xl"; } else { return "max-h-[90dvh] w-full max-w-md"; } @@ -68,7 +80,7 @@ const isFullscreen = computed(() => { 'fixed inset-x-0 top-0 z-50 flex h-[100dvh] items-center justify-center bg-zinc-800 bg-opacity-50 backdrop-blur-sm', noPadding || (stickToEdges && isMobileScreen) ? '' : 'p-2', ]" - @mousedown.self="tryDismiss()" + @mousedown.self="tryDismiss(true)" data-test="modal-backdrop" > @@ -114,6 +126,7 @@ const isFullscreen = computed(() => { variant="secondary" mainDynamicCss="px-0.5 py-0.5 rounded-xl" iconClass="h-5 w-5" + data-test="modal-close" >
diff --git a/cms/src/components/s3/StorageFormModal.spec.ts b/cms/src/components/s3/StorageFormModal.spec.ts index d993e347ed..b77780037e 100644 --- a/cms/src/components/s3/StorageFormModal.spec.ts +++ b/cms/src/components/s3/StorageFormModal.spec.ts @@ -338,3 +338,104 @@ describe("BucketFormModal", () => { expect(wrapper.props("isLoading")).toBe(true); }); }); + +/** + * Encode settings live on the media bucket, so every encode written into it + * behaves the same without each editor deciding per encode. + */ +describe("BucketFormModal media settings", () => { + const mediaBucket: StorageDto = { + _id: "storage-media", + type: DocType.Storage, + updatedTimeUtc: Date.now(), + memberOf: [], + name: "Media Bucket", + storageType: StorageType.Media, + publicUrl: "http://localhost:9000/media", + mimeTypes: ["video/*"], + }; + + const propsFor = (bucket: StorageDto) => ({ + isVisible: true, + bucket, + isEditing: true, + isLoading: false, + errors: undefined, + availableGroups: [mockData.mockGroup] as GroupDto[], + canDelete: false, + isFormValid: true, + validations: [], + hasAttemptedSubmit: false, + hasFieldError: vi.fn(() => false), + touchField: vi.fn(), + localCredentials: { + endpoint: "http://localhost:9000", + bucketName: "media", + accessKey: "k", + secretKey: "s", + } as S3CredentialDto, + hasValidCredentials: true, + }); + + it("shows the section for a media bucket only", () => { + expect( + mount(StorageFormModal, { props: propsFor(mediaBucket) }) + .find('[data-test="media-encode-settings"]') + .exists(), + ).toBe(true); + + expect( + mount(StorageFormModal, { + props: propsFor({ ...mediaBucket, storageType: StorageType.Image }), + }) + .find('[data-test="media-encode-settings"]') + .exists(), + ).toBe(false); + }); + + it("reads absent settings as the defaults: encrypted, byte-range on", () => { + const wrapper = mount(StorageFormModal, { props: propsFor(mediaBucket) }); + + expect( + wrapper.find('[data-test="media-encrypted-toggle"]').attributes("aria-checked"), + ).toBe("true"); + expect( + wrapper.find('[data-test="media-byterange-toggle"]').attributes("aria-checked"), + ).toBe("true"); + }); + + it("writes a change as one mediaSettings object on the bucket", async () => { + const wrapper = mount(StorageFormModal, { props: propsFor(mediaBucket) }); + + await wrapper.find('[data-test="media-encrypted-toggle"]').trigger("click"); + + const emitted = wrapper.emitted("update:bucket"); + expect(emitted).toBeTruthy(); + expect((emitted![0][0] as StorageDto).mediaSettings).toEqual({ encrypted: false }); + }); + + it("records the chunk size in the same object", async () => { + const wrapper = mount(StorageFormModal, { + props: propsFor({ ...mediaBucket, mediaSettings: { byteRange: true } }), + }); + + await wrapper.find('input[name="mediaChunkSizeMB"]').setValue("100"); + + const emitted = wrapper.emitted("update:bucket"); + expect(emitted).toBeTruthy(); + expect((emitted!.at(-1)![0] as StorageDto).mediaSettings).toEqual({ + byteRange: true, + chunkSizeMB: 100, + }); + }); + + it("keeps an unencrypted choice visible when the modal reopens", () => { + const wrapper = mount(StorageFormModal, { + props: propsFor({ ...mediaBucket, mediaSettings: { encrypted: false } }), + }); + + expect( + wrapper.find('[data-test="media-encrypted-toggle"]').attributes("aria-checked"), + ).toBe("false"); + }); +}); diff --git a/cms/src/components/s3/StorageFormModal.vue b/cms/src/components/s3/StorageFormModal.vue index 89195b98ea..15421bfdc5 100644 --- a/cms/src/components/s3/StorageFormModal.vue +++ b/cms/src/components/s3/StorageFormModal.vue @@ -2,12 +2,19 @@ import { ref, computed, watch } from "vue"; import { PlusIcon, ExclamationTriangleIcon, XMarkIcon } from "@heroicons/vue/24/outline"; import LButton from "../button/LButton.vue"; -import { type StorageDto, type S3CredentialDto, type GroupDto, StorageType } from "luminary-shared"; +import { + type StorageDto, + type S3CredentialDto, + type GroupDto, + type MediaEncodeSettingsDto, + StorageType, +} from "luminary-shared"; import LModal from "../modals/LModal.vue"; import LInput from "../forms/LInput.vue"; import LCombobox from "../forms/LCombobox.vue"; import { XCircleIcon } from "@heroicons/vue/20/solid"; import LSelect from "../forms/LSelect.vue"; +import LToggle from "../forms/LToggle.vue"; import { capitaliseFirstLetter } from "@/util/string"; const props = defineProps<{ @@ -54,6 +61,39 @@ const storageTypeValue = computed({ }, }); +const isMediaBucket = computed(() => storageTypeValue.value === StorageType.Media); + +/** + * The bucket's encode settings, patched as one object. Absent fields mean the + * encoder's defaults, so the toggles read absent as their default value rather + * than forcing every bucket to spell the defaults out. + */ +function patchMediaSettings(patch: Partial) { + if (!props.bucket) return; + emit("update:bucket", { + ...props.bucket, + mediaSettings: { ...(props.bucket.mediaSettings ?? {}), ...patch }, + } as StorageDto); +} + +const mediaEncrypted = computed({ + get: () => props.bucket?.mediaSettings?.encrypted !== false, + set: (value: boolean) => patchMediaSettings({ encrypted: value }), +}); + +const mediaByteRange = computed({ + get: () => props.bucket?.mediaSettings?.byteRange !== false, + set: (value: boolean) => patchMediaSettings({ byteRange: value }), +}); + +const mediaChunkSizeMB = computed({ + get: () => props.bucket?.mediaSettings?.chunkSizeMB ?? null, + set: (value: string | number | null) => + patchMediaSettings({ + chunkSizeMB: value === null || value === "" ? undefined : Number(value), + }), +}); + // Determine if we should show credentials section const shouldShowCredentialsSection = computed(() => { // Always show for new buckets @@ -231,6 +271,54 @@ function handleDelete() { :disabled="isLoading" /> + +
+ + +
+
+

Encrypt media

+

+ AES-128 encryption for every encode written to this bucket. +

+
+ +
+ +
+
+

Byte-range segments

+

+ One chunk file per rendition instead of many small segment files. +

+
+ +
+ +
+ +

+ Largest size of one chunk file. Leave empty for the encoder's default. +

+
+
+