Files
2025-01-04 00:34:03 +01:00

1 line
7.3 KiB
JavaScript

import crypto from"node:crypto";import fs from"node:fs";import path from"node:path";import LocalFileStorage from"./local.mjs";import S3FileStorage from"./s3.mjs";import{HTML_FILE_EXTENSIONS,MEDIA_MIME_TYPES,UPLOADABLE_FILE_EXTENSIONS}from"../../common/constants.mjs";import{getProperty,mergeObject,parseS3URL}from"../../common/utils/helpers.mjs";import unzipper from"unzipper";export default class Files{constructor(t){this.storages={data:new LocalFileStorage("data",paths.data),public:new LocalFileStorage("public",paths.public),s3:S3FileStorage.fromConfig("s3",t.awsConfig)},this.configuration=this._loadConfig()}static STORAGE_CONFIGURATION_FILENAME="storage.json";_loadConfig(){const t=path.join(paths.config,Files.STORAGE_CONFIGURATION_FILENAME);return fs.existsSync(t)?JSON.parse(fs.readFileSync(t,"utf8")):{}}get availableStorageNames(){return Object.entries(this.storages).reduce(((t,e)=>(e[1]&&t.push(e[0]),t)),[])}getClientConfig(t){const e=this.availableStorageNames.filter((e=>{if(!t||t.hasRole("ASSISTANT"))return!0;const{configuration:s}=this.storages[e];return!0!==s[""]?.private})),s=this.storages.s3;return{storages:e,s3:s?{endpoint:s.endpoint,buckets:s.buckets}:null}}static async copyDirectory(t,e,{onProgress:s=null,onError:r=null,ignore:a=[],_root:i=!0}={}){i&&(a=a.map((e=>path.join(t,e)))),await fs.promises.mkdir(e,{recursive:!0});const o=fs.readdirSync(t);for(let i of o){let o=path.join(t,i),n=path.join(e,i);if(!a.includes(o))if(fs.lstatSync(o).isDirectory())await this.copyDirectory(o,n,{onProgress:s,onError:r,ignore:a,_root:!1});else{try{await fs.promises.copyFile(o,n)}catch(t){if(!(r instanceof Function))throw t;r(o,n,t)}s instanceof Function&&s(o,n)}}}static async getDirectorySize(t){if(!fs.existsSync(t))return;let e=0;const s=async t=>{const r=[];for(const a of await fs.promises.readdir(t,{withFileTypes:!0})){const i=path.join(t,a.name);a.isDirectory()?r.push(s(i)):r.push(fs.promises.stat(i).then((t=>e+=t.size)))}await Promise.all(r)};return await s(t),e}static getDirectorySizeSync(t){if(!fs.existsSync(t))return;const e=(t,s)=>{for(const r of fs.readdirSync(t,{withFileTypes:!0})){const a=path.join(t,r.name);r.isDirectory()?s=e(a,s):s+=fs.statSync(a).size}return s};return e(t,0)}static async processArchive(t,e,s){const r=process;let a,i;r.noAsar=!0;try{a=await unzipper.Open.file(t),i=a.files.length;let r=0;for(const t of a.files){t.path=t.path.replace(/\\/g,"/"),r++;const o=Math.round(100*r/i);await e(a,t,r,i),s&&await s(t.path,r,i,o)}}finally{r.noAsar=!1}return i}static async extractArchive(t,e,{onProgress:s,removeRoot:r}={}){return this.processArchive(t,(async(t,s)=>{let a=s.path;r&&(a=a.replace(r,"")),a&&!a.endsWith("/")&&await this._extractEntry(t,e,s,a)}),s)}static async summarizeArchive(t,{manifestPath:e}={}){const s={contents:[],manifest:null};return await this.processArchive(t,(async(t,r)=>{const a=r.path;s.contents.push(a),a===e&&(s.manifest=await this._readEntry(t,r))})),s}static async _readEntry(t,e){return(await e.buffer()).toString()}static _extractEntry(t,e,s,r){const a=path.join(e,r);return fs.mkdirSync(path.dirname(a),{recursive:!0}),new Promise(((t,e)=>{s.stream().pipe(fs.createWriteStream(a)).on("error",e).on("finish",t)}))}static resolveClientPaths(t,e){return e.map((e=>{const s=path.join(e.root,e.directory),r=path.join(s,t),a=fs.existsSync(r);return this.isPathContained(r,s)?{exists:a,clientPath:LocalFileStorage.toClientPath(r,e.root)}:{exists:!1,clientPath:null}}))}static standardizePath(t){return path.normalize(t).split(path.sep).join(path.posix.sep)}static isPathContained(t,e){const s=path.relative(e,t);return!(s&&(s.startsWith("..")||path.isAbsolute(s)))}static writeFileSyncSafe(t,e,s={}){const r=`${t}~`,a=fs.openSync(r,"w");fs.writeFileSync(a,e,s),fs.fsyncSync(a),fs.closeSync(a),fs.renameSync(r,t);const i=fs.openSync(t,"r+");return fs.fsyncSync(i),fs.closeSync(i),i}static copyLargeFile(t,e,{encoding:s="utf8",mode:r=420}={}){return new Promise(((a,i)=>{const o=fs.createReadStream(t,{encoding:s}),n=fs.createWriteStream(e,{encoding:s,mode:r});o.on("error",i),n.on("error",i),n.on("finish",a),o.pipe(n)}))}static getFileHash(t){return new Promise(((e,s)=>{const r=crypto.createHash("sha256"),a=fs.createReadStream(t);a.on("error",s),a.on("end",(()=>e(r.digest("hex")))),a.on("data",(t=>r.update(t)))}))}static async areFilesIdentical(t,e){const[s,r]=await Promise.all([fs.promises.stat(t),fs.promises.stat(e)]);if(s.size!==r.size)return!1;const[a,i]=await Promise.all([this.getFileHash(t),this.getFileHash(e)]);return a===i}static async getAvailableDiskSpace(t){const{bavail:e,bsize:s}=await fs.promises.statfs(t);return e*s}static loadTemplate(t){const e=t.startsWith("templates")?paths.root:paths.data;if(t=path.join(e,t),!this.isPathContained(t,e))throw new Error("You are not allowed to load template files outside of the application or user data locations");if(!HTML_FILE_EXTENSIONS.includes(path.extname(t).slice(1)))throw new Error(`You are only allowed to load template files with an extension in [${HTML_FILE_EXTENSIONS}]`);try{return{html:fs.readFileSync(t,{encoding:"utf8"}),success:!0}}catch(t){return{html:"",success:!1,error:t.message}}}static parseWildcardPath(t){let e="data";const s={wildcard:!0};if(/\.s3\.[^/]+\//.test(t)){e="s3";const{bucket:r,keyPrefix:a}=parseS3URL(t);r&&(s.bucket=r,t=a)}else t.startsWith("icons/")&&(e="public");return{source:e,pattern:t,browseOptions:s}}static async upload(t,e,s={}){if(!e)throw new Error("No file was uploaded");if(!["data","s3"].includes(t))throw new Error("You may not upload to this location");const r=path.extname(e.name).slice(1).toLowerCase(),a=UPLOADABLE_FILE_EXTENSIONS[r];if(s.contentType=a,!a)throw new Error(`File "${e.name}" has a disallowed extension ".${r}" which may not be uploaded. Valid extensions include: ${Object.keys(UPLOADABLE_FILE_EXTENSIONS).join(", ")}`);const i=MEDIA_MIME_TYPES.includes(a),o=["module.json","system.json","world.json","template.json"].includes(e.name.toLowerCase());s.overwrite=i&&!o;return config.files.storages[t].upload(e,s)}static socketListeners(t){t.on("manageFiles",((e,s,r)=>{this._onManageFiles(t,e,s,r)})),t.on("template",((t,e)=>{try{e(this.loadTemplate(t))}catch(t){e({error:t.message})}}))}static _onManageFiles(t,e,s,r){const a=!game.active&&!config.adminPassword||t.session.admin;if(!(t.user?t.user.hasPermission("FILES_BROWSE"):a))return r({error:"You do not have permission to browse the host file system!"});s.isAdmin=a||game.active&&t.user.hasRole("ASSISTANT");const i="user"===e.storage?"data":e.storage,o=config.files.storages[i];if(!o)return r({error:`The requested file storage ${e.storage} does not exist!`});switch(e.action){case"browseFiles":this._handleGetFiles(o,e,s,r);break;case"createDirectory":this._handleCreateDirectory(o,e,s,r);break;case"configurePath":this._handleConfigurePath(o,e,s,r)}}static _handleCreateDirectory(t,e,s,r){t.createDirectory(e.target,s).then((t=>r(t))).catch((t=>r({error:t.message})))}static _handleGetFiles(t,e,s,r){const a=mergeObject(s,{target:e.target});t.getFiles(a).then((t=>r(t))).catch((t=>r({error:t.message})))}static _handleConfigurePath(t,e,s,r){let a=t.configuration;s.bucket&&(a=a[s.bucket]=a[s.bucket]||{});const i=e.target,o=getProperty(a,i)||{};mergeObject(o,{private:s.private,gridSize:s.gridSize}),o.private||o.gridSize?a[i]=o:delete a[i];const n=path.join(paths.config,Files.STORAGE_CONFIGURATION_FILENAME);fs.writeFileSync(n,JSON.stringify(config.files.configuration)),r(o)}}