diff --git a/backend/src/server.ts b/backend/src/server.ts index db2627f..6ca0415 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,33 +1,35 @@ -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!`); + console.error(`Could not load the .env file. If this is a production environment, this is normal!`); } /** @@ -39,18 +41,28 @@ const INVALID_EXTS: string[] = ["exe", "dll", "obj", "lib", "bin", "dat", "pdf", * Configure cors, this should work for both production and development. */ const corsOptions: cors.CorsOptions = { - origin: ["http://localhost:3100", "https://file.gophernest.net"], - methods: ["GET", "POST"] + origin: ["http://localhost:3100", "https://file.gophernest.net"], + methods: ["GET", "POST"] }; 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 @@ -61,140 +73,140 @@ const v1: Router = express.Router(); * Return a healthcheck interface loaded with the server's health */ v1.get("/healthcheck", (req: Request, res: Response): void => { - let hc: Healthcheck = { - health: "Server is in bad health", - errors: ["The root directory could not be found."], - directory_found: false, - }; + let hc: Healthcheck = { + health: "Server is in bad health", + errors: ["The root directory could not be found."], + directory_found: false, + }; - res.status(200).json(hc); + res.status(200).json(hc); }); /** * Make a log in attempt. */ v1.post("/login", (req: Request, res: Response): void => { - // Get info from body - const {username, password} = req.body; + // Get info from body + const { username, password } = req.body; - // Get required info from the environment and validate - if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) { - // Get the secret from the env - const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"]; - if (!jwt_secret) { - res.status(500).json({code: 500, message: "JSON web tokens are not configured."}); - return; - } - - // Create the token - const token: string = jwt.sign({username}, jwt_secret, {expiresIn: "30d"}); - res.status(200).json({code: 200, token}); - } else { - res.status(404).json({code: 404, message: "Invalid credentials. Please try again!"}); + // Get required info from the environment and validate + if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) { + // Get the secret from the env + const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"]; + if (!jwt_secret) { + res.status(500).json({ code: 500, message: "JSON web tokens are not configured." }); + return; } + + // Create the token + const token: string = jwt.sign({ username }, jwt_secret, { expiresIn: "30d" }); + res.status(200).json({ code: 200, token }); + } else { + res.status(404).json({ code: 404, message: "Invalid credentials. Please try again!" }); + } }); /** * Get the children of a directory provided in the path query. */ v1.get("/children", (req: Request, res: Response): void => { - // Get the path, if it was not provided, use the root - const path: string = (req.query.path || ROOT) as string; + // Get the path, if it was not provided, use the root + const path: string = (req.query.path || ROOT) as string; - // An array of names which are the children - const children_paths: string[] = fs.readdirSync(path); + // An array of names which are the children + const children_paths: string[] = fs.readdirSync(path); - // Store a list of the children as entries - const children: entry[] = []; + // Store a list of the children as entries + const children: entry[] = []; - for (const child of children_paths) { - try { - const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory(); - children.push({ - name: child, - path: path.concat("/", child), - directory: isDir - }); - } catch (error) { - console.log(error); - } + for (const child of children_paths) { + try { + const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory(); + children.push({ + name: child, + path: path.concat("/", child), + directory: isDir + }); + } catch (error) { + console.log(error); } - res.status(200).json(children); + } + res.status(200).json(children); }); /** * Down a group of files provided in the body. */ v1.post("/download", (req: Request, res: Response): void => { - // Get the files from the body - const {filePaths} = req.body; + // Get the files from the body + const { filePaths } = req.body; - // Validate the path array - if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) { - res.status(400).send({code: 400, error: 'Invalid file paths provided.'}); - return; + // Validate the path array + if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) { + res.status(400).send({ code: 400, error: 'Invalid file paths provided.' }); + return; + } + + const archive: archiver.Archiver = archiver('zip', { + zlib: { level: 9 }, // Compression leve + }); + + // Set the file headers + res.setHeader('Content-Disposition', 'attachment; filename=donwloads.zip'); + res.setHeader('Content-Type', 'application/zip'); + + archive.pipe(res); + + // Add each file to the archive + filePaths.forEach((filePath): void => { + // This works for files, but for directories, we need to read the files + + try { + const stats = fs.statSync(filePath); + if (!stats.isDirectory()) { + appendFileToArchive(filePath, archive); + } else { + // Call this with the name to include the name of the directory + appendDirectoryToArchive(filePath, path.basename(filePath), archive); + } + } catch (err) { + console.error(`Error adding file to zip: ${err}`); } + }); - const archive: archiver.Archiver = archiver('zip', { - zlib: {level: 9}, // Compression leve - }); + // Return errors + archive.on('error', (err): void => { + res.status(500).send({ error: err.message }); + }); - // Set the file headers - res.setHeader('Content-Disposition', 'attachment; filename=donwloads.zip'); - res.setHeader('Content-Type', 'application/zip'); - - archive.pipe(res); - - // Add each file to the archive - filePaths.forEach((filePath): void => { - // This works for files, but for directories, we need to read the files - - try { - const stats = fs.statSync(filePath); - if (!stats.isDirectory()) { - appendFileToArchive(filePath, archive); - } else { - // Call this with the name to include the name of the directory - appendDirectoryToArchive(filePath, path.basename(filePath), archive); - } - } catch (err) { - console.error(`Error adding file to zip: ${err}`); - } - }); - - // Return errors - archive.on('error', (err): void => { - res.status(500).send({error: err.message}); - }); - - archive.finalize(); + archive.finalize(); }); /** * Retrieve the content of a file provided in the path query. */ v1.get("/content", (req: Request, res: Response): void => { - // Get the path, if it was not provided, return no content - const tgtPath: string = (req.query.path || "") as string; - if (!tgtPath) { - res.status(204); - return; - } + // Get the path, if it was not provided, return no content + const tgtPath: string = (req.query.path || "") as string; + if (!tgtPath) { + res.status(204); + return; + } - // Ensure the file isn't something we can't edit - const ext: string = path.extname(tgtPath).slice(1); - if (INVALID_EXTS.includes(ext)) { - res.status(400).json({error: `Cannot edit files of type *.${ext}`, code: 400}); - return; - } + // Ensure the file isn't something we can't edit + const ext: string = path.extname(tgtPath).slice(1); + if (INVALID_EXTS.includes(ext)) { + res.status(400).json({ error: `Cannot edit files of type *.${ext}`, code: 400 }); + return; + } - try { - // Read the file and return it - const content = fs.readFileSync(tgtPath, {encoding: "utf-8", flag: "r"}) - res.status(200).json({content, code: 200}); - } catch (err) { - res.status(500).json({error: `An error occurred on the server. ${err}`, code: 500}); - } + try { + // Read the file and return it + const content = fs.readFileSync(tgtPath, { encoding: "utf-8", flag: "r" }) + res.status(200).json({ content, code: 200 }); + } catch (err) { + res.status(500).json({ error: `An error occurred on the server. ${err}`, code: 500 }); + } }); /** @@ -202,105 +214,152 @@ v1.get("/content", (req: Request, res: Response): void => { * On success, nothing should be sent back, 204. */ v1.post("/update", (req: Request, res: Response): void => { - // Get path and content from the request - const {path, content} = req.body; + // Get path and content from the request + const { path, content } = req.body; - try { - fs.writeFileSync(path, content); - res.status(200).json({code: 200, message: "Success"}); - } catch (error) { - res.status(500).json({code: 500, error}) - } + try { + fs.writeFileSync(path, content); + res.status(200).json({ code: 200, message: "Success" }); + } catch (error) { + res.status(500).json({ code: 500, error }) + } }); -const upload = Multer({ - dest: "tmp/", - limits: { - fileSize: 1024 * 1024 * 100 - }, -}); /** * Custom type for the multer uploads. */ interface UploadedFile { - fieldname: string; - originalname: string; - encoding: string; - mimetype: string; - destination: string; - filename: string; - path: string; - size: number; + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + destination: string; + filename: string; + path: string; + size: number; } // IMPORTANT! Calling this will expect sudo in places in the FS that require sudo v1.post("/upload", upload.array("files"), (req: Request, res: Response) => { - if (!req.files) { - res.status(400); + if (!req.files) { + res.status(400); + } + + // Directory to upload the files to + const cwd: string[] = JSON.parse(req.body.path); + const files = (req as any).files as UploadedFile[]; + files.forEach((file) => { + try { + // Get the data that was written to the local tmp path + const data: Buffer = readFileSync(file.path); + + // Generate the new path in the FS + const newPath: string = path.join("/", ...cwd, file.originalname); + + // Write the new file + writeFileSync(newPath, data, { 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 - const cwd: string[] = JSON.parse(req.body.path); - const files = (req as any).files as UploadedFile[]; - files.forEach((file) => { - try { - // Get the data that was written to the local tmp path - const data: Buffer = readFileSync(file.path); - - // Generate the new path in the FS - const newPath: string = path.join("/", ...cwd, file.originalname); - - // Write the new file - writeFileSync(newPath, data); - - // Delete the tmp file using a relative path - rmSync("./" + file.path); - } catch (error: any) { - if (error.code === 'EACCES') { - return res.status(403).json({code: 403, error: "Permission denied."}); // Specific error - } else if (error.code === 'ENOSPC') { - return res.status(507).json({code: 507, error: "Insufficient storage."}); // Specific error - } else if (error instanceof TypeError) { - return res.status(400).json({code: 400, error: "Invalid data type."}); // Example of instance check - } else { - return res.status(500).json({code: 500, error: "Error processing file."}); // Generic error - } - } - }) - - res.status(200).json({code: 200, message: "Success"}); + 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; + // Generate the path to create + const { cwd, name } = req.body; - try { - const newPath: string = path.join("/", ...cwd, name); - if (name.endsWith("/")) { - mkdirSync(newPath, {mode: "644"}) - } else { - console.log("NOT DIR"); - writeFileSync(newPath, "", {mode: "644"}) - } - } 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; - } + try { + const newPath: string = path.join("/", ...cwd, name); + if (name.endsWith("/")) { + mkdirSync(newPath, { recursive: true, mode: "666" }) + } else { + 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; + } + } - 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//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.` }); }); /** @@ -312,6 +371,6 @@ APP.use("/v1", v1); * Start the server */ APP.listen(PORT, (): void => { - printEndpoints(APP); - console.log(`Server listening on :${PORT}`); + printEndpoints(APP); + console.log(`Server listening on :${PORT}`); }); diff --git a/frontend/src/components/PathDisplay.jsx b/frontend/src/components/PathDisplay.jsx index 0927f46..b108c23 100644 --- a/frontend/src/components/PathDisplay.jsx +++ b/frontend/src/components/PathDisplay.jsx @@ -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 ( + + ); +} + /** * * @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 (
@@ -84,6 +102,9 @@ export default function PathDisplay({path, updatePath, backHome, backArrow, enab {path.map((seg, idx) => )}
+ +
+
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 352bb0a..064c8d7 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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"; @@ -11,412 +11,465 @@ import Uploader from "../components/Uploader.jsx"; import CreateDirectory from "../components/CreateDirectory.jsx"; export default function Dashboard() { - // Store the default path - const defaultPath = ["media", "vault"]; + // Store the default path + // TODO: BACK TO NORMAL PATH + // const defaultPath = ["media", "vault"]; + const defaultPath = ["home", "azpect"]; - /** - * URL To the backend web server. - * Uses the .env var in local development, but when - * pushed to dockerhub, the .env is ignored and the real - * backend URL is used. - * @type {string} - */ - const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net"; + /** + * URL To the backend web server. + * Uses the .env var in local development, but when + * pushed to dockerhub, the .env is ignored and the real + * backend URL is used. + * @type {string} + */ + const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net"; - const [token, setToken] = useState(null); - const [path, setPath] = useState([...defaultPath]); - const [showHidden, setShowHidden] = useState(false); - const [selected, setSelected] = useState([]); - const [files, setFiles] = useState([]); - const [error, setError] = useState(null); - const [editing, setEditing] = useState(""); - const [uploading, setUploading] = useState(false); - const [creating, setCreating] = useState(false); - const [childrenLoading, setChildrenLoading] = useState(false); - const [downloadLoading, setDownloadLoading] = useState(false); - const [contentLoading, setContentLoading] = useState(false); - const navigate = useNavigate(); + const [token, setToken] = useState(null); + const [path, setPath] = useState([...defaultPath]); + const [showHidden, setShowHidden] = useState(false); + const [selected, setSelected] = useState([]); + const [files, setFiles] = useState([]); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(""); + const [uploading, setUploading] = useState(false); + const [creating, setCreating] = useState(false); + const [childrenLoading, setChildrenLoading] = useState(false); + const [downloadLoading, setDownloadLoading] = useState(false); + const [contentLoading, setContentLoading] = useState(false); + const navigate = useNavigate(); - /** - * The name of the value stored in local storage. - * @type {string} - */ - const storage_id = "gophernest_credentials"; + /** + * The name of the value stored in local storage. + * @type {string} + */ + const storage_id = "gophernest_credentials"; - /** - * Update the token from the local or session storage. - * This function assumes one of them exists. - * If it does not, the token will be null. - * This function will return the token as well, to allow for other usecases. - */ - const updateToken = () => { - const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id); - setToken(t); - return t; + /** + * Update the token from the local or session storage. + * This function assumes one of them exists. + * If it does not, the token will be null. + * This function will return the token as well, to allow for other usecases. + */ + const updateToken = () => { + const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id); + setToken(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(() => { - 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(); + 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) + }); + } - setChildrenLoading(true); + }, [editing]); - // 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(); + const toggleHidden = (e) => { + setShowHidden(e.target.checked); + }; - getData(tkn).then((data) => { - setFiles(data); - }).finally(() => { - setChildrenLoading(false); - }).catch((err) => { - setError("Failed to fetch data from server."); - console.error(err); - }); + /** + * 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/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([]); - }, [path, uploading, creating]); + // Fetch the new files & deselect everything + fetchFiles(); + } + }).catch((error) => { + setError(error); + }); + }; + return ( +
+ +
+ {downloadLoading && } + {creating && } + {uploading && } - // 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]); + {error && } + {(editing !== "" && !error) && + } - /** - * 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}`); - } - }; - - 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); - }); - }; - - return ( -
- -
- {downloadLoading && } - {creating && } - {uploading && } - - {error && } - {(editing !== "" && !error) && - } - - defaultPath.length} create={createDir}/> -
- {childrenLoading && } - -
-
- - -
-
+ defaultPath.length} create={createDir} remove={removeSelected} + removeEnable={selected.length > 0} /> +
+ {childrenLoading && } +
- ) -} \ No newline at end of file +
+ + +
+
+
+ ) +}