diff --git a/.env.example b/.env.example index 5a3b271d..2252cdda 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,8 @@ SMTP_PASS= JOB_EMAIL_COOLDOWN_MINUTES=60 MAIL_FROM=fledge@hugoxk.online ADMIN_EMAIL=admin@hugoxk.online +# Optional. Feedback and unexpected-error reports go here; empty falls back to ADMIN_EMAIL. +FEEDBACK_EMAIL= # ── Platform model gateway ──────────────────────────────────────────────── # Any OpenAI-compatible /v1 endpoint. Keep the real endpoint out of git. @@ -62,6 +64,16 @@ USD_CNY_RATE=7.2 # balance may not cover a first job and recommends an own model. 50 CNY default. PLATFORM_QUOTA_COMFORT_CNY=50 +# Feedback triage: route new feedback to the server FlyBot's Fledge Lead. +# Prefer a repo Fledge already manages: +FLEDGE_LEAD_REPO_ID= +# Or target an externally managed FlyBot bridge: +FLEDGE_LEAD_BRIDGE_URL= +FLEDGE_LEAD_BRIDGE_TOKEN= +FLEDGE_LEAD_SESSION_ID= +# The sanitized public issue target the Lead may write to. +FEEDBACK_PUBLIC_REPO=HugoXK/fledge + # ── Service directory (console links) ───────────────────────────────────── # Point these at your own deployments if you self-host the sibling services. PORTAL_URL=https://portal.hugoxk.online diff --git a/changelog.d/796-feedback-error-report.added.md b/changelog.d/796-feedback-error-report.added.md new file mode 100644 index 00000000..9445b13f --- /dev/null +++ b/changelog.d/796-feedback-error-report.added.md @@ -0,0 +1 @@ +Settings now has a Feedback card that stores the note, audits it and mails the maintainer address (`FEEDBACK_EMAIL`, falling back to `ADMIN_EMAIL`), plus an About card and footer link to star the public GitHub repository. An unexpected HTML 5xx page now offers “Report to the maintainer” after confirming the data-access consent: the server keeps a pathname-only, redacted error record with a redacted stack and a random reference and mails it once; a 4xx page never offers the button and no request body, query, cookie or token is stored. diff --git a/changelog.d/814-feedback-lead.added.md b/changelog.d/814-feedback-lead.added.md new file mode 100644 index 00000000..64970a0c --- /dev/null +++ b/changelog.d/814-feedback-lead.added.md @@ -0,0 +1 @@ +Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token, expiring after seven days, with a bounded five-attempt delivery outbox, lets the Lead read the redacted record and write a user-facing decision back through the triage API; statuses carry their required message/issue/PR/version fields and a retry is a no-op; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version. diff --git a/docs/architecture.md b/docs/architecture.md index 0047c91d..677315ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,6 +109,31 @@ header, not a section — so at the top of that page nothing has passed and `#release`, the first chip, is marked. That fallback is the accepted state, not a defect. +`/settings` also carries a Feedback card and an About card. The feedback form +posts to `/feedback`, stores a `feedback` row and mails the address in +`FEEDBACK_EMAIL` (falling back to `ADMIN_EMAIL`); the About card and the footer +link to the public GitHub repository with a Star call-to-action. An unexpected +HTTP 5xx creates an `error_reports` row with a random `ref` before the error page +is rendered; the page shows the data scope, requires the consent checkbox, then +offers one POST to `/errors/:ref/report`, which claims the row atomically and +mails the same address. The error row keeps only method, pathname, status, error +name, redacted message, redacted stack, version, commit, the signed-in login and +the consent version/time — never the query, body, headers, cookies or a token. +Both routes carry a route-level limiter, and a 4xx never offers the report button. + +The feedback lifecycle is one triage row per ref (`feedback_triage`) plus +`feedback_events`. New reports are sent to the server FlyBot's Lead through a +connected repo (`FLEDGE_LEAD_REPO_ID`) or a direct bridge; the packet carries a +per-ref signed token, not the raw stack. The Lead reads and writes through +`GET`/`POST /api/feedback/:ref` and `/triage`; the read response carries a +sanitized issue draft, every transition appends an event, and the signed-in +reporter sees in-app/mail updates plus a Settings history row. Delivery is a +bounded outbox: a failure is retried with backoff up to five attempts and the +row records the last error/next attempt; a delivered row is never resent. A `fixed_version` at or +below the running `VERSION` sends the fixed notice once; the startup scan then +marks the record `released` and sends the “now live” notice once. Fledge never +creates the public issue: the Lead does, sanitized, and records its URL. + The dashboard's Repositories card owns the full content row; its Add repository button sits in the card header and opens a native `` with the GitHub App and fine-grained token options. `#add-repo` remains the deep link used by the diff --git a/docs/deploy.md b/docs/deploy.md index ae977a4b..02e2d98d 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -35,6 +35,10 @@ Key settings: | `MAIL_TRANSPORT` | `smtp` in production, `console` for a local demo | | `SMTP_HOST/PORT/SECURE/USER/PASS` | For yeah.net: `smtp.yeah.net`, 465, secure | | `MAIL_FROM` / `ADMIN_EMAIL` | `fledge@hugoxk.online` / `admin@example.com` | +| `FEEDBACK_EMAIL` | Optional feedback/error-report destination; defaults to `ADMIN_EMAIL` | +| `FLEDGE_LEAD_REPO_ID` | Repo connected to Fledge whose FlyBot Lead triages feedback | +| `FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID` | Direct bridge target when the Fledge Lead is external | +| `FEEDBACK_PUBLIC_REPO` | Sanitized public issue target; defaults to `HugoXK/fledge` | | `PLATFORM_LLM_BASE_URL` | OpenAI-compatible `/v1` endpoint | | `PLATFORM_LLM_KEY` | Platform key; never commit it | | `INITIAL_QUOTA_CNY` | Default 1 | diff --git a/docs/secrets.md b/docs/secrets.md index 90a809e4..fd1ea582 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -25,6 +25,7 @@ predates this inventory — it is not a value. | `SMTP_PASS` | approval and digest mail | production `.env` | provider's send-only SMTP credential | owner | unknown | mint a new SMTP credential at the provider | `npm run mail:outbox` on a development box, or one approval mail | unknown | | `PLATFORM_LLM_KEY` | platform-funded model calls | production `.env` | one OpenAI-compatible key with a spend cap | owner | unknown | mint at the provider, then replace and restart | one platform-funded job reaches the gateway | unknown | | `FLYBOT_BRIDGE_TOKEN` | shared secret on the optional global bridge | production `.env` and the bridge host | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | one mock job round-trips a callback | unknown | +| `FLEDGE_LEAD_BRIDGE_TOKEN` | bearer token for the external Fledge Lead triage bridge | production `.env` and the bridge host | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | one feedback packet reaches the configured Lead session | unknown | | `GITHUB_APP_PRIVATE_KEY_PATH` | GitHub App authentication | file on the host, path in `.env` | App private key | owner | unknown | regenerate the key in the App settings and replace the file | install the App on a repository and list its repositories | unknown | | `GITHUB_APP_WEBHOOK_SECRET` | verifies App webhooks | production `.env` | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both the App and `.env` | redeliver one webhook and confirm it is accepted | unknown | | `INBOUND_EMAIL_SECRET` | shared secret with the inbound Email Worker | production `.env` and the worker | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | reply to one digest mail and confirm it lands | unknown | diff --git a/docs/security.md b/docs/security.md index 63ff7702..d8939bae 100644 --- a/docs/security.md +++ b/docs/security.md @@ -172,6 +172,44 @@ classifying sweep, the ADR and the remaining privileged routes. A route added tomorrow inherits nothing from this section, and the eight `config.rateLimit` handlers above are unchanged by it — same window, same key. +### Feedback and unexpected-error reports + +The settings Feedback card stores one `feedback` row (user, kind, redacted +message, optional page) and mails `FEEDBACK_EMAIL`, which falls back to +`ADMIN_EMAIL` when unset. The user sees the same bilingual page and a flash +notice; the audit row records only the id, kind and character count. + +The error handler writes an `error_reports` row only for HTML 5xx responses. The +row carries a random `ref`, method, pathname, status, error name, redacted and +truncated message, a redacted and truncated stack, version, commit, and the +signed-in login when there is one. It never carries the query string, request +body, headers, cookies, email address or token. The error page shows what the +report contains and requires a consent checkbox before it exposes the `ref` as a +capability: one POST to `/errors/:ref/report` marks it reported atomically, +records the consent version and time, and mails the maintainer address. The route +is usable without a session because the error may happen before sign-in, is +rate-limited by client address, and a second click does not send a second mail. +Without the consent value the row stays `captured` and nothing is mailed. A 4xx +refusal is user-caused and never offers the button. + +### Feedback triage + +Each feedback/error record gets a per-ref triage token derived from +`HMAC(SESSION_SECRET, "feedback-triage:")`. The token is delivered only in +the private packet to the Fledge Lead; the read and write endpoints accept it as +a bearer capability for that one ref, it expires after seven days, and both are +rate-limited. Each status has required fields (a user message, an issue URL, a +pull URL, a fixed version), public links must be issues/pulls in +`FEEDBACK_PUBLIC_REPO`, and a replay of the same decision is a no-op. The Lead may read the +redacted message/stack and write a conclusion, but Fledge does not publish raw +feedback or error data. A public GitHub issue is created by the Lead only after a +separate sanitized-publication consent, and the issue may @ the reporter only +when that consent is present. The triage read returns a ready-to-use sanitized +issue draft built from `public_summary`; raw message/stack never enters it. The triage row stores the issue/PR URLs, fixed and +released versions, user-facing message, public summary, notification state and +the bounded Lead-delivery attempts; +the raw text remains in the feedback/error row. + ### CSRF Every state-changing browser form includes a hidden `csrf` field derived from diff --git a/src/app.ts b/src/app.ts index 97cb1603..52c4ee3e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,7 +18,10 @@ import { isAdminRole, requireAdmin, requireApproved, requireSuperAdmin, requireV import { dedupeRepoNames, instanceLimitRefusal, repoLimitRefusal, type LimitRefusal } from './lib/limits.js' import { isTrialFilter } from './lib/trials.js' import { createMailer, type Mailer } from './email/mailer.js' -import { approvedEmail, rejectedEmail, testEmail } from './email/templates.js' +import { approvedEmail, errorReportMail, feedbackMail, rejectedEmail, testEmail } from './email/templates.js' +import { attemptLeadDelivery, publicIssueDraft, retryPendingLeadDeliveries, verifyTriageToken } from './feedback/lead.js' +import { triageProblems } from './feedback/lifecycle.js' +import { notifyFeedbackReporter, notifyReleasedFeedback } from './feedback/notify.js' import { notifyEmail } from './email/recipient.js' import { authenticateApiToken } from './lib/apiToken.js' import { registerGateway } from './gateway/gateway.js' @@ -38,7 +41,7 @@ import type { PriorityLevel } from './lib/priority.js' import { decisionKindLabel, isDecisionKind } from './lib/taskDecision.js' import fastifyRateLimit from '@fastify/rate-limit' import { rateLimitKey, RateLimiter, RouteLimiters } from './lib/rateLimit.js' -import { decryptSecret, encryptSecret, randomId, randomToken, safeEqual, sha256Hex } from './lib/secrets.js' +import { decryptSecret, encryptSecret, randomId, randomToken, redact, safeEqual, sha256Hex } from './lib/secrets.js' import { expiryFromDays, GATEWAY_SCOPE, isTokenScope, JOBS_READ_SCOPE, patExpiryDays } from './lib/tokens.js' import { AGREEMENT_VERSION, agreementHashFor } from './users/agreements.js' import { addMinutes, nowIso } from './lib/time.js' @@ -601,6 +604,20 @@ export async function buildApp(options: BuildOptions): Promise { timeWindow: 60_000, keyGenerator: (request: FastifyRequest): string => rateLimitKey(request), } + // #786: user feedback and a public error-report button are not destructive, + // so they get their own tighter buckets; the plugin shape is the one CodeQL's + // Fastify model can see, and the key stays the normalized client address. + const ERROR_REPORT_CONSENT_VERSION = '2026-11-15' + const feedbackLimit = { + max: 5, + timeWindow: 60 * 60_000, + keyGenerator: (request: FastifyRequest): string => rateLimitKey(request), + } + const errorReportLimit = { + max: 10, + timeWindow: 60 * 60_000, + keyGenerator: (request: FastifyRequest): string => rateLimitKey(request), + } const app = Fastify({ logger: false, @@ -4159,6 +4176,7 @@ export async function buildApp(options: BuildOptions): Promise { sessions: store.listSessions(viewer.user.id), currentSessionHash: viewer.sessionHash, providers: store.listModelProviders(viewer.user.id).map((provider) => ({ ...provider, models: store.listProviderModels(provider.id) })), + feedback: store.listFeedbackTriageForUser(viewer.user.id, 10), discoveredProvider: queryText(request, 'discoveredProvider'), discovered: (queryText(request, 'discovered') ?? '').split(',').filter((value) => value !== ''), notice: request.flash?.notice?.text, @@ -4367,6 +4385,161 @@ export async function buildApp(options: BuildOptions): Promise { return reply.redirect(`/settings?notice=${encodeURIComponent('通知设置已保存。')}`) }) + app.post('/feedback', { config: { rateLimit: feedbackLimit } }, async (request, reply) => { + const viewer = approvedCsrfGuard(request) + const body = form(request) + const kind = body.kind === 'bug' || body.kind === 'other' ? body.kind : 'feedback' + const message = redact((body.message ?? '').trim()).slice(0, 2000) + const page = (body.page ?? '').trim().slice(0, 200) + if (message === '') return reply.redirect(`/settings?error=${encodeURIComponent('请填写反馈内容。')}#feedback`) + const id = randomId('fb') + const createdAt = new Date().toISOString() + const publishConsent = body.publishConsent === '1' + store.createFeedback({ id, userId: viewer.user.id, kind, message, page, createdAt }) + const triage = store.createFeedbackTriage({ ref: id, userId: viewer.user.id, userLogin: viewer.user.login, kind, publishConsent, createdAt }) + void attemptLeadDelivery({ store, config, instances, triage, feedback: store.feedbackById(id) }).catch((error) => log.warn('feedback lead delivery failed', { ref: id, error: redact(String(error)) })) + store.audit(viewer.user.id, 'feedback.created', `${id} kind=${kind} chars=${message.length}`) + const replyTo = notifyEmail(viewer.user) + try { + await mailer.send({ + to: config.mail.feedbackEmail, + ...feedbackMail({ id, login: viewer.user.login, kind, message, page, createdAt }), + ...(replyTo === '' ? {} : { replyTo }), + }) + return reply.redirect(`/settings?notice=${encodeURIComponent('反馈已发送,谢谢。')}#feedback`) + } catch (error) { + log.error('feedback mail failed', { id, error: redact(String(error)) }) + return reply.redirect(`/settings?error=${encodeURIComponent('反馈已保存,但通知邮件发送失败;请稍后再试。')}#feedback`) + } + }) + + // #786: the ref is the unguessable capability the 5xx page carries. The route + // is deliberately usable without a session so an error on the sign-in or + // public page can still be reported; the claim is atomic, so two clicks send + // one mail. + app.post('/errors/:ref/report', { config: { rateLimit: errorReportLimit } }, async (request, reply) => { + const ref = String((request.params as Record).ref ?? '') + const viewer = request.viewer === undefined ? null : viewOf(request.viewer) + if (!/^[A-Za-z0-9_-]{10,80}$/.test(ref)) { + return htmlPage(reply, 404, errorPage(config, viewer, '页面不存在', '这个报告编号不存在。', request.locale)) + } + const report = store.errorReportByRef(ref) + if (report === undefined) { + return htmlPage(reply, 404, errorPage(config, viewer, '页面不存在', '这个报告编号不存在。', request.locale)) + } + const reportBody = form(request) + if (reportBody.consent !== '1') { + return htmlPage(reply, 400, errorPage(config, viewer, '需要授权', '请先勾选“我已知晓并授权 Fledge 平台读取这些失败数据”,再提交报告。', request.locale)) + } + if (!store.claimErrorReport(ref, ERROR_REPORT_CONSENT_VERSION)) { + return htmlPage(reply, 200, errorPage(config, viewer, '报告已发送', `这个报告(${ref})已经发送过了,谢谢。`, request.locale)) + } + const publishConsent = request.viewer !== undefined && reportBody.publish === '1' + const triage = store.createFeedbackTriage({ + ref, + userId: report.user_id, + userLogin: report.user_login, + kind: 'error', + publishConsent, + createdAt: report.created_at, + }) + void attemptLeadDelivery({ store, config, instances, triage, error: report }).catch((error) => log.warn('error report lead delivery failed', { ref, error: redact(String(error)) })) + try { + await mailer.send({ + to: config.mail.feedbackEmail, + ...errorReportMail({ + ref, + login: report.user_login, + method: report.method, + path: report.path, + statusCode: report.status_code, + errorName: report.error_name, + errorMessage: report.error_message, + stack: report.error_stack, + version: report.build_version, + commit: report.build_commit, + createdAt: report.created_at, + consentVersion: ERROR_REPORT_CONSENT_VERSION, + consentedAt: store.errorReportByRef(ref)?.consented_at ?? report.created_at, + }), + }) + } catch (error) { + log.error('error report mail failed', { ref, error: redact(String(error)) }) + } + store.audit(report.user_id, 'error.reported', `${ref} ${report.status_code} ${report.path}`) + return htmlPage(reply, 200, errorPage(config, viewer, '报告已发送', `谢谢。编号:${ref}。开发者会查看这个错误的类型、页面和时间;报告不包含你的输入、token 或请求体。`, request.locale)) + }) + + const triageAuthorized = (request: FastifyRequest, ref: string): boolean => { + const header = String(request.headers.authorization ?? '') + const token = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : '' + return verifyTriageToken(config.sessionSecret, ref, token) + } + + app.get('/api/feedback/:ref', async (request, reply) => { + if (!apiLimiter.take(`feedback-triage:${rateLimitKey(request)}`)) return reply.code(429).send({ error: { type: 'rate_limited', message: 'Too many requests' } }) + const ref = String((request.params as Record).ref ?? '') + if (!triageAuthorized(request, ref)) return reply.code(401).send({ error: { type: 'invalid_triage_token', message: 'Missing or invalid triage token' } }) + const triage = store.feedbackTriageByRef(ref) + if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } }) + return { + triage, + feedback: store.feedbackById(ref) ?? null, + error: store.errorReportByRef(ref) ?? null, + events: store.listFeedbackEvents(ref), + issueDraft: publicIssueDraft(triage), + } + }) + + app.post('/api/feedback/:ref/triage', async (request, reply) => { + if (!apiLimiter.take(`feedback-triage:${rateLimitKey(request)}`)) return reply.code(429).send({ error: { type: 'rate_limited', message: 'Too many requests' } }) + const ref = String((request.params as Record).ref ?? '') + if (!triageAuthorized(request, ref)) return reply.code(401).send({ error: { type: 'invalid_triage_token', message: 'Missing or invalid triage token' } }) + const triage = store.feedbackTriageByRef(ref) + if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } }) + const body = (request.body ?? {}) as Record + const status = typeof body.status === 'string' ? body.status.trim() : '' + const userMessage = redact(String(body.user_message ?? '')).trim().slice(0, 1000) + const publicSummary = redact(String(body.public_summary ?? '')).trim().slice(0, 1000) + const issueUrl = String(body.issue_url ?? '').trim() + const prUrl = String(body.pr_url ?? '').trim() + const fixedVersion = String(body.fixed_version ?? '').trim() + const problems = triageProblems({ status, userMessage, publicSummary, issueUrl, prUrl, fixedVersion, publicRepo: config.feedback.publicRepo }) + if (problems.length > 0) return reply.code(400).send({ error: { type: 'invalid_triage', message: problems.join('; ') } }) + if (fixedVersion !== '' && parseVersion(fixedVersion) === null) return reply.code(400).send({ error: { type: 'invalid_version', message: 'fixed_version must be SemVer' } }) + const unchanged = triage.status === status && + (triage.user_message ?? '') === userMessage && + (triage.public_summary ?? '') === publicSummary && + (triage.issue_url ?? '') === issueUrl && + (triage.pr_url ?? '') === prUrl && + (triage.fixed_version ?? '') === fixedVersion + if (unchanged) { + return { ok: true, status, unchanged: true, issueDraft: publicIssueDraft(triage) } + } + store.updateFeedbackTriage(ref, { + status, + user_message: userMessage === '' ? null : userMessage, + public_summary: publicSummary === '' ? null : publicSummary, + issue_url: issueUrl === '' ? null : issueUrl, + pr_url: prUrl === '' ? null : prUrl, + fixed_version: fixedVersion === '' ? null : fixedVersion, + }) + store.addFeedbackEvent(ref, status, userMessage.slice(0, 300), 'fledge-lead') + store.audit(triage.user_id, 'feedback.triaged', `${ref} ${status}`) + await notifyFeedbackReporter({ + store, + mailer, + ref, + userId: triage.user_id, + status, + userMessage, + issueUrl: issueUrl === '' ? null : issueUrl, + prUrl: prUrl === '' ? null : prUrl, + }) + const updated = store.feedbackTriageByRef(ref) + return { ok: true, status, issueDraft: updated === undefined ? null : publicIssueDraft(updated) } + }) + app.post('/settings/tokens', { config: { rateLimit: destructiveLimit } }, async (request, reply) => { const viewer = approvedCsrfGuard(request) if (requireRecentAuth(request, reply, viewer, 'token.create')) return reply @@ -5772,7 +5945,7 @@ export async function buildApp(options: BuildOptions): Promise { const status = failure.statusCode ?? 500 const code = failure.code ?? 'ERROR' const message = failure.message ?? 'Unknown error' - if (status >= 500) log.error('request failed', { url: request.url, error: String(error) }) + if (status >= 500) log.error('request failed', { url: request.url.split('?')[0], error: redact(String(error)) }) if (request.url.startsWith('/v1') || request.url.startsWith('/api/')) { return reply.code(status).send({ error: { type: code, message } }) } @@ -5783,17 +5956,50 @@ export async function buildApp(options: BuildOptions): Promise { if (status === 401 && code === 'AUTH_REQUIRED' && (request.method === 'GET' || request.method === 'HEAD')) { return reply.redirect(`/auth/github?next=${encodeURIComponent(safeLocalPath(request.url, '/dashboard'))}`) } - return htmlPage(reply, status, errorPage(config, request.viewer === undefined ? null : viewOf(request.viewer), status === 404 ? '页面不存在' : '出错了', status >= 500 ? '服务器出了点问题,请稍后再试。' : message, request.locale)) + let report: { ref: string } | undefined + if (status >= 500) { + try { + const ref = randomToken(18) + const viewer = request.viewer + store.createErrorReport({ + ref, + user_id: viewer?.user.id ?? null, + user_login: viewer?.user.login ?? null, + method: request.method, + path: (request.url.split('?')[0] ?? '/').slice(0, 500), + status_code: status, + error_name: (error instanceof Error && error.name !== '' ? error.name : 'Error').slice(0, 120), + error_message: redact(message).slice(0, 1000), + error_stack: error instanceof Error ? redact(error.stack ?? '').slice(0, 4000) : '', + build_version: VERSION, + build_commit: config.build.commit, + }) + report = { ref } + } catch (reportError) { + log.warn('error report capture failed', { error: redact(String(reportError)) }) + } + } + return htmlPage(reply, status, errorPage(config, request.viewer === undefined ? null : viewOf(request.viewer), status === 404 ? '页面不存在' : '出错了', status >= 500 ? '服务器出了点问题,请稍后再试。' : message, request.locale, undefined, report)) }) + let retryingLead = false const sweeper = setInterval(() => { limits.sweep() apiLimiter.sweep() store.deleteExpiredSessions() + if (options.startScheduler === false || retryingLead) return + retryingLead = true + void retryPendingLeadDeliveries({ store, config, instances }) + .catch((error) => log.warn('feedback lead retry failed', { error: redact(String(error)) })) + .finally(() => { retryingLead = false }) }, 60_000) sweeper.unref() - if (options.startScheduler !== false) jobs.start() + if (options.startScheduler !== false) { + jobs.start() + void notifyReleasedFeedback({ store, mailer }).catch((error) => log.warn('released feedback notification failed', { error: redact(String(error)) })) + void retryPendingLeadDeliveries({ store, config, instances }).catch((error) => log.warn('feedback lead retry failed', { error: redact(String(error)) })) + } return { app, diff --git a/src/config.ts b/src/config.ts index 1da9e599..41210c5c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,6 +66,8 @@ export interface Config { transport: MailTransport from: string adminEmail: string + /** #786: feedback and error reports; empty falls back to ADMIN_EMAIL. */ + feedbackEmail: string /** #353: how long one repository's failure mail stays coalesced, in minutes. */ jobEmailCooldownMinutes: number smtp: { @@ -90,6 +92,16 @@ export interface Config { /** Below this remaining balance the first-run checklist recommends an own model. */ comfortCny: number } + // #814: the server-deployed FlyBot's Fledge Lead. `leadRepoId` uses a repo + // Fledge already manages; the bridge trio targets an externally managed + // instance. `publicRepo` is the sanitized issue target the Lead may write to. + feedback: { + leadRepoId: string + leadBridgeUrl: string + leadBridgeToken: string + leadSessionId: string + publicRepo: string + } runner: { mode: RunnerMode bridgeUrl: string @@ -324,6 +336,7 @@ export function loadConfig(overrides: Partial = {}): Config { transport: (text('MAIL_TRANSPORT', 'console') as MailTransport) || 'console', from: text('MAIL_FROM', 'no-reply@example.com'), adminEmail: text('ADMIN_EMAIL', 'admin@example.com'), + feedbackEmail: text('FEEDBACK_EMAIL', '') || text('ADMIN_EMAIL', 'admin@example.com'), // #353: the window that keeps a failing repository from mailing once per run. jobEmailCooldownMinutes: Math.max(0, number('JOB_EMAIL_COOLDOWN_MINUTES', 60)), smtp: { @@ -346,6 +359,13 @@ export function loadConfig(overrides: Partial = {}): Config { usdCny: number('USD_CNY_RATE', 7.2), comfortCny: number('PLATFORM_QUOTA_COMFORT_CNY', 50), }, + feedback: { + leadRepoId: text('FLEDGE_LEAD_REPO_ID'), + leadBridgeUrl: text('FLEDGE_LEAD_BRIDGE_URL').replace(/\/$/, ''), + leadBridgeToken: text('FLEDGE_LEAD_BRIDGE_TOKEN'), + leadSessionId: text('FLEDGE_LEAD_SESSION_ID'), + publicRepo: text('FEEDBACK_PUBLIC_REPO', 'HugoXK/fledge'), + }, runner: { mode: (text('RUNNER_MODE', 'mock') as RunnerMode) || 'mock', bridgeUrl: text('FLYBOT_BRIDGE_URL', 'http://127.0.0.1:8787/run'), diff --git a/src/db.ts b/src/db.ts index d3126027..ea19cf7b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1997,6 +1997,81 @@ CREATE TABLE IF NOT EXISTS schedule_occurrences ( CREATE INDEX IF NOT EXISTS schedule_occurrences_lease_idx ON schedule_occurrences(state, lease_expires_at); CREATE INDEX IF NOT EXISTS schedule_occurrences_repo_idx ON schedule_occurrences(repo_id, logical_time DESC); CREATE INDEX IF NOT EXISTS schedule_occurrences_schedule_idx ON schedule_occurrences(schedule_id, logical_time DESC); + +-- #786: feedback and unexpected-error reports. Both are additive; no existing +-- table or row is rewritten. The error ref is the unguessable capability the +-- error page carries; the row is written with redacted, bounded fields before +-- the page is rendered. +CREATE TABLE IF NOT EXISTS feedback ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + message TEXT NOT NULL, + page TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS feedback_created_idx ON feedback(created_at DESC); +CREATE INDEX IF NOT EXISTS feedback_user_idx ON feedback(user_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS error_reports ( + ref TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + user_login TEXT, + status TEXT NOT NULL DEFAULT 'captured', + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + error_name TEXT NOT NULL, + error_message TEXT NOT NULL, + error_stack TEXT NOT NULL DEFAULT '', + build_version TEXT NOT NULL, + build_commit TEXT NOT NULL, + created_at TEXT NOT NULL, + reported_at TEXT, + consent_version TEXT, + consented_at TEXT +); +CREATE INDEX IF NOT EXISTS error_reports_created_idx ON error_reports(created_at DESC); +CREATE INDEX IF NOT EXISTS error_reports_status_idx ON error_reports(status, created_at DESC); + +-- #814: the triage lifecycle, keyed by the feedback id / error ref. The user's +-- raw text stays in feedback/error_reports; this row holds the decision, +-- links, versions, consent and notification state. +CREATE TABLE IF NOT EXISTS feedback_triage ( + ref TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + user_login TEXT, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', + publish_consent INTEGER NOT NULL DEFAULT 0, + publish_consent_at TEXT, + public_summary TEXT, + user_message TEXT, + issue_url TEXT, + pr_url TEXT, + fixed_version TEXT, + released_version TEXT, + released_at TEXT, + last_notified_status TEXT, + notified_at TEXT, + lead_delivered_at TEXT, + lead_attempts INTEGER NOT NULL DEFAULT 0, + lead_last_error TEXT, + lead_next_attempt_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS feedback_triage_status_idx ON feedback_triage(status, created_at DESC); + +CREATE TABLE IF NOT EXISTS feedback_events ( + id TEXT PRIMARY KEY, + ref TEXT NOT NULL REFERENCES feedback_triage(ref) ON DELETE CASCADE, + status TEXT NOT NULL, + detail TEXT, + actor TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS feedback_events_ref_idx ON feedback_events(ref, created_at DESC); ` function clean(params: unknown[]): unknown[] { @@ -2109,6 +2184,78 @@ export interface SecurityFindingWrite { dismissedInJob: string | null } +// #786: a user-authored note for the maintainer. The row is the durable record; +// the mail is the notification. Neither carries a token, a cookie or a request +// body. +export interface FeedbackRow { + id: string + user_id: string + kind: string + message: string + page: string + created_at: string +} + +// #786: a captured 5xx. The ref is the capability the error page carries; the +// row is written before the page is rendered, so the user can report it with one +// click even when signed out. The fields are already redacted and bounded. +export interface ErrorReportRow { + ref: string + user_id: string | null + user_login: string | null + status: 'captured' | 'reported' + method: string + path: string + status_code: number + error_name: string + error_message: string + error_stack: string + build_version: string + build_commit: string + created_at: string + reported_at: string | null + consent_version: string | null + consented_at: string | null +} + +// #814: the lifecycle record for one feedback/error ref. It is separate from +// `feedback` and `error_reports` so the user text is never rewritten by a +// triage update, and an anonymous error report can carry a lifecycle without a +// users row. +export interface FeedbackTriageRow { + ref: string + user_id: string | null + user_login: string | null + kind: string + status: string + publish_consent: number + publish_consent_at: string | null + public_summary: string | null + user_message: string | null + issue_url: string | null + pr_url: string | null + fixed_version: string | null + released_version: string | null + released_at: string | null + last_notified_status: string | null + notified_at: string | null + lead_delivered_at: string | null + lead_attempts: number + lead_last_error: string | null + lead_next_attempt_at: string | null + created_at: string + updated_at: string +} + +export interface FeedbackEventRow { + id: string + ref: string + status: string + detail: string | null + actor: string | null + created_at: string +} + export class Store { readonly db: Database @@ -2119,11 +2266,12 @@ export class Store { this.db.exec('PRAGMA foreign_keys = ON;') this.db.exec('PRAGMA busy_timeout = 5000;') const previousVersion = Number(this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0) - if (previousVersion > 41) { + if (previousVersion > 43) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (41)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (43)`) } this.db.exec(SCHEMA) + this.migrateFeedbackTriageColumns() this.migrateColumns() this.migrateRepoColumns() this.migrateProviderColumns() @@ -2145,7 +2293,7 @@ export class Store { this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso()) } this.migrateIdentity() - this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 41)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 43)};`) } // #544 slice 3B-B2: a database written by slice B1 already has the occurrence @@ -2361,6 +2509,16 @@ export class Store { } } + // #814 follow-up: the triage row existed before the Lead delivery outbox, so + // an older database gains the retry columns in place. + private migrateFeedbackTriageColumns(): void { + const columns = this.all<{ name: string }>('PRAGMA table_info(feedback_triage)').map((column) => column.name) + if (!columns.includes('lead_delivered_at')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_delivered_at TEXT') + if (!columns.includes('lead_attempts')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_attempts INTEGER NOT NULL DEFAULT 0') + if (!columns.includes('lead_last_error')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_last_error TEXT') + if (!columns.includes('lead_next_attempt_at')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_next_attempt_at TEXT') + } + private migrateColumns(): void { const columns = new Set(this.all<{ name: string }>('PRAGMA table_info(users)').map((row) => row.name)) const additions: Array<[string, string]> = [ @@ -6108,6 +6266,103 @@ export class Store { this.run('INSERT INTO notifications (id, user_id, kind, message, code, created_at) VALUES (?, ?, ?, ?, ?, ?)', `ntf_${randomBytes(6).toString('hex')}`, userId, kind, message, code, nowIso()) } + createFeedback(input: { id: string; userId: string; kind: string; message: string; page: string; createdAt?: string }): FeedbackRow { + const at = input.createdAt ?? nowIso() + this.run('INSERT INTO feedback (id, user_id, kind, message, page, created_at) VALUES (?, ?, ?, ?, ?, ?)', input.id, input.userId, input.kind, input.message, input.page, at) + return this.get('SELECT * FROM feedback WHERE id = ?', input.id)! + } + + listFeedback(limit = 20): FeedbackRow[] { + return this.all('SELECT * FROM feedback ORDER BY created_at DESC, rowid DESC LIMIT ?', Math.max(1, Math.min(100, limit))) + } + + createErrorReport(input: Omit & { createdAt?: string }): ErrorReportRow { + const at = input.createdAt ?? nowIso() + this.run( + 'INSERT INTO error_reports (ref, user_id, user_login, status, method, path, status_code, error_name, error_message, error_stack, build_version, build_commit, created_at, reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)', + input.ref, input.user_id, input.user_login, 'captured', input.method, input.path, input.status_code, input.error_name, input.error_message, input.error_stack, input.build_version, input.build_commit, at, + ) + return this.errorReportByRef(input.ref)! + } + + errorReportByRef(ref: string): ErrorReportRow | undefined { + return this.get('SELECT * FROM error_reports WHERE ref = ?', ref) + } + + claimErrorReport(ref: string, consentVersion: string): boolean { + const at = nowIso() + return this.runChanges("UPDATE error_reports SET status = 'reported', reported_at = ?, consent_version = ?, consented_at = ? WHERE ref = ? AND status = 'captured'", at, consentVersion, at, ref) === 1 + } + + listErrorReports(limit = 20): ErrorReportRow[] { + return this.all('SELECT * FROM error_reports ORDER BY created_at DESC, rowid DESC LIMIT ?', Math.max(1, Math.min(100, limit))) + } + + feedbackById(id: string): FeedbackRow | undefined { + return this.get('SELECT * FROM feedback WHERE id = ?', id) + } + + createFeedbackTriage(input: { + ref: string + userId: string | null + userLogin: string | null + kind: string + publishConsent: boolean + createdAt?: string + }): FeedbackTriageRow { + const at = input.createdAt ?? nowIso() + this.run( + 'INSERT INTO feedback_triage (ref, user_id, user_login, kind, status, publish_consent, publish_consent_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + input.ref, input.userId, input.userLogin, input.kind, 'new', input.publishConsent ? 1 : 0, input.publishConsent ? at : null, at, at, + ) + return this.feedbackTriageByRef(input.ref)! + } + + feedbackTriageByRef(ref: string): FeedbackTriageRow | undefined { + return this.get('SELECT * FROM feedback_triage WHERE ref = ?', ref) + } + + updateFeedbackTriage(ref: string, patch: Partial>): void { + const entries = Object.entries(patch).filter(([, value]) => value !== undefined) + if (entries.length === 0) return + const sets = entries.map(([key]) => `${key} = ?`).join(', ') + this.run(`UPDATE feedback_triage SET ${sets}, updated_at = ? WHERE ref = ?`, ...entries.map(([, value]) => value), nowIso(), ref) + } + + addFeedbackEvent(ref: string, status: string, detail: string | null = null, actor: string | null = null): void { + this.run('INSERT INTO feedback_events (id, ref, status, detail, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)', `fev_${randomBytes(6).toString('hex')}`, ref, status, detail, actor, nowIso()) + } + + listFeedbackEvents(ref: string, limit = 50): FeedbackEventRow[] { + return this.all('SELECT * FROM feedback_events WHERE ref = ? ORDER BY created_at DESC, rowid DESC LIMIT ?', ref, Math.max(1, Math.min(100, limit))) + } + + listFeedbackTriageForUser(userId: string, limit = 10): FeedbackTriageRow[] { + return this.all('SELECT * FROM feedback_triage WHERE user_id = ? ORDER BY updated_at DESC, rowid DESC LIMIT ?', userId, Math.max(1, Math.min(50, limit))) + } + + markLeadDelivered(ref: string): void { + const at = nowIso() + this.run('UPDATE feedback_triage SET lead_delivered_at = ?, lead_last_error = NULL, lead_next_attempt_at = NULL, updated_at = ? WHERE ref = ?', at, at, ref) + } + + recordLeadDeliveryFailure(ref: string, error: string, attempts: number, nextAt: string | null): void { + this.run('UPDATE feedback_triage SET lead_attempts = ?, lead_last_error = ?, lead_next_attempt_at = ?, updated_at = ? WHERE ref = ?', attempts, error.slice(0, 500), nextAt, nowIso(), ref) + } + + listPendingLeadDeliveries(now: string, limit = 20): FeedbackTriageRow[] { + return this.all('SELECT * FROM feedback_triage WHERE lead_delivered_at IS NULL AND lead_attempts < 5 AND (lead_next_attempt_at IS NULL OR lead_next_attempt_at <= ?) ORDER BY created_at ASC LIMIT ?', now, Math.max(1, Math.min(100, limit))) + } + + listFeedbackForRelease(limit = 50): FeedbackTriageRow[] { + return this.all("SELECT * FROM feedback_triage WHERE fixed_version IS NOT NULL AND released_version IS NULL AND user_id IS NOT NULL AND status IN ('fixed','pr_opened') ORDER BY created_at ASC LIMIT ?", Math.max(1, Math.min(100, limit))) + } + + markFeedbackReleased(ref: string, version: string): boolean { + const at = nowIso() + return this.runChanges("UPDATE feedback_triage SET status = 'released', released_version = ?, released_at = ?, updated_at = ? WHERE ref = ? AND released_version IS NULL", version, at, at, ref) === 1 + } + listNotifications(userId: string, limit = 20): NotificationRow[] { return this.all('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?', userId, limit) } diff --git a/src/email/templates.ts b/src/email/templates.ts index d14b76ca..828e5be7 100644 --- a/src/email/templates.ts +++ b/src/email/templates.ts @@ -404,3 +404,94 @@ export function testEmail(user: UserRow, baseUrl: string): { subject: string; te html: emailShell(`

