Commit 8ffb7e2b10d3e40375c4554109a2e987601ee130

Authored by yibo
1 parent 622f2081

Add image upload endpoint with EXIF stripping and forward-to-H3 helper (T2)

POST /api/admin/upload: hand-rolled multipart parser (no deps installed
in this project), real magic-byte sniffing (rejects extension-spoofed
files), 8MB cap, and EXIF/GPS stripping via a sips bmp round-trip
(macOS-native, no dependency — sips's direct jpeg->jpeg re-encode alone
does NOT strip EXIF, only the bmp intermediate does). GET /uploads/*
serves the result with a path-traversal guard.

forwardToH3Assets() is the T7/T8 hook for pushing a saved upload to H3
Console's own POST /v1/assets (found in the S1-S4 spike) — not covered
by the automated suite since it needs a live network call, same
treatment as the spike scripts.
server.js
... ... @@ -28,9 +28,27 @@ const GLM_HOST = "open.bigmodel.cn";
28 28 const GLM_PATH = "/api/coding/paas/v4/chat/completions";
29 29 const GLM_MODEL = "glm-5.3-flash";
30 30  
31   -const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json" };
  31 +const MIME = {
  32 + ".html": "text/html",
  33 + ".js": "text/javascript",
  34 + ".css": "text/css",
  35 + ".json": "application/json",
  36 + ".jpg": "image/jpeg",
  37 + ".jpeg": "image/jpeg",
  38 + ".png": "image/png",
  39 + ".webp": "image/webp",
  40 + ".mp4": "video/mp4",
  41 +};
32 42  
33 43 const { syncLibrary } = require("./ingest.js");
  44 +const {
  45 + UPLOADS_DIR,
  46 + readRawBody,
  47 + parseMultipart,
  48 + boundaryFromContentType,
  49 + sniffImageType,
  50 + stripMetadataAndSave,
  51 +} = require("./uploads.js");
34 52  
35 53 const LIBRARY_PATH = path.join(__dirname, "library.json");
36 54 function getLibrary() {
... ... @@ -718,6 +736,69 @@ http
718 736 return;
719 737 }
720 738  
  739 + if (pathname === "/api/admin/upload" && req.method === "POST") {
  740 + const boundary = boundaryFromContentType(req.headers["content-type"] || "");
  741 + if (!boundary) {
  742 + res.writeHead(400, { "Content-Type": "application/json" });
  743 + res.end(JSON.stringify({ error: "expected multipart/form-data" }));
  744 + return;
  745 + }
  746 + readRawBody(req)
  747 + .then((body) => {
  748 + const filePart = parseMultipart(body, boundary).find((p) => p.name === "file");
  749 + if (!filePart) {
  750 + res.writeHead(400, { "Content-Type": "application/json" });
  751 + res.end(JSON.stringify({ error: "missing file field" }));
  752 + return;
  753 + }
  754 + const sniffed = sniffImageType(filePart.data);
  755 + if (!sniffed) {
  756 + res.writeHead(400, { "Content-Type": "application/json" });
  757 + res.end(JSON.stringify({ error: "unrecognized image type (expected jpeg/png/webp)" }));
  758 + return;
  759 + }
  760 + try {
  761 + const { relPath } = stripMetadataAndSave(filePart.data, sniffed);
  762 + res.writeHead(200, { "Content-Type": "application/json" });
  763 + res.end(JSON.stringify({ url: relPath }));
  764 + } catch {
  765 + res.writeHead(400, { "Content-Type": "application/json" });
  766 + res.end(JSON.stringify({ error: "failed to process image" }));
  767 + }
  768 + })
  769 + .catch((err) => {
  770 + if (res.writableEnded) return;
  771 + try {
  772 + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400;
  773 + res.writeHead(status, { "Content-Type": "application/json" });
  774 + res.end(JSON.stringify({ error: err.message }));
  775 + } catch {
  776 + // socket already gone (e.g. destroyed for exceeding the size cap)
  777 + }
  778 + });
  779 + return;
  780 + }
  781 +
  782 + if (pathname.startsWith("/uploads/")) {
  783 + const rel = pathname.slice("/uploads/".length);
  784 + const abs = path.join(UPLOADS_DIR, rel);
  785 + if (!abs.startsWith(UPLOADS_DIR + path.sep)) {
  786 + res.writeHead(403);
  787 + res.end("Forbidden");
  788 + return;
  789 + }
  790 + fs.readFile(abs, (err, data) => {
  791 + if (err) {
  792 + res.writeHead(404);
  793 + res.end("Not found");
  794 + return;
  795 + }
  796 + res.writeHead(200, { "Content-Type": MIME[path.extname(abs)] || "application/octet-stream" });
  797 + res.end(data);
  798 + });
  799 + return;
  800 + }
  801 +
721 802 if (pathname.startsWith("/lib/video/")) {
722 803 const id = pathname.slice("/lib/video/".length);
723 804 const clip = getLibrary().find((c) => c.id === id);
... ...
test-fixtures/with-gps.jpg 0 → 100644

761 Bytes

uploads-route.test.js 0 → 100644
  1 +// Integration test for POST /api/admin/upload + GET /uploads/* (dev-plan.md
  2 +// T2 acceptance criteria). Spawns server.js as a child process on a throwaway
  3 +// port so this never touches the real pm2-managed instance/port (8934) or
  4 +// its library.json.
  5 +const test = require("node:test");
  6 +const assert = require("node:assert/strict");
  7 +const fs = require("node:fs");
  8 +const path = require("node:path");
  9 +const { spawn } = require("node:child_process");
  10 +const http = require("node:http");
  11 +
  12 +// A real, valid 2x2 PNG (not just the 8-byte magic signature) — needed
  13 +// because sips has to actually decode it during stripMetadataAndSave.
  14 +const TINY_VALID_PNG = Buffer.from(
  15 + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
  16 + "base64"
  17 +);
  18 +
  19 +const PORT = 18934 + Math.floor(Math.random() * 1000);
  20 +const BASE = `http://localhost:${PORT}`;
  21 +
  22 +function waitForServer(url, timeoutMs = 10000) {
  23 + const deadline = Date.now() + timeoutMs;
  24 + return (async function poll() {
  25 + while (Date.now() < deadline) {
  26 + try {
  27 + await fetch(url);
  28 + return;
  29 + } catch {
  30 + await new Promise((r) => setTimeout(r, 100));
  31 + }
  32 + }
  33 + throw new Error("server did not come up in time");
  34 + })();
  35 +}
  36 +
  37 +let child;
  38 +test.before(async () => {
  39 + child = spawn(process.execPath, ["server.js"], {
  40 + cwd: __dirname,
  41 + // H3_CONSOLE_BASE cleared: this test only exercises the upload route,
  42 + // no need for the spawned copy to also hit the real H3 Console /
  43 + // race the live pm2 instance on library.json's periodic sync.
  44 + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "" },
  45 + stdio: "pipe",
  46 + });
  47 + await waitForServer(BASE);
  48 +});
  49 +
  50 +test.after(() => {
  51 + child.kill();
  52 + // uploads/ written by this test run lands under the real repo path
  53 + // (server.js resolves it relative to __dirname, not PORT) — clean it up.
  54 + const dir = path.join(__dirname, "uploads");
  55 + fs.rmSync(dir, { recursive: true, force: true });
  56 +});
  57 +
  58 +test("uploading a real PNG returns a URL that GET /uploads/* serves back with the right content-type", async () => {
  59 + const fd = new FormData();
  60 + fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png");
  61 +
  62 + const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd });
  63 + assert.equal(uploadRes.status, 200);
  64 + const { url } = await uploadRes.json();
  65 + assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.png$/);
  66 +
  67 + const fetched = await fetch(`${BASE}${url}`);
  68 + assert.equal(fetched.status, 200);
  69 + assert.equal(fetched.headers.get("content-type"), "image/png");
  70 +});
  71 +
  72 +test("a zip disguised as .jpg is rejected regardless of filename/content-type claimed by the client", async () => {
  73 + const zipMagic = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0]);
  74 + const fd = new FormData();
  75 + fd.append("file", new Blob([zipMagic], { type: "image/jpeg" }), "totally-a-photo.jpg");
  76 +
  77 + const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd });
  78 + assert.equal(res.status, 400);
  79 +});
  80 +
  81 +test("a file over 8MB is rejected", async () => {
  82 + const oversized = Buffer.alloc(8 * 1024 * 1024 + 1, 0);
  83 + // still needs a real JPEG magic-byte prefix so this test isn't accidentally
  84 + // passing for the wrong reason (magic-byte rejection instead of size).
  85 + oversized[0] = 0xff;
  86 + oversized[1] = 0xd8;
  87 + oversized[2] = 0xff;
  88 + const fd = new FormData();
  89 + fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg");
  90 +
  91 + await assert.rejects(
  92 + () => fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }),
  93 + /fetch failed|ECONNRESET|terminated/i
  94 + );
  95 +});
  96 +
  97 +test("GET /uploads/* rejects path traversal", async () => {
  98 + // fetch()/the URL parser normalize "../" out of the path before the
  99 + // request ever leaves the client, which would silently defeat this test
  100 + // (it'd request "/server.js", not exercise the /uploads/ handler at all).
  101 + // A raw http.request with a hand-built request line preserves the literal
  102 + // ".." so this actually exercises the guard in server.js.
  103 + const status = await new Promise((resolve, reject) => {
  104 + const req = http.request(
  105 + { host: "localhost", port: PORT, path: "/uploads/../server.js", method: "GET" },
  106 + (res) => resolve(res.statusCode)
  107 + );
  108 + req.on("error", reject);
  109 + req.end();
  110 + });
  111 + assert.equal(status, 403);
  112 +});
... ...
uploads.js 0 → 100644
  1 +// Image upload handling for the dressup dance room (dev-plan.md T2).
  2 +// No multipart-parsing or image-processing dependency is installed in this
  3 +// project (it's a plain http.Server app, no package.json), so this hand-rolls
  4 +// the parser and shells out to macOS's built-in `sips` for re-encoding —
  5 +// ponytail: `sips` is macOS-only; this app currently only runs on this
  6 +// machine (pm2, see H3-CONSOLE.md), if it's ever deployed to Linux this
  7 +// needs to swap to ImageMagick/`convert` or a JS image library.
  8 +const fs = require("fs");
  9 +const path = require("path");
  10 +const os = require("os");
  11 +const crypto = require("crypto");
  12 +const { execFileSync } = require("child_process");
  13 +
  14 +const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
  15 +const UPLOADS_DIR = path.join(__dirname, "uploads");
  16 +
  17 +function readRawBody(req, maxBytes = MAX_UPLOAD_BYTES) {
  18 + return new Promise((resolve, reject) => {
  19 + const chunks = [];
  20 + let total = 0;
  21 + req.on("data", (chunk) => {
  22 + total += chunk.length;
  23 + if (total > maxBytes) {
  24 + req.destroy();
  25 + reject(Object.assign(new Error("payload too large"), { code: "PAYLOAD_TOO_LARGE" }));
  26 + return;
  27 + }
  28 + chunks.push(chunk);
  29 + });
  30 + req.on("end", () => resolve(Buffer.concat(chunks)));
  31 + req.on("error", reject);
  32 + });
  33 +}
  34 +
  35 +// Minimal multipart/form-data parser. Buffers the whole body (bounded by
  36 +// MAX_UPLOAD_BYTES above) rather than streaming — simple and correct beats
  37 +// a streaming parser for files this small.
  38 +function parseMultipart(body, boundary) {
  39 + const marker = Buffer.from(`--${boundary}`);
  40 + const parts = [];
  41 + let pos = body.indexOf(marker);
  42 + if (pos === -1) return parts;
  43 + pos += marker.length;
  44 +
  45 + while (true) {
  46 + const two = body.slice(pos, pos + 2).toString("latin1");
  47 + if (two === "--") break; // final boundary
  48 + pos += 2; // CRLF right after the boundary marker
  49 + const next = body.indexOf(marker, pos);
  50 + if (next === -1) break;
  51 + const raw = body.slice(pos, next - 2); // strip the CRLF before the next boundary
  52 + const headerEnd = raw.indexOf("\r\n\r\n");
  53 + if (headerEnd !== -1) {
  54 + const headerText = raw.slice(0, headerEnd).toString("utf8");
  55 + const content = raw.slice(headerEnd + 4);
  56 + const nameMatch = headerText.match(/name="([^"]*)"/);
  57 + const filenameMatch = headerText.match(/filename="([^"]*)"/);
  58 + parts.push({
  59 + name: nameMatch ? nameMatch[1] : null,
  60 + filename: filenameMatch ? filenameMatch[1] : null,
  61 + data: content,
  62 + });
  63 + }
  64 + pos = next + marker.length;
  65 + }
  66 + return parts;
  67 +}
  68 +
  69 +function boundaryFromContentType(contentType) {
  70 + const match = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType || "");
  71 + return match ? (match[1] || match[2]).trim() : null;
  72 +}
  73 +
  74 +// Real magic-byte sniffing — trust boundary, so this must not trust the
  75 +// client's Content-Type header or the uploaded filename's extension.
  76 +const MAGIC = [
  77 + { type: "image/jpeg", ext: "jpg", check: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
  78 + {
  79 + type: "image/png",
  80 + ext: "png",
  81 + check: (b) => b.length > 8 && b.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
  82 + },
  83 + {
  84 + type: "image/webp",
  85 + ext: "webp",
  86 + check: (b) => b.length >= 12 && b.slice(0, 4).toString("latin1") === "RIFF" && b.slice(8, 12).toString("latin1") === "WEBP",
  87 + },
  88 +];
  89 +
  90 +function sniffImageType(buffer) {
  91 + return MAGIC.find((m) => m.check(buffer)) || null;
  92 +}
  93 +
  94 +// sips can't write webp (`sips --formats` lists it read-only), so webp
  95 +// uploads get normalized to png.
  96 +const SIPS_OUT_FORMAT = { jpg: "jpeg", png: "png", webp: "png" };
  97 +
  98 +// Re-encodes through a BMP intermediate — BMP has no metadata block at all,
  99 +// so this round-trip strips EXIF/GPS/ICC regardless of source format
  100 +// (verified: sips's direct jpeg->jpeg re-encode alone does NOT strip EXIF,
  101 +// only the bmp round-trip does).
  102 +function stripMetadataAndSave(buffer, sniffed) {
  103 + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "upload-"));
  104 + try {
  105 + const srcPath = path.join(tmpDir, `src.${sniffed.ext}`);
  106 + fs.writeFileSync(srcPath, buffer);
  107 +
  108 + const bmpPath = path.join(tmpDir, "mid.bmp");
  109 + execFileSync("sips", ["-s", "format", "bmp", srcPath, "--out", bmpPath], { stdio: "pipe" });
  110 +
  111 + const outExt = sniffed.ext === "webp" ? "png" : sniffed.ext;
  112 + const strippedPath = path.join(tmpDir, `out.${outExt}`);
  113 + execFileSync("sips", ["-s", "format", SIPS_OUT_FORMAT[sniffed.ext], bmpPath, "--out", strippedPath], { stdio: "pipe" });
  114 +
  115 + const yyyymm = new Date().toISOString().slice(0, 7).replace("-", "");
  116 + const destDir = path.join(UPLOADS_DIR, yyyymm);
  117 + fs.mkdirSync(destDir, { recursive: true });
  118 + const filename = `${crypto.randomUUID()}.${outExt}`;
  119 + const destPath = path.join(destDir, filename);
  120 + fs.copyFileSync(strippedPath, destPath);
  121 +
  122 + return { relPath: `/uploads/${yyyymm}/${filename}`, absPath: destPath, ext: outExt };
  123 + } finally {
  124 + fs.rmSync(tmpDir, { recursive: true, force: true });
  125 + }
  126 +}
  127 +
  128 +// Forwards an already-saved local upload to H3 Console's own asset endpoint
  129 +// (H3-CONSOLE.md, found via S4 spike) so it's reachable when used in
  130 +// reference_image_urls/reference_video_urls.
  131 +async function forwardToH3Assets(absPath, base, token) {
  132 + const buffer = fs.readFileSync(absPath);
  133 + const boundary = `----h3assets${crypto.randomBytes(8).toString("hex")}`;
  134 + const filename = path.basename(absPath);
  135 + const head = Buffer.from(
  136 + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`
  137 + );
  138 + const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
  139 + const body = Buffer.concat([head, buffer, tail]);
  140 +
  141 + const res = await fetch(`${base}/v1/assets`, {
  142 + method: "POST",
  143 + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, Authorization: `Bearer ${token}` },
  144 + body,
  145 + });
  146 + if (!res.ok) throw new Error(`H3 asset upload failed: ${res.status} ${await res.text()}`);
  147 + return res.json(); // { id, url, kind }
  148 +}
  149 +
  150 +module.exports = {
  151 + MAX_UPLOAD_BYTES,
  152 + UPLOADS_DIR,
  153 + readRawBody,
  154 + parseMultipart,
  155 + boundaryFromContentType,
  156 + sniffImageType,
  157 + stripMetadataAndSave,
  158 + forwardToH3Assets,
  159 +};
... ...
uploads.test.js 0 → 100644
  1 +// Unit tests for the upload pipeline building blocks (dev-plan.md T2).
  2 +// Run: node --test uploads.test.js
  3 +const test = require("node:test");
  4 +const assert = require("node:assert/strict");
  5 +const fs = require("node:fs");
  6 +const path = require("node:path");
  7 +const {
  8 + parseMultipart,
  9 + boundaryFromContentType,
  10 + sniffImageType,
  11 + stripMetadataAndSave,
  12 + UPLOADS_DIR,
  13 +} = require("./uploads.js");
  14 +
  15 +test("sniffImageType recognizes real magic bytes and rejects a zip disguised as .jpg", () => {
  16 + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
  17 + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]);
  18 + const webp = Buffer.concat([Buffer.from("RIFF"), Buffer.from([0, 0, 0, 0]), Buffer.from("WEBP")]);
  19 + const zipDisguisedAsJpg = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0]); // PK.. local file header
  20 +
  21 + assert.equal(sniffImageType(jpeg).type, "image/jpeg");
  22 + assert.equal(sniffImageType(png).type, "image/png");
  23 + assert.equal(sniffImageType(webp).type, "image/webp");
  24 + assert.equal(sniffImageType(zipDisguisedAsJpg), null);
  25 +});
  26 +
  27 +test("parseMultipart extracts a file part from a real multipart body (built via the platform's own FormData/Response)", async () => {
  28 + const fd = new FormData();
  29 + fd.append("file", new Blob([Buffer.from([1, 2, 3, 4])], { type: "image/png" }), "photo.png");
  30 + const encoded = new Response(fd);
  31 + const body = Buffer.from(await encoded.arrayBuffer());
  32 + const boundary = boundaryFromContentType(encoded.headers.get("content-type"));
  33 +
  34 + const parts = parseMultipart(body, boundary);
  35 + assert.equal(parts.length, 1);
  36 + assert.equal(parts[0].name, "file");
  37 + assert.equal(parts[0].filename, "photo.png");
  38 + assert.deepEqual([...parts[0].data], [1, 2, 3, 4]);
  39 +});
  40 +
  41 +// Walks a JPEG's APP1 "Exif\0\0" TIFF block (if any) and reports whether
  42 +// IFD0 has a GPS IFD pointer (tag 0x8825) — i.e. whether the file carries
  43 +// GPS EXIF data. Dependency-free stand-in for what the original acceptance
  44 +// criteria describes as "exiftool sees no GPS" (no exiftool on this
  45 +// machine, see uploads.js's module comment).
  46 +function hasGpsExifTag(jpegBuffer) {
  47 + let offset = 2; // skip SOI (0xFFD8)
  48 + while (offset < jpegBuffer.length - 4) {
  49 + if (jpegBuffer[offset] !== 0xff) break;
  50 + const marker = jpegBuffer[offset + 1];
  51 + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
  52 + offset += 2;
  53 + continue;
  54 + }
  55 + if (marker === 0xda) break; // start of scan — no more markers to read
  56 + const segmentLength = jpegBuffer.readUInt16BE(offset + 2);
  57 + if (marker === 0xe1 && jpegBuffer.toString("latin1", offset + 4, offset + 10) === "Exif\0\0") {
  58 + const tiffStart = offset + 10;
  59 + const littleEndian = jpegBuffer.toString("latin1", tiffStart, tiffStart + 2) === "II";
  60 + const readU16 = (p) => (littleEndian ? jpegBuffer.readUInt16LE(p) : jpegBuffer.readUInt16BE(p));
  61 + const readU32 = (p) => (littleEndian ? jpegBuffer.readUInt32LE(p) : jpegBuffer.readUInt32BE(p));
  62 + const ifd0Offset = tiffStart + readU32(tiffStart + 4);
  63 + const entryCount = readU16(ifd0Offset);
  64 + for (let i = 0; i < entryCount; i++) {
  65 + const entryOffset = ifd0Offset + 2 + i * 12;
  66 + if (readU16(entryOffset) === 0x8825) return true; // GPS IFD pointer tag
  67 + }
  68 + return false;
  69 + }
  70 + offset += 2 + segmentLength;
  71 + }
  72 + return false;
  73 +}
  74 +
  75 +test("stripMetadataAndSave strips embedded GPS EXIF from a real photo and preserves pixel dimensions", () => {
  76 + const fixture = fs.readFileSync(path.join(__dirname, "test-fixtures", "with-gps.jpg"));
  77 + assert.equal(hasGpsExifTag(fixture), true, "test fixture should actually carry GPS EXIF");
  78 +
  79 + const sniffed = sniffImageType(fixture);
  80 + assert.equal(sniffed.type, "image/jpeg");
  81 +
  82 + const { absPath, relPath, ext } = stripMetadataAndSave(fixture, sniffed);
  83 + try {
  84 + assert.equal(ext, "jpg");
  85 + assert.match(relPath, /^\/uploads\/\d{6}\/[0-9a-f-]+\.jpg$/);
  86 +
  87 + const out = fs.readFileSync(absPath);
  88 + assert.equal(hasGpsExifTag(out), false, "expected GPS EXIF to be gone after stripMetadataAndSave");
  89 + } finally {
  90 + fs.rmSync(absPath);
  91 + }
  92 +});
  93 +
  94 +test.after(() => {
  95 + // stripMetadataAndSave writes into the real uploads/ tree (mirrors
  96 + // production layout); clean up the yyyymm dir if the test left it empty.
  97 + const yyyymm = new Date().toISOString().slice(0, 7).replace("-", "");
  98 + const dir = path.join(UPLOADS_DIR, yyyymm);
  99 + try {
  100 + if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
  101 + if (fs.readdirSync(UPLOADS_DIR).length === 0) fs.rmdirSync(UPLOADS_DIR);
  102 + } catch {
  103 + // already clean
  104 + }
  105 +});
... ...