Merge branch 'feature/delete_files'
This commit is contained in:
commit
030b67844f
@ -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.` });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user