semuthitamku commited on
Commit
d7d2554
·
verified ·
1 Parent(s): 9e0b8e0

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +344 -212
index.js CHANGED
@@ -1,255 +1,387 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import fs from 'node:fs/promises';
4
- import express from 'express';
5
- import serveIndex from 'serve-index';
6
- import bytes from 'bytes';
7
- import { fileTypeFromBuffer } from 'file-type';
8
- import cloudcmd from 'cloudcmd';
9
-
10
- const PORT = Number(process.env.PORT || 7860);
11
- const LIMIT_SIZE = process.env.LIMIT_SIZE || '500mb';
12
- const TMP_DIR = process.env.TMP_DIR || path.join(os.tmpdir());
13
- const MAX_BYTES = typeof LIMIT_SIZE === 'string' ? bytes.parse(LIMIT_SIZE) : LIMIT_SIZE;
14
-
15
- const toHuman = (n) =>
16
- typeof bytes.format === 'function'
17
- ? bytes.format(n, { unitSeparator: ' ' })
18
- : bytes(n, { unitSeparator: ' ' });
19
-
20
- await fs.mkdir(TMP_DIR, { recursive: true });
21
 
22
  const app = express();
23
- app.set('json spaces', 4);
24
- app.use(express.json({ limit: LIMIT_SIZE }));
25
- app.use(express.urlencoded({ extended: true, limit: LIMIT_SIZE }));
26
-
27
- // Logger
28
- app.use((req, _res, next) => {
29
- const time = new Date().toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' });
30
- console.log(`[${time}] ${req.method}: ${req.url}`);
31
- next();
32
- });
33
-
34
- // Static + directory listing
35
- app.use('/file', express.static(TMP_DIR));
36
- app.use('/file', serveIndex(TMP_DIR, { hidden: true, icons: true }));
37
-
38
- app.use(
39
- '/manage',
40
- cloudcmd({
41
- root: TMP_DIR,
42
- prefix: '/manage',
43
- })
44
- );
45
-
46
- // Helper: buat URL absolut untuk file
47
- function fileUrl(req, fileName) {
48
- const host = process.env.SPACE_HOST || req.get('host');
49
- const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
50
- return `${proto}://${host}/file/${encodeURIComponent(fileName)}`;
51
- }
52
-
53
- // Helper: decode base64 (dukungan data URL + base64url)
54
- function decodeBase64ToBuffer(input) {
55
- if (typeof input !== 'string') {
56
- throw new Error('Input harus string base64');
57
  }
58
- let raw = input.trim();
59
-
60
- // data:[mime];base64,<data>
61
- const comma = raw.indexOf(',');
62
- if (raw.startsWith('data:') && comma !== -1) {
63
- raw = raw.slice(comma + 1);
 
 
 
64
  }
65
-
66
- raw = raw.replace(/\s+/g, '');
67
- // dukung base64url
68
- raw = raw.replace(/-/g, '+').replace(/_/g, '/');
69
- const pad = raw.length % 4;
70
- if (pad) raw += '='.repeat(4 - pad);
71
-
72
- const buf = Buffer.from(raw, 'base64');
73
- // validasi round-trip
74
- const reEncoded = buf.toString('base64').replace(/=+$/, '');
75
- const normalized = raw.replace(/=+$/, '');
76
- if (reEncoded !== normalized) throw new Error('Base64 tidak valid');
77
- return buf;
 
 
 
 
 
 
 
 
78
  }
79
 
