diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 00000000..93ea7f94 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,6 @@ +# Copy this file to .dev.vars. Wrangler reads it during local development. +# Do not commit .dev.vars. +LINEAR_API_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 new file mode 100644 index 00000000..74519747 --- /dev/null +++ b/FEEDBACK.md @@ -0,0 +1,61 @@ +# 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 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`. + +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 +with one category label, the concrete desktop platform when available, and Logs Attached when applicable. + +## Test locally + +1. Copy `.dev.vars.example` to `.dev.vars`. +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`. +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. +Cloudflare test keys work on local loopback addresses only. Production requires a real Turnstile widget. + +## 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 user attachments, up to 5 MiB each. PNG/JPEG screenshots and TXT/LOG logs only. +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. +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/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 = []; 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/lib/feedback.test.ts b/src/lib/feedback.test.ts new file mode 100644 index 00000000..abac3fdf --- /dev/null +++ b/src/lib/feedback.test.ts @@ -0,0 +1,165 @@ +import { test, expect } from 'bun:test'; +import { categories, platforms, 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(), url = 'https://qrate.dvnl.work/api/feedback') => new Request(url, { + 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(5 * 1024 * 1024 + 1)], 'large.log')); + 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`)); + } + 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); + 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 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' }); + 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, identifier: 'TSGB-42' } } } }); + }; + 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', + ticket: 'TSGB-42', + }); + expect(input.stateId).toBe('4b7df4af-a498-4656-8d0a-c87e1c1d28aa'); + expect(input.projectMilestoneId).toBe('7cdb86ec-c9a9-4d58-b46b-2e382566f1f5'); + 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('\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 () => { + const mocked = async () => Response.json({ success: true, hostname: 'attacker.example', action: 'feedback' }); + 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 () => { + 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..9340d00b --- /dev/null +++ b/src/lib/feedback.ts @@ -0,0 +1,169 @@ +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 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', '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'); + } + 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'); + 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'); + const fileValues = form.getAll('files'); + 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.'); + if (attachments.length > 3) throw new Error('Choose no more than three attachments.'); + for (const file of files) { + 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, platform, email, files, + 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), + }; +} + +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(); + try { + report = validate(form); + } catch (error) { + return reply(400, { error: error instanceof Error ? error.message : 'Check the report fields.' }); + } + } catch { + return reply(400, { error: 'The report could not be read. Please retry.' }); + } + 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; + 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) => { + 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 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 = 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); + 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 } } } }', + { 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 = [ + 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 } } }', { + 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, + report.email ? `Reply to: ${report.email}` : '', + 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\`\`\`text\n${report.logs}\n\`\`\`` + : '', + ...attachments, + ].filter(Boolean).join('\n\n'), + }, + }); + if (!created.issueCreate.success || !created.issueCreate.issue) throw new Error('Create failed'); + 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/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..a15a92a0 --- /dev/null +++ b/src/pages/feedback.astro @@ -0,0 +1,344 @@ +--- +import { env } from 'cloudflare:workers'; +import Base from '../layouts/Base.astro'; + +export const prerender = false; +const sitekey = env.TURNSTILE_SITE_KEY; +--- + + + + +
+
+ Beta feedback +
+

+ Help shape qrate. +

+

+ Report a bug, suggest an improvement, or tell us where the interface gets in your way. + No account is required. +

+
+ +
+
+
+ + +
+ + + + + +
+ +
+ + Review or edit diagnostics + +
+ +
+
+
+ + + + + +
+ Logs can contain secrets and collection data. Inspect and redact every file before attaching it. +
+ + + + { + sitekey ? ( +
+ ) : ( +

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

+ ) + } + +
+ +

+
+ + + +
+ + +
+
Feedback report
+

Thank you

+

+ +
+ +
+
+
+ + + + + + 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 }