diff --git a/server.js b/server.js index e9a3e9d..b54055e 100644 --- a/server.js +++ b/server.js @@ -48,7 +48,9 @@ const { parseMultipart, boundaryFromContentType, sniffImageType, + sniffVideoType, stripMetadataAndSave, + saveVideoUpload, } = require("./uploads.js"); const { characters, garments, danceRefs } = require("./dressup-store.js"); @@ -1006,6 +1008,44 @@ http return; } + if (pathname === "/api/admin/upload-video" && req.method === "POST") { + const boundary = boundaryFromContentType(req.headers["content-type"] || ""); + if (!boundary) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "expected multipart/form-data" })); + return; + } + readRawBody(req, 50 * 1024 * 1024) + .then((body) => { + const filePart = parseMultipart(body, boundary).find((p) => p.name === "file"); + if (!filePart) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "missing file field" })); + return; + } + const sniffed = sniffVideoType(filePart.data); + if (!sniffed) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unrecognized video type (expected mp4)" })); + return; + } + const { relPath } = saveVideoUpload(filePart.data, sniffed); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ url: relPath })); + }) + .catch((err) => { + if (res.writableEnded) return; + try { + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } catch { + // socket already gone (e.g. destroyed for exceeding the size cap) + } + }); + return; + } + if (pathname.startsWith("/uploads/")) { const rel = pathname.slice("/uploads/".length); const abs = path.join(UPLOADS_DIR, rel); diff --git a/uploads-route.test.js b/uploads-route.test.js index cedb6a1..7d30b0e 100644 --- a/uploads-route.test.js +++ b/uploads-route.test.js @@ -64,6 +64,21 @@ test.after(() => { fs.rmSync(dir, { recursive: true, force: true }); }); +test("uploading a real mp4 via /api/admin/upload-video returns a URL that GET /uploads/* serves back as video/mp4", async () => { + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); + const fd = new FormData(); + fd.append("file", new Blob([realMp4], { type: "video/mp4" }), "dance.mp4"); + + const uploadRes = await fetch(`${BASE}/api/admin/upload-video`, { method: "POST", headers: { Cookie: cookie }, body: fd }); + assert.equal(uploadRes.status, 200); + const { url } = await uploadRes.json(); + assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.mp4$/); + + const fetched = await fetch(`${BASE}${url}`); + assert.equal(fetched.status, 200); + assert.equal(fetched.headers.get("content-type"), "video/mp4"); +}); + test("uploading a real PNG returns a URL that GET /uploads/* serves back with the right content-type", async () => { const fd = new FormData(); fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png"); diff --git a/uploads.js b/uploads.js index 124a7c0..6c55d9e 100644 --- a/uploads.js +++ b/uploads.js @@ -112,19 +112,38 @@ function stripMetadataAndSave(buffer, sniffed) { const strippedPath = path.join(tmpDir, `out.${outExt}`); execFileSync("sips", ["-s", "format", SIPS_OUT_FORMAT[sniffed.ext], bmpPath, "--out", strippedPath], { stdio: "pipe" }); - const yyyymm = new Date().toISOString().slice(0, 7).replace("-", ""); - const destDir = path.join(UPLOADS_DIR, yyyymm); - fs.mkdirSync(destDir, { recursive: true }); - const filename = `${crypto.randomUUID()}.${outExt}`; - const destPath = path.join(destDir, filename); - fs.copyFileSync(strippedPath, destPath); - - return { relPath: `/uploads/${yyyymm}/${filename}`, absPath: destPath, ext: outExt }; + return placeInUploads(fs.readFileSync(strippedPath), outExt); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } +function placeInUploads(buffer, ext) { + const yyyymm = new Date().toISOString().slice(0, 7).replace("-", ""); + const destDir = path.join(UPLOADS_DIR, yyyymm); + fs.mkdirSync(destDir, { recursive: true }); + const filename = `${crypto.randomUUID()}.${ext}`; + const destPath = path.join(destDir, filename); + fs.writeFileSync(destPath, buffer); + return { relPath: `/uploads/${yyyymm}/${filename}`, absPath: destPath, ext }; +} + +const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024; + +// mp4's magic bytes aren't at offset 0 — the file starts with a 4-byte box +// size, then the literal ASCII "ftyp" box-type tag. +function sniffVideoType(buffer) { + if (buffer.length > 12 && buffer.toString("latin1", 4, 8) === "ftyp") return { type: "video/mp4", ext: "mp4" }; + return null; +} + +// Reference dance videos (T5) don't need EXIF stripping or re-encoding — +// just a real magic-byte check and a stable uploads/ location, same layout +// as images. +function saveVideoUpload(buffer, sniffed) { + return placeInUploads(buffer, sniffed.ext); +} + // Forwards an already-saved local upload to H3 Console's own asset endpoint // (H3-CONSOLE.md, found via S4 spike) so it's reachable when used in // reference_image_urls/reference_video_urls. @@ -149,11 +168,14 @@ async function forwardToH3Assets(absPath, base, token) { module.exports = { MAX_UPLOAD_BYTES, + MAX_VIDEO_UPLOAD_BYTES, UPLOADS_DIR, readRawBody, parseMultipart, boundaryFromContentType, sniffImageType, + sniffVideoType, stripMetadataAndSave, + saveVideoUpload, forwardToH3Assets, }; diff --git a/uploads.test.js b/uploads.test.js index 2e0bc13..ffe9261 100644 --- a/uploads.test.js +++ b/uploads.test.js @@ -8,7 +8,9 @@ const { parseMultipart, boundaryFromContentType, sniffImageType, + sniffVideoType, stripMetadataAndSave, + saveVideoUpload, UPLOADS_DIR, } = require("./uploads.js"); @@ -38,6 +40,27 @@ test("parseMultipart extracts a file part from a real multipart body (built via assert.deepEqual([...parts[0].data], [1, 2, 3, 4]); }); +test("sniffVideoType recognizes a real mp4 (reused from the S1-S4 spike) and rejects a zip disguised as .mp4", () => { + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); + assert.equal(sniffVideoType(realMp4).type, "video/mp4"); + + const zipDisguisedAsMp4 = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + assert.equal(sniffVideoType(zipDisguisedAsMp4), null); +}); + +test("saveVideoUpload stores the video byte-for-byte under uploads/", () => { + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); + const sniffed = sniffVideoType(realMp4); + const { absPath, relPath, ext } = saveVideoUpload(realMp4, sniffed); + try { + assert.equal(ext, "mp4"); + assert.match(relPath, /^\/uploads\/\d{6}\/[0-9a-f-]+\.mp4$/); + assert.deepEqual(fs.readFileSync(absPath), realMp4); + } finally { + fs.rmSync(absPath); + } +}); + // Walks a JPEG's APP1 "Exif\0\0" TIFF block (if any) and reports whether // IFD0 has a GPS IFD pointer (tag 0x8825) — i.e. whether the file carries // GPS EXIF data. Dependency-free stand-in for what the original acceptance