From ce6d478d2e2409b9502a8af4241df66e765aece1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:46:56 +0100 Subject: [PATCH] fix(web): require canView on transcript and AI metadata endpoints --- .../videos/get-available-translations.ts | 29 +++++-- apps/web/actions/videos/get-transcript.ts | 24 ++++-- .../actions/videos/translate-transcript.ts | 29 +++++-- apps/web/app/api/video/ai/route.ts | 25 ++++-- apps/web/lib/rate-limit.ts | 76 +++++++++++++++++++ 5 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 apps/web/lib/rate-limit.ts diff --git a/apps/web/actions/videos/get-available-translations.ts b/apps/web/actions/videos/get-available-translations.ts index e4c6f13086f..e0555f591c7 100644 --- a/apps/web/actions/videos/get-available-translations.ts +++ b/apps/web/actions/videos/get-available-translations.ts @@ -2,10 +2,11 @@ import { db } from "@cap/database"; import { videos } from "@cap/database/schema"; -import { Storage } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; +import { provideOptionalAuth, Storage, VideosPolicy } from "@cap/web-backend"; +import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect } from "effect"; +import { Effect, Exit } from "effect"; +import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; import { decodeStorageVideo } from "@/lib/video-storage"; import { @@ -37,10 +38,24 @@ export async function getAvailableTranslations( }; } - const query = await db() - .select({ video: videos }) - .from(videos) - .where(eq(videos.id, videoId)); + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db().select({ video: videos }).from(videos).where(eq(videos.id, videoId)), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + + if (Exit.isFailure(exit)) { + return { + success: false, + hasOriginal: false, + translations: [], + message: "Video not found", + }; + } + + const query = exit.value; if (query.length === 0 || !query[0]?.video) { return { diff --git a/apps/web/actions/videos/get-transcript.ts b/apps/web/actions/videos/get-transcript.ts index d957b0767cd..9a77c2fc9e6 100644 --- a/apps/web/actions/videos/get-transcript.ts +++ b/apps/web/actions/videos/get-transcript.ts @@ -3,10 +3,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import { Storage } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; +import { provideOptionalAuth, Storage, VideosPolicy } from "@cap/web-backend"; +import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect, Exit, Option } from "effect"; +import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; import { decodeStorageVideo } from "@/lib/video-storage"; @@ -22,10 +23,19 @@ export async function getTranscript( }; } - const query = await db() - .select({ video: videos }) - .from(videos) - .where(eq(videos.id, videoId)); + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db().select({ video: videos }).from(videos).where(eq(videos.id, videoId)), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + + if (Exit.isFailure(exit)) { + return { success: false, message: "Video not found" }; + } + + const query = exit.value; if (query.length === 0) { return { success: false, message: "Video not found" }; diff --git a/apps/web/actions/videos/translate-transcript.ts b/apps/web/actions/videos/translate-transcript.ts index 7ca24d4a10f..0dfdfdaf3ab 100644 --- a/apps/web/actions/videos/translate-transcript.ts +++ b/apps/web/actions/videos/translate-transcript.ts @@ -2,11 +2,13 @@ import { db } from "@cap/database"; import { videos } from "@cap/database/schema"; -import { Storage } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; +import { provideOptionalAuth, Storage, VideosPolicy } from "@cap/web-backend"; +import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect, Exit, Option } from "effect"; import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; import { decodeStorageVideo } from "@/lib/video-storage"; import { @@ -46,10 +48,23 @@ export async function translateTranscript( }; } - const query = await db() - .select({ video: videos }) - .from(videos) - .where(eq(videos.id, videoId)); + if (await isRateLimited(RATE_LIMIT_IDS.TRANSLATE_TRANSCRIPT)) { + return { success: false, message: "Too many requests" }; + } + + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db().select({ video: videos }).from(videos).where(eq(videos.id, videoId)), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + + if (Exit.isFailure(exit)) { + return { success: false, message: "Video not found" }; + } + + const query = exit.value; if (query.length === 0 || !query[0]?.video) { return { success: false, message: "Video not found" }; diff --git a/apps/web/app/api/video/ai/route.ts b/apps/web/app/api/video/ai/route.ts index f1de882018f..f3dc57b35d9 100644 --- a/apps/web/app/api/video/ai/route.ts +++ b/apps/web/app/api/video/ai/route.ts @@ -2,10 +2,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; -import type { Video } from "@cap/web-domain"; +import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend"; +import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { Effect, Exit } from "effect"; import type { NextRequest } from "next/server"; import { startAiGeneration } from "@/lib/generate-ai"; +import * as EffectRuntime from "@/lib/server"; import { isAiGenerationEnabled } from "@/utils/flags"; export const dynamic = "force-dynamic"; @@ -27,10 +30,22 @@ export async function GET(request: NextRequest) { ); } - const result = await db() - .select() - .from(videos) - .where(eq(videos.id, videoId)); + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db().select().from(videos).where(eq(videos.id, videoId)), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + + if (Exit.isFailure(exit)) { + return Response.json( + { error: true, message: "Video not found" }, + { status: 404 }, + ); + } + + const result = exit.value; if (result.length === 0 || !result[0]) { return Response.json( { error: true, message: "Video not found" }, diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 00000000000..168a800f168 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -0,0 +1,76 @@ +import { checkRateLimit } from "@vercel/firewall"; +import { headers as nextHeaders } from "next/headers"; + +/** + * Best-effort per-key rate limiting backed by the Vercel Firewall. + * + * IMPORTANT: each `ruleId` passed here must also be configured as a Rate Limit + * rule in the Vercel Firewall dashboard (Firewall → Rate Limiting) with a + * `@vercel/firewall` rule condition whose ID matches `ruleId`, plus the desired + * window / limit / action. An ID that has no matching dashboard rule fails + * OPEN (`checkRateLimit` returns `{ rateLimited: false, error: "not-found" }`), + * so this helper never breaks self-hosted deploys that lack the firewall — but + * it also provides no protection until the rule exists. + * + * Mirrors the existing pattern in `actions/collections/password.ts` and + * `actions/send-download-link.ts`: + * - only enforced in production, + * - best-effort (any error → not limited) so a firewall/IP-header outage can + * never take down the underlying feature. + * + * @param ruleId Stable rule id, also configured in the Vercel Firewall. + * @param opts.key Optional bucket key (e.g. per-email / per-user). Defaults to + * the caller IP (the firewall's default behaviour). + * @param opts.headers Optional request headers (required inside Hono handlers + * where `next/headers` is unavailable; defaults to the App + * Router request headers). + * @returns `true` when the request should be rejected. + */ +export async function isRateLimited( + ruleId: string, + opts?: { key?: string; headers?: Headers }, +): Promise { + if (process.env.NODE_ENV !== "production") return false; + + try { + const headersList = opts?.headers ?? (await nextHeaders()); + const request = new Request("https://cap.so/api/rate-limit", { + method: "POST", + headers: headersList, + }); + + const { rateLimited } = await checkRateLimit(ruleId, { + request, + ...(opts?.key ? { rateLimitKey: opts.key } : {}), + }); + + return rateLimited; + } catch (error) { + console.warn(`Rate limit check failed for "${ruleId}":`, error); + return false; + } +} + +/** + * Canonical Vercel Firewall rate-limit rule ids introduced by the security + * hardening pass. Each MUST be created in the Vercel Firewall dashboard for the + * corresponding protection to take effect (see `isRateLimited`). + */ +export const RATE_LIMIT_IDS = { + /** Email OTP verification attempts (brute-force guard). Suggested: 10 / 10m per key (email). */ + AUTH_OTP_VERIFY: "rl_auth_otp_verify", + /** Email OTP / magic-link send (mailbomb + token-reseed guard). Suggested: 5 / 10m per key (email). */ + AUTH_OTP_SEND: "rl_auth_otp_send", + /** Unauthed Loom download/convert (ffmpeg + memory DoS). Suggested: 10 / 1m per IP. */ + LOOM_DOWNLOAD: "rl_loom_download", + /** Unauthed transcript translation (Groq cost). Suggested: 10 / 1m per IP. */ + TRANSLATE_TRANSCRIPT: "rl_translate_transcript", + /** Anonymous support-chat messages (Groq + Supermemory cost). Suggested: 20 / 1m per IP. */ + MESSENGER_MESSAGE: "rl_messenger_message", + /** Unauthed analytics view tracking (Tinybird ingest + notifications). Suggested: 60 / 1m per IP. */ + ANALYTICS_TRACK: "rl_analytics_track", + /** Unauthed guest checkout (Stripe object/cost abuse). Suggested: 10 / 10m per IP. */ + GUEST_CHECKOUT: "rl_guest_checkout", + /** Unauthed desktop log → Discord forwarding (spam). Suggested: 10 / 1m per IP. */ + DESKTOP_LOGS: "rl_desktop_logs", +} as const;