80
- // FE sederhana
81
- app.get('/', (_req, res) => {
82
- res.type('html').send(`<!doctype html>
83
- <html lang="id">
84
- <head>
85
- <meta charset="utf-8" />
86
- <meta name="viewport" content="width=device-width,initial-scale=1" />
87
- <title>Downloader -> tmp</title>
88
- <style>
89
- body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:720px;margin:40px auto;padding:0 16px;}
90
- form{display:flex;gap:8px}
91
- input[type=url]{flex:1;padding:10px;border:1px solid #ccc;border-radius:8px}
92
- button{padding:10px 14px;border:0;background:#0b5;color:#fff;border-radius:8px;cursor:pointer}
93
- button:disabled{opacity:.6;cursor:not-allowed}
94
- .result{margin-top:16px;white-space:pre-wrap;background:#f7f7f7;padding:12px;border-radius:8px;border:1px solid #eee}
95
- .tips{margin-top:10px;font-size:.9em;color:#555}
96
- a{color:#06c;text-decoration:none}
97
- </style>
98
- </head>
99
- <body>
100
- <h1>Download ke tmp folder</h1>
101
- <p class="tips">Limit ukuran: ${LIMIT_SIZE}. Lihat daftar file di <a href="/file" target="_blank">/file</a> atau kelola di <a href="/manage" target="_blank">/manage</a>.</p>
102
-
103
- <form id="dl-form">
104
- <input id="url" type="url" placeholder="https://contoh.com/file.jpg" required />
105
- <button id="submit" type="submit">Download</button>
106
- </form>
107
-
108
- <div id="out" class="result" hidden></div>
109
-
110
- <script>
111
- const form = document.getElementById('dl-form');
112
- const out = document.getElementById('out');
113
- const btn = document.getElementById('submit');
114
-
115
- form.addEventListener('submit', async (e) => {
116
- e.preventDefault();
117
- const url = document.getElementById('url').value.trim();
118
- if (!url) return;
119
-
120
- btn.disabled = true;
121
- out.hidden = true;
122
- out.textContent = '';
123
-
124
- try {
125
- const resp = await fetch('/download', {
126
- method: 'POST',
127
- headers: {'Content-Type': 'application/json'},
128
- body: JSON.stringify({ url })
129
- });
130
- const data = await resp.json();
131
- btn.disabled = false;
132
- out.hidden = false;
133
-
134
- if (!resp.ok) {
135
- out.textContent = 'Error: ' + (data.message || resp.statusText);
136
- return;
137
- }
138
 
139
- const link = data.url || ('/file/' + encodeURIComponent(data.name));
140
- out.innerHTML = 'Saved as: <b>' + data.name + '</b> (' + data.size.readable + ')<br/>' +
141
- 'Type: ' + (data.type && data.type.mime) + '<br/>' +
142
- 'Open: <a href="' + link + '" target="_blank" rel="noopener">' + link + '</a>';
143
- } catch (err) {
144
- btn.disabled = false;
145
- out.hidden = false;
146
- out.textContent = 'Error: ' + err.message;
147
- }
148
- });
149
- </script>
150
- </body>
151
- </html>`);
152
  });
153
 
154
- // Upload base64
155
- app.post('/upload', async (req, res) => {
156
- const { file } = req.body || {};
157
- if (!file || typeof file !== 'string') {
158
- return res.status(400).json({ message: 'Payload body "file" harus base64 string' });
 
 
 
 
159
  }
 
160
 
 
161
  try {
162
- const fileBuffer = decodeBase64ToBuffer(file);
163
- const ftype = (await fileTypeFromBuffer(fileBuffer)) || {
164
- mime: 'application/octet-stream',
165
- ext: 'bin',
166
- };
167
-
168
- const name = `${(ftype.mime.split('/')[0] || 'file')}-${Math.random().toString(36).slice(2)}.${ftype.ext}`;
169
- await fs.writeFile(path.join(TMP_DIR, name), fileBuffer);
170
-
171
- return res.json({
172
- name,
173
- size: { bytes: fileBuffer.length, readable: toHuman(fileBuffer.length) },
174
- type: ftype,
175
- url: fileUrl(req, name),
176
- });
177
  } catch (err) {
178
- return res.status(400).json({ message: err.message || 'Gagal memproses base64' });
179
  }
180
  });
181
 
