bend_puzzle/server/world-backup.js

52 lines
5.2 KiB
JavaScript
Raw Normal View History

2026-08-01 22:31:04 +09:00
'use strict';
const fs=require('fs');
const fsp=fs.promises;
const path=require('path');
const crypto=require('crypto');
const PLAYER_FILE=/^[a-f0-9]{16,64}\.json$/i;
const BOARD_FILE=/^B(?:0|[1-9][0-9]*)\.[0-9]+\.json$/;
async function hashFile(file){const hash=crypto.createHash('sha256'),stream=fs.createReadStream(file);for await(const chunk of stream)hash.update(chunk);return hash.digest('hex')}
async function copyVerified(source,destination,{link=false}={}){await fsp.mkdir(path.dirname(destination),{recursive:true,mode:0o700});if(link)try{await fsp.link(source,destination);return await hashFile(destination)}catch(error){if(!['EXDEV','EPERM','EACCES','EEXIST'].includes(error?.code))throw error}await fsp.copyFile(source,destination);await fsp.chmod(destination,0o600).catch(()=>{});return hashFile(destination)}
function backupName(date=new Date()){return date.toISOString().replace(/[:.]/g,'-')}
function safeBackupName(value){const name=String(value||'');if(!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(name))throw new Error('Invalid LinkField backup name');return name}
async function createWorldBackup(dataDirValue,backupRootValue,{retain=7,appVersion='unknown'}={}){
const dataDir=path.resolve(dataDirValue),backupRoot=path.resolve(backupRootValue),name=backupName(),stage=path.join(backupRoot,`.stage-${name}-${process.pid}`),destination=path.join(backupRoot,name);
await fsp.mkdir(backupRoot,{recursive:true,mode:0o700});await fsp.rm(stage,{recursive:true,force:true});await fsp.mkdir(path.join(stage,'data','shared-world.boards'),{recursive:true,mode:0o700});
try{
const worldFile=path.join(dataDir,'shared-world.json'),world=JSON.parse(await fsp.readFile(worldFile,'utf8')),files=['shared-world.json'];
if(!world||!Number.isSafeInteger(world.revision)||!world.boardVersions||typeof world.boardVersions!=='object')throw new Error('Shared world is not valid enough to back up');
for(const[id,revision]of Object.entries(world.boardVersions)){const name=`${id}.${revision}.json`;if(!BOARD_FILE.test(name))throw new Error(`Invalid board reference in shared world: ${name}`);files.push(path.join('shared-world.boards',name))}
const rootNames=await fsp.readdir(dataDir);for(const file of rootNames)if(PLAYER_FILE.test(file))files.push(file);
const hashes={};for(const relative of files){const source=path.join(dataDir,relative),target=path.join(stage,'data',relative);hashes[relative.replace(/\\/g,'/')]=await copyVerified(source,target,{link:relative.startsWith(`shared-world.boards${path.sep}`)})}
const manifest={app:'LinkField',appVersion,createdAt:new Date().toISOString(),worldRevision:world.revision,fileCount:files.length,hashes};
await fsp.writeFile(path.join(stage,'manifest.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o600});
await fsp.rename(stage,destination);
const names=(await fsp.readdir(backupRoot,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();
for(const retired of names.slice(Math.max(1,retain)))await fsp.rm(path.join(backupRoot,retired),{recursive:true,force:true});
return{destination,name,manifest};
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
}
async function verifyWorldBackup(backupRootValue,nameValue){
const backupRoot=path.resolve(backupRootValue),name=safeBackupName(nameValue),directory=path.join(backupRoot,name),manifest=JSON.parse(await fsp.readFile(path.join(directory,'manifest.json'),'utf8'));
if(manifest?.app!=='LinkField'||!manifest.hashes||typeof manifest.hashes!=='object')throw new Error('Invalid LinkField backup manifest');
for(const[relative,expected]of Object.entries(manifest.hashes)){if(relative.includes('..')||path.isAbsolute(relative))throw new Error('Unsafe backup path');const actual=await hashFile(path.join(directory,'data',relative));if(actual!==expected)throw new Error(`Backup checksum mismatch: ${relative}`)}
return{directory,manifest,name};
}
async function restoreWorldBackup(dataDirValue,backupRootValue,nameValue){
const dataDir=path.resolve(dataDirValue),verified=await verifyWorldBackup(backupRootValue,nameValue),parent=path.dirname(dataDir),stamp=Date.now(),stage=path.join(parent,`.${path.basename(dataDir)}.restore-${stamp}`),previous=path.join(parent,`${path.basename(dataDir)}.pre-restore-${stamp}`);
await fsp.rm(stage,{recursive:true,force:true});await copyDirectory(path.join(verified.directory,'data'),stage);
if(fs.existsSync(dataDir))await fsp.rename(dataDir,previous);
try{await fsp.rename(stage,dataDir)}catch(error){if(fs.existsSync(previous)&&!fs.existsSync(dataDir))await fsp.rename(previous,dataDir);throw error}
return{restored:verified.name,previous};
}
async function copyDirectory(source,destination){const stat=await fsp.lstat(source);if(stat.isSymbolicLink())throw new Error('Backup contains a symbolic link');if(stat.isDirectory()){await fsp.mkdir(destination,{recursive:true,mode:0o700});for(const entry of await fsp.readdir(source))await copyDirectory(path.join(source,entry),path.join(destination,entry));return}if(stat.isFile())await copyVerified(source,destination)}
module.exports=Object.freeze({backupName,createWorldBackup,verifyWorldBackup,restoreWorldBackup});