// Read-only origin backup and local web payload preparation. Never uploads. import fs from 'node:fs/promises'; import path from 'node:path'; import {createHash} from 'node:crypto'; import {execFileSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; import {manifestFor} from './release-admin-web.mjs'; const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); const origin='https://xiaoshu-h5.obs.cn-south-1.myhuaweicloud.com/'; const root=process.argv[2]; if(!root||!path.isAbsolute(root))throw Error('Provide an absolute private mobile release directory'); const sha=bytes=>createHash('sha256').update(bytes).digest('hex'); 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!=='..'); async function get(name){ if(!safe(name))throw Error('Unsafe origin asset path'); const url=new URL(name,origin);url.searchParams.set('backup',Date.now().toString()); const response=await fetch(url,{headers:{'Cache-Control':'no-cache'},signal:AbortSignal.timeout(30000),redirect:'error'}); if(!response.ok)throw Error(`Origin HTTP ${response.status}: ${name}`); return Buffer.from(await response.arrayBuffer()); } await fs.mkdir(root,{recursive:true,mode:0o700}); for(const directory of ['baseline','site']){ try{await fs.access(path.join(root,directory));throw Error('Refusing to replace existing '+directory);}catch(error){if(error.code!=='ENOENT')throw error;} } const stage=await fs.mkdtemp(path.join(root,'.prepare-')); const baseline=path.join(stage,'baseline'),site=path.join(stage,'site'); await fs.mkdir(baseline,{mode:0o700}); // A missing/invalid manifest fails closed. This release has a verified complete // manifest; older unmanifested releases need a separately reviewed asset crawl. const index=await get('index.html'),manifestBytes=await get('deployment-version.json'); const remote=JSON.parse(manifestBytes.toString()); if(remote.application!=='xiaoshu-mobile'||!Array.isArray(remote.files)||!remote.files.length)throw Error('Invalid mobile origin manifest'); const seen=new Set(); for(const file of remote.files){ 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'); seen.add(file.path); } if(!seen.has('index.html'))throw Error('Origin manifest omits index'); for(let offset=0;offset{ const data=file.path==='index.html'?index:await get(file.path); if(sha(data)!==file.sha256)throw Error('Origin manifest hash mismatch: '+file.path); const target=path.join(baseline,file.path);await fs.mkdir(path.dirname(target),{recursive:true});await fs.writeFile(target,data,{mode:0o600}); })); } await fs.writeFile(path.join(baseline,'deployment-version.json'),manifestBytes,{mode:0o600}); const commit=execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim(); const releaseId='mobile-speed3-'+commit.slice(0,12)+'-'+new Date().toISOString().replace(/[-:.]/g,''); const build=path.join(repo,'dist/xiaoshu-mobile/browser'); await fs.cp(build,site,{recursive:true}); const manifest={...await manifestFor(site,releaseId,commit),application:'xiaoshu-mobile'}; const sourceManifest=await manifestFor(build,releaseId,commit); if(JSON.stringify(sourceManifest.files)!==JSON.stringify(manifest.files))throw Error('Build changed while copying'); await fs.writeFile(path.join(site,'deployment-version.json'),JSON.stringify(manifest,null,2)+'\n',{mode:0o600}); // Fetch both mutable pointers again after all payload and backup work. 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'); 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}; await fs.rename(baseline,path.join(root,'baseline'));await fs.rename(site,path.join(root,'site')); await fs.rmdir(stage); await fs.writeFile(path.join(root,'preparation.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600}); console.log(JSON.stringify(receipt,null,2));