UNPKG

universal-fs

Version:

Allows a client or server to access the file system in the current codebase or a different one.

319 lines (281 loc) 9.51 kB
import bcrypt from "bcrypt"; import express from "express"; import type {Express} from "express"; import fs from "fs"; import isJson from "../helpers/isJson"; import ngrok from "@ngrok/ngrok"; import http from "http"; import process from "process"; /** * The class for controllering the file relay server. */ class Server { private app!: Express; private port!: number; private server!: http.Server; private isProtected: boolean = true; /** * An optional custom function to use your own Express server. * @param app - The express app * @param server - The http server * @returns The url of your custom server */ private startServer?: | ((app: Express, server?: http.Server) => Promise<string> | string) | undefined; /** * The constructor of the class for controllering the file relay server. * @param startServer - An optional custom function to use your own Express server */ constructor(options?: { /** * Whether the server should require a password for readOperations on non-ignored files * @default true */ isProtected?: boolean; /** An optional object with options for configuring the server */ startServer?: ( app: Express, server?: http.Server ) => Promise<string> | string; }) { this.app = express(); this.port = 3000; this.startServer = options?.startServer ?? undefined; this.isProtected = options?.isProtected ?? true; } /** * Initilizes the file relay server * @returns The url to the open Ngrok tunnel * @async */ public async init() { this.app.use(express.json()); let authed = false; if (this.isProtected) { this.app.use((req, res, next) => { if (!process.env.UNIVERSAL_FS_PASSWORD) { return res.status(401).json({ success: false, error: "An environment variable UNIVERSAL_FS_PASSWORD is required to protect your files" }); } if (!req.headers.authorization) { return res.status(401).json({ success: false, error: "An Authorization header is required" }); } const token = req.headers.authorization.replace(/^Bearer\s/, ""); if (!bcrypt.compareSync(process.env.UNIVERSAL_FS_PASSWORD, token)) { return res.status(401).json({ success: false, error: "Unauthorized request" }); } authed = true; next(); }); } this.app.use((req, res, next) => { const path = decodeURIComponent(req.params.path); if ( !authed && (req.query.method === "writeFile" || req.query.method === "mkdir" || req.query.method === "unlink" || req.query.method === "rmdir") ) { throw new Error( "ILLEGAL METHOD: you cannot use write or delete on a non-protected server" ); } const ignoredFile = fs.readFileSync(".gitignore", "utf8").split("\n"); for (const file of ignoredFile) { if (file === path) { return res.status(403).json({ error: "The requested resource is not avabile" }); } } if (!req.query.method || req.query.method === "") { return res.status(422).json({ error: "A method is required" }); } if (req.method === "POST" && !req.body) { return res.status(422).json({ error: "A body is required on post requests" }); } next(); }); this.app.use((req, res, next) => { const path = decodeURIComponent(req.params.path); const validPathRegex = /^(\/?(\.{1,2}\/)*[a-zA-Z0-9\-.]+)+$/; if (!validPathRegex.test(path)) { return res.status(400).json({ success: false, error: `Path ${path} is not valid` }); } next(); }); this.app.get("/:path", async (req, res) => { const path = decodeURIComponent(req.params.path); switch (req.query.method) { case "readFile": let fileOptions: { encoding?: null | undefined; flag?: string | undefined; } | null = null; let fileBuffer: Buffer | null = null; if (isJson(req.headers.options as string)) { fileOptions = JSON.parse(req.headers.options as string); } try { fileBuffer = fs.readFileSync(path, fileOptions); return res.json({success: true, buffer: fileBuffer}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } case "readdir": let dirs: string[] | null = null; let dirOptions: | BufferEncoding | { encoding: BufferEncoding | null; withFileTypes?: false | undefined; recursive?: boolean | undefined; } | null = null; if (isJson(req.headers.options as string)) { dirOptions = JSON.parse(req.headers.options as string); } try { dirs = fs.readdirSync(path, dirOptions); return res.json({success: true, dirs: dirs}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } case "exists": try { const exists = fs.existsSync(path); return res.json({success: true, exists: exists}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } default: // This should never trigger because of the first check return res.status(422).json({error: "Method not found"}); } }); this.app.post("/:path", (req, res) => { const path = decodeURIComponent(req.params.path); switch (req.query.method) { case "writeFile": let writeOptions: fs.WriteFileOptions | undefined; if (isJson(req.headers.options as string)) { writeOptions = JSON.parse(req.headers.options as string); } try { fs.writeFileSync(path, req.body.contents, writeOptions); return res .status(200) .json({success: true, message: "File written"}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } case "mkdir": let mkdirOptions: | (fs.MakeDirectoryOptions & {recursive?: false | undefined}) | null = null; if (isJson(req.headers.options as string)) { mkdirOptions = JSON.parse(req.headers.options as string); } try { fs.mkdirSync(path, mkdirOptions); return res .status(200) .json({success: true, message: "Directory created"}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } default: return res.status(422).json({error: "Method not found"}); } }); this.app.delete("/:path", (req, res) => { const path = decodeURIComponent(req.params.path); switch (req.query.method) { case "unlink": try { fs.unlinkSync(path); return res .status(200) .json({success: true, message: "File deleted"}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } case "rmdir": let rmOptions: fs.RmOptions | undefined = undefined; if (isJson(req.headers.options as string)) { rmOptions = JSON.parse(req.headers.options as string); } try { fs.rmdirSync(path, rmOptions); return res .status(200) .json({success: true, message: "Directory deleted"}); } catch (err: any) { return res.status(500).json({success: false, error: err}); } default: return res.status(422).json({error: "Method not found"}); } }); if (this.startServer) { let customUrl; if (this.startServer.length === 0) { throw new Error( "A startServer function must accpet at least one argument" ); } else if (this.startServer.length === 1) { customUrl = await this.startServer(this.app); } else if (this.startServer.length >= 3) { throw new Error("StartServer has too many arguments"); } else { customUrl = await this.startServer(this.app, this.server); } if (!customUrl) { throw new Error("StartServer must return a url"); } return customUrl; } try { this.server = this.app.listen(this.port, () => { console.info(`Listening on this.port ${this.port}`); }); } catch (err: any) { if (err.code === "EADDRINUSE") { console.info( `this.port ${this.port} is already in use. Trying to connect on this.port ${this.port++}` ); this.port = this.port++; } } const listener = await ngrok.connect({ addr: this.port, authtoken: process.env.NGROK_AUTHTOKEN }); return listener.url(); } /** * Stops the server by closing the current connection. */ public stop() { this.server.close(); } } export default Server;