From 44d96428c441bccf5c14af643b903f9a958e50c7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:40:34 +0000 Subject: [PATCH 1/2] fix(web): resolve legacy HLS playlist URIs on the server --- .../unit/legacy-playlist-route.test.ts | 161 ++++++++++++++++++ apps/web/app/api/playlist/route.ts | 60 ++++++- 2 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 apps/web/__tests__/unit/legacy-playlist-route.test.ts diff --git a/apps/web/__tests__/unit/legacy-playlist-route.test.ts b/apps/web/__tests__/unit/legacy-playlist-route.test.ts new file mode 100644 index 00000000000..0cae31cc025 --- /dev/null +++ b/apps/web/__tests__/unit/legacy-playlist-route.test.ts @@ -0,0 +1,161 @@ +import type { HttpApi } from "@effect/platform"; +import type { Context, Layer as EffectLayer } from "effect"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + objects: {} as Record, + source: { type: "MediaConvert" } as { type: string }, + dispose: async () => {}, +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.so" }), + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, + NODE_ENV: "test", +})); + +vi.mock("@cap/web-backend", async (importOriginal) => { + const actual = await importOriginal(); + const { Context, Effect, Option } = await import("effect"); + const bucket = { + provider: "s3", + getObject: (key: string) => + Effect.succeed(Option.fromNullable(mocks.objects[key])), + getSignedObjectUrl: (key: string) => + Effect.succeed(`https://media.example.com/${key}?Signature=s`), + }; + return { + ...actual, + provideOptionalAuth: ( + effect: import("effect").Effect.Effect, + ) => effect, + Storage: Object.assign(Context.GenericTag("LegacyPlaylistTestStorage"), { + getAccessForVideo: () => Effect.succeed([bucket, Option.none()] as const), + }), + Videos: Object.assign(Context.GenericTag("LegacyPlaylistTestVideos"), { + testService: { + getByIdForViewing: () => + Effect.succeed( + Option.some([ + { id: "recording", ownerId: "owner", source: mocks.source }, + ]), + ), + }, + }), + }; +}); + +vi.mock("@/lib/server", async () => { + const { Storage, Videos } = await import("@cap/web-backend"); + const { HttpApiBuilder, HttpServer } = await import("@effect/platform"); + const { Layer } = await import("effect"); + return { + apiToHandler: ( + api: EffectLayer.Layer< + HttpApi.Api, + never, + | Context.Tag.Identifier + | Context.Tag.Identifier + >, + ) => { + const videos = Videos as unknown as { + testService: Context.Tag.Service; + }; + const handler = api.pipe( + Layer.provideMerge( + Layer.succeed(Storage, {} as Context.Tag.Service), + ), + Layer.provideMerge(Layer.succeed(Videos, videos.testService)), + Layer.merge(HttpServer.layerContext), + HttpApiBuilder.toWebHandler, + ); + mocks.dispose = handler.dispose; + return handler.handler; + }, + }; +}); + +import { GET } from "@/app/api/playlist/route"; + +const request = (type: string) => + GET( + new Request( + `https://cap.so/api/playlist?videoId=recording&videoType=${type}`, + ), + ); + +const signed = (key: string) => `https://media.example.com/${key}?Signature=s`; + +describe("HLS playlists on default storage", () => { + beforeEach(() => { + mocks.objects = {}; + mocks.source = { type: "MediaConvert" }; + }); + afterAll(() => mocks.dispose()); + + it("serves the rendition of a multivariant playlist with signed segments", async () => { + mocks.objects = { + "owner/recording/output/video_recording_000.m3u8": + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=5508776,CODECS="avc1.640033"\nvideo_recording_000_output.m3u8\n', + "owner/recording/output/video_recording_000_output.m3u8": + "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXTINF:4,\nvideo_recording_000_output_00001.ts\n#EXTINF:4,\r\nvideo_recording_000_output_00002.ts\r\n#EXT-X-ENDLIST\n", + }; + for (const type of ["master", "video"]) { + const response = await request(type); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/vnd.apple.mpegurl", + ); + const lines = (await response.text()).split("\n"); + expect(lines).toContain( + signed("owner/recording/output/video_recording_000_output_00001.ts"), + ); + expect(lines).toContain( + signed("owner/recording/output/video_recording_000_output_00002.ts"), + ); + expect(lines).toContain("#EXTINF:4,\r"); + expect(lines).toContain("#EXT-X-ENDLIST"); + expect(lines.some((line) => line.endsWith(".ts"))).toBe(false); + } + }); + + it("signs every segment of a combined-source playlist", async () => { + mocks.source = { type: "local" }; + mocks.objects = { + "owner/recording/combined-source/stream.m3u8": + "#EXTM3U\n#EXTINF:3.427,\nsegment_000.ts\n#EXTINF:4,\nsegment_001.ts\n\n#EXT-X-ENDLIST", + }; + const text = await (await request("video")).text(); + expect(text).toBe( + `#EXTM3U\n#EXTINF:3.427,\n${signed("owner/recording/combined-source/segment_000.ts")}\n#EXTINF:4,\n${signed("owner/recording/combined-source/segment_001.ts")}\n\n#EXT-X-ENDLIST`, + ); + }); + + it("leaves absolute URIs alone", async () => { + mocks.source = { type: "local" }; + mocks.objects = { + "owner/recording/combined-source/stream.m3u8": + "#EXTM3U\n#EXTINF:4,\nhttps://cdn.example.com/a.ts\n", + }; + expect(await (await request("video")).text()).toContain( + "\nhttps://cdn.example.com/a.ts\n", + ); + }); + + it("returns 404 when the playlist is missing", async () => { + expect((await request("master")).status).toBe(404); + mocks.objects = { + "owner/recording/output/video_recording_000.m3u8": + "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nmissing.m3u8\n", + }; + expect((await request("master")).status).toBe(404); + }); + + it("still redirects MP4 requests to a signed URL", async () => { + const response = await request("mp4"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + signed("owner/recording/result.mp4"), + ); + }); +}); diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 724c4c42ae3..5a714afaf4d 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -423,15 +423,63 @@ const getPlaylistResponse = ( } if (bucket.provider === "s3" && Option.isNone(customBucket)) { - let redirect = `${video.ownerId}/${video.id}/combined-source/stream.m3u8`; + if (isMp4Source || urlParams.videoType === "mp4") { + return HttpServerResponse.redirect( + yield* bucket.getSignedObjectUrl(mp4Key), + ); + } + + const readPlaylist = (key: string) => + bucket.getObject(key).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new HttpApiError.NotFound()), + onSome: (text) => Effect.succeed({ key, text }), + }), + ), + ); + + let playlist = yield* readPlaylist( + video.source.type === "MediaConvert" + ? `${video.ownerId}/${video.id}/output/video_recording_000.m3u8` + : `${video.ownerId}/${video.id}/combined-source/stream.m3u8`, + ); - if (isMp4Source || urlParams.videoType === "mp4") redirect = mp4Key; - else if (video.source.type === "MediaConvert") - redirect = `${video.ownerId}/${video.id}/output/video_recording_000.m3u8`; + // Each URI resolves against the playlist's own folder, and a signed + // playlist URL does not carry over to them, so every one is signed here. + const resolveUri = (uri: string) => + `${playlist.key.slice(0, playlist.key.lastIndexOf("/") + 1)}${uri}`; + const isUriLine = (line: string) => + line.length > 0 && !line.startsWith("#") && !/^[a-z]+:/i.test(line); + + // A multivariant playlist would hand the player relative child + // playlists, so its first rendition is served in its place. + if (playlist.text.includes("#EXT-X-STREAM-INF")) { + const variant = playlist.text + .split("\n") + .map((line) => line.trim()) + .find(isUriLine); + if (!variant) return yield* Effect.fail(new HttpApiError.NotFound()); + playlist = yield* readPlaylist(resolveUri(variant)); + } - return HttpServerResponse.redirect( - yield* bucket.getSignedObjectUrl(redirect), + const lines = yield* Effect.forEach( + playlist.text.split("\n"), + (line) => { + const uri = line.trim(); + return isUriLine(uri) + ? bucket.getSignedObjectUrl(resolveUri(uri)) + : Effect.succeed(line); + }, + { concurrency: 16 }, ); + + return HttpServerResponse.text(lines.join("\n"), { + headers: { + ...CACHE_CONTROL_HEADERS, + "Content-Type": "application/vnd.apple.mpegurl", + }, + }); } if ( From 937d003d0990b261a12767d61b173bb1f5bc50b1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:46:07 +0000 Subject: [PATCH 2/2] fix(web): cap the URIs signed for one legacy playlist --- .../unit/legacy-playlist-route.test.ts | 22 ++++++++++++++++++- apps/web/app/api/playlist/route.ts | 14 +++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/legacy-playlist-route.test.ts b/apps/web/__tests__/unit/legacy-playlist-route.test.ts index 0cae31cc025..a635c7ca284 100644 --- a/apps/web/__tests__/unit/legacy-playlist-route.test.ts +++ b/apps/web/__tests__/unit/legacy-playlist-route.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ objects: {} as Record, source: { type: "MediaConvert" } as { type: string }, + sign: vi.fn(), dispose: async () => {}, })); @@ -22,7 +23,10 @@ vi.mock("@cap/web-backend", async (importOriginal) => { getObject: (key: string) => Effect.succeed(Option.fromNullable(mocks.objects[key])), getSignedObjectUrl: (key: string) => - Effect.succeed(`https://media.example.com/${key}?Signature=s`), + Effect.sync(() => { + mocks.sign(key); + return `https://media.example.com/${key}?Signature=s`; + }), }; return { ...actual, @@ -142,6 +146,22 @@ describe("HLS playlists on default storage", () => { ); }); + it("rejects an oversized playlist before signing it", async () => { + mocks.source = { type: "local" }; + const segments = (count: number) => + `#EXTM3U\n${Array.from({ length: count }, (_, i) => `#EXTINF:4,\nsegment_${i}.ts`).join("\n")}\n`; + mocks.objects = { + "owner/recording/combined-source/stream.m3u8": segments(10_000), + }; + expect((await request("video")).status).toBe(200); + mocks.objects = { + "owner/recording/combined-source/stream.m3u8": segments(10_001), + }; + mocks.sign.mockClear(); + expect((await request("video")).status).toBe(400); + expect(mocks.sign).not.toHaveBeenCalled(); + }); + it("returns 404 when the playlist is missing", async () => { expect((await request("master")).status).toBe(404); mocks.objects = { diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 5a714afaf4d..eb5ee014626 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -36,6 +36,8 @@ import { export const dynamic = "force-dynamic"; +const MAX_LEGACY_PLAYLIST_URIS = 10_000; + const GetPlaylistParams = Schema.Struct({ videoId: Video.VideoId, videoType: Schema.Literal( @@ -463,8 +465,18 @@ const getPlaylistResponse = ( playlist = yield* readPlaylist(resolveUri(variant)); } + const playlistLines = playlist.text.split("\n"); + // Legacy segments run 3-4 s, so this covers recordings of several + // hours while bounding the signing work a single request can cause. + if ( + playlistLines.filter((line) => isUriLine(line.trim())).length > + MAX_LEGACY_PLAYLIST_URIS + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + const lines = yield* Effect.forEach( - playlist.text.split("\n"), + playlistLines, (line) => { const uri = line.trim(); return isUriLine(uri)