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
text/typescript
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;