'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const net = require('net'); const random = () => crypto.randomBytes(32).toString('hex'); const hash = value => crypto.createHash('sha256').update(value).digest('hex'); // One Node process owns this embedded store. No credentials or identity are accepted // from a report request; the owner always comes from a server-created session. module.exports = function createAccounts(root, env = process.env) { fs.mkdirSync(root, { recursive: true, mode: 0o700 }); const dbFile = path.join(root, 'identities.json'); const snapshots = path.join(root, 'reports'); fs.mkdirSync(snapshots, { recursive: true, mode: 0o700 }); let db; try { db = JSON.parse(fs.readFileSync(dbFile, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw e; db = { users: [], sessions: {}, states: {} }; } function atomic(file, value) { const tmp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); } function save() { atomic(dbFile, db); } let base = null; try { base = new URL(env.PUBLIC_BASE_URL); } catch (_) { /* local mode */ } const prefix = base ? base.pathname.replace(/\/?$/, '/') : '/'; if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; } const secure = !!(base && base.protocol === 'https:'); const guestSessions = new Map(); // No guest identities/sessions are written to disk. const appId = env.WECHAT_APP_ID || ''; const secret = env.WECHAT_APP_SECRET || ''; const available = !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname)); function cookies(req) { return Object.fromEntries((req.headers.cookie || '').split(';').map(s => s.trim().split('='))); } function cookie(res, name, value, age) { const line = `${name}=${value}; Path=${prefix}; HttpOnly; SameSite=Lax${age == null ? '' : '; Max-Age=' + age}${secure ? '; Secure' : ''}`; const old = res.getHeader('Set-Cookie') || []; res.setHeader('Set-Cookie', [].concat(old, line)); } function session(req) { const token = cookies(req).juzi_session; const guest = token && guestSessions.get(hash(token)); if (guest && guest.expires > Date.now()) return guest.user; const row = token && db.sessions[hash(token)]; return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'wechat') : null; } function login(res, user) { for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key]; const token = random(); db.sessions[hash(token)] = { user: user.id, expires: Date.now() + 30 * 86400000 }; cookie(res, 'juzi_session', token, 30 * 86400); save(); } function publicUser(u) { return u ? { id: u.id, kind: u.kind } : null; } function json(res, status, obj) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)); } async function body(req, limit = 4096) { let size = 0; const parts = []; for await (const part of req) { size += part.length; if (size > limit) throw new Error('请求过大'); parts.push(part); } return JSON.parse(Buffer.concat(parts).toString() || '{}'); } function sameOrigin(req) { if (!req.headers.origin) return true; try { const origin = new URL(req.headers.origin); return origin.origin === (base ? base.origin : `http://${req.headers.host}`); } catch (_) { return false; } } const limits = new Map(); function rate(req) { let key = req.socket.remoteAddress; // Opt in only behind a loopback proxy that OVERWRITES X-Real-IP. const forwarded = req.headers['x-real-ip']; if (env.TRUST_LOOPBACK_PROXY === '1' && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(key) && typeof forwarded === 'string' && net.isIP(forwarded)) key = forwarded; const now = Date.now(); for (const [k, v] of limits) if (now - v.time > 60000) limits.delete(k); const value = limits.get(key) || { time: now, count: 0 }; limits.set(key, value); return ++value.count <= 60; } function redirect(res, suffix) { res.writeHead(302, { Location: prefix + suffix, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.end(); } // Operational events only: never log code/state, cookies, OpenID, secrets or tokens. function wechatEvent(event, fields = {}) { console.log('[auth/wechat] ' + event + ' ' + JSON.stringify(fields)); } async function route(req, res, pathname) { if (!pathname.startsWith('/api/auth/') && !pathname.startsWith('/api/reports')) return false; try { if (req.method !== 'GET' && !sameOrigin(req)) { json(res, 403, { ok: false, message: '请求来源不匹配' }); return true; } if (pathname === '/api/auth/config' && req.method === 'GET') { json(res, 200, { ok: true, wechat: available, fallback: 'browser', reason: available ? null : '当前环境尚未开通微信登录,可使用游客登录' }); } else if (pathname === '/api/auth/session' && req.method === 'GET') { json(res, 200, { ok: true, user: publicUser(session(req)) }); } else if (pathname === '/api/auth/browser' && req.method === 'POST') { if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后再试' }); return true; } const data = await body(req); if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; } // Old persistent guest tokens cannot restore identity or history. if (data.token) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; } for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key); const previous = cookies(req).juzi_session; if (previous) guestSessions.delete(hash(previous)); const token = random(), user = { id: crypto.randomUUID(), kind: 'browser' }; guestSessions.set(hash(token), { user, expires: Date.now() + 8 * 3600000 }); cookie(res, 'juzi_session', token, null); json(res, 200, { ok: true, user: publicUser(user) }); } else if (pathname === '/api/auth/logout' && req.method === 'POST') { const token = cookies(req).juzi_session; if (token) { guestSessions.delete(hash(token)); if (db.sessions[hash(token)]) { delete db.sessions[hash(token)]; save(); } } cookie(res, 'juzi_session', '', 0); json(res, 200, { ok: true }); } else if (pathname === '/api/auth/wechat' && req.method === 'POST') { const data = await body(req); if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; } if (!available) { json(res, 503, { ok: false, message: '微信登录尚未配置,请使用游客登录' }); return true; } if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后重试' }); return true; } const state = random(), binding = random(); for (const k of Object.keys(db.states)) if (db.states[k].expires < Date.now()) delete db.states[k]; db.states[hash(state)] = { binding: hash(binding), expires: Date.now() + 600000 }; cookie(res, 'juzi_oauth', binding, 600); save(); const auth = new URL('https://open.weixin.qq.com/connect/oauth2/authorize'); auth.search = new URLSearchParams({ appid: appId, redirect_uri: new URL('api/auth/wechat/callback', base).href, response_type: 'code', scope: 'snsapi_base', state }).toString(); auth.hash = 'wechat_redirect'; json(res, 200, { ok: true, url: auth.href }); } else if (pathname === '/api/auth/wechat/callback' && req.method === 'GET') { const url = new URL(req.url, 'http://localhost'); const state = url.searchParams.get('state') || '', code = url.searchParams.get('code'); const row = db.states[hash(state)], binding = cookies(req).juzi_oauth || ''; delete db.states[hash(state)]; save(); cookie(res, 'juzi_oauth', '', 0); if (!available || !code || !row || row.expires < Date.now() || row.binding !== hash(binding)) { wechatEvent('callback_rejected', { reason: 'state' }); redirect(res, '?login_error=state'); return true; } const tokenUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token'); tokenUrl.search = new URLSearchParams({ appid: appId, secret, code, grant_type: 'authorization_code' }).toString(); let data, upstreamStatus; try { const r = await fetch(tokenUrl, { signal: AbortSignal.timeout(10000) }); upstreamStatus = r.status; data = await r.json(); } catch (_) { wechatEvent('exchange_failed', { reason: 'network' }); redirect(res, '?login_error=network'); return true; } if (!data || typeof data.openid !== 'string' || !data.openid || data.errcode || (upstreamStatus && upstreamStatus !== 200)) { wechatEvent('exchange_failed', { reason: 'wechat', errcode: data && Number.isInteger(data.errcode) ? data.errcode : null, httpStatus: upstreamStatus || null }); redirect(res, '?login_error=wechat'); return true; } const identity = hash(appId + ':' + data.openid); let user = db.users.find(u => u.kind === 'wechat' && u.identity === identity); const newAccount = !user; if (!user) { user = { id: crypto.randomUUID(), kind: 'wechat', identity, consent: new Date().toISOString(), reports: [] }; db.users.push(user); } login(res, user); wechatEvent('login_success', { newAccount }); redirect(res, '?login=success'); } else if (pathname.startsWith('/api/reports') && req.method === 'GET') { const user = session(req); if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; } const id = pathname.slice('/api/reports/'.length); if (user.kind !== 'wechat') { json(res, pathname === '/api/reports' ? 200 : 404, pathname === '/api/reports' ? { ok: true, total: 0, items: [] } : { ok: false, message: '游客模式不保存历史报告' }); return true; } if (pathname === '/api/reports') json(res, 200, { ok: true, total: user.reports.length, items: user.reports }); else if (/^R[\w-]+$/.test(id) && user.reports.some(r => r.id === id)) { const file = path.join(snapshots, user.id, id + '.json'); if (!fs.existsSync(file)) json(res, 404, { ok: false, message: '报告文件暂不可用' }); else json(res, 200, { ok: true, report: JSON.parse(fs.readFileSync(file, 'utf8')) }); } else json(res, 404, { ok: false, message: '报告不存在' }); } else json(res, 404, { ok: false, message: '接口不存在' }); } catch (_) { if (!res.headersSent) json(res, 400, { ok: false, message: '请求处理失败,请稍后重试' }); } return true; } function storeReport(ownerId, snapshot) { const user = db.users.find(u => u.id === ownerId); if (!user || user.kind !== 'wechat' || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner'); const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); atomic(path.join(dir, snapshot.id + '.json'), snapshot); user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }] .concat(user.reports.filter(r => r.id !== snapshot.id)).slice(0, 5); save(); } // Internal admin use only: reuse historical copy, never regenerate it. // Not a public route; validate the stored owner and report ID before reading. function reportPresentation(ownerId, id) { const user = db.users.find(u => u.id === ownerId); if (!user || !/^R[\w-]+$/.test(id)) return null; try { return JSON.parse(fs.readFileSync(path.join(snapshots, user.id, id + '.json'), 'utf8')).presentation || null; } catch (_) { return null; } } return { route, session, storeReport, reportPresentation, sameOrigin, wechatAvailable: available }; };