publish-workshop-files.mjs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. export const FILES=Object.freeze([
  6. 'src/services/workshop-data.js',
  7. 'lib/vendor/signal-polyfill/v0.2.2/dist/index.js',
  8. 'src/app.config.js','src/core/kfs/build.js','src/core/library/reports.js',
  9. 'src/core/opportunity/opportunity.js','src/core/taxonomy/taxonomy.js',
  10. 'src/pages/view-members.js','src/services/board-service.js'
  11. ]);
  12. const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
  13. const normalized=bytes=>sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
  14. const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
  15. const plain=file=>{const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE',file);return s;};
  16. function safePath(root,relative){
  17. const target=path.resolve(root,relative);if(!target.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
  18. let current=root;
  19. for(const part of relative.split('/')){
  20. current=path.join(current,part);
  21. if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH',relative);
  22. }
  23. return target;
  24. }
  25. function unchanged(file,before){
  26. if(before===null){if(fs.existsSync(file))fail('CONCURRENT_CHANGE');}
  27. else if(!fs.existsSync(file)||sha(fs.readFileSync(file))!==before)fail('CONCURRENT_CHANGE');
  28. }
  29. function atomicWrite(file,bytes,mode){
  30. const temp=file+'.ksf-update-'+crypto.randomUUID();let fd;
  31. try{fd=fs.openSync(temp,'wx',mode);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);}
  32. finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
  33. }
  34. /** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
  35. export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},dependencies={}){
  36. if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
  37. sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
  38. if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
  39. if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
  40. const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
  41. if(manifest.version!=='ksf.workshop.publish.v1'||manifest.commit!=='d14d02fd358c6c5dedfabc96c53fb29add8bd021'||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
  42. if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...FILES].sort()))fail('UNEXPECTED_FILE_LIST');
  43. const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
  44. if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
  45. const byName=new Map(manifest.files.map(f=>[f.path,f]));
  46. const entries=FILES.map(relative=>{
  47. const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
  48. 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');
  49. const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
  50. const stat=fs.existsSync(target)?plain(target):null,before=stat?fs.readFileSync(target):null;
  51. const same=before!==null&&normalized(before)===normalized(bytes);
  52. if(before&&!same&&!def.priorNormalizedSha256.includes(normalized(before)))fail('UNKNOWN_TARGET_CONTENT',relative);
  53. if(!stat&&!def.allowMissing)fail('BASELINE_FILE_MISSING',relative);
  54. return{relative,target,bytes,before,beforeHash:before===null?null:sha(before),mode:stat?stat.mode&0o777:0o644,same};
  55. });
  56. const changes=entries.filter(e=>!e.same);
  57. 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};
  58. if(!apply||!changes.length)return{ok:true,...base,backup:null};
  59. const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-workshop.lock');
  60. const lockFd=fs.openSync(lock,'wx',0o600);let backup=null;const done=[],createdDirs=[];
  61. try{
  62. backup=fs.mkdtempSync(path.join(path.dirname(targetDir),'ksf-workshop-backup-'));
  63. 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)}))};
  64. const writeJournal=()=>fs.writeFileSync(path.join(backup,'journal.json'),JSON.stringify(journal,null,2),{mode:0o600});
  65. // Save every prior file before replacing the first one.
  66. 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});}
  67. writeJournal();
  68. for(const e of changes){
  69. unchanged(index,indexHash);safePath(targetDir,e.relative);unchanged(e.target,e.beforeHash);
  70. const absent=[];let parent=path.dirname(e.target);while(!fs.existsSync(parent)){absent.push(parent);parent=path.dirname(parent);}
  71. for(const dir of absent.reverse()){fs.mkdirSync(dir);createdDirs.push(dir);}
  72. dependencies.beforeWrite?.(e.relative);
  73. atomicWrite(e.target,e.bytes,e.mode);done.push(e);
  74. }
  75. for(const e of entries){plain(e.target);if(normalized(fs.readFileSync(e.target))!==normalized(e.bytes))fail('TARGET_VERIFY_FAILED',e.relative);}
  76. unchanged(index,indexHash);journal.status='verified';writeJournal();
  77. return{ok:true,...base,backup,filesystemVerified:true,publicVerificationRequired:true};
  78. }catch(error){
  79. let rollbackComplete=true;
  80. for(const e of [...done].reverse())try{
  81. // Never overwrite a concurrent publisher's version while rolling back.
  82. unchanged(e.target,sha(e.bytes));
  83. if(e.before===null)fs.unlinkSync(e.target);else atomicWrite(e.target,e.before,e.mode);
  84. }catch{rollbackComplete=false;}
  85. for(const dir of [...createdDirs].reverse())try{fs.rmdirSync(dir);}catch{}
  86. 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{}
  87. error.backup=backup;error.rollbackComplete=rollbackComplete;throw error;
  88. }finally{fs.closeSync(lockFd);fs.unlinkSync(lock);}
  89. }
  90. const self=fileURLToPath(import.meta.url);
  91. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  92. const stamp={invocationId:crypto.randomUUID(),at:new Date().toISOString()};
  93. try{
  94. const argv=process.argv.slice(2);let targetDir,apply=false;
  95. 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');}
  96. if(!targetDir)fail('USAGE');const root=path.resolve(path.dirname(self),'..');
  97. console.log(JSON.stringify({...stamp,...publishFiles({sourceDir:root,manifestPath:path.join(root,'deploy/workshop-files-20261101.json'),targetDir,apply})},null,2));
  98. }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;}
  99. }