(FEAT): Can no delete, but it is buggy.

It selects the first element after a delete, not sure why and don't want
to worry about it right now.
This commit is contained in:
Hayden Hargreaves 2025-05-23 18:40:51 -07:00
parent 4cd88c6505
commit b31626dce9
2 changed files with 668 additions and 648 deletions

View File

@ -1,17 +1,17 @@
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 {mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from "node:fs"; import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import {entry} from "./entry"; 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";
/** /**
@ -27,9 +27,9 @@ 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!`);
} }
/** /**
@ -41,8 +41,8 @@ const INVALID_EXTS: string[] = ["exe", "dll", "obj", "lib", "bin", "dat", "pdf",
* Configure cors, this should work for both production and development. * Configure cors, this should work for both production and development.
*/ */
const corsOptions: cors.CorsOptions = { const corsOptions: cors.CorsOptions = {
origin: ["http://localhost:3100", "https://file.gophernest.net"], origin: ["http://localhost:3100", "https://file.gophernest.net"],
methods: ["GET", "POST"] methods: ["GET", "POST"]
}; };
APP.use(cors(corsOptions)); APP.use(cors(corsOptions));
@ -50,10 +50,10 @@ APP.use(cors(corsOptions));
* File upload settings * File upload settings
*/ */
const upload = Multer({ const upload = Multer({
dest: "tmp/", dest: "tmp/",
limits: { limits: {
fileSize: 1024 * 1024 * 100 fileSize: 1024 * 1024 * 100
}, },
}); });
/** /**
@ -62,7 +62,7 @@ const upload = Multer({
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
@ -73,140 +73,140 @@ const v1: Router = express.Router();
* Return a healthcheck interface loaded with the server's health * Return a healthcheck interface loaded with the server's health
*/ */
v1.get("/healthcheck", (req: Request, res: Response): void => { v1.get("/healthcheck", (req: Request, res: Response): void => {
let hc: Healthcheck = { let hc: Healthcheck = {
health: "Server is in bad health", health: "Server is in bad health",
errors: ["The root directory could not be found."], errors: ["The root directory could not be found."],
directory_found: false, directory_found: false,
}; };
res.status(200).json(hc); res.status(200).json(hc);
}); });
/** /**
* Make a log in attempt. * Make a log in attempt.
*/ */
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
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!"});
} }
// 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. * Get the children of a directory provided in the path query.
*/ */
v1.get("/children", (req: Request, res: Response): void => { v1.get("/children", (req: Request, res: Response): void => {
// Get the path, if it was not provided, use the root // Get the path, if it was not provided, use the root
const path: string = (req.query.path || ROOT) as string; const path: string = (req.query.path || ROOT) as string;
// An array of names which are the children // An array of names which are the children
const children_paths: string[] = fs.readdirSync(path); const children_paths: string[] = fs.readdirSync(path);
// Store a list of the children as entries // Store a list of the children as entries
const children: entry[] = []; const children: entry[] = [];
for (const child of children_paths) { for (const child of children_paths) {
try { try {
const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory(); const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory();
children.push({ children.push({
name: child, name: child,
path: path.concat("/", child), path: path.concat("/", child),
directory: isDir directory: isDir
}); });
} catch (error) { } catch (error) {
console.log(error); console.log(error);
}
} }
res.status(200).json(children); }
res.status(200).json(children);
}); });
/** /**
* Down a group of files provided in the body. * Down a group of files provided in the body.
*/ */
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', {
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}`);
} }
});
const archive: archiver.Archiver = archiver('zip', { // Return errors
zlib: {level: 9}, // Compression leve archive.on('error', (err): void => {
}); res.status(500).send({ error: err.message });
});
// Set the file headers archive.finalize();
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. * Retrieve the content of a file provided in the path query.
*/ */
v1.get("/content", (req: Request, res: Response): void => { v1.get("/content", (req: Request, res: Response): void => {
// Get the path, if it was not provided, return no content // Get the path, if it was not provided, return no content
const tgtPath: string = (req.query.path || "") as string; const tgtPath: string = (req.query.path || "") as string;
if (!tgtPath) { if (!tgtPath) {
res.status(204); res.status(204);
return; return;
} }
// 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 });
} }
}); });
/** /**
@ -214,15 +214,15 @@ v1.get("/content", (req: Request, res: Response): void => {
* On success, nothing should be sent back, 204. * On success, nothing should be sent back, 204.
*/ */
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 })
} }
}); });
@ -230,82 +230,96 @@ v1.post("/update", (req: Request, res: Response): void => {
* Custom type for the multer uploads. * Custom type for the multer uploads.
*/ */
interface UploadedFile { interface UploadedFile {
fieldname: string; fieldname: string;
originalname: string; originalname: string;
encoding: string; encoding: string;
mimetype: string; mimetype: string;
destination: string; destination: string;
filename: string; filename: string;
path: string; path: string;
size: number; size: number;
} }
// IMPORTANT! Calling this will expect sudo in places in the FS that require sudo // IMPORTANT! Calling this will expect sudo in places in the FS that require sudo
v1.post("/upload", upload.array("files"), (req: Request, res: Response) => { v1.post("/upload", upload.array("files"), (req: Request, res: Response) => {
if (!req.files) { if (!req.files) {
res.status(400); 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, { 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
} 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
}
} }
})
// Directory to upload the files to res.status(200).json({ code: 200, message: "Success" });
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, {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
} 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"});
}); });
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, {recursive: true, mode: "666"}) mkdirSync(newPath, { recursive: true, mode: "666" })
} else { } else {
writeFileSync(newPath, "", {mode: "666"}) writeFileSync(newPath, "", { mode: "666" })
}
} catch (error: any) {
if (error.code === 'EACCES') {
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
return;
} else if (error instanceof TypeError) {
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
return;
}
} }
} catch (error: any) {
if (error.code === 'EACCES') {
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
return;
} else if (error instanceof TypeError) {
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
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.` });
}); });
/** /**
@ -321,31 +335,31 @@ v1.post("/create", (req: Request, res: Response): void => {
* The timestamp will also be appended to allow files with the same path to be deleted. * The timestamp will also be appended to allow files with the same path to be deleted.
*/ */
v1.post("/remove", (req: Request, res: Response): void => { v1.post("/remove", (req: Request, res: Response): void => {
// Get the array of paths and the root // Get the array of paths and the root
const {files, root} = req.body; const { files, root } = req.body;
// Stores the name of the trash directory // Stores the name of the trash directory
const trashDir: string = ".trash"; const trashDir: string = ".trash";
const timestamp: string = (new Date()).toISOString(); const timestamp: string = (new Date()).toISOString();
console.log(timestamp); console.log(timestamp);
for (const file of files) { for (const file of files) {
const oldPath = path.join("/", ...file); const oldPath = path.join("/", ...file);
const newPath = path.join("/", ...root, trashDir, timestamp, ...file); const newPath = path.join("/", ...root, trashDir, timestamp, ...file);
console.log(oldPath, " -> ", newPath); console.log(oldPath, " -> ", newPath);
try { try {
mkdirSync(path.dirname(newPath), {recursive: true, mode: "666"}); mkdirSync(path.dirname(newPath), { recursive: true, mode: "666" });
renameSync(oldPath, newPath) renameSync(oldPath, newPath)
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).json({code: 500, message: `Failed to delete. ${error}`}) res.status(500).json({ code: 500, message: `Failed to delete. ${error}` })
return; return;
}
} }
}
res.status(201).json({code: 201, message: `Deleted ${files.length} files.`}); res.status(201).json({ code: 201, message: `Deleted ${files.length} files.` });
}); });
/** /**
@ -357,6 +371,6 @@ APP.use("/v1", v1);
* Start the server * Start the server
*/ */
APP.listen(PORT, (): void => { APP.listen(PORT, (): void => {
printEndpoints(APP); printEndpoints(APP);
console.log(`Server listening on :${PORT}`); console.log(`Server listening on :${PORT}`);
}); });

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";
@ -11,459 +11,465 @@ import Uploader from "../components/Uploader.jsx";
import CreateDirectory from "../components/CreateDirectory.jsx"; import CreateDirectory from "../components/CreateDirectory.jsx";
export default function Dashboard() { export default function Dashboard() {
// Store the default path // Store the default path
// TODO: BACK TO NORMAL PATH // TODO: BACK TO NORMAL PATH
// const defaultPath = ["media", "vault"]; // const defaultPath = ["media", "vault"];
const defaultPath = ["home", "azpect"]; const defaultPath = ["home", "azpect"];
/** /**
* URL To the backend web server. * URL To the backend web server.
* Uses the .env var in local development, but when * Uses the .env var in local development, but when
* pushed to dockerhub, the .env is ignored and the real * pushed to dockerhub, the .env is ignored and the real
* backend URL is used. * backend URL is used.
* @type {string} * @type {string}
*/ */
const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net"; const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net";
const [token, setToken] = useState(null); const [token, setToken] = useState(null);
const [path, setPath] = useState([...defaultPath]); const [path, setPath] = useState([...defaultPath]);
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editing, setEditing] = useState(""); const [editing, setEditing] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [childrenLoading, setChildrenLoading] = useState(false); const [childrenLoading, setChildrenLoading] = useState(false);
const [downloadLoading, setDownloadLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState(false);
const [contentLoading, setContentLoading] = useState(false); const [contentLoading, setContentLoading] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
/** /**
* The name of the value stored in local storage. * The name of the value stored in local storage.
* @type {string} * @type {string}
*/ */
const storage_id = "gophernest_credentials"; const storage_id = "gophernest_credentials";
/** /**
* Update the token from the local or session storage. * Update the token from the local or session storage.
* This function assumes one of them exists. * This function assumes one of them exists.
* If it does not, the token will be null. * If it does not, the token will be null.
* This function will return the token as well, to allow for other usecases. * This function will return the token as well, to allow for other usecases.
*/ */
const updateToken = () => { const updateToken = () => {
const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id); const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id);
setToken(t); setToken(t);
return t; return t;
};
const fetchFiles = () => {
const getData = async (token) => {
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"authorization": `Bearer ${token}`,
},
});
if (!response.ok) {
console.error("Something went wrong");
}
return await response.json();
}
setChildrenLoading(true);
// If the token doesnt exit, update it and use the return value.
// This is a silly work around to prevent the first render from not working
// TODO: Fix this shit.
let tkn = token ? token : updateToken();
getData(tkn).then((data) => {
setFiles(data);
}).finally(() => {
setChildrenLoading(false);
}).catch((err) => {
setError("Failed to fetch data from server.");
console.error(err);
});
setSelected([]);
};
useEffect(() => {
fetchFiles();
}, [path, uploading, creating]);
// Redirect if the user isn't logged in, otherwise update the state.
// Store the token in the storage, it should be attached to every request.
useEffect(() => {
if (localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null) {
navigate("/login");
} else {
updateToken();
}
}, [navigate]);
/**
* Updates the path by slicing [0:index]
* @param index {number} Index to slice to.
*/
const updatePath = (index) => {
let newPath = path.slice(0, index + 1);
if (newPath.length < defaultPath.length) {
newPath = [...defaultPath];
}
setPath(newPath);
};
/**
* Set the path back to the default.
*/
const backHome = () => {
// TODO: Fix this in production
setPath([...defaultPath]);
};
/**
* Add name to the path.
* @param name Target child
*/
const appendPath = (name) => {
setPath([...path, name])
};
/**
* Back arrow, goes back one directory (cd ..)
*/
const backArrow = () => {
if (path.length > defaultPath.length) {
setPath(path.slice(0, path.length - 1));
}
}
/**
* This isn't fast, but hopefully the use case will be small batches.
* @param file {string} The file to toggle
*/
const toggleSelected = (file) => {
if (!selected.includes(file)) {
setSelected([...selected, file]);
} else {
const idx = selected.indexOf(file);
setSelected([...selected.slice(0, idx), ...selected.slice(idx + 1, selected.length)])
}
};
/**
* Callback function for when the download button is clicked.
*/
const downloadFiles = () => {
// Do not allow empty downloads
if (selected.length === 0) {
setError("Please select files/directories to download.");
return
}
const download = async (paths) => {
try {
const resp = await fetch(`${backendUrl}/v1/download`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ filePaths: paths }),
});
if (!resp.ok) {
const data = await resp.json();
setError(`Error ${data.code}: ${data.error}`);
} else {
// TODO: Figure out how tf this works.
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = "downloads.zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
} catch (err) {
console.error(`Download error: ${err}`);
}
}; };
useEffect(() => { setDownloadLoading(true);
const getData = async (token) => {
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, { const targets = [];
method: "GET", selected.forEach((file) => {
headers: { targets.push(`/${path.join("/")}/${file}`);
"Content-Type": "application/json", });
"authorization": `Bearer ${token}`,
}, // TODO: Implement UI for errors
}); download(targets).catch((err) => {
if (!response.ok) { setError(`Download error: ${err}.`)
console.error("Something went wrong"); }).finally(() => {
} setDownloadLoading(false)
return await response.json(); });
};
/**
* Clear the error in the error state.
*/
const clearError = () => {
setError(null);
}
/**
* Toggle editing of a file.
* @param path {string}
*/
const toggleEditing = (path) => {
setEditing(path);
};
const exitFile = () => {
setEditing("");
};
const exitAndSaveFile = (newContent) => {
const updateContent = async (path, content) => {
const resp = await fetch(`${backendUrl}/v1/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ path, content }),
})
if (!resp.ok) {
setError("An error occurred when saving the file. Please try again.");
}
return await resp.json();
};
// Send request to server to update the file. This will return nothing
// so no need for any promise handling.
updateContent(editing, newContent).then((data) => {
if (data.code === 200) {
setEditing("");
}
});
};
/**
* Handle the state for the content being modified in the text editor.
*/
const [editingFileContent, setEditingFileContent] = useState("");
useEffect(() => {
const fetchContent = async (path) => {
const resp = await fetch(`${backendUrl}/v1/content?path=${path}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
});
if (!resp.ok) {
// TODO: Add this back in, its broken right now.
setError("Something went wrong! Failed to get file content.")
}
return await resp.json();
};
setContentLoading(true);
// Prevent running when nothing is being edited. Also prevents a call on mount.
if (editing) {
// Fetch the data and handle errors accordingly
fetchContent(editing).then((data) => {
if (data.code === 200) {
setEditingFileContent(data.content);
} else {
// An error occurred, do not open the editor
setEditing("");
setError(data.error);
} }
}).finally(() => {
setContentLoading(false)
});
}
setChildrenLoading(true); }, [editing]);
// If the token doesnt exit, update it and use the return value. const toggleHidden = (e) => {
// This is a silly work around to prevent the first render from not working setShowHidden(e.target.checked);
// TODO: Fix this shit. };
let tkn = token ? token : updateToken();
getData(tkn).then((data) => { /**
setFiles(data); * Toggle the upload modal.
}).finally(() => { * This can be used in the navbar and the close button!
setChildrenLoading(false); */
}).catch((err) => { const toggleUploading = () => {
setError("Failed to fetch data from server."); setUploading(!uploading);
console.error(err); };
});
/**
* This will be where the magic happens, where the files are upload
* @param files {object[]}
* TODO: Actually do something here...
*/
const upload = (files) => {
const uploadFiles = async (_files) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', _files[i]); // 'files' is the field name
}
// Add the current path to the form data.
// formData.append('path', "/" + path.join("/"));
formData.append('path', JSON.stringify(path));
const resp = await fetch(`${backendUrl}/v1/upload`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
body: formData,
})
if (!resp.ok) {
const data = await resp.json()
setError(data.error);
return data;
}
return await resp.json();
};
if (files.length === 0) {
setError("Cannot upload nothing. Please select files to upload.");
return;
}
uploadFiles(files).then((data) => {
if (data.code === 200) {
setUploading(false);
}
});
};
const createDir = () => {
setCreating(true);
};
const closeCreate = () => {
setCreating(false);
};
/**
* Create a directory or file in the backend
* @param name {string} Name of new directory/file
*/
const createDirectory = (name) => {
const create = async (name, cwd) => {
const resp = await fetch(`${backendUrl}/v1/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"authorization": `Bearer ${token}`,
},
body: JSON.stringify({ name, cwd })
});
if (!resp.ok) {
const data = await resp.json()
setError(data.error);
return data;
}
return await resp.json();
};
create(name, path).then((data) => {
if (data.code === 201) {
setCreating(false);
}
}).catch((error) => {
setError(error);
});
};
/**
* 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([]); setSelected([]);
}, [path, uploading, creating]); // 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} />
<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} />}
// Redirect if the user isn't logged in, otherwise update the state. {error && <Error error={error} clear={clearError} />}
// Store the token in the storage, it should be attached to every request. {(editing !== "" && !error) &&
useEffect(() => { <Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile}
if (localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null) { loading={contentLoading} />}
navigate("/login");
} else {
updateToken();
}
}, [navigate]);
/** <PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}
* Updates the path by slicing [0:index] enabled={path.length > defaultPath.length} create={createDir} remove={removeSelected}
* @param index {number} Index to slice to. removeEnable={selected.length > 0} />
*/ <div className="w-2/3 h-5/6 overflow-y-auto border-1 border-gray-300">
const updatePath = (index) => { {childrenLoading && <ChildrenLoading />}
let newPath = path.slice(0, index + 1); <DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath}
if (newPath.length < defaultPath.length) { // TODO: Rework the toggleSelected functionality
newPath = [...defaultPath]; toggleSelected={toggleSelected} toggleEditing={toggleEditing} />
}
setPath(newPath);
};
/**
* Set the path back to the default.
*/
const backHome = () => {
// TODO: Fix this in production
setPath([...defaultPath]);
};
/**
* Add name to the path.
* @param name Target child
*/
const appendPath = (name) => {
setPath([...path, name])
};
/**
* Back arrow, goes back one directory (cd ..)
*/
const backArrow = () => {
if (path.length > defaultPath.length) {
setPath(path.slice(0, path.length - 1));
}
}
/**
* This isn't fast, but hopefully the use case will be small batches.
* @param file {string} The file to toggle
*/
const toggleSelected = (file) => {
if (!selected.includes(file)) {
setSelected([...selected, file]);
} else {
const idx = selected.indexOf(file);
setSelected([...selected.slice(0, idx), ...selected.slice(idx + 1, selected.length)])
}
};
/**
* Callback function for when the download button is clicked.
*/
const downloadFiles = () => {
// Do not allow empty downloads
if (selected.length === 0) {
setError("Please select files/directories to download.");
return
}
const download = async (paths) => {
try {
const resp = await fetch(`${backendUrl}/v1/download`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({filePaths: paths}),
});
if (!resp.ok) {
const data = await resp.json();
setError(`Error ${data.code}: ${data.error}`);
} else {
// TODO: Figure out how tf this works.
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = "downloads.zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
} catch (err) {
console.error(`Download error: ${err}`);
}
};
setDownloadLoading(true);
const targets = [];
selected.forEach((file) => {
targets.push(`/${path.join("/")}/${file}`);
});
// TODO: Implement UI for errors
download(targets).catch((err) => {
setError(`Download error: ${err}.`)
}).finally(() => {
setDownloadLoading(false)
});
};
/**
* Clear the error in the error state.
*/
const clearError = () => {
setError(null);
}
/**
* Toggle editing of a file.
* @param path {string}
*/
const toggleEditing = (path) => {
setEditing(path);
};
const exitFile = () => {
setEditing("");
};
const exitAndSaveFile = (newContent) => {
const updateContent = async (path, content) => {
const resp = await fetch(`${backendUrl}/v1/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({path, content}),
})
if (!resp.ok) {
setError("An error occurred when saving the file. Please try again.");
}
return await resp.json();
};
// Send request to server to update the file. This will return nothing
// so no need for any promise handling.
updateContent(editing, newContent).then((data) => {
if (data.code === 200) {
setEditing("");
}
});
};
/**
* Handle the state for the content being modified in the text editor.
*/
const [editingFileContent, setEditingFileContent] = useState("");
useEffect(() => {
const fetchContent = async (path) => {
const resp = await fetch(`${backendUrl}/v1/content?path=${path}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
});
if (!resp.ok) {
// TODO: Add this back in, its broken right now.
setError("Something went wrong! Failed to get file content.")
}
return await resp.json();
};
setContentLoading(true);
// Prevent running when nothing is being edited. Also prevents a call on mount.
if (editing) {
// Fetch the data and handle errors accordingly
fetchContent(editing).then((data) => {
if (data.code === 200) {
setEditingFileContent(data.content);
} else {
// An error occurred, do not open the editor
setEditing("");
setError(data.error);
}
}).finally(() => {
setContentLoading(false)
});
}
}, [editing]);
const toggleHidden = (e) => {
setShowHidden(e.target.checked);
};
/**
* Toggle the upload modal.
* This can be used in the navbar and the close button!
*/
const toggleUploading = () => {
setUploading(!uploading);
};
/**
* This will be where the magic happens, where the files are upload
* @param files {object[]}
* TODO: Actually do something here...
*/
const upload = (files) => {
const uploadFiles = async (_files) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', _files[i]); // 'files' is the field name
}
// Add the current path to the form data.
// formData.append('path', "/" + path.join("/"));
formData.append('path', JSON.stringify(path));
const resp = await fetch(`${backendUrl}/v1/upload`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
body: formData,
})
if (!resp.ok) {
const data = await resp.json()
setError(data.error);
return data;
}
return await resp.json();
};
if (files.length === 0) {
setError("Cannot upload nothing. Please select files to upload.");
return;
}
uploadFiles(files).then((data) => {
if (data.code === 200) {
setUploading(false);
}
});
};
const createDir = () => {
setCreating(true);
};
const closeCreate = () => {
setCreating(false);
};
/**
* Create a directory or file in the backend
* @param name {string} Name of new directory/file
*/
const createDirectory = (name) => {
const create = async (name, cwd) => {
const resp = await fetch(`${backendUrl}/v1/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"authorization": `Bearer ${token}`,
},
body: JSON.stringify({name, cwd})
});
if (!resp.ok) {
const data = await resp.json()
setError(data.error);
return data;
}
return await resp.json();
};
create(name, path).then((data) => {
if (data.code === 201) {
setCreating(false);
}
}).catch((error) => {
setError(error);
});
};
/**
* 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/remove`, {
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([]);
}
}).catch((error) => {
setError(error);
});
};
return (
<div className="w-full min-h-screen h-screen pb-8">
<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}/>}
{error && <Error error={error} clear={clearError}/>}
{(editing !== "" && !error) &&
<Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile}
loading={contentLoading}/>}
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}
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/>}
<DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath}
// 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>
<input
className="p-2"
name="showHiddenItems"
type="checkbox"
checked={showHidden}
onClick={toggleHidden}/>
</div>
</div>
</div> </div>
) <div className="w-2/3 flex justify-end items-center">
} <label className="text-sm mx-2" htmlFor="showHiddenItems">Show Hidden Items</label>
<input
className="p-2"
name="showHiddenItems"
type="checkbox"
checked={showHidden}
onClick={toggleHidden} />
</div>
</div>
</div>
)
}