From db96e1494a19d18a9a562817e543383704d7c5fe Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:54:22 -0700 Subject: [PATCH 01/13] fix(site): decode build output paths with fileURLToPath --- src/integrations/strip-html-links.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/integrations/strip-html-links.mjs b/src/integrations/strip-html-links.mjs index dedca4da..f04ffc29 100644 --- a/src/integrations/strip-html-links.mjs +++ b/src/integrations/strip-html-links.mjs @@ -1,5 +1,6 @@ import { readdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; /** * Drop the trailing slash from the URLs in the MCP corpus. @@ -53,7 +54,7 @@ export default function stripHtmlLinks() { name: 'qrate:strip-html-links', hooks: { 'astro:build:done': async ({ dir, logger }) => { - const root = new URL(dir).pathname.replace(/^\/([A-Za-z]:)/, '$1'); + const root = fileURLToPath(dir); const walk = async (p) => { const out = []; From bbae47423c1082eee0b100ff29e4a29e54659c42 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:54:22 -0700 Subject: [PATCH 02/13] feat(feedback): add private hosted form and Linear intake --- FEEDBACK.md | 44 ++++++++++++ src/lib/feedback.test.ts | 117 ++++++++++++++++++++++++++++++++ src/lib/feedback.ts | 137 ++++++++++++++++++++++++++++++++++++++ src/pages/api/feedback.ts | 6 ++ src/pages/feedback.astro | 108 ++++++++++++++++++++++++++++++ src/pages/privacy.astro | 35 ++++++++-- wrangler.jsonc | 8 +++ 7 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 FEEDBACK.md create mode 100644 src/lib/feedback.test.ts create mode 100644 src/lib/feedback.ts create mode 100644 src/pages/api/feedback.ts create mode 100644 src/pages/feedback.astro diff --git a/FEEDBACK.md b/FEEDBACK.md new file mode 100644 index 00000000..97cfab05 --- /dev/null +++ b/FEEDBACK.md @@ -0,0 +1,44 @@ +# Feedback deployment + +Desktop branch: `feat/in-app-feedback`. Website branch: `feat/feedback-site`. +Deploy the website before releasing the desktop buttons. + +## Configure + +1. Create a Cloudflare Turnstile widget for `qrate.dvnl.work`. +2. Set build variable `PUBLIC_TURNSTILE_SITE_KEY` to its public site key. +3. Run `bunx wrangler secret put TURNSTILE_SECRET_KEY`. +4. Run `bunx wrangler secret put LINEAR_API_KEY`. + +The Linear key needs read access, issue creation and file upload access for Grass Labs. +Never put either secret in source files, command arguments, or the browser. +Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake & Stabilization / Backlog. + +## Verify before deployment + +- Run `bun test src/lib/feedback.test.ts`. +- Run `bun --bun run build`. +- Run Wrangler preview with local secrets supplied through the Cloudflare-supported local environment. +- Verify Turnstile hostname and action checks using the preview's hostname. +- Submit a synthetic report with no private data. Verify routing and labels in Linear. +- Submit a text attachment and screenshot. Verify private attachment access and Logs Attached. +- Simulate a lost response and retry the same UUID. Verify only one issue exists. +- Confirm no analytics beacon appears on `/feedback`. +- Confirm the browser clears the fragment immediately and diagnostics are editable. +- Test the title-bar button in packaged Windows, macOS, and Linux builds. + +## Operations and limits + +Five attempts per minute per Cloudflare location/IP. Workplace users may share an IP. +Three attachments, up to 2 MiB each. PNG/JPEG screenshots and TXT/LOG logs only. +No automatic crash uploads, session recording, screenshot capture, or full project upload. +The report UUID also serves as Linear issue UUID for retry deduplication. Retries require a new Turnstile token. +Uploads that succeed before a later provider failure can leave orphaned Linear assets. +No private report text is logged by the handler. + +The public privacy policy describes purpose-based retention, not automatic timed deletion. +Confirm the team's retention/deletion process and Linear team visibility before launch. +Configure the Feedback Dashboard manually using project, milestone, active states and Feedback OR Needs Triage labels. +Remove Needs Triage when reviewed. Apply Reproducible only after verification. + +Deployment and live provider verification are separate operator steps. Unit tests use mocked providers, not actual tickets. diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts new file mode 100644 index 00000000..7e3d3a96 --- /dev/null +++ b/src/lib/feedback.test.ts @@ -0,0 +1,117 @@ +import { test, expect } from 'bun:test'; +import { categories, submit, validate } from './feedback'; + +function form() { + const data = new FormData(); + for (const [key, value] of Object.entries({ + id: 'd721d1ca-6213-4bda-8928-21ca37671d96', category: 'bug', summary: 'Example', + description: 'Steps and expected result', consent: 'yes', 'cf-turnstile-response': 'test', + })) data.set(key, value); + return data; +} +const env = { + LINEAR_API_KEY: 'test-only', TURNSTILE_SECRET_KEY: 'test-only', + FEEDBACK_LIMITER: { limit: async () => ({ success: true }) }, +}; +const request = (data = form()) => new Request('https://qrate.dvnl.work/api/feedback', { + method: 'POST', headers: { Origin: 'https://qrate.dvnl.work' }, body: data, +}); + +test('all six categories validate; unknown fields, category and missing consent fail', () => { + for (const category of Object.keys(categories)) { + const data = form(); data.set('category', category); + expect(validate(data).category).toBe(category); + } + for (const [key, value] of [['category', '__proto__'], ['consent', ''], ['unexpected', 'x']]) { + const data = form(); data.set(key, value); + expect(() => validate(data)).toThrow(); + } +}); + +test('reject executable and oversized attachments', () => { + const data = form(); + data.append('files', new File(['x'], 'secret.exe')); + expect(() => validate(data)).toThrow(); + data.set('files', new File([new Uint8Array(2 * 1024 * 1024 + 1)], 'large.log')); + expect(() => validate(data)).toThrow(); +}); + +test('no provider calls for invalid input or unconfigured service', async () => { + const never = async () => { throw new Error('Unexpected network call'); }; + expect((await submit(request(), {}, never as any)).status).toBe(503); + const data = form(); data.delete('consent'); + expect((await submit(request(data), env, never as any)).status).toBe(400); +}); + +test('create issue with exact routing and triage labels', async () => { + let input: any; + const mocked = async (url: string, options: any) => { + if (url.includes('siteverify')) return Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' }); + const body = JSON.parse(options.body); + if (body.query.startsWith('query')) return Response.json({ data: { issues: { nodes: [] } } }); + input = body.variables.input; + return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id } } } }); + }; + expect((await submit(request(), env, mocked as any)).status).toBe(200); + expect(input.stateId).toBe('4b7df4af-a498-4656-8d0a-c87e1c1d28aa'); + expect(input.projectMilestoneId).toBe('7cdb86ec-c9a9-4d58-b46b-2e382566f1f5'); + expect(input.labelIds).toContain('88dcf4fa-6ccd-4d48-b427-d6d190745e05'); + expect(input.labelIds).not.toContain('0429eef3-39f3-4f67-a303-a9f985122a61'); +}); + +test('Turnstile wrong hostname fails before Linear', async () => { + const mocked = async () => Response.json({ success: true, hostname: 'attacker.example', action: 'feedback' }); + expect((await submit(request(), env, mocked as any)).status).toBe(403); +}); + +test('existing issue returns receipt without creating another', async () => { + let calls = 0; + const mocked = async () => { + calls++; + return calls === 1 + ? Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' }) + : Response.json({ data: { issues: { nodes: [{ id: 'existing' }] } } }); + }; + expect((await submit(request(), env, mocked as any)).status).toBe(200); + expect(calls).toBe(2); +}); + +test('GraphQL errors with HTTP 200 never report success', async () => { + let calls = 0; + const mocked = async () => ++calls === 1 + ? Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' }) + : Response.json({ errors: [{ message: 'private provider error' }] }); + const response = await submit(request(), env, mocked as any); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain('private provider error'); +}); + +test('log upload uses generic filename and labels only the successful issue', async () => { + let input: any; + let uploadName: string | undefined; + const data = form(); + data.append('files', new File(['redacted log'], 'private-collection.log')); + const mocked = async (url: string, options: any) => { + if (url.includes('siteverify')) return Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' }); + if (options.method === 'PUT') return new Response(null, { status: 200 }); + const body = JSON.parse(options.body); + if (body.query.startsWith('query')) return Response.json({ data: { issues: { nodes: [] } } }); + if (body.query.includes('fileUpload')) { + uploadName = body.variables.name; + return Response.json({ data: { fileUpload: { success: true, uploadFile: { uploadUrl: 'https://upload.example', assetUrl: 'https://asset.example/log', headers: [] } } } }); + } + input = body.variables.input; + return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id } } } }); + }; + expect((await submit(request(data), env, mocked as any)).status).toBe(200); + expect(uploadName).toBe('log-1.log'); + expect(input.labelIds).toContain('0429eef3-39f3-4f67-a303-a9f985122a61'); + expect(input.description).not.toContain('private-collection'); +}); + +test('rate limit stops all provider work', async () => { + const response = await submit(request(), { + ...env, FEEDBACK_LIMITER: { limit: async () => ({ success: false }) }, + }, (() => { throw new Error('Unexpected fetch'); }) as any); + expect(response.status).toBe(429); +}); diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts new file mode 100644 index 00000000..d01c1949 --- /dev/null +++ b/src/lib/feedback.ts @@ -0,0 +1,137 @@ +export const categories: Record = { + bug: '9c1c54ef-3835-4b07-b7fe-c02952216f74', + ui_ux: '8ac21715-4a1e-4a00-a4bb-06276a4ff7ca', + feature: 'bf455f57-e866-46ae-8a73-5dcdd4f1cb23', + crash: 'ed742751-05de-42db-a428-e52864a8a907', + performance: '154aeee1-f247-4bc6-b797-3b06051f1a7b', + improvement: '486d0f47-7520-42d5-88a5-c1fde5dcf769', +}; +export const MAX_BYTES = 7 * 1024 * 1024; + +export function validate(form: FormData) { + const allowed = ['id', 'category', 'summary', 'description', 'email', 'diagnostics', 'consent', 'cf-turnstile-response', 'files']; + for (const key of form.keys()) { + if (!allowed.includes(key) || (key !== 'files' && form.getAll(key).length !== 1)) throw new Error('Invalid fields'); + } + const text = (key: string, max: number, required = false) => { + const value = form.get(key) ?? ''; + if (typeof value !== 'string' || value.length > max || (required && !value.trim())) throw new Error(`Invalid ${key}`); + return value.trim(); + }; + const id = text('id', 36, true); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) throw new Error('Invalid ID'); + const category = text('category', 20, true); + if (!Object.hasOwn(categories, category)) throw new Error('Invalid category'); + if (text('consent', 3) !== 'yes') throw new Error('Review required'); + const email = text('email', 254); + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Invalid email'); + if (form.getAll('files').some(file => !(file instanceof File))) throw new Error('Invalid files'); + const files = form.getAll('files').filter((file): file is File => file instanceof File && file.size > 0); + if (files.length > 3) throw new Error('Maximum three files'); + for (const file of files) { + if (file.size > 2 * 1024 * 1024 || !/\.(png|jpg|jpeg|txt|log)$/i.test(file.name)) throw new Error('Use PNG, JPEG, TXT or LOG files up to 2 MiB'); + } + return { + id, category, email, files, + summary: text('summary', 160, true), + description: text('description', 10000, true), + diagnostics: text('diagnostics', 8000), + token: text('cf-turnstile-response', 2048, true), + }; +} + +export async function submit(request: Request, env: any, fetcher: typeof fetch = fetch): Promise { + const reply = (status: number, body: object) => Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } }); + if (!env.LINEAR_API_KEY || !env.TURNSTILE_SECRET_KEY || !env.FEEDBACK_LIMITER) return reply(503, { error: 'Feedback is not configured yet.' }); + if (request.headers.get('origin') !== new URL(request.url).origin) return reply(403, { error: 'Open the qrate feedback form to submit.' }); + const { success } = await env.FEEDBACK_LIMITER.limit({ key: request.headers.get('cf-connecting-ip') || 'unknown' }); + if (!success) return reply(429, { error: 'Too many requests. Please try again later.' }); + let report: ReturnType; + try { + if (!request.headers.get('content-type')?.startsWith('multipart/form-data')) throw new Error('Expected form'); + const reader = request.body?.getReader(); + if (!reader) throw new Error('Missing body'); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > MAX_BYTES) { await reader.cancel(); return reply(413, { error: 'Report is too large.' }); } + chunks.push(value); + } + const form = await new Response(new Blob(chunks), { headers: { 'content-type': request.headers.get('content-type')! } }).formData(); + report = validate(form); + } catch { + return reply(400, { error: 'Check required fields and attachments (three PNG/JPEG/TXT/LOG files, up to 2 MiB each).' }); + } + try { + const verification = await fetcher('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', body: new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: report.token }), + signal: AbortSignal.timeout(15000), + }); + const challenge = await verification.json() as any; + if (!verification.ok || !challenge.success || challenge.hostname !== new URL(request.url).hostname || challenge.action !== 'feedback') { + return reply(403, { error: 'Verification expired. Please try again.' }); + } + const graphql = async (query: string, variables: object) => { + const response = await fetcher('https://api.linear.app/graphql', { + method: 'POST', + headers: { Authorization: env.LINEAR_API_KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(20000), + }); + const result = await response.json() as any; + if (!response.ok || result.errors?.length || !result.data) throw new Error('Linear failed'); + return result.data; + }; + // A stable issue UUID makes retries safe without a second database. + const existing = await graphql('query($id: ID!) { issues(filter: { id: { eq: $id }, project: { id: { eq: "aa8cfc24-95f6-461e-aac4-46437d89459e" } } }) { nodes { id } } }', { id: report.id }); + if (existing.issues.nodes.length) return reply(200, { receipt: report.id }); + const attachments: string[] = []; + let logs = false; + for (const [index, file] of report.files.entries()) { + const extension = file.name.split('.').pop()!.toLowerCase(); + const isLog = ['txt', 'log'].includes(extension); + const type = isLog ? 'text/plain' : extension === 'png' ? 'image/png' : 'image/jpeg'; + const filename = `${isLog ? 'log' : 'screenshot'}-${index + 1}.${extension}`; + const upload = await graphql( + 'mutation($type: String!, $name: String!, $size: Int!) { fileUpload(contentType: $type, filename: $name, size: $size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }', + { type, name: filename, size: file.size }, + ); + if (!upload.fileUpload.success || !upload.fileUpload.uploadFile) throw new Error('Upload failed'); + const target = upload.fileUpload.uploadFile; + const headers = new Headers({ 'Content-Type': type }); + for (const { key, value } of target.headers) headers.set(key, value); + const stored = await fetcher(target.uploadUrl, { method: 'PUT', headers, body: file, signal: AbortSignal.timeout(30000) }); + if (!stored.ok) throw new Error('Upload failed'); + attachments.push(`[${filename}](${target.assetUrl})`); + logs ||= isLog; + } + const labelIds = [ + '9caf21d4-082c-4a89-955e-981e850b6405', '88dcf4fa-6ccd-4d48-b427-d6d190745e05', + '26451746-50ca-453e-840e-e8ed9c5a6fb7', 'a47da0e4-5ef6-4a40-98ec-2ee8dcdcea20', + categories[report.category], + ...(logs ? ['0429eef3-39f3-4f67-a303-a9f985122a61'] : []), + ]; + const created = await graphql('mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id } } }', { + input: { + id: report.id, teamId: 'd14f1e07-93ce-4cfe-968c-c6372c6f58a6', + projectId: 'aa8cfc24-95f6-461e-aac4-46437d89459e', + projectMilestoneId: '7cdb86ec-c9a9-4d58-b46b-2e382566f1f5', + stateId: '4b7df4af-a498-4656-8d0a-c87e1c1d28aa', labelIds, + title: report.summary, + description: [ + report.description, `Category: ${report.category}`, + report.email ? `Reply to: ${report.email}` : '', + report.diagnostics ? `## User-reviewed diagnostics\n\n${report.diagnostics}` : '', + ...attachments, + ].filter(Boolean).join('\n\n'), + }, + }); + if (!created.issueCreate.success || !created.issueCreate.issue) throw new Error('Create failed'); + return reply(200, { receipt: report.id }); + } catch { + return reply(502, { error: 'Delivery could not be confirmed. Your form is unchanged. Retry with the same report ID.' }); + } +} diff --git a/src/pages/api/feedback.ts b/src/pages/api/feedback.ts new file mode 100644 index 00000000..746657d3 --- /dev/null +++ b/src/pages/api/feedback.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; +import { env } from 'cloudflare:workers'; +import { submit } from '../../lib/feedback'; + +export const prerender = false; +export const POST: APIRoute = ({ request }) => submit(request, env); diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro new file mode 100644 index 00000000..2d0fe3d5 --- /dev/null +++ b/src/pages/feedback.astro @@ -0,0 +1,108 @@ +--- +import '../styles/global.css'; +const sitekey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY; +--- + + + + + + + + Send feedback — qrate + + + +
+ qrate +

Send feedback

+

Reports go privately to the qrate team through Cloudflare and Linear. No account required.

+
+ + + + + +
+ Review or edit diagnostics + +
+ +

Up to three files, 2 MiB each. Use Help → Open Logs Folder in qrate. Logs can contain secrets and collection data. Inspect and redact files before attaching them.

+ + {sitekey ?
:

Feedback submission is not configured yet. You can still prepare your report.

} + +

+
+
+ + + + + diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index e786999f..21ae6563 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -10,28 +10,28 @@ const CONTACT = 'qrate@dvnl.work'; eyebrow="Privacy" updated="16 August 2026" description="qrate is a desktop app with no accounts and no telemetry. Your collection stays on your own disk. What it touches, and what it never sends." - lede="qrate is a desktop application. Your catalogue stays on your own disk, and the project operates no servers that hold your data." + lede="qrate is a desktop application. Your catalogue stays on your own disk. Optional feedback reports go privately to the qrate team." >

The short version

The application has no accounts, no telemetry and no analytics. It does not phone home. Every record, field, note and linked file stays in the .qrate file on your own disk. The only time qrate talks to anyone else is when you ask it to: exporting to Google Sheets, - downloading a dictionary, or checking for a release. This website counts page views, without + downloading a dictionary, checking for a release, or submitting feedback. This website counts page views, without cookies and without identifying you, which is the one difference between the two.

What qrate collects

- Nothing. The application does not send your catalogue, your file paths, your settings or any - usage data to us or to anyone else. There is no crash reporting service and no usage counter. + The application does not automatically send your catalogue, file paths, settings or usage + data to us. There is no automatic crash reporting service and no usage counter. qrate writes a session log on your own machine to help you diagnose problems. That log stays there unless you attach it to a bug report yourself.

This website

- This site is static: pages and images, no accounts and no tracking cookies. Cloudflare serves + This site serves pages, images, and an optional feedback form, without accounts. Cloudflare serves it, and records standard request information for delivery and abuse prevention. That information includes your IP address, your user agent and the page you asked for. We do not use it to build profiles, and we run no advertising scripts. GitHub serves the application @@ -45,8 +45,8 @@ const CONTACT = 'qrate@dvnl.work'; that would follow you between sessions or to any other site. It reports the page you viewed, the referrer, and coarse details such as country, browser and device type. It has no access to your catalogue, and it runs only on this website. Cloudflare discards the IP address at the - edge rather than storing it with the measurement. The application itself still reports nothing, - as described above. To opt out, block static.cloudflareinsights.com; any content + edge rather than storing it with the measurement. The feedback page does not load this analytics script. + To opt out, block static.cloudflareinsights.com; any content blocker will do it, and the site works exactly the same without the script.

@@ -56,6 +56,27 @@ const CONTACT = 'qrate@dvnl.work'; between your browser, Google, and your own computer.

+

Optional feedback reports

+

+ The Feedback button opens a browser form with app version, commit, operating system, + architecture, project dimensions, and plugin counts. This summary contains no cell values, + project paths, plugin names, or logs. The URL fragment holds the summary locally in your + browser. The page removes the fragment and lets you edit or exclude the summary. +

+

+ Only submitting the form sends its contents to us. Reports can include your description, + optional contact email, diagnostic summary, and files you explicitly attach. + Inspect logs and screenshots for private information before attaching them. + Cloudflare processes the submission and uses Turnstile and request information to prevent abuse. + Linear stores reports and attachments for the qrate team. Reports are not public GitHub issues. +

+

+ We use reports to investigate issues, improve qrate, and reply if you provide an email. + Reports remain in Linear while needed for investigation and follow-up. + To request deletion, email {CONTACT} with your report ID. + Provider backup and security retention policies may apply after deletion. +

+

Google user data

qrate can export a project to a Google Sheet. That is the only feature that touches a Google diff --git a/wrangler.jsonc b/wrangler.jsonc index 7049aa58..fc26bbcc 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -20,6 +20,14 @@ "not_found_handling": "404-page" }, + "ratelimits": [ + { + "name": "FEEDBACK_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 5, "period": 60 } + } + ], + "observability": { "enabled": true } From e9047cc06a55c8c47fccf2f208c6f36f184562e7 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:26:35 -0700 Subject: [PATCH 03/13] dev(feedback): support local form configuration --- .dev.vars.example | 5 +++++ FEEDBACK.md | 15 ++++++++++++++- src/pages/feedback.astro | 5 ++++- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 .dev.vars.example diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 00000000..49778c1c --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,5 @@ +# Copy this file to .dev.vars. Wrangler reads it during local development. +# Do not commit .dev.vars. +LINEAR_API_KEY= +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= diff --git a/FEEDBACK.md b/FEEDBACK.md index 97cfab05..6be8c201 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -6,7 +6,7 @@ Deploy the website before releasing the desktop buttons. ## Configure 1. Create a Cloudflare Turnstile widget for `qrate.dvnl.work`. -2. Set build variable `PUBLIC_TURNSTILE_SITE_KEY` to its public site key. +2. Set Worker variable `TURNSTILE_SITE_KEY` to its public site key. 3. Run `bunx wrangler secret put TURNSTILE_SECRET_KEY`. 4. Run `bunx wrangler secret put LINEAR_API_KEY`. @@ -14,6 +14,19 @@ The Linear key needs read access, issue creation and file upload access for Gras Never put either secret in source files, command arguments, or the browser. Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake & Stabilization / Backlog. +## Test locally + +1. Copy `.dev.vars.example` to `.dev.vars`. +2. Put the Linear key and a Turnstile widget's site and secret keys in `.dev.vars`. +3. Allow `localhost` in that Turnstile widget. +4. Run `bun install`. +5. Run `bun run dev`. +6. Open `http://localhost:4321/feedback`. +7. In another checkout on `feat/in-app-feedback`, run `cargo run`. + +Debug qrate builds open the local form. Release builds open `https://qrate.dvnl.work/feedback`. +The repository ignores `.dev.vars`. Stop the local server before switching site branches. + ## Verify before deployment - Run `bun test src/lib/feedback.test.ts`. diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index 2d0fe3d5..4d7499a4 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -1,6 +1,9 @@ --- import '../styles/global.css'; -const sitekey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY; +import { env } from 'cloudflare:workers'; + +export const prerender = false; +const sitekey = env.TURNSTILE_SITE_KEY; --- From 4c17cabf8eeaa150fb3682345310b5afe08cc0e0 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:34:04 -0700 Subject: [PATCH 04/13] dev(feedback): use Cloudflare test keys locally --- .dev.vars.example | 5 +++-- FEEDBACK.md | 5 +++-- src/lib/feedback.test.ts | 19 ++++++++++++++++++- src/lib/feedback.ts | 6 +++++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index 49778c1c..93ea7f94 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,5 +1,6 @@ # Copy this file to .dev.vars. Wrangler reads it during local development. # Do not commit .dev.vars. LINEAR_API_KEY= -TURNSTILE_SITE_KEY= -TURNSTILE_SECRET_KEY= +# Cloudflare's public test keys always pass and work only for local testing. +TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA diff --git a/FEEDBACK.md b/FEEDBACK.md index 6be8c201..4ad667f7 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -17,8 +17,8 @@ Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake ## Test locally 1. Copy `.dev.vars.example` to `.dev.vars`. -2. Put the Linear key and a Turnstile widget's site and secret keys in `.dev.vars`. -3. Allow `localhost` in that Turnstile widget. +2. Put the Linear key in `.dev.vars`. +3. Keep Cloudflare's public test Turnstile keys from the example file. 4. Run `bun install`. 5. Run `bun run dev`. 6. Open `http://localhost:4321/feedback`. @@ -26,6 +26,7 @@ Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake Debug qrate builds open the local form. Release builds open `https://qrate.dvnl.work/feedback`. The repository ignores `.dev.vars`. Stop the local server before switching site branches. +Cloudflare test keys work on local loopback addresses only. Production requires a real Turnstile widget. ## Verify before deployment diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index 7e3d3a96..7c6512b2 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -13,7 +13,7 @@ const env = { LINEAR_API_KEY: 'test-only', TURNSTILE_SECRET_KEY: 'test-only', FEEDBACK_LIMITER: { limit: async () => ({ success: true }) }, }; -const request = (data = form()) => new Request('https://qrate.dvnl.work/api/feedback', { +const request = (data = form(), url = 'https://qrate.dvnl.work/api/feedback') => new Request(url, { method: 'POST', headers: { Origin: 'https://qrate.dvnl.work' }, body: data, }); @@ -64,6 +64,23 @@ test('Turnstile wrong hostname fails before Linear', async () => { expect((await submit(request(), env, mocked as any)).status).toBe(403); }); +test('Cloudflare test keys work only on a local form', async () => { + const mocked = async (_url: string, options: any) => { + if (options.body instanceof URLSearchParams) { + return Response.json({ + success: true, + hostname: 'example.com', + metadata: { result_with_testing_key: true }, + }); + } + return Response.json({ data: { issues: { nodes: [{ id: 'existing' }] } } }); + }; + const local = request(form(), 'http://localhost:4321/api/feedback'); + local.headers.set('Origin', 'http://localhost:4321'); + expect((await submit(local, env, mocked as any)).status).toBe(200); + expect((await submit(request(), env, mocked as any)).status).toBe(403); +}); + test('existing issue returns receipt without creating another', async () => { let calls = 0; const mocked = async () => { diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index d01c1949..bb6e0652 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -71,7 +71,11 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = signal: AbortSignal.timeout(15000), }); const challenge = await verification.json() as any; - if (!verification.ok || !challenge.success || challenge.hostname !== new URL(request.url).hostname || challenge.action !== 'feedback') { + const requestHost = new URL(request.url).hostname; + const localTest = ['localhost', '127.0.0.1'].includes(requestHost) + && challenge.metadata?.result_with_testing_key === true; + const production = challenge.hostname === requestHost && challenge.action === 'feedback'; + if (!verification.ok || !challenge.success || (!localTest && !production)) { return reply(403, { error: 'Verification expired. Please try again.' }); } const graphql = async (query: string, variables: object) => { From 6efc6d37dd298c46d4990e231b7cd7d9ec8ed1fa Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:14:34 -0700 Subject: [PATCH 05/13] style(feedback): match the qrate site design --- src/layouts/Base.astro | 5 +- src/pages/feedback.astro | 324 +++++++++++++++++++++++++++------------ 2 files changed, 232 insertions(+), 97 deletions(-) diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 23854213..f4d2850e 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -11,6 +11,8 @@ const { current = '', version = '', noindex = false, + noReferrer = false, + analytics = true, } = Astro.props; // Absolute URLs: scrapers do not resolve relative ones. `build.format: 'file'` @@ -25,7 +27,7 @@ const ogImage = new URL('/og.png', Astro.site); // Cloudflare Web Analytics: cookieless, no cross-site identifier, and it only // exists when CF_BEACON_TOKEN is set at build time. The docs pages get the same // tag from astro.config.mjs, because Starlight does not render this layout. -const beacon = import.meta.env.CF_BEACON_TOKEN; +const beacon = analytics ? import.meta.env.CF_BEACON_TOKEN : undefined; --- @@ -39,6 +41,7 @@ const beacon = import.meta.env.CF_BEACON_TOKEN; {noindex && } + {noReferrer && } diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index 4d7499a4..139ed197 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -1,111 +1,243 @@ --- -import '../styles/global.css'; import { env } from 'cloudflare:workers'; +import Base from '../layouts/Base.astro'; export const prerender = false; const sitekey = env.TURNSTILE_SITE_KEY; --- - - - - - - - - Send feedback — qrate - - - -

- qrate -

Send feedback

-

Reports go privately to the qrate team through Cloudflare and Linear. No account required.

-
-
- - - - + const response = await fetch('/api/feedback', { method: 'POST', body: data }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Unable to send.'); + status.textContent = `Thank you. Report received: ${result.receipt}`; + button.textContent = 'Sent'; + } catch (error) { + submitting = false; + status.textContent = error instanceof Error ? error.message : 'Unable to send. Please retry.'; + button.disabled = false; + (window as any).turnstile?.reset(); + } + }); + + + + From 5859bee9d59293516329d4464bfddc9d8c9b9d92 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:22:46 -0700 Subject: [PATCH 06/13] fix(feedback): align single-line form controls --- src/pages/feedback.astro | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index 139ed197..b1e73bec 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -209,6 +209,7 @@ const sitekey = env.TURNSTILE_SITE_KEY; input:not([type='checkbox']), textarea, select { + box-sizing: border-box; width: 100%; border: 1px solid var(--rule); background: var(--paper); @@ -218,6 +219,11 @@ const sitekey = env.TURNSTILE_SITE_KEY; outline: none; } + input:not([type='checkbox']):not([type='file']), + select { + height: 3.25rem; + } + input:not([type='checkbox']):focus, textarea:focus, select:focus { From c27aa0a55c2920b048aacaf34b57c47f447dbaf8 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:31:42 -0700 Subject: [PATCH 07/13] feat(feedback): confirm reports with ticket reference --- src/lib/feedback.test.ts | 9 ++++- src/lib/feedback.ts | 8 ++-- src/pages/feedback.astro | 85 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index 7c6512b2..27909687 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -50,9 +50,14 @@ test('create issue with exact routing and triage labels', async () => { const body = JSON.parse(options.body); if (body.query.startsWith('query')) return Response.json({ data: { issues: { nodes: [] } } }); input = body.variables.input; - return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id } } } }); + return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id, identifier: 'TSGB-42' } } } }); }; - expect((await submit(request(), env, mocked as any)).status).toBe(200); + const response = await submit(request(), env, mocked as any); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + receipt: 'd721d1ca-6213-4bda-8928-21ca37671d96', + ticket: 'TSGB-42', + }); expect(input.stateId).toBe('4b7df4af-a498-4656-8d0a-c87e1c1d28aa'); expect(input.projectMilestoneId).toBe('7cdb86ec-c9a9-4d58-b46b-2e382566f1f5'); expect(input.labelIds).toContain('88dcf4fa-6ccd-4d48-b427-d6d190745e05'); diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index bb6e0652..deeb5e15 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -90,8 +90,8 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = return result.data; }; // A stable issue UUID makes retries safe without a second database. - const existing = await graphql('query($id: ID!) { issues(filter: { id: { eq: $id }, project: { id: { eq: "aa8cfc24-95f6-461e-aac4-46437d89459e" } } }) { nodes { id } } }', { id: report.id }); - if (existing.issues.nodes.length) return reply(200, { receipt: report.id }); + const existing = await graphql('query($id: ID!) { issues(filter: { id: { eq: $id }, project: { id: { eq: "aa8cfc24-95f6-461e-aac4-46437d89459e" } } }) { nodes { id identifier } } }', { id: report.id }); + if (existing.issues.nodes.length) return reply(200, { receipt: report.id, ticket: existing.issues.nodes[0].identifier }); const attachments: string[] = []; let logs = false; for (const [index, file] of report.files.entries()) { @@ -118,7 +118,7 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = categories[report.category], ...(logs ? ['0429eef3-39f3-4f67-a303-a9f985122a61'] : []), ]; - const created = await graphql('mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id } } }', { + const created = await graphql('mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier } } }', { input: { id: report.id, teamId: 'd14f1e07-93ce-4cfe-968c-c6372c6f58a6', projectId: 'aa8cfc24-95f6-461e-aac4-46437d89459e', @@ -134,7 +134,7 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = }, }); if (!created.issueCreate.success || !created.issueCreate.issue) throw new Error('Create failed'); - return reply(200, { receipt: report.id }); + return reply(200, { receipt: report.id, ticket: created.issueCreate.issue.identifier }); } catch { return reply(502, { error: 'Delivery could not be confirmed. Your form is unchanged. Retry with the same report ID.' }); } diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index b1e73bec..c841b718 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -89,11 +89,34 @@ const sitekey = env.TURNSTILE_SITE_KEY; + + @@ -144,23 +167,56 @@ const sitekey = env.TURNSTILE_SITE_KEY; + +
+
Feedback report
+

Thank you

+

+ +
+ +
+
+
+ @@ -246,4 +317,8 @@ const sitekey = env.TURNSTILE_SITE_KEY; margin-top: 0.2rem; accent-color: var(--accent); } + + dialog::backdrop { + background: rgb(23 20 15 / 45%); + } From c9b031d588b86a87c319fe185bc978bc0a807d0e Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:37:41 -0700 Subject: [PATCH 08/13] fix(feedback): compress automatic logs separately --- src/lib/feedback.test.ts | 11 ++++++++++- src/lib/feedback.ts | 27 ++++++++++++++++++++------- src/pages/feedback.astro | 37 +++++++++++++++++++++++++------------ 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index 27909687..adffbd84 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -32,10 +32,19 @@ test('reject executable and oversized attachments', () => { const data = form(); data.append('files', new File(['x'], 'secret.exe')); expect(() => validate(data)).toThrow(); - data.set('files', new File([new Uint8Array(2 * 1024 * 1024 + 1)], 'large.log')); + data.set('files', new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'large.log')); expect(() => validate(data)).toThrow(); }); +test('automatic compressed log does not consume a user attachment slot', () => { + const data = form(); + for (let index = 0; index < 3; index++) { + data.append('files', new File(['image'], `screenshot-${index}.png`)); + } + data.append('files', new File(['gzip'], 'qrate-session.log.gz')); + expect(validate(data).files).toHaveLength(4); +}); + test('no provider calls for invalid input or unconfigured service', async () => { const never = async () => { throw new Error('Unexpected network call'); }; expect((await submit(request(), {}, never as any)).status).toBe(503); diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index deeb5e15..0fd85cce 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -6,7 +6,7 @@ export const categories: Record = { performance: '154aeee1-f247-4bc6-b797-3b06051f1a7b', improvement: '486d0f47-7520-42d5-88a5-c1fde5dcf769', }; -export const MAX_BYTES = 7 * 1024 * 1024; +export const MAX_BYTES = 17 * 1024 * 1024; export function validate(form: FormData) { const allowed = ['id', 'category', 'summary', 'description', 'email', 'diagnostics', 'consent', 'cf-turnstile-response', 'files']; @@ -27,9 +27,18 @@ export function validate(form: FormData) { if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Invalid email'); if (form.getAll('files').some(file => !(file instanceof File))) throw new Error('Invalid files'); const files = form.getAll('files').filter((file): file is File => file instanceof File && file.size > 0); - if (files.length > 3) throw new Error('Maximum three files'); + const automaticLogs = files.filter(file => /^qrate-session\.log(?:\.gz)?$/i.test(file.name)); + const attachments = files.filter(file => !automaticLogs.includes(file)); + if (automaticLogs.length > 1) throw new Error('Only one automatic session log is allowed.'); + if (attachments.length > 3) throw new Error('Choose no more than three attachments.'); for (const file of files) { - if (file.size > 2 * 1024 * 1024 || !/\.(png|jpg|jpeg|txt|log)$/i.test(file.name)) throw new Error('Use PNG, JPEG, TXT or LOG files up to 2 MiB'); + const automatic = automaticLogs.includes(file); + const sizeLimit = automatic ? 512 * 1024 : 5 * 1024 * 1024; + if (file.size > sizeLimit || !/\.(png|jpg|jpeg|txt|log|log\.gz)$/i.test(file.name)) { + throw new Error(automatic + ? 'The automatic session log is too large.' + : 'Use PNG, JPEG, TXT or LOG attachments up to 5 MiB each.'); + } } return { id, category, email, files, @@ -61,9 +70,13 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = chunks.push(value); } const form = await new Response(new Blob(chunks), { headers: { 'content-type': request.headers.get('content-type')! } }).formData(); - report = validate(form); + try { + report = validate(form); + } catch (error) { + return reply(400, { error: error instanceof Error ? error.message : 'Check the report fields.' }); + } } catch { - return reply(400, { error: 'Check required fields and attachments (three PNG/JPEG/TXT/LOG files, up to 2 MiB each).' }); + return reply(400, { error: 'The report could not be read. Please retry.' }); } try { const verification = await fetcher('https://challenges.cloudflare.com/turnstile/v0/siteverify', { @@ -96,8 +109,8 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = let logs = false; for (const [index, file] of report.files.entries()) { const extension = file.name.split('.').pop()!.toLowerCase(); - const isLog = ['txt', 'log'].includes(extension); - const type = isLog ? 'text/plain' : extension === 'png' ? 'image/png' : 'image/jpeg'; + const isLog = ['txt', 'log', 'gz'].includes(extension); + const type = extension === 'gz' ? 'application/gzip' : isLog ? 'text/plain' : extension === 'png' ? 'image/png' : 'image/jpeg'; const filename = `${isLog ? 'log' : 'screenshot'}-${index + 1}.${extension}`; const upload = await graphql( 'mutation($type: String!, $name: String!, $size: Int!) { fileUpload(contentType: $type, filename: $name, size: $size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }', diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index c841b718..63a532d0 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -116,7 +116,7 @@ const sitekey = env.TURNSTILE_SITE_KEY; Additional logs or screenshots (optional) - Up to three attachments in total, 2 MiB each. The automatic log counts as one. + Up to three attachments, 5 MiB each. The automatic session log does not count toward this limit. @@ -224,20 +224,33 @@ const sitekey = env.TURNSTILE_SITE_KEY; event.preventDefault(); if (submitting) return; submitting = true; - const data = new FormData(form); - data.set('id', id); - if (!include.checked) data.delete('diagnostics'); - if (includeLogs.checked && recentLogs.value) { - if ((files.files?.length ?? 0) >= 3) { - submitting = false; - showResult('Too many attachments', 'Remove one attachment to include the automatic application log.'); - return; - } - data.append('files', new File([recentLogs.value], 'qrate.log', { type: 'text/plain' })); - } button.disabled = true; status.textContent = 'Sending…'; try { + const data = new FormData(form); + data.set('id', id); + if (!include.checked) data.delete('diagnostics'); + if ((files.files?.length ?? 0) > 3) { + throw new Error('Choose no more than three attachments.'); + } + if (includeLogs.checked && recentLogs.value) { + if ('CompressionStream' in window) { + const compressed = new Blob([recentLogs.value]) + .stream() + .pipeThrough(new CompressionStream('gzip')); + data.append( + 'files', + new File([await new Response(compressed).blob()], 'qrate-session.log.gz', { + type: 'application/gzip', + }), + ); + } else { + data.append( + 'files', + new File([recentLogs.value], 'qrate-session.log', { type: 'text/plain' }), + ); + } + } const response = await fetch('/api/feedback', { method: 'POST', body: data }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Unable to send.'); From eabaf506321eaf2966d1d1b0cbb87aeb9204b49c Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:47:46 -0700 Subject: [PATCH 09/13] fix(feedback): accept Worker multipart files --- src/lib/feedback.test.ts | 2 ++ src/lib/feedback.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index adffbd84..a6005e80 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -34,6 +34,8 @@ test('reject executable and oversized attachments', () => { expect(() => validate(data)).toThrow(); data.set('files', new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'large.log')); expect(() => validate(data)).toThrow(); + data.set('files', 'not a file'); + expect(() => validate(data)).toThrow('Invalid files'); }); test('automatic compressed log does not consume a user attachment slot', () => { diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index 0fd85cce..911ebf68 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -25,8 +25,9 @@ export function validate(form: FormData) { if (text('consent', 3) !== 'yes') throw new Error('Review required'); const email = text('email', 254); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Invalid email'); - if (form.getAll('files').some(file => !(file instanceof File))) throw new Error('Invalid files'); - const files = form.getAll('files').filter((file): file is File => file instanceof File && file.size > 0); + const fileValues = form.getAll('files'); + if (fileValues.some(file => typeof file === 'string')) throw new Error('Invalid files'); + const files = fileValues.filter((file): file is File => typeof file !== 'string' && file.size > 0); const automaticLogs = files.filter(file => /^qrate-session\.log(?:\.gz)?$/i.test(file.name)); const attachments = files.filter(file => !automaticLogs.includes(file)); if (automaticLogs.length > 1) throw new Error('Only one automatic session log is allowed.'); From 50367dc1a53ee1068495c76d68224c9c6273cdf3 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:55:33 -0700 Subject: [PATCH 10/13] fix(feedback): ignore empty browser file fields --- FEEDBACK.md | 3 ++- src/lib/feedback.test.ts | 5 +++++ src/lib/feedback.ts | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/FEEDBACK.md b/FEEDBACK.md index 4ad667f7..0811c2d8 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -44,7 +44,8 @@ Cloudflare test keys work on local loopback addresses only. Production requires ## Operations and limits Five attempts per minute per Cloudflare location/IP. Workplace users may share an IP. -Three attachments, up to 2 MiB each. PNG/JPEG screenshots and TXT/LOG logs only. +Three user attachments, up to 5 MiB each. PNG/JPEG screenshots and TXT/LOG logs only. +The optional automatic session log is gzip-compressed in the browser and has a separate 512 KiB limit. No automatic crash uploads, session recording, screenshot capture, or full project upload. The report UUID also serves as Linear issue UUID for retry deduplication. Retries require a new Turnstile token. Uploads that succeed before a later provider failure can leave orphaned Linear assets. diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index a6005e80..0cd62505 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -36,10 +36,15 @@ test('reject executable and oversized attachments', () => { expect(() => validate(data)).toThrow(); data.set('files', 'not a file'); expect(() => validate(data)).toThrow('Invalid files'); + data.set('files', new File([], 'empty.exe')); + expect(() => validate(data)).toThrow(); + data.set('files', new File([], 'empty.log')); + expect(validate(data).files).toHaveLength(1); }); test('automatic compressed log does not consume a user attachment slot', () => { const data = form(); + data.append('files', ''); for (let index = 0; index < 3; index++) { data.append('files', new File(['image'], `screenshot-${index}.png`)); } diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index 911ebf68..385d3ca7 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -26,8 +26,9 @@ export function validate(form: FormData) { const email = text('email', 254); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Invalid email'); const fileValues = form.getAll('files'); - if (fileValues.some(file => typeof file === 'string')) throw new Error('Invalid files'); - const files = fileValues.filter((file): file is File => typeof file !== 'string' && file.size > 0); + if (fileValues.some(file => typeof file === 'string' && file !== '')) throw new Error('Invalid files'); + const files = fileValues.filter((file): file is File => + typeof file !== 'string' && (file.size > 0 || file.name.length > 0)); const automaticLogs = files.filter(file => /^qrate-session\.log(?:\.gz)?$/i.test(file.name)); const attachments = files.filter(file => !automaticLogs.includes(file)); if (automaticLogs.length > 1) throw new Error('Only one automatic session log is allowed.'); From e4f03677d64d5bd55084676a92ae24fb30471aa7 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:07:00 -0700 Subject: [PATCH 11/13] feat(feedback): show small logs inline in Linear --- FEEDBACK.md | 3 ++- src/lib/feedback.test.ts | 7 +++++-- src/lib/feedback.ts | 8 ++++++-- src/pages/feedback.astro | 9 ++++++--- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/FEEDBACK.md b/FEEDBACK.md index 0811c2d8..e3b7d6ca 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -45,7 +45,8 @@ Cloudflare test keys work on local loopback addresses only. Production requires Five attempts per minute per Cloudflare location/IP. Workplace users may share an IP. Three user attachments, up to 5 MiB each. PNG/JPEG screenshots and TXT/LOG logs only. -The optional automatic session log is gzip-compressed in the browser and has a separate 512 KiB limit. +The optional automatic session log is shown inline in Linear up to 64 KiB. Larger logs become +gzip attachments in browsers that support compression and have a separate 512 KiB limit. No automatic crash uploads, session recording, screenshot capture, or full project upload. The report UUID also serves as Linear issue UUID for retry deduplication. Retries require a new Turnstile token. Uploads that succeed before a later provider failure can leave orphaned Linear assets. diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index 0cd62505..a6831d8f 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -61,6 +61,8 @@ test('no provider calls for invalid input or unconfigured service', async () => test('create issue with exact routing and triage labels', async () => { let input: any; + const data = form(); + data.set('logs', '15:00 [ERROR] example'); const mocked = async (url: string, options: any) => { if (url.includes('siteverify')) return Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' }); const body = JSON.parse(options.body); @@ -68,7 +70,7 @@ test('create issue with exact routing and triage labels', async () => { input = body.variables.input; return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id, identifier: 'TSGB-42' } } } }); }; - const response = await submit(request(), env, mocked as any); + const response = await submit(request(data), env, mocked as any); expect(response.status).toBe(200); expect(await response.json()).toEqual({ receipt: 'd721d1ca-6213-4bda-8928-21ca37671d96', @@ -77,7 +79,8 @@ test('create issue with exact routing and triage labels', async () => { expect(input.stateId).toBe('4b7df4af-a498-4656-8d0a-c87e1c1d28aa'); expect(input.projectMilestoneId).toBe('7cdb86ec-c9a9-4d58-b46b-2e382566f1f5'); expect(input.labelIds).toContain('88dcf4fa-6ccd-4d48-b427-d6d190745e05'); - expect(input.labelIds).not.toContain('0429eef3-39f3-4f67-a303-a9f985122a61'); + expect(input.labelIds).toContain('0429eef3-39f3-4f67-a303-a9f985122a61'); + expect(input.description).toContain(' 15:00 [ERROR] example'); }); test('Turnstile wrong hostname fails before Linear', async () => { diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index 385d3ca7..d0a54f41 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -9,7 +9,7 @@ export const categories: Record = { export const MAX_BYTES = 17 * 1024 * 1024; export function validate(form: FormData) { - const allowed = ['id', 'category', 'summary', 'description', 'email', 'diagnostics', 'consent', 'cf-turnstile-response', 'files']; + const allowed = ['id', 'category', 'summary', 'description', 'email', 'diagnostics', 'logs', 'consent', 'cf-turnstile-response', 'files']; for (const key of form.keys()) { if (!allowed.includes(key) || (key !== 'files' && form.getAll(key).length !== 1)) throw new Error('Invalid fields'); } @@ -47,6 +47,7 @@ export function validate(form: FormData) { summary: text('summary', 160, true), description: text('description', 10000, true), diagnostics: text('diagnostics', 8000), + logs: text('logs', 64 * 1024), token: text('cf-turnstile-response', 2048, true), }; } @@ -108,7 +109,7 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = const existing = await graphql('query($id: ID!) { issues(filter: { id: { eq: $id }, project: { id: { eq: "aa8cfc24-95f6-461e-aac4-46437d89459e" } } }) { nodes { id identifier } } }', { id: report.id }); if (existing.issues.nodes.length) return reply(200, { receipt: report.id, ticket: existing.issues.nodes[0].identifier }); const attachments: string[] = []; - let logs = false; + let logs = Boolean(report.logs); for (const [index, file] of report.files.entries()) { const extension = file.name.split('.').pop()!.toLowerCase(); const isLog = ['txt', 'log', 'gz'].includes(extension); @@ -144,6 +145,9 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = report.description, `Category: ${report.category}`, report.email ? `Reply to: ${report.email}` : '', report.diagnostics ? `## User-reviewed diagnostics\n\n${report.diagnostics}` : '', + report.logs + ? `## User-reviewed application log\n\n${report.logs.split('\n').map(line => ` ${line}`).join('\n')}` + : '', ...attachments, ].filter(Boolean).join('\n\n'), }, diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index 63a532d0..52ceec36 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -94,7 +94,7 @@ const sitekey = env.TURNSTILE_SITE_KEY; Include recent application logs - A short tail with home-directory paths redacted. Off until you choose it. + A short tail with home-directory paths redacted. Small logs appear directly in the ticket.
@@ -234,7 +234,10 @@ const sitekey = env.TURNSTILE_SITE_KEY; throw new Error('Choose no more than three attachments.'); } if (includeLogs.checked && recentLogs.value) { - if ('CompressionStream' in window) { + const log = new Blob([recentLogs.value], { type: 'text/plain' }); + if (log.size <= 64 * 1024) { + data.set('logs', recentLogs.value); + } else if ('CompressionStream' in window) { const compressed = new Blob([recentLogs.value]) .stream() .pipeThrough(new CompressionStream('gzip')); @@ -247,7 +250,7 @@ const sitekey = env.TURNSTILE_SITE_KEY; } else { data.append( 'files', - new File([recentLogs.value], 'qrate-session.log', { type: 'text/plain' }), + new File([log], 'qrate-session.log', { type: 'text/plain' }), ); } } From ba478e7de39c514e3dcc30a2d52e26cb049299ac Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:28:48 -0700 Subject: [PATCH 12/13] refactor(feedback): use minimal concrete labels --- FEEDBACK.md | 3 ++- src/lib/feedback.test.ts | 9 +++++++-- src/lib/feedback.ts | 14 ++++++++++---- src/pages/feedback.astro | 6 +++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/FEEDBACK.md b/FEEDBACK.md index e3b7d6ca..74519747 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -12,7 +12,8 @@ Deploy the website before releasing the desktop buttons. The Linear key needs read access, issue creation and file upload access for Grass Labs. Never put either secret in source files, command arguments, or the browser. -Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake & Stabilization / Backlog. +Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake & Stabilization / Backlog +with one category label, the concrete desktop platform when available, and Logs Attached when applicable. ## Test locally diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index a6831d8f..726f249e 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'bun:test'; -import { categories, submit, validate } from './feedback'; +import { categories, platforms, submit, validate } from './feedback'; function form() { const data = new FormData(); @@ -62,6 +62,7 @@ test('no provider calls for invalid input or unconfigured service', async () => test('create issue with exact routing and triage labels', async () => { let input: any; const data = form(); + data.set('platform', 'windows'); data.set('logs', '15:00 [ERROR] example'); const mocked = async (url: string, options: any) => { if (url.includes('siteverify')) return Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' }); @@ -78,7 +79,11 @@ test('create issue with exact routing and triage labels', async () => { }); expect(input.stateId).toBe('4b7df4af-a498-4656-8d0a-c87e1c1d28aa'); expect(input.projectMilestoneId).toBe('7cdb86ec-c9a9-4d58-b46b-2e382566f1f5'); - expect(input.labelIds).toContain('88dcf4fa-6ccd-4d48-b427-d6d190745e05'); + expect(input.labelIds).toEqual([ + categories.bug, + platforms.windows, + '0429eef3-39f3-4f67-a303-a9f985122a61', + ]); expect(input.labelIds).toContain('0429eef3-39f3-4f67-a303-a9f985122a61'); expect(input.description).toContain(' 15:00 [ERROR] example'); }); diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index d0a54f41..21e5e8ee 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -6,10 +6,15 @@ export const categories: Record = { performance: '154aeee1-f247-4bc6-b797-3b06051f1a7b', improvement: '486d0f47-7520-42d5-88a5-c1fde5dcf769', }; +export const platforms: Record = { + windows: '951df0ef-4d13-4eaa-97cc-b7621702126c', + macos: '6ad3615e-c1ad-4be6-91bf-9ce6a9d6b093', + linux: '2dae9fe2-a98c-43bd-90d5-3a786923d8af', +}; export const MAX_BYTES = 17 * 1024 * 1024; export function validate(form: FormData) { - const allowed = ['id', 'category', 'summary', 'description', 'email', 'diagnostics', 'logs', 'consent', 'cf-turnstile-response', 'files']; + const allowed = ['id', 'category', 'platform', 'summary', 'description', 'email', 'diagnostics', 'logs', 'consent', 'cf-turnstile-response', 'files']; for (const key of form.keys()) { if (!allowed.includes(key) || (key !== 'files' && form.getAll(key).length !== 1)) throw new Error('Invalid fields'); } @@ -22,6 +27,8 @@ export function validate(form: FormData) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) throw new Error('Invalid ID'); const category = text('category', 20, true); if (!Object.hasOwn(categories, category)) throw new Error('Invalid category'); + const platform = text('platform', 20); + if (platform && !Object.hasOwn(platforms, platform)) throw new Error('Invalid platform'); if (text('consent', 3) !== 'yes') throw new Error('Review required'); const email = text('email', 254); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Invalid email'); @@ -43,7 +50,7 @@ export function validate(form: FormData) { } } return { - id, category, email, files, + id, category, platform, email, files, summary: text('summary', 160, true), description: text('description', 10000, true), diagnostics: text('diagnostics', 8000), @@ -129,9 +136,8 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = logs ||= isLog; } const labelIds = [ - '9caf21d4-082c-4a89-955e-981e850b6405', '88dcf4fa-6ccd-4d48-b427-d6d190745e05', - '26451746-50ca-453e-840e-e8ed9c5a6fb7', 'a47da0e4-5ef6-4a40-98ec-2ee8dcdcea20', categories[report.category], + ...(report.platform ? [platforms[report.platform]] : []), ...(logs ? ['0429eef3-39f3-4f67-a303-a9f985122a61'] : []), ]; const created = await graphql('mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier } } }', { diff --git a/src/pages/feedback.astro b/src/pages/feedback.astro index 52ceec36..a15a92a0 100644 --- a/src/pages/feedback.astro +++ b/src/pages/feedback.astro @@ -206,11 +206,14 @@ const sitekey = env.TURNSTILE_SITE_KEY; include.checked = !['feature', 'improvement'].includes(category.value); const fragment = (window as any).qrateFeedbackFragment; delete (window as any).qrateFeedbackFragment; + let platform = ''; try { const values = new URLSearchParams(fragment?.slice(1)); const raw = values.get('diagnostics'); if (raw && raw.length <= 8000) { - diagnostics.value = JSON.stringify(JSON.parse(raw), null, 2); + const parsed = JSON.parse(raw); + diagnostics.value = JSON.stringify(parsed, null, 2); + if (['windows', 'macos', 'linux'].includes(parsed.os)) platform = parsed.os; } const logs = values.get('logs'); if (logs && logs.length <= 2000) { @@ -229,6 +232,7 @@ const sitekey = env.TURNSTILE_SITE_KEY; try { const data = new FormData(form); data.set('id', id); + if (platform) data.set('platform', platform); if (!include.checked) data.delete('diagnostics'); if ((files.files?.length ?? 0) > 3) { throw new Error('Choose no more than three attachments.'); From 31ddf8d476c392760ccd82ec568e4ecb28581854 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:34:21 -0700 Subject: [PATCH 13/13] fix(feedback): format Linear report details --- src/lib/feedback.test.ts | 4 +++- src/lib/feedback.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts index 726f249e..abac3fdf 100644 --- a/src/lib/feedback.test.ts +++ b/src/lib/feedback.test.ts @@ -85,7 +85,9 @@ test('create issue with exact routing and triage labels', async () => { '0429eef3-39f3-4f67-a303-a9f985122a61', ]); expect(input.labelIds).toContain('0429eef3-39f3-4f67-a303-a9f985122a61'); - expect(input.description).toContain(' 15:00 [ERROR] example'); + expect(input.description).toContain('\n\n---\n\n'); + expect(input.description).toContain('```text\n15:00 [ERROR] example\n```'); + expect(input.description).not.toContain('Category:'); }); test('Turnstile wrong hostname fails before Linear', async () => { diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts index 21e5e8ee..9340d00b 100644 --- a/src/lib/feedback.ts +++ b/src/lib/feedback.ts @@ -148,11 +148,14 @@ export async function submit(request: Request, env: any, fetcher: typeof fetch = stateId: '4b7df4af-a498-4656-8d0a-c87e1c1d28aa', labelIds, title: report.summary, description: [ - report.description, `Category: ${report.category}`, + report.description, report.email ? `Reply to: ${report.email}` : '', - report.diagnostics ? `## User-reviewed diagnostics\n\n${report.diagnostics}` : '', + report.diagnostics || report.logs || attachments.length ? '---' : '', + report.diagnostics + ? `## User-reviewed diagnostics\n\n\`\`\`json\n${report.diagnostics}\n\`\`\`` + : '', report.logs - ? `## User-reviewed application log\n\n${report.logs.split('\n').map(line => ` ${line}`).join('\n')}` + ? `## User-reviewed application log\n\n\`\`\`text\n${report.logs}\n\`\`\`` : '', ...attachments, ].filter(Boolean).join('\n\n'),