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 | 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 | +}); | ... | ... |