@seasonkoh/webaz 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
- package/dist/layer0-foundation/L0-1-database/db.js +65 -0
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
- package/dist/layer1-agent/L1-1-mcp-server/server.js +198 -83
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
- package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +173 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +10 -2
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
- package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
- package/dist/pwa/acp-feed.js +13 -1
- package/dist/pwa/contract-fingerprint.js +2 -0
- package/dist/pwa/endpoint-actions.js +5 -1
- package/dist/pwa/goal-index.js +8 -8
- package/dist/pwa/human-presence.js +62 -0
- package/dist/pwa/public/app.js +575 -68
- package/dist/pwa/public/i18n.js +29 -20
- package/dist/pwa/public/index.html +1 -0
- package/dist/pwa/public/openapi.json +2 -2
- package/dist/pwa/rate-limit.js +22 -0
- package/dist/pwa/routes/account-deletion.js +15 -13
- package/dist/pwa/routes/addresses.js +10 -9
- package/dist/pwa/routes/admin-admins.js +13 -14
- package/dist/pwa/routes/admin-analytics.js +109 -69
- package/dist/pwa/routes/admin-catalog.js +13 -11
- package/dist/pwa/routes/admin-editor-picks.js +15 -10
- package/dist/pwa/routes/admin-events.js +5 -3
- package/dist/pwa/routes/admin-health.js +2 -1
- package/dist/pwa/routes/admin-moderation.js +26 -29
- package/dist/pwa/routes/admin-ops.js +22 -21
- package/dist/pwa/routes/admin-protocol-params.js +16 -19
- package/dist/pwa/routes/admin-reports.js +23 -21
- package/dist/pwa/routes/admin-tokenomics.js +26 -25
- package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
- package/dist/pwa/routes/admin-users-query.js +54 -53
- package/dist/pwa/routes/admin-verifier-flow.js +82 -41
- package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
- package/dist/pwa/routes/admin-wallet-ops.js +7 -5
- package/dist/pwa/routes/agent-buy.js +46 -22
- package/dist/pwa/routes/agent-governance.js +52 -56
- package/dist/pwa/routes/ai.js +7 -5
- package/dist/pwa/routes/analytics.js +43 -41
- package/dist/pwa/routes/anchors.js +19 -20
- package/dist/pwa/routes/announcements.js +13 -13
- package/dist/pwa/routes/arbitrator.js +97 -31
- package/dist/pwa/routes/auction.js +153 -114
- package/dist/pwa/routes/auth-login.js +6 -4
- package/dist/pwa/routes/auth-read.js +11 -9
- package/dist/pwa/routes/auth-register.js +35 -20
- package/dist/pwa/routes/auth-sessions.js +12 -11
- package/dist/pwa/routes/blocklist.js +16 -15
- package/dist/pwa/routes/build-feedback.js +10 -9
- package/dist/pwa/routes/build-reputation.js +6 -2
- package/dist/pwa/routes/build-tasks.js +45 -13
- package/dist/pwa/routes/buyer-feeds.js +27 -25
- package/dist/pwa/routes/cart.js +16 -15
- package/dist/pwa/routes/charity.js +212 -150
- package/dist/pwa/routes/chat.js +42 -43
- package/dist/pwa/routes/checkin-tasks.js +10 -9
- package/dist/pwa/routes/checkout-helpers.js +12 -10
- package/dist/pwa/routes/claim-initiators.js +34 -14
- package/dist/pwa/routes/claim-verify.js +86 -53
- package/dist/pwa/routes/claim-voting.js +43 -18
- package/dist/pwa/routes/contribution-identity.js +147 -0
- package/dist/pwa/routes/contribution-score.js +19 -0
- package/dist/pwa/routes/coupons.js +19 -16
- package/dist/pwa/routes/dashboards.js +18 -16
- package/dist/pwa/routes/dispute-cases.js +25 -24
- package/dist/pwa/routes/disputes-read.js +45 -51
- package/dist/pwa/routes/disputes-write.js +124 -61
- package/dist/pwa/routes/evidence.js +9 -9
- package/dist/pwa/routes/external-anchors.js +13 -12
- package/dist/pwa/routes/feedback.js +29 -33
- package/dist/pwa/routes/flash-sales.js +18 -16
- package/dist/pwa/routes/follows.js +25 -24
- package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
- package/dist/pwa/routes/governance-onboarding.js +70 -59
- package/dist/pwa/routes/group-buys.js +22 -22
- package/dist/pwa/routes/growth.js +33 -30
- package/dist/pwa/routes/import-product.js +12 -10
- package/dist/pwa/routes/kyc.js +9 -8
- package/dist/pwa/routes/leaderboard.js +20 -18
- package/dist/pwa/routes/listings.js +23 -22
- package/dist/pwa/routes/logistics.js +10 -8
- package/dist/pwa/routes/manifests.js +27 -27
- package/dist/pwa/routes/me-data.js +23 -21
- package/dist/pwa/routes/notifications.js +7 -6
- package/dist/pwa/routes/offers.js +30 -12
- package/dist/pwa/routes/orders-action.js +33 -17
- package/dist/pwa/routes/orders-create.js +75 -20
- package/dist/pwa/routes/orders-read.js +21 -20
- package/dist/pwa/routes/p2p-products.js +30 -18
- package/dist/pwa/routes/payments-governance.js +61 -56
- package/dist/pwa/routes/peers.js +9 -8
- package/dist/pwa/routes/pin-receipts.js +13 -13
- package/dist/pwa/routes/products-aliases.js +12 -10
- package/dist/pwa/routes/products-claims.js +36 -17
- package/dist/pwa/routes/products-create.js +53 -38
- package/dist/pwa/routes/products-crud.js +17 -16
- package/dist/pwa/routes/products-links.js +49 -26
- package/dist/pwa/routes/products-list.js +6 -4
- package/dist/pwa/routes/products-meta.js +40 -39
- package/dist/pwa/routes/products-update.js +19 -5
- package/dist/pwa/routes/profile-credentials.js +14 -16
- package/dist/pwa/routes/profile-identity.js +14 -13
- package/dist/pwa/routes/profile-location.js +7 -6
- package/dist/pwa/routes/profile-placement.js +19 -17
- package/dist/pwa/routes/profile-prefs.js +11 -11
- package/dist/pwa/routes/promoter.js +55 -49
- package/dist/pwa/routes/public-build-tasks.js +19 -0
- package/dist/pwa/routes/public-utils.js +108 -46
- package/dist/pwa/routes/push.js +16 -15
- package/dist/pwa/routes/ratings.js +30 -30
- package/dist/pwa/routes/recover-key.js +13 -12
- package/dist/pwa/routes/referral.js +37 -32
- package/dist/pwa/routes/reputation.js +3 -2
- package/dist/pwa/routes/returns.js +76 -73
- package/dist/pwa/routes/reviews.js +41 -18
- package/dist/pwa/routes/rewards-apply.js +16 -15
- package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
- package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
- package/dist/pwa/routes/rfqs.js +163 -85
- package/dist/pwa/routes/search.js +16 -14
- package/dist/pwa/routes/secondhand.js +25 -22
- package/dist/pwa/routes/seller-quota.js +24 -26
- package/dist/pwa/routes/share-redirects.js +59 -55
- package/dist/pwa/routes/shareables-interactions.js +34 -35
- package/dist/pwa/routes/shareables.js +55 -51
- package/dist/pwa/routes/shop-referral.js +57 -0
- package/dist/pwa/routes/shops.js +20 -18
- package/dist/pwa/routes/signaling.js +10 -9
- package/dist/pwa/routes/skill-market.js +16 -16
- package/dist/pwa/routes/skills.js +15 -14
- package/dist/pwa/routes/snf.js +14 -13
- package/dist/pwa/routes/tags.js +10 -9
- package/dist/pwa/routes/task-proposals.js +45 -0
- package/dist/pwa/routes/trial.js +69 -51
- package/dist/pwa/routes/trusted-kpi.js +20 -18
- package/dist/pwa/routes/url-claim.js +67 -28
- package/dist/pwa/routes/users-public.js +62 -60
- package/dist/pwa/routes/variants.js +12 -13
- package/dist/pwa/routes/verifier-user.js +61 -21
- package/dist/pwa/routes/verify-tasks.js +49 -25
- package/dist/pwa/routes/waitlist.js +16 -15
- package/dist/pwa/routes/wallet-read.js +74 -36
- package/dist/pwa/routes/wallet-write.js +12 -9
- package/dist/pwa/routes/webauthn.js +25 -26
- package/dist/pwa/routes/webhooks.js +26 -26
- package/dist/pwa/routes/welcome.js +45 -50
- package/dist/pwa/routes/wishlist-qa.js +29 -32
- package/dist/pwa/server.js +237 -81
- package/dist/version.js +1 -1
- package/package.json +47 -2
|
@@ -2,6 +2,7 @@ import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthen
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
// RFC-004 体验补:绑定 Passkey 后,追溯补发此前"已受理但无锚点跳过"的建设信誉。
|
|
4
4
|
import { grantPendingAnchorCredits } from '../../layer2-business/L2-8-feedback/build-feedback-engine.js';
|
|
5
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
5
6
|
export function registerWebauthnRoutes(app, deps) {
|
|
6
7
|
const { db, auth, generateId, rpId, rpName, origin, challengeTtlMs, gateTtlMs, invalidateAgentRiskCacheForUser, requireHumanPresence } = deps;
|
|
7
8
|
// 1. 注册:start — 生成 challenge + 选项
|
|
@@ -9,7 +10,7 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
9
10
|
const user = auth(req, res);
|
|
10
11
|
if (!user)
|
|
11
12
|
return;
|
|
12
|
-
const existing =
|
|
13
|
+
const existing = await dbAll('SELECT id FROM webauthn_credentials WHERE user_id = ?', [user.id]);
|
|
13
14
|
const opts = await generateRegistrationOptions({
|
|
14
15
|
rpName,
|
|
15
16
|
rpID: rpId,
|
|
@@ -21,8 +22,7 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
21
22
|
authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
|
|
22
23
|
});
|
|
23
24
|
const chId = generateId('wac');
|
|
24
|
-
|
|
25
|
-
.run(chId, user.id, opts.challenge, 'register', new Date(Date.now() + challengeTtlMs).toISOString());
|
|
25
|
+
await dbRun(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, expires_at) VALUES (?,?,?,?,?)`, [chId, user.id, opts.challenge, 'register', new Date(Date.now() + challengeTtlMs).toISOString()]);
|
|
26
26
|
res.json({ options: opts, challenge_id: chId });
|
|
27
27
|
});
|
|
28
28
|
// 2. 注册:finish — 验证 + 入库
|
|
@@ -31,7 +31,7 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
31
31
|
if (!user)
|
|
32
32
|
return;
|
|
33
33
|
const { challenge_id, response, device_label } = req.body || {};
|
|
34
|
-
const ch =
|
|
34
|
+
const ch = await dbOne(`SELECT challenge, expires_at, consumed_at FROM webauthn_challenges WHERE id = ? AND user_id = ? AND purpose = 'register'`, [challenge_id, user.id]);
|
|
35
35
|
if (!ch)
|
|
36
36
|
return void res.status(404).json({ error: 'challenge not found' });
|
|
37
37
|
if (ch.consumed_at)
|
|
@@ -51,9 +51,9 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
51
51
|
return void res.status(400).json({ error: 'verification failed' });
|
|
52
52
|
}
|
|
53
53
|
const { credential } = verification.registrationInfo;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
await dbRun(`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, device_label) VALUES (?,?,?,?,?,?)`, [credential.id, user.id, Buffer.from(credential.publicKey), credential.counter || 0,
|
|
55
|
+
JSON.stringify(credential.transports || []), (device_label || '').slice(0, 60) || null]);
|
|
56
|
+
await dbRun("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?", [challenge_id]);
|
|
57
57
|
invalidateAgentRiskCacheForUser(user.id); // 让 D2b 中间件立刻看到刚绑的 Passkey,否则 5min 缓存窗内仍被拦
|
|
58
58
|
// RFC-004:绑定 Passkey = 成为可问责真人 → 追溯补发此前因无锚点跳过的建设信誉(advisory,永不阻塞绑定)
|
|
59
59
|
let backfilled;
|
|
@@ -73,11 +73,11 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
73
73
|
if (!user)
|
|
74
74
|
return;
|
|
75
75
|
const purpose = String(req.body?.purpose || '').trim();
|
|
76
|
-
const allowed = new Set(['withdraw', 'change-password', 'reveal-key', 'region', 'delete_passkey', 'governance_apply', 'governance_activate', 'governance_resign', 'governance_appeal_resolve', 'rewards_apply', 'rewards_deactivate']);
|
|
76
|
+
const allowed = new Set(['withdraw', 'change-password', 'reveal-key', 'region', 'delete_passkey', 'governance_apply', 'governance_activate', 'governance_resign', 'governance_appeal_resolve', 'rewards_apply', 'rewards_deactivate', 'identity_claim']);
|
|
77
77
|
if (!allowed.has(purpose))
|
|
78
78
|
return void res.status(400).json({ error: 'invalid purpose' });
|
|
79
79
|
const purpose_data = req.body?.purpose_data ?? null;
|
|
80
|
-
const creds =
|
|
80
|
+
const creds = await dbAll('SELECT id, transports FROM webauthn_credentials WHERE user_id = ?', [user.id]);
|
|
81
81
|
if (creds.length === 0)
|
|
82
82
|
return void res.status(403).json({ error: '尚未注册任何 Passkey' });
|
|
83
83
|
const opts = await generateAuthenticationOptions({
|
|
@@ -92,8 +92,9 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
92
92
|
} })() })),
|
|
93
93
|
});
|
|
94
94
|
const chId = generateId('wac');
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
await dbRun(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?,?)`, [chId, user.id, opts.challenge, purpose,
|
|
96
|
+
purpose_data ? JSON.stringify(purpose_data) : null,
|
|
97
|
+
new Date(Date.now() + challengeTtlMs).toISOString()]);
|
|
97
98
|
res.json({ options: opts, challenge_id: chId });
|
|
98
99
|
});
|
|
99
100
|
// 4. 认证:finish — 验证签名 + 颁发短 gate token
|
|
@@ -102,15 +103,14 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
102
103
|
if (!user)
|
|
103
104
|
return;
|
|
104
105
|
const { challenge_id, response } = req.body || {};
|
|
105
|
-
const ch =
|
|
106
|
+
const ch = await dbOne(`SELECT challenge, purpose, purpose_data, expires_at, consumed_at FROM webauthn_challenges WHERE id = ? AND user_id = ?`, [challenge_id, user.id]);
|
|
106
107
|
if (!ch)
|
|
107
108
|
return void res.status(404).json({ error: 'challenge not found' });
|
|
108
109
|
if (ch.consumed_at)
|
|
109
110
|
return void res.status(409).json({ error: 'challenge already used' });
|
|
110
111
|
if (new Date(ch.expires_at).getTime() < Date.now())
|
|
111
112
|
return void res.status(410).json({ error: 'challenge expired' });
|
|
112
|
-
const cred =
|
|
113
|
-
.get(response?.id, user.id);
|
|
113
|
+
const cred = await dbOne(`SELECT id, public_key, counter, transports FROM webauthn_credentials WHERE id = ? AND user_id = ?`, [response?.id, user.id]);
|
|
114
114
|
if (!cred)
|
|
115
115
|
return void res.status(404).json({ error: 'credential not registered' });
|
|
116
116
|
try {
|
|
@@ -136,13 +136,12 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
136
136
|
if (!verification.verified)
|
|
137
137
|
return void res.status(400).json({ error: 'signature failed' });
|
|
138
138
|
// 更新 counter(防重放)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
db.prepare("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?").run(challenge_id);
|
|
139
|
+
await dbRun(`UPDATE webauthn_credentials SET counter = ?, last_used_at = datetime('now') WHERE id = ?`, [verification.authenticationInfo.newCounter, cred.id]);
|
|
140
|
+
await dbRun("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?", [challenge_id]);
|
|
142
141
|
// 颁发短 token
|
|
143
142
|
const token = generateId('wgt') + '_' + randomBytes(8).toString('hex');
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
await dbRun(`INSERT INTO webauthn_gate_tokens (id, user_id, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?)`, [token, user.id, ch.purpose, ch.purpose_data,
|
|
144
|
+
new Date(Date.now() + gateTtlMs).toISOString()]);
|
|
146
145
|
res.json({ success: true, gate_token: token, expires_in_seconds: Math.floor(gateTtlMs / 1000) });
|
|
147
146
|
}
|
|
148
147
|
catch (e) {
|
|
@@ -150,15 +149,15 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
150
149
|
}
|
|
151
150
|
});
|
|
152
151
|
// 列出 / 删除 credential
|
|
153
|
-
app.get('/api/webauthn/credentials', (req, res) => {
|
|
152
|
+
app.get('/api/webauthn/credentials', async (req, res) => {
|
|
154
153
|
const user = auth(req, res);
|
|
155
154
|
if (!user)
|
|
156
155
|
return;
|
|
157
|
-
const rows =
|
|
156
|
+
const rows = await dbAll(`SELECT id, device_label, transports, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC`, [user.id]);
|
|
158
157
|
const required = !!user.webauthn_required_for_withdraw;
|
|
159
158
|
res.json({ credentials: rows, settings: { required_for_withdraw: required } });
|
|
160
159
|
});
|
|
161
|
-
app.delete('/api/webauthn/credentials/:id', (req, res) => {
|
|
160
|
+
app.delete('/api/webauthn/credentials/:id', async (req, res) => {
|
|
162
161
|
const user = auth(req, res);
|
|
163
162
|
if (!user)
|
|
164
163
|
return;
|
|
@@ -174,23 +173,23 @@ export function registerWebauthnRoutes(app, deps) {
|
|
|
174
173
|
});
|
|
175
174
|
if (!hpCheck.ok)
|
|
176
175
|
return void res.status(412).json({ error: hpCheck.reason, error_code: hpCheck.error_code });
|
|
177
|
-
const r =
|
|
176
|
+
const r = await dbRun('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?', [req.params.id, user.id]);
|
|
178
177
|
if (r.changes > 0)
|
|
179
178
|
invalidateAgentRiskCacheForUser(user.id); // 删到最后一把就丢真人身份,立刻反映到 D2b
|
|
180
179
|
res.json({ success: true, deleted: r.changes });
|
|
181
180
|
});
|
|
182
|
-
app.post('/api/webauthn/settings', (req, res) => {
|
|
181
|
+
app.post('/api/webauthn/settings', async (req, res) => {
|
|
183
182
|
const user = auth(req, res);
|
|
184
183
|
if (!user)
|
|
185
184
|
return;
|
|
186
185
|
const required = req.body?.required_for_withdraw ? 1 : 0;
|
|
187
186
|
// 开启前必须至少有 1 个 credential
|
|
188
187
|
if (required) {
|
|
189
|
-
const n =
|
|
188
|
+
const n = (await dbOne('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?', [user.id])).n;
|
|
190
189
|
if (n === 0)
|
|
191
190
|
return void res.status(400).json({ error: '请先注册至少一个 Passkey' });
|
|
192
191
|
}
|
|
193
|
-
|
|
192
|
+
await dbRun('UPDATE users SET webauthn_required_for_withdraw = ? WHERE id = ?', [required, user.id]);
|
|
194
193
|
res.json({ success: true, required_for_withdraw: !!required });
|
|
195
194
|
});
|
|
196
195
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { isPrivateOrInternalHost } from '../security/ssrf.js';
|
|
3
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
3
4
|
export const WEBHOOK_EVENT_TYPES = [
|
|
4
5
|
'order.created', 'order.paid', 'order.shipped', 'order.delivered', 'order.completed', 'order.disputed',
|
|
5
6
|
'wish.claimed', 'wish.proof', 'wish.confirmed', 'wish.repay', 'wish.repay_resp',
|
|
@@ -7,37 +8,36 @@ export const WEBHOOK_EVENT_TYPES = [
|
|
|
7
8
|
'charity.donation', 'charity.fund_redirect',
|
|
8
9
|
];
|
|
9
10
|
// P2.2 失败通知:连续 5 次失败 → 自动 active=0 + 通知用户
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// RFC-016: db 参数保留(调用方仍传),内部走异步 seam(同一实例,setSeamDb)。
|
|
12
|
+
async function recordWebhookFailure(_db, generateId, sub, errMsg) {
|
|
13
|
+
await dbRun(`UPDATE webhook_subscriptions SET fail_count = fail_count + 1, last_error = ?, last_fired_at = datetime('now') WHERE id = ?`, [errMsg, sub.id]);
|
|
14
|
+
const after = (await dbOne(`SELECT fail_count, active FROM webhook_subscriptions WHERE id = ?`, [sub.id]));
|
|
13
15
|
if (after.active && after.fail_count > 0 && after.fail_count % 5 === 0) {
|
|
14
16
|
try {
|
|
15
|
-
|
|
16
|
-
VALUES (?,?,'webhook_fail',?,?,datetime('now'))`)
|
|
17
|
-
.run(generateId('ntf'), sub.user_id, `⚠ Webhook 连续 ${after.fail_count} 次失败`, `${sub.event_type} → ${String(sub.target_url).slice(0, 60)}... · ${errMsg}`);
|
|
17
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
|
|
18
|
+
VALUES (?,?,'webhook_fail',?,?,datetime('now'))`, [generateId('ntf'), sub.user_id, `⚠ Webhook 连续 ${after.fail_count} 次失败`, `${sub.event_type} → ${String(sub.target_url).slice(0, 60)}... · ${errMsg}`]);
|
|
18
19
|
}
|
|
19
20
|
catch (e) {
|
|
20
21
|
console.error('[webhook notify fail]', e);
|
|
21
22
|
}
|
|
22
23
|
// 失败 >= 20 次 → 自动暂停
|
|
23
24
|
if (after.fail_count >= 20) {
|
|
24
|
-
|
|
25
|
+
await dbRun(`UPDATE webhook_subscriptions SET active = 0 WHERE id = ?`, [sub.id]);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
// 触发 webhook 投递(v1 同步 fetch,超时 5s)
|
|
29
30
|
// 跨域 API — charity / RFQ / orders 等通过此函数广播事件
|
|
30
|
-
export async function fireWebhooks(
|
|
31
|
+
export async function fireWebhooks(_db, generateId, eventType, payload, userIds) {
|
|
31
32
|
const where = userIds && userIds.length
|
|
32
33
|
? `event_type = ? AND active = 1 AND user_id IN (${userIds.map(() => '?').join(',')})`
|
|
33
34
|
: `event_type = ? AND active = 1`;
|
|
34
35
|
const args = [eventType, ...(userIds || [])];
|
|
35
|
-
const subs =
|
|
36
|
+
const subs = await dbAll(`SELECT * FROM webhook_subscriptions WHERE ${where}`, args);
|
|
36
37
|
for (const sub of subs) {
|
|
37
38
|
// P1.1 SSRF:投递前再次校验(防止旧订阅或 DB 直改绕过创建时检查)
|
|
38
39
|
if (isPrivateOrInternalHost(String(sub.target_url))) {
|
|
39
|
-
|
|
40
|
-
.run('blocked: private/internal host', sub.id);
|
|
40
|
+
await dbRun(`UPDATE webhook_subscriptions SET fail_count = fail_count + 1, last_error = ?, active = 0 WHERE id = ?`, ['blocked: private/internal host', sub.id]);
|
|
41
41
|
continue;
|
|
42
42
|
}
|
|
43
43
|
const body = JSON.stringify({ event: eventType, payload, ts: new Date().toISOString() });
|
|
@@ -52,21 +52,22 @@ export async function fireWebhooks(db, generateId, eventType, payload, userIds)
|
|
|
52
52
|
});
|
|
53
53
|
clearTimeout(tm);
|
|
54
54
|
if (r.ok) {
|
|
55
|
-
|
|
55
|
+
await dbRun(`UPDATE webhook_subscriptions SET fire_count = fire_count + 1, last_fired_at = datetime('now'), last_error = NULL WHERE id = ?`, [sub.id]);
|
|
56
56
|
}
|
|
57
57
|
else {
|
|
58
|
-
recordWebhookFailure(
|
|
58
|
+
await recordWebhookFailure(_db, generateId, sub, 'HTTP ' + r.status);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
catch (e) {
|
|
62
|
-
recordWebhookFailure(
|
|
62
|
+
await recordWebhookFailure(_db, generateId, sub, String(e.message).slice(0, 200));
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
export function registerWebhookRoutes(app, deps) {
|
|
67
|
-
|
|
67
|
+
// db 已走 RFC-016 异步 seam(dbOne/dbAll/dbRun),不再直接用 deps.db
|
|
68
|
+
const { auth, generateId, rateLimitOk } = deps;
|
|
68
69
|
// POST 订阅
|
|
69
|
-
app.post('/api/webhooks', (req, res) => {
|
|
70
|
+
app.post('/api/webhooks', async (req, res) => {
|
|
70
71
|
const user = auth(req, res);
|
|
71
72
|
if (!user)
|
|
72
73
|
return;
|
|
@@ -86,40 +87,39 @@ export function registerWebhookRoutes(app, deps) {
|
|
|
86
87
|
if (!WEBHOOK_EVENT_TYPES.includes(eventType))
|
|
87
88
|
return void res.json({ error: '不支持的 event_type' });
|
|
88
89
|
// 每用户最多 20 个订阅
|
|
89
|
-
const cnt =
|
|
90
|
+
const cnt = (await dbOne(`SELECT COUNT(1) as n FROM webhook_subscriptions WHERE user_id = ?`, [user.id])).n;
|
|
90
91
|
if (cnt >= 20)
|
|
91
92
|
return void res.json({ error: '订阅数量上限 20' });
|
|
92
93
|
const id = generateId('whk');
|
|
93
|
-
|
|
94
|
-
.run(id, user.id, eventType, url, secret);
|
|
94
|
+
await dbRun(`INSERT INTO webhook_subscriptions (id, user_id, event_type, target_url, secret) VALUES (?,?,?,?,?)`, [id, user.id, eventType, url, secret]);
|
|
95
95
|
res.json({ id, event_type: eventType, target_url: url });
|
|
96
96
|
});
|
|
97
97
|
// GET 我的订阅
|
|
98
|
-
app.get('/api/webhooks', (req, res) => {
|
|
98
|
+
app.get('/api/webhooks', async (req, res) => {
|
|
99
99
|
const user = auth(req, res);
|
|
100
100
|
if (!user)
|
|
101
101
|
return;
|
|
102
|
-
const items =
|
|
103
|
-
FROM webhook_subscriptions WHERE user_id = ? ORDER BY created_at DESC
|
|
102
|
+
const items = await dbAll(`SELECT id, event_type, target_url, active, last_fired_at, fire_count, fail_count, last_error, created_at
|
|
103
|
+
FROM webhook_subscriptions WHERE user_id = ? ORDER BY created_at DESC`, [user.id]);
|
|
104
104
|
res.json({ items, event_types: WEBHOOK_EVENT_TYPES });
|
|
105
105
|
});
|
|
106
106
|
// DELETE
|
|
107
|
-
app.delete('/api/webhooks/:id', (req, res) => {
|
|
107
|
+
app.delete('/api/webhooks/:id', async (req, res) => {
|
|
108
108
|
const user = auth(req, res);
|
|
109
109
|
if (!user)
|
|
110
110
|
return;
|
|
111
|
-
const r =
|
|
111
|
+
const r = await dbRun(`DELETE FROM webhook_subscriptions WHERE id = ? AND user_id = ?`, [req.params.id, user.id]);
|
|
112
112
|
if (r.changes === 0)
|
|
113
113
|
return void res.json({ error: '订阅不存在或非你所有' });
|
|
114
114
|
res.json({ ok: true });
|
|
115
115
|
});
|
|
116
116
|
// PATCH active toggle
|
|
117
|
-
app.patch('/api/webhooks/:id', (req, res) => {
|
|
117
|
+
app.patch('/api/webhooks/:id', async (req, res) => {
|
|
118
118
|
const user = auth(req, res);
|
|
119
119
|
if (!user)
|
|
120
120
|
return;
|
|
121
121
|
const active = req.body.active ? 1 : 0;
|
|
122
|
-
const r =
|
|
122
|
+
const r = await dbRun(`UPDATE webhook_subscriptions SET active = ? WHERE id = ? AND user_id = ?`, [active, req.params.id, user.id]);
|
|
123
123
|
if (r.changes === 0)
|
|
124
124
|
return void res.json({ error: '订阅不存在' });
|
|
125
125
|
res.json({ ok: true, active });
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
3
|
const SUB_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3
4
|
const VALID_ROLE_PREFS = new Set(['buyer', 'seller', 'creator', 'verifier', 'arbitrator', 'other']);
|
|
4
5
|
export function registerWelcomeRoutes(app, deps) {
|
|
5
|
-
|
|
6
|
+
// db 已走 RFC-016 异步 seam(dbOne/dbAll/dbRun),不再直接用 deps.db
|
|
7
|
+
const { generateId, getUser, clientIpHash, clientUaHash, requireSupportAdmin } = deps;
|
|
6
8
|
// ─── admin 端 ─────────────────────────────────────────────
|
|
7
|
-
app.get('/api/admin/public-ideas', (req, res) => {
|
|
9
|
+
app.get('/api/admin/public-ideas', async (req, res) => {
|
|
8
10
|
const admin = requireSupportAdmin(req, res);
|
|
9
11
|
if (!admin)
|
|
10
12
|
return;
|
|
@@ -16,47 +18,45 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
16
18
|
args.push(status);
|
|
17
19
|
}
|
|
18
20
|
const whereClause = `WHERE ${where.join(' AND ')}`;
|
|
19
|
-
const rows =
|
|
21
|
+
const rows = await dbAll(`
|
|
20
22
|
SELECT id, user_id, contact, content, status, created_at
|
|
21
23
|
FROM public_ideas ${whereClause}
|
|
22
24
|
ORDER BY created_at DESC LIMIT 500
|
|
23
|
-
|
|
24
|
-
const counts =
|
|
25
|
+
`, args);
|
|
26
|
+
const counts = await dbOne(`SELECT
|
|
25
27
|
SUM(CASE WHEN status='new' THEN 1 ELSE 0 END) as st_new,
|
|
26
28
|
SUM(CASE WHEN status='triaged' THEN 1 ELSE 0 END) as st_triaged,
|
|
27
29
|
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as st_resolved,
|
|
28
30
|
SUM(CASE WHEN status='spam' THEN 1 ELSE 0 END) as st_spam,
|
|
29
31
|
COUNT(*) as total
|
|
30
|
-
FROM public_ideas WHERE content NOT LIKE 'WELCOME_EMAIL_SUBSCRIBE:%'`)
|
|
32
|
+
FROM public_ideas WHERE content NOT LIKE 'WELCOME_EMAIL_SUBSCRIBE:%'`);
|
|
31
33
|
// P1 审计:admin 读 PII 留痕
|
|
32
34
|
try {
|
|
33
|
-
|
|
34
|
-
VALUES (?, ?, 'read_public_ideas', 'public_ideas', NULL, ?)
|
|
35
|
-
.run(generateId('aud'), admin.id, JSON.stringify({ count: rows.length, status }));
|
|
35
|
+
await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
36
|
+
VALUES (?, ?, 'read_public_ideas', 'public_ideas', NULL, ?)`, [generateId('aud'), admin.id, JSON.stringify({ count: rows.length, status })]);
|
|
36
37
|
}
|
|
37
38
|
catch { }
|
|
38
39
|
res.json({ items: rows, counts });
|
|
39
40
|
});
|
|
40
|
-
app.patch('/api/admin/public-ideas/:id', (req, res) => {
|
|
41
|
+
app.patch('/api/admin/public-ideas/:id', async (req, res) => {
|
|
41
42
|
const admin = requireSupportAdmin(req, res);
|
|
42
43
|
if (!admin)
|
|
43
44
|
return;
|
|
44
45
|
const newStatus = String(req.body?.status || '');
|
|
45
46
|
if (!['new', 'triaged', 'resolved', 'spam'].includes(newStatus))
|
|
46
47
|
return void res.status(400).json({ error: 'status 取值非法' });
|
|
47
|
-
const r =
|
|
48
|
+
const r = await dbRun("UPDATE public_ideas SET status=? WHERE id=?", [newStatus, req.params.id]);
|
|
48
49
|
if (r.changes === 0)
|
|
49
50
|
return void res.status(404).json({ error: '记录不存在' });
|
|
50
51
|
try {
|
|
51
|
-
|
|
52
|
-
VALUES (?, ?, 'patch_public_idea', 'public_ideas', ?, ?)
|
|
53
|
-
.run(generateId('aud'), admin.id, req.params.id, JSON.stringify({ status: newStatus }));
|
|
52
|
+
await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
53
|
+
VALUES (?, ?, 'patch_public_idea', 'public_ideas', ?, ?)`, [generateId('aud'), admin.id, req.params.id, JSON.stringify({ status: newStatus })]);
|
|
54
54
|
}
|
|
55
55
|
catch { }
|
|
56
56
|
res.json({ ok: true, status: newStatus });
|
|
57
57
|
});
|
|
58
58
|
// 2026-05-25 admin 查邮箱订阅 — 独立端点,与建议分开
|
|
59
|
-
app.get('/api/admin/email-subscriptions', (req, res) => {
|
|
59
|
+
app.get('/api/admin/email-subscriptions', async (req, res) => {
|
|
60
60
|
const admin = requireSupportAdmin(req, res);
|
|
61
61
|
if (!admin)
|
|
62
62
|
return;
|
|
@@ -72,13 +72,13 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
72
72
|
args.push(statusFilter);
|
|
73
73
|
}
|
|
74
74
|
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
|
75
|
-
const rows =
|
|
75
|
+
const rows = await dbAll(`
|
|
76
76
|
SELECT id, email, source, role_preference, note, consent_at, unsubscribed_at, user_id, created_at,
|
|
77
77
|
COALESCE(handle_status,'pending') as handle_status, handled_at
|
|
78
78
|
FROM email_subscriptions ${where}
|
|
79
79
|
ORDER BY created_at DESC LIMIT 500
|
|
80
|
-
|
|
81
|
-
const counts =
|
|
80
|
+
`, args);
|
|
81
|
+
const counts = await dbOne(`SELECT
|
|
82
82
|
COUNT(*) as total,
|
|
83
83
|
SUM(CASE WHEN unsubscribed_at IS NULL THEN 1 ELSE 0 END) as active,
|
|
84
84
|
SUM(CASE WHEN unsubscribed_at IS NOT NULL THEN 1 ELSE 0 END) as unsubscribed,
|
|
@@ -86,17 +86,16 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
86
86
|
SUM(CASE WHEN handle_status = 'contacted' THEN 1 ELSE 0 END) as st_contacted,
|
|
87
87
|
SUM(CASE WHEN handle_status = 'invited' THEN 1 ELSE 0 END) as st_invited,
|
|
88
88
|
SUM(CASE WHEN handle_status = 'done' THEN 1 ELSE 0 END) as st_done
|
|
89
|
-
FROM email_subscriptions`)
|
|
89
|
+
FROM email_subscriptions`);
|
|
90
90
|
try {
|
|
91
|
-
|
|
92
|
-
VALUES (?, ?, 'read_email_subscriptions', 'email_subscriptions', NULL, ?)
|
|
93
|
-
.run(generateId('aud'), admin.id, JSON.stringify({ count: rows.length, include_unsubscribed: includeUnsub }));
|
|
91
|
+
await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
92
|
+
VALUES (?, ?, 'read_email_subscriptions', 'email_subscriptions', NULL, ?)`, [generateId('aud'), admin.id, JSON.stringify({ count: rows.length, include_unsubscribed: includeUnsub })]);
|
|
94
93
|
}
|
|
95
94
|
catch { }
|
|
96
95
|
res.json({ items: rows, counts });
|
|
97
96
|
});
|
|
98
97
|
// 2026-05-29: admin 标记申请处理状态(pending→contacted→invited→done)— 不动 POST 提交逻辑
|
|
99
|
-
app.patch('/api/admin/email-subscriptions/:id/status', (req, res) => {
|
|
98
|
+
app.patch('/api/admin/email-subscriptions/:id/status', async (req, res) => {
|
|
100
99
|
const admin = requireSupportAdmin(req, res);
|
|
101
100
|
if (!admin)
|
|
102
101
|
return;
|
|
@@ -104,15 +103,13 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
104
103
|
const status = String((req.body || {}).status || '');
|
|
105
104
|
if (!HANDLE_STATES.includes(status))
|
|
106
105
|
return void res.status(400).json({ error: 'status 必须是 pending/contacted/invited/done' });
|
|
107
|
-
const row =
|
|
106
|
+
const row = await dbOne('SELECT id, handle_status FROM email_subscriptions WHERE id = ?', [req.params.id]);
|
|
108
107
|
if (!row)
|
|
109
108
|
return void res.status(404).json({ error: '记录不存在' });
|
|
110
|
-
|
|
111
|
-
.run(status, admin.id, req.params.id);
|
|
109
|
+
await dbRun("UPDATE email_subscriptions SET handle_status = ?, handled_at = datetime('now'), handled_by = ? WHERE id = ?", [status, admin.id, req.params.id]);
|
|
112
110
|
try {
|
|
113
|
-
|
|
114
|
-
VALUES (?, ?, 'update_email_subscription_status', 'email_subscriptions', ?, ?)
|
|
115
|
-
.run(generateId('aud'), admin.id, req.params.id, JSON.stringify({ from: row.handle_status || 'pending', to: status }));
|
|
111
|
+
await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
112
|
+
VALUES (?, ?, 'update_email_subscription_status', 'email_subscriptions', ?, ?)`, [generateId('aud'), admin.id, req.params.id, JSON.stringify({ from: row.handle_status || 'pending', to: status })]);
|
|
116
113
|
}
|
|
117
114
|
catch { }
|
|
118
115
|
res.json({ success: true, status });
|
|
@@ -120,7 +117,7 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
120
117
|
// ─── 公开端 ───────────────────────────────────────────────
|
|
121
118
|
// 2026-05-24 首屏「我有建议」— 公开提交(无需登录)
|
|
122
119
|
// 反 bot:honeypot 字段 + 单 IP+UA 联合 rate limit 5/h + 内容 hash 去重 1h
|
|
123
|
-
app.post('/api/public-ideas', (req, res) => {
|
|
120
|
+
app.post('/api/public-ideas', async (req, res) => {
|
|
124
121
|
// 蜜罐字段 `_hp`:bot 倾向于填所有 input;真人不会看到(前端 display:none)
|
|
125
122
|
if (req.body?._hp)
|
|
126
123
|
return void res.status(400).json({ error: 'invalid' }); // 不告诉 bot 真原因
|
|
@@ -132,24 +129,23 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
132
129
|
const ipHash = clientIpHash(req);
|
|
133
130
|
const uaHash = clientUaHash(req);
|
|
134
131
|
// IP+UA 联合:5/h(之前是仅 IP,NAT 误伤)
|
|
135
|
-
const recent =
|
|
136
|
-
WHERE (ip_hash = ? OR ua_hash = ?) AND created_at > datetime('now', '-1 hour')
|
|
132
|
+
const recent = (await dbOne(`SELECT COUNT(*) as n FROM public_ideas
|
|
133
|
+
WHERE (ip_hash = ? OR ua_hash = ?) AND created_at > datetime('now', '-1 hour')`, [ipHash, uaHash])).n;
|
|
137
134
|
if (recent >= 5)
|
|
138
135
|
return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
|
|
139
136
|
// 内容去重:1h 内同 ip+content 不重复落库
|
|
140
|
-
const dup =
|
|
141
|
-
WHERE ip_hash = ? AND content = ? AND created_at > datetime('now', '-1 hour')
|
|
137
|
+
const dup = await dbOne(`SELECT 1 FROM public_ideas
|
|
138
|
+
WHERE ip_hash = ? AND content = ? AND created_at > datetime('now', '-1 hour')`, [ipHash, content]);
|
|
142
139
|
if (dup)
|
|
143
140
|
return void res.status(409).json({ error: '请勿重复提交相同内容' });
|
|
144
141
|
const userId = getUser(req)?.id || null;
|
|
145
142
|
const id = generateId('idea');
|
|
146
|
-
|
|
147
|
-
.run(id, userId, contact || null, content, ipHash, uaHash);
|
|
143
|
+
await dbRun(`INSERT INTO public_ideas (id, user_id, contact, content, ip_hash, ua_hash) VALUES (?,?,?,?,?,?)`, [id, userId, contact || null, content, ipHash, uaHash]);
|
|
148
144
|
res.json({ ok: true, id });
|
|
149
145
|
});
|
|
150
146
|
// 2026-05-25 邮箱订阅独立端点(替代旧 WELCOME_EMAIL_SUBSCRIBE: 前缀 hack)
|
|
151
147
|
// 2026-05-26 加 role_preference + note 字段(welcome 表单丰富化)
|
|
152
|
-
app.post('/api/email-subscriptions', (req, res) => {
|
|
148
|
+
app.post('/api/email-subscriptions', async (req, res) => {
|
|
153
149
|
if (req.body?._hp)
|
|
154
150
|
return void res.status(400).json({ error: 'invalid' }); // honeypot
|
|
155
151
|
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
@@ -165,13 +161,13 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
165
161
|
const note = noteRaw ? noteRaw.slice(0, 500) : null;
|
|
166
162
|
const ipHash = clientIpHash(req);
|
|
167
163
|
// rate limit: 单 IP 1h 最多 3 次(防爆破探测同人多邮箱)
|
|
168
|
-
const recent =
|
|
169
|
-
WHERE ip_hash = ? AND created_at > datetime('now', '-1 hour')
|
|
164
|
+
const recent = (await dbOne(`SELECT COUNT(*) as n FROM email_subscriptions
|
|
165
|
+
WHERE ip_hash = ? AND created_at > datetime('now', '-1 hour')`, [ipHash])).n;
|
|
170
166
|
if (recent >= 3)
|
|
171
167
|
return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
|
|
172
168
|
// 已存在(active)→ 幂等返回 ok(不暴露"该邮箱已订阅"避免邮箱枚举)
|
|
173
169
|
// 但若提供了新 role/note,更新这俩字段(用户可能回来补充)
|
|
174
|
-
const exist =
|
|
170
|
+
const exist = await dbOne(`SELECT id, unsubscribed_at FROM email_subscriptions WHERE email = ?`, [email]);
|
|
175
171
|
if (exist) {
|
|
176
172
|
const sets = [];
|
|
177
173
|
const args = [];
|
|
@@ -188,36 +184,35 @@ export function registerWelcomeRoutes(app, deps) {
|
|
|
188
184
|
}
|
|
189
185
|
if (sets.length > 0) {
|
|
190
186
|
args.push(exist.id);
|
|
191
|
-
|
|
187
|
+
await dbRun(`UPDATE email_subscriptions SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
192
188
|
}
|
|
193
189
|
return void res.json({ ok: true, id: exist.id, status: 'subscribed' });
|
|
194
190
|
}
|
|
195
191
|
const id = generateId('eml');
|
|
196
192
|
const token = createHash('sha256').update(id + email + Math.random()).digest('hex').slice(0, 32);
|
|
197
193
|
const userId = getUser(req)?.id || null;
|
|
198
|
-
|
|
199
|
-
.run(id, email, source, rolePref, note, token, ipHash, userId);
|
|
194
|
+
await dbRun(`INSERT INTO email_subscriptions (id, email, source, role_preference, note, unsubscribe_token, ip_hash, user_id) VALUES (?,?,?,?,?,?,?,?)`, [id, email, source, rolePref, note, token, ipHash, userId]);
|
|
200
195
|
res.json({ ok: true, id, status: 'subscribed', unsubscribe_url: `/unsubscribe?t=${token}` });
|
|
201
196
|
});
|
|
202
197
|
// 公开退订端点 — 接受 GET(邮件里的链接)+ POST(页面按钮)
|
|
203
|
-
function doUnsubscribe(token) {
|
|
198
|
+
async function doUnsubscribe(token) {
|
|
204
199
|
if (!token || token.length !== 32)
|
|
205
200
|
return { ok: false, error: 'invalid_token' };
|
|
206
|
-
const row =
|
|
201
|
+
const row = await dbOne(`SELECT id, email, unsubscribed_at FROM email_subscriptions WHERE unsubscribe_token = ?`, [token]);
|
|
207
202
|
if (!row)
|
|
208
203
|
return { ok: false, error: 'token_not_found' };
|
|
209
204
|
if (row.unsubscribed_at)
|
|
210
205
|
return { ok: true, email: row.email }; // 已退订也返 ok(幂等)
|
|
211
|
-
|
|
206
|
+
await dbRun(`UPDATE email_subscriptions SET unsubscribed_at = datetime('now') WHERE id = ?`, [row.id]);
|
|
212
207
|
return { ok: true, email: row.email };
|
|
213
208
|
}
|
|
214
|
-
app.post('/api/email-subscriptions/unsubscribe', (req, res) => {
|
|
215
|
-
const r = doUnsubscribe(String(req.body?.token || ''));
|
|
209
|
+
app.post('/api/email-subscriptions/unsubscribe', async (req, res) => {
|
|
210
|
+
const r = await doUnsubscribe(String(req.body?.token || ''));
|
|
216
211
|
res.status(r.ok ? 200 : 400).json(r);
|
|
217
212
|
});
|
|
218
213
|
// 浏览器友好的退订页(GET)
|
|
219
|
-
app.get('/unsubscribe', (req, res) => {
|
|
220
|
-
const r = doUnsubscribe(String(req.query?.t || ''));
|
|
214
|
+
app.get('/unsubscribe', async (req, res) => {
|
|
215
|
+
const r = await doUnsubscribe(String(req.query?.t || ''));
|
|
221
216
|
const okHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>已退订 — webaz</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#FAFAFA;color:#18181B}main{text-align:center;padding:32px}h1{font-size:22px;font-weight:600;margin:0 0 12px}p{font-size:14px;color:#71717A;margin:0 0 8px}a{color:#6366f1;text-decoration:none;font-size:13px}</style></head><body><main><h1>✓ 已退订 / Unsubscribed</h1><p>${r.email ? `<code>${r.email}</code>` : ''}</p><p>不会再收到 webaz 的邮件。<br>You won't receive emails from webaz anymore.</p><a href="/">← 返回首页 / Back</a></main></body></html>`;
|
|
222
217
|
const errHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>退订链接无效</title><style>body{font-family:-apple-system,sans-serif;text-align:center;padding:80px 20px;color:#18181B}p{color:#71717A}</style></head><body><h1>退订链接无效</h1><p>${r.error}</p><a href="/">← 返回首页</a></body></html>`;
|
|
223
218
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|