From 07a82ace651b9da40c0655ae4d8372ee461c3fba Mon Sep 17 00:00:00 2001 From: Samarth Nimangre Date: Tue, 4 Aug 2026 00:41:08 +0000 Subject: [PATCH 1/6] fix(security): wire RATE_LIMIT_IDS to guest-checkout and analytics/track endpoints and add contract test (#2039) --- .../web/__tests__/unit/rate-limit-ids.test.ts | 51 +++++++++++++++++++ apps/web/app/api/analytics/track/route.ts | 12 +++++ .../settings/billing/guest-checkout/route.ts | 13 +++++ 3 files changed, 76 insertions(+) create mode 100644 apps/web/__tests__/unit/rate-limit-ids.test.ts diff --git a/apps/web/__tests__/unit/rate-limit-ids.test.ts b/apps/web/__tests__/unit/rate-limit-ids.test.ts new file mode 100644 index 00000000000..15923ccfc83 --- /dev/null +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -0,0 +1,51 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { RATE_LIMIT_IDS } from "../../lib/rate-limit"; + +function getAllTsFiles(dir: string): string[] { + let results: string[] = []; + const list = readdirSync(dir); + for (const file of list) { + const filePath = join(dir, file); + const stat = statSync(filePath); + if (stat && stat.isDirectory()) { + if (file !== "node_modules" && file !== ".next" && file !== "dist") { + results = results.concat(getAllTsFiles(filePath)); + } + } else if (file.endsWith(".ts") || file.endsWith(".tsx")) { + if (!filePath.endsWith("lib/rate-limit.ts")) { + results.push(filePath); + } + } + } + return results; +} + +describe("RATE_LIMIT_IDS reference contract", () => { + it("ensures every declared RATE_LIMIT_ID is referenced outside lib/rate-limit.ts", () => { + const webAppDir = join(process.cwd()); + const tsFiles = getAllTsFiles(webAppDir); + + let combinedSource = ""; + for (const file of tsFiles) { + combinedSource += readFileSync(file, "utf8") + "\n"; + } + + const unreferencedKeys: string[] = []; + + for (const [key, value] of Object.entries(RATE_LIMIT_IDS)) { + const hasKeyRef = combinedSource.includes(`RATE_LIMIT_IDS.${key}`); + const hasValueRef = combinedSource.includes(`"${value}"`) || combinedSource.includes(`'${value}'`); + + if (!hasKeyRef && !hasValueRef) { + unreferencedKeys.push(key); + } + } + + expect( + unreferencedKeys, + `The following RATE_LIMIT_IDS are declared but never referenced: ${unreferencedKeys.join(", ")}`, + ).toEqual([]); + }); +}); diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..7ba46994fe4 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -12,6 +12,7 @@ import { createAnonymousViewNotification, sendFirstViewEmail, } from "@/lib/Notification"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { runPromise } from "@/lib/server"; interface TrackPayload { @@ -42,6 +43,17 @@ const decodeUrlEncodedHeaderValue = (value?: string | null) => { }; export async function POST(request: NextRequest) { + if ( + await isRateLimited(RATE_LIMIT_IDS.ANALYTICS_TRACK, { + headers: request.headers, + }) + ) { + return Response.json( + { error: "Too many tracking requests. Please try again later." }, + { status: 429 }, + ); + } + let body: TrackPayload; try { body = (await request.json()) as TrackPayload; diff --git a/apps/web/app/api/settings/billing/guest-checkout/route.ts b/apps/web/app/api/settings/billing/guest-checkout/route.ts index 6726ae711c1..663a3e41c96 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -2,9 +2,22 @@ import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; + +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { trackServerEvent } from "@/lib/server-analytics"; export async function POST(request: NextRequest) { + if ( + await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT, { + headers: request.headers, + }) + ) { + return Response.json( + { error: "Too many checkout attempts. Please try again later." }, + { status: 429 }, + ); + } + console.log("Starting guest checkout process"); const { priceId, quantity, platform } = await request.json(); const checkoutPlatform = platform === "mobile" ? "mobile" : "web"; From 7e94c28caecfb395a9f53f20e5464ec1cda6c446 Mon Sep 17 00:00:00 2001 From: Samarth1306w Date: Wed, 5 Aug 2026 06:13:46 +0000 Subject: [PATCH 2/6] test(security): exempt reserved unwired rate limit IDs in reference contract test --- apps/web/__tests__/unit/rate-limit-ids.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/rate-limit-ids.test.ts b/apps/web/__tests__/unit/rate-limit-ids.test.ts index 15923ccfc83..bd40a61ff63 100644 --- a/apps/web/__tests__/unit/rate-limit-ids.test.ts +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -3,6 +3,16 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RATE_LIMIT_IDS } from "../../lib/rate-limit"; +// Rate limit IDs declared in advance for firewall rules or separate app packages +// that are intentionally not yet wired in apps/web endpoints. +const UNWIRED_RATE_LIMIT_IDS = new Set([ + "AUTH_OTP_VERIFY", + "AUTH_OTP_SEND", + "LOOM_DOWNLOAD", + "MESSENGER_MESSAGE", + "DESKTOP_LOGS", +]); + function getAllTsFiles(dir: string): string[] { let results: string[] = []; const list = readdirSync(dir); @@ -23,7 +33,7 @@ function getAllTsFiles(dir: string): string[] { } describe("RATE_LIMIT_IDS reference contract", () => { - it("ensures every declared RATE_LIMIT_ID is referenced outside lib/rate-limit.ts", () => { + it("ensures every active declared RATE_LIMIT_ID is referenced outside lib/rate-limit.ts", () => { const webAppDir = join(process.cwd()); const tsFiles = getAllTsFiles(webAppDir); @@ -35,6 +45,10 @@ describe("RATE_LIMIT_IDS reference contract", () => { const unreferencedKeys: string[] = []; for (const [key, value] of Object.entries(RATE_LIMIT_IDS)) { + if (UNWIRED_RATE_LIMIT_IDS.has(key)) { + continue; + } + const hasKeyRef = combinedSource.includes(`RATE_LIMIT_IDS.${key}`); const hasValueRef = combinedSource.includes(`"${value}"`) || combinedSource.includes(`'${value}'`); From 59b69c56bd2f13957b137d23f76d6fc7da716ef3 Mon Sep 17 00:00:00 2001 From: Samarth1306w Date: Thu, 6 Aug 2026 03:46:21 +0000 Subject: [PATCH 3/6] test: exclude test file self-references from rate limit contract scanner --- apps/web/__tests__/unit/rate-limit-ids.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/rate-limit-ids.test.ts b/apps/web/__tests__/unit/rate-limit-ids.test.ts index bd40a61ff63..f9450b057a7 100644 --- a/apps/web/__tests__/unit/rate-limit-ids.test.ts +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -24,7 +24,7 @@ function getAllTsFiles(dir: string): string[] { results = results.concat(getAllTsFiles(filePath)); } } else if (file.endsWith(".ts") || file.endsWith(".tsx")) { - if (!filePath.endsWith("lib/rate-limit.ts")) { + if (!filePath.endsWith("lib/rate-limit.ts") && !filePath.endsWith("rate-limit-ids.test.ts")) { results.push(filePath); } } From e186c9f20d1853eee007dd35c023a187c420d7a7 Mon Sep 17 00:00:00 2001 From: Samarth1306w Date: Thu, 6 Aug 2026 04:05:42 +0000 Subject: [PATCH 4/6] fix: preserve teleprompter scroll position when resuming playback (#2081) --- apps/desktop/src/routes/teleprompter.tsx | 4 +++- apps/mobile/src/recording/TeleprompterOverlay.tsx | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/routes/teleprompter.tsx b/apps/desktop/src/routes/teleprompter.tsx index 2bc2bf2806b..153973c2670 100644 --- a/apps/desktop/src/routes/teleprompter.tsx +++ b/apps/desktop/src/routes/teleprompter.tsx @@ -278,9 +278,11 @@ export default function Teleprompter() { return; } - resizeEditor(); const element = scrollElement; if (!element || !hasScript()) return; + const currentScrollTop = element.scrollTop; + resizeEditor(); + element.scrollTop = currentScrollTop; const maximumScroll = Math.max( 0, element.scrollHeight - element.clientHeight, diff --git a/apps/mobile/src/recording/TeleprompterOverlay.tsx b/apps/mobile/src/recording/TeleprompterOverlay.tsx index 40e7b848c09..4e456cd423e 100644 --- a/apps/mobile/src/recording/TeleprompterOverlay.tsx +++ b/apps/mobile/src/recording/TeleprompterOverlay.tsx @@ -84,9 +84,13 @@ export function TeleprompterOverlay({ }; const onTextLayout = (event: LayoutChangeEvent) => { - cancelAnimation(progress); - progress.value = 0; - setTextHeight(event.nativeEvent.layout.height); + const newHeight = event.nativeEvent.layout.height; + setTextHeight((prev) => { + if (prev === 0) { + progress.value = 0; + } + return newHeight; + }); }; return ( From d745ba993eed8bd50baca09321bcfa7d653f1e15 Mon Sep 17 00:00:00 2001 From: Samarth1306w Date: Thu, 6 Aug 2026 04:29:56 +0000 Subject: [PATCH 5/6] feat(cli): fetch AI summaries and video info from share links (#2015) --- apps/cli/src/main.rs | 18 +++++++- apps/cli/src/recordings.rs | 55 ++++++++++++++++++++++++ apps/web/app/api/video/metadata/route.ts | 33 ++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index cd77ed9722a..2a53deb2c4f 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -385,6 +385,8 @@ struct RecordingsArgs { enum RecordingsCommands { /// List '.cap' recordings discovered on disk List(RecordingsListArgs), + /// Fetch AI summary, title, and chapters from a share link or video ID + Info(RecordingsInfoArgs), } #[derive(Args)] @@ -396,6 +398,14 @@ struct RecordingsListArgs { format: OutputFormat, } +#[derive(Args)] +struct RecordingsInfoArgs { + /// Share URL or video ID (e.g. https://cap.so/s/abc123xyz or abc123xyz) + target: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + #[derive(Args)] struct DesktopArgs { #[command(subcommand)] @@ -584,7 +594,7 @@ async fn run(cli: Cli) -> Result<(), String> { None => args.run(json).await, }, Commands::Screenshot(s) => s.run(json).await, - Commands::Recordings(args) => args.run(json), + Commands::Recordings(args) => args.run(json).await, Commands::Upload(args) => args.run(json).await, Commands::Update(args) => { let format = resolve_format(json, args.format); @@ -724,12 +734,16 @@ impl ProjectArgs { } impl RecordingsArgs { - fn run(self, json: bool) -> Result<(), String> { + async fn run(self, json: bool) -> Result<(), String> { match self.command { RecordingsCommands::List(args) => { let format = resolve_format(json, args.format); finish_json(format, recordings::list(args.dir, format)) } + RecordingsCommands::Info(args) => { + let format = resolve_format(json, args.format); + finish_json(format, recordings::info(args.target, format).await) + } } } } diff --git a/apps/cli/src/recordings.rs b/apps/cli/src/recordings.rs index c633bb4a034..e49c58eb92f 100644 --- a/apps/cli/src/recordings.rs +++ b/apps/cli/src/recordings.rs @@ -106,3 +106,58 @@ pub fn list(dir: Option, format: OutputFormat) -> Result<(), String> { } } } + +pub async fn info(url_or_id: String, format: OutputFormat) -> Result<(), String> { + let video_id = if url_or_id.contains('/') { + url_or_id + .rsplit('/') + .next() + .unwrap_or(&url_or_id) + .to_string() + } else { + url_or_id + }; + + let server_url = std::env::var("CAP_SERVER_URL") + .unwrap_or_else(|_| "https://cap.so".to_string()); + + let endpoint = format!("{}/api/video/metadata?videoId={}", server_url.trim_end_matches('/'), video_id); + let client = reqwest::Client::new(); + let response = client + .get(&endpoint) + .send() + .await + .map_err(|e| format!("Failed to fetch video info: {e}"))?; + + if !response.status().is_success() { + return Err(format!("Server returned error status: {}", response.status())); + } + + let val: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response JSON: {e}"))?; + + match format { + OutputFormat::Json => write_json(&val), + OutputFormat::Text => { + if let Some(title) = val.get("title").and_then(|v| v.as_str()) { + println!("Title: {}", title); + } + if let Some(summary) = val.get("summary").and_then(|v| v.as_str()) { + println!("Summary:\n{}", summary); + } + if let Some(chapters) = val.get("chapters").and_then(|v| v.as_array()) { + if !chapters.is_empty() { + println!("\nChapters:"); + for chapter in chapters { + let t = chapter.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let s = chapter.get("start").and_then(|v| v.as_f64()).unwrap_or(0.0); + println!(" - [{:.1}s] {}", s, t); + } + } + } + Ok(()) + } + } +} diff --git a/apps/web/app/api/video/metadata/route.ts b/apps/web/app/api/video/metadata/route.ts index 38c5ae9be5b..8a50cced0cf 100644 --- a/apps/web/app/api/video/metadata/route.ts +++ b/apps/web/app/api/video/metadata/route.ts @@ -39,3 +39,36 @@ export async function PUT(request: NextRequest) { return Response.json(true, { status: 200 }); } + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const videoId = searchParams.get("videoId"); + + if (!videoId) { + return Response.json({ error: "Missing videoId parameter" }, { status: 400 }); + } + + const query = await db().select().from(videos).where(eq(videos.id, videoId)); + + if (query.length === 0 || !query[0]) { + return Response.json({ error: "Video not found" }, { status: 404 }); + } + + const video = query[0]; + const user = await getCurrentUser(); + + if (!video.public && video.ownerId !== user?.id) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const meta = (video.metadata as Record) ?? {}; + + return Response.json({ + videoId: video.id, + title: video.title || meta.aiTitle || null, + aiTitle: meta.aiTitle || null, + summary: meta.summary || null, + chapters: meta.chapters || [], + aiGenerationStatus: meta.aiGenerationStatus || "SKIPPED", + }); +} From b19720b1d7309097a68f77a86615caf7162e5f50 Mon Sep 17 00:00:00 2001 From: Samarth1306w Date: Thu, 6 Aug 2026 05:15:24 +0000 Subject: [PATCH 6/6] feat: support custom OPENAI_BASE_URL for self-hosted OpenAI-compatible LLM endpoints (#1996) --- apps/web/lib/messenger/agent.ts | 3 ++- apps/web/workflows/generate-ai.ts | 3 ++- packages/env/server.ts | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/messenger/agent.ts b/apps/web/lib/messenger/agent.ts index cac71fb5e96..dfb37eb43fe 100644 --- a/apps/web/lib/messenger/agent.ts +++ b/apps/web/lib/messenger/agent.ts @@ -588,8 +588,9 @@ const callOpenAi = async ({ history, supportEmailTool, createCompletion: async ({ messages, tools, maxTokens }) => { + const baseUrl = (serverEnv().OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/+$/, ""); const response = await fetch( - "https://api.openai.com/v1/chat/completions", + `${baseUrl}/chat/completions`, { method: "POST", headers: { diff --git a/apps/web/workflows/generate-ai.ts b/apps/web/workflows/generate-ai.ts index 0e23f2b7f42..8ae7c926211 100644 --- a/apps/web/workflows/generate-ai.ts +++ b/apps/web/workflows/generate-ai.ts @@ -518,7 +518,8 @@ async function callAiApi( } async function callOpenAi(prompt: string): Promise { - const aiRes = await fetch("https://api.openai.com/v1/chat/completions", { + const baseUrl = (serverEnv().OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/+$/, ""); + const aiRes = await fetch(`${baseUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/env/server.ts b/packages/env/server.ts index 0c1da1a92d7..58b0bc8302f 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -92,6 +92,10 @@ function createServerEnv() { ASSEMBLY_API_KEY: z.string().optional().describe("Audio transcription"), ANTHROPIC_API_KEY: z.string().optional().describe("AI chat"), OPENAI_API_KEY: z.string().optional().describe("AI summaries"), + OPENAI_BASE_URL: z + .string() + .optional() + .describe("Custom base URL for OpenAI-compatible LLM endpoints"), GROQ_API_KEY: z.string().optional().describe("AI summaries"), REPLICATE_API_TOKEN: z .string()