feat(rednote): add isolated persistent browser H5 entry

This commit is contained in:
xsl
2026-10-19 22:59:10 +08:00
parent 9eec4369da
commit 4af825a5e4
24 changed files with 707 additions and 45 deletions
+25 -11
View File
@@ -11,6 +11,9 @@ 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');
@@ -32,7 +35,7 @@ module.exports = function createAccounts(root, env = process.env) {
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));
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('=')));
}
@@ -42,17 +45,17 @@ module.exports = function createAccounts(root, env = process.env) {
res.setHeader('Set-Cookie', [].concat(old, line));
}
function session(req) {
const token = cookies(req).juzi_session;
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 === 'wechat') : null;
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, 'juzi_session', token, 30 * 86400);
cookie(res, sessionCookie, token, 30 * 86400);
save();
}
function publicUser(u) { return u ? { id: u.id, kind: u.kind } : null; }
@@ -91,6 +94,9 @@ module.exports = function createAccounts(root, env = process.env) {
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') {
@@ -101,20 +107,28 @@ module.exports = function createAccounts(root, env = process.env) {
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).juzi_session;
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, 'juzi_session', token, null);
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).juzi_session;
const token = cookies(req)[sessionCookie];
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 });
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; }
@@ -155,7 +169,7 @@ module.exports = function createAccounts(root, env = process.env) {
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') {
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;
@@ -177,7 +191,7 @@ module.exports = function createAccounts(root, env = process.env) {
}
function storeReport(ownerId, snapshot) {
const user = db.users.find(u => u.id === ownerId);
if (!user || !['wechat', 'taobao'].includes(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
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 }]
@@ -193,7 +207,7 @@ module.exports = function createAccounts(root, env = process.env) {
catch (_) { return null; }
}
function reportSnapshot(ownerId,id) {
if (!db.users.some(u=>u.id===ownerId&&['wechat','taobao'].includes(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
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) {
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const path = require('node:path');
const fs = require('node:fs');
function physical(file) {
if (fs.existsSync(file)) return fs.realpathSync(file);
const parent = path.dirname(file);
return parent === file ? file : path.join(physical(parent), path.basename(file));
}
function inside(file, parent) {
if (process.platform === 'win32') { file = file.toLowerCase(); parent = parent.toLowerCase(); }
return file === parent || file.startsWith(parent + path.sep);
}
// One immutable entry mode per process. Never infer a channel from client input.
module.exports = function entryConfig(env, root) {
const mode = env.JUZI_ENTRY_MODE || 'state';
if (!['state', 'rednote'].includes(mode)) throw Error('Unsupported JUZI_ENTRY_MODE');
const rednote = mode === 'rednote';
const dataRoot = env.JUZI_DATA_ROOT ? path.resolve(env.JUZI_DATA_ROOT) : path.join(root, 'data');
if (rednote) {
const protectedRoots = [root, env.JUZI_PROTECTED_DATA_ROOT].filter(Boolean).map(p => physical(path.resolve(p)));
if (!env.JUZI_DATA_ROOT || protectedRoots.some(p => inside(physical(dataRoot), p))) {
throw Error('Rednote requires its own JUZI_DATA_ROOT outside the shared h5 directory and protected data');
}
const base = new URL(env.PUBLIC_BASE_URL || '');
if (!['http:', 'https:'].includes(base.protocol) || base.pathname !== '/rednote/' || base.search || base.hash || base.username || base.password ||
(env.NODE_ENV === 'production' && base.protocol !== 'https:')) {
throw Error('Rednote PUBLIC_BASE_URL must end with /rednote/');
}
}
return { mode, rednote, dataRoot, channel: rednote ? 'rednote' : 'h5',
client: { mode, persistentBrowser: rednote } };
};
+17
View File
@@ -0,0 +1,17 @@
'use strict';
// Rednote-only admission control. Includes JSON consumption in the critical section.
module.exports = function providerGate(fetchImpl, maxPending = 4) {
let tail = Promise.resolve(), pending = 0;
return function (...args) {
if (pending >= maxPending) return Promise.reject(Error('Detection queue is full'));
pending++;
const result = tail.then(async () => {
args[1]?.signal?.throwIfAborted();
const response = await fetchImpl(...args);
const data = await response.json();
return { ok: response.ok, status: response.status, json: async () => data };
});
tail = result.catch(() => {}).finally(() => { pending--; });
return result;
};
};
+13
View File
@@ -0,0 +1,13 @@
'use strict';
// Only the rednote instance transforms the shared HTML; state is served verbatim.
module.exports = function rednoteView(html, admin = false) {
const bootstrap = '<script>window.JuziEntry={mode:"rednote",persistentBrowser:true};</script>';
if (admin) return html.replace('</head>', bootstrap + '</head>')
.replace('<title>', '<title>Rednote · ');
return html.replace('</head>', bootstrap + '<link rel="stylesheet" href="css/rednote.css"></head>')
.replace(/<button class="login-button login-wechat"[\s\S]*?<\/button>/, '')
.replace('游客登陆</button>', '浏览器继续体验</button>')
.replace('我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。',
'同意后将自动建立本机浏览器身份。我们将采集面部影像和基础档案,交由 Face⁺⁺ 分析,并在服务器保存照片和报告,供此浏览器查看最近5份报告。浏览器身份有效期30天,清除浏览器数据或更换设备后无法恢复。')
.replace('<h1 class="policy-title">', '<p>本入口使用浏览器身份保存检测记录及照片,最近5份报告可由同一浏览器查看。Face⁺⁺提供检测分析,以下为其政策说明;清除本机身份后无法恢复原身份的历史报告。</p><h1 class="policy-title">');
};
+2 -2
View File
@@ -16,7 +16,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
fs.renameSync(tmp,file);
}
function register(owner,record,context) {
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao'].includes(owner.kind),status:'pending',context};
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao','browser_persistent'].includes(owner.kind),status:'pending',context};
save(job);remember(job);
return {token:job.token,status:'pending'};
}
@@ -26,7 +26,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
let job=jobs.get(token);
if (!job) {
try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;}
if (job.owner!==owner.id || !['wechat','taobao'].includes(owner.kind)) return null;
if (job.owner!==owner.id || !['wechat','taobao','browser_persistent'].includes(owner.kind)) return null;
remember(job);
}
if (job.owner!==owner.id) return null;