Files

252 lines
16 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const net = require('net');
const SkinScore = require('../js/skin-score');
const ReportUI = require('../js/report-ui');
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) {
const rednote = env.JUZI_ENTRY_MODE === 'rednote';
const sessionCookie = rednote ? 'juzi_rednote_session' : 'juzi_session';
const persistentKind = kind => ['wechat', 'taobao'].includes(kind) || (rednote && kind === 'browser_persistent');
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 = !rednote && !!(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)[sessionCookie];
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 === (rednote ? 'browser_persistent' : '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, sessionCookie, 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 (rednote && pathname.startsWith('/api/auth/wechat')) {
json(res, 404, { 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; }
if (rednote) {
let user = session(req);
if (!user) {
user = { id: crypto.randomUUID(), kind: 'browser_persistent', channel: 'rednote', consent: new Date().toISOString(), reports: [] };
db.users.push(user); login(res, user);
}
json(res, 200, { ok: true, user: publicUser(user) }); return true;
}
for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key);
const previous = cookies(req)[sessionCookie];
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, sessionCookie, token, null);
json(res, 200, { ok: true, user: publicUser(user) });
} else if (pathname === '/api/auth/logout' && req.method === 'POST') {
const token = cookies(req)[sessionCookie];
if (token) {
guestSessions.delete(hash(token));
if (db.sessions[hash(token)]) { delete db.sessions[hash(token)]; save(); }
}
cookie(res, sessionCookie, '', 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' && !(rednote && user.kind === 'browser_persistent')) {
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 {
const snapshot=JSON.parse(fs.readFileSync(file,'utf8'));
snapshot.vm=SkinScore.withDisplayRecommendation(snapshot.vm,snapshot.payload && snapshot.payload.result);
if(snapshot.presentation)snapshot.presentation=Object.assign({},snapshot.presentation,{product:ReportUI.products.find(p=>p.tier===snapshot.vm.recommendation.tier)||null});
json(res,200,{ok:true,report:snapshot});
}
} 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 || !persistentKind(user.kind) || !/^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; }
}
function reportSnapshot(ownerId,id) {
if (!db.users.some(u=>u.id===ownerId&&persistentKind(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;}
}
function updateWrinkleViews(ownerId,id,views) {
const snapshot=reportSnapshot(ownerId,id);
if (!snapshot) return;
snapshot.payload.wrinkle_views=views;
if (snapshot.payload.wrinkle_job) snapshot.payload.wrinkle_job.status='complete';
atomic(path.join(snapshots,ownerId,id+'.json'),snapshot);
}
function loginTaobao(providerKey) {
let user = db.users.find(u => u.kind === 'taobao' && u.provider_key === providerKey);
if (!user) { user = { id: crypto.randomUUID(), kind: 'taobao', provider_key: providerKey, reports: [] }; db.users.push(user); }
const token = random(), expires = Date.now() + 30 * 86400000;
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
db.sessions[hash(token)] = { user: user.id, expires };
save();
return { token, expires_in: 30 * 86400, user: publicUser(user) };
}
function taobaoSession(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
const row = match && db.sessions[hash(match[1])];
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null;
}
function taobaoLogout(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
}
function reports(owner) {
return db.users.find(u => u.id === owner && u.kind === 'taobao')?.reports || [];
}
function ownedReport(owner, id) {
if (!reports(owner).some(r => r.id === id)) return null;
const snapshot = reportSnapshot(owner, id);
if (!snapshot) return null;
snapshot.vm = SkinScore.withDisplayRecommendation(snapshot.vm, snapshot.payload && snapshot.payload.result);
snapshot.presentation = ReportUI.forDisplay(snapshot.vm, snapshot.payload && snapshot.payload.result, snapshot.presentation);
return snapshot;
}
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
};