182
- // Download by URL -> simpan ke TMP_DIR
183
- app.post('/download', async (req, res) => {
184
- const { url } = req.body || {};
185
- if (!url || typeof url !== 'string') {
186
- return res.status(400).json({ message: 'Body.url wajib diisi' });
 
 
 
 
 
 
187
  }
 
188
 
189
- let resp;
190
  try {
191
- resp = await fetch(url, { redirect: 'follow' });
 
 
 
 
 
 
192
  } catch (err) {
193
- return res.status(400).json({ message: `Gagal mengunduh URL: ${err.message}` });
194
  }
 
195
 
196
- if (!resp.ok) {
197
- return res.status(resp.status).json({ message: `Gagal mengunduh (${resp.status} ${resp.statusText})` });
 
 
 
 
 
 
 
 
 
198
  }
 
199
 
200
- // Early check lewat Content-Length
201
- const contentLengthHeader = resp.headers.get('content-length');
202
- const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
203
- if (contentLength && contentLength > MAX_BYTES) {
204
- return res.status(413).json({ message: `Ukuran file (${toHuman(contentLength)}) melebihi limit ${LIMIT_SIZE}` });
 
 
 
 
 
 
 
205
  }
 
206
 
207
- // Baca body ke Buffer dengan limit
208
- if (!resp.body) {
209
- return res.status(400).json({ message: 'Response tidak memiliki body' });
 
 
 
 
 
210
  }
 
211
 
212
- const reader = resp.body.getReader();
213
- const chunks = [];
214
- let total = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
 
216
  try {
217
- while (true) {
218
- const { done, value } = await reader.read();
219
- if (done) break;
220
- total += value.byteLength;
221
- if (total > MAX_BYTES) {
222
- try { reader.cancel(); } catch {}
223
- return res.status(413).json({ message: `Ukuran file melebihi limit ${LIMIT_SIZE}` });
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
225
- chunks.push(Buffer.from(value));
226
  }
 
 
 
 
 
227
  } catch (err) {
228
- try { reader.cancel(); } catch {}
229
- return res.status(400).json({ message: `Gagal membaca stream: ${err.message}` });
230
  }
 
231
 
232
- const fileBuffer = Buffer.concat(chunks);
 
 
 
 
 
 
233
 
234
- let ftype = await fileTypeFromBuffer(fileBuffer);
235
- if (!ftype) ftype = { mime: 'application/octet-stream', ext: 'bin' };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
- const name = `${(ftype.mime.split('/')[0] || 'file')}-${Math.random().toString(36).slice(2)}.${ftype.ext}`;
238
- await fs.writeFile(path.join(TMP_DIR, name), fileBuffer);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- return res.json({
241
- name,
242
- size: { bytes: fileBuffer.length, readable: toHuman(fileBuffer.length) },
243
- type: ftype,
244
- url: fileUrl(req, name),
245
- });
246
  });
247
 
248
- app.all('/', (_req, res, next) => next()); // biar GET / tetap ke FE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
 
250
  app.listen(PORT, () => {
251
- console.log(`App running on port ${PORT}`);
252
- console.log(`Files dir: ${TMP_DIR}`);
253
- console.log(`Listing: http://localhost:${PORT}/file`);
254
- console.log(`Manager: http://localhost:${PORT}/manage`);
255
  });
 
1
+ import express from "express";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { promises as fsp } from "node:fs";
5
+ import fsExtra from "fs-extra";
6
+ import multer from "multer";
7
+ import archiver from "archiver";
8
+ import extract from "extract-zip";
9
+ import tar from "tar";
10
+ import axios from "axios";
11
+ import os from "node:os";
12
+ import { pipeline } from "node:stream/promises";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 
 
 
 
16
 
17
  const app = express();
18
+ app.use(express.json({ limit: "50mb" }));
19
+ app.use(express.urlencoded({ extended: true }));
20
+
21
+ // ROOT_DIR default ke os.tmpdir(), bisa override via env ROOT_DIR
22
+ const ROOT_DIR = path.resolve(process.env.ROOT_DIR || os.tmpdir());
23
+ fsExtra.ensureDirSync(ROOT_DIR);
24
+
25
+ // Multer temp dir juga pakai tmp
26
+ const uploadTmpDir = path.join(os.tmpdir(), "filemgr_uploads");
27
+ fsExtra.ensureDirSync(uploadTmpDir);
28
+ const upload = multer({ dest: uploadTmpDir });
29
+
30
+ // Utils
31
+ const toPosix = (p) => p.replace(/\\/g, "/");
32
+ const badName = (name) => !name || name.includes("..") || name.includes("/") || name.includes("\\");
33
+ function safeResolve(rel = "") {
34
+ const relNorm = String(rel).replace(/^[/\```+/, "");
35
+ const full = path.resolve(ROOT_DIR, relNorm);
36
+ if (!full.startsWith(ROOT_DIR)) {
37
+ const err = new Error("Path outside ROOT_DIR");
38
+ err.status = 400;
39
+ throw err;
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
+ return full;
42
+ }
43
+ function buildBreadcrumb(rel = "") {
44
+ const parts = toPosix(rel).split("/").filter(Boolean);
45
+ let acc = "";
46
+ const crumbs = [{ name: "root", path: "" }];
47
+ for (const p of parts) {
48
+ acc = acc ? `${acc}/${p}` : p;
49
+ crumbs.push({ name: p, path: acc });
50
  }
51
+ return crumbs;
52
+ }
53
+ function detectExtFull(name) {
54
+ const lower = name.toLowerCase();
55
+ if (lower.endsWith(".tar.gz")) return "tar.gz";
56
+ if (lower.endsWith(".tgz")) return "tgz";
57
+ return path.extname(lower).slice(1);
58
+ }
59
+ function archiveFormatFromName(name) {
60
+ const e = detectExtFull(name);
61
+ if (e === "zip") return "zip";
62
+ if (e === "tar") return "tar";
63
+ if (e === "tgz" || e === "tar.gz") return "tgz";
64
+ return null;
65
+ }
66
+ function stripArchiveExt(baseName) {
67
+ return baseName
68
+ .replace(/\.tar\.gz$/i, "")
69
+ .replace(/\.tgz$/i, "")
70
+ .replace(/\.zip$/i, "")
71
+ .replace(/\.tar$/i, "");
72
  }
73
 
74
+ // Routes
75
+ app.get("/api/list", async (req, res, next) => {
76
+ try {
77
+ const rel = req.query.path ? String(req.query.path) : "";
78
+ const dirFull = safeResolve(rel);
79
+ const stat = await fsExtra.stat(dirFull);
80
+ if (!stat.isDirectory()) return res.status(400).json({ error: "Not a directory" });
81
+
82
+ const items = await fsp.readdir(dirFull, { withFileTypes: true });
83
+ const details = await Promise.all(
84
+ items.map(async (d) => {
85
+ const full = path.join(dirFull, d.name);
86
+ const s = await fsExtra.stat(full);
87
+ const isDir = s.isDirectory();
88
+ const ext = isDir ? "" : path.extname(d.name).slice(1).toLowerCase();
89
+ const extFull = isDir ? "" : detectExtFull(d.name);
90
+ const archFormat = isDir ? null : archiveFormatFromName(d.name);
91
+ return {
92
+ name: d.name,
93
+ relPath: toPosix(path.join(rel, d.name)),
94
+ isDir,
95
+ size: isDir ? null : s.size,
96
+ mtime: s.mtimeMs,
97
+ ext,
98
+ extFull,
99
+ archFormat
100
+ };
101
+ })
102
+ );
103
+
104
+ details.sort((a, b) => {
105
+ if (a.isDir && !b.isDir) return -1;
106
+ if (!a.isDir && b.isDir) return 1;
107
+ return a.name.localeCompare(b.name);
108
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
+ res.json({ path: toPosix(rel), breadcrumb: buildBreadcrumb(rel), items: details });
111
+ } catch (err) {
112
+ next(err);
113
+ }
 
 
 
 
 
 
 
 
 
114
  });
115
 
116
+ app.post("/api/folder", async (req, res, next) => {
117
+ try {
118
+ const { parent = "", name } = req.body;
119
+ if (badName(name)) return res.status(400).json({ error: "Invalid folder name" });
120
+ const dirFull = safeResolve(parent);
121
+ await fsExtra.ensureDir(path.join(dirFull, name));
122
+ res.json({ ok: true });
123
+ } catch (err) {
124
+ next(err);
125
  }
126
+ });
127
 
128
+ app.post("/api/file", async (req, res, next) => {
129
  try {
130
+ const { parent = "", name, content = "" } = req.body;
131
+ if (badName(name)) return res.status(400).json({ error: "Invalid file name" });
132
+ const dirFull = safeResolve(parent);
133
+ const dest = path.join(dirFull, name);
134
+ await fsExtra.ensureDir(path.dirname(dest));
135
+ await fsp.writeFile(dest, content, "utf8");
136
+ res.json({ ok: true });
 
 
 
 
 
 
 
 
137
  } catch (err) {
138
+ next(err);
139
  }
140
  });
141
 
142
+ app.put("/api/file", async (req, res, next) => {
143
+ try {
144
+ const { path: rel, content = "" } = req.body;
145
+ if (!rel) return res.status(400).json({ error: "Missing path" });
146
+ const full = safeResolve(rel);
147
+ const s = await fsExtra.stat(full);
148
+ if (!s.isFile()) return res.status(400).json({ error: "Not a file" });
149
+ await fsp.writeFile(full, content, "utf8");
150
+ res.json({ ok: true });
151
+ } catch (err) {
152
+ next(err);
153
  }
154
+ });
155
 
156
+ app.get("/api/file", async (req, res, next) => {
157
  try {
158
+ const rel = String(req.query.path || "");
159
+ const full = safeResolve(rel);
160
+ const s = await fsExtra.stat(full);
161
+ if (!s.isFile()) return res.status(400).json({ error: "Not a file" });
162
+ if (s.size > 2 * 1024 * 1024) return res.status(400).json({ error: "File too large to view" });
163
+ const buf = await fsp.readFile(full);
164
+ res.json({ content: buf.toString("utf8") });
165
  } catch (err) {
166
+ next(err);
167
  }
168
+ });
169
 
170
+ app.post("/api/rename", async (req, res, next) => {
171
+ try {
172
+ const { path: rel, newName } = req.body;
173
+ if (!rel || badName(newName)) return res.status(400).json({ error: "Invalid input" });
174
+ const full = safeResolve(rel);
175
+ const parentRel = toPosix(path.dirname(rel));
176
+ const dest = path.join(safeResolve(parentRel), newName);
177
+ await fsExtra.move(full, dest, { overwrite: false });
178
+ res.json({ ok: true, newPath: toPosix(path.join(parentRel, newName)) });
179
+ } catch (err) {
180
+ next(err);
181
  }
182
+ });
183
 
184
+ app.post("/api/move", async (req, res, next) => {
185
+ try {
186
+ const { from, toDir } = req.body;
187
+ if (!from) return res.status(400).json({ error: "Missing from" });
188
+ const srcFull = safeResolve(from);
189
+ const toDirFull = safeResolve(toDir || "");
190
+ const base = path.basename(srcFull);
191
+ const destFull = path.join(toDirFull, base);
192
+ await fsExtra.move(srcFull, destFull, { overwrite: false });
193
+ res.json({ ok: true, newPath: toPosix(path.join(toDir || "", base)) });
194
+ } catch (err) {
195
+ next(err);
196
  }
197
+ });
198
 
199
+ app.delete("/api/entry", async (req, res, next) => {
200
+ try {
201
+ const rel = String(req.query.path || "");
202
+ const full = safeResolve(rel);
203
+ await fsExtra.remove(full);
204
+ res.json({ ok: true });
205
+ } catch (err) {
206
+ next(err);
207
  }
208
+ });
209
 
210
+ app.post("/api/upload", upload.array("files"), async (req, res, next) => {
211
+ try {
212
+ const rel = String(req.query.path || "");
213
+ const destDir = safeResolve(rel);
214
+ await fsExtra.ensureDir(destDir);
215
+ const moved = [];
216
+ for (const file of req.files || []) {
217
+ const target = path.join(destDir, file.originalname);
218
+ await fsExtra.move(file.path, target, { overwrite: true });
219
+ moved.push({ name: file.originalname });
220
+ }
221
+ res.json({ ok: true, files: moved });
222
+ } catch (err) {
223
+ next(err);
224
+ }
225
+ });
226
 
227
+ app.post("/api/fetch-url", async (req, res, next) => {
228
  try {
229
+ const { url, destDir = "", filename } = req.body;
230
+ if (!url) return res.status(400).json({ error: "Missing url" });
231
+ const destDirFull = safeResolve(destDir);
232
+ await fsExtra.ensureDir(destDirFull);
233
+
234
+ let name = filename;
235
+ const resp = await axios.get(url, { responseType: "stream", validateStatus: (s) => s >= 200 && s < 400 });
236
+ if (!name) {
237
+ const cd = resp.headers["content-disposition"];
238
+ if (cd) {
239
+ const m = /filename\*?=(?:UTF-8'')?["']?([^"';]+)["']?/i.exec(cd);
240
+ if (m) name = decodeURIComponent(m[1]);
241
+ }
242
+ if (!name) {
243
+ try {
244
+ const u = new URL(url);
245
+ const base = path.basename(u.pathname);
246
+ name = base || "downloaded.file";
247
+ } catch {}
248
  }
 
249
  }
250
+ if (badName(name)) name = `downloaded-${Date.now()}`;
251
+ const destFull = path.join(destDirFull, name);
252
+ const ws = fs.createWriteStream(destFull);
253
+ await pipeline(resp.data, ws);
254
+ res.json({ ok: true, savedAs: toPosix(path.join(destDir, name)) });
255
  } catch (err) {
256
+ next(err);
 
257
  }
258
+ });
259
 
260
+ app.post("/api/archive", async (req, res, next) => {
261
+ try {
262
+ const { path: rel = "", entries = [], name, format = "zip" } = req.body;
263
+ const dirFull = safeResolve(rel);
264
+ if (!Array.isArray(entries) || entries.length === 0) {
265
+ return res.status(400).json({ error: "No entries to archive" });
266
+ }
267
 
268
+ const fmt = String(format).toLowerCase();
269
+ let extName, archiverType, archiverOpts;
270
+ if (fmt === "zip") {
271
+ extName = "zip";
272
+ archiverType = "zip";
273
+ archiverOpts = { zlib: { level: 9 } };
274
+ } else if (fmt === "tar") {
275
+ extName = "tar";
276
+ archiverType = "tar";
277
+ archiverOpts = { gzip: false };
278
+ } else if (fmt === "tgz" || fmt === "tar.gz") {
279
+ extName = "tar.gz";
280
+ archiverType = "tar";
281
+ archiverOpts = { gzip: true, gzipOptions: { level: 9 } };
282
+ } else {
283
+ return res.status(400).json({ error: "Unsupported format" });
284
+ }
285
 
286
+ let outName = name;
287
+ if (!outName) {
288
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
289
+ outName = `archive-${ts}.${extName}`;
290
+ } else {
291
+ const lower = outName.toLowerCase();
292
+ const needExt =
293
+ (extName === "zip" && !lower.endsWith(".zip")) ||
294
+ (extName === "tar" && !lower.endsWith(".tar")) ||
295
+ (extName === "tar.gz" && !(lower.endsWith(".tar.gz") || lower.endsWith(".tgz")));
296
+ if (needExt) outName += `.${extName}`;
297
+ }
298
+ if (badName(outName)) return res.status(400).json({ error: "Invalid archive name" });
299
+
300
+ const outFull = path.join(dirFull, outName);
301
+
302
+ await new Promise((resolve, reject) => {
303
+ const output = fs.createWriteStream(outFull);
304
+ const archive = archiver(archiverType, archiverOpts);
305
+ output.on("close", resolve);
306
+ output.on("error", reject);
307
+ archive.on("error", reject);
308
+ archive.pipe(output);
309
+
310
+ for (const name of entries) {
311
+ if (badName(path.basename(name))) {
312
+ archive.destroy(new Error("Invalid entry name"));
313
+ return;
314
+ }
315
+ const full = path.join(dirFull, name);
316
+ const nameInArchive = path.basename(name);
317
+ if (fs.existsSync(full)) {
318
+ const stat = fs.lstatSync(full);
319
+ if (stat.isDirectory()) archive.directory(full, nameInArchive);
320
+ else archive.file(full, { name: nameInArchive });
321
+ }
322
+ }
323
+ archive.finalize();
324
+ });
325
 
326
+ res.json({ ok: true, archive: toPosix(path.join(rel, outName)) });
327
+ } catch (err) {
328
+ next(err);
329
+ }
 
 
330
  });
331
 
332
+ app.post("/api/unarchive", async (req, res, next) => {
333
+ try {
334
+ const { zipPath, destDir } = req.body;
335
+ if (!zipPath) return res.status(400).json({ error: "archive path required" });
336
+ const archiveFull = safeResolve(zipPath);
337
+ const format = archiveFormatFromName(archiveFull);
338
+ if (!format) return res.status(400).json({ error: "Unsupported archive format" });
339
+
340
+ let destRel = destDir;
341
+ if (!destRel) {
342
+ const base = stripArchiveExt(path.basename(archiveFull));
343
+ destRel = toPosix(path.join(path.dirname(zipPath), base));
344
+ }
345
+ const destFull = safeResolve(destRel);
346
+ await fsExtra.ensureDir(destFull);
347
+
348
+ if (format === "zip") {
349
+ await extract(archiveFull, { dir: destFull });
350
+ } else if (format === "tar") {
351
+ await tar.x({ file: archiveFull, cwd: destFull, gzip: false });
352
+ } else if (format === "tgz") {
353
+ await tar.x({ file: archiveFull, cwd: destFull, gzip: true });
354
+ }
355
+
356
+ res.json({ ok: true, extractedTo: destRel });
357
+ } catch (err) {
358
+ next(err);
359
+ }
360
+ });
361
+
362
+ app.get("/api/download", async (req, res, next) => {
363
+ try {
364
+ const rel = String(req.query.path || "");
365
+ const full = safeResolve(rel);
366
+ const s = await fsExtra.stat(full);
367
+ if (!s.isFile()) return res.status(400).json({ error: "Not a file" });
368
+ res.download(full, path.basename(full));
369
+ } catch (err) {
370
+ next(err);
371
+ }
372
+ });
373
+
374
+ // UI
375
+ app.use("/", express.static(path.join(__dirname, "public")));
376
+
377
+ app.use((err, req, res, next) => {
378
+ console.error(err);
379
+ const status = err.status || 500;
380
+ res.status(status).json({ error: err.message || "Internal error" });
381
+ });
382
 
383
+ const PORT = process.env.PORT || 3000;
384
  app.listen(PORT, () => {
385
+ console.log(`File Manager (ESM) at http://localhost:${PORT}`);
386
+ console.log(`ROOT_DIR = ${ROOT_DIR}`);
 
 
387
  });