From 3546c171d91f902a1f2a2a16e620aced1347aac4 Mon Sep 17 00:00:00 2001 From: Gaozx1 Date: Sun, 27 Sep 2026 14:20:10 +0800 Subject: [PATCH 1/2] feat(donations): accept contributed accounts for New API credit Add a public donation flow: anyone can contribute a provider account and receive New API credit for it, without a console key. Backend - internal/control/donations.go: Donations service. It resolves the target site from system settings, imports the contributed credential through the existing account import path, and only then credits the contributor via New API POST /api/user/manage. - internal/console/donations.go: GET /api/donations advertises the accepted credential formats from the provider registry; POST submits a contribution. - Registered on the public router, deliberately outside withConsoleKey: a contributor has no console key and the reward goes to their own numeric New API user id. - System settings gain donation_base_url and donation_token. The token is stored as a secret and reported only as donation_configured, never echoed. Ordering and failure handling - Import first, credit second. A contribution that fails validation never reaches the credit call, so an unusable credential is never rewarded. - A credit failure after a successful import keeps the account and returns credited=false with credit_error, rather than rolling back a valid contribution or silently dropping the reward. Frontend - New public /donations page (HeroUI) outside RequireAuth, with per-format fields: a JSON credential, or the Qoder auth blob plus machine id. - api/donations.ts client, zh/en strings, and the rebuilt embedded assets. Tests cover the quota conversion, format-to-provider mapping, the credit request shape (Authorization + New-Api-User), site business errors, the no-credit-before-import ordering, and that the stored token is never exposed. --- changelog/unreleased/donations-page.md | 11 + frontend/src/App.tsx | 4 + frontend/src/api/donations.ts | 60 ++++ frontend/src/i18n/messages.ts | 60 ++++ frontend/src/pages/DonationsPage.tsx | 260 +++++++++++++++++ internal/console/donations.go | 109 +++++++ internal/control/control.go | 22 +- internal/control/donations.go | 269 ++++++++++++++++++ internal/control/donations_test.go | 204 +++++++++++++ internal/control/system.go | 28 +- internal/server/router.go | 5 +- ...t-DVf_aibb.js => TrafficChart-DXYqxmqS.js} | 2 +- .../webui/static/assets/index-CQrAj8e4.js | 28 -- ...{index-BEwWCUnU.css => index-DECZMxhX.css} | 2 +- .../webui/static/assets/index-e6Xn8luk.js | 28 ++ internal/webui/static/index.html | 4 +- 16 files changed, 1052 insertions(+), 44 deletions(-) create mode 100644 changelog/unreleased/donations-page.md create mode 100644 frontend/src/api/donations.ts create mode 100644 frontend/src/pages/DonationsPage.tsx create mode 100644 internal/console/donations.go create mode 100644 internal/control/donations.go create mode 100644 internal/control/donations_test.go rename internal/webui/static/assets/{TrafficChart-DVf_aibb.js => TrafficChart-DXYqxmqS.js} (99%) delete mode 100644 internal/webui/static/assets/index-CQrAj8e4.js rename internal/webui/static/assets/{index-BEwWCUnU.css => index-DECZMxhX.css} (91%) create mode 100644 internal/webui/static/assets/index-e6Xn8luk.js diff --git a/changelog/unreleased/donations-page.md b/changelog/unreleased/donations-page.md new file mode 100644 index 00000000..5c39da9b --- /dev/null +++ b/changelog/unreleased/donations-page.md @@ -0,0 +1,11 @@ +### English + +- Add a public `/donations` page where anyone can contribute a WorkBuddy, Qoder, Trae, Devin, or Command Code account and receive New API credit for it. The account is validated and imported into the pool first, and the credit is issued only afterwards, so an unusable credential is never rewarded. +- Add `GET /api/donations` (accepted formats) and `POST /api/donations` (submit a contribution). Both are public by design: a contributor has no console key, and the reward goes to their own numeric New API user id. +- Configure the donation site under System settings with `donation_base_url` and `donation_token`. The token is stored as a secret and is never returned by the settings API. + +### 中文 + +- 新增公开的 `/donations` 贡献页面:任何人都可以贡献 WorkBuddy、Qoder、Trae、Devin 或 Command Code 账号并获得 New API 额度。账号先经过校验并导入账号池,之后才发放额度,因此不可用的凭据不会被奖励。 +- 新增 `GET /api/donations`(可贡献类型)与 `POST /api/donations`(提交贡献)。两者按设计均为公开接口:贡献者没有控制台密钥,额度发放到其本人的 New API 数字用户 ID。 +- 在「系统设置」里用 `donation_base_url` 和 `donation_token` 配置贡献站点。令牌以密钥形式保存,系统设置接口不会返回它。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 174456ab..b8acbde7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { LoginPage } from '@/pages/LoginPage' import { SystemPage } from '@/pages/SystemPage' import { LogsPage } from '@/pages/LogsPage' import { KeysPage } from '@/pages/KeysPage' +import { DonationsPage } from '@/pages/DonationsPage' export default function App() { return ( @@ -23,6 +24,9 @@ export default function App() { } /> + {/* Donations are public: a contributor has no console key, and + the reward is credited to their own New API user id. */} + } /> }> }> } /> diff --git a/frontend/src/api/donations.ts b/frontend/src/api/donations.ts new file mode 100644 index 00000000..a7edd9c0 --- /dev/null +++ b/frontend/src/api/donations.ts @@ -0,0 +1,60 @@ +import { api } from './client' + +// A contributable credential format advertised by the backend. The page renders +// these instead of hardcoding which providers accept donations, so adding a +// provider on the Go side is enough. +export type DonationFormat = { + format: string + provider: string + label: string + region: string + // "json" for a credential object, "qoder_native" for the base64 auth blob + // plus machine id pair the Qoder CLI writes to its own home. + credential_kind: string + description: string +} + +export type DonationInfo = { + object: string + default_usd: number + quota_per_usd: number + formats: DonationFormat[] +} + +export type DonationResult = { + account_id: string + account_name: string + provider: string + region: string + status: string + credited_usd: number + credited_quota: number + credited: boolean + // Set when the account was accepted but the credit call failed. The + // contribution still counts; an operator credits it manually. + credit_error?: string +} + +export type DonationSubmit = { + format: string + name?: string + region?: string + newapi_user_id: number + credit_usd?: number + credential?: unknown + user_blob?: string + machine_id?: string +} + +// /api/donations is intentionally public, so these calls never send a console +// key and must not be wrapped in RequireAuth. +export function fetchDonationInfo() { + return api('/api/donations') +} + +export function submitDonation(input: DonationSubmit) { + return api('/api/donations', { + method: 'POST', + body: JSON.stringify(input), + }) +} diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index b19b9d3e..d6133465 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -656,6 +656,36 @@ export const messages: Record = { consoleKeyRotateNow: 'Rotate now', consoleKeySecretTitle: 'New console key', consoleKeySecretHint: 'This browser session is already updated. Copy the key for any other clients that used the old console secret.', + 'donations.title': 'Contribute an account', + 'donations.noLogin': 'No sign-in required', + 'donations.subtitle': 'Contribute a WorkBuddy, Qoder, or Trae account and receive New API credit on the site. The account joins the shared pool once it is accepted.', + 'donations.rewardTitle': 'Reward', + 'donations.rewardBody': 'Each accepted account earns {usd} USD, credited as {quota} New API quota units.', + 'donations.fieldType': 'Account type', + 'donations.fieldUserID': 'New API user ID', + 'donations.fieldUserIDHint': 'The numeric user ID on the New API site, not the username. It is shown in the site URL or personal settings.', + 'donations.fieldName': 'Account label', + 'donations.fieldNameHint': 'Optional. A name for this account in the console.', + 'donations.fieldCredential': 'Credential', + 'donations.fieldCredentialHint': 'Paste the credential JSON exported from this console, or the provider login bundle.', + 'donations.fieldUserBlob': 'Auth blob', + 'donations.fieldUserBlobHint': 'The base64 contents of the Qoder CLI auth file.', + 'donations.fieldMachineID': 'Machine ID', + 'donations.submit': 'Contribute', + 'donations.submitting': 'Contributing...', + 'donations.success': 'Thank you. The account was accepted and {usd} USD was credited.', + 'donations.credited': 'Contribution accepted', + 'donations.failed': 'Contribution failed', + 'donations.creditFailed': 'The account was accepted but the credit did not go through: {error}. Contact an operator to have it credited.', + 'donations.acceptedNoCredit': 'Account accepted, credit pending', + 'donations.infoFailed': 'Could not load contribution options', + 'donations.userIDInvalid': 'Enter a valid numeric New API user ID.', + 'donations.formatRequired': 'Choose an account type.', + 'donations.credentialRequired': 'Paste a credential.', + 'donations.credentialInvalid': 'The credential is not valid JSON.', + 'donations.qoderFieldsRequired': 'Both the auth blob and the machine ID are required.', + 'donations.notesTitle': 'Before you contribute', + 'donations.notesBody': 'A contributed account is added to the shared pool and used for other members requests. Only contribute accounts you are willing to share. Invalid or unusable credentials are rejected before any credit is issued.', }, zh: { brandSub: '登录态网关', @@ -1310,6 +1340,36 @@ export const messages: Record = { consoleKeyRotateNow: '立即轮换', consoleKeySecretTitle: '新的控制台密钥', consoleKeySecretHint: '当前浏览器会话已更新。如果还有客户端在用旧的控制台密钥,请把新值复制过去。', + 'donations.title': '贡献账号', + 'donations.noLogin': '无需登录', + 'donations.subtitle': '贡献一个 WorkBuddy、Qoder 或 Trae 账号,即可获得站点的 New API 额度。账号通过校验后会计入共享账号池。', + 'donations.rewardTitle': '贡献奖励', + 'donations.rewardBody': '每个通过的账号奖励 {usd} 美元,折算为 {quota} New API 额度单位。', + 'donations.fieldType': '账号类型', + 'donations.fieldUserID': 'New API 用户 ID', + 'donations.fieldUserIDHint': '站点上的数字用户 ID,不是用户名。在站点地址或个人设置里可以看到。', + 'donations.fieldName': '账号备注', + 'donations.fieldNameHint': '可选。这个账号在控制台里显示的名称。', + 'donations.fieldCredential': '凭据', + 'donations.fieldCredentialHint': '粘贴从本控制台导出的凭据 JSON,或对应平台的登录信息。', + 'donations.fieldUserBlob': '认证数据', + 'donations.fieldUserBlobHint': 'Qoder CLI 认证文件里的 base64 内容。', + 'donations.fieldMachineID': '机器码', + 'donations.submit': '提交贡献', + 'donations.submitting': '提交中…', + 'donations.success': '感谢贡献。账号已通过校验,已发放 {usd} 美元额度。', + 'donations.credited': '贡献成功', + 'donations.failed': '贡献失败', + 'donations.creditFailed': '账号已通过校验,但额度发放未成功:{error}。请联系管理员手动发放。', + 'donations.acceptedNoCredit': '账号已收录,额度待发放', + 'donations.infoFailed': '无法加载可贡献的类型', + 'donations.userIDInvalid': '请填写有效的 New API 数字用户 ID。', + 'donations.formatRequired': '请选择账号类型。', + 'donations.credentialRequired': '请填写凭据。', + 'donations.credentialInvalid': '凭据不是合法的 JSON。', + 'donations.qoderFieldsRequired': '认证数据和机器码都需要填写。', + 'donations.notesTitle': '贡献前请确认', + 'donations.notesBody': '贡献的账号会加入共享账号池,用于其他成员的请求。请只贡献你愿意共享的账号。无法使用的凭据会在发放额度之前被拒绝。', }, } diff --git a/frontend/src/pages/DonationsPage.tsx b/frontend/src/pages/DonationsPage.tsx new file mode 100644 index 00000000..f816b394 --- /dev/null +++ b/frontend/src/pages/DonationsPage.tsx @@ -0,0 +1,260 @@ +import { useEffect, useMemo, useState } from 'react' +import { Button, Card, Chip, Input, Label, ListBox, Select, TextArea } from '@heroui/react' +import { CheckCircle, HandHeart, Warning } from '@phosphor-icons/react' +import { useI18n } from '@/hooks/useI18n' +import { fetchDonationInfo, submitDonation, type DonationFormat, type DonationInfo, type DonationResult } from '@/api/donations' +import { FormRow } from '@/components/ui/FormRow' +import { PageAlert } from '@/components/ui/PageAlert' +import { ProviderMark } from '@/components/ProviderMark' +import { accountProviderLabel } from '@/lib/provider' + +type SubmitState = 'idle' | 'loading' | 'done' | 'error' + +// The Qoder CLI stores its login as a base64 auth blob plus a machine id rather +// than a JSON credential, so that format gets its own pair of fields. +const QODER_NATIVE = 'qoder_native' + +function parseJSONCredential(raw: string): { value?: unknown; error?: string } { + const text = raw.trim() + if (!text) return { error: 'empty' } + try { + return { value: JSON.parse(text) } + } catch { + return { error: 'invalid' } + } +} + +export function DonationsPage() { + const { t } = useI18n() + const [info, setInfo] = useState(null) + const [infoError, setInfoError] = useState('') + const [formatID, setFormatID] = useState('') + const [userID, setUserID] = useState('') + const [accountName, setAccountName] = useState('') + const [credential, setCredential] = useState('') + const [userBlob, setUserBlob] = useState('') + const [machineID, setMachineID] = useState('') + const [state, setState] = useState('idle') + const [message, setMessage] = useState('') + const [result, setResult] = useState(null) + + useEffect(() => { + let cancelled = false + fetchDonationInfo() + .then((data) => { + if (cancelled) return + setInfo(data) + const first = data.formats?.[0] + if (first) setFormatID(first.format) + }) + .catch((err: unknown) => { + if (!cancelled) setInfoError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, []) + + const selected: DonationFormat | undefined = useMemo( + () => info?.formats?.find((format) => format.format === formatID), + [info, formatID], + ) + + const rewardUSD = info?.default_usd ?? 1 + const rewardQuota = info?.quota_per_usd ?? 0 + + async function onSubmit() { + const numericID = Number.parseInt(userID.trim(), 10) + if (!Number.isFinite(numericID) || numericID <= 0) { + setState('error') + setMessage(t('donations.userIDInvalid')) + return + } + if (!selected) { + setState('error') + setMessage(t('donations.formatRequired')) + return + } + + const body: Record = { + format: selected.format, + name: accountName.trim() || undefined, + region: selected.region || undefined, + newapi_user_id: numericID, + credit_usd: rewardUSD, + } + if (selected.credential_kind === QODER_NATIVE) { + if (!userBlob.trim() || !machineID.trim()) { + setState('error') + setMessage(t('donations.qoderFieldsRequired')) + return + } + body.user_blob = userBlob.trim() + body.machine_id = machineID.trim() + } else { + const parsed = parseJSONCredential(credential) + if (parsed.value === undefined) { + setState('error') + setMessage(parsed.error === 'empty' ? t('donations.credentialRequired') : t('donations.credentialInvalid')) + return + } + body.credential = parsed.value + } + + setState('loading') + setMessage('') + setResult(null) + try { + const data = await submitDonation(body as never) + setResult(data) + if (data.credited) { + setState('done') + setMessage(t('donations.success', { usd: data.credited_usd, quota: data.credited_quota })) + } else { + // The account was accepted but the credit did not land. Report it as a + // partial outcome rather than a failure so the contributor knows the + // contribution counted. + setState('error') + setMessage(t('donations.creditFailed', { error: data.credit_error || '' })) + } + setCredential('') + setUserBlob('') + setMachineID('') + } catch (err: unknown) { + setState('error') + setMessage(err instanceof Error ? err.message : String(err)) + } + } + + return ( +
+
+
+ +

{t('donations.title')}

+ {t('donations.noLogin')} +
+

{t('donations.subtitle')}

+
+ + +
+
+

{t('donations.rewardTitle')}

+

+ {t('donations.rewardBody', { usd: rewardUSD, quota: rewardQuota.toLocaleString() })} +

+
+ + + + + + + setUserID(event.target.value)} + /> + + + + setAccountName(event.target.value)} + /> + + + {selected?.credential_kind === QODER_NATIVE ? ( + <> + +