Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.self-hosting.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copy to .env next to docker-compose.yml and fill in every value.
# Generate each secret with: openssl rand -hex 32
# cap-web and the media server refuse to start when these are missing or left at a known placeholder.
NEXTAUTH_SECRET=
DATABASE_ENCRYPTION_KEY=
MEDIA_SERVER_WEBHOOK_SECRET=

# Optional: database and storage passwords (internal-only defaults are used when unset)
MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD=
MINIO_ROOT_USER=cap-admin
MINIO_ROOT_PASSWORD=

# Public URLs
CAP_URL=http://localhost:3000
S3_PUBLIC_URL=http://localhost:9000

# Optional: email. Without both values, login codes are printed to the cap-web logs.
RESEND_API_KEY=
RESEND_FROM_DOMAIN=
2 changes: 2 additions & 0 deletions apps/media-server/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./src/__tests__/setup.ts"]
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe("mediaAudio integration tests", () => {
try {
await expect(
checkHasAudioTrack(`${server.url}video.mp4`),
).rejects.toThrow("FFprobe exited");
).rejects.toThrow("Failed to fetch media: 503");
await waitForAudioOperations(beforeCount);
} finally {
await server.stop(true);
Expand Down
116 changes: 43 additions & 73 deletions apps/media-server/src/__tests__/lib/media-video.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
Expand Down Expand Up @@ -329,7 +328,7 @@ describe("generateThumbnail integration tests", () => {
}
});

test("joins an in-flight thumbnail decoder when its worker is cancelled", async () => {
test("cancels an in-flight remote thumbnail download", async () => {
let ready: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
ready = resolve;
Expand Down Expand Up @@ -358,35 +357,8 @@ describe("generateThumbnail integration tests", () => {
);
try {
await withTimeout(started, 5_000);
const commands =
process.platform === "linux"
? readdirSync("/proc")
.filter((entry) => /^\d+$/.test(entry))
.map((pid) => {
try {
return `${pid} ${readFileSync(`/proc/${pid}/cmdline`, "utf8")}`;
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ESRCH")
)
return "";
throw error;
}
})
: execFileSync("ps", ["-axo", "pid=,command="])
.toString()
.split("\n");
const pids = commands
.filter(
(command) => command.includes("ffmpeg") && command.includes(input),
)
.map((command) => Number.parseInt(command.trim(), 10));
expect(pids).toHaveLength(1);
controller.abort(reason);
expect(await withTimeout(outcome, 7_000)).toBe(reason);
for (const pid of pids) expect(() => process.kill(pid, 0)).toThrow();
} finally {
controller.abort(reason);
await server.stop(true);
Expand Down Expand Up @@ -645,11 +617,16 @@ describe("processVideo integration tests", () => {
const originalFetch = globalThis.fetch;
const manifestDir = mkdtempSync(join(tmpdir(), "cap-mpd-"));

globalThis.fetch = (async () =>
new Response(
'<MPD><Period><AdaptationSet><Representation><SegmentTemplate initialization="init.mp4" media="chunk-$Number$.m4s"/><Initialization sourceURL="escaped.mp4?part=1&amp;token=x"/></Representation></AdaptationSet></Period></MPD>',
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
requestedUrls.push(
input instanceof Request ? input.url : input.toString(),
);
return new Response(
'<MPD><Period><AdaptationSet><Representation><Initialization sourceURL="init.mp4"/><SegmentURL media="escaped.mp4?part=1&amp;token=x"/></Representation></AdaptationSet></Period></MPD>',
{ status: 200, statusText: "OK" },
)) as unknown as typeof fetch;
);
}) as unknown as typeof fetch;

try {
const path = await materializeMpdManifest(
Expand All @@ -658,13 +635,13 @@ describe("processVideo integration tests", () => {
);
const content = readFileSync(path, "utf8");

expect(content).toContain(
"init.mp4?Policy=a&amp;Signature=b&amp;Key-Pair-Id=c",
expect(requestedUrls).toContain(
"https://cdn.example/video/init.mp4?Policy=a&Signature=b&Key-Pair-Id=c",
);
expect(content).toContain(
"chunk-$Number$.m4s?Policy=a&amp;Signature=b&amp;Key-Pair-Id=c",
expect(requestedUrls).toContain(
"https://cdn.example/video/escaped.mp4?part=1&token=x",
);
expect(content).toContain("escaped.mp4?part=1&amp;token=x");
expect(content).not.toContain("https://cdn.example");
expect(content).not.toContain("&amp;amp;");
} finally {
globalThis.fetch = originalFetch;
Expand Down Expand Up @@ -717,21 +694,10 @@ describe("processVideo integration tests", () => {
const videoPlaylist = readFileSync(videoPlaylistPath ?? "", "utf8");
const audioPlaylist = readFileSync(mediaMatch?.[1] ?? "", "utf8");

expect(videoPlaylist).toContain(
"https://cdn.example/video/video/init-v1.mp4?Policy=a&Signature=b",
);
expect(videoPlaylist).toContain(
"https://cdn.example/video/video/chunk-00003.m4s?Policy=a&Signature=b",
);
expect(videoPlaylist).toContain(
"https://cdn.example/video/video/chunk-00004.m4s?Policy=a&Signature=b",
);
expect(audioPlaylist).toContain(
"https://cdn.example/video/audio/chunk-0.m4s?Policy=a&Signature=b",
);
expect(audioPlaylist).toContain(
"https://cdn.example/video/audio/chunk-48000.m4s?Policy=a&Signature=b",
);
expect(videoPlaylist).not.toContain("https://cdn.example");
expect(audioPlaylist).not.toContain("https://cdn.example");
expect(videoPlaylist).toContain(".m4s");
expect(audioPlaylist).toContain(".m4s");
} finally {
globalThis.fetch = originalFetch;
rmSync(manifestDir, { recursive: true, force: true });
Expand All @@ -741,10 +707,12 @@ describe("processVideo integration tests", () => {
test("falls back to generic DASH manifest materialization for unsupported MPD shapes", async () => {
const originalFetch = globalThis.fetch;
const manifestDir = mkdtempSync(join(tmpdir(), "cap-mpd-fallback-"));
let requests = 0;
const requestedUrls: string[] = [];

globalThis.fetch = (async () => {
requests++;
globalThis.fetch = (async (input: RequestInfo | URL) => {
requestedUrls.push(
input instanceof Request ? input.url : input.toString(),
);
return new Response(
`<MPD>
<Period>
Expand All @@ -771,11 +739,13 @@ describe("processVideo integration tests", () => {
const content = readFileSync(path, "utf8");

expect(path.endsWith(".mpd")).toBe(true);
expect(requests).toBe(2);
expect(content).toContain(
"https://cdn.example/video/media/?Policy=a&amp;Signature=b",
expect(requestedUrls).toContain(
"https://cdn.example/video/init.mp4?Policy=a&Signature=b",
);
expect(content).toContain("seg-1.m4s?Policy=a&amp;Signature=b");
expect(requestedUrls).toContain(
"https://cdn.example/video/seg-1.m4s?Policy=a&Signature=b",
);
expect(content).not.toContain("https://cdn.example");
} finally {
globalThis.fetch = originalFetch;
rmSync(manifestDir, { recursive: true, force: true });
Expand Down Expand Up @@ -877,10 +847,7 @@ describe("processVideo integration tests", () => {
);
}

return new Response("not found", {
status: 404,
statusText: "Not Found",
});
return new Response("segment", { status: 200, statusText: "OK" });
}) as unknown as typeof fetch;

try {
Expand All @@ -896,21 +863,24 @@ describe("processVideo integration tests", () => {
expect(requestedUrls).toContain(
"https://cdn.example/video/variant.m3u8?Policy=a&Signature=b",
);
expect(variantPath).toBeTruthy();

const variant = readFileSync(variantPath ?? "", "utf8");
expect(variant).toContain(
'URI="https://cdn.example/video/init.mp4?Policy=a&Signature=b"',
expect(requestedUrls).toContain(
"https://cdn.example/video/init.mp4?Policy=a&Signature=b",
);
expect(variant).toContain(
'URI="https://cdn.example/video/key.bin?Policy=a&Signature=b"',
expect(requestedUrls).toContain(
"https://cdn.example/video/key.bin?Policy=a&Signature=b",
);
expect(variant).toContain(
expect(requestedUrls).toContain(
"https://cdn.example/video/segment-1.ts?Policy=a&Signature=b",
);
expect(variant).toContain(
expect(requestedUrls).toContain(
"https://cdn.example/video/segment-2.ts?Policy=a&Signature=b",
);
expect(variantPath).toBeTruthy();

const variant = readFileSync(variantPath ?? "", "utf8");
expect(variant).not.toContain("https://cdn.example");
expect(variant).toContain(".ts");
expect(variant).toContain(".bin");
} finally {
globalThis.fetch = originalFetch;
rmSync(manifestDir, { recursive: true, force: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1940,10 +1940,6 @@ async function decoderPids(input: string): Promise<number[]> {
.map((line) => Number.parseInt(line.trim(), 10));
}

function expectExited(pid: number) {
expect(() => process.kill(pid, 0)).toThrow();
}

describe("recording timing metadata lifetime", () => {
test.each(["cancel", "timeout"])(
"joins a stalled raw terminal-duration read after %s",
Expand Down Expand Up @@ -2353,15 +2349,13 @@ describe("recording verification lifetime", () => {
(error: unknown) => error,
);
await request;
const pids = await decoderPids(input);
expect(pids.length).toBe(1);
if (cause === "cancel") controller.abort();
const error = await outcome;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
cause === "cancel" ? "cancelled" : "timed out",
);
for (const pid of pids) expectExited(pid);
expect(await decoderPids(input)).toEqual([]);
} finally {
controller.abort();
await server.stop(true);
Expand Down Expand Up @@ -2923,8 +2917,6 @@ describe("remote recording object identity", () => {
(error: unknown) => error,
);
await decoding;
const pids = await decoderPids(input);
expect(pids.length).toBe(1);
if (cause === "cancel") controller.abort();
const error = await outcome;
expect(error).toBeInstanceOf(Error);
Expand All @@ -2935,7 +2927,7 @@ describe("remote recording object identity", () => {
expect(isRetryableRecordingVerificationError(error)).toBe(
cause === "timeout",
);
for (const pid of pids) expectExited(pid);
expect(await decoderPids(input)).toEqual([]);
} finally {
controller.abort();
await server.stop(true);
Expand Down
101 changes: 101 additions & 0 deletions apps/media-server/src/__tests__/lib/redirect-follow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { extractAudio } from "../../lib/media-audio";
import { probeVideo } from "../../lib/media-probe";
import { generateThumbnail } from "../../lib/media-video";
import { verifyRecording } from "../../lib/recording-verification";

const video = readFileSync(
join(import.meta.dir, "..", "fixtures", "test-with-audio.mp4"),
);

describe("redirects to a private address", () => {
const previous = process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS;

afterEach(() => {
if (previous === undefined) {
delete process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS;
} else {
process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS = previous;
}
});

function privateRedirectServer() {
let secretHits = 0;
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/secret") {
secretHits += 1;
return new Response(video, {
headers: { "content-type": "video/mp4" },
});
}
return new Response(null, {
status: 302,
headers: {
location: new URL("/secret", server.url).toString(),
},
});
},
});
return {
server,
url: `${server.url}clip.mp4`,
secretHits: () => secretHits,
};
}

test("probe does not fetch the redirect target", async () => {
process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS = "false";
const target = privateRedirectServer();
try {
await expect(probeVideo(target.url)).rejects.toThrow("private address");
expect(target.secretHits()).toBe(0);
} finally {
target.server.stop(true);
}
});

test("audio extraction does not fetch the redirect target", async () => {
process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS = "false";
const target = privateRedirectServer();
try {
await expect(extractAudio(target.url)).rejects.toThrow("private address");
expect(target.secretHits()).toBe(0);
} finally {
target.server.stop(true);
}
});

test("thumbnail generation does not fetch the redirect target", async () => {
process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS = "false";
const target = privateRedirectServer();
try {
await expect(generateThumbnail(target.url, 1)).rejects.toThrow(
"private address",
);
expect(target.secretHits()).toBe(0);
} finally {
target.server.stop(true);
}
});

test("recording verification does not fetch the redirect target", async () => {
process.env.MEDIA_SERVER_ALLOW_PRIVATE_URLS = "false";
const target = privateRedirectServer();
try {
await expect(
verifyRecording(target.url, {
allowObservedDuration: true,
requireAudio: false,
}),
).rejects.toThrow("private address");
expect(target.secretHits()).toBe(0);
} finally {
target.server.stop(true);
}
});
});
Loading