diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 52a39b3becf..41263f7589b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -40,6 +40,7 @@ mod thumbnails; mod tray; mod update_project_names; mod upload; +mod upload_health; pub mod web_api; mod window_exclusion; mod window_position_persistence; @@ -4189,6 +4190,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recording::restart_recording, recording::delete_recording, recording::take_screenshot, + upload_health::get_upload_health_status, + upload_health::refresh_upload_health_status, recording::import_current_desktop_background, recording::list_cameras, recording::get_camera_formats, @@ -4492,6 +4495,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.manage(http_client::RetryableHttpClient::default()); app.manage(PendingScreenshots::default()); app.manage(FinalizingRecordings::default()); + app.manage(upload_health::UploadHealthCache::default()); #[cfg(unix)] { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 564e8f82ec5..4611feda54e 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1326,7 +1326,7 @@ pub async fn start_recording( let Some(auth) = AuthStore::get(&app).ok().flatten() else { return Err("Please sign in to use instant recording".to_string()); }; - let instant_mode_max_resolution = if auth.is_upgraded() { + let mut instant_mode_max_resolution = if auth.is_upgraded() { general_settings .map_or(cap_recording::PRO_INSTANT_MODE_MAX_RESOLUTION, |settings| { settings.instant_mode_max_resolution @@ -1334,6 +1334,22 @@ pub async fn start_recording( } else { cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION }; + + if let Some(upload_health_cap) = + crate::upload_health::cached_instant_resolution_cap(&app).await + { + let capped_resolution = instant_mode_max_resolution.min(upload_health_cap); + if capped_resolution < instant_mode_max_resolution { + info!( + configured_resolution = instant_mode_max_resolution, + upload_health_cap = upload_health_cap, + capped_resolution = capped_resolution, + "Capping instant recording resolution based on cached upload health" + ); + } + instant_mode_max_resolution = capped_resolution; + } + let upload_mode = if matches!(inputs.capture_target, ScreenCaptureTarget::CameraOnly) { "desktopMP4" } else { diff --git a/apps/desktop/src-tauri/src/upload_health.rs b/apps/desktop/src-tauri/src/upload_health.rs new file mode 100644 index 00000000000..1f94c7f9a41 --- /dev/null +++ b/apps/desktop/src-tauri/src/upload_health.rs @@ -0,0 +1,392 @@ +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager, State}; +use tokio::sync::Mutex; +use tracing::{debug, warn}; + +use crate::{ + App, MutableState, + web_api::{AuthedApiError, ManagerExt}, +}; + +const PROBE_BYTES: usize = 256 * 1024; +const HEALTH_FRESH_FOR: Duration = Duration::from_secs(10 * 60); +const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +const HEALTH_RTT_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum UploadHealthKind { + Unknown, + Healthy, + Slow, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UploadHealthStatus { + pub kind: UploadHealthKind, + pub upload_mbps: Option, + pub max_instant_resolution: Option, + pub checked_at_unix_ms: Option, + pub stale: bool, + pub message: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UploadHealthProbeResponse { + received_bytes: usize, +} + +#[derive(Debug, Clone)] +struct UploadHealthSnapshot { + kind: UploadHealthKind, + upload_mbps: Option, + max_instant_resolution: Option, + checked_at_unix_ms: Option, + recorded_at: Option, + message: String, +} + +impl Default for UploadHealthSnapshot { + fn default() -> Self { + Self { + kind: UploadHealthKind::Unknown, + upload_mbps: None, + max_instant_resolution: None, + checked_at_unix_ms: None, + recorded_at: None, + message: "Upload health has not been checked yet.".to_string(), + } + } +} + +impl UploadHealthSnapshot { + fn is_stale(&self) -> bool { + self.recorded_at + .is_none_or(|recorded_at| recorded_at.elapsed() > HEALTH_FRESH_FOR) + } + + fn status(&self) -> UploadHealthStatus { + UploadHealthStatus { + kind: self.kind, + upload_mbps: self.upload_mbps, + max_instant_resolution: self.max_instant_resolution, + checked_at_unix_ms: self.checked_at_unix_ms, + stale: self.is_stale(), + message: self.message.clone(), + } + } +} + +#[derive(Default)] +pub struct UploadHealthCache { + snapshot: Mutex, + probe: Mutex<()>, +} + +impl UploadHealthCache { + async fn update(&self, snapshot: UploadHealthSnapshot) -> UploadHealthStatus { + let mut guard = self.snapshot.lock().await; + *guard = snapshot; + guard.status() + } + + pub async fn status(&self) -> UploadHealthStatus { + self.snapshot.lock().await.status() + } + + async fn fresh_instant_resolution_cap(&self) -> Option { + let guard = self.snapshot.lock().await; + if guard.is_stale() { + return None; + } + + match guard.kind { + UploadHealthKind::Healthy | UploadHealthKind::Slow | UploadHealthKind::Unavailable => { + guard.max_instant_resolution + } + UploadHealthKind::Unknown => None, + } + } +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub fn max_resolution_for_upload_mbps(upload_mbps: f64) -> u32 { + if upload_mbps >= 35.0 { + 3840 + } else if upload_mbps >= 18.0 { + 2560 + } else if upload_mbps >= 6.0 { + 1920 + } else { + 1280 + } +} + +fn health_kind_for_resolution(max_resolution: u32) -> UploadHealthKind { + if max_resolution >= 1920 { + UploadHealthKind::Healthy + } else { + UploadHealthKind::Slow + } +} + +fn probe_payload() -> Vec { + vec![0x63; PROBE_BYTES] +} + +async fn measure_probe_rtt(app: &AppHandle) -> Option { + let started = Instant::now(); + let response = app + .authed_api_request("/api/desktop/upload-health", |client, url| { + client.head(url).timeout(HEALTH_RTT_TIMEOUT) + }) + .await; + + match response { + Ok(response) if response.status().is_success() => Some(started.elapsed()), + Ok(response) => { + let status = response.status(); + debug!(%status, "Upload health RTT probe returned a non-success status"); + None + } + Err(err) => { + debug!(error = %err, "Upload health RTT probe failed"); + None + } + } +} + +fn upload_elapsed_after_rtt(total_elapsed: Duration, rtt_elapsed: Option) -> Duration { + let total_elapsed = total_elapsed.max(Duration::from_millis(1)); + + let Some(rtt_elapsed) = rtt_elapsed else { + return total_elapsed; + }; + + match total_elapsed.checked_sub(rtt_elapsed) { + Some(adjusted_elapsed) if adjusted_elapsed >= Duration::from_millis(50) => adjusted_elapsed, + _ => total_elapsed, + } +} + +fn upload_mbps_for_bytes(byte_count: usize, elapsed: Duration) -> f64 { + (byte_count as f64 * 8.0) / elapsed.max(Duration::from_millis(1)).as_secs_f64() / 1_000_000.0 +} + +async fn run_probe(app: &AppHandle) -> UploadHealthSnapshot { + let rtt_elapsed = measure_probe_rtt(app).await; + let payload = probe_payload(); + let payload_len = payload.len(); + + let started = Instant::now(); + let response = app + .authed_api_request("/api/desktop/upload-health", |client, url| { + client + .post(url) + .timeout(HEALTH_REQUEST_TIMEOUT) + .header("Content-Type", "application/octet-stream") + .body(payload) + }) + .await; + + match response { + Ok(response) if response.status().is_success() => { + let probe_response = match response.json::().await { + Ok(probe_response) => probe_response, + Err(err) => { + warn!(error = %err, "Upload health probe returned an invalid response"); + return UploadHealthSnapshot { + kind: UploadHealthKind::Unavailable, + upload_mbps: None, + max_instant_resolution: Some( + cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION, + ), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: "Upload health check returned an invalid response; Instant quality will be capped." + .to_string(), + }; + } + }; + + if probe_response.received_bytes != payload_len { + warn!( + expected_bytes = payload_len, + received_bytes = probe_response.received_bytes, + "Upload health probe received an incomplete payload" + ); + return UploadHealthSnapshot { + kind: UploadHealthKind::Unavailable, + upload_mbps: None, + max_instant_resolution: Some(cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: "Upload health check received an incomplete probe; Instant quality will be capped." + .to_string(), + }; + } + + let elapsed = upload_elapsed_after_rtt(started.elapsed(), rtt_elapsed); + let upload_mbps = upload_mbps_for_bytes(probe_response.received_bytes, elapsed); + let max_resolution = max_resolution_for_upload_mbps(upload_mbps); + let kind = health_kind_for_resolution(max_resolution); + + UploadHealthSnapshot { + kind, + upload_mbps: Some(upload_mbps), + max_instant_resolution: Some(max_resolution), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: if kind == UploadHealthKind::Healthy { + format!("Upload looks ready at {:.1} Mbps.", upload_mbps) + } else { + format!( + "Upload is slow at {:.1} Mbps; Instant quality will be capped.", + upload_mbps + ) + }, + } + } + Ok(response) => { + let status = response.status(); + debug!(%status, "Upload health probe returned a non-success status"); + UploadHealthSnapshot { + kind: UploadHealthKind::Unavailable, + upload_mbps: None, + max_instant_resolution: Some(cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: format!( + "Upload health check failed with status {status}; Instant quality will be capped." + ), + } + } + Err(AuthedApiError::InvalidAuthentication) => UploadHealthSnapshot { + kind: UploadHealthKind::Unknown, + upload_mbps: None, + max_instant_resolution: None, + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: "Sign in to check upload health for Instant recording.".to_string(), + }, + Err(err) => { + warn!(error = %err, "Upload health probe failed"); + UploadHealthSnapshot { + kind: UploadHealthKind::Unavailable, + upload_mbps: None, + max_instant_resolution: Some(cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: "Upload health check could not reach Cap; Instant quality will be capped." + .to_string(), + } + } + } +} + +#[tauri::command] +#[specta::specta] +pub async fn get_upload_health_status( + cache: State<'_, UploadHealthCache>, +) -> Result { + Ok(cache.status().await) +} + +#[tauri::command] +#[specta::specta] +pub async fn refresh_upload_health_status( + app: AppHandle, + app_state: MutableState<'_, App>, + cache: State<'_, UploadHealthCache>, +) -> Result { + if app_state.read().await.is_recording_active_or_pending() { + return Ok(cache.status().await); + } + + let Ok(_probe_guard) = cache.probe.try_lock() else { + return Ok(cache.status().await); + }; + + let snapshot = run_probe(&app).await; + Ok(cache.update(snapshot).await) +} + +pub async fn cached_instant_resolution_cap(app: &AppHandle) -> Option { + let cache = app.try_state::()?; + cache.fresh_instant_resolution_cap().await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_upload_speed_to_resolution_tiers() { + assert_eq!(max_resolution_for_upload_mbps(3.9), 1280); + assert_eq!(max_resolution_for_upload_mbps(6.0), 1920); + assert_eq!(max_resolution_for_upload_mbps(18.0), 2560); + assert_eq!(max_resolution_for_upload_mbps(35.0), 3840); + } + + #[tokio::test] + async fn stale_cached_resolution_is_not_used() { + let cache = UploadHealthCache { + snapshot: Mutex::new(UploadHealthSnapshot { + kind: UploadHealthKind::Healthy, + upload_mbps: Some(50.0), + max_instant_resolution: Some(3840), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now() - HEALTH_FRESH_FOR - Duration::from_secs(1)), + message: "old".to_string(), + }), + probe: Mutex::new(()), + }; + + assert_eq!(cache.fresh_instant_resolution_cap().await, None); + } + + #[tokio::test] + async fn fresh_slow_result_caps_to_safe_resolution() { + let cache = UploadHealthCache { + snapshot: Mutex::new(UploadHealthSnapshot { + kind: UploadHealthKind::Slow, + upload_mbps: Some(2.0), + max_instant_resolution: Some(1280), + checked_at_unix_ms: Some(now_unix_ms()), + recorded_at: Some(Instant::now()), + message: "slow".to_string(), + }), + probe: Mutex::new(()), + }; + + assert_eq!(cache.fresh_instant_resolution_cap().await, Some(1280)); + } + + #[test] + fn subtracts_rtt_from_probe_elapsed_when_safe() { + assert_eq!( + upload_elapsed_after_rtt(Duration::from_millis(700), Some(Duration::from_millis(500))), + Duration::from_millis(200) + ); + } + + #[test] + fn keeps_total_elapsed_when_rtt_would_overcorrect() { + assert_eq!( + upload_elapsed_after_rtt(Duration::from_millis(520), Some(Duration::from_millis(500))), + Duration::from_millis(520) + ); + } +} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 95749abd2c6..b7f1bcf7f8b 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -77,6 +77,12 @@ import { type ScreenCaptureTarget, type UploadProgress, } from "~/utils/tauri"; +import { + describeUploadHealth, + getUploadHealthStatus, + refreshUploadHealthStatus, + type UploadHealthStatus, +} from "~/utils/upload-health"; import IconCapLogoFull from "~icons/cap/logo-full"; import IconCapLogoFullDark from "~icons/cap/logo-full-dark"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; @@ -85,6 +91,7 @@ import IconLucideBug from "~icons/lucide/bug"; import IconLucideCircleHelp from "~icons/lucide/circle-help"; import IconLucideImage from "~icons/lucide/image"; import IconLucideImport from "~icons/lucide/import"; +import IconLucideRefreshCw from "~icons/lucide/refresh-cw"; import IconLucideSearch from "~icons/lucide/search"; import IconLucideSettings from "~icons/lucide/settings"; import IconLucideSquarePlay from "~icons/lucide/square-play"; @@ -1608,6 +1615,47 @@ function MainWindowHelpButton() { ); } +function UploadHealthPill(props: { + status: UploadHealthStatus | null; + refreshing: boolean; + disabled: boolean; + onRefresh: () => void; +}) { + const presentation = createMemo(() => describeUploadHealth(props.status)); + const toneClass = createMemo(() => { + switch (presentation().tone) { + case "good": + return "border-green-6 bg-green-3 text-green-12"; + case "warning": + return "border-amber-6 bg-amber-3 text-amber-12"; + case "danger": + return "border-red-6 bg-red-3 text-red-12"; + default: + return "border-gray-5 bg-gray-3 text-gray-11"; + } + }); + + return ( + {props.status?.message ?? "Upload health"}}> + + + ); +} + function Page() { const queryClient = useQueryClient(); const { rawOptions, setOptions } = useRecordingOptions(); @@ -1627,6 +1675,69 @@ function Page() { const compatibilityStudioMode = () => rawOptions.mode === "studio" && generalSettings.data?.studioRecordingQuality === "compatibility"; + const [uploadHealth, setUploadHealth] = + createSignal(null); + const [isRefreshingUploadHealth, setIsRefreshingUploadHealth] = + createSignal(false); + + const loadUploadHealth = async (refresh: boolean) => { + const shouldRefresh = refresh && !isRecording(); + if (shouldRefresh && isRefreshingUploadHealth()) return; + if (shouldRefresh) setIsRefreshingUploadHealth(true); + try { + const status = shouldRefresh + ? await refreshUploadHealthStatus() + : await getUploadHealthStatus(); + setUploadHealth(status); + } catch (error) { + console.error("Failed to load upload health:", error); + } finally { + if (shouldRefresh) setIsRefreshingUploadHealth(false); + } + }; + + onMount(() => { + void loadUploadHealth(false); + + const startupProbe = window.setTimeout(() => { + void loadUploadHealth(true); + }, 800); + + const interval = window.setInterval( + () => { + void loadUploadHealth(true); + }, + 5 * 60 * 1000, + ); + + onCleanup(() => { + window.clearTimeout(startupProbe); + window.clearInterval(interval); + }); + }); + + createTauriEventListener(events.recordingStarted, () => { + void loadUploadHealth(false); + }); + + let recordingStoppedUploadHealthTimeout: number | undefined; + onCleanup(() => { + if (recordingStoppedUploadHealthTimeout !== undefined) { + window.clearTimeout(recordingStoppedUploadHealthTimeout); + } + }); + + createTauriEventListener(events.recordingStopped, () => { + if (recordingStoppedUploadHealthTimeout !== undefined) { + window.clearTimeout(recordingStoppedUploadHealthTimeout); + } + + recordingStoppedUploadHealthTimeout = window.setTimeout(() => { + recordingStoppedUploadHealthTimeout = undefined; + void loadUploadHealth(true); + }, 1_000); + }); + let cancelScheduledTargetListPrewarm: (() => void) | undefined; onCleanup(() => cancelScheduledTargetListPrewarm?.()); @@ -2892,6 +3003,18 @@ function Page() { + +
+ { + void loadUploadHealth(true); + }} + /> +
+
diff --git a/apps/desktop/src/utils/upload-health.test.ts b/apps/desktop/src/utils/upload-health.test.ts new file mode 100644 index 00000000000..ecc02df4481 --- /dev/null +++ b/apps/desktop/src/utils/upload-health.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + describeUploadHealth, + formatUploadMbps, + type UploadHealthStatus, +} from "./upload-health"; + +const status = ( + overrides: Partial, +): UploadHealthStatus => ({ + kind: "unknown", + uploadMbps: null, + maxInstantResolution: null, + checkedAtUnixMs: null, + stale: false, + message: "", + ...overrides, +}); + +describe("upload health presentation", () => { + it("formats low and high Mbps values", () => { + expect(formatUploadMbps(4.25)).toBe("4.3 Mbps"); + expect(formatUploadMbps(18.2)).toBe("18 Mbps"); + }); + + it("shows a neutral state before the first check", () => { + expect(describeUploadHealth(null)).toEqual({ + label: "Upload health", + detail: "Not checked", + tone: "neutral", + }); + }); + + it("does not trust stale checks", () => { + expect( + describeUploadHealth(status({ kind: "healthy", stale: true })), + ).toEqual({ + label: "Upload health", + detail: "Check is stale", + tone: "neutral", + }); + }); + + it("warns when slow upload caps Instant quality", () => { + expect( + describeUploadHealth(status({ kind: "slow", uploadMbps: 3.8 })), + ).toEqual({ + label: "Upload slow", + detail: "3.8 Mbps, capped", + tone: "warning", + }); + }); + + it("keeps zero Mbps as a measured slow upload value", () => { + expect( + describeUploadHealth(status({ kind: "slow", uploadMbps: 0 })), + ).toEqual({ + label: "Upload slow", + detail: "0.0 Mbps, capped", + tone: "warning", + }); + }); + + it("reports unavailable upload checks as capped", () => { + expect(describeUploadHealth(status({ kind: "unavailable" }))).toEqual({ + label: "Upload offline", + detail: "Instant capped", + tone: "danger", + }); + }); +}); diff --git a/apps/desktop/src/utils/upload-health.ts b/apps/desktop/src/utils/upload-health.ts new file mode 100644 index 00000000000..38e803f967a --- /dev/null +++ b/apps/desktop/src/utils/upload-health.ts @@ -0,0 +1,73 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type UploadHealthKind = "unknown" | "healthy" | "slow" | "unavailable"; + +export type UploadHealthStatus = { + kind: UploadHealthKind; + uploadMbps: number | null; + maxInstantResolution: number | null; + checkedAtUnixMs: number | null; + stale: boolean; + message: string; +}; + +export type UploadHealthPresentation = { + label: string; + detail: string; + tone: "neutral" | "good" | "warning" | "danger"; +}; + +export const getUploadHealthStatus = () => + invoke("get_upload_health_status"); + +export const refreshUploadHealthStatus = () => + invoke("refresh_upload_health_status"); + +export function formatUploadMbps(uploadMbps: number) { + if (uploadMbps >= 10) return `${Math.round(uploadMbps)} Mbps`; + return `${uploadMbps.toFixed(1)} Mbps`; +} + +export function describeUploadHealth( + status: UploadHealthStatus | null | undefined, +): UploadHealthPresentation { + if (!status || status.kind === "unknown") { + return { + label: "Upload health", + detail: "Not checked", + tone: "neutral", + }; + } + + if (status.stale) { + return { + label: "Upload health", + detail: "Check is stale", + tone: "neutral", + }; + } + + if (status.kind === "unavailable") { + return { + label: "Upload offline", + detail: "Instant capped", + tone: "danger", + }; + } + + const speed = + status.uploadMbps != null ? formatUploadMbps(status.uploadMbps) : null; + if (status.kind === "slow") { + return { + label: "Upload slow", + detail: speed ? `${speed}, capped` : "Instant capped", + tone: "warning", + }; + } + + return { + label: "Upload ready", + detail: speed ?? "Ready", + tone: "good", + }; +} diff --git a/apps/web/__tests__/unit/desktop-upload-health.test.ts b/apps/web/__tests__/unit/desktop-upload-health.test.ts new file mode 100644 index 00000000000..fc504d66505 --- /dev/null +++ b/apps/web/__tests__/unit/desktop-upload-health.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES, + readUploadHealthProbeBytes, + UploadHealthProbeTooLargeError, +} from "@/app/api/desktop/[...route]/uploadHealth"; + +describe("desktop upload health probe", () => { + it("counts a bounded probe body without storing it", async () => { + const body = new Uint8Array(64 * 1024); + const request = new Request("https://cap.test/api/desktop/upload-health", { + method: "POST", + body, + }); + + await expect(readUploadHealthProbeBytes(request)).resolves.toBe( + body.byteLength, + ); + }); + + it("allows exactly the configured maximum", async () => { + const request = new Request("https://cap.test/api/desktop/upload-health", { + method: "POST", + body: new Uint8Array(MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES), + }); + + await expect(readUploadHealthProbeBytes(request)).resolves.toBe( + MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES, + ); + }); + + it("rejects probes larger than the configured maximum", async () => { + const request = new Request("https://cap.test/api/desktop/upload-health", { + method: "POST", + body: new Uint8Array(MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES + 1), + }); + + await expect(readUploadHealthProbeBytes(request)).rejects.toBeInstanceOf( + UploadHealthProbeTooLargeError, + ); + }); +}); diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 694119771b9..327ac205b20 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -32,6 +32,11 @@ import { OrganizationBrandingValidationError, toDesktopOrganization, } from "./organization-branding"; +import { + MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES, + readUploadHealthProbeBytes, + UploadHealthProbeTooLargeError, +} from "./uploadHealth"; export const app = new Hono(); @@ -440,6 +445,41 @@ app.post( }, ); +app.on("HEAD", "/upload-health", withAuth, (c) => c.body(null, 204)); + +app.post("/upload-health", withAuth, async (c) => { + const contentLengthHeader = c.req.header("content-length"); + const contentLength = + contentLengthHeader === undefined ? null : Number(contentLengthHeader); + + if ( + contentLength !== null && + Number.isFinite(contentLength) && + contentLength > MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES + ) { + return c.json({ error: "probe_too_large" }, { status: 413 }); + } + + try { + // The stream reader below is the enforcement boundary for missing, + // invalid, or understated Content-Length headers. + const receivedBytes = await readUploadHealthProbeBytes(c.req.raw); + + return c.json({ + success: true, + receivedBytes, + maxProbeBytes: MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES, + }); + } catch (error) { + if (error instanceof UploadHealthProbeTooLargeError) { + return c.json({ error: "probe_too_large" }, { status: 413 }); + } + + console.error("[upload-health] Failed to read probe body:", error); + return c.json({ error: "probe_failed" }, { status: 500 }); + } +}); + app.get("/org-custom-domain", withAuth, async (c) => { const user = c.get("user"); diff --git a/apps/web/app/api/desktop/[...route]/uploadHealth.ts b/apps/web/app/api/desktop/[...route]/uploadHealth.ts new file mode 100644 index 00000000000..6a3e0c8222d --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/uploadHealth.ts @@ -0,0 +1,35 @@ +export const MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES = 512 * 1024; + +export class UploadHealthProbeTooLargeError extends Error { + constructor() { + super("Upload health probe body is too large"); + this.name = "UploadHealthProbeTooLargeError"; + } +} + +export async function readUploadHealthProbeBytes( + request: Request, + maxBytes = MAX_DESKTOP_UPLOAD_HEALTH_PROBE_BYTES, +) { + if (!request.body) return 0; + + let receivedBytes = 0; + const reader = request.body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + receivedBytes += value.byteLength; + if (receivedBytes > maxBytes) { + await reader.cancel(); + throw new UploadHealthProbeTooLargeError(); + } + } + } finally { + reader.releaseLock(); + } + + return receivedBytes; +}