adaptorex
Version:
Connect all your live interactive storytelling devices and software
623 lines (547 loc) • 20.1 kB
JavaScript
/**
* 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