@${escapeHtml(user.login)},你好:

这是一封 Fledge 测试邮件。收到它说明你的通知邮箱和 SMTP 发信都正常。

${escapeHtml(baseUrl)}

`), } } + +// #786: the maintainer-facing mail for a user note. The route owns redaction and +// the length bound; this template only lays out the safe fields it receives. +export function feedbackMail(input: { + id: string + login: string + kind: string + message: string + page: string + createdAt: string +}): { subject: string; text: string } { + return { + subject: `[Fledge] 用户反馈 · ${input.kind} · @${input.login}`.slice(0, 200), + text: [ + `@${input.login} 提交了一条反馈。`, + `编号:${input.id}`, + `时间:${input.createdAt}`, + `类型:${input.kind}`, + `页面:${input.page === '' ? '未填写' : input.page}`, + '', + '内容:', + input.message, + '', + '这封邮件由用户在 Settings → 反馈中提交;不包含请求体、cookie 或 token。', + mailFooter(), + ].join('\n'), + } +} + +// #786: a 5xx report. The ref is the same value the user saw; the fields are the +// captured row, already redacted and bounded by the error handler. +export function errorReportMail(input: { + ref: string + login: string | null + method: string + path: string + statusCode: number + errorName: string + errorMessage: string + stack: string + version: string + commit: string + createdAt: string + consentVersion: string + consentedAt: string +}): { subject: string; text: string } { + return { + subject: `[Fledge] 异常报告 · ${input.ref} · ${input.statusCode} ${input.errorName}`.slice(0, 200), + text: [ + `用户在错误页点击了“报告给开发者”。`, + `编号:${input.ref}`, + `时间:${input.createdAt}`, + `用户:${input.login === null ? '未登录' : `@${input.login}`}`, + `请求:${input.method} ${input.path}`, + `状态:${input.statusCode}`, + `错误:${input.errorName}: ${input.errorMessage}`, + ...(input.stack === '' ? [] : ['', '脱敏堆栈:', input.stack]), + `版本:${input.version} · commit ${input.commit}`, + `授权:用户已同意失败数据读取(${input.consentVersion},${input.consentedAt})。`, + '', + '报告只包含错误类型、消息、脱敏堆栈、路径、版本和时间;不包含请求体、query、cookie、token 或完整邮箱。', + mailFooter(), + ].join('\n'), + } +} + +// #814: a reporter-facing update. The Lead supplies the user message; Fledge +// owns the delivery and the ref. No raw error data is added here. +export function feedbackUpdateMail(input: { + ref: string + status: string + userMessage: string + issueUrl: string | null + prUrl: string | null + releasedVersion: string | null +}): { subject: string; text: string } { + return { + subject: `[Fledge] 反馈更新 · ${input.status}`.slice(0, 200), + text: [ + `你反馈的问题(${input.ref})有更新:${input.status}`, + '', + input.userMessage, + ...(input.issueUrl === null ? [] : ['', `Issue: ${input.issueUrl}`]), + ...(input.prUrl === null ? [] : ['', `PR: ${input.prUrl}`]), + ...(input.releasedVersion === null ? [] : ['', `已上线版本:v${input.releasedVersion}`]), + '', + '这是一封自动通知;回复请联系维护者。', + mailFooter(), + ].join('\n'), + } +} diff --git a/src/feedback/lead.ts b/src/feedback/lead.ts new file mode 100644 index 00000000..7f29cdb8 --- /dev/null +++ b/src/feedback/lead.ts @@ -0,0 +1,174 @@ +// #814: one report, one private triage packet to the Fledge Lead inside the +// server-deployed FlyBot. Two deployment shapes share the packet: +// - a repo Fledge already manages (`FLEDGE_LEAD_REPO_ID`); +// - an externally managed bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). +// The report itself must never fail because the Lead is offline; the caller +// keeps the row and the maintainer mail. +import type { Config } from '../config.js' +import type { ErrorReportRow, FeedbackRow, FeedbackTriageRow, Store } from '../db.js' +import { hmacHex, redact, safeEqual } from '../lib/secrets.js' + +export interface LeadTransport { + sendGuidance(repoId: string, target: string, text: string): Promise +} + +export type LeadMode = 'repo' | 'bridge' | 'none' + +export function leadMode(config: Config): LeadMode { + if (config.feedback.leadRepoId !== '') return 'repo' + if (config.feedback.leadBridgeUrl !== '' && config.feedback.leadBridgeToken !== '' && config.feedback.leadSessionId !== '') return 'bridge' + return 'none' +} + +export const TRIAGE_TOKEN_TTL_MS = 7 * 86_400_000 + +export function triageToken(sessionSecret: string, ref: string, expiresAt = Date.now() + TRIAGE_TOKEN_TTL_MS): string { + return `${expiresAt}.${hmacHex(sessionSecret, `feedback-triage:${ref}:${expiresAt}`)}` +} + +export function verifyTriageToken(sessionSecret: string, ref: string, supplied: string, now = Date.now()): boolean { + const [expires, signature] = supplied.split('.', 2) + const at = Number(expires) + if (!Number.isFinite(at) || at <= now || signature === undefined) return false + return safeEqual(signature, hmacHex(sessionSecret, `feedback-triage:${ref}:${expires}`)) +} + +// #814: the only text a Lead should paste into a public issue. It is derived +// from the Lead's own public_summary, never from the raw message or stack, and +// the @mention appears only when the reporter explicitly consented. +export function publicIssueDraft(triage: FeedbackTriageRow): { title: string; body: string; mention: string } { + const summary = (triage.public_summary ?? '').trim() || 'A Fledge user reported a problem.' + const mention = triage.publish_consent === 1 && triage.user_login !== null ? `@${triage.user_login}` : '' + return { + title: `[feedback] ${summary.slice(0, 80)}`.slice(0, 120), + body: `${mention === '' ? 'Thanks for the report' : `${mention} thanks for the report`} (ref \`${triage.ref}\`).\n\n${summary}\n\nFledge will update this issue when a fix ships.`, + mention, + } +} + +export interface TriagePacketInput { + config: Config + triage: FeedbackTriageRow + feedback?: FeedbackRow + error?: ErrorReportRow +} + +// The packet is private (bridge/Lead session), so it may name the reporter and +// the consent flag; the raw message and stack are fetched through the signed +// triage API rather than pasted into a public issue. +export function buildTriagePacket(input: TriagePacketInput): string { + const { config, triage, feedback, error } = input + const tokenExpiresAt = Date.now() + TRIAGE_TOKEN_TTL_MS + const token = triageToken(config.sessionSecret, triage.ref, tokenExpiresAt) + const summary = redact((feedback?.message ?? error?.error_message ?? '').trim()).slice(0, 500) + const reporter = triage.user_login === null ? 'anonymous' : `@${triage.user_login}` + const lines = [ + '[Fledge feedback triage]', + `ref: ${triage.ref}`, + `kind: ${triage.kind}`, + `reporter: ${reporter}`, + `publishConsent: ${triage.publish_consent === 1 ? 'yes' : 'no'}`, + `publicRepo: ${config.feedback.publicRepo}`, + `summary: ${summary === '' ? '(no summary)' : summary}`, + ...(error === undefined ? [] : [`error: ${error.error_name}: ${error.error_message}`]), + '', + `Read: GET ${config.baseUrl}/api/feedback/${encodeURIComponent(triage.ref)}`, + `Authorization: Bearer ${token}`, + `Token expires: ${new Date(tokenExpiresAt).toISOString()}`, + `Write: POST ${config.baseUrl}/api/feedback/${encodeURIComponent(triage.ref)}/triage`, + 'JSON: {status, user_message, public_summary, issue_url, pr_url, fixed_version}', + 'Statuses: user_error | duplicate | needs_info | confirmed | issue_opened | pr_opened | fixed | wontfix', + 'Rules:', + '- Decide whether this is a user/configuration problem or a platform defect.', + '- A confirmed defect: create one sanitized public issue in publicRepo; include the ref and @reporter only when publishConsent=yes. Never paste the raw message, stack, email or token into the issue.', + '- Record issue_url, pr_url and fixed_version through the Write endpoint.', + '- After setting public_summary, GET the record again for a ready-to-use sanitized issue draft.', + '- Fledge sends the in-app/email update to the reporter; do not send it yourself.', + ] + return lines.join('\n').slice(0, 4000) +} + +export const LEAD_MAX_ATTEMPTS = 5 + +export function leadRetryDelayMs(attempts: number): number { + const delays = [60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000, 6 * 60 * 60_000] + return delays[Math.max(0, Math.min(delays.length - 1, attempts - 1))]! +} + +// #814 follow-up: the route records every attempt, so a temporary bridge +// outage is retried with a bounded backoff instead of silently disappearing. +// The packet is idempotent by ref, so a resend after a lost DB write is safe. +export async function attemptLeadDelivery(input: TriagePacketInput & { + store: Store + instances: LeadTransport | undefined + fetchImpl?: typeof fetch + now?: number +}): Promise<{ ok: boolean; mode: LeadMode; error?: string }> { + const result = await deliverFeedbackToLead(input) + if (result.ok) { + input.store.markLeadDelivered(input.triage.ref) + return result + } + const attempts = input.triage.lead_attempts + 1 + const next = attempts >= LEAD_MAX_ATTEMPTS + ? null + : new Date((input.now ?? Date.now()) + leadRetryDelayMs(attempts)).toISOString() + input.store.recordLeadDeliveryFailure(input.triage.ref, result.error ?? 'unknown', attempts, next) + return result +} + +export async function retryPendingLeadDeliveries(input: { + store: Store + config: Config + instances: LeadTransport | undefined + fetchImpl?: typeof fetch + now?: number + limit?: number +}): Promise { + const now = input.now ?? Date.now() + const pending = input.store.listPendingLeadDeliveries(new Date(now).toISOString(), input.limit ?? 20) + let sent = 0 + for (const triage of pending) { + const feedback = input.store.feedbackById(triage.ref) + const error = input.store.errorReportByRef(triage.ref) + const result = await attemptLeadDelivery({ + store: input.store, + config: input.config, + instances: input.instances, + triage, + ...(feedback === undefined ? {} : { feedback }), + ...(error === undefined ? {} : { error }), + ...(input.fetchImpl === undefined ? {} : { fetchImpl: input.fetchImpl }), + now, + }) + if (result.ok) sent += 1 + } + return sent +} + +export async function deliverFeedbackToLead(input: TriagePacketInput & { + instances: LeadTransport | undefined + fetchImpl?: typeof fetch +}): Promise<{ ok: boolean; mode: LeadMode; error?: string }> { + const mode = leadMode(input.config) + if (mode === 'none') return { ok: false, mode, error: 'no Fledge Lead target configured' } + const packet = buildTriagePacket(input) + try { + if (mode === 'repo') { + if (input.instances === undefined) return { ok: false, mode, error: 'instance manager is not available' } + await input.instances.sendGuidance(input.config.feedback.leadRepoId, 'lead', packet) + return { ok: true, mode } + } + const fetchImpl = input.fetchImpl ?? globalThis.fetch + const response = await fetchImpl(`${input.config.feedback.leadBridgeUrl}/dsh`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${input.config.feedback.leadBridgeToken}` }, + body: JSON.stringify({ endpoint: 'send', payload: { sessionId: input.config.feedback.leadSessionId, text: packet } }), + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) return { ok: false, mode, error: `bridge answered ${String(response.status)}` } + return { ok: true, mode } + } catch (error) { + return { ok: false, mode, error: redact(error instanceof Error ? error.message : String(error)).slice(0, 300) } + } +} diff --git a/src/feedback/lifecycle.ts b/src/feedback/lifecycle.ts new file mode 100644 index 00000000..7cb298c5 --- /dev/null +++ b/src/feedback/lifecycle.ts @@ -0,0 +1,65 @@ +// #814: the Lead writes a decision, not a free-form status. This module keeps +// the accepted statuses, the fields each one needs and the public GitHub URL +// scope in one place, so the API and its tests cannot drift. + +export const TRIAGE_STATUSES = [ + 'user_error', + 'duplicate', + 'needs_info', + 'confirmed', + 'issue_opened', + 'pr_opened', + 'fixed', + 'wontfix', +] as const + +export type TriageStatus = (typeof TRIAGE_STATUSES)[number] + +export interface TriageDecision { + status: string + userMessage: string + publicSummary: string + issueUrl: string + prUrl: string + fixedVersion: string +} + +export function isTriageStatus(value: string): value is TriageStatus { + return (TRIAGE_STATUSES as readonly string[]).includes(value) +} + +// A public issue/PR link must be in the configured Fledge repository and be the +// right object kind. A link to another repository, a profile or a comment is a +// mistake the Lead should fix before Fledge records it. +export function publicGithubObject(value: string, kind: 'issues' | 'pull', publicRepo: string): boolean { + if (value === '') return true + const [owner, name] = publicRepo.split('/') + if (owner === undefined || owner === '' || name === undefined || name === '') return false + try { + const url = new URL(value) + if (url.protocol !== 'https:' || url.hostname !== 'github.com') return false + return new RegExp(`^/${escapeRegex(owner)}/${escapeRegex(name)}/${kind}/\\d+/?$`).test(url.pathname) + } catch { + return false + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Every user-facing status needs a message. A fixed result and a public issue +// additionally need the link/version that makes the next notification honest. +export function triageProblems(input: TriageDecision & { publicRepo: string }): string[] { + const problems: string[] = [] + if (input.status === 'new' || input.status === 'released') problems.push('status is system-managed and cannot be written by the Lead') + else if (!isTriageStatus(input.status)) problems.push('unsupported status') + if (input.status !== 'new' && input.status !== 'released' && input.userMessage.trim() === '') problems.push('user_message is required for a user-facing status') + if ((input.status === 'duplicate' || input.status === 'issue_opened') && input.issueUrl === '') problems.push('issue_url is required for this status') + if ((input.status === 'pr_opened' || input.status === 'fixed') && input.prUrl === '') problems.push('pr_url is required for this status') + if ((input.status === 'confirmed' || input.status === 'issue_opened') && input.publicSummary === '') problems.push('public_summary is required for a confirmed or opened issue') + if (input.status === 'fixed' && input.fixedVersion === '') problems.push('fixed_version is required for a fixed status') + if (!publicGithubObject(input.issueUrl, 'issues', input.publicRepo)) problems.push(`issue_url must be an issue in https://github.com/${input.publicRepo}`) + if (!publicGithubObject(input.prUrl, 'pull', input.publicRepo)) problems.push(`pr_url must be a pull request in https://github.com/${input.publicRepo}`) + return problems +} diff --git a/src/feedback/notify.ts b/src/feedback/notify.ts new file mode 100644 index 00000000..4f05fe5a --- /dev/null +++ b/src/feedback/notify.ts @@ -0,0 +1,81 @@ +// #814: the reporter-facing side of the triage lifecycle. The Lead writes a +// decision through the token-authenticated API; this module delivers the +// in-app/email update and, when the running version catches up with +// `fixed_version`, the final "now live" message once. +import type { Store } from '../db.js' +import type { Mailer } from '../email/mailer.js' +import { notifyEmail } from '../email/recipient.js' +import { feedbackUpdateMail } from '../email/templates.js' +import { compareVersions } from '../releases/release.js' +import { log } from '../lib/log.js' +import { redact } from '../lib/secrets.js' +import { nowIso } from '../lib/time.js' +import { VERSION } from '../version.js' + +export async function notifyFeedbackReporter(input: { + store: Store + mailer: Mailer + ref: string + userId: string | null + status: string + userMessage: string + issueUrl?: string | null + prUrl?: string | null + releasedVersion?: string | null +}): Promise { + if (input.userId === null || input.userMessage.trim() === '') return + input.store.notify(input.userId, 'feedback', input.userMessage.slice(0, 500), `feedback:${input.status}`) + const user = input.store.userById(input.userId) + const to = user === undefined ? '' : notifyEmail(user) + if (to !== '') { + try { + await input.mailer.send({ + to, + ...feedbackUpdateMail({ + ref: input.ref, + status: input.status, + userMessage: input.userMessage, + issueUrl: input.issueUrl ?? null, + prUrl: input.prUrl ?? null, + releasedVersion: input.releasedVersion ?? null, + }), + }) + } catch (error) { + log.warn('feedback update mail failed', { ref: input.ref, error: redact(String(error)) }) + } + } + input.store.updateFeedbackTriage(input.ref, { last_notified_status: input.status, notified_at: nowIso() }) +} + +export async function notifyReleasedFeedback(input: { + store: Store + mailer: Mailer + currentVersion?: string +}): Promise { + const current = input.currentVersion ?? VERSION + let sent = 0 + for (const row of input.store.listFeedbackForRelease(50)) { + if (row.fixed_version === null) continue + try { + if (compareVersions(row.fixed_version, current) > 0) continue + } catch { + continue + } + if (!input.store.markFeedbackReleased(row.ref, current)) continue + const message = `你反馈的问题已在 v${current} 修复并上线。${row.issue_url === null ? '' : ` issue:${row.issue_url}`}${row.pr_url === null ? '' : ` PR:${row.pr_url}`}` + await notifyFeedbackReporter({ + store: input.store, + mailer: input.mailer, + ref: row.ref, + userId: row.user_id, + status: 'released', + userMessage: message, + issueUrl: row.issue_url, + prUrl: row.pr_url, + releasedVersion: current, + }) + input.store.addFeedbackEvent(row.ref, 'released', `v${current}`, 'system') + sent += 1 + } + return sent +} diff --git a/src/i18n.ts b/src/i18n.ts index bdf05082..2d011ec9 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -232,6 +232,13 @@ const MESSAGES: Record = { '这封邮件刚刚发送过,24 小时内不重复发送。': 'This email was sent moments ago; it is not sent again within 24 hours.', '重新检查失败。': 'The re-check failed.', '验证任务启动失败。': 'Could not start the verification job.', + '反馈已保存,但通知邮件发送失败;请稍后再试。': 'Feedback was saved, but the notification email could not be sent; try again later.', + '反馈已发送,谢谢。': 'Feedback sent, thank you.', + '请填写反馈内容。': 'Enter your feedback message.', + '报告已发送': 'Report sent', + '这个报告编号不存在。': 'That report reference does not exist.', + '需要授权': 'Authorization required', + '请先勾选“我已知晓并授权 Fledge 平台读取这些失败数据”,再提交报告。': 'Select “I understand and authorize Fledge to read this failure data” before sending the report.', } export function localizeMessage(locale: Locale, message: string | undefined): string | undefined { @@ -278,6 +285,10 @@ export function localizeMessage(locale: Locale, message: string | undefined): st const scope = jobBudgetStop[2] === '仓库' ? 'this repository' : jobBudgetStop[2] === '用户' ? 'this user' : 'this job' return `JOB_BUDGET_${jobBudgetStop[1] ?? ''}: ${scope} cost ${jobBudgetStop[3] ?? ''} CNY, over its ${jobBudgetStop[4] ?? ''} CNY budget. ${action}` } + const reportAlready = /^这个报告((.*))已经发送过了,谢谢。$/.exec(message) + if (reportAlready !== null) return `This report (${reportAlready[1]}) was already sent, thank you.` + const reportThanks = /^谢谢。编号:(.*)。开发者会查看这个错误的类型、页面和时间;报告不包含你的输入、token 或请求体。$/.exec(message) + if (reportThanks !== null) return `Thank you. Reference: ${reportThanks[1]}. The maintainer will see the error type, page and time; the report does not include your input, token or request body.` const modelsSaved = /^(\d+) 个模型已通过测试并保存(?:;失败:([\s\S]+))?$/.exec(message) if (modelsSaved !== null) { const count = modelsSaved[1] ?? '0' diff --git a/src/views/layout.ts b/src/views/layout.ts index 770efacd..bae6745d 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -8,6 +8,8 @@ import { releaseSurface, type ReleaseSurface } from '../lib/releaseSurface.js' import type { Density } from './density.js' import { themeMeta, type Theme } from './theme.js' +export const FLEDGE_GITHUB_URL = 'https://github.com/HugoXK/fledge' + export function escapeHtml(value: unknown): string { return String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] ?? ch) } @@ -266,7 +268,8 @@ export function layout(options: LayoutOptions): string { ${escapeHtml(t('文档', 'Docs'))} Portal ${escapeHtml(t('平台状态', 'Status'))} - GitHub + ${escapeHtml(t('反馈', 'Feedback'))} + ★ ${escapeHtml(t('在 GitHub 上加星', 'Star Fledge on GitHub'))} ` return ` diff --git a/src/views/pages.ts b/src/views/pages.ts index 71a289fa..401591eb 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -1,6 +1,6 @@ import type { Config } from '../config.js' import type { CostReport } from '../metrics/cost.js' -import type { AlertView, AuditRow, DeploymentRollbackRow, DeploymentRow, JobEventRow, JobRow, ModelProviderRow, NotificationRow, OwnedSearch, PostmortemRecurrenceRow, PostmortemRow, ProviderModelRow, ReleaseRow, RepoInstanceRow, RepoRow, ReviewTrendWindow, SecurityFindingRow, SessionRow, UserRow, ApiTokenRow, WorkflowFailureRow, WorkItemRow, TaskRelationRow } from '../db.js' +import type { AlertView, AuditRow, DeploymentRollbackRow, DeploymentRow, FeedbackTriageRow, JobEventRow, JobRow, ModelProviderRow, NotificationRow, OwnedSearch, PostmortemRecurrenceRow, PostmortemRow, ProviderModelRow, ReleaseRow, RepoInstanceRow, RepoRow, ReviewTrendWindow, SecurityFindingRow, SessionRow, UserRow, ApiTokenRow, WorkflowFailureRow, WorkItemRow, TaskRelationRow } from '../db.js' import { isVerifiedFixed } from '../lib/securityFindings.js' import { assetUrl } from '../lib/assetVersion.js' import { redactCommand, type DriftAssessment, type EnvironmentCurrent, type ReleaseSnapshot } from '../lib/deployments.js' @@ -13,7 +13,7 @@ import { levelFromRank, PRIORITY_LEVELS } from '../lib/priority.js' import { RELATION_KINDS, relationKindLabel, type RelationKind } from '../lib/relations.js' import { DECISION_KINDS, DEFAULT_DECISION, decisionKindLabel } from '../lib/taskDecision.js' import { ITERATION_INTERVALS, REVIEW_INTERVALS, intervalLabel, normalizeInterval } from '../lib/intervals.js' -import { escapeHtml, layout, money, quotaMoney, type PageNotification, type Viewer } from './layout.js' +import { escapeHtml, FLEDGE_GITHUB_URL, layout, money, quotaMoney, type PageNotification, type Viewer } from './layout.js' import { renderState, stateCell, type StateAction } from './states.js' import { redact } from '../lib/secrets.js' import { REVIEW_INSTRUCTION_LABEL, reviewInstructionFromGoal } from '../lib/followUpInstruction.js' @@ -2097,6 +2097,23 @@ export function teamPage(input: { return layout({ title: t('团队工作台', 'Team console'), viewer, body, config, locale, path: `/repos/${repo.id}/team`, active: 'dashboard', notice, error }) } +function feedbackStatusLabel(t: Translate, status: string): string { + const labels: Record = { + new: ['已收到', 'Received'], + user_error: ['使用问题', 'User error'], + duplicate: ['重复', 'Duplicate'], + needs_info: ['需要补充信息', 'Needs info'], + confirmed: ['已确认', 'Confirmed'], + issue_opened: ['已建 issue', 'Issue opened'], + pr_opened: ['修复中', 'Fix in review'], + fixed: ['已修复', 'Fixed'], + released: ['已上线', 'Released'], + wontfix: ['不修复', 'Won\'t fix'], + } + const label = labels[status] + return label === undefined ? status : t(label[0], label[1]) +} + export function settingsPage(input: { config: Config locale: Locale @@ -2105,13 +2122,14 @@ export function settingsPage(input: { sessions: SessionRow[] currentSessionHash: string providers: Array + feedback?: FeedbackTriageRow[] discoveredProvider?: string discovered?: string[] newToken?: string notice?: string error?: string }): string { - const { config, locale, viewer, tokens, sessions, currentSessionHash, providers, discoveredProvider, discovered = [], newToken, notice, error } = input + const { config, locale, viewer, tokens, sessions, currentSessionHash, providers, feedback = [], discoveredProvider, discovered = [], newToken, notice, error } = input const t = translator(locale) const user = viewer.user const quota = quotaView(user, config) @@ -2160,6 +2178,7 @@ export function settingsPage(input: { ${escapeHtml(t('通知', 'Notifications'))} ${escapeHtml(t('API token', 'API tokens'))} ${escapeHtml(t('登录设备', 'Sessions'))} + ${escapeHtml(t('反馈', 'Feedback'))}
${escapeHtml(t('账户', 'ACCOUNT'))}
@@ -2289,6 +2308,49 @@ export function settingsPage(input: {
${csrf(viewer)}
${csrf(viewer)}
+
+ +
+
${escapeHtml(t('反馈', 'FEEDBACK'))}
+

${escapeHtml(t('反馈给开发者', 'Send feedback'))}

+

${escapeHtml(t('遇到问题、想要一个功能,或者发现异常?这里会保存一条记录并邮件通知开发者。', 'Found a problem, want a feature, or saw something unexpected? This stores a record and mails the maintainer.'))}

+
+ ${csrf(viewer)} +
+ + +
+ + +

${escapeHtml(t('不要粘贴 token、密码或他人的隐私信息。反馈会保存并邮件通知开发者。', 'Do not paste tokens, passwords or another person’s private data. The note is stored and mailed to the maintainer.'))}

+ +
+ ${feedback.length === 0 ? '' : `
+ ${feedback.map((row) => { + const links = [ + row.issue_url === null ? '' : `Issue`, + row.pr_url === null ? '' : `PR`, + ].filter((value) => value !== '').join(' · ') + const version = row.released_version ?? row.fixed_version + return `
#${escapeHtml(row.ref)} · ${escapeHtml(feedbackStatusLabel(t, row.status))}${escapeHtml(row.kind)}${links === '' ? '' : ` · ${links}`}${version === null ? '' : ` · v${escapeHtml(version)}`} · ${escapeHtml(humanTimeShort(row.updated_at))}
` + }).join('')} +
`} +
+ +
+
${escapeHtml(t('关于', 'ABOUT'))}
+

${escapeHtml(t('关于 Fledge', 'About Fledge'))}

+

${escapeHtml(t('Fledge 是开源的自托管平台;如果它对你的仓库有帮助,可以在 GitHub 上加星支持。', 'Fledge is an open-source, self-hosted platform. If it helps your repositories, a star on GitHub supports it.'))}

+
` return layout({ title: t('设置', 'Settings'), viewer, body, config, locale, path: '/settings', active: 'settings', notice, error }) } @@ -2768,18 +2830,27 @@ export function errorPage( message: string, locale: Locale = 'zh', action?: { href: string; label: string }, + report?: { ref: string }, ): string { const t = translator(locale) const localizedTitle = localizeMessage(locale, title) ?? title const localizedMessage = localizeMessage(locale, message) ?? message const actionHtml = action === undefined ? '' : `${escapeHtml(action.label)}` + // #786: only an unexpected 5xx carries a ref. One click posts the ref; the + // report contains no request body, query, cookie or token. + const reportHtml = report === undefined ? '' : `
+

${escapeHtml(t('报告只包含错误类型、消息、脱敏后的堆栈、页面、版本和时间;不包含请求体、query、cookie、token 或完整邮箱。', 'The report includes only the error type, message, redacted stack, page, version and time; not the request body, query, cookies, tokens or a full email address.'))}

+ + ${viewer === null ? '' : ``} + +
` return layout({ title: localizedTitle, viewer, config, locale, path: '/', - body: `

${escapeHtml(localizedTitle)}

${escapeHtml(localizedMessage)}

`, + body: `

${escapeHtml(localizedTitle)}

${escapeHtml(localizedMessage)}

${reportHtml}
`, }) } diff --git a/test/db.test.ts b/test/db.test.ts index b39f5d2d..2f75d6ad 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -151,9 +151,8 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - // #544 moved the build's schema to 41, so the first refused version - // is 42. - reopened.db.exec('PRAGMA user_version = 42;') + // #814 moved the build's schema to 43, so the first refused version is 44. + reopened.db.exec('PRAGMA user_version = 44;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) diff --git a/test/feedback.test.ts b/test/feedback.test.ts new file mode 100644 index 00000000..71c4e533 --- /dev/null +++ b/test/feedback.test.ts @@ -0,0 +1,151 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildApp } from '../src/app.js' +import { Store } from '../src/db.js' +import { MockRunner } from '../src/jobs/mock.js' +import { flashOf, formBody, makeUser, MemoryMailer, sessionFor, testConfig } from './helpers.js' + +async function fixture(config = testConfig()) { + const store = new Store(':memory:') + const mailer = new MemoryMailer() + const built = await buildApp({ config, store, mailer, runner: new MockRunner(), startScheduler: false }) + const user = makeUser(store, { login: 'feedback-user', status: 'approved' }) + const session = sessionFor(built, user) + return { built, store, mailer, user, session, cookie: `${session.cookie}; fledge_lang=en` } +} + +test('the settings page offers feedback and a Star link', async () => { + const f = await fixture() + try { + const response = await f.built.app.inject({ method: 'GET', url: '/settings', headers: { cookie: f.cookie } }) + assert.equal(response.statusCode, 200) + assert.match(response.body, /id="feedback"/) + assert.match(response.body, /action="\/feedback"/) + assert.match(response.body, /Star Fledge on GitHub/) + assert.match(response.body, /About Fledge/) + } finally { + await f.built.close() + } +}) + +test('feedback is stored, audited and mailed to the fallback admin address', async () => { + const f = await fixture() + try { + const response = await f.built.app.inject({ + method: 'POST', + url: '/feedback', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: f.cookie }, + payload: formBody({ csrf: f.session.csrf, kind: 'bug', message: 'The schedule page shows an unknown value.', page: '/repos/repo/ schedule' }), + }) + assert.equal(response.statusCode, 302) + assert.match(String(response.headers.location), /#feedback/) + const rows = f.store.listFeedback(10) + assert.equal(rows.length, 1) + assert.equal(rows[0]?.kind, 'bug') + assert.equal(rows[0]?.page, '/repos/repo/ schedule') + assert.match(rows[0]?.message ?? '', /unknown value/) + const mail = f.mailer.messages.find((message) => /用户反馈/.test(message.subject)) + assert.ok(mail, 'the feedback mail was sent') + assert.equal(mail.to, 'admin@example.com') + assert.match(mail.text, /feedback-user/) + assert.match(mail.text, /unknown value/) + assert.ok(f.store.listAudit(20).some((row) => row.action === 'feedback.created')) + const flash = flashOf(response) + assert.match(flash?.notice?.text ?? '', /反馈已发送/) + } finally { + await f.built.close() + } +}) + +test('empty feedback is refused and CSRF is required', async () => { + const f = await fixture() + try { + const empty = await f.built.app.inject({ + method: 'POST', + url: '/feedback', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: f.cookie }, + payload: formBody({ csrf: f.session.csrf, kind: 'feedback', message: ' ' }), + }) + assert.equal(empty.statusCode, 302) + assert.match(flashOf(empty)?.error?.text ?? '', /请填写反馈内容/) + assert.equal(f.store.listFeedback(10).length, 0) + + const noCsrf = await f.built.app.inject({ + method: 'POST', + url: '/feedback', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: f.cookie }, + payload: formBody({ kind: 'feedback', message: 'hello' }), + }) + assert.equal(noCsrf.statusCode, 403) + assert.equal(f.store.listFeedback(10).length, 0) + } finally { + await f.built.close() + } +}) + +test('FEEDBACK_EMAIL overrides the fallback destination', async () => { + const base = testConfig() + const config = testConfig({ mail: { ...base.mail, feedbackEmail: 'feedback@example.com' } }) + const f = await fixture(config) + try { + await f.built.app.inject({ + method: 'POST', + url: '/feedback', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: f.cookie }, + payload: formBody({ csrf: f.session.csrf, kind: 'feedback', message: 'route it to feedback' }), + }) + assert.equal(f.mailer.messages.find((message) => /用户反馈/.test(message.subject))?.to, 'feedback@example.com') + } finally { + await f.built.close() + } +}) + +test('a 5xx error captures a ref, one click reports it, and a 4xx has no report button', async () => { + const f = await fixture() + const originalListRepos = f.store.listRepos.bind(f.store) + try { + Object.defineProperty(f.store, 'listRepos', { value: () => { throw new Error('boom') }, configurable: true }) + const broken = await f.built.app.inject({ method: 'GET', url: '/console?token=secret', headers: { cookie: f.cookie } }) + assert.equal(broken.statusCode, 500) + assert.match(broken.body, /Report to the maintainer/) + const ref = /action="\/errors\/([A-Za-z0-9_-]+)\/report"/.exec(broken.body)?.[1] + assert.ok(ref, 'the error page carries the report ref') + const report = f.store.errorReportByRef(ref) + assert.ok(report) + assert.equal(report.status_code, 500) + assert.equal(report.path, '/console', 'the query string is not stored') + assert.equal(report.user_login, 'feedback-user') + assert.equal(report.error_message, 'boom') + + const noConsent = await f.built.app.inject({ method: 'POST', url: `/errors/${ref}/report`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: formBody({}) }) + assert.equal(noConsent.statusCode, 400) + assert.equal(f.store.errorReportByRef(ref)?.status, 'captured', 'consent is required before the report is sent') + assert.equal(f.mailer.messages.filter((message) => /异常报告/.test(message.subject)).length, 0) + + const first = await f.built.app.inject({ method: 'POST', url: `/errors/${ref}/report`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: formBody({ consent: '1' }) }) + assert.equal(first.statusCode, 200) + assert.equal(f.store.errorReportByRef(ref)?.status, 'reported') + assert.equal(f.store.errorReportByRef(ref)?.consent_version, '2026-11-15') + assert.ok(f.store.errorReportByRef(ref)?.consented_at) + assert.equal(f.mailer.messages.filter((message) => /异常报告/.test(message.subject)).length, 1) + assert.ok(f.store.listAudit(20).some((row) => row.action === 'error.reported')) + + const second = await f.built.app.inject({ method: 'POST', url: `/errors/${ref}/report`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: formBody({ consent: '1' }) }) + assert.equal(second.statusCode, 200) + assert.equal(f.mailer.messages.filter((message) => /异常报告/.test(message.subject)).length, 1, 'a second click does not mail twice') + } finally { + Object.defineProperty(f.store, 'listRepos', { value: originalListRepos, configurable: true }) + await f.built.close() + } +}) + +test('a 4xx page does not offer an error report', async () => { + const f = await fixture() + try { + const missing = await f.built.app.inject({ method: 'GET', url: '/does-not-exist', headers: { cookie: f.cookie } }) + assert.equal(missing.statusCode, 404) + assert.doesNotMatch(missing.body, /Report to the maintainer/) + } finally { + await f.built.close() + } +}) diff --git a/test/feedbackLead.test.ts b/test/feedbackLead.test.ts new file mode 100644 index 00000000..2e854605 --- /dev/null +++ b/test/feedbackLead.test.ts @@ -0,0 +1,269 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildApp } from '../src/app.js' +import { Store } from '../src/db.js' +import { MockRunner } from '../src/jobs/mock.js' +import { attemptLeadDelivery, buildTriagePacket, deliverFeedbackToLead, publicIssueDraft, retryPendingLeadDeliveries, triageToken, verifyTriageToken } from '../src/feedback/lead.js' +import { notifyReleasedFeedback } from '../src/feedback/notify.js' +import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js' + +function fixture() { + const store = new Store(':memory:') + const user = makeUser(store, { login: 'reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_lead', userId: user.id, kind: 'feedback', message: 'The schedule page says unknown', page: '/settings' }) + const triage = store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'feedback', publishConsent: true }) + return { store, user, feedback, triage } +} + +test('the packet names the ref, consent and signed triage endpoints', () => { + const f = fixture() + try { + const config = testConfig() + const packet = buildTriagePacket({ config, triage: f.triage, feedback: f.feedback }) + assert.match(packet, /ref: fb_lead/) + assert.match(packet, /publishConsent: yes/) + assert.match(packet, /\/api\/feedback\/fb_lead/) + const token = packet.split('Bearer ')[1]?.split('\n')[0] ?? '' + assert.equal(verifyTriageToken(config.sessionSecret, 'fb_lead', token), true, 'the packet carries a live token for this ref') + assert.match(packet, /Never paste the raw message, stack, email or token/) + } finally { + f.store.close() + } +}) + + +test('the public issue draft mentions the reporter only with publication consent', () => { + const f = fixture() + try { + const draft = publicIssueDraft(f.triage) + assert.match(draft.body, /@reporter/) + assert.match(draft.body, /fb_lead/) + const noConsent = publicIssueDraft({ ...f.triage, publish_consent: 0 }) + assert.doesNotMatch(noConsent.body, /@reporter/) + assert.equal(noConsent.mention, '') + } finally { + f.store.close() + } +}) + +test('a configured repo target sends through the instance manager', async () => { + const f = fixture() + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: 'repo_fledge' } }) + let captured: { repo: string; target: string; text: string } | undefined + const instances = { + async sendGuidance(repo: string, target: string, text: string) { captured = { repo, target, text } }, + } + const result = await deliverFeedbackToLead({ config, instances, triage: f.triage, feedback: f.feedback }) + assert.deepEqual(result, { ok: true, mode: 'repo' }) + assert.equal(captured?.repo, 'repo_fledge') + assert.equal(captured?.target, 'lead') + assert.match(captured?.text ?? '', /ref: fb_lead/) + } finally { + f.store.close() + } +}) + +test('a configured bridge target posts a send to the configured session', async () => { + const f = fixture() + const server = await startFakeServer(async (request, response) => { + const body = JSON.parse((await readBody(request)).toString('utf8')) as { endpoint: string; payload: { sessionId: string; text: string } } + assert.equal(request.headers.authorization, 'Bearer bridge-token') + assert.equal(body.endpoint, 'send') + assert.equal(body.payload.sessionId, 'lead-session') + assert.match(body.payload.text, /ref: fb_lead/) + json(response, 200, { ok: true }) + }) + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: '', leadBridgeUrl: server.url, leadBridgeToken: 'bridge-token', leadSessionId: 'lead-session' } }) + const result = await deliverFeedbackToLead({ config, instances: undefined, triage: f.triage, feedback: f.feedback }) + assert.deepEqual(result, { ok: true, mode: 'bridge' }) + } finally { + f.store.close() + await server.close() + } +}) + +test('a failed Lead delivery is recorded and retried, then marked delivered', async () => { + const f = fixture() + let calls = 0 + const server = await startFakeServer(async (request, response) => { + await readBody(request) + calls += 1 + json(response, calls === 1 ? 500 : 200, { ok: true }) + }) + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: '', leadBridgeUrl: server.url, leadBridgeToken: 'bridge-token', leadSessionId: 'lead-session' } }) + const first = await attemptLeadDelivery({ store: f.store, config, instances: undefined, triage: f.triage, feedback: f.feedback, now: 1_000 }) + assert.equal(first.ok, false) + const failed = f.store.feedbackTriageByRef(f.triage.ref) + assert.equal(failed?.lead_attempts, 1) + assert.match(failed?.lead_last_error ?? '', /500/) + assert.ok((failed?.lead_next_attempt_at ?? '') !== '') + assert.equal(failed?.lead_delivered_at, null) + + const second = await attemptLeadDelivery({ store: f.store, config, instances: undefined, triage: failed!, feedback: f.feedback, now: 2_000 }) + assert.equal(second.ok, true) + const delivered = f.store.feedbackTriageByRef(f.triage.ref) + assert.ok(delivered?.lead_delivered_at) + assert.equal(delivered?.lead_next_attempt_at, null) + } finally { + f.store.close() + await server.close() + } +}) + +test('the retry worker sends only pending Lead deliveries', async () => { + const f = fixture() + let calls = 0 + const server = await startFakeServer(async (request, response) => { + await readBody(request) + calls += 1 + json(response, 200, { ok: true }) + }) + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: '', leadBridgeUrl: server.url, leadBridgeToken: 'bridge-token', leadSessionId: 'lead-session' } }) + assert.equal(await retryPendingLeadDeliveries({ store: f.store, config, instances: undefined, now: 10_000 }), 1) + assert.equal(calls, 1) + assert.ok(f.store.feedbackTriageByRef(f.triage.ref)?.lead_delivered_at) + assert.equal(await retryPendingLeadDeliveries({ store: f.store, config, instances: undefined, now: 20_000 }), 0, 'a delivered row is not resent') + assert.equal(calls, 1) + } finally { + f.store.close() + await server.close() + } +}) + +test('the triage API reads and writes with the per-ref token and notifies the reporter', async () => { + const config = testConfig() + const store = new Store(':memory:') + const mailer = new MemoryMailer() + const built = await buildApp({ config, store, mailer, runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'api-reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_api', userId: user.id, kind: 'bug', message: 'boom', page: '/dashboard' }) + store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'bug', publishConsent: true }) + const token = triageToken(config.sessionSecret, feedback.id) + const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' } + + const read = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers }) + assert.equal(read.statusCode, 200) + const readBodyJson = read.json() as { triage: { ref: string }, issueDraft: { body: string } } + assert.equal(readBodyJson.triage.ref, 'fb_api') + assert.match(readBodyJson.issueDraft.body, /fb_api/) + + const written = await built.app.inject({ + method: 'POST', + url: '/api/feedback/fb_api/triage', + headers, + payload: { status: 'fixed', user_message: 'The schedule fix is ready.', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2', fixed_version: '0.3.6' }, + }) + assert.equal(written.statusCode, 200) + const row = store.feedbackTriageByRef('fb_api') + assert.equal(row?.status, 'fixed') + assert.equal(row?.fixed_version, '0.3.6') + assert.equal(row?.issue_url, 'https://github.com/HugoXK/fledge/issues/1') + assert.equal(store.listFeedbackEvents('fb_api')[0]?.status, 'fixed') + assert.ok(store.listNotifications(user.id).some((n) => n.kind === 'feedback')) + assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1) + + const replay = await built.app.inject({ + method: 'POST', + url: '/api/feedback/fb_api/triage', + headers, + payload: { status: 'fixed', user_message: 'The schedule fix is ready.', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2', fixed_version: '0.3.6' }, + }) + assert.equal(replay.statusCode, 200) + assert.equal((replay.json() as { unchanged: boolean }).unchanged, true) + assert.equal(store.listFeedbackEvents('fb_api').length, 1, 'a retry does not append a second event') + assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1, 'a retry does not mail twice') + + const missingVersion = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'fixed', user_message: 'x', pr_url: 'https://github.com/HugoXK/fledge/pull/2' } }) + assert.equal(missingVersion.statusCode, 400) + const missingIssue = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'duplicate', user_message: 'x' } }) + assert.equal(missingIssue.statusCode, 400) + const outsideRepo = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'confirmed', user_message: 'x', public_summary: 'x', issue_url: 'https://github.com/other/repo/issues/1' } }) + assert.equal(outsideRepo.statusCode, 400) + const systemStatus = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'released', user_message: 'x' } }) + assert.equal(systemStatus.statusCode, 400) + + const badToken = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers: { authorization: 'Bearer nope' } }) + assert.equal(badToken.statusCode, 401) + const expiredToken = triageToken(config.sessionSecret, 'fb_api', Date.now() - 1_000) + const expired = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers: { authorization: `Bearer ${expiredToken}` } }) + assert.equal(expired.statusCode, 401) + const badStatus = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'made_up' } }) + assert.equal(badStatus.statusCode, 400) + } finally { + await built.close() + } +}) + +test('a fixed record is notified once when the running version catches up', async () => { + const store = new Store(':memory:') + const mailer = new MemoryMailer() + try { + const user = makeUser(store, { login: 'release-reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_release', userId: user.id, kind: 'bug', message: 'boom', page: '/dashboard' }) + store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'bug', publishConsent: true }) + store.updateFeedbackTriage('fb_release', { status: 'fixed', fixed_version: '0.0.1', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2' }) + + assert.equal(await notifyReleasedFeedback({ store, mailer, currentVersion: '0.3.6' }), 1) + assert.equal(store.feedbackTriageByRef('fb_release')?.status, 'released') + assert.equal(store.feedbackTriageByRef('fb_release')?.released_version, '0.3.6') + assert.ok(store.listNotifications(user.id).some((n) => /0\.3\.6/.test(n.message))) + assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1) + assert.equal(await notifyReleasedFeedback({ store, mailer, currentVersion: '0.3.6' }), 0, 'a re-deploy of the same version is silent') + } finally { + store.close() + } +}) + +test('a feedback post with public-issue consent records it on the triage row', async () => { + const config = testConfig() + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'consent-reporter', status: 'approved' }) + const session = await import('./helpers.js').then(({ sessionFor }) => sessionFor(built, user)) + const response = await built.app.inject({ + method: 'POST', + url: '/feedback', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: session.cookie }, + payload: new URLSearchParams({ csrf: session.csrf, kind: 'feedback', message: 'A real note', publishConsent: '1' }).toString(), + }) + assert.equal(response.statusCode, 302) + const row = store.listFeedback(1)[0] + assert.ok(row) + assert.equal(store.feedbackTriageByRef(row.id)?.publish_consent, 1) + } finally { + await built.close() + } +}) + + +test('settings shows the reporter own feedback status, links and version', async () => { + const config = testConfig() + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'history-reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_history', userId: user.id, kind: 'bug', message: 'boom', page: '/dashboard' }) + store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'bug', publishConsent: true }) + store.updateFeedbackTriage('fb_history', { status: 'fixed', fixed_version: '0.3.6', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2' }) + const session = await import('./helpers.js').then(({ sessionFor }) => sessionFor(built, user)) + const page = await built.app.inject({ method: 'GET', url: '/settings', headers: { cookie: `${session.cookie}; fledge_lang=en` } }) + assert.equal(page.statusCode, 200) + assert.match(page.body, /data-feedback-history/) + assert.match(page.body, /fb_history/) + assert.match(page.body, /Fixed/) + assert.match(page.body, /issues\/1/) + assert.match(page.body, /pull\/2/) + } finally { + await built.close() + } +}) diff --git a/test/helpers.ts b/test/helpers.ts index 0c72d12d..e8b108fd 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -35,6 +35,7 @@ export function testConfig(overrides: Partial = {}): Config { transport: 'console', from: 'no-reply@example.com', adminEmail: 'admin@example.com', + feedbackEmail: 'admin@example.com', jobEmailCooldownMinutes: 60, smtp: { host: '', port: 465, secure: true, user: '', pass: '' }, }, diff --git a/test/scheduleLeases.test.ts b/test/scheduleLeases.test.ts index 1c77aaa3..2781916e 100644 --- a/test/scheduleLeases.test.ts +++ b/test/scheduleLeases.test.ts @@ -45,11 +45,11 @@ function claim(h: { store: Store; repoId: string; scheduleId: string }, token: s }) } -test('the occurrence row carries attempts and a lease, at user_version 41', () => { +test('the occurrence row carries attempts and a lease, at user_version 43', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 41, 'the daily run budget moves the refusal boundary from 40 to 41') + assert.equal(Number(version?.user_version), 43, 'the Lead delivery columns move the refusal boundary from 42 to 43') const columns = store.all<{ name: string }>('PRAGMA table_info(schedule_occurrences)').map((column) => column.name) for (const column of ['attempts', 'lease_token', 'lease_expires_at']) { diff --git a/test/scheduleOccurrences.test.ts b/test/scheduleOccurrences.test.ts index a2aa4129..12bb7fe3 100644 --- a/test/scheduleOccurrences.test.ts +++ b/test/scheduleOccurrences.test.ts @@ -56,11 +56,11 @@ function audits(store: Store, action: string): string[] { // -- the table ------------------------------------------------------------- -test('the occurrence table is part of the schema at user_version 41', () => { +test('the occurrence table is part of the schema at user_version 43', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 41, 'the daily run budget moves the refusal boundary from 40 to 41') + assert.equal(Number(version?.user_version), 43, 'the Lead delivery columns move the refusal boundary from 42 to 43') const table = store.get<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedule_occurrences'") assert.equal(table?.name, 'schedule_occurrences') diff --git a/test/scheduleSchema.test.ts b/test/scheduleSchema.test.ts index 529dd899..34755ab6 100644 --- a/test/scheduleSchema.test.ts +++ b/test/scheduleSchema.test.ts @@ -7,11 +7,11 @@ import { makeUser } from './helpers.js' // the version boundary, the columns the planner reads, and the one-row-per- // repository rule the tick relies on. -test('the schedule table is part of the schema at user_version 41', () => { +test('the schedule table is part of the schema at user_version 43', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 41, 'each schema change moves the refusal boundary: 36 the schedule, 37 the occurrence, 38 its lease, 39 its grace, 40 evolution consent, 41 the daily run budget') + assert.equal(Number(version?.user_version), 43, 'each schema change moves the refusal boundary: 41 feedback and error reports, 42 triage, 43 Lead delivery') const table = store.get<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedules'") assert.equal(table?.name, 'schedules') diff --git a/test/sectionNav.test.ts b/test/sectionNav.test.ts index 7a79da98..80f20906 100644 --- a/test/sectionNav.test.ts +++ b/test/sectionNav.test.ts @@ -132,7 +132,7 @@ test('every card with an id is named by exactly one chip, in document order', () test('every nav chip resolves to exactly one section in the same rendered page', () => { const pages = [ - { picker: 'settings' as const, ids: ['account', 'model-providers', 'notifications', 'tokens', 'sessions'] }, + { picker: 'settings' as const, ids: ['account', 'model-providers', 'notifications', 'tokens', 'sessions', 'feedback'] }, { picker: 'admin' as const, ids: ['pending', 'users', 'jobs', 'audit', 'release', 'backups', 'operations'] }, ] for (const page of pages) {