(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:
parent
4cd88c6505
commit
b31626dce9
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user