From 5d0f699434b8982bbcbbcfc0fd4d838cf9848109 Mon Sep 17 00:00:00 2001 From: FILIPE RIBEIRO DOS SANTOS <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:50:19 -0300 Subject: [PATCH 1/9] feat(recording): add pre-recording upload speed test, adaptive resolution tier, and startup health check (#73) - Add `upload_probe` module in `crates/recording` classifying upload throughput (`SpeedProbeSample`, `AdaptiveUploadTier`) into adaptive resolution caps (`1920`, `1280`, `854`) and `StudioQuality` presets while preventing speed probes during active recordings (`should_run_speed_probe`). - Extend `RecordingDefaults::with_upload_speed_mbps` to apply adaptive quality and resolution limits prior to recording startup. - Add `/api/upload/health-check` Web App API route (`apps/web/app/api/upload/health-check/route.ts`) and unit tests to validate startup screen-recording upload probes and surface user-facing support indicators on failure. Fixes #73 Signed-off-by: Lucas Oliveira --- .../app/api/upload/health-check/route.test.ts | 31 +++ apps/web/app/api/upload/health-check/route.ts | 60 ++++++ crates/recording/src/defaults.rs | 10 + crates/recording/src/lib.rs | 1 + crates/recording/src/upload_probe.rs | 189 ++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 apps/web/app/api/upload/health-check/route.test.ts create mode 100644 apps/web/app/api/upload/health-check/route.ts create mode 100644 crates/recording/src/upload_probe.rs diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts new file mode 100644 index 00000000000..f64ca051376 --- /dev/null +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { GET, POST } from "./route" + +describe("Cap desktop upload health-check API route", () => { + it("accepts valid upload probe buffers and rejects undersized payloads", async () => { + const validPayload = new Uint8Array(1024).fill(42) + const okRes = await POST( + new Request("https://cap.so/api/upload/health-check", { + method: "POST", + body: validPayload, + }) + ) + expect(okRes.status).toBe(200) + const okJson = await okRes.json() + expect(okJson.ok).toBe(true) + expect(okJson.bytesReceived).toBe(1024) + + const smallRes = await POST( + new Request("https://cap.so/api/upload/health-check", { + method: "POST", + body: new Uint8Array(4), + }) + ) + expect(smallRes.status).toBe(400) + const smallJson = await smallRes.json() + expect(smallJson.ok).toBe(false) + + const getRes = await GET() + expect(getRes.status).toBe(200) + }) +}) diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts new file mode 100644 index 00000000000..dd29ae368fa --- /dev/null +++ b/apps/web/app/api/upload/health-check/route.ts @@ -0,0 +1,60 @@ +export interface UploadHealthCheckResponse { + ok: boolean + status: "healthy" | "degraded" + bytesReceived: number + serverTimestampMs: number + message: string +} + +const MIN_PROBE_BYTES = 16 + +// Validates the desktop startup screen-recording upload probe payload and returns diagnostic telemetry. +export async function POST(request: Request): Promise { + try { + const buffer = await request.arrayBuffer() + const bytesReceived = buffer.byteLength + + if (bytesReceived < MIN_PROBE_BYTES) { + const degraded: UploadHealthCheckResponse = { + ok: false, + status: "degraded", + bytesReceived, + serverTimestampMs: Date.now(), + message: + "Upload probe payload is too small — please contact support.", + } + return Response.json(degraded, { status: 400 }) + } + + const healthy: UploadHealthCheckResponse = { + ok: true, + status: "healthy", + bytesReceived, + serverTimestampMs: Date.now(), + message: "Upload health check succeeded.", + } + return Response.json(healthy, { status: 200 }) + } catch { + const failed: UploadHealthCheckResponse = { + ok: false, + status: "degraded", + bytesReceived: 0, + serverTimestampMs: Date.now(), + message: + "Upload health check failed — please contact support if recording uploads fail.", + } + return Response.json(failed, { status: 500 }) + } +} + +// Responds to lightweight GET readiness checks from the Cap desktop client. +export async function GET(): Promise { + const body: UploadHealthCheckResponse = { + ok: true, + status: "healthy", + bytesReceived: 0, + serverTimestampMs: Date.now(), + message: "Upload health-check endpoint ready.", + } + return Response.json(body, { status: 200 }) +} diff --git a/crates/recording/src/defaults.rs b/crates/recording/src/defaults.rs index 9d225666b90..3b84fe06538 100644 --- a/crates/recording/src/defaults.rs +++ b/crates/recording/src/defaults.rs @@ -83,3 +83,13 @@ pub fn default_studio_recording_quality() -> StudioQuality { _ => StudioQuality::Balanced, } } + +impl RecordingDefaults { + // Adjusts instant mode resolution cap and studio quality tier based on pre-recording upload throughput. + pub fn with_upload_speed_mbps(mut self, mbps: f64) -> Self { + let tier = crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps); + self.instant_mode_max_resolution = tier.max_instant_resolution(); + self.studio_recording_quality = tier.studio_quality(); + self + } +} diff --git a/crates/recording/src/lib.rs b/crates/recording/src/lib.rs index 341ed4c47f1..e6882e138cb 100644 --- a/crates/recording/src/lib.rs +++ b/crates/recording/src/lib.rs @@ -20,6 +20,7 @@ pub mod studio_recording; pub mod sync_calibration; pub mod track_heal; pub mod upload_preparation; +pub mod upload_probe; pub mod upload_resume; pub mod upload_verification; diff --git a/crates/recording/src/upload_probe.rs b/crates/recording/src/upload_probe.rs new file mode 100644 index 00000000000..0cfd0441c0f --- /dev/null +++ b/crates/recording/src/upload_probe.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::{ + StudioQuality, + defaults::{FREE_INSTANT_MODE_MAX_RESOLUTION, PRO_INSTANT_MODE_MAX_RESOLUTION}, +}; + +pub const LOW_BANDWIDTH_MAX_RESOLUTION: u32 = 854; +pub const HIGH_SPEED_THRESHOLD_MBPS: f64 = 15.0; +pub const STANDARD_SPEED_THRESHOLD_MBPS: f64 = 5.0; +pub const DEFAULT_PROBE_PAYLOAD_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AdaptiveUploadTier { + High, + Standard, + Low, + Offline, +} + +impl AdaptiveUploadTier { + // Classifies measured upload throughput in Mbps into an adaptive recording quality tier. + pub fn from_mbps(mbps: f64) -> Self { + if !mbps.is_finite() || mbps <= 0.0 { + return Self::Offline; + } + if mbps >= HIGH_SPEED_THRESHOLD_MBPS { + return Self::High; + } + if mbps >= STANDARD_SPEED_THRESHOLD_MBPS { + return Self::Standard; + } + Self::Low + } + + // Maps the upload tier to the maximum horizontal/vertical dimension for instant recordings. + pub fn max_instant_resolution(self) -> u32 { + match self { + Self::High => PRO_INSTANT_MODE_MAX_RESOLUTION, + Self::Standard => FREE_INSTANT_MODE_MAX_RESOLUTION, + Self::Low | Self::Offline => LOW_BANDWIDTH_MAX_RESOLUTION, + } + } + + // Maps the upload tier to the encoder quality preset. + pub fn studio_quality(self) -> StudioQuality { + match self { + Self::High => StudioQuality::Ultra, + Self::Standard => StudioQuality::Balanced, + Self::Low | Self::Offline => StudioQuality::Compatibility, + } + } + + // Produces compact indicator badge text for the desktop corner overlay. + pub fn indicator_label(self, mbps: f64) -> String { + match self { + Self::High => format!("{mbps:.1} Mbps • HD Ready"), + Self::Standard => format!("{mbps:.1} Mbps • 720p Adaptive"), + Self::Low => format!("{mbps:.1} Mbps • Low Bandwidth (480p)"), + Self::Offline => "Upload check failed — contact support".to_string(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeedProbeSample { + pub bytes_transferred: usize, + pub elapsed: Duration, +} + +impl SpeedProbeSample { + // Calculates upload speed in megabits per second from transferred byte count and duration. + pub fn throughput_mbps(&self) -> f64 { + let secs = self.elapsed.as_secs_f64(); + if secs <= 0.0 || self.bytes_transferred == 0 { + return 0.0; + } + let bits = (self.bytes_transferred as f64) * 8.0; + bits / (secs * 1_000_000.0) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupHealthCheckStatus { + pub upload_ok: bool, + pub speed_mbps: f64, + pub tier: AdaptiveUploadTier, + pub indicator_message: String, + pub contact_support_required: bool, +} + +// Evaluates startup health check outcome and builds the desktop corner indicator state. +pub fn evaluate_startup_health( + http_ok: bool, + sample: Option, +) -> StartupHealthCheckStatus { + if !http_ok { + return StartupHealthCheckStatus { + upload_ok: false, + speed_mbps: 0.0, + tier: AdaptiveUploadTier::Offline, + indicator_message: + "Upload health check failed — please contact support".to_string(), + contact_support_required: true, + }; + } + + let mbps = sample.map(|s| s.throughput_mbps()).unwrap_or(0.0); + let tier = AdaptiveUploadTier::from_mbps(mbps); + let upload_ok = tier != AdaptiveUploadTier::Offline; + + StartupHealthCheckStatus { + upload_ok, + speed_mbps: mbps, + tier, + indicator_message: tier.indicator_label(mbps), + contact_support_required: !upload_ok, + } +} + +// Prevents upload speed probes from running while a recording session is already active. +pub fn should_run_speed_probe(is_recording_active: bool) -> bool { + !is_recording_active +} + +// Builds a deterministic synthetic test pattern buffer for the startup upload health check. +pub fn build_probe_payload(size_bytes: usize) -> Vec { + let target_len = if size_bytes == 0 { + DEFAULT_PROBE_PAYLOAD_BYTES + } else { + size_bytes + }; + (0..target_len).map(|idx| (idx % 251) as u8).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_upload_tiers_and_resolutions() { + assert_eq!(AdaptiveUploadTier::from_mbps(25.0), AdaptiveUploadTier::High); + assert_eq!( + AdaptiveUploadTier::High.max_instant_resolution(), + PRO_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!( + AdaptiveUploadTier::from_mbps(8.5), + AdaptiveUploadTier::Standard + ); + assert_eq!( + AdaptiveUploadTier::Standard.max_instant_resolution(), + FREE_INSTANT_MODE_MAX_RESOLUTION + ); + assert_eq!(AdaptiveUploadTier::from_mbps(2.1), AdaptiveUploadTier::Low); + assert_eq!( + AdaptiveUploadTier::Low.max_instant_resolution(), + LOW_BANDWIDTH_MAX_RESOLUTION + ); + assert_eq!( + AdaptiveUploadTier::from_mbps(0.0), + AdaptiveUploadTier::Offline + ); + } + + #[test] + fn blocks_probe_during_active_recording_and_evaluates_health() { + assert!(should_run_speed_probe(false)); + assert!(!should_run_speed_probe(true)); + + let sample = SpeedProbeSample { + bytes_transferred: 1_000_000, + elapsed: Duration::from_millis(500), + }; + let healthy = evaluate_startup_health(true, Some(sample)); + assert!(healthy.upload_ok); + assert!(!healthy.contact_support_required); + assert_eq!(healthy.tier, AdaptiveUploadTier::High); + + let failed = evaluate_startup_health(false, None); + assert!(!failed.upload_ok); + assert!(failed.contact_support_required); + } +} From 31228ad532763634c595ad765fbb7ad8eb52444a Mon Sep 17 00:00:00 2001 From: FILIPE RIBEIRO DOS SANTOS <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:09:17 -0300 Subject: [PATCH 2/9] fix(web): bound upload health-check probe stream size and enforce per-IP rate limiting --- .../app/api/upload/health-check/route.test.ts | 61 +++--- apps/web/app/api/upload/health-check/route.ts | 176 +++++++++++++----- 2 files changed, 159 insertions(+), 78 deletions(-) diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts index f64ca051376..c0ec083091d 100644 --- a/apps/web/app/api/upload/health-check/route.test.ts +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -1,31 +1,38 @@ -import { describe, expect, it } from "vitest" -import { GET, POST } from "./route" +import { describe, expect, it } from "vitest"; +import { GET, POST } from "./route"; describe("Cap desktop upload health-check API route", () => { - it("accepts valid upload probe buffers and rejects undersized payloads", async () => { - const validPayload = new Uint8Array(1024).fill(42) - const okRes = await POST( - new Request("https://cap.so/api/upload/health-check", { - method: "POST", - body: validPayload, - }) - ) - expect(okRes.status).toBe(200) - const okJson = await okRes.json() - expect(okJson.ok).toBe(true) - expect(okJson.bytesReceived).toBe(1024) + it("accepts valid upload probe buffers and rejects undersized or oversized payloads", async () => { + const validPayload = new Uint8Array(1024).fill(42); + const okRes = await POST( + new Request("https://cap.so/api/upload/health-check", { + method: "POST", + body: validPayload, + }), + ); + expect(okRes.status).toBe(200); + const okJson = await okRes.json(); + expect(okJson.ok).toBe(true); + expect(okJson.bytesReceived).toBe(1024); - const smallRes = await POST( - new Request("https://cap.so/api/upload/health-check", { - method: "POST", - body: new Uint8Array(4), - }) - ) - expect(smallRes.status).toBe(400) - const smallJson = await smallRes.json() - expect(smallJson.ok).toBe(false) + const smallRes = await POST( + new Request("https://cap.so/api/upload/health-check", { + method: "POST", + body: new Uint8Array(4), + }), + ); + expect(smallRes.status).toBe(400); - const getRes = await GET() - expect(getRes.status).toBe(200) - }) -}) + const oversizedRes = await POST( + new Request("https://cap.so/api/upload/health-check", { + method: "POST", + headers: { "content-length": "1048576" }, + body: new Uint8Array(32), + }), + ); + expect(oversizedRes.status).toBe(413); + + const getRes = await GET(); + expect(getRes.status).toBe(200); + }); +}); diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index dd29ae368fa..27f189bc0d3 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -1,60 +1,134 @@ export interface UploadHealthCheckResponse { - ok: boolean - status: "healthy" | "degraded" - bytesReceived: number - serverTimestampMs: number - message: string + ok: boolean; + status: "healthy" | "degraded"; + bytesReceived: number; + serverTimestampMs: number; + message: string; } -const MIN_PROBE_BYTES = 16 +const MIN_PROBE_BYTES = 16; +const MAX_PROBE_BYTES = 262_144; +const RATE_WINDOW_MS = 60_000; +const MAX_REQUESTS_PER_WINDOW = 30; + +const ipBuckets = new Map(); + +function isRateLimited(request: Request, nowMs: number): boolean { + const forwarded = request.headers.get("x-forwarded-for") ?? "local"; + const clientIp = forwarded.split(",")[0]?.trim() || "local"; + const bucket = ipBuckets.get(clientIp); + if (!bucket || nowMs >= bucket.resetAt) { + ipBuckets.set(clientIp, { count: 1, resetAt: nowMs + RATE_WINDOW_MS }); + return false; + } + bucket.count += 1; + return bucket.count > MAX_REQUESTS_PER_WINDOW; +} + +async function readBoundedBytes(request: Request): Promise { + const declared = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PROBE_BYTES) { + return null; + } + if (!request.body) { + return 0; + } + const reader = request.body.getReader(); + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value?.byteLength ?? 0; + if (total > MAX_PROBE_BYTES) { + await reader.cancel(); + return null; + } + } + return total; +} + +function buildResponse( + payload: Omit, + status: number, +): Response { + return Response.json( + { ...payload, serverTimestampMs: Date.now() }, + { status }, + ); +} -// Validates the desktop startup screen-recording upload probe payload and returns diagnostic telemetry. export async function POST(request: Request): Promise { - try { - const buffer = await request.arrayBuffer() - const bytesReceived = buffer.byteLength - - if (bytesReceived < MIN_PROBE_BYTES) { - const degraded: UploadHealthCheckResponse = { - ok: false, - status: "degraded", - bytesReceived, - serverTimestampMs: Date.now(), - message: - "Upload probe payload is too small — please contact support.", - } - return Response.json(degraded, { status: 400 }) - } - - const healthy: UploadHealthCheckResponse = { - ok: true, - status: "healthy", - bytesReceived, - serverTimestampMs: Date.now(), - message: "Upload health check succeeded.", - } - return Response.json(healthy, { status: 200 }) - } catch { - const failed: UploadHealthCheckResponse = { - ok: false, - status: "degraded", - bytesReceived: 0, - serverTimestampMs: Date.now(), - message: - "Upload health check failed — please contact support if recording uploads fail.", - } - return Response.json(failed, { status: 500 }) - } + if (isRateLimited(request, Date.now())) { + return buildResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: "Rate limit exceeded for upload health probe.", + }, + 429, + ); + } + + try { + const bytesReceived = await readBoundedBytes(request); + if (bytesReceived === null) { + return buildResponse( + { + ok: false, + status: "degraded", + bytesReceived: MAX_PROBE_BYTES, + message: "Upload probe payload exceeds maximum allowed size.", + }, + 413, + ); + } + + if (bytesReceived < MIN_PROBE_BYTES) { + return buildResponse( + { + ok: false, + status: "degraded", + bytesReceived, + message: "Upload probe payload is too small — please contact support.", + }, + 400, + ); + } + + return buildResponse( + { + ok: true, + status: "healthy", + bytesReceived, + message: "Upload health check succeeded.", + }, + 200, + ); + } catch { + return buildResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: + "Upload health check failed — please contact support if recording uploads fail.", + }, + 500, + ); + } } -// Responds to lightweight GET readiness checks from the Cap desktop client. export async function GET(): Promise { - const body: UploadHealthCheckResponse = { - ok: true, - status: "healthy", - bytesReceived: 0, - serverTimestampMs: Date.now(), - message: "Upload health-check endpoint ready.", - } - return Response.json(body, { status: 200 }) + return buildResponse( + { + ok: true, + status: "healthy", + bytesReceived: 0, + message: "Upload health-check endpoint ready.", + }, + 200, + ); } From f18a2d9d44f54ecc3d0a2afd7390be6265be7464 Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:52:05 -0300 Subject: [PATCH 3/9] feat(upload): wire authenticated HttpApi health check and startup speed probe Integrate /api/upload/health-check with @effect/platform HttpApi, HttpAuthMiddleware, and Storage readiness checks, and wire startup speed probing into desktop-gpui and RecordingDefaults. --- apps/desktop-gpui/src/power.rs | 67 +++- .../app/api/upload/health-check/route.test.ts | 94 +++++- apps/web/app/api/upload/health-check/route.ts | 307 +++++++++++------- crates/recording/src/defaults.rs | 38 ++- crates/recording/src/upload_probe.rs | 45 ++- 5 files changed, 403 insertions(+), 148 deletions(-) diff --git a/apps/desktop-gpui/src/power.rs b/apps/desktop-gpui/src/power.rs index b23d9c3b472..e73a4ffc7c2 100644 --- a/apps/desktop-gpui/src/power.rs +++ b/apps/desktop-gpui/src/power.rs @@ -3,21 +3,86 @@ //! (lid closed, Sleep chosen, battery empty) the recording is paused first, so //! capture never dies inside sleep, and it waits paused for the user on wake. +use std::time::Instant; + +use cap_recording::{ + RecordingDefaults, + upload_probe::{ + DEFAULT_PROBE_PAYLOAD_BYTES, SpeedProbeSample, build_probe_payload, record_startup_health, + should_run_speed_probe, + }, +}; use gpui::App; +use serde_json::Value; -use crate::session::Phase; +use crate::session::{Phase, RecordingSession}; pub(crate) fn holds_off_idle_sleep(phase: Phase) -> bool { phase != Phase::Idle } pub fn install(cx: &mut App) { + spawn_startup_upload_probe(cx); #[cfg(target_os = "macos")] macos::install(cx); #[cfg(not(target_os = "macos"))] let _ = cx; } +fn spawn_startup_upload_probe(cx: &mut App) { + let phase = RecordingSession::global(cx).read(cx).phase; + let recording_active = holds_off_idle_sleep(phase); + if !should_run_speed_probe(recording_active) { + return; + } + gpui_tokio::Tokio::spawn(cx, run_startup_upload_probe()).detach(); +} + +async fn run_startup_upload_probe() { + let auth = crate::store::auth_snapshot(); + let Some(token) = auth.token.filter(|token| !token.is_empty()) else { + return; + }; + let Ok(client) = crate::auth::api_client() else { + return; + }; + let server = crate::auth::server_url(); + let url = format!("{server}/api/upload/health-check"); + let payload = build_probe_payload(DEFAULT_PROBE_PAYLOAD_BYTES); + let bytes_transferred = payload.len(); + let start = Instant::now(); + let request = crate::auth::apply_desktop_headers(client.post(url)) + .bearer_auth(token) + .header("Content-Type", "application/octet-stream") + .body(payload); + + match request.send().await { + Ok(response) if response.status().is_success() => { + let sample = SpeedProbeSample { + bytes_transferred, + elapsed: start.elapsed(), + }; + let status = record_startup_health(true, Some(sample)); + apply_adaptive_recording_defaults(status.speed_mbps); + } + Ok(_) | Err(_) => { + record_startup_health(false, None); + } + } +} + +fn apply_adaptive_recording_defaults(speed_mbps: f64) { + let adapted = RecordingDefaults::default().with_upload_speed_mbps(speed_mbps); + let current = crate::store::GeneralSettings::load().instant_mode_max_resolution; + if adapted.instant_mode_max_resolution < current { + crate::store::set_store_setting( + "general_settings", + "instantModeMaxResolution", + Value::from(adapted.instant_mode_max_resolution), + ); + } +} + #[cfg(target_os = "macos")] mod macos { use std::{ diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts index c0ec083091d..88447f5584c 100644 --- a/apps/web/app/api/upload/health-check/route.test.ts +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -1,11 +1,75 @@ -import { describe, expect, it } from "vitest"; -import { GET, POST } from "./route"; +import { CurrentUser, HttpAuthMiddleware, Organisation, User } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import { Effect, Layer, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + rateLimited: false, + storageHealthy: true, + putObject: vi.fn(), + deleteObject: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: vi.fn(async () => mocks.rateLimited), +})); +vi.mock("@/lib/server", async () => { + const { HttpApiBuilder, HttpServer } = await import("@effect/platform"); + const authLayer = Layer.succeed(HttpAuthMiddleware, () => + Effect.succeed({ + id: User.UserId.make("user_test"), + email: "user@cap.so", + activeOrganizationId: Organisation.OrganisationId.make("org_test"), + iconUrlOrKey: Option.none(), + }), + ); + const storageLayer = Layer.succeed(Storage, { + getWritableAccessForUser: () => + mocks.storageHealthy + ? Effect.succeed({ + access: { + putObject: (...args: unknown[]) => { + mocks.putObject(...args); + return Effect.void; + }, + deleteObject: (...args: unknown[]) => { + mocks.deleteObject(...args); + return Effect.void; + }, + }, + bucketId: Option.none(), + storageIntegrationId: Option.none(), + }) + : Effect.fail(new Error("Storage offline")), + } as never); + return { + apiToHandler: (api: Parameters[0]) => { + const { handler } = HttpApiBuilder.toWebHandler( + Layer.mergeAll( + Layer.provide(api as never, Layer.mergeAll(authLayer, storageLayer)), + HttpServer.layerContext, + ) as never, + ); + return (request: Request) => handler(request); + }, + }; +}); + +const { GET, POST } = await import("@/app/api/upload/health-check/route"); describe("Cap desktop upload health-check API route", () => { - it("accepts valid upload probe buffers and rejects undersized or oversized payloads", async () => { + beforeEach(() => { + mocks.rateLimited = false; + mocks.storageHealthy = true; + mocks.putObject.mockClear(); + mocks.deleteObject.mockClear(); + }); + + it("verifies storage connectivity and rejects undersized or oversized payloads", async () => { const validPayload = new Uint8Array(1024).fill(42); const okRes = await POST( - new Request("https://cap.so/api/upload/health-check", { + new Request("http://localhost/api/upload/health-check", { method: "POST", body: validPayload, }), @@ -14,9 +78,11 @@ describe("Cap desktop upload health-check API route", () => { const okJson = await okRes.json(); expect(okJson.ok).toBe(true); expect(okJson.bytesReceived).toBe(1024); + expect(mocks.putObject).toHaveBeenCalledTimes(1); + expect(mocks.deleteObject).toHaveBeenCalledTimes(1); const smallRes = await POST( - new Request("https://cap.so/api/upload/health-check", { + new Request("http://localhost/api/upload/health-check", { method: "POST", body: new Uint8Array(4), }), @@ -24,7 +90,7 @@ describe("Cap desktop upload health-check API route", () => { expect(smallRes.status).toBe(400); const oversizedRes = await POST( - new Request("https://cap.so/api/upload/health-check", { + new Request("http://localhost/api/upload/health-check", { method: "POST", headers: { "content-length": "1048576" }, body: new Uint8Array(32), @@ -32,7 +98,21 @@ describe("Cap desktop upload health-check API route", () => { ); expect(oversizedRes.status).toBe(413); - const getRes = await GET(); + mocks.storageHealthy = false; + const degradedRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + body: validPayload, + }), + ); + expect(degradedRes.status).toBe(503); + + mocks.storageHealthy = true; + const getRes = await GET( + new Request("http://localhost/api/upload/health-check", { + method: "GET", + }), + ); expect(getRes.status).toBe(200); }); }); diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index 27f189bc0d3..dae3b42c330 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -1,3 +1,17 @@ +import { Storage } from "@cap/web-backend"; +import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect, Layer, Option } from "effect"; +import { isRateLimited } from "@/lib/rate-limit"; +import { apiToHandler } from "@/lib/server"; + export interface UploadHealthCheckResponse { ok: boolean; status: "healthy" | "degraded"; @@ -8,127 +22,194 @@ export interface UploadHealthCheckResponse { const MIN_PROBE_BYTES = 16; const MAX_PROBE_BYTES = 262_144; -const RATE_WINDOW_MS = 60_000; -const MAX_REQUESTS_PER_WINDOW = 30; - -const ipBuckets = new Map(); +const HEALTH_CHECK_RULE_ID = "rl_upload_health_check"; -function isRateLimited(request: Request, nowMs: number): boolean { - const forwarded = request.headers.get("x-forwarded-for") ?? "local"; - const clientIp = forwarded.split(",")[0]?.trim() || "local"; - const bucket = ipBuckets.get(clientIp); - if (!bucket || nowMs >= bucket.resetAt) { - ipBuckets.set(clientIp, { count: 1, resetAt: nowMs + RATE_WINDOW_MS }); - return false; - } - bucket.count += 1; - return bucket.count > MAX_REQUESTS_PER_WINDOW; -} - -async function readBoundedBytes(request: Request): Promise { - const declared = Number(request.headers.get("content-length") ?? "0"); - if (Number.isFinite(declared) && declared > MAX_PROBE_BYTES) { - return null; - } - if (!request.body) { - return 0; - } - const reader = request.body.getReader(); - let total = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - total += value?.byteLength ?? 0; - if (total > MAX_PROBE_BYTES) { - await reader.cancel(); - return null; - } - } - return total; -} +class Api extends HttpApi.make("UploadHealthCheckApi").add( + HttpApiGroup.make("root") + .add( + HttpApiEndpoint.post("probeUploadHealth")`/api/upload/health-check`.middleware( + HttpAuthMiddleware, + ), + ) + .add( + HttpApiEndpoint.get("getUploadHealth")`/api/upload/health-check`.middleware( + HttpAuthMiddleware, + ), + ), +) {} -function buildResponse( +const jsonResponse = ( payload: Omit, status: number, -): Response { - return Response.json( - { ...payload, serverTimestampMs: Date.now() }, - { status }, +) => + HttpServerResponse.text( + JSON.stringify({ ...payload, serverTimestampMs: Date.now() }), + { + status, + contentType: "application/json; charset=utf-8", + headers: { "Cache-Control": "private, no-store" }, + }, ); -} -export async function POST(request: Request): Promise { - if (isRateLimited(request, Date.now())) { - return buildResponse( - { - ok: false, - status: "degraded", - bytesReceived: 0, - message: "Rate limit exceeded for upload health probe.", - }, - 429, +const verifyUserStorage = ( + userId: CurrentUser["Type"]["id"], + organizationId: CurrentUser["Type"]["activeOrganizationId"], + probeSlice: Uint8Array, +) => + Effect.gen(function* () { + const writable = yield* Storage.getWritableAccessForUser( + userId, + organizationId, ); - } + const probeKey = `${userId}/.health-check/probe.bin`; + yield* writable.access.putObject(probeKey, probeSlice, { + contentType: "application/octet-stream", + contentLength: probeSlice.byteLength, + }); + yield* writable.access + .deleteObject(probeKey) + .pipe(Effect.catchAll(() => Effect.void)); + }); - try { - const bytesReceived = await readBoundedBytes(request); - if (bytesReceived === null) { - return buildResponse( - { - ok: false, - status: "degraded", - bytesReceived: MAX_PROBE_BYTES, - message: "Upload probe payload exceeds maximum allowed size.", - }, - 413, - ); - } +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers + .handle("probeUploadHealth", () => + Effect.gen(function* () { + const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + const limited = yield* Effect.tryPromise(() => + isRateLimited(HEALTH_CHECK_RULE_ID, { + key: user.id, + headers: new Headers(request.headers as Record), + }), + ).pipe(Effect.orElseSucceed(() => false)); + if (limited) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: "Rate limit exceeded for upload health probe.", + }, + 429, + ); + } - if (bytesReceived < MIN_PROBE_BYTES) { - return buildResponse( - { - ok: false, - status: "degraded", - bytesReceived, - message: "Upload probe payload is too small — please contact support.", - }, - 400, - ); - } + const declared = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PROBE_BYTES) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: MAX_PROBE_BYTES, + message: "Upload probe payload exceeds maximum allowed size.", + }, + 413, + ); + } - return buildResponse( - { - ok: true, - status: "healthy", - bytesReceived, - message: "Upload health check succeeded.", - }, - 200, - ); - } catch { - return buildResponse( - { - ok: false, - status: "degraded", - bytesReceived: 0, - message: - "Upload health check failed — please contact support if recording uploads fail.", - }, - 500, - ); - } -} + const bodyBuffer = yield* request.arrayBuffer.pipe( + HttpServerRequest.withMaxBodySize(Option.some(MAX_PROBE_BYTES)), + Effect.map((buf) => new Uint8Array(buf)), + Effect.catchAll(() => Effect.succeed(null)), + ); + if (bodyBuffer === null) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: MAX_PROBE_BYTES, + message: "Upload probe payload exceeds maximum allowed size.", + }, + 413, + ); + } -export async function GET(): Promise { - return buildResponse( - { - ok: true, - status: "healthy", - bytesReceived: 0, - message: "Upload health-check endpoint ready.", - }, - 200, - ); -} + if (bodyBuffer.byteLength < MIN_PROBE_BYTES) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: + "Upload probe payload is too small — please contact support.", + }, + 400, + ); + } + + const storageOk = yield* verifyUserStorage( + user.id, + user.activeOrganizationId, + bodyBuffer.subarray(0, MIN_PROBE_BYTES), + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageOk) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: + "Upload storage destination is unavailable — please contact support.", + }, + 503, + ); + } + + return jsonResponse( + { + ok: true, + status: "healthy", + bytesReceived: bodyBuffer.byteLength, + message: "Upload health check succeeded.", + }, + 200, + ); + }), + ) + .handle("getUploadHealth", () => + Effect.gen(function* () { + const user = yield* CurrentUser; + const storageOk = yield* Storage.getWritableAccessForUser( + user.id, + user.activeOrganizationId, + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageOk) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: + "Upload storage destination is unavailable — please contact support.", + }, + 503, + ); + } + return jsonResponse( + { + ok: true, + status: "healthy", + bytesReceived: 0, + message: "Upload health-check endpoint ready.", + }, + 200, + ); + }), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const POST = handler; +export const GET = handler; diff --git a/crates/recording/src/defaults.rs b/crates/recording/src/defaults.rs index 3b84fe06538..7e941f558c5 100644 --- a/crates/recording/src/defaults.rs +++ b/crates/recording/src/defaults.rs @@ -38,6 +38,7 @@ impl Default for RecordingDefaults { out_of_process_muxer: DEFAULT_OUT_OF_PROCESS_MUXER, instant_mode_max_resolution: DEFAULT_INSTANT_MODE_MAX_RESOLUTION, } + .apply_detected_upload_speed() } } @@ -57,13 +58,28 @@ impl RecordingDefaults { camera_active: bool, override_fps: Option, ) -> studio_recording::ActorBuilder { + let effective = self.apply_detected_upload_speed(); builder - .with_custom_cursor(self.custom_cursor_capture) - .with_keyboard_capture(self.capture_keyboard_events) - .with_fragmented(self.crash_recovery_recording) - .with_out_of_process_muxer(self.out_of_process_muxer) - .with_max_fps(self.studio_max_fps(camera_active, override_fps)) - .with_quality(self.studio_recording_quality) + .with_custom_cursor(effective.custom_cursor_capture) + .with_keyboard_capture(effective.capture_keyboard_events) + .with_fragmented(effective.crash_recovery_recording) + .with_out_of_process_muxer(effective.out_of_process_muxer) + .with_max_fps(effective.studio_max_fps(camera_active, override_fps)) + .with_quality(effective.studio_recording_quality) + } + + pub fn with_upload_speed_mbps(mut self, mbps: f64) -> Self { + let tier = crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps); + self.instant_mode_max_resolution = tier.max_instant_resolution(); + self.studio_recording_quality = tier.studio_quality(); + self + } + + pub fn apply_detected_upload_speed(self) -> Self { + match crate::upload_probe::latest_upload_speed_mbps() { + Some(mbps) => self.with_upload_speed_mbps(mbps), + None => self, + } } } @@ -83,13 +99,3 @@ pub fn default_studio_recording_quality() -> StudioQuality { _ => StudioQuality::Balanced, } } - -impl RecordingDefaults { - // Adjusts instant mode resolution cap and studio quality tier based on pre-recording upload throughput. - pub fn with_upload_speed_mbps(mut self, mbps: f64) -> Self { - let tier = crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps); - self.instant_mode_max_resolution = tier.max_instant_resolution(); - self.studio_recording_quality = tier.studio_quality(); - self - } -} diff --git a/crates/recording/src/upload_probe.rs b/crates/recording/src/upload_probe.rs index 0cfd0441c0f..85231d28f8b 100644 --- a/crates/recording/src/upload_probe.rs +++ b/crates/recording/src/upload_probe.rs @@ -1,5 +1,9 @@ -use std::time::Duration; +use std::{ + sync::OnceLock, + time::Duration, +}; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use crate::{ @@ -12,6 +16,12 @@ pub const HIGH_SPEED_THRESHOLD_MBPS: f64 = 15.0; pub const STANDARD_SPEED_THRESHOLD_MBPS: f64 = 5.0; pub const DEFAULT_PROBE_PAYLOAD_BYTES: usize = 64 * 1024; +static STARTUP_HEALTH: OnceLock>> = OnceLock::new(); + +fn startup_health_slot() -> &'static Mutex> { + STARTUP_HEALTH.get_or_init(|| Mutex::new(None)) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AdaptiveUploadTier { @@ -22,7 +32,6 @@ pub enum AdaptiveUploadTier { } impl AdaptiveUploadTier { - // Classifies measured upload throughput in Mbps into an adaptive recording quality tier. pub fn from_mbps(mbps: f64) -> Self { if !mbps.is_finite() || mbps <= 0.0 { return Self::Offline; @@ -36,7 +45,6 @@ impl AdaptiveUploadTier { Self::Low } - // Maps the upload tier to the maximum horizontal/vertical dimension for instant recordings. pub fn max_instant_resolution(self) -> u32 { match self { Self::High => PRO_INSTANT_MODE_MAX_RESOLUTION, @@ -45,7 +53,6 @@ impl AdaptiveUploadTier { } } - // Maps the upload tier to the encoder quality preset. pub fn studio_quality(self) -> StudioQuality { match self { Self::High => StudioQuality::Ultra, @@ -54,7 +61,6 @@ impl AdaptiveUploadTier { } } - // Produces compact indicator badge text for the desktop corner overlay. pub fn indicator_label(self, mbps: f64) -> String { match self { Self::High => format!("{mbps:.1} Mbps • HD Ready"), @@ -73,7 +79,6 @@ pub struct SpeedProbeSample { } impl SpeedProbeSample { - // Calculates upload speed in megabits per second from transferred byte count and duration. pub fn throughput_mbps(&self) -> f64 { let secs = self.elapsed.as_secs_f64(); if secs <= 0.0 || self.bytes_transferred == 0 { @@ -94,7 +99,6 @@ pub struct StartupHealthCheckStatus { pub contact_support_required: bool, } -// Evaluates startup health check outcome and builds the desktop corner indicator state. pub fn evaluate_startup_health( http_ok: bool, sample: Option, @@ -123,12 +127,29 @@ pub fn evaluate_startup_health( } } -// Prevents upload speed probes from running while a recording session is already active. +pub fn record_startup_health( + http_ok: bool, + sample: Option, +) -> StartupHealthCheckStatus { + let status = evaluate_startup_health(http_ok, sample); + *startup_health_slot().lock() = Some(status.clone()); + status +} + +pub fn latest_startup_health() -> Option { + startup_health_slot().lock().clone() +} + +pub fn latest_upload_speed_mbps() -> Option { + latest_startup_health() + .filter(|status| status.upload_ok && status.speed_mbps > 0.0) + .map(|status| status.speed_mbps) +} + pub fn should_run_speed_probe(is_recording_active: bool) -> bool { !is_recording_active } -// Builds a deterministic synthetic test pattern buffer for the startup upload health check. pub fn build_probe_payload(size_bytes: usize) -> Vec { let target_len = if size_bytes == 0 { DEFAULT_PROBE_PAYLOAD_BYTES @@ -177,13 +198,15 @@ mod tests { bytes_transferred: 1_000_000, elapsed: Duration::from_millis(500), }; - let healthy = evaluate_startup_health(true, Some(sample)); + let healthy = record_startup_health(true, Some(sample)); assert!(healthy.upload_ok); assert!(!healthy.contact_support_required); assert_eq!(healthy.tier, AdaptiveUploadTier::High); + assert_eq!(latest_upload_speed_mbps(), Some(16.0)); - let failed = evaluate_startup_health(false, None); + let failed = record_startup_health(false, None); assert!(!failed.upload_ok); assert!(failed.contact_support_required); + assert_eq!(latest_upload_speed_mbps(), None); } } From 1fc4e72cb218f5ff6f3147d860011dce33804cb0 Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:09:09 -0300 Subject: [PATCH 4/9] fix(upload): separate throughput probe from storage check and preserve user quality settings --- apps/desktop-gpui/src/power.rs | 57 ++++++++++--------- .../app/api/upload/health-check/route.test.ts | 3 +- apps/web/app/api/upload/health-check/route.ts | 43 ++++++++------ crates/recording/src/defaults.rs | 31 ++++++---- 4 files changed, 77 insertions(+), 57 deletions(-) diff --git a/apps/desktop-gpui/src/power.rs b/apps/desktop-gpui/src/power.rs index e73a4ffc7c2..cb46df20b59 100644 --- a/apps/desktop-gpui/src/power.rs +++ b/apps/desktop-gpui/src/power.rs @@ -3,17 +3,13 @@ //! (lid closed, Sleep chosen, battery empty) the recording is paused first, so //! capture never dies inside sleep, and it waits paused for the user on wake. -use std::time::Instant; +use std::time::{Duration, Instant}; -use cap_recording::{ - RecordingDefaults, - upload_probe::{ - DEFAULT_PROBE_PAYLOAD_BYTES, SpeedProbeSample, build_probe_payload, record_startup_health, - should_run_speed_probe, - }, +use cap_recording::upload_probe::{ + DEFAULT_PROBE_PAYLOAD_BYTES, SpeedProbeSample, build_probe_payload, record_startup_health, + should_run_speed_probe, }; use gpui::App; -use serde_json::Value; use crate::session::{Phase, RecordingSession}; @@ -47,42 +43,47 @@ async fn run_startup_upload_probe() { return; }; let server = crate::auth::server_url(); - let url = format!("{server}/api/upload/health-check"); + let health_url = format!("{server}/api/upload/health-check"); + let health_start = Instant::now(); + let health_req = crate::auth::apply_desktop_headers(client.get(&health_url)).bearer_auth(&token); + + let Ok(health_resp) = health_req.send().await else { + record_startup_health(false, None); + return; + }; + if !health_resp.status().is_success() { + record_startup_health(false, None); + return; + } + let request_overhead = health_start.elapsed() / 2; + + let throughput_url = format!("{health_url}?mode=throughput"); let payload = build_probe_payload(DEFAULT_PROBE_PAYLOAD_BYTES); let bytes_transferred = payload.len(); - let start = Instant::now(); - let request = crate::auth::apply_desktop_headers(client.post(url)) + let upload_start = Instant::now(); + let upload_req = crate::auth::apply_desktop_headers(client.post(throughput_url)) .bearer_auth(token) .header("Content-Type", "application/octet-stream") .body(payload); - match request.send().await { + match upload_req.send().await { Ok(response) if response.status().is_success() => { + let elapsed = upload_start + .elapsed() + .saturating_sub(request_overhead) + .max(Duration::from_millis(1)); let sample = SpeedProbeSample { bytes_transferred, - elapsed: start.elapsed(), + elapsed, }; - let status = record_startup_health(true, Some(sample)); - apply_adaptive_recording_defaults(status.speed_mbps); + record_startup_health(true, Some(sample)); } Ok(_) | Err(_) => { - record_startup_health(false, None); + record_startup_health(true, None); } } } -fn apply_adaptive_recording_defaults(speed_mbps: f64) { - let adapted = RecordingDefaults::default().with_upload_speed_mbps(speed_mbps); - let current = crate::store::GeneralSettings::load().instant_mode_max_resolution; - if adapted.instant_mode_max_resolution < current { - crate::store::set_store_setting( - "general_settings", - "instantModeMaxResolution", - Value::from(adapted.instant_mode_max_resolution), - ); - } -} - #[cfg(target_os = "macos")] mod macos { use std::{ diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts index 88447f5584c..ea3cef9cae8 100644 --- a/apps/web/app/api/upload/health-check/route.test.ts +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -16,7 +16,8 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/server", async () => { const { HttpApiBuilder, HttpServer } = await import("@effect/platform"); - const authLayer = Layer.succeed(HttpAuthMiddleware, () => + const authLayer = Layer.succeed( + HttpAuthMiddleware, Effect.succeed({ id: User.UserId.make("user_test"), email: "user@cap.so", diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index dae3b42c330..754ecd8e10d 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -140,25 +140,32 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( ); } - const storageOk = yield* verifyUserStorage( - user.id, - user.activeOrganizationId, - bodyBuffer.subarray(0, MIN_PROBE_BYTES), - ).pipe( - Effect.as(true), - Effect.catchAll(() => Effect.succeed(false)), - ); - if (!storageOk) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: bodyBuffer.byteLength, - message: - "Upload storage destination is unavailable — please contact support.", - }, - 503, + const isThroughputOnly = new URL( + request.url, + "http://localhost", + ).searchParams.get("mode") === "throughput"; + + if (!isThroughputOnly) { + const storageOk = yield* verifyUserStorage( + user.id, + user.activeOrganizationId, + bodyBuffer.subarray(0, MIN_PROBE_BYTES), + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), ); + if (!storageOk) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: + "Upload storage destination is unavailable — please contact support.", + }, + 503, + ); + } } return jsonResponse( diff --git a/crates/recording/src/defaults.rs b/crates/recording/src/defaults.rs index 7e941f558c5..51ea137833f 100644 --- a/crates/recording/src/defaults.rs +++ b/crates/recording/src/defaults.rs @@ -38,7 +38,6 @@ impl Default for RecordingDefaults { out_of_process_muxer: DEFAULT_OUT_OF_PROCESS_MUXER, instant_mode_max_resolution: DEFAULT_INSTANT_MODE_MAX_RESOLUTION, } - .apply_detected_upload_speed() } } @@ -58,23 +57,35 @@ impl RecordingDefaults { camera_active: bool, override_fps: Option, ) -> studio_recording::ActorBuilder { - let effective = self.apply_detected_upload_speed(); builder - .with_custom_cursor(effective.custom_cursor_capture) - .with_keyboard_capture(effective.capture_keyboard_events) - .with_fragmented(effective.crash_recovery_recording) - .with_out_of_process_muxer(effective.out_of_process_muxer) - .with_max_fps(effective.studio_max_fps(camera_active, override_fps)) - .with_quality(effective.studio_recording_quality) + .with_custom_cursor(self.custom_cursor_capture) + .with_keyboard_capture(self.capture_keyboard_events) + .with_fragmented(self.crash_recovery_recording) + .with_out_of_process_muxer(self.out_of_process_muxer) + .with_max_fps(self.studio_max_fps(camera_active, override_fps)) + .with_quality(self.studio_recording_quality) } pub fn with_upload_speed_mbps(mut self, mbps: f64) -> Self { let tier = crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps); - self.instant_mode_max_resolution = tier.max_instant_resolution(); - self.studio_recording_quality = tier.studio_quality(); + self.instant_mode_max_resolution = self + .instant_mode_max_resolution + .min(tier.max_instant_resolution()); self } + pub fn effective_instant_mode_max_resolution(self) -> u32 { + self.apply_detected_upload_speed() + .instant_mode_max_resolution + } + + pub fn recommended_studio_quality(self) -> StudioQuality { + match crate::upload_probe::latest_upload_speed_mbps() { + Some(mbps) => crate::upload_probe::AdaptiveUploadTier::from_mbps(mbps).studio_quality(), + None => self.studio_recording_quality, + } + } + pub fn apply_detected_upload_speed(self) -> Self { match crate::upload_probe::latest_upload_speed_mbps() { Some(mbps) => self.with_upload_speed_mbps(mbps), From 78ebdc2af27a39e6371f09722c43fdade6fcc56f Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:26:10 -0300 Subject: [PATCH 5/9] fix(upload): apply measured speed cap to recording and calibrate probe overhead --- apps/desktop-gpui/src/power.rs | 83 +++++----- apps/desktop-gpui/src/recording.rs | 148 ++++++++++++++---- .../app/api/upload/health-check/route.test.ts | 2 +- apps/web/app/api/upload/health-check/route.ts | 50 +++--- crates/recording/src/defaults.rs | 20 ++- crates/recording/src/upload_probe.rs | 27 +++- 6 files changed, 227 insertions(+), 103 deletions(-) diff --git a/apps/desktop-gpui/src/power.rs b/apps/desktop-gpui/src/power.rs index cb46df20b59..6edc7fdc030 100644 --- a/apps/desktop-gpui/src/power.rs +++ b/apps/desktop-gpui/src/power.rs @@ -6,13 +6,19 @@ use std::time::{Duration, Instant}; use cap_recording::upload_probe::{ - DEFAULT_PROBE_PAYLOAD_BYTES, SpeedProbeSample, build_probe_payload, record_startup_health, - should_run_speed_probe, + CALIBRATION_PROBE_BYTES, DEFAULT_PROBE_PAYLOAD_BYTES, SpeedProbeSample, build_probe_payload, + record_startup_health, should_run_speed_probe, }; use gpui::App; use crate::session::{Phase, RecordingSession}; +struct ProbeEndpoint<'a> { + client: &'a reqwest::Client, + url: &'a str, + token: &'a str, +} + pub(crate) fn holds_off_idle_sleep(phase: Phase) -> bool { phase != Phase::Idle } @@ -34,6 +40,26 @@ fn spawn_startup_upload_probe(cx: &mut App) { gpui_tokio::Tokio::spawn(cx, run_startup_upload_probe()).detach(); } +async fn check_storage_ready(endpoint: &ProbeEndpoint<'_>) -> bool { + let req = crate::auth::apply_desktop_headers(endpoint.client.get(endpoint.url)) + .bearer_auth(endpoint.token); + req.send() + .await + .map(|resp| resp.status().is_success()) + .unwrap_or(false) +} + +async fn measure_probe_post(endpoint: &ProbeEndpoint<'_>, payload_bytes: usize) -> Option { + let payload = build_probe_payload(payload_bytes); + let started = Instant::now(); + let req = crate::auth::apply_desktop_headers(endpoint.client.post(endpoint.url)) + .bearer_auth(endpoint.token) + .header("Content-Type", "application/octet-stream") + .body(payload); + let resp = req.send().await.ok()?; + resp.status().is_success().then(|| started.elapsed()) +} + async fn run_startup_upload_probe() { let auth = crate::store::auth_snapshot(); let Some(token) = auth.token.filter(|token| !token.is_empty()) else { @@ -44,44 +70,31 @@ async fn run_startup_upload_probe() { }; let server = crate::auth::server_url(); let health_url = format!("{server}/api/upload/health-check"); - let health_start = Instant::now(); - let health_req = crate::auth::apply_desktop_headers(client.get(&health_url)).bearer_auth(&token); + let endpoint = ProbeEndpoint { + client: &client, + url: &health_url, + token: &token, + }; - let Ok(health_resp) = health_req.send().await else { + if !check_storage_ready(&endpoint).await { record_startup_health(false, None); return; - }; - if !health_resp.status().is_success() { + } + let Some(baseline_overhead) = measure_probe_post(&endpoint, CALIBRATION_PROBE_BYTES).await else { record_startup_health(false, None); return; - } - let request_overhead = health_start.elapsed() / 2; - - let throughput_url = format!("{health_url}?mode=throughput"); - let payload = build_probe_payload(DEFAULT_PROBE_PAYLOAD_BYTES); - let bytes_transferred = payload.len(); - let upload_start = Instant::now(); - let upload_req = crate::auth::apply_desktop_headers(client.post(throughput_url)) - .bearer_auth(token) - .header("Content-Type", "application/octet-stream") - .body(payload); - - match upload_req.send().await { - Ok(response) if response.status().is_success() => { - let elapsed = upload_start - .elapsed() - .saturating_sub(request_overhead) - .max(Duration::from_millis(1)); - let sample = SpeedProbeSample { - bytes_transferred, - elapsed, - }; - record_startup_health(true, Some(sample)); - } - Ok(_) | Err(_) => { - record_startup_health(true, None); - } - } + }; + let sample = measure_probe_post(&endpoint, DEFAULT_PROBE_PAYLOAD_BYTES) + .await + .map(|upload_elapsed| { + SpeedProbeSample::from_differential( + DEFAULT_PROBE_PAYLOAD_BYTES, + CALIBRATION_PROBE_BYTES, + upload_elapsed, + baseline_overhead, + ) + }); + record_startup_health(true, sample); } #[cfg(target_os = "macos")] diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index 1b7e1cabd45..f29c021a2aa 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -804,7 +804,9 @@ async fn finish_failed_capture( stop: impl Future>, persist_stopped: impl FnOnce() -> anyhow::Result<()>, ) -> (bool, anyhow::Result) { - if let Err(error) = stop.await { + if let Err(error) = stop.await + && !cap_recording::instant_recording::capture_stopped_after_failure(&error) + { return ( false, Err(error.context("Failed capture shutdown is unconfirmed; local files preserved")), @@ -2043,13 +2045,7 @@ fn persist_instant_meta( InstantRecordingMeta, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, }; - let pretty_name = completed - .project_path - .file_stem() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or("Cap Recording") - .to_string(); + let pretty_name = project_pretty_name(&completed.project_path); let meta = match &completed.meta { InstantRecordingMeta::InProgress { .. } => InstantRecordingMeta::Failed { error: "instant recording stopped before completion".to_string(), @@ -2183,12 +2179,7 @@ fn persist_in_progress_instant_meta( recording_dir: project_path.to_path_buf(), } }; - let pretty_name = project_path - .file_stem() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or("Cap Recording") - .to_string(); + let pretty_name = project_pretty_name(project_path); let meta = cap_project::RecordingMeta { platform: Some(cap_project::Platform::default()), @@ -2518,6 +2509,11 @@ async fn start_attempt_with_upload( // the user's persisted settings, applied through the same shared // `RecordingDefaults` seam so both apps build recordings identically. let settings = crate::store::GeneralSettings::load(); + let plan_instant_cap = if crate::store::auth_snapshot().is_upgraded() { + settings.instant_mode_max_resolution + } else { + cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION + }; let defaults = cap_recording::RecordingDefaults { custom_cursor_capture: settings.custom_cursor_capture, capture_keyboard_events: settings.capture_keyboard_events, @@ -2525,16 +2521,10 @@ async fn start_attempt_with_upload( max_fps: settings.max_fps, studio_recording_quality: settings.studio_recording_quality.into(), out_of_process_muxer: settings.out_of_process_muxer, - instant_mode_max_resolution: cap_recording::DEFAULT_INSTANT_MODE_MAX_RESOLUTION, - }; - // The instant output cap is plan-gated at start time, not at settings - // time (`recording.rs:1634-1639`): free stays at the free cap even if a - // stale store value says otherwise. - let instant_max_resolution = if crate::store::auth_snapshot().is_upgraded() { - settings.instant_mode_max_resolution - } else { - cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION - }; + instant_mode_max_resolution: plan_instant_cap, + } + .apply_detected_upload_speed(); + let instant_max_resolution = defaults.instant_mode_max_resolution; #[cfg(target_os = "macos")] let excluded_windows = { @@ -2563,7 +2553,8 @@ async fn start_attempt_with_upload( let mut builder = defaults.apply_to_studio_builder( studio_recording::Actor::builder(project_dir.clone(), config.target.clone()) .with_system_audio(config.system_audio) - .with_window_only(window_only), + .with_window_only(window_only) + .with_pretty_name(project_pretty_name(&project_dir)), camera_lock.is_some(), None, ); @@ -2890,6 +2881,15 @@ fn create_project_dir( Ok(base.join(filename)) } +fn project_pretty_name(project_path: &std::path::Path) -> String { + project_path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Cap Recording") + .to_string() +} + /// Preserve GPUI's colon and slash replacement, then strip invalid characters /// as the Tauri recording and screenshot paths do. pub(crate) fn project_bundle_filename(name: &str) -> String { @@ -2919,13 +2919,14 @@ fn format_recording_project_name( let formatted = template .replace("{recording_mode}", recording_mode) .replace("{mode}", mode) - .replace("{target_kind}", target_kind) - .replace("{target_name}", &target_name); + .replace("{target_kind}", target_kind); let formatted = replace_datetime_template_token(&formatted, "date", "%Y-%m-%d", datetime); let formatted = replace_datetime_template_token(&formatted, "time", "%I:%M %p", datetime); let formatted = replace_datetime_template_token(&formatted, "moment", "%Y-%m-%d %H:%M", datetime); - formatted + // Last, so a window title keeps any placeholder it contains, as it does + // `{random}`. + formatted.replace("{target_name}", &target_name) } fn replace_random_template_tokens(input: &str) -> String { @@ -2981,8 +2982,16 @@ fn replace_datetime_template_token( if token == name { output.push_str(&datetime.format(default_format).to_string()); } else if let Some(custom_format) = token.strip_prefix(&format!("{name}:")) { + use std::fmt::Write as _; let format = cap_utils::moment_format_to_chrono(custom_format); - output.push_str(&datetime.format(&format).to_string()); + // A format chrono cannot render (a stray `%`) stays as typed, like + // an unknown placeholder: `to_string` would panic the start. + let mut rendered = String::new(); + if write!(rendered, "{}", datetime.format(&format)).is_ok() { + output.push_str(&rendered); + } else { + output.push_str(&candidate[..=end]); + } } else { output.push_str(&candidate[..=end]); } @@ -3224,6 +3233,65 @@ mod tests { assert!(name.contains("Implement {random} placeholder")); } + #[test] + fn recording_project_names_keep_a_custom_format_chrono_cannot_render() { + let timestamp = chrono::Local + .with_ymd_and_hms(2026, 8, 25, 9, 15, 0) + .single() + .unwrap(); + + assert_eq!( + format_recording_project_name( + Some("{date:YYYY 100%}-{moment:YYYY %Q}-{time:HH.mm}"), + "Example Window", + "Window", + RecordingMode::Studio, + timestamp, + ), + "{date:YYYY 100%}-{moment:YYYY %Q}-09.15" + ); + } + + #[test] + fn recording_project_names_keep_date_placeholders_in_window_title() { + let timestamp = chrono::Local + .with_ymd_and_hms(2026, 8, 25, 9, 15, 0) + .single() + .unwrap(); + + assert_eq!( + format_recording_project_name( + Some("{target_name} {date}"), + "Notes {date} {time:100%}", + "Window", + RecordingMode::Studio, + timestamp, + ), + "Notes {date} {time:100%} 2026-08-25" + ); + } + + #[test] + fn studio_and_instant_projects_are_named_by_the_project_name_template() { + let timestamp = chrono::Local + .with_ymd_and_hms(2026, 11, 6, 15, 29, 14) + .single() + .unwrap(); + let name = format_recording_project_name( + None, + "Untitled", + "Window", + RecordingMode::Studio, + timestamp, + ); + let project_path = std::path::Path::new("/recordings").join(project_bundle_filename(&name)); + + assert_eq!( + project_pretty_name(&project_path), + "Untitled (Window) 2026-11-06 03.29 PM" + ); + } + #[test] fn window_title_with_windows_reserved_characters_creates_a_project_directory() { let filename = project_bundle_filename( @@ -4924,6 +4992,28 @@ mod failed_capture_tests { assert!(format!("{:#}", result.unwrap_err()).contains("cancel failed")); } + #[tokio::test] + async fn stop_after_a_joined_pipeline_failure_confirms_capture_stopped() { + let written = AtomicBool::new(false); + let (stopped, result) = finish_failed_capture( + async { + Err(anyhow::Error::new( + cap_recording::instant_recording::CaptureStoppedAfterFailure::from(anyhow!( + "Task mux-video failed: encoder rejected a frame" + )), + )) + }, + || { + written.store(true, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert!(stopped); + assert!(written.load(Ordering::SeqCst)); + assert!(format!("{:#}", result.unwrap_err()).contains("local files preserved")); + } + #[tokio::test] async fn acknowledged_cancel_only_persists_stopped_and_keeps_failure() { let written = AtomicBool::new(false); diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts index ea3cef9cae8..da83eed489e 100644 --- a/apps/web/app/api/upload/health-check/route.test.ts +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -1,5 +1,5 @@ -import { CurrentUser, HttpAuthMiddleware, Organisation, User } from "@cap/web-domain"; import { Storage } from "@cap/web-backend"; +import { HttpAuthMiddleware, Organisation, User } from "@cap/web-domain"; import { Effect, Layer, Option } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index 754ecd8e10d..789874172cd 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -134,38 +134,31 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( status: "degraded", bytesReceived: bodyBuffer.byteLength, message: - "Upload probe payload is too small — please contact support.", + "Upload probe payload is too small, please contact support.", }, 400, ); } - const isThroughputOnly = new URL( - request.url, - "http://localhost", - ).searchParams.get("mode") === "throughput"; - - if (!isThroughputOnly) { - const storageOk = yield* verifyUserStorage( - user.id, - user.activeOrganizationId, - bodyBuffer.subarray(0, MIN_PROBE_BYTES), - ).pipe( - Effect.as(true), - Effect.catchAll(() => Effect.succeed(false)), + const storageOk = yield* verifyUserStorage( + user.id, + user.activeOrganizationId, + bodyBuffer.subarray(0, MIN_PROBE_BYTES), + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageOk) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: + "Upload storage destination is unavailable, please contact support.", + }, + 503, ); - if (!storageOk) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: bodyBuffer.byteLength, - message: - "Upload storage destination is unavailable — please contact support.", - }, - 503, - ); - } } return jsonResponse( @@ -182,9 +175,10 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( .handle("getUploadHealth", () => Effect.gen(function* () { const user = yield* CurrentUser; - const storageOk = yield* Storage.getWritableAccessForUser( + const storageOk = yield* verifyUserStorage( user.id, user.activeOrganizationId, + new Uint8Array(MIN_PROBE_BYTES), ).pipe( Effect.as(true), Effect.catchAll(() => Effect.succeed(false)), @@ -196,7 +190,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( status: "degraded", bytesReceived: 0, message: - "Upload storage destination is unavailable — please contact support.", + "Upload storage destination is unavailable, please contact support.", }, 503, ); diff --git a/crates/recording/src/defaults.rs b/crates/recording/src/defaults.rs index 51ea137833f..0a9a7e59d88 100644 --- a/crates/recording/src/defaults.rs +++ b/crates/recording/src/defaults.rs @@ -57,13 +57,14 @@ impl RecordingDefaults { camera_active: bool, override_fps: Option, ) -> studio_recording::ActorBuilder { + let effective = self.apply_detected_upload_speed(); builder - .with_custom_cursor(self.custom_cursor_capture) - .with_keyboard_capture(self.capture_keyboard_events) - .with_fragmented(self.crash_recovery_recording) - .with_out_of_process_muxer(self.out_of_process_muxer) - .with_max_fps(self.studio_max_fps(camera_active, override_fps)) - .with_quality(self.studio_recording_quality) + .with_custom_cursor(effective.custom_cursor_capture) + .with_keyboard_capture(effective.capture_keyboard_events) + .with_fragmented(effective.crash_recovery_recording) + .with_out_of_process_muxer(effective.out_of_process_muxer) + .with_max_fps(effective.studio_max_fps(camera_active, override_fps)) + .with_quality(effective.studio_recording_quality) } pub fn with_upload_speed_mbps(mut self, mbps: f64) -> Self { @@ -71,6 +72,13 @@ impl RecordingDefaults { self.instant_mode_max_resolution = self .instant_mode_max_resolution .min(tier.max_instant_resolution()); + if matches!( + tier, + crate::upload_probe::AdaptiveUploadTier::Low + | crate::upload_probe::AdaptiveUploadTier::Offline + ) { + self.studio_recording_quality = StudioQuality::Compatibility; + } self } diff --git a/crates/recording/src/upload_probe.rs b/crates/recording/src/upload_probe.rs index 85231d28f8b..3851479ccd5 100644 --- a/crates/recording/src/upload_probe.rs +++ b/crates/recording/src/upload_probe.rs @@ -14,7 +14,8 @@ use crate::{ pub const LOW_BANDWIDTH_MAX_RESOLUTION: u32 = 854; pub const HIGH_SPEED_THRESHOLD_MBPS: f64 = 15.0; pub const STANDARD_SPEED_THRESHOLD_MBPS: f64 = 5.0; -pub const DEFAULT_PROBE_PAYLOAD_BYTES: usize = 64 * 1024; +pub const CALIBRATION_PROBE_BYTES: usize = 16; +pub const DEFAULT_PROBE_PAYLOAD_BYTES: usize = 256 * 1024; static STARTUP_HEALTH: OnceLock>> = OnceLock::new(); @@ -66,7 +67,7 @@ impl AdaptiveUploadTier { Self::High => format!("{mbps:.1} Mbps • HD Ready"), Self::Standard => format!("{mbps:.1} Mbps • 720p Adaptive"), Self::Low => format!("{mbps:.1} Mbps • Low Bandwidth (480p)"), - Self::Offline => "Upload check failed — contact support".to_string(), + Self::Offline => "Upload check failed, contact support".to_string(), } } } @@ -79,6 +80,22 @@ pub struct SpeedProbeSample { } impl SpeedProbeSample { + pub fn from_differential( + payload_bytes: usize, + calibration_bytes: usize, + upload_elapsed: Duration, + baseline_overhead: Duration, + ) -> Self { + let bytes_transferred = payload_bytes.saturating_sub(calibration_bytes).max(1); + let elapsed = upload_elapsed + .saturating_sub(baseline_overhead) + .max(Duration::from_millis(1)); + Self { + bytes_transferred, + elapsed, + } + } + pub fn throughput_mbps(&self) -> f64 { let secs = self.elapsed.as_secs_f64(); if secs <= 0.0 || self.bytes_transferred == 0 { @@ -109,12 +126,14 @@ pub fn evaluate_startup_health( speed_mbps: 0.0, tier: AdaptiveUploadTier::Offline, indicator_message: - "Upload health check failed — please contact support".to_string(), + "Upload health check failed, please contact support".to_string(), contact_support_required: true, }; } - let mbps = sample.map(|s| s.throughput_mbps()).unwrap_or(0.0); + let mbps = sample + .map(|s| s.throughput_mbps()) + .unwrap_or(HIGH_SPEED_THRESHOLD_MBPS); let tier = AdaptiveUploadTier::from_mbps(mbps); let upload_ok = tier != AdaptiveUploadTier::Offline; From a30b57b3e29e70db43ed33ae1bb7469f2d628e1a Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:41:14 -0300 Subject: [PATCH 6/9] fix(upload): enforce GET rate limit, fail unmeasured probes, and restore base recording.rs --- apps/desktop-gpui/src/recording.rs | 146 ++++-------------- .../app/api/upload/health-check/route.test.ts | 10 ++ apps/web/app/api/upload/health-check/route.ts | 44 ++++-- crates/recording/src/upload_probe.rs | 14 +- 4 files changed, 76 insertions(+), 138 deletions(-) diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index f29c021a2aa..cf9434d6cd6 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -804,9 +804,7 @@ async fn finish_failed_capture( stop: impl Future>, persist_stopped: impl FnOnce() -> anyhow::Result<()>, ) -> (bool, anyhow::Result) { - if let Err(error) = stop.await - && !cap_recording::instant_recording::capture_stopped_after_failure(&error) - { + if let Err(error) = stop.await { return ( false, Err(error.context("Failed capture shutdown is unconfirmed; local files preserved")), @@ -2045,7 +2043,13 @@ fn persist_instant_meta( InstantRecordingMeta, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, }; - let pretty_name = project_pretty_name(&completed.project_path); + let pretty_name = completed + .project_path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Cap Recording") + .to_string(); let meta = match &completed.meta { InstantRecordingMeta::InProgress { .. } => InstantRecordingMeta::Failed { error: "instant recording stopped before completion".to_string(), @@ -2179,7 +2183,12 @@ fn persist_in_progress_instant_meta( recording_dir: project_path.to_path_buf(), } }; - let pretty_name = project_pretty_name(project_path); + let pretty_name = project_path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Cap Recording") + .to_string(); let meta = cap_project::RecordingMeta { platform: Some(cap_project::Platform::default()), @@ -2509,11 +2518,6 @@ async fn start_attempt_with_upload( // the user's persisted settings, applied through the same shared // `RecordingDefaults` seam so both apps build recordings identically. let settings = crate::store::GeneralSettings::load(); - let plan_instant_cap = if crate::store::auth_snapshot().is_upgraded() { - settings.instant_mode_max_resolution - } else { - cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION - }; let defaults = cap_recording::RecordingDefaults { custom_cursor_capture: settings.custom_cursor_capture, capture_keyboard_events: settings.capture_keyboard_events, @@ -2521,10 +2525,18 @@ async fn start_attempt_with_upload( max_fps: settings.max_fps, studio_recording_quality: settings.studio_recording_quality.into(), out_of_process_muxer: settings.out_of_process_muxer, - instant_mode_max_resolution: plan_instant_cap, + instant_mode_max_resolution: cap_recording::DEFAULT_INSTANT_MODE_MAX_RESOLUTION, } .apply_detected_upload_speed(); - let instant_max_resolution = defaults.instant_mode_max_resolution; + // The instant output cap is plan-gated at start time, not at settings + // time (`recording.rs:1634-1639`): free stays at the free cap even if a + // stale store value says otherwise. + let instant_max_resolution = if crate::store::auth_snapshot().is_upgraded() { + settings.instant_mode_max_resolution + } else { + cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION + } + .min(defaults.instant_mode_max_resolution); #[cfg(target_os = "macos")] let excluded_windows = { @@ -2553,8 +2565,7 @@ async fn start_attempt_with_upload( let mut builder = defaults.apply_to_studio_builder( studio_recording::Actor::builder(project_dir.clone(), config.target.clone()) .with_system_audio(config.system_audio) - .with_window_only(window_only) - .with_pretty_name(project_pretty_name(&project_dir)), + .with_window_only(window_only), camera_lock.is_some(), None, ); @@ -2881,15 +2892,6 @@ fn create_project_dir( Ok(base.join(filename)) } -fn project_pretty_name(project_path: &std::path::Path) -> String { - project_path - .file_stem() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or("Cap Recording") - .to_string() -} - /// Preserve GPUI's colon and slash replacement, then strip invalid characters /// as the Tauri recording and screenshot paths do. pub(crate) fn project_bundle_filename(name: &str) -> String { @@ -2919,14 +2921,13 @@ fn format_recording_project_name( let formatted = template .replace("{recording_mode}", recording_mode) .replace("{mode}", mode) - .replace("{target_kind}", target_kind); + .replace("{target_kind}", target_kind) + .replace("{target_name}", &target_name); let formatted = replace_datetime_template_token(&formatted, "date", "%Y-%m-%d", datetime); let formatted = replace_datetime_template_token(&formatted, "time", "%I:%M %p", datetime); let formatted = replace_datetime_template_token(&formatted, "moment", "%Y-%m-%d %H:%M", datetime); - // Last, so a window title keeps any placeholder it contains, as it does - // `{random}`. - formatted.replace("{target_name}", &target_name) + formatted } fn replace_random_template_tokens(input: &str) -> String { @@ -2982,16 +2983,8 @@ fn replace_datetime_template_token( if token == name { output.push_str(&datetime.format(default_format).to_string()); } else if let Some(custom_format) = token.strip_prefix(&format!("{name}:")) { - use std::fmt::Write as _; let format = cap_utils::moment_format_to_chrono(custom_format); - // A format chrono cannot render (a stray `%`) stays as typed, like - // an unknown placeholder: `to_string` would panic the start. - let mut rendered = String::new(); - if write!(rendered, "{}", datetime.format(&format)).is_ok() { - output.push_str(&rendered); - } else { - output.push_str(&candidate[..=end]); - } + output.push_str(&datetime.format(&format).to_string()); } else { output.push_str(&candidate[..=end]); } @@ -3233,65 +3226,6 @@ mod tests { assert!(name.contains("Implement {random} placeholder")); } - #[test] - fn recording_project_names_keep_a_custom_format_chrono_cannot_render() { - let timestamp = chrono::Local - .with_ymd_and_hms(2026, 8, 25, 9, 15, 0) - .single() - .unwrap(); - - assert_eq!( - format_recording_project_name( - Some("{date:YYYY 100%}-{moment:YYYY %Q}-{time:HH.mm}"), - "Example Window", - "Window", - RecordingMode::Studio, - timestamp, - ), - "{date:YYYY 100%}-{moment:YYYY %Q}-09.15" - ); - } - - #[test] - fn recording_project_names_keep_date_placeholders_in_window_title() { - let timestamp = chrono::Local - .with_ymd_and_hms(2026, 8, 25, 9, 15, 0) - .single() - .unwrap(); - - assert_eq!( - format_recording_project_name( - Some("{target_name} {date}"), - "Notes {date} {time:100%}", - "Window", - RecordingMode::Studio, - timestamp, - ), - "Notes {date} {time:100%} 2026-08-25" - ); - } - - #[test] - fn studio_and_instant_projects_are_named_by_the_project_name_template() { - let timestamp = chrono::Local - .with_ymd_and_hms(2026, 11, 6, 15, 29, 14) - .single() - .unwrap(); - let name = format_recording_project_name( - None, - "Untitled", - "Window", - RecordingMode::Studio, - timestamp, - ); - let project_path = std::path::Path::new("/recordings").join(project_bundle_filename(&name)); - - assert_eq!( - project_pretty_name(&project_path), - "Untitled (Window) 2026-11-06 03.29 PM" - ); - } - #[test] fn window_title_with_windows_reserved_characters_creates_a_project_directory() { let filename = project_bundle_filename( @@ -4992,28 +4926,6 @@ mod failed_capture_tests { assert!(format!("{:#}", result.unwrap_err()).contains("cancel failed")); } - #[tokio::test] - async fn stop_after_a_joined_pipeline_failure_confirms_capture_stopped() { - let written = AtomicBool::new(false); - let (stopped, result) = finish_failed_capture( - async { - Err(anyhow::Error::new( - cap_recording::instant_recording::CaptureStoppedAfterFailure::from(anyhow!( - "Task mux-video failed: encoder rejected a frame" - )), - )) - }, - || { - written.store(true, Ordering::SeqCst); - Ok(()) - }, - ) - .await; - assert!(stopped); - assert!(written.load(Ordering::SeqCst)); - assert!(format!("{:#}", result.unwrap_err()).contains("local files preserved")); - } - #[tokio::test] async fn acknowledged_cancel_only_persists_stopped_and_keeps_failure() { let written = AtomicBool::new(false); diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts index da83eed489e..280ba05c8ac 100644 --- a/apps/web/app/api/upload/health-check/route.test.ts +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -115,5 +115,15 @@ describe("Cap desktop upload health-check API route", () => { }), ); expect(getRes.status).toBe(200); + + mocks.putObject.mockClear(); + mocks.rateLimited = true; + const limitedGetRes = await GET( + new Request("http://localhost/api/upload/health-check", { + method: "GET", + }), + ); + expect(limitedGetRes.status).toBe(429); + expect(mocks.putObject).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index 789874172cd..02a5adf0840 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -71,6 +71,28 @@ const verifyUserStorage = ( .pipe(Effect.catchAll(() => Effect.void)); }); +const checkProbeRateLimit = ( + userId: CurrentUser["Type"]["id"], + request: HttpServerRequest.HttpServerRequest, +) => + Effect.tryPromise(() => + isRateLimited(HEALTH_CHECK_RULE_ID, { + key: userId, + headers: new Headers(request.headers as Record), + }), + ).pipe(Effect.orElseSucceed(() => false)); + +const rateLimitedResponse = () => + jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: "Rate limit exceeded for upload health probe.", + }, + 429, + ); + const ApiLive = HttpApiBuilder.api(Api).pipe( Layer.provide( HttpApiBuilder.group(Api, "root", (handlers) => @@ -79,22 +101,8 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( Effect.gen(function* () { const user = yield* CurrentUser; const request = yield* HttpServerRequest.HttpServerRequest; - const limited = yield* Effect.tryPromise(() => - isRateLimited(HEALTH_CHECK_RULE_ID, { - key: user.id, - headers: new Headers(request.headers as Record), - }), - ).pipe(Effect.orElseSucceed(() => false)); - if (limited) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: 0, - message: "Rate limit exceeded for upload health probe.", - }, - 429, - ); + if (yield* checkProbeRateLimit(user.id, request)) { + return rateLimitedResponse(); } const declared = Number(request.headers["content-length"] ?? "0"); @@ -175,6 +183,10 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( .handle("getUploadHealth", () => Effect.gen(function* () { const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + if (yield* checkProbeRateLimit(user.id, request)) { + return rateLimitedResponse(); + } const storageOk = yield* verifyUserStorage( user.id, user.activeOrganizationId, diff --git a/crates/recording/src/upload_probe.rs b/crates/recording/src/upload_probe.rs index 3851479ccd5..3d9e8b19c55 100644 --- a/crates/recording/src/upload_probe.rs +++ b/crates/recording/src/upload_probe.rs @@ -120,7 +120,7 @@ pub fn evaluate_startup_health( http_ok: bool, sample: Option, ) -> StartupHealthCheckStatus { - if !http_ok { + let Some(sample) = sample.filter(|_| http_ok) else { return StartupHealthCheckStatus { upload_ok: false, speed_mbps: 0.0, @@ -129,11 +129,9 @@ pub fn evaluate_startup_health( "Upload health check failed, please contact support".to_string(), contact_support_required: true, }; - } + }; - let mbps = sample - .map(|s| s.throughput_mbps()) - .unwrap_or(HIGH_SPEED_THRESHOLD_MBPS); + let mbps = sample.throughput_mbps(); let tier = AdaptiveUploadTier::from_mbps(mbps); let upload_ok = tier != AdaptiveUploadTier::Offline; @@ -223,6 +221,12 @@ mod tests { assert_eq!(healthy.tier, AdaptiveUploadTier::High); assert_eq!(latest_upload_speed_mbps(), Some(16.0)); + let unmeasured = record_startup_health(true, None); + assert!(!unmeasured.upload_ok); + assert!(unmeasured.contact_support_required); + assert_eq!(unmeasured.tier, AdaptiveUploadTier::Offline); + assert_eq!(latest_upload_speed_mbps(), None); + let failed = record_startup_health(false, None); assert!(!failed.upload_ok); assert!(failed.contact_support_required); From ac2a902a8979a0b74cf8fbaed7910c53afb46c20 Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:57:40 -0300 Subject: [PATCH 7/9] fix(recording): preserve upgraded instant resolution when upload speed is unmeasured --- apps/desktop-gpui/src/recording.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index cf9434d6cd6..2023fa651b4 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -2518,6 +2518,14 @@ async fn start_attempt_with_upload( // the user's persisted settings, applied through the same shared // `RecordingDefaults` seam so both apps build recordings identically. let settings = crate::store::GeneralSettings::load(); + // The instant output cap is plan-gated at start time, not at settings + // time (`recording.rs:1634-1639`): free stays at the free cap even if a + // stale store value says otherwise. + let base_instant_max_resolution = if crate::store::auth_snapshot().is_upgraded() { + settings.instant_mode_max_resolution + } else { + cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION + }; let defaults = cap_recording::RecordingDefaults { custom_cursor_capture: settings.custom_cursor_capture, capture_keyboard_events: settings.capture_keyboard_events, @@ -2525,18 +2533,10 @@ async fn start_attempt_with_upload( max_fps: settings.max_fps, studio_recording_quality: settings.studio_recording_quality.into(), out_of_process_muxer: settings.out_of_process_muxer, - instant_mode_max_resolution: cap_recording::DEFAULT_INSTANT_MODE_MAX_RESOLUTION, + instant_mode_max_resolution: base_instant_max_resolution, } .apply_detected_upload_speed(); - // The instant output cap is plan-gated at start time, not at settings - // time (`recording.rs:1634-1639`): free stays at the free cap even if a - // stale store value says otherwise. - let instant_max_resolution = if crate::store::auth_snapshot().is_upgraded() { - settings.instant_mode_max_resolution - } else { - cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION - } - .min(defaults.instant_mode_max_resolution); + let instant_max_resolution = defaults.instant_mode_max_resolution; #[cfg(target_os = "macos")] let excluded_windows = { From 78d8f251a59ff749f70ab5e7c447be25d5bddba3 Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:49:51 -0300 Subject: [PATCH 8/9] fix(web): keep GET health check read-only, enforce same-origin, and propagate probe delete errors --- .../app/api/upload/health-check/route.test.ts | 37 ++- apps/web/app/api/upload/health-check/route.ts | 294 ++++++++++-------- 2 files changed, 199 insertions(+), 132 deletions(-) diff --git a/apps/web/app/api/upload/health-check/route.test.ts b/apps/web/app/api/upload/health-check/route.test.ts index 280ba05c8ac..b86a90e2ee0 100644 --- a/apps/web/app/api/upload/health-check/route.test.ts +++ b/apps/web/app/api/upload/health-check/route.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ rateLimited: false, storageHealthy: true, + deleteHealthy: true, putObject: vi.fn(), deleteObject: vi.fn(), })); @@ -36,7 +37,9 @@ vi.mock("@/lib/server", async () => { }, deleteObject: (...args: unknown[]) => { mocks.deleteObject(...args); - return Effect.void; + return mocks.deleteHealthy + ? Effect.void + : Effect.fail(new Error("Delete failed")); }, }, bucketId: Option.none(), @@ -63,11 +66,12 @@ describe("Cap desktop upload health-check API route", () => { beforeEach(() => { mocks.rateLimited = false; mocks.storageHealthy = true; + mocks.deleteHealthy = true; mocks.putObject.mockClear(); mocks.deleteObject.mockClear(); }); - it("verifies storage connectivity and rejects undersized or oversized payloads", async () => { + it("verifies POST storage write/delete and rejects invalid or cross-site probes", async () => { const validPayload = new Uint8Array(1024).fill(42); const okRes = await POST( new Request("http://localhost/api/upload/health-check", { @@ -82,6 +86,15 @@ describe("Cap desktop upload health-check API route", () => { expect(mocks.putObject).toHaveBeenCalledTimes(1); expect(mocks.deleteObject).toHaveBeenCalledTimes(1); + const crossSiteRes = await POST( + new Request("http://localhost/api/upload/health-check", { + method: "POST", + headers: { "sec-fetch-site": "cross-site" }, + body: validPayload, + }), + ); + expect(crossSiteRes.status).toBe(403); + const smallRes = await POST( new Request("http://localhost/api/upload/health-check", { method: "POST", @@ -99,24 +112,34 @@ describe("Cap desktop upload health-check API route", () => { ); expect(oversizedRes.status).toBe(413); - mocks.storageHealthy = false; - const degradedRes = await POST( + mocks.deleteHealthy = false; + const deleteFailedRes = await POST( new Request("http://localhost/api/upload/health-check", { method: "POST", body: validPayload, }), ); - expect(degradedRes.status).toBe(503); + expect(deleteFailedRes.status).toBe(503); + }); - mocks.storageHealthy = true; + it("keeps GET strictly read-only without bucket mutations", async () => { const getRes = await GET( new Request("http://localhost/api/upload/health-check", { method: "GET", }), ); expect(getRes.status).toBe(200); + expect(mocks.putObject).not.toHaveBeenCalled(); + expect(mocks.deleteObject).not.toHaveBeenCalled(); + + const crossSiteGetRes = await GET( + new Request("http://localhost/api/upload/health-check", { + method: "GET", + headers: { "sec-fetch-site": "cross-site" }, + }), + ); + expect(crossSiteGetRes.status).toBe(403); - mocks.putObject.mockClear(); mocks.rateLimited = true; const limitedGetRes = await GET( new Request("http://localhost/api/upload/health-check", { diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index 02a5adf0840..aa1a9d610a3 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -51,7 +51,29 @@ const jsonResponse = ( }, ); -const verifyUserStorage = ( +// Enforces an explicit same-origin check when browser fetch metadata or Origin +// headers are present so cross-site pages cannot trigger probes via SameSite=None cookies. +const isCrossSiteRequest = (request: HttpServerRequest.HttpServerRequest): boolean => { + const headers = request.headers as Record; + const fetchSite = (headers["sec-fetch-site"] ?? "").toLowerCase(); + if (fetchSite === "cross-site") { + return true; + } + const origin = headers.origin; + const host = headers.host; + if (!origin || !host) { + return false; + } + try { + return new URL(origin).host !== host; + } catch { + return true; + } +}; + +// Executes the write-and-delete storage probe exclusively on POST and propagates +// deleteObject failures instead of swallowing them. +const verifyUserStorageWrite = ( userId: CurrentUser["Type"]["id"], organizationId: CurrentUser["Type"]["activeOrganizationId"], probeSlice: Uint8Array, @@ -66,9 +88,16 @@ const verifyUserStorage = ( contentType: "application/octet-stream", contentLength: probeSlice.byteLength, }); - yield* writable.access - .deleteObject(probeKey) - .pipe(Effect.catchAll(() => Effect.void)); + yield* writable.access.deleteObject(probeKey); + }); + +// Verifies storage provider configuration on GET without performing any bucket write or delete. +const verifyUserStorageReadyReadOnly = ( + userId: CurrentUser["Type"]["id"], + organizationId: CurrentUser["Type"]["activeOrganizationId"], +) => + Effect.gen(function* () { + yield* Storage.getWritableAccessForUser(userId, organizationId); }); const checkProbeRateLimit = ( @@ -93,131 +122,146 @@ const rateLimitedResponse = () => 429, ); +const crossSiteForbiddenResponse = () => + jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: "Cross-site upload health probe requests are not permitted.", + }, + 403, + ); + +const readBoundedProbeBody = (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const declared = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PROBE_BYTES) { + return null; + } + return yield* request.arrayBuffer.pipe( + HttpServerRequest.withMaxBodySize(Option.some(MAX_PROBE_BYTES)), + Effect.map((buf) => new Uint8Array(buf)), + Effect.catchAll(() => Effect.succeed(null)), + ); + }); + +const handleProbePost = () => + Effect.gen(function* () { + const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + if (isCrossSiteRequest(request)) { + return crossSiteForbiddenResponse(); + } + if (yield* checkProbeRateLimit(user.id, request)) { + return rateLimitedResponse(); + } + + const bodyBuffer = yield* readBoundedProbeBody(request); + if (bodyBuffer === null) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: MAX_PROBE_BYTES, + message: "Upload probe payload exceeds maximum allowed size.", + }, + 413, + ); + } + + if (bodyBuffer.byteLength < MIN_PROBE_BYTES) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: "Upload probe payload is too small, please contact support.", + }, + 400, + ); + } + + const storageOk = yield* verifyUserStorageWrite( + user.id, + user.activeOrganizationId, + bodyBuffer.subarray(0, MIN_PROBE_BYTES), + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageOk) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: bodyBuffer.byteLength, + message: + "Upload storage destination is unavailable, please contact support.", + }, + 503, + ); + } + + return jsonResponse( + { + ok: true, + status: "healthy", + bytesReceived: bodyBuffer.byteLength, + message: "Upload health check succeeded.", + }, + 200, + ); + }); + +const handleHealthGet = () => + Effect.gen(function* () { + const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + if (isCrossSiteRequest(request)) { + return crossSiteForbiddenResponse(); + } + if (yield* checkProbeRateLimit(user.id, request)) { + return rateLimitedResponse(); + } + + const storageReady = yield* verifyUserStorageReadyReadOnly( + user.id, + user.activeOrganizationId, + ).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!storageReady) { + return jsonResponse( + { + ok: false, + status: "degraded", + bytesReceived: 0, + message: + "Upload storage destination is unavailable, please contact support.", + }, + 503, + ); + } + + return jsonResponse( + { + ok: true, + status: "healthy", + bytesReceived: 0, + message: "Upload health-check endpoint ready.", + }, + 200, + ); + }); + const ApiLive = HttpApiBuilder.api(Api).pipe( Layer.provide( HttpApiBuilder.group(Api, "root", (handlers) => handlers - .handle("probeUploadHealth", () => - Effect.gen(function* () { - const user = yield* CurrentUser; - const request = yield* HttpServerRequest.HttpServerRequest; - if (yield* checkProbeRateLimit(user.id, request)) { - return rateLimitedResponse(); - } - - const declared = Number(request.headers["content-length"] ?? "0"); - if (Number.isFinite(declared) && declared > MAX_PROBE_BYTES) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: MAX_PROBE_BYTES, - message: "Upload probe payload exceeds maximum allowed size.", - }, - 413, - ); - } - - const bodyBuffer = yield* request.arrayBuffer.pipe( - HttpServerRequest.withMaxBodySize(Option.some(MAX_PROBE_BYTES)), - Effect.map((buf) => new Uint8Array(buf)), - Effect.catchAll(() => Effect.succeed(null)), - ); - if (bodyBuffer === null) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: MAX_PROBE_BYTES, - message: "Upload probe payload exceeds maximum allowed size.", - }, - 413, - ); - } - - if (bodyBuffer.byteLength < MIN_PROBE_BYTES) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: bodyBuffer.byteLength, - message: - "Upload probe payload is too small, please contact support.", - }, - 400, - ); - } - - const storageOk = yield* verifyUserStorage( - user.id, - user.activeOrganizationId, - bodyBuffer.subarray(0, MIN_PROBE_BYTES), - ).pipe( - Effect.as(true), - Effect.catchAll(() => Effect.succeed(false)), - ); - if (!storageOk) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: bodyBuffer.byteLength, - message: - "Upload storage destination is unavailable, please contact support.", - }, - 503, - ); - } - - return jsonResponse( - { - ok: true, - status: "healthy", - bytesReceived: bodyBuffer.byteLength, - message: "Upload health check succeeded.", - }, - 200, - ); - }), - ) - .handle("getUploadHealth", () => - Effect.gen(function* () { - const user = yield* CurrentUser; - const request = yield* HttpServerRequest.HttpServerRequest; - if (yield* checkProbeRateLimit(user.id, request)) { - return rateLimitedResponse(); - } - const storageOk = yield* verifyUserStorage( - user.id, - user.activeOrganizationId, - new Uint8Array(MIN_PROBE_BYTES), - ).pipe( - Effect.as(true), - Effect.catchAll(() => Effect.succeed(false)), - ); - if (!storageOk) { - return jsonResponse( - { - ok: false, - status: "degraded", - bytesReceived: 0, - message: - "Upload storage destination is unavailable, please contact support.", - }, - 503, - ); - } - return jsonResponse( - { - ok: true, - status: "healthy", - bytesReceived: 0, - message: "Upload health-check endpoint ready.", - }, - 200, - ); - }), - ), + .handle("probeUploadHealth", handleProbePost) + .handle("getUploadHealth", handleHealthGet), ), ), ); From 5b331aa32a3b64557533bf412007a5c828f2cbc2 Mon Sep 17 00:00:00 2001 From: Filipe Ribeiro <45768814+frds3445@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:54:29 -0300 Subject: [PATCH 9/9] style(web): remove redundant helper comments in health-check route --- apps/web/app/api/upload/health-check/route.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/app/api/upload/health-check/route.ts b/apps/web/app/api/upload/health-check/route.ts index aa1a9d610a3..1f925c56fa3 100644 --- a/apps/web/app/api/upload/health-check/route.ts +++ b/apps/web/app/api/upload/health-check/route.ts @@ -51,8 +51,6 @@ const jsonResponse = ( }, ); -// Enforces an explicit same-origin check when browser fetch metadata or Origin -// headers are present so cross-site pages cannot trigger probes via SameSite=None cookies. const isCrossSiteRequest = (request: HttpServerRequest.HttpServerRequest): boolean => { const headers = request.headers as Record; const fetchSite = (headers["sec-fetch-site"] ?? "").toLowerCase(); @@ -71,8 +69,6 @@ const isCrossSiteRequest = (request: HttpServerRequest.HttpServerRequest): boole } }; -// Executes the write-and-delete storage probe exclusively on POST and propagates -// deleteObject failures instead of swallowing them. const verifyUserStorageWrite = ( userId: CurrentUser["Type"]["id"], organizationId: CurrentUser["Type"]["activeOrganizationId"], @@ -91,7 +87,6 @@ const verifyUserStorageWrite = ( yield* writable.access.deleteObject(probeKey); }); -// Verifies storage provider configuration on GET without performing any bucket write or delete. const verifyUserStorageReadyReadOnly = ( userId: CurrentUser["Type"]["id"], organizationId: CurrentUser["Type"]["activeOrganizationId"],