Spaces:
Paused
Paused
| import express from "express"; | |
| import path from "node:path"; | |
| import fs from "node:fs"; | |
| import { promises as fsp } from "node:fs"; | |
| import fsExtra from "fs-extra"; | |
| import multer from "multer"; | |
| import archiver from "archiver"; | |
| import extract from "extract-zip"; | |
| import tar from "tar"; | |
| import axios from "axios"; | |
| import os from "node:os"; | |
| import { pipeline } from "node:stream/promises"; | |
| import { fileURLToPath } from "node:url"; | |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| const app = express(); | |
| app.use(express.json({ limit: "50mb" })); | |
| app.use(express.urlencoded({ extended: true })); | |
| // ROOT_DIR default: os.tmpdir() (bisa override via env ROOT_DIR) | |
| const ROOT_DIR = path.resolve(process.env.ROOT_DIR || os.tmpdir()); | |
| fsExtra.ensureDirSync(ROOT_DIR); | |
| // Multer temp dir juga pakai tmp | |
| const uploadTmpDir = path.join(os.tmpdir(), "filemgr_uploads"); | |
| fsExtra.ensureDirSync(uploadTmpDir); | |
| const upload = multer({ dest: uploadTmpDir }); | |
| // ===== Utils (tanpa regex untuk normalisasi path) ===== | |
| const toPosix = (p) => String(p).split("\\").join("/"); | |
| function stripLeadingSlashes(s) { | |
| if (!s) return ""; | |
| let i = 0; | |
| while (i < s.length && (s[i] === "/" || s[i] === "\\")) i++; | |
| return s.slice(i); | |
| } | |
| const badName = (name) => | |
| !name || String(name).includes("..") || String(name).includes("/") || String(name).includes("\\"); | |
| function safeResolve(rel = "") { | |
| const relNorm = stripLeadingSlashes(String(rel)); | |
| const full = path.resolve(ROOT_DIR, relNorm); | |
| const relToRoot = path.relative(ROOT_DIR, full); | |
| if (relToRoot.startsWith("..") || path.isAbsolute(relToRoot)) { | |
| const err = new Error("Path outside ROOT_DIR"); | |
| err.status = 400; | |
| throw err; | |
| } | |
| return full; | |
| } | |
| function buildBreadcrumb(rel = "") { | |
| const parts = toPosix(rel).split("/").filter(Boolean); | |
| let acc = ""; | |
| const crumbs = [{ name: "root", path: "" }]; | |
| for (const p of parts) { | |
| acc = acc ? `${acc}/${p}` : p; | |
| crumbs.push({ name: p, path: acc }); | |
| } | |
| return crumbs; | |
| } | |
| // Deteksi format arsip (tanpa regex) | |
| function detectExtFull(name) { | |
| const lower = String(name).toLowerCase(); | |
| if (lower.endsWith(".tar.gz")) return "tar.gz"; | |
| if (lower.endsWith(".tgz")) return "tgz"; | |
| return path.extname(lower).slice(1); // zip/tar dll | |
| } | |
| function archiveFormatFromName(name) { | |
| const e = detectExtFull(name); | |
| if (e === "zip") return "zip"; | |
| if (e === "tar") return "tar"; | |
| if (e === "tgz" || e === "tar.gz") return "tgz"; | |
| return null; | |
| } | |
| function stripArchiveExt(baseName) { | |
| let b = String(baseName); | |
| const lower = b.toLowerCase(); | |
| if (lower.endsWith(".tar.gz")) return b.slice(0, -7); | |
| if (lower.endsWith(".tgz")) return b.slice(0, -4); | |
| if (lower.endsWith(".zip")) return b.slice(0, -4); | |
| if (lower.endsWith(".tar")) return b.slice(0, -4); | |
| return b; | |
| } | |
| function guessArchiveFormat(name, contentType = "") { | |
| const byName = archiveFormatFromName(name); | |
| if (byName) return byName; | |
| const ct = String(contentType).toLowerCase(); | |
| if (ct.includes("zip")) return "zip"; | |
| if (ct.includes("x-tar") || ct === "application/tar") return "tar"; | |
| if (ct.includes("gzip")) return "tgz"; | |
| return null; | |
| } | |
| function parseContentDispositionFilename(cd) { | |
| if (!cd) return null; | |
| // Contoh: attachment; filename="file.zip"; filename*=UTF-8''file.zip | |
| const parts = cd.split(";").map((s) => s.trim()); | |
| let fname = null; | |
| for (const p of parts) { | |
| if (p.toLowerCase().startsWith("filename*=")) { | |
| let v = p.substring(9).trim(); // setelah 'filename*=' | |
| if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { | |
| v = v.slice(1, -1); | |
| } | |
| const idx = v.indexOf("''"); | |
| if (idx !== -1) v = v.slice(idx + 2); | |
| try { fname = decodeURIComponent(v); } catch { fname = v; } | |
| break; | |
| } else if (p.toLowerCase().startsWith("filename=")) { | |
| let v = p.substring(9).trim(); | |
| if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { | |
| v = v.slice(1, -1); | |
| } | |
| try { fname = decodeURIComponent(v); } catch { fname = v; } | |
| // Jangan break dulu, utamakan filename* kalau ada (RFC) | |
| } | |
| } | |
| return fname; | |
| } | |
| // ===== Routes ===== | |
| app.get("/api/list", async (req, res, next) => { | |
| try { | |
| const rel = req.query.path ? String(req.query.path) : ""; | |
| const dirFull = safeResolve(rel); | |
| const stat = await fsExtra.stat(dirFull); | |
| if (!stat.isDirectory()) return res.status(400).json({ error: "Not a directory" }); | |
| const items = await fsp.readdir(dirFull, { withFileTypes: true }); | |
| const details = await Promise.all( | |
| items.map(async (d) => { | |
| const full = path.join(dirFull, d.name); | |
| const s = await fsExtra.stat(full); | |
| const isDir = s.isDirectory(); | |
| const ext = isDir ? "" : path.extname(d.name).slice(1).toLowerCase(); | |
| const extFull = isDir ? "" : detectExtFull(d.name); | |
| const archFormat = isDir ? null : archiveFormatFromName(d.name); | |
| return { | |
| name: d.name, | |
| relPath: toPosix(path.join(rel, d.name)), | |
| isDir, | |
| size: isDir ? null : s.size, | |
| mtime: s.mtimeMs, | |
| ext, | |
| extFull, | |
| archFormat | |
| }; | |
| }) | |
| ); | |
| details.sort((a, b) => { | |
| if (a.isDir && !b.isDir) return -1; | |
| if (!a.isDir && b.isDir) return 1; | |
| return a.name.localeCompare(b.name); | |
| }); | |
| res.json({ path: toPosix(rel), breadcrumb: buildBreadcrumb(rel), items: details }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Create folder | |
| app.post("/api/folder", async (req, res, next) => { | |
| try { | |
| const { parent = "", name } = req.body; | |
| if (badName(name)) return res.status(400).json({ error: "Invalid folder name" }); | |
| const dirFull = safeResolve(parent); | |
| await fsExtra.ensureDir(path.join(dirFull, name)); | |
| res.json({ ok: true }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Create file | |
| app.post("/api/file", async (req, res, next) => { | |
| try { | |
| const { parent = "", name, content = "" } = req.body; | |
| if (badName(name)) return res.status(400).json({ error: "Invalid file name" }); | |
| const dirFull = safeResolve(parent); | |
| const dest = path.join(dirFull, name); | |
| await fsExtra.ensureDir(path.dirname(dest)); | |
| await fsp.writeFile(dest, content, "utf8"); | |
| res.json({ ok: true }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Update text file | |
| app.put("/api/file", async (req, res, next) => { | |
| try { | |
| const { path: rel, content = "" } = req.body; | |
| if (!rel) return res.status(400).json({ error: "Missing path" }); | |
| const full = safeResolve(rel); | |
| const s = await fsExtra.stat(full); | |
| if (!s.isFile()) return res.status(400).json({ error: "Not a file" }); | |
| await fsp.writeFile(full, content, "utf8"); | |
| res.json({ ok: true }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Read small text file | |
| app.get("/api/file", async (req, res, next) => { | |
| try { | |
| const rel = String(req.query.path || ""); | |
| const full = safeResolve(rel); | |
| const s = await fsExtra.stat(full); | |
| if (!s.isFile()) return res.status(400).json({ error: "Not a file" }); | |
| if (s.size > 2 * 1024 * 1024) return res.status(400).json({ error: "File too large to view" }); | |
| const buf = await fsp.readFile(full); | |
| res.json({ content: buf.toString("utf8") }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Rename | |
| app.post("/api/rename", async (req, res, next) => { | |
| try { | |
| const { path: rel, newName } = req.body; | |
| if (!rel || badName(newName)) return res.status(400).json({ error: "Invalid input" }); | |
| const full = safeResolve(rel); | |
| const parentRel = toPosix(path.dirname(rel)); | |
| const dest = path.join(safeResolve(parentRel), newName); | |
| await fsExtra.move(full, dest, { overwrite: false }); | |
| res.json({ ok: true, newPath: toPosix(path.join(parentRel, newName)) }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Move | |
| app.post("/api/move", async (req, res, next) => { | |
| try { | |
| const { from, toDir } = req.body; | |
| if (!from) return res.status(400).json({ error: "Missing from" }); | |
| const srcFull = safeResolve(from); | |
| const toDirFull = safeResolve(toDir || ""); | |
| const base = path.basename(srcFull); | |
| const destFull = path.join(toDirFull, base); | |
| await fsExtra.move(srcFull, destFull, { overwrite: false }); | |
| res.json({ ok: true, newPath: toPosix(path.join(toDir || "", base)) }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Delete | |
| app.delete("/api/entry", async (req, res, next) => { | |
| try { | |
| const rel = String(req.query.path || ""); | |
| const full = safeResolve(rel); | |
| await fsExtra.remove(full); | |
| res.json({ ok: true }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Upload | |
| app.post("/api/upload", upload.array("files"), async (req, res, next) => { | |
| try { | |
| const rel = String(req.query.path || ""); | |
| const destDir = safeResolve(rel); | |
| await fsExtra.ensureDir(destDir); | |
| const moved = []; | |
| for (const file of req.files || []) { | |
| const target = path.join(destDir, file.originalname); | |
| await fsExtra.move(file.path, target, { overwrite: true }); | |
| moved.push({ name: file.originalname }); | |
| } | |
| res.json({ ok: true, files: moved }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Download dari URL + auto-extract (ZIP/TAR/TGZ) | |
| app.post("/api/fetch-url", async (req, res, next) => { | |
| try { | |
| const { url, destDir = "", filename, autoExtract = true, removeArchive = false, extractTo } = req.body; | |
| if (!url) return res.status(400).json({ error: "Missing url" }); | |
| const destDirFull = safeResolve(destDir); | |
| await fsExtra.ensureDir(destDirFull); | |
| const resp = await axios.get(url, { | |
| responseType: "stream", | |
| validateStatus: (s) => s >= 200 && s < 400 | |
| }); | |
| let name = filename; | |
| if (!name) { | |
| const cd = resp.headers["content-disposition"]; | |
| name = parseContentDispositionFilename(cd); | |
| if (!name) { | |
| try { | |
| const u = new URL(url); | |
| const base = path.basename(u.pathname); | |
| name = base || "downloaded.file"; | |
| } catch { | |
| name = "downloaded.file"; | |
| } | |
| } | |
| } | |
| if (badName(name)) name = `downloaded-${Date.now()}`; | |
| const savedRel = toPosix(path.join(destDir, name)); | |
| const savedFull = path.join(destDirFull, name); | |
| // Simpan file | |
| const ws = fs.createWriteStream(savedFull); | |
| await pipeline(resp.data, ws); | |
| let extractedTo = null; | |
| if (autoExtract) { | |
| const format = guessArchiveFormat(name, resp.headers["content-type"]); | |
| if (format) { | |
| let destRel = extractTo; | |
| if (!destRel) { | |
| const base = stripArchiveExt(path.basename(name)); | |
| destRel = toPosix(path.join(destDir, base)); | |
| } | |
| const destFull = safeResolve(destRel); | |
| await fsExtra.ensureDir(destFull); | |
| if (format === "zip") { | |
| await extract(savedFull, { dir: destFull }); | |
| } else if (format === "tar") { | |
| await tar.x({ file: savedFull, cwd: destFull, gzip: false }); | |
| } else if (format === "tgz") { | |
| await tar.x({ file: savedFull, cwd: destFull, gzip: true }); | |
| } | |
| extractedTo = destRel; | |
| if (removeArchive) { | |
| await fsExtra.remove(savedFull); | |
| } | |
| } | |
| } | |
| res.json({ ok: true, savedAs: savedRel, extractedTo }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Archive (zip/tar/tgz) | |
| app.post("/api/archive", async (req, res, next) => { | |
| try { | |
| const { path: rel = "", entries = [], name, format = "zip" } = req.body; | |
| const dirFull = safeResolve(rel); | |
| if (!Array.isArray(entries) || entries.length === 0) { | |
| return res.status(400).json({ error: "No entries to archive" }); | |
| } | |
| const fmt = String(format).toLowerCase(); | |
| let extName, archiverType, archiverOpts; | |
| if (fmt === "zip") { | |
| extName = "zip"; | |
| archiverType = "zip"; | |
| archiverOpts = { zlib: { level: 9 } }; | |
| } else if (fmt === "tar") { | |
| extName = "tar"; | |
| archiverType = "tar"; | |
| archiverOpts = { gzip: false }; | |
| } else if (fmt === "tgz" || fmt === "tar.gz") { | |
| extName = "tar.gz"; | |
| archiverType = "tar"; | |
| archiverOpts = { gzip: true, gzipOptions: { level: 9 } }; | |
| } else { | |
| return res.status(400).json({ error: "Unsupported format" }); | |
| } | |
| let outName = name; | |
| if (!outName) { | |
| const ts = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); | |
| outName = `archive-${ts}.${extName}`; | |
| } else { | |
| const lower = outName.toLowerCase(); | |
| const needExt = | |
| (extName === "zip" && !lower.endsWith(".zip")) || | |
| (extName === "tar" && !lower.endsWith(".tar")) || | |
| (extName === "tar.gz" && !(lower.endsWith(".tar.gz") || lower.endsWith(".tgz"))); | |
| if (needExt) outName += `.${extName}`; | |
| } | |
| if (badName(outName)) return res.status(400).json({ error: "Invalid archive name" }); | |
| const outFull = path.join(dirFull, outName); | |
| await new Promise((resolve, reject) => { | |
| const output = fs.createWriteStream(outFull); | |
| const archive = archiver(archiverType, archiverOpts); | |
| output.on("close", resolve); | |
| output.on("error", reject); | |
| archive.on("error", reject); | |
| archive.pipe(output); | |
| for (const nm of entries) { | |
| if (badName(path.basename(nm))) { | |
| archive.destroy(new Error("Invalid entry name")); | |
| return; | |
| } | |
| const full = path.join(dirFull, nm); | |
| const nameInArchive = path.basename(nm); | |
| if (fs.existsSync(full)) { | |
| const stat = fs.lstatSync(full); | |
| if (stat.isDirectory()) archive.directory(full, nameInArchive); | |
| else archive.file(full, { name: nameInArchive }); | |
| } | |
| } | |
| archive.finalize(); | |
| }); | |
| res.json({ ok: true, archive: toPosix(path.join(rel, outName)) }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Unarchive (zip/tar/tgz) | |
| app.post("/api/unarchive", async (req, res, next) => { | |
| try { | |
| const { zipPath, destDir } = req.body; | |
| if (!zipPath) return res.status(400).json({ error: "archive path required" }); | |
| const archiveFull = safeResolve(zipPath); | |
| const format = archiveFormatFromName(archiveFull); | |
| if (!format) return res.status(400).json({ error: "Unsupported archive format" }); | |
| let destRel = destDir; | |
| if (!destRel) { | |
| const base = stripArchiveExt(path.basename(archiveFull)); | |
| destRel = toPosix(path.join(path.dirname(zipPath), base)); | |
| } | |
| const destFull = safeResolve(destRel); | |
| await fsExtra.ensureDir(destFull); | |
| if (format === "zip") { | |
| await extract(archiveFull, { dir: destFull }); | |
| } else if (format === "tar") { | |
| await tar.x({ file: archiveFull, cwd: destFull, gzip: false }); | |
| } else if (format === "tgz") { | |
| await tar.x({ file: archiveFull, cwd: destFull, gzip: true }); | |
| } | |
| res.json({ ok: true, extractedTo: destRel }); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Download file ke client | |
| app.get("/api/download", async (req, res, next) => { | |
| try { | |
| const rel = String(req.query.path || ""); | |
| const full = safeResolve(rel); | |
| const s = await fsExtra.stat(full); | |
| if (!s.isFile()) return res.status(400).json({ error: "Not a file" }); | |
| res.download(full, path.basename(full)); | |
| } catch (err) { | |
| next(err); | |
| } | |
| }); | |
| // Serve UI | |
| app.use("/", express.static(path.join(__dirname, "public"))); | |
| app.use((err, req, res, next) => { | |
| console.error(err); | |
| const status = err.status || 500; | |
| res.status(status).json({ error: err.message || "Internal error" }); | |
| }); | |
| const PORT = process.env.PORT || 3000; | |
| app.listen(PORT, () => { | |
| console.log(`File Manager (ESM) at http://localhost:${PORT}`); | |
| console.log(`ROOT_DIR = ${ROOT_DIR}`); | |
| }); |