@seasonkoh/webaz 0.1.7 → 0.1.9
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/LICENSE +48 -0
- package/README.md +156 -20
- package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/server.js +3691 -714
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
- package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
- package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
- package/dist/pwa/public/app.js +31947 -0
- package/dist/pwa/public/i18n.js +5751 -0
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +21 -0
- package/dist/pwa/public/manifest.json +48 -0
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +535 -0
- package/dist/pwa/public/sw.js +63 -0
- package/dist/pwa/public/vendor/jsQR.js +10102 -0
- package/dist/pwa/public/webaz-logo.png +0 -0
- package/dist/pwa/routes/account-deletion.js +53 -0
- package/dist/pwa/routes/addresses.js +105 -0
- package/dist/pwa/routes/admin-admins.js +151 -0
- package/dist/pwa/routes/admin-analytics.js +253 -0
- package/dist/pwa/routes/admin-atomic.js +21 -0
- package/dist/pwa/routes/admin-catalog.js +64 -0
- package/dist/pwa/routes/admin-editor-picks.js +45 -0
- package/dist/pwa/routes/admin-events.js +60 -0
- package/dist/pwa/routes/admin-health.js +66 -0
- package/dist/pwa/routes/admin-moderation.js +120 -0
- package/dist/pwa/routes/admin-ops.js +179 -0
- package/dist/pwa/routes/admin-protocol-params.js +79 -0
- package/dist/pwa/routes/admin-reports.js +154 -0
- package/dist/pwa/routes/admin-tokenomics.js +113 -0
- package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
- package/dist/pwa/routes/admin-users-query.js +390 -0
- package/dist/pwa/routes/admin-verifier-flow.js +126 -0
- package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
- package/dist/pwa/routes/admin-wallet-ops.js +66 -0
- package/dist/pwa/routes/agent-buy.js +215 -0
- package/dist/pwa/routes/agent-governance.js +341 -0
- package/dist/pwa/routes/agent-reputation.js +34 -0
- package/dist/pwa/routes/ai.js +101 -0
- package/dist/pwa/routes/analytics.js +272 -0
- package/dist/pwa/routes/anchors.js +169 -0
- package/dist/pwa/routes/announcements.js +110 -0
- package/dist/pwa/routes/arbitrator.js +117 -0
- package/dist/pwa/routes/auction.js +436 -0
- package/dist/pwa/routes/auth-login.js +40 -0
- package/dist/pwa/routes/auth-read.js +66 -0
- package/dist/pwa/routes/auth-register.js +138 -0
- package/dist/pwa/routes/auth-sessions.js +62 -0
- package/dist/pwa/routes/blocklist.js +60 -0
- package/dist/pwa/routes/buyer-feeds.js +224 -0
- package/dist/pwa/routes/cart.js +155 -0
- package/dist/pwa/routes/charity.js +816 -0
- package/dist/pwa/routes/chat.js +318 -0
- package/dist/pwa/routes/checkin-tasks.js +122 -0
- package/dist/pwa/routes/checkout-helpers.js +85 -0
- package/dist/pwa/routes/claim-initiators.js +88 -0
- package/dist/pwa/routes/claim-verify.js +615 -0
- package/dist/pwa/routes/claim-voting.js +114 -0
- package/dist/pwa/routes/claim-withdrawals.js +20 -0
- package/dist/pwa/routes/coupons.js +165 -0
- package/dist/pwa/routes/dashboards.js +99 -0
- package/dist/pwa/routes/dispute-cases.js +267 -0
- package/dist/pwa/routes/disputes-read.js +358 -0
- package/dist/pwa/routes/disputes-write.js +475 -0
- package/dist/pwa/routes/evidence.js +86 -0
- package/dist/pwa/routes/external-anchors.js +107 -0
- package/dist/pwa/routes/feedback.js +270 -0
- package/dist/pwa/routes/flash-sales.js +130 -0
- package/dist/pwa/routes/follows.js +103 -0
- package/dist/pwa/routes/group-buys.js +208 -0
- package/dist/pwa/routes/growth.js +199 -0
- package/dist/pwa/routes/import-product.js +153 -0
- package/dist/pwa/routes/kyc.js +40 -0
- package/dist/pwa/routes/leaderboard.js +149 -0
- package/dist/pwa/routes/listings.js +281 -0
- package/dist/pwa/routes/logistics.js +35 -0
- package/dist/pwa/routes/manifests.js +126 -0
- package/dist/pwa/routes/me-data.js +101 -0
- package/dist/pwa/routes/notifications.js +48 -0
- package/dist/pwa/routes/offers.js +96 -0
- package/dist/pwa/routes/orders-action.js +285 -0
- package/dist/pwa/routes/orders-create.js +339 -0
- package/dist/pwa/routes/orders-read.js +180 -0
- package/dist/pwa/routes/p2p-products.js +178 -0
- package/dist/pwa/routes/payments-governance.js +311 -0
- package/dist/pwa/routes/peers.js +34 -0
- package/dist/pwa/routes/pin-receipts.js +39 -0
- package/dist/pwa/routes/products-aliases.js +119 -0
- package/dist/pwa/routes/products-claims.js +60 -0
- package/dist/pwa/routes/products-create.js +206 -0
- package/dist/pwa/routes/products-crud.js +73 -0
- package/dist/pwa/routes/products-links.js +129 -0
- package/dist/pwa/routes/products-list.js +424 -0
- package/dist/pwa/routes/products-meta.js +155 -0
- package/dist/pwa/routes/products-update.js +125 -0
- package/dist/pwa/routes/profile-credentials.js +105 -0
- package/dist/pwa/routes/profile-identity.js +174 -0
- package/dist/pwa/routes/profile-location.js +35 -0
- package/dist/pwa/routes/profile-placement.js +70 -0
- package/dist/pwa/routes/profile-prefs.js +93 -0
- package/dist/pwa/routes/promoter.js +208 -0
- package/dist/pwa/routes/public-utils.js +170 -0
- package/dist/pwa/routes/push.js +54 -0
- package/dist/pwa/routes/ratings.js +220 -0
- package/dist/pwa/routes/recover-key.js +100 -0
- package/dist/pwa/routes/referral.js +58 -0
- package/dist/pwa/routes/reputation.js +34 -0
- package/dist/pwa/routes/returns.js +493 -0
- package/dist/pwa/routes/reviews.js +81 -0
- package/dist/pwa/routes/rfqs.js +443 -0
- package/dist/pwa/routes/search.js +172 -0
- package/dist/pwa/routes/secondhand.js +278 -0
- package/dist/pwa/routes/seller-quota.js +225 -0
- package/dist/pwa/routes/share-redirects.js +164 -0
- package/dist/pwa/routes/shareables-interactions.js +212 -0
- package/dist/pwa/routes/shareables.js +470 -0
- package/dist/pwa/routes/shops.js +98 -0
- package/dist/pwa/routes/signaling.js +43 -0
- package/dist/pwa/routes/skill-market.js +173 -0
- package/dist/pwa/routes/skills.js +174 -0
- package/dist/pwa/routes/snf.js +126 -0
- package/dist/pwa/routes/tags.js +47 -0
- package/dist/pwa/routes/trial.js +333 -0
- package/dist/pwa/routes/trusted-kpi.js +87 -0
- package/dist/pwa/routes/url-claim.js +113 -0
- package/dist/pwa/routes/users-public.js +317 -0
- package/dist/pwa/routes/variants.js +156 -0
- package/dist/pwa/routes/verifier-user.js +107 -0
- package/dist/pwa/routes/verify-tasks.js +120 -0
- package/dist/pwa/routes/waitlist.js +65 -0
- package/dist/pwa/routes/wallet-read.js +218 -0
- package/dist/pwa/routes/wallet-write.js +273 -0
- package/dist/pwa/routes/webauthn.js +188 -0
- package/dist/pwa/routes/webhooks.js +162 -0
- package/dist/pwa/routes/welcome.js +226 -0
- package/dist/pwa/routes/wishlist-qa.js +135 -0
- package/dist/pwa/security/ssrf.js +110 -0
- package/dist/pwa/server.js +9679 -698
- package/package.json +11 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const SUB_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3
|
+
const VALID_ROLE_PREFS = new Set(['buyer', 'seller', 'creator', 'verifier', 'arbitrator', 'other']);
|
|
4
|
+
export function registerWelcomeRoutes(app, deps) {
|
|
5
|
+
const { db, generateId, getUser, clientIpHash, clientUaHash, requireSupportAdmin } = deps;
|
|
6
|
+
// ─── admin 端 ─────────────────────────────────────────────
|
|
7
|
+
app.get('/api/admin/public-ideas', (req, res) => {
|
|
8
|
+
const admin = requireSupportAdmin(req, res);
|
|
9
|
+
if (!admin)
|
|
10
|
+
return;
|
|
11
|
+
const status = req.query.status ? String(req.query.status) : null;
|
|
12
|
+
const where = ["content NOT LIKE 'WELCOME_EMAIL_SUBSCRIBE:%'"]; // 旧前缀数据兼容:排除被迁过的邮箱订阅
|
|
13
|
+
const args = [];
|
|
14
|
+
if (status) {
|
|
15
|
+
where.push('status = ?');
|
|
16
|
+
args.push(status);
|
|
17
|
+
}
|
|
18
|
+
const whereClause = `WHERE ${where.join(' AND ')}`;
|
|
19
|
+
const rows = db.prepare(`
|
|
20
|
+
SELECT id, user_id, contact, content, status, created_at
|
|
21
|
+
FROM public_ideas ${whereClause}
|
|
22
|
+
ORDER BY created_at DESC LIMIT 500
|
|
23
|
+
`).all(...args);
|
|
24
|
+
const counts = db.prepare(`SELECT
|
|
25
|
+
SUM(CASE WHEN status='new' THEN 1 ELSE 0 END) as st_new,
|
|
26
|
+
SUM(CASE WHEN status='triaged' THEN 1 ELSE 0 END) as st_triaged,
|
|
27
|
+
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as st_resolved,
|
|
28
|
+
SUM(CASE WHEN status='spam' THEN 1 ELSE 0 END) as st_spam,
|
|
29
|
+
COUNT(*) as total
|
|
30
|
+
FROM public_ideas WHERE content NOT LIKE 'WELCOME_EMAIL_SUBSCRIBE:%'`).get();
|
|
31
|
+
// P1 审计:admin 读 PII 留痕
|
|
32
|
+
try {
|
|
33
|
+
db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
34
|
+
VALUES (?, ?, 'read_public_ideas', 'public_ideas', NULL, ?)`)
|
|
35
|
+
.run(generateId('aud'), admin.id, JSON.stringify({ count: rows.length, status }));
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
res.json({ items: rows, counts });
|
|
39
|
+
});
|
|
40
|
+
app.patch('/api/admin/public-ideas/:id', (req, res) => {
|
|
41
|
+
const admin = requireSupportAdmin(req, res);
|
|
42
|
+
if (!admin)
|
|
43
|
+
return;
|
|
44
|
+
const newStatus = String(req.body?.status || '');
|
|
45
|
+
if (!['new', 'triaged', 'resolved', 'spam'].includes(newStatus))
|
|
46
|
+
return void res.status(400).json({ error: 'status 取值非法' });
|
|
47
|
+
const r = db.prepare("UPDATE public_ideas SET status=? WHERE id=?").run(newStatus, req.params.id);
|
|
48
|
+
if (r.changes === 0)
|
|
49
|
+
return void res.status(404).json({ error: '记录不存在' });
|
|
50
|
+
try {
|
|
51
|
+
db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
52
|
+
VALUES (?, ?, 'patch_public_idea', 'public_ideas', ?, ?)`)
|
|
53
|
+
.run(generateId('aud'), admin.id, req.params.id, JSON.stringify({ status: newStatus }));
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
res.json({ ok: true, status: newStatus });
|
|
57
|
+
});
|
|
58
|
+
// 2026-05-25 admin 查邮箱订阅 — 独立端点,与建议分开
|
|
59
|
+
app.get('/api/admin/email-subscriptions', (req, res) => {
|
|
60
|
+
const admin = requireSupportAdmin(req, res);
|
|
61
|
+
if (!admin)
|
|
62
|
+
return;
|
|
63
|
+
const includeUnsub = req.query.include_unsubscribed === '1';
|
|
64
|
+
const HANDLE_STATES = ['pending', 'contacted', 'invited', 'done'];
|
|
65
|
+
const statusFilter = HANDLE_STATES.includes(String(req.query.handle_status || '')) ? String(req.query.handle_status) : '';
|
|
66
|
+
const conds = [];
|
|
67
|
+
const args = [];
|
|
68
|
+
if (!includeUnsub)
|
|
69
|
+
conds.push('unsubscribed_at IS NULL');
|
|
70
|
+
if (statusFilter) {
|
|
71
|
+
conds.push("COALESCE(handle_status,'pending') = ?");
|
|
72
|
+
args.push(statusFilter);
|
|
73
|
+
}
|
|
74
|
+
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
|
75
|
+
const rows = db.prepare(`
|
|
76
|
+
SELECT id, email, source, role_preference, note, consent_at, unsubscribed_at, user_id, created_at,
|
|
77
|
+
COALESCE(handle_status,'pending') as handle_status, handled_at
|
|
78
|
+
FROM email_subscriptions ${where}
|
|
79
|
+
ORDER BY created_at DESC LIMIT 500
|
|
80
|
+
`).all(...args);
|
|
81
|
+
const counts = db.prepare(`SELECT
|
|
82
|
+
COUNT(*) as total,
|
|
83
|
+
SUM(CASE WHEN unsubscribed_at IS NULL THEN 1 ELSE 0 END) as active,
|
|
84
|
+
SUM(CASE WHEN unsubscribed_at IS NOT NULL THEN 1 ELSE 0 END) as unsubscribed,
|
|
85
|
+
SUM(CASE WHEN COALESCE(handle_status,'pending') = 'pending' THEN 1 ELSE 0 END) as st_pending,
|
|
86
|
+
SUM(CASE WHEN handle_status = 'contacted' THEN 1 ELSE 0 END) as st_contacted,
|
|
87
|
+
SUM(CASE WHEN handle_status = 'invited' THEN 1 ELSE 0 END) as st_invited,
|
|
88
|
+
SUM(CASE WHEN handle_status = 'done' THEN 1 ELSE 0 END) as st_done
|
|
89
|
+
FROM email_subscriptions`).get();
|
|
90
|
+
try {
|
|
91
|
+
db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
92
|
+
VALUES (?, ?, 'read_email_subscriptions', 'email_subscriptions', NULL, ?)`)
|
|
93
|
+
.run(generateId('aud'), admin.id, JSON.stringify({ count: rows.length, include_unsubscribed: includeUnsub }));
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
res.json({ items: rows, counts });
|
|
97
|
+
});
|
|
98
|
+
// 2026-05-29: admin 标记申请处理状态(pending→contacted→invited→done)— 不动 POST 提交逻辑
|
|
99
|
+
app.patch('/api/admin/email-subscriptions/:id/status', (req, res) => {
|
|
100
|
+
const admin = requireSupportAdmin(req, res);
|
|
101
|
+
if (!admin)
|
|
102
|
+
return;
|
|
103
|
+
const HANDLE_STATES = ['pending', 'contacted', 'invited', 'done'];
|
|
104
|
+
const status = String((req.body || {}).status || '');
|
|
105
|
+
if (!HANDLE_STATES.includes(status))
|
|
106
|
+
return void res.status(400).json({ error: 'status 必须是 pending/contacted/invited/done' });
|
|
107
|
+
const row = db.prepare('SELECT id, handle_status FROM email_subscriptions WHERE id = ?').get(req.params.id);
|
|
108
|
+
if (!row)
|
|
109
|
+
return void res.status(404).json({ error: '记录不存在' });
|
|
110
|
+
db.prepare("UPDATE email_subscriptions SET handle_status = ?, handled_at = datetime('now'), handled_by = ? WHERE id = ?")
|
|
111
|
+
.run(status, admin.id, req.params.id);
|
|
112
|
+
try {
|
|
113
|
+
db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
|
|
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 }));
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
res.json({ success: true, status });
|
|
119
|
+
});
|
|
120
|
+
// ─── 公开端 ───────────────────────────────────────────────
|
|
121
|
+
// 2026-05-24 首屏「我有建议」— 公开提交(无需登录)
|
|
122
|
+
// 反 bot:honeypot 字段 + 单 IP+UA 联合 rate limit 5/h + 内容 hash 去重 1h
|
|
123
|
+
app.post('/api/public-ideas', (req, res) => {
|
|
124
|
+
// 蜜罐字段 `_hp`:bot 倾向于填所有 input;真人不会看到(前端 display:none)
|
|
125
|
+
if (req.body?._hp)
|
|
126
|
+
return void res.status(400).json({ error: 'invalid' }); // 不告诉 bot 真原因
|
|
127
|
+
const content = String(req.body?.content || '').trim();
|
|
128
|
+
const contact = String(req.body?.contact || '').trim().slice(0, 200);
|
|
129
|
+
if (content.length < 10 || content.length > 2000) {
|
|
130
|
+
return void res.status(400).json({ error: '内容 10-2000 字' });
|
|
131
|
+
}
|
|
132
|
+
const ipHash = clientIpHash(req);
|
|
133
|
+
const uaHash = clientUaHash(req);
|
|
134
|
+
// IP+UA 联合:5/h(之前是仅 IP,NAT 误伤)
|
|
135
|
+
const recent = db.prepare(`SELECT COUNT(*) as n FROM public_ideas
|
|
136
|
+
WHERE (ip_hash = ? OR ua_hash = ?) AND created_at > datetime('now', '-1 hour')`).get(ipHash, uaHash).n;
|
|
137
|
+
if (recent >= 5)
|
|
138
|
+
return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
|
|
139
|
+
// 内容去重:1h 内同 ip+content 不重复落库
|
|
140
|
+
const dup = db.prepare(`SELECT 1 FROM public_ideas
|
|
141
|
+
WHERE ip_hash = ? AND content = ? AND created_at > datetime('now', '-1 hour')`).get(ipHash, content);
|
|
142
|
+
if (dup)
|
|
143
|
+
return void res.status(409).json({ error: '请勿重复提交相同内容' });
|
|
144
|
+
const userId = getUser(req)?.id || null;
|
|
145
|
+
const id = generateId('idea');
|
|
146
|
+
db.prepare(`INSERT INTO public_ideas (id, user_id, contact, content, ip_hash, ua_hash) VALUES (?,?,?,?,?,?)`)
|
|
147
|
+
.run(id, userId, contact || null, content, ipHash, uaHash);
|
|
148
|
+
res.json({ ok: true, id });
|
|
149
|
+
});
|
|
150
|
+
// 2026-05-25 邮箱订阅独立端点(替代旧 WELCOME_EMAIL_SUBSCRIBE: 前缀 hack)
|
|
151
|
+
// 2026-05-26 加 role_preference + note 字段(welcome 表单丰富化)
|
|
152
|
+
app.post('/api/email-subscriptions', (req, res) => {
|
|
153
|
+
if (req.body?._hp)
|
|
154
|
+
return void res.status(400).json({ error: 'invalid' }); // honeypot
|
|
155
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
156
|
+
const source = String(req.body?.source || 'welcome').slice(0, 30);
|
|
157
|
+
if (!email || !SUB_EMAIL_RE.test(email) || email.length > 200) {
|
|
158
|
+
return void res.status(400).json({ error: '邮箱格式无效' });
|
|
159
|
+
}
|
|
160
|
+
// role_preference 可选;非法值视为 null(不报错,前端 select 也限了枚举)
|
|
161
|
+
const rolePrefRaw = String(req.body?.role_preference || '').trim().toLowerCase();
|
|
162
|
+
const rolePref = VALID_ROLE_PREFS.has(rolePrefRaw) ? rolePrefRaw : null;
|
|
163
|
+
// note 可选;trim + 500 截断
|
|
164
|
+
const noteRaw = String(req.body?.note || '').trim();
|
|
165
|
+
const note = noteRaw ? noteRaw.slice(0, 500) : null;
|
|
166
|
+
const ipHash = clientIpHash(req);
|
|
167
|
+
// rate limit: 单 IP 1h 最多 3 次(防爆破探测同人多邮箱)
|
|
168
|
+
const recent = db.prepare(`SELECT COUNT(*) as n FROM email_subscriptions
|
|
169
|
+
WHERE ip_hash = ? AND created_at > datetime('now', '-1 hour')`).get(ipHash).n;
|
|
170
|
+
if (recent >= 3)
|
|
171
|
+
return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
|
|
172
|
+
// 已存在(active)→ 幂等返回 ok(不暴露"该邮箱已订阅"避免邮箱枚举)
|
|
173
|
+
// 但若提供了新 role/note,更新这俩字段(用户可能回来补充)
|
|
174
|
+
const exist = db.prepare(`SELECT id, unsubscribed_at FROM email_subscriptions WHERE email = ?`).get(email);
|
|
175
|
+
if (exist) {
|
|
176
|
+
const sets = [];
|
|
177
|
+
const args = [];
|
|
178
|
+
if (exist.unsubscribed_at) {
|
|
179
|
+
sets.push("unsubscribed_at = NULL", "consent_at = datetime('now')");
|
|
180
|
+
}
|
|
181
|
+
if (rolePref) {
|
|
182
|
+
sets.push("role_preference = ?");
|
|
183
|
+
args.push(rolePref);
|
|
184
|
+
}
|
|
185
|
+
if (note) {
|
|
186
|
+
sets.push("note = ?");
|
|
187
|
+
args.push(note);
|
|
188
|
+
}
|
|
189
|
+
if (sets.length > 0) {
|
|
190
|
+
args.push(exist.id);
|
|
191
|
+
db.prepare(`UPDATE email_subscriptions SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
192
|
+
}
|
|
193
|
+
return void res.json({ ok: true, id: exist.id, status: 'subscribed' });
|
|
194
|
+
}
|
|
195
|
+
const id = generateId('eml');
|
|
196
|
+
const token = createHash('sha256').update(id + email + Math.random()).digest('hex').slice(0, 32);
|
|
197
|
+
const userId = getUser(req)?.id || null;
|
|
198
|
+
db.prepare(`INSERT INTO email_subscriptions (id, email, source, role_preference, note, unsubscribe_token, ip_hash, user_id) VALUES (?,?,?,?,?,?,?,?)`)
|
|
199
|
+
.run(id, email, source, rolePref, note, token, ipHash, userId);
|
|
200
|
+
res.json({ ok: true, id, status: 'subscribed', unsubscribe_url: `/unsubscribe?t=${token}` });
|
|
201
|
+
});
|
|
202
|
+
// 公开退订端点 — 接受 GET(邮件里的链接)+ POST(页面按钮)
|
|
203
|
+
function doUnsubscribe(token) {
|
|
204
|
+
if (!token || token.length !== 32)
|
|
205
|
+
return { ok: false, error: 'invalid_token' };
|
|
206
|
+
const row = db.prepare(`SELECT id, email, unsubscribed_at FROM email_subscriptions WHERE unsubscribe_token = ?`).get(token);
|
|
207
|
+
if (!row)
|
|
208
|
+
return { ok: false, error: 'token_not_found' };
|
|
209
|
+
if (row.unsubscribed_at)
|
|
210
|
+
return { ok: true, email: row.email }; // 已退订也返 ok(幂等)
|
|
211
|
+
db.prepare(`UPDATE email_subscriptions SET unsubscribed_at = datetime('now') WHERE id = ?`).run(row.id);
|
|
212
|
+
return { ok: true, email: row.email };
|
|
213
|
+
}
|
|
214
|
+
app.post('/api/email-subscriptions/unsubscribe', (req, res) => {
|
|
215
|
+
const r = doUnsubscribe(String(req.body?.token || ''));
|
|
216
|
+
res.status(r.ok ? 200 : 400).json(r);
|
|
217
|
+
});
|
|
218
|
+
// 浏览器友好的退订页(GET)
|
|
219
|
+
app.get('/unsubscribe', (req, res) => {
|
|
220
|
+
const r = doUnsubscribe(String(req.query?.t || ''));
|
|
221
|
+
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
|
+
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
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
224
|
+
res.send(r.ok ? okHtml : errHtml);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export function registerWishlistQaRoutes(app, deps) {
|
|
2
|
+
const { db, generateId, auth, isTrustedRole, errorRes } = deps;
|
|
3
|
+
// ─── Wave A-1: 心愿单 ────────────────────────────────────
|
|
4
|
+
app.post('/api/wishlist/:product_id', (req, res) => {
|
|
5
|
+
const user = auth(req, res);
|
|
6
|
+
if (!user)
|
|
7
|
+
return;
|
|
8
|
+
if (isTrustedRole(user))
|
|
9
|
+
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
|
|
10
|
+
const p = db.prepare('SELECT id, price, status, seller_id FROM products WHERE id = ?').get(req.params.product_id);
|
|
11
|
+
if (!p)
|
|
12
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
13
|
+
if (p.seller_id === user.id)
|
|
14
|
+
return void res.status(400).json({ error: '不可收藏自己的商品' });
|
|
15
|
+
const note = req.body?.note ? String(req.body.note).slice(0, 200) : null;
|
|
16
|
+
db.prepare(`INSERT OR REPLACE INTO user_wishlist (user_id, product_id, note, price_at_add) VALUES (?,?,?,?)`)
|
|
17
|
+
.run(user.id, req.params.product_id, note, p.price);
|
|
18
|
+
res.json({ success: true });
|
|
19
|
+
});
|
|
20
|
+
app.delete('/api/wishlist/:product_id', (req, res) => {
|
|
21
|
+
const user = auth(req, res);
|
|
22
|
+
if (!user)
|
|
23
|
+
return;
|
|
24
|
+
db.prepare('DELETE FROM user_wishlist WHERE user_id = ? AND product_id = ?').run(user.id, req.params.product_id);
|
|
25
|
+
res.json({ success: true });
|
|
26
|
+
});
|
|
27
|
+
app.get('/api/wishlist', (req, res) => {
|
|
28
|
+
const user = auth(req, res);
|
|
29
|
+
if (!user)
|
|
30
|
+
return;
|
|
31
|
+
// 过滤已删除商品(已下架但 status=warehouse 仍显示让买家可见)
|
|
32
|
+
const rows = db.prepare(`
|
|
33
|
+
SELECT w.product_id, w.note, w.price_at_add, w.notify_price_drop, w.notify_back_in_stock, w.created_at,
|
|
34
|
+
p.title, p.price as current_price, p.stock, p.status as product_status,
|
|
35
|
+
p.category, p.claim_loss_count,
|
|
36
|
+
u.name as seller_name, u.handle as seller_handle
|
|
37
|
+
FROM user_wishlist w
|
|
38
|
+
JOIN products p ON p.id = w.product_id
|
|
39
|
+
JOIN users u ON u.id = p.seller_id
|
|
40
|
+
WHERE w.user_id = ? AND p.status != 'deleted'
|
|
41
|
+
ORDER BY w.created_at DESC LIMIT 200
|
|
42
|
+
`).all(user.id);
|
|
43
|
+
for (const r of rows) {
|
|
44
|
+
const cur = Number(r.current_price);
|
|
45
|
+
const old = Number(r.price_at_add || cur);
|
|
46
|
+
r.price_delta = Math.round((cur - old) * 100) / 100;
|
|
47
|
+
r.price_delta_pct = old > 0 ? Math.round((cur - old) / old * 1000) / 10 : 0;
|
|
48
|
+
}
|
|
49
|
+
res.json({ items: rows });
|
|
50
|
+
});
|
|
51
|
+
app.get('/api/wishlist/:product_id/check', (req, res) => {
|
|
52
|
+
const user = auth(req, res);
|
|
53
|
+
if (!user)
|
|
54
|
+
return;
|
|
55
|
+
const exists = db.prepare('SELECT 1 FROM user_wishlist WHERE user_id = ? AND product_id = ?').get(user.id, req.params.product_id);
|
|
56
|
+
res.json({ in_wishlist: !!exists });
|
|
57
|
+
});
|
|
58
|
+
// ─── Wave A-2: 商品 Q&A ─────────────────────────────────
|
|
59
|
+
app.post('/api/products/:product_id/qa', (req, res) => {
|
|
60
|
+
const user = auth(req, res);
|
|
61
|
+
if (!user)
|
|
62
|
+
return;
|
|
63
|
+
if (isTrustedRole(user))
|
|
64
|
+
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_QA', '受信角色不参与商品 Q&A');
|
|
65
|
+
const p = db.prepare('SELECT id, seller_id, status FROM products WHERE id = ?').get(req.params.product_id);
|
|
66
|
+
if (!p)
|
|
67
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
68
|
+
if (p.seller_id === user.id)
|
|
69
|
+
return void res.status(400).json({ error: '卖家不可对自己商品提问(请用 description 直接说明)' });
|
|
70
|
+
if (p.status !== 'active')
|
|
71
|
+
return void res.status(400).json({ error: '仅在售商品可提问' });
|
|
72
|
+
const question = String(req.body?.question || '').trim();
|
|
73
|
+
if (question.length < 6 || question.length > 500)
|
|
74
|
+
return void res.status(400).json({ error: 'question 长度需 6-500 字' });
|
|
75
|
+
const id = generateId('qa');
|
|
76
|
+
db.prepare(`INSERT INTO product_qa (id, product_id, asker_id, seller_id, question) VALUES (?,?,?,?,?)`)
|
|
77
|
+
.run(id, req.params.product_id, user.id, p.seller_id, question);
|
|
78
|
+
try {
|
|
79
|
+
db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
|
|
80
|
+
.run(generateId('ntf'), p.seller_id, '收到商品提问', question.slice(0, 80) + (question.length > 80 ? '...' : ''), null);
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
res.json({ success: true, id });
|
|
84
|
+
});
|
|
85
|
+
app.post('/api/products/:product_id/qa/:qa_id/answer', (req, res) => {
|
|
86
|
+
const user = auth(req, res);
|
|
87
|
+
if (!user)
|
|
88
|
+
return;
|
|
89
|
+
const qa = db.prepare('SELECT id, seller_id, answer FROM product_qa WHERE id = ? AND product_id = ?').get(req.params.qa_id, req.params.product_id);
|
|
90
|
+
if (!qa)
|
|
91
|
+
return void res.status(404).json({ error: '提问不存在' });
|
|
92
|
+
if (qa.seller_id !== user.id)
|
|
93
|
+
return void res.status(403).json({ error: '仅卖家可回答' });
|
|
94
|
+
if (qa.answer)
|
|
95
|
+
return void res.status(409).json({ error: '已回答过 — 编辑请联系 admin' });
|
|
96
|
+
const answer = String(req.body?.answer || '').trim();
|
|
97
|
+
if (answer.length < 2 || answer.length > 1000)
|
|
98
|
+
return void res.status(400).json({ error: 'answer 长度需 2-1000 字' });
|
|
99
|
+
db.prepare(`UPDATE product_qa SET answer = ?, answered_at = datetime('now') WHERE id = ?`).run(answer, req.params.qa_id);
|
|
100
|
+
try {
|
|
101
|
+
const asker = db.prepare('SELECT asker_id, question FROM product_qa WHERE id = ?').get(req.params.qa_id);
|
|
102
|
+
db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
|
|
103
|
+
.run(generateId('ntf'), asker.asker_id, '卖家回答了你的提问', answer.slice(0, 80) + (answer.length > 80 ? '...' : ''), null);
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
res.json({ success: true });
|
|
107
|
+
});
|
|
108
|
+
app.get('/api/products/:product_id/qa', (req, res) => {
|
|
109
|
+
// 公开列表(不需要登录,但只返回 is_public=1)
|
|
110
|
+
const rows = db.prepare(`
|
|
111
|
+
SELECT qa.id, qa.question, qa.answer, qa.answered_at, qa.helpful_count, qa.created_at,
|
|
112
|
+
ua.name as asker_name, ua.handle as asker_handle
|
|
113
|
+
FROM product_qa qa JOIN users ua ON ua.id = qa.asker_id
|
|
114
|
+
WHERE qa.product_id = ? AND qa.is_public = 1
|
|
115
|
+
ORDER BY qa.answered_at IS NULL ASC, qa.helpful_count DESC, qa.created_at DESC LIMIT 50
|
|
116
|
+
`).all(req.params.product_id);
|
|
117
|
+
res.json({ items: rows });
|
|
118
|
+
});
|
|
119
|
+
app.post('/api/products/:product_id/qa/:qa_id/helpful', (req, res) => {
|
|
120
|
+
const user = auth(req, res);
|
|
121
|
+
if (!user)
|
|
122
|
+
return;
|
|
123
|
+
// 防重复 — 每用户每条 QA 仅可 +1
|
|
124
|
+
try {
|
|
125
|
+
db.prepare(`INSERT INTO product_qa_helpful_voters (qa_id, user_id) VALUES (?, ?)`).run(req.params.qa_id, user.id);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
const qa = db.prepare(`SELECT helpful_count FROM product_qa WHERE id = ?`).get(req.params.qa_id);
|
|
129
|
+
return void res.json({ success: false, already_voted: true, helpful_count: qa?.helpful_count || 0 });
|
|
130
|
+
}
|
|
131
|
+
db.prepare(`UPDATE product_qa SET helpful_count = COALESCE(helpful_count, 0) + 1 WHERE id = ? AND product_id = ?`)
|
|
132
|
+
.run(req.params.qa_id, req.params.product_id);
|
|
133
|
+
res.json({ success: true });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SSRF 防护:hostname 黑名单 + safeFetch redirect 守门 + undici Agent socket 层 IP 校验
|
|
2
|
+
// 三层防御:
|
|
3
|
+
// ① isPrivateOrInternalHost — hostname 文本检查(快路径,挡显式 IP / localhost / .local)
|
|
4
|
+
// ② safeFetch — redirect: 'manual' + 每跳重验,挡 302→私网 跳板
|
|
5
|
+
// ③ ssrfAgent (undici) — DNS 解析后的 IP 校验,挡 DNS rebinding(attacker.com TTL=0 解析到 169.254)
|
|
6
|
+
import * as dns from 'dns';
|
|
7
|
+
import { Agent } from 'undici';
|
|
8
|
+
// IP 黑名单 — 解析后的 IP 直接判
|
|
9
|
+
export function isIpPrivate(ip) {
|
|
10
|
+
const h = ip.toLowerCase();
|
|
11
|
+
// IPv4 私网 / 保留段
|
|
12
|
+
const m4 = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
13
|
+
if (m4) {
|
|
14
|
+
const [a, b] = m4.slice(1).map(Number);
|
|
15
|
+
if (a === 127)
|
|
16
|
+
return true; // loopback
|
|
17
|
+
if (a === 10)
|
|
18
|
+
return true; // 10/8
|
|
19
|
+
if (a === 192 && b === 168)
|
|
20
|
+
return true; // 192.168/16
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
22
|
+
return true; // 172.16-31/12
|
|
23
|
+
if (a === 169 && b === 254)
|
|
24
|
+
return true; // link-local (AWS / GCP metadata)
|
|
25
|
+
if (a === 0)
|
|
26
|
+
return true; // 0.0.0.0/8
|
|
27
|
+
}
|
|
28
|
+
// IPv6 简单黑名单
|
|
29
|
+
if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80'))
|
|
30
|
+
return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// hostname 文本检查(不做 DNS 解析)
|
|
34
|
+
export function isPrivateOrInternalHost(url) {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(url);
|
|
37
|
+
let h = u.hostname.toLowerCase();
|
|
38
|
+
if (h.startsWith('[') && h.endsWith(']'))
|
|
39
|
+
h = h.slice(1, -1);
|
|
40
|
+
if (!h)
|
|
41
|
+
return true;
|
|
42
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local'))
|
|
43
|
+
return true;
|
|
44
|
+
return isIpPrivate(h);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function ssrfLookup(hostname, options, callback) {
|
|
51
|
+
const opts = typeof options === 'function' ? {} : (options || {});
|
|
52
|
+
const cb = typeof options === 'function' ? options : callback;
|
|
53
|
+
dns.lookup(hostname, opts, (err, address, family) => {
|
|
54
|
+
if (err)
|
|
55
|
+
return cb(err);
|
|
56
|
+
if (Array.isArray(address)) {
|
|
57
|
+
// options.all === true → 多 IP 数组
|
|
58
|
+
const safe = address.filter(a => !isIpPrivate(a.address));
|
|
59
|
+
if (safe.length === 0) {
|
|
60
|
+
const e = new Error('ssrf_resolved_to_private_ip');
|
|
61
|
+
e.code = 'ENOTFOUND';
|
|
62
|
+
return cb(e);
|
|
63
|
+
}
|
|
64
|
+
return cb(null, safe, family);
|
|
65
|
+
}
|
|
66
|
+
if (isIpPrivate(String(address))) {
|
|
67
|
+
const e = new Error('ssrf_resolved_to_private_ip');
|
|
68
|
+
e.code = 'ENOTFOUND';
|
|
69
|
+
return cb(e);
|
|
70
|
+
}
|
|
71
|
+
cb(null, address, family);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// 单例 Agent — 注入 ssrfLookup 拦截 DNS 解析层
|
|
75
|
+
export const ssrfAgent = new Agent({
|
|
76
|
+
connect: {
|
|
77
|
+
lookup: ssrfLookup,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
// SSRF-safe redirect-following fetch.
|
|
81
|
+
// 三层防御组合:safeFetch (hostname check + per-hop) + ssrfAgent (DNS rebinding 拦截)
|
|
82
|
+
// 抛出 ssrf_blocked / ssrf_bad_scheme / ssrf_bad_redirect / ssrf_too_many_redirects /
|
|
83
|
+
// ssrf_resolved_to_private_ip (DNS 层) 中之一时表示拦截
|
|
84
|
+
export async function safeFetch(initialUrl, init = {}, maxHops = 5) {
|
|
85
|
+
let url = initialUrl;
|
|
86
|
+
for (let hop = 0; hop <= maxHops; hop++) {
|
|
87
|
+
if (!/^https?:\/\//i.test(url))
|
|
88
|
+
throw new Error('ssrf_bad_scheme');
|
|
89
|
+
if (isPrivateOrInternalHost(url))
|
|
90
|
+
throw new Error('ssrf_blocked');
|
|
91
|
+
// dispatcher 选项是 undici 扩展,标准 RequestInit 不含
|
|
92
|
+
const resp = await fetch(url, { ...init, redirect: 'manual', dispatcher: ssrfAgent });
|
|
93
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
94
|
+
const loc = resp.headers.get('location');
|
|
95
|
+
if (!loc)
|
|
96
|
+
return resp;
|
|
97
|
+
let next;
|
|
98
|
+
try {
|
|
99
|
+
next = new URL(loc, url).href;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error('ssrf_bad_redirect');
|
|
103
|
+
}
|
|
104
|
+
url = next;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return resp;
|
|
108
|
+
}
|
|
109
|
+
throw new Error('ssrf_too_many_redirects');
|
|
110
|
+
}
|