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}`); });