prepare-mobile-speed3-web.mjs 4.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
  1. // Read-only origin backup and local web payload preparation. Never uploads.
  2. import fs from 'node:fs/promises';
  3. import path from 'node:path';
  4. import {createHash} from 'node:crypto';
  5. import {execFileSync} from 'node:child_process';
  6. import {fileURLToPath} from 'node:url';
  7. import {manifestFor} from './release-admin-web.mjs';
  8. const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  9. const origin='https://xiaoshu-h5.obs.cn-south-1.myhuaweicloud.com/';
  10. const root=process.argv[2];
  11. if(!root||!path.isAbsolute(root))throw Error('Provide an absolute private mobile release directory');
  12. const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
  13. const safe=name=>typeof name==='string'&&name.length>0&&!name.startsWith('/')&&!name.includes('\\')&&!name.includes('%')&&!/[?#\r\n]/.test(name)&&name.split('/').every(part=>part&&part!=='.'&&part!=='..');
  14. async function get(name){
  15. if(!safe(name))throw Error('Unsafe origin asset path');
  16. const url=new URL(name,origin);url.searchParams.set('backup',Date.now().toString());
  17. const response=await fetch(url,{headers:{'Cache-Control':'no-cache'},signal:AbortSignal.timeout(30000),redirect:'error'});
  18. if(!response.ok)throw Error(`Origin HTTP ${response.status}: ${name}`);
  19. return Buffer.from(await response.arrayBuffer());
  20. }
  21. await fs.mkdir(root,{recursive:true,mode:0o700});
  22. for(const directory of ['baseline','site']){
  23. try{await fs.access(path.join(root,directory));throw Error('Refusing to replace existing '+directory);}catch(error){if(error.code!=='ENOENT')throw error;}
  24. }
  25. const stage=await fs.mkdtemp(path.join(root,'.prepare-'));
  26. const baseline=path.join(stage,'baseline'),site=path.join(stage,'site');
  27. await fs.mkdir(baseline,{mode:0o700});
  28. // A missing/invalid manifest fails closed. This release has a verified complete
  29. // manifest; older unmanifested releases need a separately reviewed asset crawl.
  30. const index=await get('index.html'),manifestBytes=await get('deployment-version.json');
  31. const remote=JSON.parse(manifestBytes.toString());
  32. if(remote.application!=='xiaoshu-mobile'||!Array.isArray(remote.files)||!remote.files.length)throw Error('Invalid mobile origin manifest');
  33. const seen=new Set();
  34. for(const file of remote.files){
  35. if(!safe(file.path)||file.path==='deployment-version.json'||seen.has(file.path)||!/^\w{64}$/.test(file.sha256)||!/^[0-9a-f]+$/.test(file.sha256))throw Error('Invalid/duplicate manifest asset');
  36. seen.add(file.path);
  37. }
  38. if(!seen.has('index.html'))throw Error('Origin manifest omits index');
  39. for(let offset=0;offset<remote.files.length;offset+=6){
  40. await Promise.all(remote.files.slice(offset,offset+6).map(async file=>{
  41. const data=file.path==='index.html'?index:await get(file.path);
  42. if(sha(data)!==file.sha256)throw Error('Origin manifest hash mismatch: '+file.path);
  43. const target=path.join(baseline,file.path);await fs.mkdir(path.dirname(target),{recursive:true});await fs.writeFile(target,data,{mode:0o600});
  44. }));
  45. }
  46. await fs.writeFile(path.join(baseline,'deployment-version.json'),manifestBytes,{mode:0o600});
  47. const commit=execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim();
  48. const releaseId='mobile-speed3-'+commit.slice(0,12)+'-'+new Date().toISOString().replace(/[-:.]/g,'');
  49. const build=path.join(repo,'dist/xiaoshu-mobile/browser');
  50. await fs.cp(build,site,{recursive:true});
  51. const manifest={...await manifestFor(site,releaseId,commit),application:'xiaoshu-mobile'};
  52. const sourceManifest=await manifestFor(build,releaseId,commit);
  53. if(JSON.stringify(sourceManifest.files)!==JSON.stringify(manifest.files))throw Error('Build changed while copying');
  54. await fs.writeFile(path.join(site,'deployment-version.json'),JSON.stringify(manifest,null,2)+'\n',{mode:0o600});
  55. // Fetch both mutable pointers again after all payload and backup work.
  56. if(sha(await get('index.html'))!==sha(index)||sha(await get('deployment-version.json'))!==sha(manifestBytes))throw Error('Production origin changed during backup; aborting preparation');
  57. const receipt={preparedAt:new Date().toISOString(),origin,baseline:{releaseId:remote.releaseId,commit:remote.commit,files:remote.files.length,indexHash:sha(index),manifestHash:sha(manifestBytes)},candidate:{releaseId,commit,commitProvisional:true,files:manifest.files.length,indexHash:manifest.files.find(file=>file.path==='index.html').sha256},build};
  58. await fs.rename(baseline,path.join(root,'baseline'));await fs.rename(site,path.join(root,'site'));
  59. await fs.rmdir(stage);
  60. await fs.writeFile(path.join(root,'preparation.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600});
  61. console.log(JSON.stringify(receipt,null,2));