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 {Healthcheck} from "./healthcheck";
import {printEndpoints, validateHash} from "./utils";
import {LogRequestMiddleware} from "./log";
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 { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { entry } from "./entry";
import cors from "cors";
import archiver from "archiver";
import {appendDirectoryToArchive, appendFileToArchive} from "./download";
import { appendDirectoryToArchive, appendFileToArchive } from "./download";
import path from "node:path";
import {verifyToken} from "./authenicate";
import { verifyToken } from "./authenicate";
import jwt from "jsonwebtoken";
import {config} from "dotenv";
import { config } from "dotenv";
import Multer from "multer";
import {mkdirSync, readFileSync, rmSync, writeFileSync} from "fs";
/**
* App details
*/
const PORT = 5000;
const APP: Express = express();
// TODO: BACK TO NORMAL PATH
// const ROOT: string = "/media/vault";
const ROOT: string = "/home/azpect";
/**
* Configure the .env file, this is for testing only, should be ignored in production.
*/
try {
config({path: ".env"});
config({ path: ".env" });
} catch (error) {
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));
/**
* File upload settings
*/
const upload = Multer({
dest: "tmp/",
limits: {
fileSize: 1024 * 1024 * 100
},
});
/**
* 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}));
APP.use(express.urlencoded({ extended: true }));
/**
* 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 => {
// Get info from body
const {username, password} = req.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."});
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});
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!"});
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 => {
// Get the files from the body
const {filePaths} = req.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.'});
res.status(400).send({ code: 400, error: 'Invalid file paths provided.' });
return;
}
const archive: archiver.Archiver = archiver('zip', {
zlib: {level: 9}, // Compression leve
zlib: { level: 9 }, // Compression leve
});
// Set the file headers
@ -164,7 +176,7 @@ v1.post("/download", (req: Request, res: Response): void => {
// Return errors
archive.on('error', (err): void => {
res.status(500).send({error: err.message});
res.status(500).send({ error: err.message });
});
archive.finalize();
@ -184,16 +196,16 @@ v1.get("/content", (req: Request, res: Response): void => {
// 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});
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});
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});
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 => {
// Get path and content from the request
const {path, content} = req.body;
const { path, content } = req.body;
try {
fs.writeFileSync(path, content);
res.status(200).json({code: 200, message: "Success"});
res.status(200).json({ code: 200, message: "Success" });
} 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.
@ -252,55 +258,108 @@ v1.post("/upload", upload.array("files"), (req: Request, res: Response) => {
const newPath: string = path.join("/", ...cwd, file.originalname);
// Write the new file
writeFileSync(newPath, data);
writeFileSync(newPath, data, { mode: "666" });
// 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
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
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
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
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 => {
// Generate the path to create
const {cwd, name} = req.body;
const { cwd, name } = req.body;
try {
const newPath: string = path.join("/", ...cwd, name);
if (name.endsWith("/")) {
mkdirSync(newPath, {mode: "644"})
mkdirSync(newPath, { recursive: true, mode: "666" })
} else {
console.log("NOT DIR");
writeFileSync(newPath, "", {mode: "644"})
writeFileSync(newPath, "", { mode: "666" })
}
} catch (error: any) {
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;
} 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;
} 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;
} 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;
}
}
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
* is called when the button is clicked.
* @param onClick {function}
* @param enabled
* @returns {JSX.Element}
* @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}
@ -73,10 +89,12 @@ function PathElement({name, index, onClick}) {
* @param backArrow {function}
* @param enabled {boolean}
* @param create {function}
* @param remove {function}
* @param removeEnable {boolean}
* @returns {JSX.Element}
* @constructor
*/
export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create}) {
export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create, remove, removeEnable}) {
return (
<div
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}/>
{path.map((seg, idx) => <PathElement name={seg} key={idx} index={idx} onClick={updatePath}/>)}
<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}/>
</div>
</div>

View File

@ -1,5 +1,5 @@
import {useEffect, useState} from "react";
import {useNavigate} from "react-router-dom";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import DirectoryList from "../components/DirectoryList.jsx";
import PathDisplay from "../components/PathDisplay.jsx";
import Navbar from "../components/Navbar.jsx";
@ -12,7 +12,9 @@ import CreateDirectory from "../components/CreateDirectory.jsx";
export default function Dashboard() {
// 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.
@ -56,7 +58,7 @@ export default function Dashboard() {
return t;
};
useEffect(() => {
const fetchFiles = () => {
const getData = async (token) => {
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
method: "GET",
@ -88,7 +90,10 @@ export default function Dashboard() {
});
setSelected([]);
};
useEffect(() => {
fetchFiles();
}, [path, uploading, creating]);
@ -170,7 +175,7 @@ export default function Dashboard() {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({filePaths: paths}),
body: JSON.stringify({ filePaths: paths }),
});
if (!resp.ok) {
const data = await resp.json();
@ -234,7 +239,7 @@ export default function Dashboard() {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({path, content}),
body: JSON.stringify({ path, content }),
})
if (!resp.ok) {
setError("An error occurred when saving the file. Please try again.");
@ -366,7 +371,7 @@ export default function Dashboard() {
"Content-Type": "application/json",
"authorization": `Bearer ${token}`,
},
body: JSON.stringify({name, cwd})
body: JSON.stringify({ name, cwd })
});
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 (
<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">
{downloadLoading && <DownloadLoading/>}
{creating && <CreateDirectory close={closeCreate} create={createDirectory}/>}
{uploading && <Uploader close={toggleUploading} upload={upload}/>}
{downloadLoading && <DownloadLoading />}
{creating && <CreateDirectory close={closeCreate} create={createDirectory} />}
{uploading && <Uploader close={toggleUploading} upload={upload} />}
{error && <Error error={error} clear={clearError}/>}
{error && <Error error={error} clear={clearError} />}
{(editing !== "" && !error) &&
<Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile}
loading={contentLoading}/>}
loading={contentLoading} />}
<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">
{childrenLoading && <ChildrenLoading/>}
{childrenLoading && <ChildrenLoading />}
<DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath}
toggleSelected={toggleSelected} toggleEditing={toggleEditing}/>
// TODO: Rework the toggleSelected functionality
toggleSelected={toggleSelected} toggleEditing={toggleEditing} />
</div>
<div className="w-2/3 flex justify-end items-center">
<label className="text-sm mx-2" htmlFor="showHiddenItems">Show Hidden Items</label>
@ -414,7 +467,7 @@ export default function Dashboard() {
name="showHiddenItems"
type="checkbox"
checked={showHidden}
onClick={toggleHidden}/>
onClick={toggleHidden} />
</div>
</div>
</div>