Browse Source

build(admin): prepare verifiable frontend release packages

彭峰 1 tháng trước cách đây
mục cha
commit
4b8abbd275
4 tập tin đã thay đổi với 127 bổ sung và 1 xóa
  1. 6 0
      README.md
  2. 4 1
      package.json
  3. 65 0
      scripts/release-admin-web.mjs
  4. 52 0
      scripts/tests/admin-release.test.mjs

+ 6 - 0
README.md

@@ -48,6 +48,12 @@ npm run test:admin:ci
 
 移动端输出到 `dist/xiaoshu-mobile/browser/`,管理后台输出到 `dist/xiaoshu-admin/browser/`。两个站点部署时都需要把各自的 Angular 路由回退到对应的 `index.html`。
 
+管理端发布包使用 `npm run release:admin:prepare` 生成,要求已跟踪的修改先提交。输出位于 `dist/admin-release/`,包含静态站点、带源码提交号的版本清单、每个文件的 SHA-256 和压缩包校验和。压缩包只包含前端构建,不包含云函数、数据库备份或服务端密钥。
+
+上传 `site/` 内容到现有后台站点时,先上传资源文件,最后更新 `index.html`;保留旧的哈希资源,避免已打开的页面找不到旧脚本。上传完成后执行 `npm run release:admin:verify`,核对 `https://admin.xiaoshu.pro` 的版本、全部文件及 `/admin/login`、`/admin/payroll` 深层路由。其他核验地址通过 `XIAOSHU_ADMIN_URL` 指定。准备和核验命令均不会上传文件或修改线上配置。
+
+当前公网后台直接调用 `https://server.xiaoshu.pro/api/functions`,仅本地开发使用代理。前端上传需要独立的站点托管权限;`XIAOSHU_MASTER_KEY` 不具备该权限,不能放入前端配置或发布包。
+
 ## 迁移验收
 
 - `pages.json` 中 95/95 条路由已注册,路径保持不变。

+ 4 - 1
package.json

@@ -48,7 +48,10 @@
     "members:migrate": "node scripts/migrate-member-enrollment.mjs",
     "members:smoke": "node scripts/smoke-member-enrollment.mjs",
     "test:payroll-integrity": "node --test scripts/tests/payroll-integrity.test.mjs",
-    "payroll:integrity:migrate": "node scripts/migrate-payroll-integrity.mjs"
+    "payroll:integrity:migrate": "node scripts/migrate-payroll-integrity.mjs",
+    "release:admin:prepare": "node scripts/release-admin-web.mjs --prepare",
+    "release:admin:verify": "node scripts/release-admin-web.mjs --verify",
+    "test:admin-release": "node --test scripts/tests/admin-release.test.mjs"
   },
   "private": true,
   "dependencies": {

+ 65 - 0
scripts/release-admin-web.mjs

@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+// Prepare and verify static releases without reading or uploading server credentials.
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createHash } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
+
+const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
+const sha=data=>createHash('sha256').update(data).digest('hex');
+const run=(cmd,args,options={})=>execFileSync(cmd,args,{cwd:repo,stdio:'inherit',...options});
+
+export async function manifestFor(directory,releaseId,commit){
+ const files=[];
+ async function walk(relative=''){
+  for(const entry of await fs.readdir(path.join(directory,relative),{withFileTypes:true})){
+   const name=path.posix.join(relative,entry.name);
+   if(entry.isSymbolicLink()||/[\r\n]/.test(name)||/(^|\/)\.(env|git)|\.(pem|key)$/i.test(name))throw Error('发布包包含不允许的文件: '+name);
+   if(entry.isDirectory())await walk(name);
+   else if(entry.isFile()&&name!=='deployment-version.json')files.push({path:name,sha256:sha(await fs.readFile(path.join(directory,name)))});
+  }
+ }
+ await walk();
+ if(!files.some(f=>f.path==='index.html')||!files.some(f=>/^main-.*\.js$/.test(f.path)))throw Error('缺少管理端生产入口或主脚本');
+ return{application:'xiaoshu-admin',releaseId,commit,builtAt:new Date().toISOString(),files:files.sort((a,b)=>a.path.localeCompare(b.path))};
+}
+
+async function response(url){const result=await fetch(url,{signal:AbortSignal.timeout(30000),headers:{'Cache-Control':'no-cache'}});if(!result.ok)throw Error('HTTP '+result.status+' '+url);return result;}
+export async function verifyRelease(base,manifest){
+ const url=new URL(base);if(url.protocol!=='https:'&&!['localhost','127.0.0.1'].includes(url.hostname))throw Error('公网发布地址必须使用 HTTPS');
+ const live=await(await response(new URL('/deployment-version.json?release='+manifest.releaseId,url))).json();
+ if(live.releaseId!==manifest.releaseId||live.commit!==manifest.commit)throw Error('公网版本与本次发布不一致');
+ for(let i=0;i<manifest.files.length;i+=6)await Promise.all(manifest.files.slice(i,i+6).map(async file=>{
+  const remote=new URL('/'+file.path,url);remote.searchParams.set('release',manifest.releaseId);
+  if(sha(Buffer.from(await(await response(remote)).arrayBuffer()))!==file.sha256)throw Error('公网文件校验失败: '+file.path);
+ }));
+ const expected=manifest.files.find(f=>f.path==='index.html').sha256;
+ for(const route of ['/admin/login','/admin/payroll']){
+  const page=await response(new URL(route+'?release='+manifest.releaseId,url));
+  if(!page.headers.get('content-type')?.includes('text/html')||sha(Buffer.from(await page.arrayBuffer()))!==expected)throw Error('Angular 深层路由未返回新入口: '+route);
+ }
+ return{releaseId:manifest.releaseId,commit:manifest.commit,filesVerified:manifest.files.length,url:url.origin};
+}
+
+async function main(){
+ const mode=process.argv[2]||'--prepare';
+ if(!['--prepare','--verify'].includes(mode))throw Error('使用 --prepare 或 --verify;上传前需另行配置现有站点的部署入口');
+ const base=process.env.XIAOSHU_ADMIN_URL||'https://admin.xiaoshu.pro';
+ const output=path.join(repo,'dist/admin-release'),bundle=path.join(output,'site');
+ if(mode==='--verify'){const manifest=JSON.parse(await fs.readFile(path.join(bundle,'deployment-version.json'),'utf8'));console.log(JSON.stringify(await verifyRelease(base,manifest)));return;}
+ const commit=run('git',['rev-parse','HEAD'],{encoding:'utf8',stdio:'pipe'}).trim();
+ if(run('git',['status','--porcelain','--untracked-files=no'],{encoding:'utf8',stdio:'pipe'}).trim())throw Error('请先提交已跟踪文件的修改,再构建可追踪的发布版本');
+ run('npm',['run','build:admin']);
+ await fs.rm(output,{recursive:true,force:true});await fs.mkdir(output,{recursive:true});
+ await fs.cp(path.join(repo,'dist/xiaoshu-admin/browser'),bundle,{recursive:true});
+ const releaseId=commit.slice(0,12)+'-'+new Date().toISOString().replace(/[-:.]/g,'');
+ const manifest=await manifestFor(bundle,releaseId,commit);
+ await fs.writeFile(path.join(bundle,'deployment-version.json'),JSON.stringify(manifest,null,2));
+ const archive=path.join(output,'xiaoshu-admin-'+releaseId+'.tar.gz');
+ run('tar',['-czf',archive,'-C',bundle,'.']);
+ await fs.writeFile(path.join(output,'SHA256SUMS'),sha(await fs.readFile(archive))+'  '+path.basename(archive)+'\n');
+ console.log(JSON.stringify({prepared:true,releaseId,archive,files:manifest.files.length}));
+
+}
+if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url))main().catch(error=>{console.error(error.message);process.exitCode=1;});

