| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- import fs from 'node:fs';
- import path from 'node:path';
- import crypto from 'node:crypto';
- import {fileURLToPath} from 'node:url';
- export const FILES=Object.freeze([
- 'src/services/workshop-data.js',
- 'lib/vendor/signal-polyfill/v0.2.2/dist/index.js',
- 'src/app.config.js','src/core/kfs/build.js','src/core/library/reports.js',
- 'src/core/opportunity/opportunity.js','src/core/taxonomy/taxonomy.js',
- 'src/pages/view-members.js','src/services/board-service.js'
- ]);
- const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
- const normalized=bytes=>sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
- const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
- const plain=file=>{const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE',file);return s;};
- function safePath(root,relative){
- const target=path.resolve(root,relative);if(!target.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
- let current=root;
- for(const part of relative.split('/')){
- current=path.join(current,part);
- if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH',relative);
- }
- return target;
- }
- function unchanged(file,before){
- if(before===null){if(fs.existsSync(file))fail('CONCURRENT_CHANGE');}
- else if(!fs.existsSync(file)||sha(fs.readFileSync(file))!==before)fail('CONCURRENT_CHANGE');
- }
- function atomicWrite(file,bytes,mode){
- const temp=file+'.ksf-update-'+crypto.randomUUID();let fd;
- try{fd=fs.openSync(temp,'wx',mode);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);}
- finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
- }
- /** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
- export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},dependencies={}){
- if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
- sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
- if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
- if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
- const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
- if(manifest.version!=='ksf.workshop.publish.v1'||manifest.commit!=='d14d02fd358c6c5dedfabc96c53fb29add8bd021'||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
- if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...FILES].sort()))fail('UNEXPECTED_FILE_LIST');
- const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
- if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
- const byName=new Map(manifest.files.map(f=>[f.path,f]));
- const entries=FILES.map(relative=>{
- const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
- if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
- const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
- const stat=fs.existsSync(target)?plain(target):null,before=stat?fs.readFileSync(target):null;
- const same=before!==null&&normalized(before)===normalized(bytes);
- if(before&&!same&&!def.priorNormalizedSha256.includes(normalized(before)))fail('UNKNOWN_TARGET_CONTENT',relative);
- if(!stat&&!def.allowMissing)fail('BASELINE_FILE_MISSING',relative);
- return{relative,target,bytes,before,beforeHash:before===null?null:sha(before),mode:stat?stat.mode&0o777:0o644,same};
- });
- const changes=entries.filter(e=>!e.same);
- const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,commit:manifest.commit,filesChecked:9,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
- if(!apply||!changes.length)return{ok:true,...base,backup:null};
- const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-workshop.lock');
- const lockFd=fs.openSync(lock,'wx',0o600);let backup=null;const done=[],createdDirs=[];
- try{
- backup=fs.mkdtempSync(path.join(path.dirname(targetDir),'ksf-workshop-backup-'));
- const journal={at:new Date().toISOString(),targetDir,commit:manifest.commit,homepageSha256:indexHash,status:'prepared',files:changes.map(e=>({path:e.relative,beforeSha256:e.beforeHash,afterSha256:sha(e.bytes)}))};
- const writeJournal=()=>fs.writeFileSync(path.join(backup,'journal.json'),JSON.stringify(journal,null,2),{mode:0o600});
- // Save every prior file before replacing the first one.
- for(const e of changes)if(e.before!==null){const p=path.join(backup,e.relative);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,e.before,{flag:'wx',mode:0o600});}
- writeJournal();
- for(const e of changes){
- unchanged(index,indexHash);safePath(targetDir,e.relative);unchanged(e.target,e.beforeHash);
- const absent=[];let parent=path.dirname(e.target);while(!fs.existsSync(parent)){absent.push(parent);parent=path.dirname(parent);}
- for(const dir of absent.reverse()){fs.mkdirSync(dir);createdDirs.push(dir);}
- dependencies.beforeWrite?.(e.relative);
- atomicWrite(e.target,e.bytes,e.mode);done.push(e);
- }
- for(const e of entries){plain(e.target);if(normalized(fs.readFileSync(e.target))!==normalized(e.bytes))fail('TARGET_VERIFY_FAILED',e.relative);}
- unchanged(index,indexHash);journal.status='verified';writeJournal();
- return{ok:true,...base,backup,filesystemVerified:true,publicVerificationRequired:true};
- }catch(error){
- let rollbackComplete=true;
- for(const e of [...done].reverse())try{
- // Never overwrite a concurrent publisher's version while rolling back.
- unchanged(e.target,sha(e.bytes));
- if(e.before===null)fs.unlinkSync(e.target);else atomicWrite(e.target,e.before,e.mode);
- }catch{rollbackComplete=false;}
- for(const dir of [...createdDirs].reverse())try{fs.rmdirSync(dir);}catch{}
- if(backup)try{const p=path.join(backup,'journal.json');const j=JSON.parse(fs.readFileSync(p));fs.writeFileSync(p,JSON.stringify({...j,status:'stopped',rollbackComplete},null,2));}catch{}
- error.backup=backup;error.rollbackComplete=rollbackComplete;throw error;
- }finally{fs.closeSync(lockFd);fs.unlinkSync(lock);}
- }
- const self=fileURLToPath(import.meta.url);
- if(process.argv[1]&&path.resolve(process.argv[1])===self){
- const stamp={invocationId:crypto.randomUUID(),at:new Date().toISOString()};
- try{
- const argv=process.argv.slice(2);let targetDir,apply=false;
- for(let i=0;i<argv.length;i++){if(argv[i]==='--apply'&&!apply)apply=true;else if(argv[i]==='--target-dir'&&!targetDir&&argv[i+1]&&!argv[i+1].startsWith('--'))targetDir=argv[++i];else fail('USAGE');}
- if(!targetDir)fail('USAGE');const root=path.resolve(path.dirname(self),'..');
- console.log(JSON.stringify({...stamp,...publishFiles({sourceDir:root,manifestPath:path.join(root,'deploy/workshop-files-20261101.json'),targetDir,apply})},null,2));
- }catch(e){console.error(JSON.stringify({...stamp,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'PUBLISH_STOPPED',...(FILES.includes(e.file)||e.file==='index.html'?{file:e.file}:{}),message:'发布停止;核对源站目录、首页和白名单文件,不自动修改校验基准。'},...(e.backup?{backup:e.backup,rollbackComplete:e.rollbackComplete}:{})},null,2));process.exitCode=1;}
- }
|