Skip to content
Merged
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
1 change: 0 additions & 1 deletion apps/web/app/_components/recording/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export {
hydrateRecordingConfig,
isRecordingGradientKey,
isRecordingHexColor,
recordingConfigKeyFor,
recordingGradientCss,
} from "@/lib/recording-config";
export type {
Expand Down
58 changes: 25 additions & 33 deletions apps/web/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getAuth } from "@/lib/auth";
import {
deleteReactionsForRecording,
deleteRecordingForAdmin,
updateRecordingConfigForUser,
getRecordingForAdmin,
getRecordingForUser,
updateRecordingTitleForUser,
updateRecordingVisibilityForAdmin,
type RecordingVisibility,
Expand All @@ -28,12 +27,16 @@ import {
objectExists,
putObject,
} from "@/lib/r2";
import { sourceKeyFor, stateKeyFor } from "@/lib/screenshot-keys";
import {
screenshotObjectKeysFor,
sourceKeyFor,
stateKeyFor,
} from "@/lib/screenshot-keys";
import {
hydrateRecordingConfig,
recordingConfigKeyFor,
type RecordingConfig,
} from "@/lib/recording-config";
import { objectKeysFor } from "@/lib/recording/object-keys";
import { revokeDeviceToken } from "@/lib/device-tokens";

// Middleware only guards page requests; a forged/replayed direct action
Expand Down Expand Up @@ -112,17 +115,20 @@ export async function deleteRecordingAction(slug: string): Promise<{
// at worst a row pointing at a missing object (delete again to clean up).
try {
await deleteObject(row.storageKey);
if (row.posterKey) {
await deleteObject(row.posterKey);
}
} catch (err) {
return {
error: `Could not delete the video file: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
await deleteReactionsForRecording(cleanSlug);
// Poster/webcam/sidecars are best-effort: the video is already gone, so
// failing here would only block a delete the user can no longer retry.
await Promise.allSettled(
objectKeysFor(row)
.filter((key) => key !== row.storageKey)
.map((key) => deleteObject(key)),
);
await deleteRecordingForAdmin(userId, cleanSlug);
revalidatePath("/");
return { error: null };
Expand All @@ -137,27 +143,13 @@ export async function saveRecordingConfigAction(
const userId = await requireUserId();
const cleanSlug = typeof slug === "string" ? slug.trim() : "";
if (!cleanSlug) return { error: "Missing slug" };
const recording = await getRecordingForUser(userId, cleanSlug);
if (!recording) return { error: "Recording not found" };
const config: RecordingConfig = hydrateRecordingConfig(raw);
const json = JSON.stringify(config);
const bytes = new TextEncoder().encode(json);
try {
await putObject(
recordingConfigKeyFor(recording.storageKey),
bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer,
"application/json",
);
} catch (err) {
return {
error: `Could not save recording config: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
const ok = await updateRecordingConfigForUser(
userId,
cleanSlug,
JSON.stringify(config),
);
if (!ok) return { error: "Recording not found" };
revalidatePath(`/recordings/${cleanSlug}/edit`);
revalidatePath("/");
return { error: null };
Expand Down Expand Up @@ -187,11 +179,11 @@ export async function deleteScreenshotAction(screenshotId: string): Promise<{
if (!ok) return { error: "Screenshot not found" };
// Best-effort R2 cleanup; row is already soft-deleted and the retention
// cron sweeps any stranded bytes.
await Promise.allSettled([
deleteObject(screenshot.storageKey),
deleteObject(sourceKeyFor(screenshot.storageKey)),
deleteObject(stateKeyFor(screenshot.storageKey)),
]);
await Promise.allSettled(
screenshotObjectKeysFor(screenshot.storageKey).map((key) =>
deleteObject(key),
),
);
revalidatePath("/screenshots");
return { error: null };
}
Expand Down
17 changes: 11 additions & 6 deletions apps/web/app/api/r/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { headers } from "next/headers";
import { deleteRecording, getRecording } from "@/lib/recording/db";
import { isValidSlug } from "@/lib/recording/slug";
import { abortMultipartUpload, deleteObject } from "@/lib/recording/r2";
import { objectKeysFor } from "@/lib/recording/object-keys";
import { verifySessionOrNull } from "@/lib/recording/verify-session";
import { optionsResponse, withCors, jsonError } from "@/lib/recording/cors";

Expand Down Expand Up @@ -46,12 +47,16 @@ export async function DELETE(
}
}
await deleteObject(row.storageKey);
if (row.posterKey) await deleteObject(row.posterKey);
if (row.webcamStorageKey) {
try {
await deleteObject(row.webcamStorageKey);
} catch (err) {
console.warn(`[delete] webcam r2 delete failed for ${id}:`, err);
// Poster/webcam/sidecars are best-effort: the video is already gone, so a
// failure here must not leave the caller with an undeletable recording.
const stranded = await Promise.allSettled(
objectKeysFor(row)
.filter((key) => key !== row.storageKey)
.map((key) => deleteObject(key)),
);
for (const result of stranded) {
if (result.status === "rejected") {
console.warn(`[delete] r2 delete failed for ${id}:`, result.reason);
}
}
await deleteRecording(id);
Expand Down
26 changes: 22 additions & 4 deletions apps/web/app/api/r/finalize/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { getRecording, updateRecording } from "@/lib/recording/db";
import { isValidSlug } from "@/lib/recording/slug";
import { completeMultipartUpload, headObject } from "@/lib/recording/r2";
import {
completeMultipartUpload,
deleteObject,
objectSize,
} from "@/lib/recording/r2";
import { optionsResponse, withCors, jsonError } from "@/lib/recording/cors";
import { exceedsStorage } from "@/lib/recording/quota";
import { viewUrlForRequest } from "@/lib/site";
Expand Down Expand Up @@ -90,12 +94,26 @@ export async function POST(req: NextRequest) {
return jsonError(`R2 complete: ${reason}`, 502, "r2_complete_failed");
}

const exists = await headObject(row.storageKey);
if (!exists) {
const actualBytes = await objectSize(row.storageKey);
if (actualBytes === null) {
await updateRecording(row.slug, { state: "failed", uploadId: null });
return jsonError("Object missing after complete", 502, "object_missing");
}

/*
* Re-check with what R2 actually stored: the pre-complete check ran on the
* client's claimed size, and only a larger truth can flip the verdict. The
* object is already committed, so an over-cap upload is removed, not kept.
*/
if (
actualBytes > sizeBytes &&
(await exceedsStorage(deviceId, row, actualBytes))
) {
await deleteObject(row.storageKey);
await updateRecording(row.slug, { state: "failed", uploadId: null });
return jsonError("Storage cap reached", 413, "storage_limit");
}

// Bad duration is dropped rather than rejected: the bytes are already in R2,
// and a missing badge beats an upload that reports failure.
const durationMs =
Expand All @@ -109,7 +127,7 @@ export async function POST(req: NextRequest) {
await updateRecording(row.slug, {
state: "ready",
uploadId: null,
sizeBytes,
sizeBytes: actualBytes,
lastViewedAt: Date.now(),
...(durationMs === null ? {} : { durationMs }),
});
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/api/r/init/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export async function POST(req: NextRequest) {
webcamUploadId,
webcamSizeBytes: 0,
webcamState,
configJson: null,
summaryChaptersJson: null,
});

const res: InitResponse = {
Expand Down
12 changes: 6 additions & 6 deletions apps/web/app/api/r/summary-chapters/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { getRecording } from "@/lib/recording/db";
import { getRecording, updateRecording } from "@/lib/recording/db";
import { isValidSlug } from "@/lib/recording/slug";
import { verifySessionOrNull } from "@/lib/recording/verify-session";
import {
hydrateSummaryChapters,
loadSummaryChapters,
saveSummaryChapters,
parseSummaryChaptersJson,
type RecordingSummaryChapters,
} from "@/lib/recording/summary-chapters";

Expand All @@ -23,8 +22,7 @@ export async function GET(_req: Request, { params }: Params) {
if (!row || row.state !== "ready") {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
const payload = await loadSummaryChapters(row.storageKey);
return NextResponse.json(payload);
return NextResponse.json(parseSummaryChaptersJson(row.summaryChaptersJson));
}

export async function PUT(req: Request, { params }: Params) {
Expand All @@ -48,6 +46,8 @@ export async function PUT(req: Request, { params }: Params) {
return NextResponse.json({ error: "bad_request" }, { status: 400 });
}
const normalized: RecordingSummaryChapters = hydrateSummaryChapters(body);
await saveSummaryChapters(row.storageKey, normalized);
await updateRecording(row.slug, {
summaryChaptersJson: JSON.stringify(normalized),
});
return NextResponse.json(normalized);
}
25 changes: 21 additions & 4 deletions apps/web/app/api/r/webcam-finalize/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { getRecording, updateRecording } from "@/lib/recording/db";
import { isValidSlug } from "@/lib/recording/slug";
import { completeMultipartUpload, headObject } from "@/lib/recording/r2";
import {
completeMultipartUpload,
deleteObject,
objectSize,
} from "@/lib/recording/r2";
import { optionsResponse, withCors, jsonError } from "@/lib/recording/cors";
import { exceedsStorage } from "@/lib/recording/quota";
import type { FinalizeRequest } from "@/lib/recording/types";
Expand Down Expand Up @@ -76,19 +80,32 @@ export async function POST(req: NextRequest) {
body.parts,
);

const exists = await headObject(row.webcamStorageKey);
if (!exists) {
const actualWebcamBytes = await objectSize(row.webcamStorageKey);
if (actualWebcamBytes === null) {
await updateRecording(row.slug, {
webcamState: "failed",
webcamUploadId: null,
});
return jsonError("Object missing after complete", 502, "object_missing");
}

// Same post-complete re-check as /api/r/finalize, against R2's actual size.
if (
actualWebcamBytes > sizeBytes &&
(await exceedsStorage(deviceId, row, actualWebcamBytes))
) {
await deleteObject(row.webcamStorageKey);
await updateRecording(row.slug, {
webcamState: "failed",
webcamUploadId: null,
});
return jsonError("Storage cap reached", 413, "storage_limit");
}

await updateRecording(row.slug, {
webcamState: "ready",
webcamUploadId: null,
webcamSizeBytes: sizeBytes,
webcamSizeBytes: actualWebcamBytes,
});

return withCors(NextResponse.json({ ok: true }));
Expand Down
21 changes: 5 additions & 16 deletions apps/web/app/r/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,9 @@ import {
} from "@/lib/recording/db";
import { verifySession } from "@/lib/recording/verify-session";
import { canViewResource } from "@/lib/visibility";
import { getObjectJson, publicUrlFor } from "@/lib/recording/r2";
import {
DEFAULT_RECORDING_CONFIG,
hydrateRecordingConfig,
recordingConfigKeyFor,
} from "@/lib/recording-config";
import { loadSummaryChapters } from "@/lib/recording/summary-chapters";
import { publicUrlFor } from "@/lib/recording/r2";
import { parseRecordingConfigJson } from "@/lib/recording-config";
import { parseSummaryChaptersJson } from "@/lib/recording/summary-chapters";
import { isValidSlug } from "@/lib/recording/slug";
import { APP_WEB_SITE_URL, PRODUCT_NAME, viewUrlFor } from "@/lib/site";
import { ViewerNav } from "../../_components/screenshot";
Expand Down Expand Up @@ -230,8 +226,6 @@ export default async function RecordingPage({ params }: { params: Params }) {
comments,
ownerName,
webcamUrlRaw,
configRaw,
summaryChapters,
] = await Promise.all([
publicUrlFor(row.storageKey),
row.posterKey ? publicUrlFor(row.posterKey) : Promise.resolve(undefined),
Expand All @@ -241,14 +235,9 @@ export default async function RecordingPage({ params }: { params: Params }) {
row.webcamStorageKey && row.webcamState === "ready"
? publicUrlFor(row.webcamStorageKey)
: Promise.resolve(undefined),
getObjectJson<unknown>(recordingConfigKeyFor(row.storageKey)).catch(
() => null,
),
loadSummaryChapters(row.storageKey),
]);
const recordingConfig = configRaw
? hydrateRecordingConfig(configRaw)
: DEFAULT_RECORDING_CONFIG;
const recordingConfig = parseRecordingConfigJson(row.configJson);
const summaryChapters = parseSummaryChaptersJson(row.summaryChaptersJson);
const videoUrl = withVersion(videoUrlRaw, row.sizeBytes);
const posterUrl = posterUrlRaw
? withVersion(posterUrlRaw, row.sizeBytes)
Expand Down
16 changes: 2 additions & 14 deletions apps/web/app/recordings/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ import { readThemeFromCookieHeader } from "@captureflow/ui";
import { getAppWebEnv } from "@/lib/cf-env";
import { requireSession } from "@/lib/session-guard";
import { getRecordingForUser } from "@/lib/recordings-db";
import { getObjectJson } from "@/lib/r2";
import {
recordingConfigKeyFor,
hydrateRecordingConfig,
} from "@/lib/recording-config";
import { parseRecordingConfigJson } from "@/lib/recording-config";
import { CDN_BASE_URL, viewUrlFor } from "@/lib/site";
import { RecordingEditor } from "./RecordingEditor";

Expand All @@ -25,15 +21,7 @@ export default async function RecordingEditPage({
const recording = await getRecordingForUser(session.user.id, id);
if (!recording) notFound();

let savedConfig: unknown = null;
try {
savedConfig = await getObjectJson<unknown>(
recordingConfigKeyFor(recording.storageKey),
);
} catch (err) {
console.error("[recording-edit-page] config sidecar read failed:", err);
}
const initialConfig = hydrateRecordingConfig(savedConfig);
const initialConfig = parseRecordingConfigJson(recording.configJson);

const env = await getAppWebEnv();
const workspaceRow =
Expand Down
20 changes: 20 additions & 0 deletions apps/web/app/s/[id]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Link from "next/link";
import { MARKETING_SITE_URL, PRODUCT_NAME } from "@/lib/site";

export default function ScreenshotNotFound() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-black p-6 text-center">
<h1 className="text-2xl font-medium text-white">Screenshot not found</h1>
<p className="mt-2 max-w-md text-sm text-neutral-400">
This link has expired or never existed. Screenshots are kept for 30 days
from the last time they were viewed.
</p>
<Link
href={MARKETING_SITE_URL}
className="mt-6 text-sm text-neutral-400 hover:text-fg-strong"
>
Made with {PRODUCT_NAME}
</Link>
</main>
);
}
Loading
Loading