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/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 2c9a6eed18c..4e97388cfd7 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -21,11 +21,38 @@ use tracing::instrument; #[derive(Serialize, Deserialize, Type, PartialEq, Clone, Copy, Debug)] pub struct Hotkey { #[specta(type = String)] - code: Code, - meta: bool, - ctrl: bool, - alt: bool, - shift: bool, + pub code: Code, + pub meta: bool, + pub ctrl: bool, + pub alt: bool, + pub shift: bool, +} + +impl Hotkey { + pub fn to_accelerator_string(&self) -> String { + let mut parts = Vec::new(); + if self.meta { + parts.push("CmdOrCtrl"); + } + if self.ctrl { + parts.push("Ctrl"); + } + if self.alt { + parts.push("Alt"); + } + if self.shift { + parts.push("Shift"); + } + let code_str = format!("{:?}", self.code); + parts.push(&code_str); + parts.join("+") + } +} + +pub fn get_hotkey_accelerator(app: &AppHandle, action: HotkeyAction) -> Option { + let state = app.try_state::()?; + let store = state.lock().ok()?; + store.hotkeys.get(&action).map(|h| h.to_accelerator_string()) } impl From for Shortcut { @@ -362,6 +389,8 @@ pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option) global_shortcut.register(Shortcut::from(hotkey)).ok(); } + tray::refresh_tray_menu_for_app(&app); + Ok(()) } diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 4d0d8f7e3e7..e76a80936e9 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -451,27 +451,29 @@ fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result None::<&str>, )?)?; + use crate::hotkeys::{HotkeyAction, get_hotkey_accelerator}; + if is_screenshot_mode { menu.append(&MenuItem::with_id( app, TrayItem::RecordDisplay, "Screenshot Display", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::ScreenshotDisplay), )?)?; menu.append(&MenuItem::with_id( app, TrayItem::RecordWindow, "Screenshot Window", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::ScreenshotWindow), )?)?; menu.append(&MenuItem::with_id( app, TrayItem::RecordArea, "Screenshot Area", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::ScreenshotArea), )?)?; } else { menu.append(&MenuItem::with_id( @@ -479,28 +481,28 @@ fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result TrayItem::RecordDisplay, "Record Display", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::OpenRecordingPickerDisplay), )?)?; menu.append(&MenuItem::with_id( app, TrayItem::RecordWindow, "Record Window", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::OpenRecordingPickerWindow), )?)?; menu.append(&MenuItem::with_id( app, TrayItem::RecordArea, "Record Area", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::OpenRecordingPickerArea), )?)?; menu.append(&MenuItem::with_id( app, TrayItem::TakeScreenshot, "Take a Screenshot", true, - None::<&str>, + get_hotkey_accelerator(app, HotkeyAction::ScreenshotDisplay), )?)?; } 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 ( 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..f9450b057a7 --- /dev/null +++ b/apps/web/__tests__/unit/rate-limit-ids.test.ts @@ -0,0 +1,65 @@ +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"; + +// 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); + 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") && !filePath.endsWith("rate-limit-ids.test.ts")) { + results.push(filePath); + } + } + } + return results; +} + +describe("RATE_LIMIT_IDS reference contract", () => { + it("ensures every active 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)) { + if (UNWIRED_RATE_LIMIT_IDS.has(key)) { + continue; + } + + 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/developer/v1/[...route]/videos.ts b/apps/web/app/api/developer/v1/[...route]/videos.ts index e8cbecdb1e4..0e8c47f6448 100644 --- a/apps/web/app/api/developer/v1/[...route]/videos.ts +++ b/apps/web/app/api/developer/v1/[...route]/videos.ts @@ -123,3 +123,43 @@ app.get("/:id/status", async (c) => { }, }); }); + +app.get("/:id/transcript", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select() + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + if (video.transcriptionStatus === "PROCESSING") { + return c.json({ error: "Transcript still processing" }, 202); + } + + if (video.transcriptionStatus !== "COMPLETE") { + return c.json( + { error: "No transcript available", status: video.transcriptionStatus }, + 400, + ); + } + + return c.json({ + data: { + id: video.id, + transcriptionStatus: video.transcriptionStatus, + s3Key: video.s3Key, + }, + }); +}); 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"; 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", + }); +} 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()