Enforce server-side feedback quotas and improve submission errors

This commit is contained in:
webadderall
2026-09-23 17:31:17 +10:00
parent ff4be391a9
commit e6b626701b
14 changed files with 484 additions and 86 deletions
+4
View File
@@ -108,6 +108,10 @@
"javascript": { "formatter": { "quoteStyle": "double" } },
"css": { "parser": { "cssModules": true, "tailwindDirectives": true } },
"overrides": [
{
"includes": ["services/supabase/functions/**/*.ts"],
"javascript": { "globals": ["Deno"] }
},
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
+12 -4
View File
@@ -1,9 +1,17 @@
# Desktop feedback
Apply `migrations/202609230001_feedback.sql` in the Supabase SQL editor for the project configured by `VITE_SUPABASE_URL` before releasing this feature. The desktop client uses its existing signed-in session; never put a service-role key in the app.
Before releasing feedback submission:
Feedback is stored in `public.feedback_reports`. View reports through the Supabase dashboard and retrieve attachments from the private `feedback-attachments` bucket using each report's attachment paths. There is no email notification or public issue creation. Owners can read their own reports; other users cannot. Staff use server-side service-role access.
1. Apply `migrations/202609230001_feedback.sql` and then `migrations/202609230002_feedback_limits.sql` in the configured Supabase project. If the first migration is already applied, apply only the second.
2. Deploy the `submit-feedback` Edge Function from the repository root with the Supabase CLI: `supabase functions deploy submit-feedback --workdir services --project-ref YOUR_PROJECT_REF`. The function explicitly validates the bearer token with `auth.getUser` before parsing or storing feedback. The function configuration disables only the gateway's JWT check, not the function's authentication.
3. Use the project's built-in `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` secrets for the function. Never put a service-role key in the desktop app.
On submission, the modal captures up to 100 recent renderer warnings/errors (2,000 characters each), browser/platform details, and a timestamp. Common secrets, URLs, emails, and local home paths are redacted. Arbitrary log objects are not serialized. Diagnostics are automatically included with each submission. Native-process logs are not collected. Attachments are chosen explicitly, up to five files and 10 MB total in the client, with a 10 MB per-object storage limit.
The desktop client calls the function with its existing signed-in session. Direct client report inserts and attachment uploads are disabled by the second migration. The function validates subject/message lengths, diagnostics bytes, and the actual uploaded files (five nonempty files, 10 MB total per submission), and caps the incoming request body. It reserves a per-account UTC daily quota atomically: 10 submission attempts, 25 files, and 50 MB. Failed attempts still consume quota, so repeated upload/delete/retry cycles do not reset it. No Storage schema triggers or modifications are required beyond the access policies.
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. Failed uploads are cleaned up on a best-effort basis; periodically remove unattached objects left by interrupted sessions.
Feedback is stored in `public.feedback_reports`. View reports through the Supabase dashboard and retrieve attachments from the private `feedback-attachments` bucket using each report's attachment paths. Owners can read their own reports; other users cannot. Staff use server-side service-role access. There is no email notification or public issue creation.
On submission, the modal captures up to 100 recent renderer warnings/errors (2,000 characters each), browser/platform details, and a timestamp. Common secrets, URLs, emails, and local home paths are redacted. Arbitrary log objects are not serialized. The final JSON is bounded to 290,000 UTF-8 bytes by dropping oldest entries, below the database's 300,000-byte limit. Diagnostics are automatically included; native-process logs are not collected. Attachments are chosen explicitly.
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.
+5
View File
@@ -0,0 +1,5 @@
project_id = "recordly-feedback"
[functions.submit-feedback]
# The handler verifies the bearer token with auth.getUser before doing any work.
verify_jwt = false
@@ -0,0 +1,117 @@
const MAX_FILES = 5;
const MAX_BYTES = 10 * 1024 * 1024;
const MAX_REQUEST_BYTES = MAX_BYTES + 1024 * 1024;
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Content-Type": "application/json",
};
export interface FeedbackBackend {
authenticate(token: string): Promise<string | null>;
reserve(userId: string, files: number, bytes: number): Promise<boolean>;
upload(path: string, file: File): Promise<void>;
insert(report: Record<string, unknown>): Promise<void>;
remove(paths: string[]): Promise<void>;
}
const reply = (status: number, code?: string) =>
new Response(JSON.stringify(code ? { code } : { success: true }), { status, headers });
// Bound the actual body, not just the caller's Content-Length header.
async function readForm(request: Request) {
const reader = request.body?.getReader();
if (!reader) throw new Error("Missing body");
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_REQUEST_BYTES) throw new Error("Body too large");
chunks.push(value);
}
} finally {
await reader.cancel();
}
const body = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new Response(body, {
headers: { "Content-Type": request.headers.get("Content-Type") ?? "" },
}).formData();
}
export function feedbackHandler(backend: FeedbackBackend) {
return async (request: Request): Promise<Response> => {
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers });
if (request.method !== "POST") return reply(405, "METHOD_NOT_ALLOWED");
const token = request.headers.get("Authorization")?.match(/^Bearer (.+)$/i)?.[1];
if (!token) return reply(401, "SIGN_IN_REQUIRED");
const uploaded: string[] = [];
try {
const userId = await backend.authenticate(token);
if (!userId) return reply(401, "SIGN_IN_REQUIRED");
let form: FormData;
try {
form = await readForm(request);
} catch {
return reply(400, "INVALID_FEEDBACK");
}
const title = form.get("title");
const message = form.get("message");
const subject = form.get("subject");
const logs = form.get("logs");
const files = form.getAll("files");
if (
typeof title !== "string" ||
!title.trim() ||
title.trim().length > 160 ||
typeof message !== "string" ||
!message.trim() ||
message.trim().length > 10000 ||
typeof subject !== "string" ||
!["bug", "idea", "question", "other"].includes(subject) ||
(logs !== null &&
(typeof logs !== "string" || new TextEncoder().encode(logs).length > 300000))
) {
return reply(400, "INVALID_FEEDBACK");
}
if (
files.length > MAX_FILES ||
files.some((file) => typeof file === "string" || file.size === 0)
) {
return reply(400, "INVALID_ATTACHMENTS");
}
const attachments = files as File[];
const bytes = attachments.reduce((total, file) => total + file.size, 0);
if (bytes > MAX_BYTES) return reply(400, "INVALID_ATTACHMENTS");
if (!(await backend.reserve(userId, attachments.length, bytes)))
return reply(429, "FEEDBACK_LIMIT");
const id = crypto.randomUUID();
const metadata = [];
for (const file of attachments) {
const path = `${userId}/${id}/${crypto.randomUUID()}`;
await backend.upload(path, file);
uploaded.push(path);
metadata.push({ path, name: file.name, size: file.size, type: file.type });
}
await backend.insert({
id,
user_id: userId,
title: title.trim(),
subject,
message: message.trim(),
logs,
attachments: metadata,
});
return reply(200);
} catch {
if (uploaded.length) await backend.remove(uploaded).catch(() => undefined);
return reply(500, "SUBMISSION_FAILED");
}
};
}
@@ -0,0 +1,41 @@
import { createClient } from "npm:@supabase/supabase-js@2.116.0";
import { feedbackHandler } 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(
feedbackHandler({
async authenticate(token) {
const { data, error } = await client.auth.getUser(token);
return error ? null : (data.user?.id ?? null);
},
async reserve(userId, files, bytes) {
const { data, error } = await client.rpc("reserve_feedback_quota", {
account_id: userId,
file_count: files,
byte_count: bytes,
});
if (error) throw error;
return data === true;
},
async upload(path, file) {
const { error } = await client.storage
.from("feedback-attachments")
.upload(path, file, { contentType: "application/octet-stream", upsert: false });
if (error) throw error;
},
async insert(report) {
const { error } = await client.from("feedback_reports").insert(report);
if (error) throw error;
},
async remove(paths) {
const { error } = await client.storage.from("feedback-attachments").remove(paths);
if (error) throw error;
},
}),
);
@@ -0,0 +1,44 @@
begin;
-- Submissions now go through the authenticated submit-feedback Edge Function.
-- No direct client uploads or report inserts may bypass server validation/quotas.
drop policy "Upload own feedback files" on storage.objects;
drop policy "Submit own feedback" on public.feedback_reports;
revoke insert on public.feedback_reports from anon, authenticated;
grant select, insert on public.feedback_reports to service_role;
create table public.feedback_daily_usage (
user_id uuid not null references auth.users(id) on delete cascade,
day date not null,
reports integer not null default 0,
files integer not null default 0,
bytes bigint not null default 0,
primary key (user_id, day)
);
alter table public.feedback_daily_usage enable row level security;
revoke all on public.feedback_daily_usage from public, anon, authenticated;
-- The upsert takes a row lock, so simultaneous requests cannot overspend quota.
-- Failed attempts consume quota too; deletion/retry must not reset the limit.
create function public.reserve_feedback_quota(account_id uuid, file_count integer, byte_count bigint)
returns boolean language plpgsql security definer set search_path = '' as $$
begin
if account_id is null or file_count is null or byte_count is null
or file_count < 0 or file_count > 5 or byte_count < 0 or byte_count > 10485760 then
return false;
end if;
insert into public.feedback_daily_usage as usage (user_id, day, reports, files, bytes)
values (account_id, (now() at time zone 'UTC')::date, 1, file_count, byte_count)
on conflict (user_id, day) do update
set reports = usage.reports + 1,
files = usage.files + excluded.files,
bytes = usage.bytes + excluded.bytes
where usage.reports < 10
and usage.files + excluded.files <= 25
and usage.bytes + excluded.bytes <= 52428800;
return found;
end;
$$;
revoke all on function public.reserve_feedback_quota(uuid, integer, bigint) from public, anon, authenticated;
grant execute on function public.reserve_feedback_quota(uuid, integer, bigint) to service_role;
commit;
@@ -0,0 +1,50 @@
-- Run as migration owner in a disposable Supabase database after both migrations.
begin;
insert into auth.users (id) values
('77000000-0000-0000-0000-000000000001'),
('77000000-0000-0000-0000-000000000002'),
('77000000-0000-0000-0000-000000000003');
do $$
declare
account uuid := '77000000-0000-0000-0000-000000000001';
begin
if has_function_privilege('authenticated', 'public.reserve_feedback_quota(uuid,integer,bigint)', 'EXECUTE')
or has_function_privilege('anon', 'public.reserve_feedback_quota(uuid,integer,bigint)', 'EXECUTE')
or has_table_privilege('authenticated', 'public.feedback_daily_usage', 'UPDATE')
or has_table_privilege('authenticated', 'public.feedback_reports', 'INSERT') then
raise exception 'Client can bypass server quotas';
end if;
if not has_function_privilege('service_role', 'public.reserve_feedback_quota(uuid,integer,bigint)', 'EXECUTE') then
raise exception 'Edge Function cannot reserve quota';
end if;
if exists (select 1 from pg_policies where schemaname = 'storage' and policyname = 'Upload own feedback files') then
raise exception 'Direct attachment uploads remain enabled';
end if;
if public.reserve_feedback_quota(account, 6, 1)
or public.reserve_feedback_quota(account, 1, 10485761)
or public.reserve_feedback_quota(account, -1, 0) then
raise exception 'Per-submission bounds bypassed';
end if;
for i in 1..5 loop
if not public.reserve_feedback_quota(account, 5, 1) then raise exception 'Valid file quota rejected'; end if;
end loop;
if public.reserve_feedback_quota(account, 1, 1) then raise exception 'Daily file cap bypassed'; end if;
account := '77000000-0000-0000-0000-000000000002';
for i in 1..5 loop
if not public.reserve_feedback_quota(account, 1, 10485760) then raise exception 'Valid byte quota rejected'; end if;
end loop;
if public.reserve_feedback_quota(account, 1, 1) then raise exception 'Daily byte cap bypassed'; end if;
account := '77000000-0000-0000-0000-000000000003';
for i in 1..10 loop
if not public.reserve_feedback_quota(account, 0, 0) then raise exception 'Valid report quota rejected'; end if;
end loop;
if public.reserve_feedback_quota(account, 0, 0) then raise exception 'Daily report cap bypassed'; end if;
if (select reports from public.feedback_daily_usage where user_id = account) <> 10 then
raise exception 'Rejected reservation changed quota';
end if;
-- A prior day's usage does not block today's quota.
update public.feedback_daily_usage set day = day - 1 where user_id = account;
if not public.reserve_feedback_quota(account, 1, 1) then raise exception 'Daily quota did not reset'; end if;
end;
$$;
rollback;
+15 -5
View File
@@ -10,7 +10,11 @@ import {
} from "@/components/ui/dialog";
import { ChatDots, File, X } from "@/components/ui/icons";
import { feedbackDiagnostics } from "@/lib/feedback/diagnostics";
import { submitFeedback, validateAttachments } from "@/lib/feedback/submitFeedback";
import {
feedbackErrorMessage,
submitFeedback,
validateAttachments,
} from "@/lib/feedback/submitFeedback";
export function FeedbackDialog({
className,
@@ -103,8 +107,8 @@ export function FeedbackDialog({
setSubject("");
setMessage("");
setFiles([]);
} catch {
setError("Could not send feedback. Please try again.");
} catch (error) {
setError(feedbackErrorMessage(error));
} finally {
sendingRef.current = false;
setSending(false);
@@ -116,7 +120,10 @@ export function FeedbackDialog({
isRequired
isDisabled={sending}
value={subject}
onChange={setSubject}
onChange={(value) => {
setSubject(value);
setError("");
}}
className="w-full"
>
<Label>Subject</Label>
@@ -127,7 +134,10 @@ export function FeedbackDialog({
isRequired
isDisabled={sending}
value={message}
onChange={setMessage}
onChange={(value) => {
setMessage(value);
setError("");
}}
className="w-full"
>
<Label>Description</Label>
+22
View File
@@ -20,3 +20,25 @@ describe("feedback diagnostics", () => {
expect(validateAttachments([file(100)])).toBeNull();
});
});
it("bounds final JSON bytes for Unicode, escapes, and oversized metadata", async () => {
const { vi } = await import("vitest");
vi.stubGlobal("window", new EventTarget());
vi.stubGlobal("navigator", { platform: "平台".repeat(5000), userAgent: "代理".repeat(5000) });
const { installFeedbackDiagnostics, feedbackDiagnostics } = await import("./diagnostics");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
installFeedbackDiagnostics();
for (let i = 0; i < 100; i++) console.warn(`${i}: ${'中\\\"\u0001'.repeat(500)}`);
const payload = feedbackDiagnostics();
expect(new TextEncoder().encode(payload).length).toBeLessThanOrEqual(290000);
const data = JSON.parse(payload);
expect(data.logs.length).toBeGreaterThan(0);
expect(data.logs.length).toBeLessThan(100);
expect(data.logs.at(-1)).toContain("99:");
expect(data.capturedAt).toBeTruthy();
} finally {
warn.mockRestore();
vi.unstubAllGlobals();
}
});
+14 -10
View File
@@ -40,15 +40,19 @@ export function installFeedbackDiagnostics() {
window.addEventListener("error", (event) => record("error", [event.message]));
window.addEventListener("unhandledrejection", (event) => record("rejection", [event.reason]));
}
const MAX_DIAGNOSTIC_BYTES = 290_000;
export function feedbackDiagnostics() {
return JSON.stringify(
{
capturedAt: new Date().toISOString(),
platform: navigator.platform,
userAgent: navigator.userAgent,
logs: [...entries],
},
null,
2,
);
const metadata = {
capturedAt: new Date().toISOString(),
platform: navigator.platform.slice(0, 2000),
userAgent: navigator.userAgent.slice(0, 2000),
};
const logs = [...entries];
const encoder = new TextEncoder();
let payload = JSON.stringify({ ...metadata, logs });
while (encoder.encode(payload).byteLength > MAX_DIAGNOSTIC_BYTES && logs.length) {
logs.shift();
payload = JSON.stringify({ ...metadata, logs });
}
return payload;
}
+86
View File
@@ -0,0 +1,86 @@
import { beforeEach, expect, it, vi } from "vitest";
import { feedbackHandler } from "../../../services/supabase/functions/submit-feedback/handler";
const backend = {
authenticate: vi.fn(),
reserve: vi.fn(),
upload: vi.fn(),
insert: vi.fn(),
remove: vi.fn(),
};
const handle = feedbackHandler(backend);
function request(files = [new File(["notes"], "notes.txt")], logs = "logs") {
const body = new FormData();
body.set("title", "Bug");
body.set("subject", "bug");
body.set("message", "Steps");
body.set("logs", logs);
for (const file of files) body.append("files", file);
return new Request("https://example.test", {
method: "POST",
headers: { Authorization: "Bearer session" },
body,
});
}
beforeEach(() => {
vi.resetAllMocks();
backend.authenticate.mockResolvedValue("user-id");
backend.reserve.mockResolvedValue(true);
backend.upload.mockResolvedValue(undefined);
backend.insert.mockResolvedValue(undefined);
backend.remove.mockResolvedValue(undefined);
});
it("authenticates and reserves quota before uploading, then saves server-owned paths", async () => {
expect((await handle(request())).status).toBe(200);
expect(backend.reserve).toHaveBeenCalledWith("user-id", 1, 5);
expect(backend.reserve.mock.invocationCallOrder[0]).toBeLessThan(
backend.upload.mock.invocationCallOrder[0],
);
expect(backend.insert).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "user-id",
attachments: [expect.objectContaining({ path: expect.stringMatching(/^user-id\//) })],
}),
);
});
it("rejects unauthenticated requests and exhausted quotas before storage", async () => {
backend.authenticate.mockResolvedValueOnce(null);
expect((await handle(request())).status).toBe(401);
expect(backend.reserve).not.toHaveBeenCalled();
backend.reserve.mockResolvedValue(false);
expect((await handle(request())).status).toBe(429);
expect(backend.upload).not.toHaveBeenCalled();
});
it("enforces attachment and diagnostics limits against direct requests", async () => {
for (const req of [
request(Array.from({ length: 6 }, () => new File(["x"], "x"))),
request([new File([], "empty")]),
request([
new File([new Uint8Array(6 * 1024 * 1024)], "a"),
new File([new Uint8Array(5 * 1024 * 1024)], "b"),
]),
request([], "中".repeat(100001)),
]) {
expect((await handle(req)).status).toBe(400);
}
expect(backend.reserve).not.toHaveBeenCalled();
expect(backend.upload).not.toHaveBeenCalled();
});
it("cleans partial uploads and does not expose backend errors", async () => {
backend.insert.mockRejectedValue(new Error("private db details"));
const response = await handle(request());
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ code: "SUBMISSION_FAILED" });
expect(backend.remove).toHaveBeenCalledWith([expect.stringMatching(/^user-id\//)]);
});
it("caps streamed request bodies even without a content-length header", async () => {
const req = new Request("https://example.test", {
method: "POST",
headers: {
Authorization: "Bearer session",
"Content-Type": "multipart/form-data; boundary=x",
},
body: new Uint8Array(12 * 1024 * 1024),
});
expect((await handle(req)).status).toBe(400);
expect(backend.reserve).not.toHaveBeenCalled();
});
+31 -34
View File
@@ -1,18 +1,9 @@
import { beforeEach, expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({
getUser: vi.fn(),
upload: vi.fn(),
remove: vi.fn(),
insert: vi.fn(),
}));
const api = vi.hoisted(() => ({ getUser: vi.fn(), invoke: vi.fn() }));
vi.mock("@/lib/auth/recordlyAuth", () => ({
recordlyAuth: {
auth: { getUser: api.getUser },
storage: { from: () => ({ upload: api.upload, remove: api.remove }) },
from: () => ({ insert: api.insert }),
},
recordlyAuth: { auth: { getUser: api.getUser }, functions: { invoke: api.invoke } },
}));
import { submitFeedback } from "./submitFeedback";
import { feedbackErrorMessage, submitFeedback } from "./submitFeedback";
const input = {
title: " Bug ",
subject: "bug",
@@ -23,30 +14,36 @@ const input = {
beforeEach(() => {
vi.resetAllMocks();
api.getUser.mockResolvedValue({ data: { user: { id: "user-id" } }, error: null });
api.upload.mockResolvedValue({ error: null });
api.insert.mockResolvedValue({ error: null });
api.remove.mockResolvedValue({ error: null });
api.invoke.mockResolvedValue({ data: { success: true }, error: null });
});
it("stores a report only after its private attachments upload", async () => {
it("submits attachments and diagnostics through the server endpoint", async () => {
await submitFeedback(input);
expect(api.upload).toHaveBeenCalledOnce();
expect(api.insert).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "user-id",
title: "Bug",
message: "Steps",
attachments: [expect.objectContaining({ name: "notes.txt" })],
}),
);
expect(api.remove).not.toHaveBeenCalled();
const [name, { body }] = api.invoke.mock.calls[0];
expect(name).toBe("submit-feedback");
expect(body.get("title")).toBe("Bug");
expect(body.get("message")).toBe("Steps");
expect(body.get("logs")).toBe("logs");
expect(body.getAll("files")[0].name).toBe("notes.txt");
});
it("cleans uploaded attachments up when the report is rejected", async () => {
api.insert.mockResolvedValue({ error: new Error("unavailable") });
await expect(submitFeedback(input)).rejects.toThrow("unavailable");
expect(api.remove).toHaveBeenCalledWith([expect.stringMatching(/^user-id\//)]);
});
it("does not upload when the account session has expired", async () => {
it("does not submit when the session has expired and explains how to recover", async () => {
api.getUser.mockResolvedValue({ data: { user: null }, error: null });
await expect(submitFeedback(input)).rejects.toThrow("sign in");
expect(api.upload).not.toHaveBeenCalled();
const error = await submitFeedback(input).catch((error) => error);
expect(feedbackErrorMessage(error)).toContain("sign in again");
expect(api.invoke).not.toHaveBeenCalled();
});
it("shows the server quota error without exposing internal errors", async () => {
api.invoke.mockResolvedValue({
error: {
context: new Response(JSON.stringify({ code: "FEEDBACK_LIMIT" }), { status: 429 }),
},
});
const error = await submitFeedback(input).catch((error) => error);
expect(feedbackErrorMessage(error)).toContain("tomorrow");
expect(feedbackErrorMessage(new Error("database private details"))).toBe(
"Could not send feedback. Please try again.",
);
});
it("rejects unconfirmed submissions", async () => {
api.invoke.mockResolvedValue({ data: {}, error: null });
await expect(submitFeedback(input)).rejects.toThrow("not confirmed");
});
+36 -32
View File
@@ -1,5 +1,20 @@
import { recordlyAuth } from "@/lib/auth/recordlyAuth";
export class FeedbackError extends Error {}
export function feedbackErrorMessage(error: unknown) {
return error instanceof FeedbackError
? error.message
: "Could not send feedback. Please try again.";
}
const submissionErrors: Record<string, string> = {
SIGN_IN_REQUIRED: "Please sign in again to send feedback.",
INVALID_FEEDBACK: "Check your subject and description, then try again.",
INVALID_ATTACHMENTS: "Attach up to 5 nonempty files totaling 10 MB or less.",
FEEDBACK_LIMIT: "Youve reached todays feedback limit. Please try again tomorrow.",
};
export const MAX_FILES = 5;
export const MAX_BYTES = 10 * 1024 * 1024;
export function validateAttachments(files: File[]) {
@@ -17,40 +32,29 @@ export async function submitFeedback(input: {
logs: string | null;
}) {
const client = recordlyAuth;
if (!client) throw new Error("Feedback is unavailable until account services are configured.");
if (!client)
throw new FeedbackError("Feedback is unavailable until account services are configured.");
const { data, error } = await client.auth.getUser();
if (error || !data.user) throw new Error("Please sign in again to send feedback.");
if (error || !data.user) throw new FeedbackError("Please sign in again to send feedback.");
const validation = validateAttachments(input.files);
if (validation) throw new Error(validation);
const id = crypto.randomUUID();
const uploaded: string[] = [];
const attachments = [];
try {
for (const file of input.files) {
const path = `${data.user.id}/${id}/${crypto.randomUUID()}`;
const { error: uploadError } = await client.storage
.from("feedback-attachments")
.upload(path, file, { contentType: "application/octet-stream" });
if (uploadError) throw uploadError;
uploaded.push(path);
attachments.push({ path, name: file.name, size: file.size, type: file.type });
if (validation) throw new FeedbackError(validation);
const body = new FormData();
body.set("title", input.title.trim());
body.set("subject", input.subject);
body.set("message", input.message.trim());
if (input.logs !== null) body.set("logs", input.logs);
for (const file of input.files) body.append("files", file);
const result = await client.functions.invoke("submit-feedback", { body });
if (result.error) {
const response = result.error.context;
if (response instanceof Response) {
if (response.status === 401) throw new FeedbackError(submissionErrors.SIGN_IN_REQUIRED);
const payload = await response.json().catch(() => null);
if (payload?.code && Object.keys(submissionErrors).includes(payload.code)) {
throw new FeedbackError(submissionErrors[payload.code]);
}
}
const { error: insertError } = await client.from("feedback_reports").insert({
id,
user_id: data.user.id,
title: input.title.trim(),
subject: input.subject,
message: input.message.trim(),
logs: input.logs,
attachments,
});
if (insertError) throw insertError;
} catch (error) {
if (uploaded.length)
await client.storage
.from("feedback-attachments")
.remove(uploaded)
.catch(() => undefined);
throw error;
throw result.error;
}
if (result.data?.success !== true) throw new Error("Feedback submission was not confirmed");
}
+7 -1
View File
@@ -27,8 +27,14 @@ test("feedback preserves a draft on failure, supports attachments, and includes
await expect(modal.getByRole("checkbox")).toHaveCount(0);
await expect(modal.getByLabel("Title", { exact: true })).toHaveCount(0);
await modal.getByRole("button", { name: "Send feedback", exact: true }).click();
await expect(modal.getByRole("alert")).toContainText("Could not send feedback");
await expect(modal.getByRole("alert")).toContainText(/sign in again|unavailable/i);
await expect(modal.getByLabel("Subject", { exact: true })).toHaveValue("Export stops early");
await modal.getByLabel("Subject", { exact: true }).fill("Export stops early (updated)");
await expect(modal.getByRole("alert")).toHaveCount(0);
await modal.getByRole("button", { name: "Send feedback", exact: true }).click();
await expect(modal.getByRole("alert")).toBeVisible();
await modal.getByLabel("Description", { exact: true }).fill("Updated reproduction steps.");
await expect(modal.getByRole("alert")).toHaveCount(0);
await page.screenshot({ path: "test-results/feedback.png" });
await modal.getByRole("button", { name: "Close", exact: true }).click();
await page.getByRole("button", { name: "Home", exact: true }).click();