+ 52 - 0
scripts/tests/admin-release.test.mjs

@@ -0,0 +1,52 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import http from 'node:http';
+import {manifestFor,verifyRelease} from '../release-admin-web.mjs';
+
+async function fixture(t){
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'xiaoshu-admin-release-'));
+ t.after(()=>fs.rm(dir,{recursive:true,force:true}));
+ await fs.writeFile(path.join(dir,'index.html'),'<!doctype html><script src="main-ABC.js"></script>');
+ await fs.writeFile(path.join(dir,'main-ABC.js'),'console.log("admin");');
+ const manifest=await manifestFor(dir,'test-release','source-commit');
+ await fs.writeFile(path.join(dir,'deployment-version.json'),JSON.stringify(manifest));
+ return{dir,manifest};
+}
+async function server(t,dir,override){
+ const app=http.createServer(async(req,res)=>{
+  try{
+   const url=new URL(req.url,'http://localhost'),name=url.pathname.slice(1);
+   const body=override?.(name);
+   if(body!==undefined){res.setHeader('Content-Type','text/html');res.end(body);return;}
+   const file=name.startsWith('admin/')?'index.html':name;
+   res.setHeader('Content-Type',file.endsWith('.html')?'text/html':file.endsWith('.json')?'application/json':'text/javascript');
+   res.end(await fs.readFile(path.join(dir,file)));
+  }catch{res.statusCode=404;res.end('missing');}
+ });
+ await new Promise(resolve=>app.listen(0,'127.0.0.1',resolve));
+ t.after(()=>new Promise(resolve=>app.close(resolve)));
+ return'http://127.0.0.1:'+app.address().port;
+}
+test('核对文件哈希和登录/工资深层路由,不能只凭首页成功判断发布成功',async t=>{
+ const {dir,manifest}=await fixture(t),url=await server(t,dir);
+ assert.equal((await verifyRelease(url,manifest)).filesVerified,2);
+ await fs.writeFile(path.join(dir,'main-ABC.js'),'old build');
+ await assert.rejects(verifyRelease(url,manifest),/文件校验失败/);
+});
+test('拒绝版本仍旧或深层路由返回其他页面的发布',async t=>{
+ const {dir,manifest}=await fixture(t),url=await server(t,dir,name=>name==='admin/payroll'?'old index':undefined);
+ await assert.rejects(verifyRelease(url,manifest),/深层路由/);
+ await fs.writeFile(path.join(dir,'deployment-version.json'),JSON.stringify({...manifest,releaseId:'old-release'}));
+ await assert.rejects(verifyRelease(url,manifest),/版本.*不一致/);
+});
+test('发布包拒绝密钥文件和符号链接',async t=>{
+ const {dir}=await fixture(t);
+ await fs.writeFile(path.join(dir,'.env'),'EXAMPLE=private');
+ await assert.rejects(manifestFor(dir,'r','c'),/不允许/);
+ await fs.unlink(path.join(dir,'.env'));
+ await fs.symlink(path.join(dir,'index.html'),path.join(dir,'link'));
+ await assert.rejects(manifestFor(dir,'r','c'),/不允许/);
+});