Merge branch 'feature/delete_files'

This commit is contained in:
Hayden Hargreaves 2025-05-23 18:42:15 -07:00
commit 030b67844f
3 changed files with 719 additions and 586 deletions

View File

@ -1,31 +1,33 @@
import express, {Express, Request, Response, Router} from "express"; import express, { Express, Request, Response, Router } from "express";
import {Healthcheck} from "./healthcheck"; import { Healthcheck } from "./healthcheck";
import {printEndpoints, validateHash} from "./utils"; import { printEndpoints, validateHash } from "./utils";
import {LogRequestMiddleware} from "./log"; import { LogRequestMiddleware } from "./log";
import * as fs from "node:fs"; import * as fs from "node:fs";
import {entry} from "./entry"; import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { entry } from "./entry";
import cors from "cors"; import cors from "cors";
import archiver from "archiver"; import archiver from "archiver";
import {appendDirectoryToArchive, appendFileToArchive} from "./download"; import { appendDirectoryToArchive, appendFileToArchive } from "./download";
import path from "node:path"; import path from "node:path";
import {verifyToken} from "./authenicate"; import { verifyToken } from "./authenicate";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import {config} from "dotenv"; import { config } from "dotenv";
import Multer from "multer"; import Multer from "multer";
import {mkdirSync, readFileSync, rmSync, writeFileSync} from "fs";
/** /**
* App details * App details
*/ */
const PORT = 5000; const PORT = 5000;
const APP: Express = express(); const APP: Express = express();
// TODO: BACK TO NORMAL PATH
// const ROOT: string = "/media/vault";
const ROOT: string = "/home/azpect"; const ROOT: string = "/home/azpect";
/** /**
* Configure the .env file, this is for testing only, should be ignored in production. * Configure the .env file, this is for testing only, should be ignored in production.
*/ */
try { try {
config({path: ".env"}); config({ path: ".env" });
} catch (error) { } catch (error) {
console.error(`Could not load the .env file. If this is a production environment, this is normal!`); console.error(`Could not load the .env file. If this is a production environment, this is normal!`);
} }
@ -44,13 +46,23 @@ const corsOptions: cors.CorsOptions = {
}; };
APP.use(cors(corsOptions)); APP.use(cors(corsOptions));
/**
* File upload settings
*/
const upload = Multer({
dest: "tmp/",
limits: {
fileSize: 1024 * 1024 * 100
},
});
/** /**
* Apply middleware, this must be done before the routes are created. * Apply middleware, this must be done before the routes are created.
*/ */
APP.use(verifyToken); APP.use(verifyToken);
APP.use(LogRequestMiddleware); APP.use(LogRequestMiddleware);
APP.use(express.json()); APP.use(express.json());
APP.use(express.urlencoded({extended: true})); APP.use(express.urlencoded({ extended: true }));
/** /**
* Create routes for modular routing * Create routes for modular routing
@ -75,22 +87,22 @@ v1.get("/healthcheck", (req: Request, res: Response): void => {
*/ */
v1.post("/login", (req: Request, res: Response): void => { v1.post("/login", (req: Request, res: Response): void => {
// Get info from body // Get info from body
const {username, password} = req.body; const { username, password } = req.body;
// Get required info from the environment and validate // Get required info from the environment and validate
if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) { if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) {
// Get the secret from the env // Get the secret from the env
const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"]; const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"];
if (!jwt_secret) { if (!jwt_secret) {
res.status(500).json({code: 500, message: "JSON web tokens are not configured."}); res.status(500).json({ code: 500, message: "JSON web tokens are not configured." });
return; return;
} }
// Create the token // Create the token
const token: string = jwt.sign({username}, jwt_secret, {expiresIn: "30d"}); const token: string = jwt.sign({ username }, jwt_secret, { expiresIn: "30d" });
res.status(200).json({code: 200, token}); res.status(200).json({ code: 200, token });
} else { } else {
res.status(404).json({code: 404, message: "Invalid credentials. Please try again!"}); res.status(404).json({ code: 404, message: "Invalid credentials. Please try again!" });
} }
}); });
@ -127,16 +139,16 @@ v1.get("/children", (req: Request, res: Response): void => {
*/ */
v1.post("/download", (req: Request, res: Response): void => { v1.post("/download", (req: Request, res: Response): void => {
// Get the files from the body // Get the files from the body
const {filePaths} = req.body; const { filePaths } = req.body;
// Validate the path array // Validate the path array
if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) { if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) {
res.status(400).send({code: 400, error: 'Invalid file paths provided.'}); res.status(400).send({ code: 400, error: 'Invalid file paths provided.' });
return; return;
} }
const archive: archiver.Archiver = archiver('zip', { const archive: archiver.Archiver = archiver('zip', {
zlib: {level: 9}, // Compression leve zlib: { level: 9 }, // Compression leve
}); });
// Set the file headers // Set the file headers
@ -164,7 +176,7 @@ v1.post("/download", (req: Request, res: Response): void => {
// Return errors // Return errors
archive.on('error', (err): void => { archive.on('error', (err): void => {
res.status(500).send({error: err.message}); res.status(500).send({ error: err.message });
}); });
archive.finalize(); archive.finalize();
@ -184,16 +196,16 @@ v1.get("/content", (req: Request, res: Response): void => {
// Ensure the file isn't something we can't edit // Ensure the file isn't something we can't edit
const ext: string = path.extname(tgtPath).slice(1); const ext: string = path.extname(tgtPath).slice(1);
if (INVALID_EXTS.includes(ext)) { if (INVALID_EXTS.includes(ext)) {
res.status(400).json({error: `Cannot edit files of type *.${ext}`, code: 400}); res.status(400).json({ error: `Cannot edit files of type *.${ext}`, code: 400 });
return; return;
} }
try { try {
// Read the file and return it // Read the file and return it
const content = fs.readFileSync(tgtPath, {encoding: "utf-8", flag: "r"}) const content = fs.readFileSync(tgtPath, { encoding: "utf-8", flag: "r" })
res.status(200).json({content, code: 200}); res.status(200).json({ content, code: 200 });
} catch (err) { } catch (err) {
res.status(500).json({error: `An error occurred on the server. ${err}`, code: 500}); res.status(500).json({ error: `An error occurred on the server. ${err}`, code: 500 });
} }
}); });
@ -203,22 +215,16 @@ v1.get("/content", (req: Request, res: Response): void => {
*/ */
v1.post("/update", (req: Request, res: Response): void => { v1.post("/update", (req: Request, res: Response): void => {
// Get path and content from the request // Get path and content from the request
const {path, content} = req.body; const { path, content } = req.body;
try { try {
fs.writeFileSync(path, content); fs.writeFileSync(path, content);
res.status(200).json({code: 200, message: "Success"}); res.status(200).json({ code: 200, message: "Success" });
} catch (error) { } catch (error) {
res.status(500).json({code: 500, error}) res.status(500).json({ code: 500, error })
} }
}); });
const upload = Multer({
dest: "tmp/",
limits: {
fileSize: 1024 * 1024 * 100
},
});
/** /**
* Custom type for the multer uploads. * Custom type for the multer uploads.
@ -252,55 +258,108 @@ v1.post("/upload", upload.array("files"), (req: Request, res: Response) => {
const newPath: string = path.join("/", ...cwd, file.originalname); const newPath: string = path.join("/", ...cwd, file.originalname);
// Write the new file // Write the new file
writeFileSync(newPath, data); writeFileSync(newPath, data, { mode: "666" });
// Delete the tmp file using a relative path // Delete the tmp file using a relative path
rmSync("./" + file.path); rmSync("./" + file.path);
} catch (error: any) { } catch (error: any) {
if (error.code === 'EACCES') { if (error.code === 'EACCES') {
return res.status(403).json({code: 403, error: "Permission denied."}); // Specific error return res.status(403).json({ code: 403, error: "Permission denied." }); // Specific error
} else if (error.code === 'ENOSPC') { } else if (error.code === 'ENOSPC') {
return res.status(507).json({code: 507, error: "Insufficient storage."}); // Specific error return res.status(507).json({ code: 507, error: "Insufficient storage." }); // Specific error
} else if (error instanceof TypeError) { } else if (error instanceof TypeError) {
return res.status(400).json({code: 400, error: "Invalid data type."}); // Example of instance check return res.status(400).json({ code: 400, error: "Invalid data type." }); // Example of instance check
} else { } else {
return res.status(500).json({code: 500, error: "Error processing file."}); // Generic error return res.status(500).json({ code: 500, error: "Error processing file." }); // Generic error
} }
} }
}) })
res.status(200).json({code: 200, message: "Success"}); res.status(200).json({ code: 200, message: "Success" });
}); });
v1.post("/create", (req: Request, res: Response): void => { v1.post("/create", (req: Request, res: Response): void => {
// Generate the path to create // Generate the path to create
const {cwd, name} = req.body; const { cwd, name } = req.body;
try { try {
const newPath: string = path.join("/", ...cwd, name); const newPath: string = path.join("/", ...cwd, name);
if (name.endsWith("/")) { if (name.endsWith("/")) {
mkdirSync(newPath, {mode: "644"}) mkdirSync(newPath, { recursive: true, mode: "666" })
} else { } else {
console.log("NOT DIR"); writeFileSync(newPath, "", { mode: "666" })
writeFileSync(newPath, "", {mode: "644"})
} }
} catch (error: any) { } catch (error: any) {
if (error.code === 'EACCES') { if (error.code === 'EACCES') {
res.status(403).json({code: 403, error: "Permission denied."}); // Specific error res.status(403).json({ code: 403, error: "Permission denied." }); // Specific error
return; return;
} else if (error.code === 'ENOSPC') { } else if (error.code === 'ENOSPC') {
res.status(507).json({code: 507, error: "Insufficient storage."}); // Specific error res.status(507).json({ code: 507, error: "Insufficient storage." }); // Specific error
return; return;
} else if (error instanceof TypeError) { } else if (error instanceof TypeError) {
res.status(400).json({code: 400, error: "Invalid data type."}); // Example of instance check res.status(400).json({ code: 400, error: "Invalid data type." }); // Example of instance check
return; return;
} else { } else {
res.status(500).json({code: 500, error: "Error processing directory."}); // Generic error res.status(500).json({ code: 500, error: "Error processing directory." }); // Generic error
return; return;
} }
} }
res.status(201).json({code: 201, message: "Success"}); res.status(201).json({ code: 201, message: "Success" });
});
v1.post("/delete", (req: Request, res: Response): void => {
// Get the array of paths and the root
const { files, root } = req.body;
// Delete the files
// NOTE: These are not moved somewhere, they're just raw deleted
for (const file of files) {
const absPath = "/" + path.join(...file);
rmSync(absPath, { recursive: true });
}
res.status(201).json({ code: 201, message: `Deleted ${files.length} files.` });
});
/**
* Files are not deleted from the file system, just moved to a hidden folder where
* they can be recovered if needed.
*
* The hidden folder will be called `.trash` stored in the root of the mount, and
* the files entire paths will be created.
*
* e.g., Deleting /media/vault/main.txt will get moved to
* `/media/vault/.trash/<timstamp>/media/vault/main.txt`
* assuming /media/vault is the mounted root.
* The timestamp will also be appended to allow files with the same path to be deleted.
*/
v1.post("/remove", (req: Request, res: Response): void => {
// Get the array of paths and the root
const { files, root } = req.body;
// Stores the name of the trash directory
const trashDir: string = ".trash";
const timestamp: string = (new Date()).toISOString();
console.log(timestamp);
for (const file of files) {
const oldPath = path.join("/", ...file);
const newPath = path.join("/", ...root, trashDir, timestamp, ...file);
console.log(oldPath, " -> ", newPath);
try {
mkdirSync(path.dirname(newPath), { recursive: true, mode: "666" });
renameSync(oldPath, newPath)
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: `Failed to delete. ${error}` })
return;
}
}
res.status(201).json({ code: 201, message: `Deleted ${files.length} files.` });
}); });
/** /**

View File

@ -2,6 +2,7 @@
* Takes the user back to the home directory. The onClick prop * Takes the user back to the home directory. The onClick prop
* is called when the button is clicked. * is called when the button is clicked.
* @param onClick {function} * @param onClick {function}
* @param enabled
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
@ -50,6 +51,21 @@ function CreateButton({onClick}) {
) )
} }
function DeleteButton({onClick, enabled}) {
return (
<button onClick={onClick}
disabled={!enabled}
title="Delete selected files/directories"
className="hover:bg-gray-200 p-1.5 mr-1 rounded-full transition-colors duration-150 disabled:text-gray-500 disabled:hover:bg-red-300 disabled:cursor-not-allowed text-black">
<svg className="h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18 6L17.1991 18.0129C17.129 19.065 17.0939 19.5911 16.8667 19.99C16.6666 20.3412 16.3648 20.6235 16.0011 20.7998C15.588 21 15.0607 21 14.0062 21H9.99377C8.93927 21 8.41202 21 7.99889 20.7998C7.63517 20.6235 7.33339 20.3412 7.13332 19.99C6.90607 19.5911 6.871 19.065 6.80086 18.0129L6 6M4 6H20M16 6L15.7294 5.18807C15.4671 4.40125 15.3359 4.00784 15.0927 3.71698C14.8779 3.46013 14.6021 3.26132 14.2905 3.13878C13.9376 3 13.523 3 12.6936 3H11.3064C10.477 3 10.0624 3 9.70951 3.13878C9.39792 3.26132 9.12208 3.46013 8.90729 3.71698C8.66405 4.00784 8.53292 4.40125 8.27064 5.18807L8 6"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
);
}
/** /**
* *
* @param name {string} * @param name {string}
@ -73,10 +89,12 @@ function PathElement({name, index, onClick}) {
* @param backArrow {function} * @param backArrow {function}
* @param enabled {boolean} * @param enabled {boolean}
* @param create {function} * @param create {function}
* @param remove {function}
* @param removeEnable {boolean}
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create}) { export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create, remove, removeEnable}) {
return ( return (
<div <div
className="w-2/3 mt-8 border-b-1 border-gray-400 bg-white flex items-center truncate"> className="w-2/3 mt-8 border-b-1 border-gray-400 bg-white flex items-center truncate">
@ -84,6 +102,9 @@ export default function PathDisplay({path, updatePath, backHome, backArrow, enab
<BackButton onClick={backArrow} enabled={enabled}/> <BackButton onClick={backArrow} enabled={enabled}/>
{path.map((seg, idx) => <PathElement name={seg} key={idx} index={idx} onClick={updatePath}/>)} {path.map((seg, idx) => <PathElement name={seg} key={idx} index={idx} onClick={updatePath}/>)}
<div className="ml-auto h-full flex items-center"> <div className="ml-auto h-full flex items-center">
<DeleteButton onClick={remove} enabled={removeEnable}/>
</div>
<div className="h-full flex items-center">
<CreateButton onClick={create}/> <CreateButton onClick={create}/>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import DirectoryList from "../components/DirectoryList.jsx"; import DirectoryList from "../components/DirectoryList.jsx";
import PathDisplay from "../components/PathDisplay.jsx"; import PathDisplay from "../components/PathDisplay.jsx";
import Navbar from "../components/Navbar.jsx"; import Navbar from "../components/Navbar.jsx";
@ -12,7 +12,9 @@ import CreateDirectory from "../components/CreateDirectory.jsx";
export default function Dashboard() { export default function Dashboard() {
// Store the default path // Store the default path
const defaultPath = ["media", "vault"]; // TODO: BACK TO NORMAL PATH
// const defaultPath = ["media", "vault"];
const defaultPath = ["home", "azpect"];
/** /**
* URL To the backend web server. * URL To the backend web server.
@ -56,7 +58,7 @@ export default function Dashboard() {
return t; return t;
}; };
useEffect(() => { const fetchFiles = () => {
const getData = async (token) => { const getData = async (token) => {
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, { const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
method: "GET", method: "GET",
@ -88,7 +90,10 @@ export default function Dashboard() {
}); });
setSelected([]); setSelected([]);
};
useEffect(() => {
fetchFiles();
}, [path, uploading, creating]); }, [path, uploading, creating]);
@ -170,7 +175,7 @@ export default function Dashboard() {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`, "Authorization": `Bearer ${token}`,
}, },
body: JSON.stringify({filePaths: paths}), body: JSON.stringify({ filePaths: paths }),
}); });
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json(); const data = await resp.json();
@ -234,7 +239,7 @@ export default function Dashboard() {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}`, "Authorization": `Bearer ${token}`,
}, },
body: JSON.stringify({path, content}), body: JSON.stringify({ path, content }),
}) })
if (!resp.ok) { if (!resp.ok) {
setError("An error occurred when saving the file. Please try again."); setError("An error occurred when saving the file. Please try again.");
@ -366,7 +371,7 @@ export default function Dashboard() {
"Content-Type": "application/json", "Content-Type": "application/json",
"authorization": `Bearer ${token}`, "authorization": `Bearer ${token}`,
}, },
body: JSON.stringify({name, cwd}) body: JSON.stringify({ name, cwd })
}); });
if (!resp.ok) { if (!resp.ok) {
@ -387,25 +392,73 @@ export default function Dashboard() {
}); });
}; };
/**
* Remove the selected files.
*/
const removeSelected = () => {
if (selected.length === 0) {
return setError("Please select files or directories to delete");
}
const remove = async (files) => {
const resp = await fetch(`${backendUrl}/v1/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"authorization": `Bearer ${token}`,
},
body: JSON.stringify({ files, root: defaultPath })
});
if (!resp.ok) {
const data = await resp.json()
setError(data.error);
return data;
}
return await resp.json();
};
// Files are stored as arrays of paths
const files = [];
for (const file of selected) {
files.push([...path, file]);
}
remove(files).then((data) => {
if (data.code === 201) {
console.log(data);
setSelected([]);
// Fetch the new files & deselect everything
fetchFiles();
}
}).catch((error) => {
setError(error);
});
};
return ( return (
<div className="w-full min-h-screen h-screen pb-8"> <div className="w-full min-h-screen h-screen pb-8">
<Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading}/> <Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading} />
<div className="h-full w-full flex flex-col items-center justify-center pb-8"> <div className="h-full w-full flex flex-col items-center justify-center pb-8">
{downloadLoading && <DownloadLoading/>} {downloadLoading && <DownloadLoading />}
{creating && <CreateDirectory close={closeCreate} create={createDirectory}/>} {creating && <CreateDirectory close={closeCreate} create={createDirectory} />}
{uploading && <Uploader close={toggleUploading} upload={upload}/>} {uploading && <Uploader close={toggleUploading} upload={upload} />}
{error && <Error error={error} clear={clearError}/>} {error && <Error error={error} clear={clearError} />}
{(editing !== "" && !error) && {(editing !== "" && !error) &&
<Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile} <Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile}
loading={contentLoading}/>} loading={contentLoading} />}
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow} <PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}
enabled={path.length > defaultPath.length} create={createDir}/> enabled={path.length > defaultPath.length} create={createDir} remove={removeSelected}
removeEnable={selected.length > 0} />
<div className="w-2/3 h-5/6 overflow-y-auto border-1 border-gray-300"> <div className="w-2/3 h-5/6 overflow-y-auto border-1 border-gray-300">
{childrenLoading && <ChildrenLoading/>} {childrenLoading && <ChildrenLoading />}
<DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath} <DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath}
toggleSelected={toggleSelected} toggleEditing={toggleEditing}/> // TODO: Rework the toggleSelected functionality
toggleSelected={toggleSelected} toggleEditing={toggleEditing} />
</div> </div>
<div className="w-2/3 flex justify-end items-center"> <div className="w-2/3 flex justify-end items-center">
<label className="text-sm mx-2" htmlFor="showHiddenItems">Show Hidden Items</label> <label className="text-sm mx-2" htmlFor="showHiddenItems">Show Hidden Items</label>
@ -414,7 +467,7 @@ export default function Dashboard() {
name="showHiddenItems" name="showHiddenItems"
type="checkbox" type="checkbox"
checked={showHidden} checked={showHidden}
onClick={toggleHidden}/> onClick={toggleHidden} />
</div> </div>
</div> </div>
</div> </div>