up / index.js
semuthitamku's picture
Update index.js
42596c9 verified
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}`);
});