Files
Foundry-VTT-Docker/resources/app/dist/packages/package-backups.mjs
2025-01-04 00:34:03 +01:00

1 line
13 KiB
JavaScript

import fs from"node:fs";import path from"node:path";import archiver from"archiver";import Files from"../files/files.mjs";import DataModel from"../../common/abstract/data.mjs";import*as fields from"../../common/data/fields.mjs";import{PackageCompatibility}from"../../common/packages/module.mjs";import BasePackage,{PackageRelationships}from"../../common/packages/base-package.mjs";import ProgressEmitter from"../components/progress-emitter.mjs";import{formatFileSize}from"../../common/utils/helpers.mjs";export default class PackageBackups{static BACKUP_TYPES=["module","system","world","snapshot"];async createBackups(e,{level:t=6,onProgress:a}={}){const s=[];for(const{packageId:i,type:n,note:r,snapshotId:o,id:c}of e)s.push(await this.createBackup(i,n,{level:t,note:r,snapshotId:o,id:c,onProgress:a}));return s}createBackup(e,t,{level:a=6,note:s="",onProgress:i,snapshotId:n,id:r}={}){const{logger:o,packages:c}=global,{ACTIONS:l,STEPS:p}=CONST.SETUP_PACKAGE_PROGRESS,d=c.PACKAGE_TYPE_MAPPING[t].get(e);if(!d)throw new Error(`Cannot create a backup of ${t} '${e}' because it is not installed.`);const{backups:g,install:S}=this.#e(e,t);return fs.mkdirSync(g,{recursive:!0}),o.info(`Writing backup for ${t} '${e}'.`),new Promise((async(c,u)=>{const P=new Date;r??=`${t}.${e}.${P.toDateInputString()}.${P.valueOf()}`;const f=path.join(g,`${r}.bak`),h=path.join(g,`${r}.json`),k=fs.createWriteStream(f),m=archiver("zip",{zlib:{level:a}}),E=await Files.getDirectorySize(S),b=new ProgressEmitter(l.CREATE_BACKUP,p.ARCHIVE,E,{packageId:e,type:t,id:r,title:d.title,message:"SETUP.BACKUPS.BackingUp"},{onProgress:i});let A;m.on("warning",(e=>o.warn(e))),m.on("error",(e=>{A=e,b.error(e),u(e)})),m.on("progress",(({fs:e})=>b.emit(e.processedBytes))),k.on("close",(()=>{if(A)return void(fs.existsSync(f)&&fs.unlinkSync(f));const a=m.pointer(),i=`Wrote backup for ${t} '${e}' to '${f}'. Wrote ${a} bytes.`,o=this.#t(d,h,{id:r,size:a,note:s,snapshotId:n,originalSize:E,createdAt:P.valueOf()});b.complete({log:i,context:{backupData:o}}),c(o)})),m.pipe(k),m.directory(S,!1),m.finalize()}))}async restoreBackups(e,{onProgress:t}={}){for(const a of e)await this.restoreBackup(a,{onProgress:t})}async restoreBackup(e,{onProgress:t}={}){const{paths:a}=global,{id:s,packageId:i,type:n}=e,{ACTIONS:r}=CONST.SETUP_PACKAGE_PROGRESS,o=path.join(a.backups,"tmp",s);fs.mkdirSync(o,{recursive:!0});const c=new ProgressEmitter(r.RESTORE_BACKUP,null,1,{id:s});try{const a=await this.#a(e,o,{onProgress:t});await this.#s(e,o,a,{onProgress:t}),this.#i(i,n),c.complete({context:{backupData:e}})}catch(e){c.error(e)}finally{fs.rmSync(o,{recursive:!0,maxRetries:10})}}async deleteBackups(e){const{ACTIONS:t,STEPS:a}=CONST.SETUP_PACKAGE_PROGRESS,s=new ProgressEmitter(t.DELETE_BACKUP,a.DELETE,e.length,{id:t.DELETE_BACKUP,message:"SETUP.BACKUPS.DeletingBackup"});try{let t=0;for(const a of e)await this.deleteBackup(a),s.emit(++t);s.complete()}catch(e){s.error(e)}}async deleteBackup({id:e,packageId:t,type:a}){const{logger:s}=global,{backups:i}=this.#e(t,a);await fs.promises.unlink(path.join(i,`${e}.json`)),await fs.promises.unlink(path.join(i,`${e}.bak`)),s.info(`Deleted backup ${e}`)}listBackups(){const{paths:e,logger:t}=global,a=Object.keys(packages.PACKAGE_TYPE_MAPPING).reduce(((a,s)=>{a[s]={};const i=packages.PACKAGE_TYPE_MAPPING[s],n=path.join(e.backups,i.collection);if(!fs.existsSync(n))return a;for(const e of fs.readdirSync(n,{withFileTypes:!0})){if(!e.isDirectory())continue;const i=e.name;try{BasePackage.validateId(i)}catch(e){t.warn(`Invalid backup directory '${i}' found when listing backups: ${e.message}`);continue}a[s][i]=this.getBackups(i,s)}return a}),{});return a.snapshots=this.getSnapshots(),a}getBackups(e,t){const{logger:a}=global,{backups:s}=this.#e(e,t);if(!fs.existsSync(s))return[];const i=[];for(const e of fs.readdirSync(s))if(".json"===path.extname(e))try{const t=JSON.parse(fs.readFileSync(path.join(s,e),{encoding:"utf8"}));i.push(new BackupData(t))}catch(t){a.warn(`Found JSON file '${path.join(s,e)}' that was not a valid backup manifest: ${t.message}`)}return i.sort(((e,t)=>t.createdAt-e.createdAt)),i}async#a(e,t,{onProgress:a,emitCompletion:s}={}){const{logger:i}=global,{ACTIONS:n,STEPS:r}=CONST.SETUP_PACKAGE_PROGRESS,{id:o,packageId:c,type:l,title:p}=e,{backups:d}=this.#e(c,l),g=path.join(d,`${o}.bak`);if(!fs.existsSync(g))throw new Error(`Backup for ${l} package '${c}' with ID '${o}' did not exist.`);let S,u;return i.info(`Extracting backup '${o}' for ${l} package '${c}'.`),await Files.extractArchive(g,t,{onProgress:(e,t,s)=>{S=s,u||(u=new ProgressEmitter(n.RESTORE_BACKUP,r.EXTRACT,s,{packageId:c,type:l,id:o,title:p,message:"SETUP.BACKUPS.Extracting"},{onProgress:a})),u.emit(t)}}),s&&u.complete(),S}async#s(e,t,a,{onProgress:s,emitCompletion:i}={}){const{logger:n}=global,{ACTIONS:r,STEPS:o}=CONST.SETUP_PACKAGE_PROGRESS,{id:c,packageId:l,type:p,title:d}=e,{install:g}=this.#e(l,p);n.info(`Deleting existing installation at '${g}'.`),fs.rmSync(g,{force:!0,recursive:!0,maxRetries:10});let S=0;const u=new ProgressEmitter(r.RESTORE_BACKUP,o.INSTALL,a,{packageId:l,type:p,id:c,title:d,message:"SETUP.BACKUPS.Copying"},{onProgress:s});n.info(`Copying backup files '${t}' -> '${g}'`),await Files.copyDirectory(t,g,{onProgress:()=>u.emit(++S)}),i&&u.complete()}#t(e,t,a={}){const s=new BackupData({...e.toObject(),...a,packageId:e.id,type:e.constructor.type});return fs.writeFileSync(t,JSON.stringify(s.toObject(),null,2),{encoding:"utf8"}),s}async createSnapshot({level:e=6,note:t="",id:a,onProgress:s}={}){const{logger:i,packages:n,paths:r}=global,{ACTIONS:o,STEPS:c}=CONST.SETUP_PACKAGE_PROGRESS,l=new Date;a??=`snapshot.${l.toDateInputString()}.${l.valueOf()}`;const p=path.join(r.backups,"snapshots"),d=path.join(p,`${a}.json`),g=new ProgressEmitter(o.CREATE_SNAPSHOT,null,1,{id:a,type:"snapshot"}),S=[];let u=0,P=0;fs.mkdirSync(p,{recursive:!0}),i.info(`Taking snapshot '${a}'.`);try{for(const[t,r]of Object.entries(n.PACKAGE_TYPE_MAPPING)){if(i.info(`Snapshotting ${t}s.`),!r.packages.size)continue;const n=c[`SNAPSHOT_${t.toUpperCase()}S`];g.nextStep(n,r.packages.size,{message:`SETUP.BACKUPS.Snapshotting.${t}`}),g.emit(0);let o=0;for(const i of r.packages){const n=await this.createBackup(i.id,t,{level:e,onProgress:s,snapshotId:a});g.emit(++o),u+=n.size,P+=n.originalSize,S.push(n)}}}catch(e){throw g.error(e),await this.deleteBackups(S),e}const f=`Wrote ${formatFileSize(u,{decimalPlaces:0})} snapshot '${a}'.`;let h;return S.length&&(h=this.#n(d,{id:a,note:t,originalSize:P,createdAt:l.valueOf(),size:u,backups:S.map((e=>e.id))})),g.complete({log:f,context:{snapshotData:h}}),h}async checkCreateSnapshotDiskSpace(){const{packages:e,paths:t}=global;fs.mkdirSync(t.backups,{recursive:!0});let a=0;for(const t of Object.values(e.PACKAGE_TYPE_MAPPING))a+=await Files.getDirectorySize(t.baseDir);return{required:a,available:await Files.getAvailableDiskSpace(t.backups)}}async restoreSnapshot(e,{onProgress:t}={}){const{paths:a,packages:s}=global,{id:i}=e,n=new ProgressEmitter(CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_SNAPSHOT,null,1,{id:i}),r=path.join(a.backups,"tmp.extract",i),o=path.join(a.backups,"tmp.original",i);fs.mkdirSync(r,{recursive:!0}),fs.mkdirSync(o,{recursive:!0});try{const a=await this.#r(e,r,{onProgress:t});await this.#o(i,a,o,{onProgress:t})}finally{fs.rmSync(r,{recursive:!0,maxRetries:10}),fs.rmSync(o,{recursive:!0,maxRetries:10})}for(const e of Object.values(s.PACKAGE_TYPE_MAPPING))e.resetPackages();n.complete()}async checkRestoreSnapshotDiskSpace({originalSize:e}){const{paths:t}=global;return{required:e,available:await Files.getAvailableDiskSpace(t.backups)}}async#r({backups:e,id:t},a,{onProgress:s}={}){e instanceof Set||(e=new Set(e));const{ACTIONS:i,STEPS:n}=CONST.SETUP_PACKAGE_PROGRESS,r=[],o=new ProgressEmitter(i.RESTORE_SNAPSHOT,n.EXTRACT,e.size,{id:t,type:"snapshot",message:"SETUP.BACKUPS.ExtractingPl"});o.emit(0);for(const t of e)try{const e=this.#c(t);if(!e)throw new Error("Failed to read backup manifest.");const i=path.join(a,t);fs.mkdirSync(i,{recursive:!0});const n=await this.#a(e,i,{onProgress:s,emitCompletion:!0});r.push({backupData:e,totalFiles:n,extractDir:i}),o.emit(r.length)}catch(e){throw e.message=`Failed to restore backup ID '${t}': ${e.message}`,logger.error(e),o.error(e),e}return r}async#o(e,t,a,{onProgress:s}={}){let i;const n=[],{ACTIONS:r,STEPS:o}=CONST.SETUP_PACKAGE_PROGRESS,c=new ProgressEmitter(r.RESTORE_SNAPSHOT,o.INSTALL,t.length,{id:e,type:"snapshot",message:"SETUP.BACKUPS.CopyingPl"});c.emit(0);for(const{backupData:e,totalFiles:r,extractDir:o}of t)try{const{packageId:t,type:i}=e,{install:l}=this.#e(t,i);if(fs.existsSync(l)){const e=path.join(a,i,t);fs.mkdirSync(path.dirname(e),{recursive:!0}),fs.renameSync(l,e)}await this.#s(e,o,r,{onProgress:s,emitCompletion:!0}),n.push(e),c.emit(n.length)}catch(t){t.message=`Failed to restore backup ID '${e.id}': ${t.message}`,logger.error(t),c.error(t),i=t;break}if(i){for(const e of n){const{packageId:t,type:s}=e,i=path.join(a,s,t);if(!fs.existsSync(i))continue;const{install:n}=this.#e(t,s);fs.rmSync(n,{force:!0,recursive:!0,maxRetries:10}),fs.renameSync(i,n)}throw i}}async deleteSnapshots(e,{onProgress:t}={}){const{ACTIONS:a,STEPS:s}=CONST.SETUP_PACKAGE_PROGRESS,i=new ProgressEmitter(a.DELETE_SNAPSHOT,s.DELETE,e.length,{id:a.DELETE_SNAPSHOT,type:"snapshot",message:"SETUP.BACKUPS.DeletingSnapshot"},{onProgress:t});let n=0;for(const a of e)await this.deleteSnapshot(a,{onProgress:t}),i.emit(++n);i.complete()}async deleteSnapshot({id:e,backups:t,generation:a,build:s},{onProgress:i}={}){t instanceof Set||(t=new Set(t));const{paths:n,logger:r}=global,{ACTIONS:o,STEPS:c}=CONST.SETUP_PACKAGE_PROGRESS,l=path.join(n.backups,"snapshots");fs.unlinkSync(path.join(l,`${e}.json`));const p=new ProgressEmitter(o.DELETE_BACKUP,c.DELETE,t.size,{id:e,type:"snapshot",message:"SETUP.BACKUPS.DeletingBackup"},{onProgress:i});let d=0;for(const e of t){const t=this.#c(e);if(!t)throw new Error("Failed to read backup manifest.");await this.deleteBackup(t),p.emit(++d)}p.complete(),r.info(`Deleted snapshot ${e}`)}getSnapshots(){const{logger:e,paths:t}=global,a=path.join(t.backups,"snapshots");if(!fs.existsSync(a))return{};const s=[];for(const t of fs.readdirSync(a))if(".json"===path.extname(t))try{const e=JSON.parse(fs.readFileSync(path.join(a,t),{encoding:"utf8"})),i=new SnapshotData(e);s.push(i)}catch(s){e.warn(`Found JSON file '${path.join(a,t)}' that was not a valid snapshot manifest: ${s.message}`)}return s.sort(((e,t)=>t.createdAt-e.createdAt)),Object.fromEntries(s.map((e=>[e.id,e])))}#n(e,t={}){const{config:a}=global,{generation:s,build:i}=a.release,n=new SnapshotData({type:"snapshot",generation:s,build:i,...t});return fs.writeFileSync(e,JSON.stringify(n.toObject(),null,2),{encoding:"utf8"}),n}#c(e){const[t,a]=e.split(".");if(!t||!a)return;const{backups:s}=this.#e(a,t),i=JSON.parse(fs.readFileSync(path.join(s,`${e}.json`),{encoding:"utf8"}));return new BackupData(i)}#e(e,t){const{packages:a,paths:s}=global,i=a.PACKAGE_TYPE_MAPPING[t];return{install:path.join(i.baseDir,e),backups:path.join(s.backups,i.collection,e)}}#i(e,t){const{packages:a}=global,s=a.PACKAGE_TYPE_MAPPING[t];"system"===t&&a.World.resetPackages();const i=path.join(s.baseDir,e,s.manifestFile),n=s.fromManifestPath(i);s.packages.set(e,n);for(const e of Object.values(a.PACKAGE_TYPE_MAPPING))e.reevaluateAvailabilities()}}class BaseBackupData extends DataModel{static defineSchema(){return{type:new fields.StringField({required:!0,blank:!1,choices:PackageBackups.BACKUP_TYPES}),createdAt:new fields.NumberField({required:!0,integer:!0,positive:!0}),size:new fields.NumberField({required:!0,integer:!0,positive:!0}),originalSize:new fields.NumberField({required:!0,integer:!0,positive:!0}),note:new fields.StringField({required:!0})}}}class BackupData extends BaseBackupData{static defineSchema(){return Object.assign({},super.defineSchema(),{id:new fields.StringField({required:!0,blank:!1,validate:BackupData.validateBackupId}),packageId:new fields.StringField({required:!0,blank:!1,validate:BasePackage.validateId}),snapshotId:new fields.StringField({required:!0,blank:!1,nullable:!0,validate:SnapshotData.validateSnapshotId}),title:new fields.StringField({required:!0,blank:!1}),description:new fields.StringField({required:!0}),version:new fields.StringField({required:!0,blank:!1,initial:"0"}),compatibility:new PackageCompatibility,relationships:new PackageRelationships,system:new fields.StringField({required:!0,blank:!1,nullable:!0})})}static validateBackupId(e){const{packages:t}=global,[,a,s]=e.match(/([a-z]+)\.([A-Z\d-_]+)\.\d{4}-\d\d-\d\d\.\d+/i)??[];if(!(a in t.PACKAGE_TYPE_MAPPING)||!s)throw new Error("Malformed backup identifier.");BasePackage.validateId(s)}}class SnapshotData extends BaseBackupData{static defineSchema(){return Object.assign({},super.defineSchema(),{id:new fields.StringField({required:!0,blank:!1,validate:SnapshotData.validateSnapshotId}),generation:new fields.NumberField({required:!0,nullable:!1,integer:!0,min:1}),build:new fields.NumberField({required:!0,nullable:!1,integer:!0}),backups:new fields.SetField(new fields.StringField({required:!0,blank:!1,validate:BackupData.validateBackupId}))})}static validateSnapshotId(e){if(!/snapshot\.\d{4}-\d\d-\d\d\.\d+/.test(e))throw new Error("Malformed snapshot identifier.")}}