Fix Recordly 2.0 part 6 follow-up review findings

Preserve existing PR history and integrate validated current-cycle fixes.
This commit is contained in:
webadderall committed 2026-11-08 12:06:42 +11:00
commit 39b41dfeea
8 files changed
+487 -21

No files matched your search

@@ -64,6 +64,11 @@ export async function cleanupExpired(env) {
"DELETE FROM comment_sessions WHERE datetime(expires_at) < datetime('now')",
).run();
// Match handleReport's one-hour admission window; keep reports for moderation.
await env.DB.prepare(
"UPDATE abuse_reports SET client_ip = '' WHERE client_ip <> '' AND created_at <= datetime('now', '-1 hour')",
).run();
const expired = await env.DB.prepare(
"SELECT id, share_code FROM videos WHERE never_expires = 0 AND datetime(expires_at) <= datetime('now')",
).all();
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, vi } from "vitest";
import { env, SELF } from "cloudflare:test";
import worker, { sha256Hex } from "../src/index.js";
import { withRecordingLock } from "../src/recordingLock.js";
import { cleanupExpired } from "../src/library.js";
import { localCommentHeaders } from "./commentFixtures.js";
let commentAuth;
@@ -939,3 +940,128 @@ it("keeps an old live lock exclusive and recovers an orphan only by its exact ob
.first(),
).toBeNull();
});
describe("report IP cleanup", () => {
it("clears expired reporter IPs while preserving reports and moderation history", async () => {
const { shareCode } = await createShare();
await completeUpload(shareCode);
const target = crypto.randomUUID();
await env.DB.batch([
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip,created_at) VALUES (?,'spam','old report','198.51.100.240',datetime('now','-2 hours'))",
).bind(shareCode),
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip,created_at) VALUES (?,'privacy','boundary report','198.51.100.241',datetime('now','-1 hour'))",
).bind(shareCode),
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip,created_at) VALUES (?,'other','active report','198.51.100.242',datetime('now','-59 minutes'))",
).bind(shareCode),
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip,created_at) VALUES (?,'spam','already cleared','',datetime('now','-3 hours'))",
).bind(shareCode),
env.DB.prepare(
"INSERT INTO moderation_actions (action,target,reason) VALUES ('suspend-account',?,'Keep the audit record')",
).bind(target),
]);
const reports = await env.DB.prepare(
"SELECT id,share_code,reason,details,created_at FROM abuse_reports WHERE share_code = ? ORDER BY id",
)
.bind(shareCode)
.all();
const audit = await env.DB.prepare("SELECT * FROM moderation_actions WHERE target = ?")
.bind(target)
.all();
await env.DB.prepare(
"UPDATE videos SET expires_at = datetime('now','-1 day') WHERE share_code = ?",
)
.bind(shareCode)
.run();
await cleanupExpired(env);
expect(
await env.DB.prepare("SELECT id FROM videos WHERE share_code = ?")
.bind(shareCode)
.first(),
).toBeNull();
expect(
(
await env.DB.prepare(
"SELECT id,share_code,reason,details,created_at FROM abuse_reports WHERE share_code = ? ORDER BY id",
)
.bind(shareCode)
.all()
).results,
).toEqual(reports.results);
const addresses = await env.DB.prepare(
"SELECT details,client_ip FROM abuse_reports WHERE share_code = ? ORDER BY id",
)
.bind(shareCode)
.all();
expect(addresses.results).toEqual([
{ details: "old report", client_ip: "" },
{ details: "boundary report", client_ip: "" },
{ details: "active report", client_ip: "198.51.100.242" },
{ details: "already cleared", client_ip: "" },
]);
expect(
(
await env.DB.prepare("SELECT * FROM moderation_actions WHERE target = ?")
.bind(target)
.all()
).results,
).toEqual(audit.results);
expect(
(
await env.DB.prepare(
"SELECT suspended FROM account_restrictions WHERE owner_id = ?",
)
.bind(target)
.first()
).suspended,
).toBe(1);
await cleanupExpired(env);
expect(
(
await env.DB.prepare(
"SELECT details,client_ip FROM abuse_reports WHERE share_code = ? ORDER BY id",
)
.bind(shareCode)
.all()
).results,
).toEqual(addresses.results);
});
it("retains active per-IP and per-recording report admission caps after cleanup", async () => {
const { shareCode: first } = await createShare();
const { shareCode: second } = await createShare();
await completeUpload(first);
await completeUpload(second);
const ip = "198.51.100.244";
await env.DB.batch([
...Array.from({ length: 3 }, () =>
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip,created_at) VALUES (?,'spam','old',?,datetime('now','-2 hours'))",
).bind(first, ip),
),
...Array.from({ length: 3 }, () =>
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip) VALUES (?,'spam','active',?)",
).bind(first, ip),
),
...Array.from({ length: 20 }, (_, i) =>
env.DB.prepare(
"INSERT INTO abuse_reports (share_code,reason,details,client_ip) VALUES (?,'spam','active recording report',?)",
).bind(second, `203.0.113.${i + 1}`),
),
]);
await cleanupExpired(env);
const report = (code, clientIp) =>
SELF.fetch(`${BASE}/s/${code}/report`, {
method: "POST",
headers: { "Content-Type": "application/json", "CF-Connecting-IP": clientIp },
body: JSON.stringify({ reason: "spam", details: "New report" }),
});
expect((await report(first, ip)).status).toBe(429);
expect((await report(second, "198.51.100.245")).status).toBe(429);
expect((await report(first, "198.51.100.245")).status).toBe(200);
});
});
+39
View File
@@ -100,4 +100,43 @@ describe("studio runtime compatibility", () => {
}),
).toHaveLength(2);
});
it.each([
["id", "Use an ID between 1 and 100 characters."],
["title", "Use a title between 1 and 160 characters."],
["body", "Use a message between 1 and 2,000 characters."],
] as const)("does not blame a valid action when the %s is missing", (field, message) => {
for (const action of [
{ actionType: "section", actionDestination: "settings" },
{ actionType: "url", actionDestination: "https://recordly.dev" },
] as const) {
expect(
validateDraft({ ...defaults, [field]: " ", actionEnabled: true, ...action }),
).toEqual([message]);
}
});
it.each([
{ actionLabel: "" },
{ actionType: "url", actionDestination: "http://recordly.dev" },
{ actionType: "section", actionDestination: "not-an-editor-section" },
] as const)("still rejects an invalid action on an otherwise valid draft: %o", (action) => {
expect(validateDraft({ ...defaults, actionEnabled: true, ...action })).toEqual([
"Use an action label (up to 80 characters) and a valid HTTPS URL or editor section.",
]);
});
it("keeps independent dashboard presentation errors when a required field is missing", () => {
expect(
validateDraft({
...defaults,
id: "",
presentation: "dashboard-banner",
mediaType: "video",
mediaUrl: "/demo.mp4",
actionEnabled: true,
}),
).toEqual([
"Use an ID between 1 and 100 characters.",
"Dashboard banners support images only.",
"Dashboard banners link to an HTTPS URL.",
]);
});
});
+3 -3
View File
@@ -182,9 +182,9 @@ export function validateDraft(draft: StudioDraft): string[] {
if (draft.posterUrl && !safeMedia(draft.posterUrl))
errors.push("Poster must use HTTPS or a /public-path.");
if (draft.actionEnabled) {
const action = parseAnnouncementFeed({ announcements: [buildAnnouncement(draft)] })
.announcements[0]?.action;
if (!action)
const announcement = parseAnnouncementFeed({ announcements: [buildAnnouncement(draft)] })
.announcements[0];
if (announcement && !announcement.action)
errors.push(
"Use an action label (up to 80 characters) and a valid HTTPS URL or editor section.",
);
+67 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { RecordingFileWriter } from "./recordingFileWriter";
function setup() {
const api = {
openRecordingFileStream: vi.fn(async () => ({
@@ -54,12 +55,74 @@ describe("recording chunk persistence", () => {
expect(await writer.finish()).toMatchObject({ success: false, message: "No space" });
expect(api.appendRecordingFileStream).not.toHaveBeenCalled();
});
it("bounds backlog and stops accepting chunks when storage stalls", async () => {
it("accepts a single large chunk on an empty queue and drains it in ordered bounded IPC writes", async () => {
const { api, writer, fail } = setup();
writer.write(new Blob([new Uint8Array(33 * 1024 * 1024)]));
const slices = Array.from({ length: 9 }, (_, index) =>
new Uint8Array((index === 8 ? 1 : 4) * 1024 * 1024).fill(index),
);
writer.write(new Blob(slices));
await expect(writer.finish(100)).resolves.toEqual({ success: true, path: "/final.mp4" });
expect(fail).not.toHaveBeenCalled();
expect(api.openRecordingFileStream).toHaveBeenCalledOnce();
expect(api.appendRecordingFileStream.mock.calls).toHaveLength(9);
for (const [index, call] of api.appendRecordingFileStream.mock.calls.entries()) {
const bytes = new Uint8Array(call[1]);
expect(bytes.byteLength).toBe(slices[index].byteLength);
expect(bytes[0]).toBe(index);
expect(bytes.at(-1)).toBe(index);
}
expect(api.closeRecordingFileStream.mock.invocationCallOrder[0]).toBeGreaterThan(
api.appendRecordingFileStream.mock.invocationCallOrder.at(-1)!,
);
});
it.each([
16, 33,
])("bounds queued backlog while a %s MiB chunk is already stalled", async (sizeMiB) => {
const { api, writer, fail } = setup();
let release!: () => void;
api.appendRecordingFileStream.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ success: true });
}),
);
writer.write(new Blob([new Uint8Array(sizeMiB * 1024 * 1024)]));
await vi.waitFor(() => expect(api.appendRecordingFileStream).toHaveBeenCalledOnce());
if (sizeMiB === 16) writer.write(new Blob([new Uint8Array(16 * 1024 * 1024)]));
expect(fail).not.toHaveBeenCalled();
writer.write(new Blob(["overflow"]));
writer.write(new Blob(["ignored after failure"]));
expect(fail).toHaveBeenCalledOnce();
expect((await writer.finish()).success).toBe(false);
expect(api.openRecordingFileStream).not.toHaveBeenCalled();
const finished = writer.finish();
expect(api.closeRecordingFileStream).not.toHaveBeenCalled();
release();
await expect(finished).resolves.toMatchObject({ success: false, path: "/final.mp4" });
expect((await finished).message).toContain("Partial recording: /final.mp4");
expect(api.appendRecordingFileStream).toHaveBeenCalledOnce();
expect(api.closeRecordingFileStream).toHaveBeenCalledOnce();
});
it("stops draining a large chunk and discards its stream when cancelled", async () => {
const { api, writer, fail } = setup();
let release!: () => void;
api.appendRecordingFileStream.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ success: true });
}),
);
writer.write(new Blob([new Uint8Array(33 * 1024 * 1024)]));
await vi.waitFor(() => expect(api.appendRecordingFileStream).toHaveBeenCalledOnce());
writer.cancel();
const finished = writer.finish();
release();
await expect(finished).resolves.toEqual({
success: false,
message: "Recording cancelled.",
});
expect(fail).not.toHaveBeenCalled();
expect(api.appendRecordingFileStream).toHaveBeenCalledOnce();
expect(api.discardRecordingFileStream).toHaveBeenCalledExactlyOnceWith("1");
expect(api.closeRecordingFileStream).not.toHaveBeenCalled();
});
it("splits large recorder chunks into bounded IPC messages", async () => {
const { api, writer } = setup();
+2 -1
View File
@@ -28,7 +28,8 @@ export class RecordingFileWriter {
}
write(chunk: Blob) {
if (this.cancelled || this.failure || !chunk.size) return;
if (this.pendingBytes + chunk.size > 32 * 1024 * 1024) {
// A single delayed recorder Blob drains in slices; bound additional queued data.
if (this.pendingBytes > 0 && this.pendingBytes + chunk.size > 32 * 1024 * 1024) {
this.fail(
new Error(
"Recording storage cannot keep up. The partial recording will be preserved.",
@@ -422,3 +422,235 @@ describe("browser microphone fallback capture", () => {
expect(storeMicrophoneSidecar).not.toHaveBeenCalled();
});
});
const microphoneHooks = vi.hoisted(() => {
const states: unknown[] = [];
const cleanups: (() => void)[] = [];
let writes = 0;
return {
states,
reset() {
states.length = 0;
cleanups.length = 0;
writes = 0;
},
cleanup() {
for (const cleanup of cleanups.splice(0)) cleanup();
},
get writes() {
return writes;
},
useState<T>(initial: T) {
const index = states.length;
states.push(initial);
return [
initial,
(value: T | ((previous: T) => T)) => {
writes++;
states[index] =
typeof value === "function"
? (value as (previous: T) => T)(states[index] as T)
: value;
},
] as const;
},
useEffect(effect: () => (() => void) | undefined) {
const cleanup = effect();
if (cleanup) cleanups.push(cleanup);
},
};
});
vi.mock("react", () => ({
useState: microphoneHooks.useState,
useEffect: microphoneHooks.useEffect,
}));
vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({
t: (key: string, fallback: string, values: Record<string, string> = {}) =>
key === "notifications.microphone"
? `Micrófono ${values.id}`
: fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values[name] ?? ""),
}),
}));
describe("microphone device label permission", () => {
let useMicrophoneDevices: typeof import("./useMicrophoneDevices").useMicrophoneDevices;
let enumerateDevices: ReturnType<typeof vi.fn>;
let getUserMedia: ReturnType<typeof vi.fn>;
let stop: ReturnType<typeof vi.fn>;
let stream: MediaStream;
let deviceChange: (() => void) | undefined;
let removeEventListener: ReturnType<typeof vi.fn>;
const microphone = (label = "", deviceId = "microphone-1") => ({
kind: "audioinput",
deviceId,
groupId: "group-1",
label,
});
const flush = async () => {
for (let step = 0; step < 10; step++) await Promise.resolve();
};
const mount = (enabled = true, preferredDeviceId?: string) => {
useMicrophoneDevices(enabled, preferredDeviceId);
};
beforeEach(async () => {
vi.resetModules();
microphoneHooks.reset();
deviceChange = undefined;
stop = vi.fn();
stream = { getTracks: () => [{ stop }] } as unknown as MediaStream;
enumerateDevices = vi.fn().mockResolvedValue([microphone()]);
getUserMedia = vi.fn().mockResolvedValue(stream);
removeEventListener = vi.fn();
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices,
getUserMedia,
addEventListener: (_event: string, listener: () => void) => {
deviceChange = listener;
},
removeEventListener,
},
});
vi.spyOn(console, "error").mockImplementation(() => {});
({ useMicrophoneDevices } = await import("./useMicrophoneDevices"));
});
afterEach(() => {
microphoneHooks.cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("requests hidden microphone labels from raw devices and releases the permission stream", async () => {
enumerateDevices
.mockResolvedValueOnce([
microphone(),
{ kind: "videoinput", deviceId: "camera", label: "Camera" },
])
.mockResolvedValueOnce([microphone("Desk microphone")]);
mount(true, "microphone-1");
await flush();
expect(getUserMedia).toHaveBeenCalledExactlyOnceWith({ audio: true });
expect(enumerateDevices).toHaveBeenCalledTimes(2);
expect(microphoneHooks.states[0]).toEqual(
[microphone("Desk microphone")].map(({ kind: _kind, ...device }) => device),
);
expect(microphoneHooks.states[1]).toBe("microphone-1");
expect(microphoneHooks.states[2]).toBe(false);
expect(stop).toHaveBeenCalledOnce();
});
it("retains localized display fallbacks if permission still leaves labels hidden", async () => {
mount();
await flush();
expect(getUserMedia).toHaveBeenCalledOnce();
expect(microphoneHooks.states[0]).toEqual([
{ deviceId: "microphone-1", groupId: "group-1", label: "Micrófono micropho" },
]);
expect(stop).toHaveBeenCalledOnce();
});
it.each([
["labeled", [microphone("Desk")]],
["mixed", [microphone("Desk"), microphone("", "second")]],
["no audio", [{ kind: "videoinput", deviceId: "camera", label: "" }]],
])("does not request label permission for %s inputs", async (_label, devices) => {
enumerateDevices.mockResolvedValue(devices);
mount();
await flush();
expect(getUserMedia).not.toHaveBeenCalled();
expect(stop).not.toHaveBeenCalled();
if (_label === "mixed") {
expect(microphoneHooks.states[0]).toContainEqual({
deviceId: "second",
groupId: "group-1",
label: "Micrófono second",
});
}
});
it("treats whitespace-only raw labels as hidden", async () => {
enumerateDevices.mockResolvedValue([microphone(" ")]);
mount();
await flush();
expect(getUserMedia).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
});
it("does not enumerate or request permission while disabled", async () => {
mount(false);
await flush();
expect(enumerateDevices).not.toHaveBeenCalled();
expect(getUserMedia).not.toHaveBeenCalled();
expect(deviceChange).toBeUndefined();
});
it("reports permission denial and does not prompt again on device changes", async () => {
getUserMedia.mockRejectedValue(new Error("Microphone permission denied"));
mount();
await flush();
expect(microphoneHooks.states[3]).toBe("Microphone permission denied");
expect(microphoneHooks.states[2]).toBe(false);
expect(stop).not.toHaveBeenCalled();
deviceChange?.();
await flush();
expect(getUserMedia).toHaveBeenCalledOnce();
expect(microphoneHooks.states[3]).toBeNull();
});
it("releases the permission stream when follow-up enumeration rejects", async () => {
enumerateDevices
.mockResolvedValueOnce([microphone()])
.mockRejectedValueOnce(new Error("Enumeration failed"));
mount();
await flush();
expect(getUserMedia).toHaveBeenCalledOnce();
expect(microphoneHooks.states[3]).toBe("Enumeration failed");
expect(microphoneHooks.states[2]).toBe(false);
expect(stop).toHaveBeenCalledOnce();
});
it("does not request permission after pending enumeration is unmounted", async () => {
let resolve!: (devices: ReturnType<typeof microphone>[]) => void;
enumerateDevices.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
mount();
microphoneHooks.cleanup();
const writes = microphoneHooks.writes;
resolve([microphone()]);
await flush();
expect(getUserMedia).not.toHaveBeenCalled();
expect(microphoneHooks.writes).toBe(writes);
expect(removeEventListener).toHaveBeenCalledWith("devicechange", deviceChange);
});
it("releases a late permission stream without state writes after unmount", async () => {
let resolve!: (stream: MediaStream) => void;
getUserMedia.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
mount();
await flush();
expect(getUserMedia).toHaveBeenCalledOnce();
microphoneHooks.cleanup();
const writes = microphoneHooks.writes;
resolve(stream);
await flush();
expect(stop).toHaveBeenCalledOnce();
expect(microphoneHooks.writes).toBe(writes);
});
it("requests label permission only once per session after success", async () => {
mount();
await flush();
deviceChange?.();
await flush();
expect(getUserMedia).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
});
});
+13 -13
View File
@@ -31,22 +31,22 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId?
setError(null);
let allDevices = await navigator.mediaDevices.enumerateDevices();
let audioInputs = allDevices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
deviceId: device.deviceId,
label:
device.label ||
t("notifications.microphone", "Microphone {{id}}", {
id: device.deviceId.slice(0, 8),
}),
groupId: device.groupId,
}));
const rawAudioInputs = allDevices.filter((device) => device.kind === "audioinput");
let audioInputs = rawAudioInputs.map((device) => ({
deviceId: device.deviceId,
label:
device.label ||
t("notifications.microphone", "Microphone {{id}}", {
id: device.deviceId.slice(0, 8),
}),
groupId: device.groupId,
}));
const needsLabelPermission =
audioInputs.length > 0 && audioInputs.every((device) => !device.label.trim());
rawAudioInputs.length > 0 &&
rawAudioInputs.every((device) => !device.label.trim());
if (needsLabelPermission && !hasRequestedMicrophoneLabels) {
if (mounted && needsLabelPermission && !hasRequestedMicrophoneLabels) {
hasRequestedMicrophoneLabels = true;
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true });
allDevices = await navigator.mediaDevices.enumerateDevices();