UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

623 lines (547 loc) 20.1 kB
/** * Provides API endpoints for file management for each game * * @module files_management * @copyright machina eX 2026 * @license MIT */ const express = require("express") const multer = require("multer") const { Glob } = require("glob") const extract = require("extract-zip") const path = require("path") const fs = require("node:fs/promises") const fi = require("./file.js") class FilesManagement { constructor(game) { /** @type {import('./game').Game} */ this.game = game this.log = log.getContextLog(`${this.game.name} files`) const storage = multer.diskStorage({ destination: (req, file, callback) => { if (req.query.path) { const file_path = path.join(this.game.files, req.query.path) fi.mkdir(file_path) callback(null, file_path) } else { callback(null, this.game.files) } }, filename: (req, file, cb) => { cb(null, file.originalname) } }) const upload = multer({ storage, limits: { fileSize: 104857600 // 100MB } }) this.router = express.Router() // list files this.router.get("/", (req, res) => { this.getFiles(req.query) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) // upload files this.router.post("/", (req, res) => { new Promise((resolve, reject) => { upload.array("files")(req, res, (err) => { if (err) reject(err) else resolve() }) }) .then(() => this.filesUploaded(req.files, req.query)) .then((result) => { if (result) { res.status(201).send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) // download file this.router.get("/*", (req, res) => { this.getFile(req.params[0], req.query) .then((result) => { if (result) { res.sendFile( result, { dotfiles: "deny" }, (err) => { if (err) { this.errorHandling(err, res) } } ) } }) .catch((err) => { this.errorHandling(err, res) }) }) // delete file or directory this.router.delete("/*", (req, res) => { this.delete(req.params[0]) .then(() => { res.status(204).send() }) .catch((err) => { this.errorHandling(err, res) }) }) // copy file or directory this.router.post("/*/_copy", (req, res) => { this.copy(req.params[0], req.body.destination) .then((result) => { res.status(201).send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) // move file or directory this.router.post("/*/_move", (req, res) => { this.move(req.params[0], req.body.destination) .then((result) => { res.send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) // publish file or directory this.router.post("/*/_publish", (req, res) => { this.publish(req.params[0]) .then((result) => { res.status(200).send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) // unpublish file or directory this.router.post("/*/_unpublish", (req, res) => { this.unpublish(req.params[0]) .then((result) => { res.status(200).send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) // create directory this.router.post("/*", (req, res) => { this.createDirectory(req.params[0]) .then((result) => { res.status(201).send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) } /** * Called once all files have been uploaded. If query.extract is true, * it will extract zip files to a directory with the same name * as the zip file. * * @param {Object} files - an object containing the files to upload * @param {Object} query - an object containing the query parameters * @returns {Promise<Object>} - an object containing the uploaded files */ async filesUploaded(files, query) { const filenames = Object.keys(files) .slice(0, 5) .map((key) => files[key].originalname) .join(", ") this.log.info( `${files.length} files uploaded: ${filenames}${files.length > 5 ? " ..." : ""}.` ) if (query.extract === "true") { for (const file of files) { if (!file.path.endsWith(".zip")) continue const archiveName = path.parse(file.path).name let extractTo = path.resolve(this.game.files, archiveName) if (query.path) { extractTo = path.resolve( this.game.files, query.path, `${archiveName}` ) } this.log.debug(`Extracting ${file.path} to ${extractTo}`) await extract(file.path, { dir: extractTo }) fs.rm(file.path) } } return files } /** * Retrieves a list of files and directories in the game files directory. * * If 'path' is provided in the query, it will only return files and directories in that path. * If 'path' is not provided, it will return all files and directories in the game files directory. * * Will check for symlinks in the public files directory and mark the linked files as public. * * @param {Object} query - query object containing 'path' property * @returns {Promise<Array<Object>>} - array of file objects with name, path, absolute_path, type, size, public, modified_at and created_at properties * @throws {adaptor.NotFoundError} if 'path' does not exist in the game files directory */ async getFiles(query) { const globOptions = { stat: true, withFileTypes: true } const publicGlobOptions = { stat: true, withFileTypes: true, follow: true } const toGlobPath = (p) => p.replaceAll(path.sep, path.posix.sep) const [files, publicFiles] = await Promise.all([ query.path ? new Glob( toGlobPath(path.join(this.game.files, query.path)) + "/**/*", globOptions ).walk() : new Glob(toGlobPath(this.game.files) + "/**/*", globOptions).walk(), query.path ? new Glob( toGlobPath(path.join(this.game.public, query.path)) + "/**/*", publicGlobOptions ).walk() : new Glob( toGlobPath(this.game.public) + "/**/*", publicGlobOptions ).walk() ]) const publicSymlinkPaths = new Set() const publiclyAccessiblePaths = new Set() publicFiles.forEach((f) => { const relative = path .relative(this.game.public, f.fullpath()) .replaceAll(path.sep, path.posix.sep) if (f.isSymbolicLink()) { publicSymlinkPaths.add(relative) } else { publiclyAccessiblePaths.add(relative) } }) const result = files.map((file) => { const relative = path .relative(this.game.files, file.fullpath()) .replaceAll(path.sep, path.posix.sep) const isPublished = publicSymlinkPaths.has(relative) const isAccessible = !isPublished && publiclyAccessiblePaths.has(relative) return { name: file.name, path: relative, absolute_path: file.fullpath(), type: file.isDirectory() ? "directory" : "file", size: file.lstatSync()?.size ?? null, public: isPublished, publicly_accessible: isAccessible, public_url: isPublished || isAccessible ? this.game.getFileURL(relative) : null, modified_at: file.mtime, created_at: file.ctime } }) this.log( `Listing ${result.length} files and directories in ${ query.path ? path.join(this.game.files, query.path) : this.game.files }.` ) return result } /** * Sends a file to the client. * * @param {string} filepath - path to the file relative to the game files root * @returns {Promise<string>} resolved absolute path, passed to res.sendFile() * @throws {adaptor.NotFoundError} if the file does not exist * @throws {adaptor.InvalidError} if the path escapes the game files root or points to a directory */ async getFile(file) { const absolute_path = this.resolvePath(file) const stat = await fs.stat(absolute_path) if (stat.isDirectory()) { throw new adaptor.InvalidError( `Path '${file}' is a directory. Only files can be downloaded.` ) } this.log.debug(`Downloading file: ${file}`) return absolute_path } /** * Deletes a file or directory (recursively). * * @param {string} file - path to the file or directory relative to the game files root * @returns {Promise<void>} * @throws {adaptor.NotFoundError} if the file or directory does not exist * @throws {adaptor.InvalidError} if the path escapes the game files root */ async delete(file) { const absolute_path = this.resolvePath(file) if (absolute_path === path.resolve(this.game.files)) { throw new adaptor.InvalidError(`Cannot delete the root files directory.`) } this.log.debug(`Deleting file or directory: ${file}`) await fs.rm(absolute_path, { recursive: true }) } /** * Copies a file or all files of a directory to a new location. Creates intermediate destination * directories if they do not exist. Overwrites the destination if it * already exists. Copies directories recursively. * * @param {string} file - source path relative to the game files root * @param {string} destination - destination path relative to the game files root * @returns {Promise<Object>} response object with created_path * @throws {adaptor.NotFoundError} if the source file does not exist * @throws {adaptor.InvalidError} if either path escapes the game files root */ async copy(file, destination) { if (!destination) { throw new adaptor.InvalidError("'destination' is required.") } const src = this.resolvePath(file) const dest = this.resolveDestPath(destination) await fs.mkdir(path.dirname(dest), { recursive: true }) await fs.cp(src, dest, { recursive: true }) this.log.debug(`Copied '${file}' to '${destination}'.`) return { created_path: dest } } /** * Moves or renames a file or directory. Creates intermediate destination directories * if they do not exist. Overwrites the destination if it already exists. * * @param {string} file - source path relative to the game files root * @param {string} destination - destination path relative to the game files root * @returns {Promise<Object>} response object with changed flag and new path * @throws {adaptor.NotFoundError} if the source file does not exist * @throws {adaptor.InvalidError} if either path escapes the game files root or the source is a directory */ async move(file, destination) { if (!destination) { throw new adaptor.InvalidError("'destination' is required.") } const src = this.resolvePath(file) const dest = this.resolveDestPath(destination) await fs.mkdir(path.dirname(dest), { recursive: true }) await fs.rename(src, dest) // also move symlink if this file was published const publicSrc = path.join(this.game.public, file) const publicDest = path.join(this.game.public, destination) try { await fs.lstat(publicSrc) // throws ENOENT if no symlink exists await fs.mkdir(path.dirname(publicDest), { recursive: true }) await fs.unlink(publicSrc) await this.cleanupPublicDirs(path.dirname(publicSrc)) await fs.symlink(dest, publicDest) this.log.debug( `Repointed public symlink from '${publicSrc}' to '${publicDest}'.` ) } catch (err) { if (err.code !== "ENOENT") { throw err } // else file is not public, nothing to do } this.log.debug(`Moved '${file}' to '${destination}'.`) return { changed: 1, path: destination } } /** * Creates a directory at the specified path. Intermediate parent directories * are created automatically. Does nothing if the directory already exists. * * @param {string} directory - path relative to the game files root * @returns {Promise<Object>} response object with created_path * @throws {adaptor.InvalidError} if the path escapes the game files root */ async createDirectory(directory) { const absolute_path = this.resolveDestPath(directory) await fs.mkdir(absolute_path, { recursive: true }) this.log.info(`Created directory: ${directory}`) return { created_path: directory } } /** * Publishes a file or directory by creating a symlink to it in the game's public directory. * * @param {string} file - path to the file or directory relative to the game files root * @returns {Promise<Object>} response object with published_path * @throws {adaptor.NotFoundError} if the source file does not exist */ async publish(file) { const src = this.resolvePath(file) const dest = path.join(this.game.public, file) const destDir = path.dirname(dest) await fs.mkdir(destDir, { recursive: true }) const stat = await fs.stat(src) const symlinkType = stat.isDirectory() ? "junction" : "file" try { await fs.symlink(src, dest, symlinkType) } catch (err) { // Remove abandoned directories. Walk up from destDir to public root, removing empty dirs let current = destDir while ( current.startsWith(this.game.public) && current !== this.game.public ) { try { await fs.rmdir(current) current = path.dirname(current) } catch { break // not empty, stop continuing upward } } if (err.code === "EPERM" && process.platform === "win32") { throw new adaptor.ForbiddenError( `Cannot publish "${file}". On Windows, file symlinks require Developer Mode to be enabled (Settings → System → For developers) or running adaptor:ex as Administrator.` ) } throw err } this.log.info(`Published: ${file}`) return { published_path: file } } /** * Unpublishes a file or directory by removing the symlink in the game's public directory. * * Throws InvalidError if the file is not a symlink. That might be the case if the file or directory was not * published by itself. E.G. because the file is inside a published directory. Or if the file is a directory that was * not itself published. * * @param {string} file - path to the file or directory relative to the game files root * @returns {Promise<undefined>} once the file has been unpublished * @throws {adaptor.NotFoundError} if the file is not public or does not exist * @throws {adaptor.InvalidError} if the file is not a symlink (e.g. because it is inside a published directory) */ async unpublish(file) { const dest = this.resolvePath(file, this.game.public) const lstat = await fs.lstat(dest).catch(() => null) if (!lstat) { throw new adaptor.NotFoundError(`'${file}' is not public.`) } if (!lstat.isSymbolicLink()) { throw new adaptor.InvalidError( `'${file}' is not a symlink. It may be public because a related file or directory was published. Please unpublish the related file or directory instead.` ) } await fs.unlink(dest) // removes symlink only, not the source file await this.cleanupPublicDirs(path.dirname(dest)) this.log.info(`Unpublished: ${file}`) } /** * Resolves and validates a relative file path against the game's files * directory. Throws a NotFoundError if the path does not exist and * an InvalidError if the resolved path escapes the game directory * (path traversal guard). * * @param {string} relative_path - path relative to the game files root * @param {string} game_files_dir - path to the game files root. Defaults to this.game.files * @returns {string} resolved absolute path * @throws {adaptor.NotFoundError} if the path does not exist * @throws {adaptor.ForbiddenError} if the path contains a dotfile or hidden directory * @throws {adaptor.InvalidError} if the path escapes the game files root */ resolvePath(relative_path, game_files_dir = this.game.files) { const absolute_path = path.resolve(game_files_dir, relative_path) if (!absolute_path.startsWith(path.resolve(game_files_dir))) { throw new adaptor.InvalidError( `Path '${relative_path}' is outside the game files directory.` ) } const segments = relative_path.split(path.sep) if (segments.some((seg) => seg.startsWith("."))) { throw new adaptor.ForbiddenError( `Path '${relative_path}' contains a dotfile or hidden directory.` ) } if (!fi.exists(absolute_path)) { throw new adaptor.NotFoundError( `Path '${relative_path}' does not exist in '${game_files_dir}'.` ) } return absolute_path } /** * Resolves a destination path against the game files root. Only validates * that the path stays within the game directory — does not require the path * to already exist. * * @param {string} relative_path - destination path relative to game files root * @returns {string} resolved absolute path * @throws {adaptor.InvalidError} if the path escapes the game files root */ resolveDestPath(relative_path) { const absolute_path = path.resolve(this.game.files, relative_path) if (!absolute_path.startsWith(path.resolve(this.game.files))) { throw new adaptor.InvalidError( `Destination '${relative_path}' is outside the game files directory.` ) } const segments = relative_path.split(path.sep) if (segments.some((seg) => seg.startsWith("."))) { throw new adaptor.ForbiddenError( `Destination '${relative_path}' contains a dotfile or hidden directory.` ) } return absolute_path } /** * Removes empty parent directories in the public folder up to the public root. * * @param {string} dir - absolute path to start cleaning up from */ async cleanupPublicDirs(dir) { const publicRoot = path.resolve(this.game.public) while (dir !== publicRoot) { const entries = await fs.readdir(dir).catch(() => null) if (entries === null || entries.length > 0) break await fs.rmdir(dir) this.log.debug(`Removed empty public directory: ${dir}`) dir = path.dirname(dir) } } /** * Make error specific response for AdaptorErrors. Otherwise, respond with 500 internal server error. * * Dispatch 400 InvalidError for MulterError * * @param {Error} err - error object that was caught * @param {Object} res - request response object * @returns undefined */ errorHandling(err, res) { this.log.error(err) if (err.cause) { err.message = err.message + "\n" + err.cause.message this.log.error(err.cause.message) } if (err.status == 404) { return res .status(404) .send({ name: "NotFoundError", message: err.message }) } if (err instanceof adaptor.NotFoundError) { return res.status(404).send({ name: err.name, message: err.message }) } else if (err instanceof adaptor.AdaptorError) { return res.status(400).send({ name: err.name, message: err.message }) } else if (err instanceof multer.MulterError) { return res .status(400) .send({ name: "InvalidError", message: err.message }) } res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) } } module.exports.FilesManagement = FilesManagement