feat(rednote): add isolated persistent browser H5 entry
This commit is contained in:
+25
-11
@@ -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) {
|
||||
|
||||
@@ -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 } };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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">');
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user