feat(rednote): add isolated persistent browser H5 entry
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# Rednote H5
|
||||
|
||||
面向 `/rednote/` 的浏览器入口,复用 `h5/` 的页面、相机、检测、评分、报告及管理后台。不是小红书账号授权:同意隐私说明后由服务器生成随机浏览器身份,使用独立 HttpOnly Cookie 保存30天,可查看最近5份完整报告。清除浏览器数据、清除本机身份、身份到期或更换设备后无法恢复原身份;不同 App 的内置浏览器可能不共享身份。
|
||||
|
||||
## 本地样本环境
|
||||
|
||||
```powershell
|
||||
cd D:\juzi\rednote
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 新入口:`http://127.0.0.1:18380/rednote/`,后台 `/rednote/admin.html`。
|
||||
- 对照入口:`http://127.0.0.1:18380/state/`,本地未配置微信真实授权,只提供原游客体验。
|
||||
- 数据:`../temp/rednote-local-data/rednote/` 和 `state/`,与真实 H5 数据隔离。
|
||||
- 所有检测返回固定样本,不连接 Face++ 或淘宝/微信。后台在回环开发环境不要求 Basic Auth,公网必须使用部署模板的鉴权保护。
|
||||
|
||||
## 配置与隔离
|
||||
|
||||
真实服务直接运行共享 `h5/server.js`,不要运行 fixture-server 或 dev 脚本。新增 `JUZI_ENTRY_MODE=rednote`,并显式配置外部独立 `JUZI_DATA_ROOT` 及以 `/rednote/` 结尾的 `PUBLIC_BASE_URL`。配置错误会在初始化账号/数据目录之前终止。部署模板另通过 `JUZI_PROTECTED_DATA_ROOT` 拦截旧数据路径(含符号链接),并将旧部署目录以只读方式挂载到新服务的运行环境;生产模式要求HTTPS。未设置入口模式时仍是原 state 模式。
|
||||
|
||||
- 原入口:`juzi_session`,原有 `/state/` Cookie 路径,微信和临时游客规则不变。
|
||||
- 新入口:`juzi_rednote_session`,`/rednote/` 路径,服务器持久化浏览器身份;不会恢复微信 Cookie,也不会接受微信/淘宝登录接口。
|
||||
- 账号身份和会话仅由服务器生成,客户端提交的 token、用户 ID、channel 不用来恢复身份或决定来源;服务器只保存会话 token 哈希。
|
||||
- 表单缓存使用 `juzi.rednote.profile.v1.*` 和 `juzi.rednote.auth.kind.v1`,避免同域 localStorage 与 state 相互清理。
|
||||
- 首次打开仅生成页面内体验状态,同意隐私后才创建服务器身份;有效 Cookie 自动恢复。清除本机身份会撤销服务端会话并清理本机档案缓存,界面明确说明历史不可恢复。
|
||||
- 检测记录统一由服务端标记 `channel=rednote`。新实例后台及 CSV 可查看新来源,原实例数据和后台不改。
|
||||
- 每个新实例同时只接纳1个主检测;上游主检测和侧脸请求使用有界串行队列。限流只约束新实例,不能代替旧入口和新入口共享 Face++ 账户的全局额度管理。
|
||||
- 前台展示最近5份报告,后台检测记录及照片的保留方式沿用现有微信入口;没有删除或迁移原渠道数据。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
npm run test:browser
|
||||
cd ..\taobao
|
||||
npm test
|
||||
npm run check:shared
|
||||
npm run test:h5
|
||||
```
|
||||
|
||||
新测试覆盖持久身份、首次同意、Secure Cookie、来源防伪、重启恢复、最近5份报告、越权拦截、侧脸缓存、并发限制,以及同一浏览器下 state/rednote 的登录、缓存、历史和退出隔离。浏览器截图位于 `../temp/rednote-browser/`。自动测试使用样本,不代表手机相机已验收。
|
||||
|
||||
## 部署方式
|
||||
|
||||
2026-10-19发布前基线:线上 `juzi-h5.service` 在127.0.0.1:8080运行,工作目录 `/home/ubuntu/juzi/h5`,版本 `fc9f251`;`/state/` 正常,微信授权已启用。新入口使用独立目录、进程和数据存储。
|
||||
|
||||
部署到 `/home/ubuntu/juzi-rednote/releases/<版本>/`,`current` 仅指向新入口发布目录;数据固定 `/home/ubuntu/juzi-rednote-data`。不能在旧线上目录执行 git pull、覆盖其静态文件、移动旧数据或重启旧服务。
|
||||
|
||||
`deploy/juzi-rednote.service` 和 `deploy/nginx-rednote.locations.conf` 是部署模板,不会自动安装。管理员通过私有 `/etc/juzi/rednote.env` 配置 Face++ 凭据;不复制旧微信配置,也不将密钥放进仓库。
|
||||
|
||||
发布顺序:备份原Nginx配置并记录旧进程/版本/健康基线 → 部署新目录和独立数据 → 启动新服务并用8081检查 → 在现有HTTPS server中仅添加新location → `nginx -t`通过后平滑reload → 两入口健康、鉴权和数据隔离检查 → 用户手机验收相机及微信登录。
|
||||
|
||||
回退:还原新增前的Nginx配置、检查并reload,停止新服务;保留新入口数据供复核。无需重启旧服务,不删除或覆盖任何原数据。
|
||||
|
||||
共享主机、Nginx与Face++额度仍是公共依赖;生产发布记录应包含版本、配置备份位置及两入口验证结果。
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=1me rednote H5 - isolated browser identities
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/juzi-rednote/current/h5
|
||||
Environment=JUZI_ENTRY_MODE=rednote
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOST=127.0.0.1
|
||||
Environment=PORT=8081
|
||||
Environment=PUBLIC_BASE_URL=https://1me.xajuzi.com/rednote/
|
||||
Environment=JUZI_DATA_ROOT=/home/ubuntu/juzi-rednote-data
|
||||
Environment=JUZI_PROTECTED_DATA_ROOT=/home/ubuntu/juzi/h5/data
|
||||
Environment=TRUST_LOOPBACK_PROXY=1
|
||||
EnvironmentFile=/etc/juzi/rednote.env
|
||||
ExecStart=/usr/bin/node /home/ubuntu/juzi-rednote/current/h5/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ReadOnlyPaths=/home/ubuntu/juzi
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,42 @@
|
||||
# Insert into the EXISTING HTTPS server during an authorized deployment.
|
||||
# Retain all /state/, /api/, TLS and WeChat verification locations unchanged.
|
||||
location = /rednote {
|
||||
return 301 /rednote/;
|
||||
}
|
||||
location = /rednote/admin.html {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/admin.html;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location = /rednote/css/admin.css {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/css/admin.css;
|
||||
}
|
||||
location = /rednote/js/admin.js {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/js/admin.js;
|
||||
}
|
||||
location ^~ /rednote/api/admin/ {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/api/admin/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 150s;
|
||||
}
|
||||
location ^~ /rednote/data/ {
|
||||
return 404;
|
||||
}
|
||||
location /rednote/ {
|
||||
client_max_body_size 20m;
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 150s;
|
||||
}
|
||||
Generated
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "juzi-skin-rednote",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "juzi-skin-rednote",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "juzi-skin-rednote",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"test": "node --test test/*.test.cjs",
|
||||
"test:browser": "node test/browser.cjs"
|
||||
},
|
||||
"devDependencies": { "playwright": "1.58.2" }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
const path = require('node:path'), { spawn } = require('node:child_process');
|
||||
const root = path.resolve(__dirname, '../..'), children = [];
|
||||
for (const mode of ['state', 'rednote']) {
|
||||
children.push(spawn(process.execPath, [path.join(__dirname, 'fixture-server.cjs')], {
|
||||
env: { ...process.env, JUZI_ENTRY_MODE: mode, PORT: mode === 'state' ? '18381' : '18382',
|
||||
PUBLIC_BASE_URL: 'http://127.0.0.1:18380/' + mode + '/', JUZI_DATA_ROOT: path.join(root, 'temp/rednote-local-data', mode) },
|
||||
stdio: 'inherit'
|
||||
}));
|
||||
}
|
||||
const proxy = require('./proxy.cjs')({ statePort: 18381, rednotePort: 18382 });
|
||||
proxy.listen(18380, '127.0.0.1', () => console.log('Samples only: http://127.0.0.1:18380/rednote/ and /state/; no external API calls'));
|
||||
let closing = false;
|
||||
function stop(code = 0) {
|
||||
if (closing) return; closing = true;
|
||||
children.forEach(child => { if (child.exitCode == null) child.kill(); });
|
||||
proxy.close(() => { process.exitCode = code; });
|
||||
}
|
||||
children.forEach(child => child.on('exit', code => { if (!closing) stop(code || 1); }));
|
||||
process.on('SIGINT', () => stop()); process.on('SIGTERM', () => stop());
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
// Local test/dev runner only. The production service launches h5/server.js directly.
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
if (!process.env.JUZI_DATA_ROOT || !process.env.PUBLIC_BASE_URL) throw Error('Explicit isolated data root and public URL required');
|
||||
Object.assign(process.env, { NODE_ENV: 'test', HOST: '127.0.0.1', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture',
|
||||
WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', TAOBAO_APP_KEY: '', TAOBAO_APP_SECRET: '' });
|
||||
const root = path.resolve(__dirname, '../..'), context = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), context);
|
||||
const fixture = context.window.MOCK_FIXTURE;
|
||||
global.fetch = async url => {
|
||||
if (!String(url).startsWith('https://api-cn.faceplusplus.com/')) throw Error('External requests disabled in fixture mode');
|
||||
if (process.env.FIXTURE_DELAY_MS) await new Promise(resolve => setTimeout(resolve, Number(process.env.FIXTURE_DELAY_MS)));
|
||||
return { ok: true, status: 200, json: async () => ({ request_id: 'fixture', result: structuredClone(fixture.result),
|
||||
face_rectangle: fixture.face_rectangle, left_side_result: fixture.side.left, right_side_result: fixture.side.right }) };
|
||||
};
|
||||
require('../../h5/server');
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
module.exports = function proxy({ statePort, rednotePort }) {
|
||||
return http.createServer((req, res) => {
|
||||
const mode = /^\/(state|rednote)(?:\/|\?|$)/.exec(req.url)?.[1];
|
||||
if (!mode) { res.writeHead(404); return res.end('Not Found'); }
|
||||
if (req.url === '/' + mode) { res.writeHead(301, { Location: '/' + mode + '/' }); return res.end(); }
|
||||
const upstream = http.request({ host: '127.0.0.1', port: mode === 'state' ? statePort : rednotePort,
|
||||
method: req.method, path: req.url.slice(mode.length + 1) || '/',
|
||||
headers: { ...req.headers, 'x-real-ip': '127.0.0.1', 'x-forwarded-proto': 'http' } }, reply => {
|
||||
res.writeHead(reply.statusCode, reply.headers); reply.pipe(res);
|
||||
});
|
||||
upstream.setTimeout(150000, () => upstream.destroy());
|
||||
upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
||||
req.pipe(upstream);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), vm = require('node:vm');
|
||||
const { freePort, start, stop } = require('./support.cjs');
|
||||
const fx = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../h5/assets/mock/report-fixture.js'), 'utf8'), fx);
|
||||
const fixture = fx.window.MOCK_FIXTURE;
|
||||
|
||||
test('persistent browser consent, cookies, owner isolation, reports, source, admission control and restart', async () => {
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-backend-')), port = await freePort();
|
||||
const base = 'http://127.0.0.1:' + port, publicBase = base + '/rednote/';
|
||||
let child;
|
||||
const get = async (route, cookie = '') => fetch(base + route, { headers: { cookie } });
|
||||
const post = async (route, data = {}, cookie = '', headers = {}) => {
|
||||
const response = await fetch(base + route, { method: 'POST', headers: { 'Content-Type': 'application/json', cookie, ...headers }, body: JSON.stringify(data) });
|
||||
return { status: response.status, cookie: response.headers.getSetCookie(), data: await response.json() };
|
||||
};
|
||||
try {
|
||||
child = await start({ dataRoot, port, publicBase, delay: 80 });
|
||||
const html = await (await get('/')).text();
|
||||
assert(html.includes('window.JuziEntry={mode:"rednote"')); assert(!html.includes('data-action="login-wechat"'));
|
||||
assert.equal((await (await get('/api/auth/config')).json()).wechat, false);
|
||||
assert.equal((await post('/api/auth/wechat', { consent: true })).status, 404);
|
||||
assert.equal((await get('/api/auth/wechat/callback?code=fake&state=fake')).status, 404);
|
||||
assert.equal((await post('/api/taobao/session')).status, 404);
|
||||
assert.equal((await post('/api/auth/browser', { consent: false })).status, 400);
|
||||
assert.equal((await post('/api/auth/browser', { consent: true }, '', { Origin: 'https://evil.invalid' })).status, 403);
|
||||
assert.equal(fs.existsSync(path.join(dataRoot, 'accounts/identities.json')), false, 'no identity before consent');
|
||||
const alice = await post('/api/auth/browser', { consent: true, channel: 'taobao', user_id: 'forged' });
|
||||
assert.equal(alice.data.user.kind, 'browser_persistent'); assert.notEqual(alice.data.user.id, 'forged');
|
||||
assert.match(alice.cookie[0], /juzi_rednote_session=[a-f0-9]{64}; Path=\/rednote\/; HttpOnly; SameSite=Lax; Max-Age=2592000/);
|
||||
const cookie = alice.cookie[0].split(';')[0];
|
||||
assert.equal((await post('/api/auth/browser', { consent: true }, cookie)).data.user.id, alice.data.user.id);
|
||||
assert.equal((await post('/api/auth/browser', { consent: true, token: cookie })).status, 401);
|
||||
const bob = await post('/api/auth/browser', { consent: true }), bobCookie = bob.cookie[0].split(';')[0];
|
||||
const body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0],
|
||||
profile: { real_age: 44, daily_skin_type: '干性肌肤', gender: '女' }, channel: 'h5' };
|
||||
const [first, busy] = await Promise.all([post('/api/skin-analyze', body, cookie), post('/api/skin-analyze', body, cookie)]);
|
||||
assert.equal(first.status, 200); assert.equal(first.data.report_saved, true);
|
||||
assert.equal(busy.status, 429); assert.equal(busy.data.code, 'BUSY');
|
||||
assert.equal((await get('/api/reports/' + first.data.record_id, bobCookie)).status, 404);
|
||||
assert.equal((await get('/api/reports', cookie.replace('juzi_rednote_session=', 'juzi_session='))).status, 401);
|
||||
assert.equal((await get('/data/accounts/identities.json')).status, 403);
|
||||
const detail = await (await get('/api/admin/records/' + first.data.record_id)).json();
|
||||
assert.equal(detail.record.channel, 'rednote');
|
||||
assert(detail.photos.front.startsWith('/rednote/api/admin/photos/'));
|
||||
const ids = [first.data.record_id]; let last = first;
|
||||
for (let i = 0; i < 5; i++) { last = await post('/api/skin-analyze', body, cookie); assert.equal(last.data.report_saved, true); ids.push(last.data.record_id); }
|
||||
const history = await (await get('/api/reports', cookie)).json();
|
||||
assert.equal(history.total, 5); assert.equal(history.items[0].id, ids.at(-1));
|
||||
assert.equal((await get('/api/reports/' + ids[0], cookie)).status, 404);
|
||||
assert.equal((await (await get('/api/reports', bobCookie)).json()).total, 0);
|
||||
const stats = await (await get('/api/admin/stats')).json(); assert.equal(stats.by_channel.rednote.total, 6);
|
||||
assert.equal((await (await get('/api/admin/records?channel=h5')).json()).total, 0);
|
||||
const csv = await (await get('/api/admin/export?channel=rednote')).text(); assert.equal(csv.trim().split('\n').length, 7); assert(csv.includes(',rednote'));
|
||||
const snapshot = await (await get('/api/reports/' + last.data.record_id, cookie)).json();
|
||||
await stop(child); child = await start({ dataRoot, port, publicBase });
|
||||
assert.equal((await (await get('/api/auth/session', cookie)).json()).user.id, alice.data.user.id);
|
||||
assert.deepEqual((await (await get('/api/reports/' + last.data.record_id, cookie)).json()).report, snapshot.report);
|
||||
const side = await post('/api/wrinkle-views', { token: last.data.wrinkle_job.token }, cookie);
|
||||
assert.equal(side.data.ok, true); assert.equal(side.data.wrinkle_views.left.status, 'ready');
|
||||
assert.equal((await post('/api/wrinkle-views', { token: last.data.wrinkle_job.token }, bobCookie)).status, 404);
|
||||
assert.equal((await post('/api/auth/logout', {}, cookie)).status, 200);
|
||||
assert.equal((await get('/api/reports', cookie)).status, 401);
|
||||
assert.notEqual((await post('/api/auth/browser', { consent: true }, cookie)).data.user.id, alice.data.user.id);
|
||||
const db = fs.readFileSync(path.join(dataRoot, 'accounts/identities.json'), 'utf8');
|
||||
assert(!db.includes(cookie.split('=')[1]), 'server stores session hashes, not cookie secrets');
|
||||
} finally { await stop(child); fs.rmSync(dataRoot, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
const { chromium } = require('playwright'), { freePort, start, stop } = require('./support.cjs');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const artifacts = path.join(root, 'temp/rednote-browser'); fs.mkdirSync(artifacts, { recursive: true });
|
||||
const runRoot = fs.mkdtempSync(path.join(root, 'temp/rednote-dual-'));
|
||||
const ports = {}, children = []; let browser, proxy;
|
||||
function disk(dir) {
|
||||
const out = {};
|
||||
function visit(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name); if (entry.isDirectory()) visit(full); else out[path.relative(dir, full)] = fs.readFileSync(full).toString('base64');
|
||||
} } visit(dir); return out;
|
||||
}
|
||||
async function screenshot(page, name, fullPage = false) {
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: path.join(artifacts, name + '.png'), fullPage, animations: 'disabled' });
|
||||
}
|
||||
async function consent(page, capture = false) {
|
||||
await page.click('#s-splash [data-action="open-privacy"]');
|
||||
if (capture) {
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 375, height: 726 }]) {
|
||||
await page.setViewportSize(size);
|
||||
const layout = await page.evaluate(() => {
|
||||
const body = document.querySelector('#p-privacy .popup-body').getBoundingClientRect();
|
||||
const row = document.querySelector('#privacy-row').getBoundingClientRect();
|
||||
const button = document.querySelector('#p-privacy .popup-btn').getBoundingClientRect();
|
||||
return { separated: body.bottom <= row.top && row.bottom <= button.top, visible: button.bottom <= innerHeight };
|
||||
});
|
||||
assert(layout.separated && layout.visible, 'persistence disclosure must not overlap consent or hide Continue');
|
||||
}
|
||||
await screenshot(page, 'rednote-consent');
|
||||
}
|
||||
await page.locator('#privacy-check').click();
|
||||
await page.click('#p-privacy [data-action="agree-continue"]');
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
for (const key of ['proxy', 'state', 'rednote']) ports[key] = await freePort();
|
||||
const base = 'http://127.0.0.1:' + ports.proxy;
|
||||
const stateData = path.join(runRoot, 'state'), rednoteData = path.join(runRoot, 'rednote');
|
||||
const wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(stateData, 'accounts'))[0];
|
||||
for (const mode of ['state', 'rednote']) children.push(await start({ mode, dataRoot: path.join(runRoot, mode), port: ports[mode], publicBase: base + '/' + mode + '/' }));
|
||||
proxy = require('../scripts/proxy.cjs')({ statePort: ports.state, rednotePort: ports.rednote });
|
||||
await new Promise(resolve => proxy.listen(ports.proxy, '127.0.0.1', resolve));
|
||||
const baseline = disk(stateData);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
let context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
await context.addCookies([{ name: 'juzi_session', value: wechat.token, domain: '127.0.0.1', path: '/state/' }]);
|
||||
const state = await context.newPage(), rednote = await context.newPage(), errors = [], rednoteRequests = [];
|
||||
for (const page of [state, rednote]) page.on('pageerror', error => errors.push(error.message));
|
||||
rednote.on('request', request => { if (request.url().includes('/api/')) rednoteRequests.push(request.url()); });
|
||||
await state.goto(base + '/state/'); await state.waitForSelector('#s-splash.active');
|
||||
assert.equal(await state.locator('#s-splash [data-auth-status]').textContent(), '微信已登录');
|
||||
const stateKind = await state.evaluate(() => localStorage.getItem('juzi.auth.kind.v1'));
|
||||
await rednote.goto(base + '/rednote/'); await rednote.waitForSelector('#s-splash.active');
|
||||
assert.equal(await rednote.locator('[data-action="login-wechat"]').count(), 0);
|
||||
assert.equal((await context.cookies()).filter(c => c.name === 'juzi_rednote_session').length, 0, 'opening the page does not persist an identity before consent');
|
||||
await screenshot(rednote, 'rednote-home');
|
||||
await rednote.click('#s-splash [data-action="history"]');
|
||||
await rednote.waitForSelector('#s-history.active');
|
||||
assert((await rednote.locator('#identity-note').textContent()).includes('同意隐私'));
|
||||
await rednote.goBack(); await rednote.waitForSelector('#s-splash.active');
|
||||
await consent(rednote, true);
|
||||
const alice = await rednote.evaluate(() => AccountClient.user()); assert.equal(alice.kind, 'browser_persistent');
|
||||
const cookie = (await context.cookies()).find(c => c.name === 'juzi_rednote_session');
|
||||
assert.equal(cookie.path, '/rednote/'); assert(cookie.httpOnly && cookie.expires > Date.now() / 1000);
|
||||
await rednote.evaluate(() => ProfileStore.create(() => localStorage).write(AccountClient.user(), { birth: '1996-01', gender: '女', region: { province: '北京市', city: '北京市' }, skinIndex: 2 }));
|
||||
const stored = await context.storageState();
|
||||
await context.close();
|
||||
context = await browser.newContext({ storageState: stored, viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
const restored = await context.newPage(); restored.on('pageerror', e => errors.push(e.message));
|
||||
restored.on('request', r => { if (r.url().includes('/api/')) rednoteRequests.push(r.url()); });
|
||||
await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active');
|
||||
assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id);
|
||||
await consent(restored); assert.equal(await restored.locator('#f-birth').inputValue(), '1996-01');
|
||||
assert.equal(await restored.locator('#f-gender').inputValue(), '女');
|
||||
await screenshot(restored, 'rednote-profile');
|
||||
const fx = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fx);
|
||||
const photo = fx.window.MOCK_FIXTURE.photos[0];
|
||||
const payload = await (await context.request.post(base + '/rednote/api/skin-analyze', { data: {
|
||||
front_image: photo, left_image: photo, right_image: photo, profile: { real_age: 30, daily_skin_type: '中性肌肤' }, channel: 'taobao' } })).json();
|
||||
assert.equal(payload.report_saved, true);
|
||||
await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active');
|
||||
await restored.click('#s-splash [data-action="history"]'); await restored.waitForSelector('.history-detail');
|
||||
assert(!(await restored.locator('#identity-note').textContent()).includes('微信'));
|
||||
await screenshot(restored, 'rednote-history');
|
||||
await restored.click('.history-detail'); await restored.waitForSelector('#s-report.active');
|
||||
assert.equal(await restored.locator('#stat-score b').textContent(), String(payload.vm.total));
|
||||
assert.equal(await restored.locator('#stat-age b').textContent(), String(payload.vm.skinAgeDisplay));
|
||||
await screenshot(restored, 'rednote-report', true);
|
||||
const stateAgain = await context.newPage(); await stateAgain.goto(base + '/state/'); await stateAgain.waitForSelector('#s-splash.active');
|
||||
assert.equal(await stateAgain.evaluate(() => AccountClient.user().id), wechat.user.id);
|
||||
assert.equal(await stateAgain.evaluate(() => localStorage.getItem('juzi.auth.kind.v1')), stateKind);
|
||||
assert((await stateAgain.evaluate(() => Object.keys(localStorage))).some(key => key.startsWith('juzi.rednote.profile.v1.')));
|
||||
const admin = await browser.newPage({ viewport: { width: 1280, height: 900 } }), adminRequests = []; admin.on('pageerror', e => errors.push(e.message));
|
||||
admin.on('request', r => { if (r.url().includes('/api/')) adminRequests.push(r.url()); });
|
||||
await admin.goto(base + '/rednote/admin.html'); await admin.waitForSelector('#tbl-records .thumb');
|
||||
await admin.locator('#tbl-records tbody tr').first().click(); await admin.waitForSelector('#drawer:not(.hidden)');
|
||||
await admin.waitForSelector('#d-body a[download]');
|
||||
assert((await admin.locator('#d-body a[href$="/raw"]').getAttribute('href')).startsWith('/rednote/api/admin/'));
|
||||
assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/')));
|
||||
assert(adminRequests.some(url => url.includes('/api/admin/photos/')));
|
||||
await screenshot(admin, 'rednote-admin', true);
|
||||
await admin.click('#btn-close');
|
||||
await admin.click('[data-tab="stats"]');
|
||||
const [download] = await Promise.all([admin.waitForEvent('download'), admin.click('#btn-export')]);
|
||||
assert(download.url().startsWith(base + '/rednote/api/admin/export'));
|
||||
assert(fs.readFileSync(await download.path(), 'utf8').includes(',rednote'));
|
||||
await admin.click('[data-tab="records"]');
|
||||
assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/')));
|
||||
await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active');
|
||||
restored.once('dialog', dialog => dialog.dismiss()); await restored.click('#s-splash [data-action="logout-wechat"]');
|
||||
assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id);
|
||||
restored.once('dialog', dialog => dialog.accept()); await restored.click('#s-splash [data-action="logout-wechat"]');
|
||||
await restored.waitForFunction(() => AccountClient.user().kind === 'browser');
|
||||
assert.equal((await context.request.get(base + '/rednote/api/reports')).status(), 401);
|
||||
await restored.goBack();
|
||||
assert.equal(await restored.locator('#s-report.active').count(), 0, 'back navigation cannot restore the old private report after clearing identity');
|
||||
assert.equal((await context.request.get(base + '/state/api/auth/session')).status(), 200);
|
||||
assert.equal((await (await context.request.get(base + '/state/api/auth/session')).json()).user.id, wechat.user.id);
|
||||
assert(rednoteRequests.every(url => url.startsWith(base + '/rednote/api/')));
|
||||
assert.deepEqual(disk(stateData), baseline, 'rednote operations never modify original state identities/reports/data');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: consent-only creation, persistent browser restore, profile cache, saved/history reports, admin API/photo subpaths, source isolation, independent logout and unchanged state data; screenshots in temp/rednote-browser');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (proxy) await new Promise(resolve => proxy.close(resolve));
|
||||
for (const child of children) await stop(child);
|
||||
fs.rmSync(runRoot, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict'), path = require('node:path');
|
||||
const config = require('../../h5/lib/entry-config');
|
||||
test('rednote rejects missing, shared data paths and wrong public prefixes before initializing storage', () => {
|
||||
const root = path.resolve(__dirname, '../../h5');
|
||||
assert.equal(config({}, root).mode, 'state');
|
||||
const env = { JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/' };
|
||||
for (const dataRoot of [undefined, root, path.join(root, 'custom-data'), path.join(root, 'data'), path.join(root, 'data/subdir')]) {
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: dataRoot }, root), /own JUZI_DATA_ROOT/);
|
||||
}
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote'), PUBLIC_BASE_URL: 'https://skin.example.test/state/' }, root), /rednote/);
|
||||
assert.equal(config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote') }, root).channel, 'rednote');
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/original'), JUZI_PROTECTED_DATA_ROOT: path.join(root, '../temp/original') }, root), /own JUZI_DATA_ROOT/);
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote'), NODE_ENV: 'production', PUBLIC_BASE_URL: 'http://skin.example.test/rednote/' }, root), /rednote/);
|
||||
});
|
||||
test('HTTPS browser cookies are secure and persistent, even when WeChat credentials exist', async () => {
|
||||
const fs = require('node:fs'), os = require('node:os'), { Readable } = require('node:stream');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-secure-'));
|
||||
try {
|
||||
const accounts = require('../../h5/lib/accounts')(dir, { JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/', WECHAT_APP_ID: 'configured', WECHAT_APP_SECRET: 'fixture' });
|
||||
assert.equal(accounts.wechatAvailable, false);
|
||||
const headers = {};
|
||||
const req = Readable.from([Buffer.from('{"consent":true}')]);
|
||||
req.method = 'POST'; req.url = '/api/auth/browser'; req.headers = { host: 'skin.example.test', origin: 'https://skin.example.test' }; req.socket = { remoteAddress: '127.0.0.1' };
|
||||
let response;
|
||||
await accounts.route(req, { setHeader: (k, v) => { headers[k] = v; }, getHeader: k => headers[k], writeHead() {}, end: text => { response = JSON.parse(text); } }, '/api/auth/browser');
|
||||
assert.match(headers['Set-Cookie'][0], /HttpOnly; SameSite=Lax; Max-Age=2592000; Secure$/);
|
||||
assert.equal(response.user.kind, 'browser_persistent');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
test('data-directory aliases cannot bypass protection of the original deployment', () => {
|
||||
const fs = require('node:fs'), os = require('node:os');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-path-'));
|
||||
try {
|
||||
const original = path.join(dir, 'original'), alias = path.join(dir, 'alias'); fs.mkdirSync(original);
|
||||
fs.symlinkSync(original, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
assert.throws(() => config({ JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/',
|
||||
JUZI_DATA_ROOT: path.join(alias, 'nested'), JUZI_PROTECTED_DATA_ROOT: original }, path.join(dir, 'h5')), /own JUZI_DATA_ROOT/);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
test('new-channel provider requests serialize through body consumption and cap the queue', async () => {
|
||||
let active = 0, peak = 0;
|
||||
const gated = require('../../h5/lib/provider-gate')(async () => {
|
||||
peak = Math.max(peak, ++active);
|
||||
return { ok: true, status: 200, json: async () => { await new Promise(resolve => setTimeout(resolve, 10)); active--; return { ok: true }; } };
|
||||
}, 2);
|
||||
const a = gated('fixture'), b = gated('fixture');
|
||||
await assert.rejects(gated('fixture'), /queue is full/);
|
||||
assert.deepEqual(await (await a).json(), { ok: true }); await b;
|
||||
assert.equal(peak, 1); assert.equal(active, 0);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
const http = require('node:http'), path = require('node:path'), { spawn } = require('node:child_process'), { once } = require('node:events');
|
||||
async function freePort() {
|
||||
const server = http.createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port;
|
||||
}
|
||||
async function start({ mode = 'rednote', dataRoot, port, publicBase, delay = 0 }) {
|
||||
const child = spawn(process.execPath, [path.join(__dirname, '../scripts/fixture-server.cjs')], {
|
||||
env: { ...process.env, JUZI_ENTRY_MODE: mode, JUZI_DATA_ROOT: dataRoot, PORT: String(port),
|
||||
PUBLIC_BASE_URL: publicBase, FIXTURE_DELAY_MS: String(delay) }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { child.kill(); reject(Error('startup timeout')); }, 15000);
|
||||
let error = '';
|
||||
child.stderr.on('data', bytes => { error += bytes; });
|
||||
child.stdout.on('data', bytes => { if (String(bytes).includes('已启动')) { clearTimeout(timer); resolve(); } });
|
||||
child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code + ': ' + error)); });
|
||||
});
|
||||
return child;
|
||||
}
|
||||
async function stop(child) {
|
||||
if (child && child.exitCode == null) { const exited = once(child, 'exit'); child.kill(); await exited; }
|
||||
}
|
||||
module.exports = { freePort, start, stop };
|
||||
Reference in New Issue
Block a user