diff --git a/services/supabase/README.md b/services/supabase/README.md index a0d01030..6a9ba910 100644 --- a/services/supabase/README.md +++ b/services/supabase/README.md @@ -15,3 +15,13 @@ On submission, the modal captures up to 100 recent renderer warnings/errors (2,0 A local demo account can preview the form but cannot submit without a real Supabase session. Failed submissions retain the draft in memory; closing and reopening the same modal keeps it, but restarting the app does not. The function cleans up failed uploads on a best-effort basis. Periodically remove unattached objects left by interrupted sessions through the Storage API, and prune old `feedback_daily_usage` rows after their UTC day has ended. Unit tests exercise the server handler with an injected storage/auth backend. `tests/feedback_limits.sql` verifies the migration's quotas and permissions in a disposable database with the migrations applied; it rolls back its test data. Live deployment and end-to-end Supabase uploads must be checked separately before release. + +## User-triggered export error reports (no account link) + +Apply `202610020001_export_error_reports.sql` and deploy `submit-export-error` using `services/supabase/config.toml` (`verify_jwt = false`). The new function is public and accepts only the validated, 4 KiB technical-report schema; clients cannot insert/read reports directly. No change to manual account-linked feedback is required. Configure the existing public URL/publishable key in the desktop build. Never expose a service-role key. + +The export error dialog has **Report error**, then a preview and explicit **Send report** action. Nothing is sent automatically or on opening the preview. The optional cloud-plan category is self-reported (`paid`, `not_paid`, or `unknown`); it is not an authenticated billing check. Current main does not expose a billing entitlement to this UI. The client uses `fetch` with no session Authorization, cookies or referrer. Reports store no account ID/email, media, path, logs, persistent device ID or IP. The function rejects Authorization headers and unknown payload fields. Supabase/infrastructure connection logs can still contain request metadata/IP; do not describe this as guaranteed anonymity. + +View `public.export_error_reports` through staff/service-role access. Reports contain allowlisted error codes, numerical failure positions for both decoding attempts, format, stage, output dimensions/FPS, app version and OS, plus the optional cloud-plan category. Creation times are rounded to the hour. A database quota caps accepted anonymous submissions at 20 per minute globally without storing identity; this is an aggregate abuse/cost control, not per-user protection, and can be exhausted by other callers. No raw error text is persisted. Failed requests are retryable by the user. + +The migration enables Supabase Cron and schedules daily deletion of reports older than 30 days and aggregate quota counters older than one day. Confirm the job exists and succeeds before releasing the client. Apply and validate this migration/function separately; no live backend deployment is performed by the code change. Update your collection/privacy notice with the real Supabase hosting country, infrastructure logging, recipients and retention. Explicit click-to-send and minimisation reduce risk but do not establish legal compliance for every jurisdiction. diff --git a/services/supabase/config.toml b/services/supabase/config.toml index cd28e261..debd3be0 100644 --- a/services/supabase/config.toml +++ b/services/supabase/config.toml @@ -3,3 +3,6 @@ project_id = "recordly-feedback" [functions.submit-feedback] # The handler verifies the bearer token with auth.getUser before doing any work. verify_jwt = false + +[functions.submit-export-error] +verify_jwt = false diff --git a/services/supabase/functions/_shared/exportErrorReport.ts b/services/supabase/functions/_shared/exportErrorReport.ts new file mode 100644 index 00000000..707204f0 --- /dev/null +++ b/services/supabase/functions/_shared/exportErrorReport.ts @@ -0,0 +1,99 @@ +export type CloudPlan = "paid" | "not_paid" | "unknown"; +export interface DecoderAttempt { + code: string; + sourceTimeSec?: number; + chunkIndex?: number; + chunkBytes?: number; +} +export interface ExportErrorReport { + schemaVersion: number; + cloudPlan: CloudPlan; + format: "mp4" | "gif" | "unknown"; + failure: DecoderAttempt; + stage: "input_decoding" | "saving" | "export"; + initialDecoder?: DecoderAttempt; + softwareDecoder?: DecoderAttempt; + appVersion?: string; + platform: string; + output?: { width: number; height: number; fps: number }; +} +const codes = [ + "UNKNOWN", + "SAVE_FAILED", + "AUDIO_FAILED", + "ENCODE_FAILED", + "EXPORT_FAILED", + "VIDEO_DECODE_ENCODING_ERROR", + "VIDEO_DECODER_RESOURCE_EXHAUSTED", + "VIDEO_DECODER_INVALID_STATE", + "VIDEO_DECODE_FAILED", + "VIDEO_CODEC_UNSUPPORTED", + "VIDEO_DECODE_RECOVERY_FAILED", +]; +const object = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); +const keys = (value: Record, allowed: string[]) => + Object.keys(value).every((key) => allowed.includes(key)); +const oneOf = (value: unknown, allowed: string[]) => + typeof value === "string" && allowed.includes(value); +const number = (value: unknown, max: number, integer = false) => + typeof value === "number" && + Number.isFinite(value) && + value >= 0 && + value <= max && + (!integer || Number.isInteger(value)); +function attempt(value: unknown) { + return ( + object(value) && + keys(value, ["code", "sourceTimeSec", "chunkIndex", "chunkBytes"]) && + typeof value.code === "string" && + codes.includes(value.code) && + (value.sourceTimeSec === undefined || number(value.sourceTimeSec, 86400)) && + (value.chunkIndex === undefined || number(value.chunkIndex, 100_000_000, true)) && + (value.chunkBytes === undefined || number(value.chunkBytes, 100_000_000, true)) + ); +} +/** Reject arbitrary strings/extra fields, including any account or free-text diagnostics. */ +export function isExportErrorReport(value: unknown): value is ExportErrorReport { + if ( + !object(value) || + !keys(value, [ + "schemaVersion", + "cloudPlan", + "format", + "failure", + "stage", + "initialDecoder", + "softwareDecoder", + "appVersion", + "platform", + "output", + ]) + ) + return false; + if ( + value.schemaVersion !== 1 || + !oneOf(value.cloudPlan, ["paid", "not_paid", "unknown"]) || + !oneOf(value.format, ["mp4", "gif", "unknown"]) || + !oneOf(value.stage, ["input_decoding", "saving", "export"]) || + !oneOf(value.platform, ["Windows", "macOS", "Linux", "unknown"]) || + !attempt(value.failure) + ) + return false; + if (value.initialDecoder !== undefined && !attempt(value.initialDecoder)) return false; + if (value.softwareDecoder !== undefined && !attempt(value.softwareDecoder)) return false; + if ( + value.appVersion !== undefined && + (typeof value.appVersion !== "string" || + !/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(value.appVersion)) + ) + return false; + return ( + value.output === undefined || + (object(value.output) && + keys(value.output, ["width", "height", "fps"]) && + number(value.output.width, 16384, true) && + number(value.output.height, 16384, true) && + number(value.output.fps, 240)) + ); +} diff --git a/services/supabase/functions/submit-export-error/handler.ts b/services/supabase/functions/submit-export-error/handler.ts new file mode 100644 index 00000000..cdea7748 --- /dev/null +++ b/services/supabase/functions/submit-export-error/handler.ts @@ -0,0 +1,59 @@ +import { isExportErrorReport, type ExportErrorReport } from "../_shared/exportErrorReport.ts"; +const MAX_BYTES = 4096; +const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", +}; +const reply = (status: number, code?: string) => + new Response(JSON.stringify(code ? { code } : { success: true }), { status, headers }); +export interface ExportErrorBackend { + reserve(): Promise; + insert(report: ExportErrorReport): Promise; +} +/** Public, minimal reports only. No identity/IP/device identifier is read or persisted. */ +export function exportErrorHandler(backend: ExportErrorBackend) { + return async (request: Request): Promise => { + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers }); + if (request.method !== "POST") return reply(405, "METHOD_NOT_ALLOWED"); + if (request.headers.has("Authorization")) return reply(400, "IDENTITY_NOT_ACCEPTED"); + if (!request.headers.get("Content-Type")?.startsWith("application/json")) + return reply(400, "INVALID_REPORT"); + let report: unknown; + try { + const reader = request.body?.getReader(); + if (!reader) return reply(400, "INVALID_REPORT"); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_BYTES) return reply(413, "REPORT_TOO_LARGE"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + report = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return reply(400, "INVALID_REPORT"); + } + if (!isExportErrorReport(report)) return reply(400, "INVALID_REPORT"); + try { + if (!(await backend.reserve())) return reply(429, "REPORT_LIMIT"); + await backend.insert(report); + return reply(200); + } catch { + return reply(500, "SUBMISSION_FAILED"); + } + }; +} diff --git a/services/supabase/functions/submit-export-error/index.ts b/services/supabase/functions/submit-export-error/index.ts new file mode 100644 index 00000000..1edaff93 --- /dev/null +++ b/services/supabase/functions/submit-export-error/index.ts @@ -0,0 +1,24 @@ +import { createClient } from "npm:@supabase/supabase-js@2.116.0"; +import { exportErrorHandler } from "./handler.ts"; +const client = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + { + auth: { persistSession: false, autoRefreshToken: false }, + }, +); +Deno.serve( + exportErrorHandler({ + async reserve() { + const { data, error } = await client.rpc("reserve_export_error_quota"); + if (error) throw error; + return data === true; + }, + async insert(report) { + const { error } = await client + .from("export_error_reports") + .insert({ cloud_plan: report.cloudPlan, diagnostics: report }); + if (error) throw error; + }, + }), +); diff --git a/services/supabase/migrations/202610020001_export_error_reports.sql b/services/supabase/migrations/202610020001_export_error_reports.sql new file mode 100644 index 00000000..4cf73162 --- /dev/null +++ b/services/supabase/migrations/202610020001_export_error_reports.sql @@ -0,0 +1,38 @@ +begin; +-- Deliberately separate from account-linked feedback: no user_id or identity fields. +create table public.export_error_reports ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default date_trunc('hour', now()), + cloud_plan text not null check (cloud_plan in ('paid', 'not_paid', 'unknown')), + diagnostics jsonb not null check (jsonb_typeof(diagnostics) = 'object' and octet_length(diagnostics::text) <= 4096) +); +create index export_error_reports_created_at on public.export_error_reports(created_at); +alter table public.export_error_reports enable row level security; +revoke all on public.export_error_reports from public, anon, authenticated; +grant select, insert, delete on public.export_error_reports to service_role; + +-- A global cap bounds anonymous writes without persisting IPs or device identifiers. +create table public.export_error_usage ( + minute timestamptz primary key, + attempts integer not null +); +alter table public.export_error_usage enable row level security; +revoke all on public.export_error_usage from public, anon, authenticated; +create function public.reserve_export_error_quota() +returns boolean language plpgsql security definer set search_path = '' as $$ +begin + insert into public.export_error_usage as usage (minute, attempts) + values (date_trunc('minute', now()), 1) + on conflict (minute) do update set attempts = usage.attempts + 1 where usage.attempts < 20; + return found; +end; +$$; +revoke all on function public.reserve_export_error_quota() from public, anon, authenticated; +grant execute on function public.reserve_export_error_quota() to service_role; + +-- Retain diagnostics for 30 days and aggregate quota counters for one day. +create extension if not exists pg_cron with schema pg_catalog; +select cron.schedule('recordly-export-report-retention', '17 3 * * *', + $job$delete from public.export_error_reports where created_at < now() - interval '30 days'; + delete from public.export_error_usage where minute < now() - interval '1 day';$job$); +commit; diff --git a/services/supabase/tests/export_error_reports.sql b/services/supabase/tests/export_error_reports.sql new file mode 100644 index 00000000..ceafa7aa --- /dev/null +++ b/services/supabase/tests/export_error_reports.sql @@ -0,0 +1,24 @@ +-- Run as migration owner in a disposable Supabase database after the reporting migration. +begin; +delete from public.export_error_usage where minute = date_trunc('minute', now()); +do $$ +begin + if has_table_privilege('anon', 'public.export_error_reports', 'INSERT') + or has_table_privilege('authenticated', 'public.export_error_reports', 'SELECT') + or has_function_privilege('anon', 'public.reserve_export_error_quota()', 'EXECUTE') then + raise exception 'Client can bypass endpoint or read private reports'; + end if; + if exists (select 1 from information_schema.columns where table_schema='public' + and table_name='export_error_reports' and column_name in ('user_id','email','ip','device_id')) then + raise exception 'Reports contain identity columns'; + end if; + for i in 1..20 loop + if not public.reserve_export_error_quota() then raise exception 'Valid quota reservation rejected'; end if; + end loop; + if public.reserve_export_error_quota() then raise exception 'Global minute cap bypassed'; end if; + if not exists (select 1 from cron.job where jobname='recordly-export-report-retention' and active) then + raise exception 'Retention job missing'; + end if; +end; +$$; +rollback; diff --git a/src/components/feedback/ExportErrorReportButton.tsx b/src/components/feedback/ExportErrorReportButton.tsx new file mode 100644 index 00000000..fc1d279a --- /dev/null +++ b/src/components/feedback/ExportErrorReportButton.tsx @@ -0,0 +1,114 @@ +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + buildExportErrorReport, + submitExportErrorReport, +} from "@/lib/feedback/exportErrorReporting"; +import type { CloudPlan } from "../../../services/supabase/functions/_shared/exportErrorReport"; + +export function ExportErrorReportButton({ + error, + format, +}: { + error: string; + format: "mp4" | "gif"; +}) { + const [open, setOpen] = useState(false); + const [plan, setPlan] = useState("unknown"); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const [sendError, setSendError] = useState(""); + const sendingRef = useRef(false); + const report = buildExportErrorReport(error, plan, format); + return ( + <> + + { + if (!sendingRef.current) setOpen(value); + }} + > + + + {sent ? "Report sent" : "Report export error"} + + {sent ? ( +

+ Thanks. The technical report was sent to Recordly support. +

+ ) : ( + <> +

+ Send technical details to Recordly support through Supabase. No + account ID, email, recording, file path, attachments or console logs + are included. Supabase may keep connection logs separately. +

+ +
+ + View data to be sent + +
+									{JSON.stringify(report, null, 2)}
+								
+
+

+ Reports are kept for 30 days, then removed by daily cleanup. +

+ {sendError && ( +

+ {sendError} +

+ )} + + + )} +
+
+ + ); +} diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index 4dea1caf..ac4a7aa2 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -1,3 +1,4 @@ +import { ExportErrorReportButton } from "@/components/feedback/ExportErrorReportButton"; import { useEffect, useState } from "react"; import { CloudArrowUp } from "@/components/ui/icons"; import { CloudShareButton } from "../cloud/CloudShareButton"; @@ -15,7 +16,7 @@ import type { useExportSettings } from "../export/useExportSettings"; import type { useExportStatusViewModel } from "../export/useExportStatusViewModel"; type Props = { - projectPath?: string | null; + projectPath?: string | null; t: ReturnType["t"]; exportSettings: ReturnType; exportSession: ReturnType; @@ -250,6 +251,11 @@ export function EditorExportMenu(props: Props) { > {t("editor.exportStatus.copyError", "Copy error")} + {hasPendingExportSave ? (