Commit 8ffb7e2b10d3e40375c4554109a2e987601ee130
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.
Showing
5 changed files
with
458 additions
and
1 deletions
server.js
| @@ -28,9 +28,27 @@ const GLM_HOST = "open.bigmodel.cn"; | @@ -28,9 +28,27 @@ const GLM_HOST = "open.bigmodel.cn"; | ||
| 28 | const GLM_PATH = "/api/coding/paas/v4/chat/completions"; | 28 | const GLM_PATH = "/api/coding/paas/v4/chat/completions"; |
| 29 | const GLM_MODEL = "glm-5.3-flash"; | 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 | const { syncLibrary } = require("./ingest.js"); | 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 | const LIBRARY_PATH = path.join(__dirname, "library.json"); | 53 | const LIBRARY_PATH = path.join(__dirname, "library.json"); |
| 36 | function getLibrary() { | 54 | function getLibrary() { |
| @@ -718,6 +736,69 @@ http | @@ -718,6 +736,69 @@ http | ||
| 718 | return; | 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 | if (pathname.startsWith("/lib/video/")) { | 802 | if (pathname.startsWith("/lib/video/")) { |
| 722 | const id = pathname.slice("/lib/video/".length); | 803 | const id = pathname.slice("/lib/video/".length); |
| 723 | const clip = getLibrary().find((c) => c.id === id); | 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 | +}); |