286 lines
9.1 KiB
TypeScript
286 lines
9.1 KiB
TypeScript
import express, {Express, Request, Response, Router} from "express";
|
|
import {Healthcheck} from "./healthcheck";
|
|
import {printEndpoints, validateHash} from "./utils";
|
|
import {LogRequestMiddleware} from "./log";
|
|
import * as fs from "node:fs";
|
|
import {entry} from "./entry";
|
|
import cors from "cors";
|
|
import archiver from "archiver";
|
|
import {appendDirectoryToArchive, appendFileToArchive} from "./download";
|
|
import path from "node:path";
|
|
import {verifyToken} from "./authenicate";
|
|
import jwt from "jsonwebtoken";
|
|
import {config} from "dotenv";
|
|
import Multer from "multer";
|
|
import {readFileSync, rmSync, writeFileSync} from "fs";
|
|
|
|
/**
|
|
* App details
|
|
*/
|
|
const PORT = 5000;
|
|
const APP: Express = express();
|
|
const ROOT: string = "/home/azpect";
|
|
|
|
/**
|
|
* Configure the .env file, this is for testing only, should be ignored in production.
|
|
*/
|
|
try {
|
|
config({path: ".env"});
|
|
} catch (error) {
|
|
console.error(`Could not load the .env file. If this is a production environment, this is normal!`);
|
|
}
|
|
|
|
/**
|
|
* Invalid file extensions for the file editor.
|
|
*/
|
|
const INVALID_EXTS: string[] = ["exe", "dll", "obj", "lib", "bin", "dat", "pdf", "jpg", "jpeg", "png", "gif", "webm", "webp", "bmp", "mp3", "wav", "mp4", "avi", "zip", "rar", "7z", "iso", "dmg", "class", "pyc", "o", "a", "woff", "woff2", "ttf", "otf", "db", "sqlite", "mdb", "accdb", "psd", "ai", "indd", "blend", "fbx", "unitypackage", "pak", "sav", "msi", "doc", "docx", "dot", "dotx", "docm", "dotm", "rtf", "txt", "xls", "xlsx", "xlsm", "xltx", "xltm", "csv", "ppt", "pptx", "pptm", "potx", "potm", "ppsx", "ppsm", "mdb", "accdb", "accde", "accdt", "pst", "ost", "msg", "one", "onetoc2", "pub", "vsd", "vsdx", "vssx", "vstx", "odc", "oft", "pki", "odg"];
|
|
|
|
/**
|
|
* Configure cors, this should work for both production and development.
|
|
*/
|
|
const corsOptions: cors.CorsOptions = {
|
|
origin: ["http://localhost:3100", "https://file.gophernest.net"],
|
|
methods: ["GET", "POST"]
|
|
};
|
|
APP.use(cors(corsOptions));
|
|
|
|
/**
|
|
* Apply middleware, this must be done before the routes are created.
|
|
*/
|
|
APP.use(verifyToken);
|
|
APP.use(LogRequestMiddleware);
|
|
APP.use(express.json());
|
|
APP.use(express.urlencoded({extended: true}));
|
|
|
|
/**
|
|
* Create routes for modular routing
|
|
*/
|
|
const v1: Router = express.Router();
|
|
|
|
/**
|
|
* Return a healthcheck interface loaded with the server's health
|
|
*/
|
|
v1.get("/healthcheck", (req: Request, res: Response): void => {
|
|
let hc: Healthcheck = {
|
|
health: "Server is in bad health",
|
|
errors: ["The root directory could not be found."],
|
|
directory_found: false,
|
|
};
|
|
|
|
res.status(200).json(hc);
|
|
});
|
|
|
|
/**
|
|
* Make a log in attempt.
|
|
*/
|
|
v1.post("/login", (req: Request, res: Response): void => {
|
|
// Get info from body
|
|
const {username, password} = req.body;
|
|
|
|
// Get required info from the environment and validate
|
|
if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) {
|
|
// Get the secret from the env
|
|
const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"];
|
|
if (!jwt_secret) {
|
|
res.status(500).json({code: 500, message: "JSON web tokens are not configured."});
|
|
return;
|
|
}
|
|
|
|
// Create the token
|
|
const token: string = jwt.sign({username}, jwt_secret, {expiresIn: "30d"});
|
|
res.status(200).json({code: 200, token});
|
|
} else {
|
|
res.status(404).json({code: 404, message: "Invalid credentials. Please try again!"});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get the children of a directory provided in the path query.
|
|
*/
|
|
v1.get("/children", (req: Request, res: Response): void => {
|
|
// Get the path, if it was not provided, use the root
|
|
const path: string = (req.query.path || ROOT) as string;
|
|
|
|
// An array of names which are the children
|
|
const children_paths: string[] = fs.readdirSync(path);
|
|
|
|
// Store a list of the children as entries
|
|
const children: entry[] = [];
|
|
|
|
for (const child of children_paths) {
|
|
try {
|
|
const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory();
|
|
children.push({
|
|
name: child,
|
|
path: path.concat("/", child),
|
|
directory: isDir
|
|
});
|
|
} catch (error) {
|
|
console.log(error);
|
|
}
|
|
}
|
|
res.status(200).json(children);
|
|
});
|
|
|
|
/**
|
|
* Down a group of files provided in the body.
|
|
*/
|
|
v1.post("/download", (req: Request, res: Response): void => {
|
|
// Get the files from the body
|
|
const {filePaths} = req.body;
|
|
|
|
// Validate the path array
|
|
if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) {
|
|
res.status(400).send({code: 400, error: 'Invalid file paths provided.'});
|
|
return;
|
|
}
|
|
|
|
const archive: archiver.Archiver = archiver('zip', {
|
|
zlib: {level: 9}, // Compression leve
|
|
});
|
|
|
|
// Set the file headers
|
|
res.setHeader('Content-Disposition', 'attachment; filename=donwloads.zip');
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
|
|
archive.pipe(res);
|
|
|
|
// Add each file to the archive
|
|
filePaths.forEach((filePath): void => {
|
|
// This works for files, but for directories, we need to read the files
|
|
|
|
try {
|
|
const stats = fs.statSync(filePath);
|
|
if (!stats.isDirectory()) {
|
|
appendFileToArchive(filePath, archive);
|
|
} else {
|
|
// Call this with the name to include the name of the directory
|
|
appendDirectoryToArchive(filePath, path.basename(filePath), archive);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error adding file to zip: ${err}`);
|
|
}
|
|
});
|
|
|
|
// Return errors
|
|
archive.on('error', (err): void => {
|
|
res.status(500).send({error: err.message});
|
|
});
|
|
|
|
archive.finalize();
|
|
});
|
|
|
|
/**
|
|
* Retrieve the content of a file provided in the path query.
|
|
*/
|
|
v1.get("/content", (req: Request, res: Response): void => {
|
|
// Get the path, if it was not provided, return no content
|
|
const tgtPath: string = (req.query.path || "") as string;
|
|
if (!tgtPath) {
|
|
res.status(204);
|
|
return;
|
|
}
|
|
|
|
// Ensure the file isn't something we can't edit
|
|
const ext: string = path.extname(tgtPath).slice(1);
|
|
if (INVALID_EXTS.includes(ext)) {
|
|
res.status(400).json({error: `Cannot edit files of type *.${ext}`, code: 400});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Read the file and return it
|
|
const content = fs.readFileSync(tgtPath, {encoding: "utf-8", flag: "r"})
|
|
res.status(200).json({content, code: 200});
|
|
} catch (err) {
|
|
res.status(500).json({error: `An error occurred on the server. ${err}`, code: 500});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Update a file's content, path and content should be provided.
|
|
* On success, nothing should be sent back, 204.
|
|
*/
|
|
v1.post("/update", (req: Request, res: Response): void => {
|
|
// Get path and content from the request
|
|
const {path, content} = req.body;
|
|
|
|
try {
|
|
fs.writeFileSync(path, content);
|
|
res.status(204);
|
|
} catch (error) {
|
|
res.status(500).json({code: 500, error})
|
|
}
|
|
});
|
|
|
|
const upload = Multer({
|
|
dest: "tmp/",
|
|
limits: {
|
|
fileSize: 1024 * 1024 * 100
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Custom type for the multer uploads.
|
|
*/
|
|
interface UploadedFile {
|
|
fieldname: string;
|
|
originalname: string;
|
|
encoding: string;
|
|
mimetype: string;
|
|
destination: string;
|
|
filename: string;
|
|
path: string;
|
|
size: number;
|
|
}
|
|
|
|
// IMPORTANT! Calling this will expect sudo in places in the FS that require sudo
|
|
v1.post("/upload", upload.array("files"), (req: Request, res: Response) => {
|
|
if (!req.files) {
|
|
res.status(400);
|
|
}
|
|
|
|
// Directory to upload the files to
|
|
const cwd: string[] = JSON.parse(req.body.path);
|
|
|
|
const files = (req as any).files as UploadedFile[];
|
|
files.forEach((file) => {
|
|
try {
|
|
// Get the data that was written to the local tmp path
|
|
const data: Buffer = readFileSync(file.path);
|
|
|
|
// Generate the new path in the FS
|
|
const newPath: string = path.join("/", ...cwd, file.originalname);
|
|
|
|
// Write the new file
|
|
writeFileSync(newPath, data);
|
|
|
|
// Delete the tmp file using a relative path
|
|
rmSync("./" + file.path);
|
|
} catch (error: any) {
|
|
if (error.code === 'EACCES') {
|
|
return res.status(403).json({code: 403, error: "Permission denied."}); // Specific error
|
|
} else if (error.code === 'ENOSPC') {
|
|
return res.status(507).json({code: 507, error: "Insufficient storage."}); // Specific error
|
|
} else if (error instanceof TypeError) {
|
|
return res.status(400).json({code: 400, error: "Invalid data type."}); // Example of instance check
|
|
} else {
|
|
return res.status(500).json({code: 500, error: "Error processing file."}); // Generic error
|
|
}
|
|
}
|
|
})
|
|
|
|
res.status(200).json({code: 200, message: "Success"});
|
|
});
|
|
/**
|
|
* Apply the routes to the server
|
|
*/
|
|
APP.use("/v1", v1);
|
|
|
|
/**
|
|
* Start the server
|
|
*/
|
|
APP.listen(PORT, (): void => {
|
|
printEndpoints(APP);
|
|
console.log(`Server listening on :${PORT}`);
|
|
}); |