Commit 59183b0ed58105ea8bb2b90d1318b111d35d0f28
1 parent
c8844a4b
Add video upload endpoint for dance-ref reference videos
POST /api/admin/upload-video: mp4 magic-byte sniff (checks the ftyp box, not offset-0 like images), 50MB cap, no re-encode needed (unlike images, no EXIF/GPS concern for video). Reuses the multipart parser and uploads/ layout from T2's image path via a shared placeInUploads helper.
Showing
4 changed files
with
108 additions
and
8 deletions
server.js
| ... | ... | @@ -48,7 +48,9 @@ const { |
| 48 | 48 | parseMultipart, |
| 49 | 49 | boundaryFromContentType, |
| 50 | 50 | sniffImageType, |
| 51 | + sniffVideoType, | |
| 51 | 52 | stripMetadataAndSave, |
| 53 | + saveVideoUpload, | |
| 52 | 54 | } = require("./uploads.js"); |
| 53 | 55 | const { characters, garments, danceRefs } = require("./dressup-store.js"); |
| 54 | 56 | |
| ... | ... | @@ -1006,6 +1008,44 @@ http |
| 1006 | 1008 | return; |
| 1007 | 1009 | } |
| 1008 | 1010 | |
| 1011 | + if (pathname === "/api/admin/upload-video" && req.method === "POST") { | |
| 1012 | + const boundary = boundaryFromContentType(req.headers["content-type"] || ""); | |
| 1013 | + if (!boundary) { | |
| 1014 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 1015 | + res.end(JSON.stringify({ error: "expected multipart/form-data" })); | |
| 1016 | + return; | |
| 1017 | + } | |
| 1018 | + readRawBody(req, 50 * 1024 * 1024) | |
| 1019 | + .then((body) => { | |
| 1020 | + const filePart = parseMultipart(body, boundary).find((p) => p.name === "file"); | |
| 1021 | + if (!filePart) { | |
| 1022 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 1023 | + res.end(JSON.stringify({ error: "missing file field" })); | |
| 1024 | + return; | |
| 1025 | + } | |
| 1026 | + const sniffed = sniffVideoType(filePart.data); | |
| 1027 | + if (!sniffed) { | |
| 1028 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 1029 | + res.end(JSON.stringify({ error: "unrecognized video type (expected mp4)" })); | |
| 1030 | + return; | |
| 1031 | + } | |
| 1032 | + const { relPath } = saveVideoUpload(filePart.data, sniffed); | |
| 1033 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 1034 | + res.end(JSON.stringify({ url: relPath })); | |
| 1035 | + }) | |
| 1036 | + .catch((err) => { | |
| 1037 | + if (res.writableEnded) return; | |
| 1038 | + try { | |
| 1039 | + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; | |
| 1040 | + res.writeHead(status, { "Content-Type": "application/json" }); | |
| 1041 | + res.end(JSON.stringify({ error: err.message })); | |
| 1042 | + } catch { | |
| 1043 | + // socket already gone (e.g. destroyed for exceeding the size cap) | |
| 1044 | + } | |
| 1045 | + }); | |
| 1046 | + return; | |
| 1047 | + } | |
| 1048 | + | |
| 1009 | 1049 | if (pathname.startsWith("/uploads/")) { |
| 1010 | 1050 | const rel = pathname.slice("/uploads/".length); |
| 1011 | 1051 | const abs = path.join(UPLOADS_DIR, rel); | ... | ... |
uploads-route.test.js
| ... | ... | @@ -64,6 +64,21 @@ test.after(() => { |
| 64 | 64 | fs.rmSync(dir, { recursive: true, force: true }); |
| 65 | 65 | }); |
| 66 | 66 | |
| 67 | +test("uploading a real mp4 via /api/admin/upload-video returns a URL that GET /uploads/* serves back as video/mp4", async () => { | |
| 68 | + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); | |
| 69 | + const fd = new FormData(); | |
| 70 | + fd.append("file", new Blob([realMp4], { type: "video/mp4" }), "dance.mp4"); | |
| 71 | + | |
| 72 | + const uploadRes = await fetch(`${BASE}/api/admin/upload-video`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 73 | + assert.equal(uploadRes.status, 200); | |
| 74 | + const { url } = await uploadRes.json(); | |
| 75 | + assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.mp4$/); | |
| 76 | + | |
| 77 | + const fetched = await fetch(`${BASE}${url}`); | |
| 78 | + assert.equal(fetched.status, 200); | |
| 79 | + assert.equal(fetched.headers.get("content-type"), "video/mp4"); | |
| 80 | +}); | |
| 81 | + | |
| 67 | 82 | test("uploading a real PNG returns a URL that GET /uploads/* serves back with the right content-type", async () => { |
| 68 | 83 | const fd = new FormData(); |
| 69 | 84 | fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png"); | ... | ... |
uploads.js
| ... | ... | @@ -112,19 +112,38 @@ function stripMetadataAndSave(buffer, sniffed) { |
| 112 | 112 | const strippedPath = path.join(tmpDir, `out.${outExt}`); |
| 113 | 113 | execFileSync("sips", ["-s", "format", SIPS_OUT_FORMAT[sniffed.ext], bmpPath, "--out", strippedPath], { stdio: "pipe" }); |
| 114 | 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 }; | |
| 115 | + return placeInUploads(fs.readFileSync(strippedPath), outExt); | |
| 123 | 116 | } finally { |
| 124 | 117 | fs.rmSync(tmpDir, { recursive: true, force: true }); |
| 125 | 118 | } |
| 126 | 119 | } |
| 127 | 120 | |
| 121 | +function placeInUploads(buffer, ext) { | |
| 122 | + const yyyymm = new Date().toISOString().slice(0, 7).replace("-", ""); | |
| 123 | + const destDir = path.join(UPLOADS_DIR, yyyymm); | |
| 124 | + fs.mkdirSync(destDir, { recursive: true }); | |
| 125 | + const filename = `${crypto.randomUUID()}.${ext}`; | |
| 126 | + const destPath = path.join(destDir, filename); | |
| 127 | + fs.writeFileSync(destPath, buffer); | |
| 128 | + return { relPath: `/uploads/${yyyymm}/${filename}`, absPath: destPath, ext }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024; | |
| 132 | + | |
| 133 | +// mp4's magic bytes aren't at offset 0 — the file starts with a 4-byte box | |
| 134 | +// size, then the literal ASCII "ftyp" box-type tag. | |
| 135 | +function sniffVideoType(buffer) { | |
| 136 | + if (buffer.length > 12 && buffer.toString("latin1", 4, 8) === "ftyp") return { type: "video/mp4", ext: "mp4" }; | |
| 137 | + return null; | |
| 138 | +} | |
| 139 | + | |
| 140 | +// Reference dance videos (T5) don't need EXIF stripping or re-encoding — | |
| 141 | +// just a real magic-byte check and a stable uploads/ location, same layout | |
| 142 | +// as images. | |
| 143 | +function saveVideoUpload(buffer, sniffed) { | |
| 144 | + return placeInUploads(buffer, sniffed.ext); | |
| 145 | +} | |
| 146 | + | |
| 128 | 147 | // Forwards an already-saved local upload to H3 Console's own asset endpoint |
| 129 | 148 | // (H3-CONSOLE.md, found via S4 spike) so it's reachable when used in |
| 130 | 149 | // reference_image_urls/reference_video_urls. |
| ... | ... | @@ -149,11 +168,14 @@ async function forwardToH3Assets(absPath, base, token) { |
| 149 | 168 | |
| 150 | 169 | module.exports = { |
| 151 | 170 | MAX_UPLOAD_BYTES, |
| 171 | + MAX_VIDEO_UPLOAD_BYTES, | |
| 152 | 172 | UPLOADS_DIR, |
| 153 | 173 | readRawBody, |
| 154 | 174 | parseMultipart, |
| 155 | 175 | boundaryFromContentType, |
| 156 | 176 | sniffImageType, |
| 177 | + sniffVideoType, | |
| 157 | 178 | stripMetadataAndSave, |
| 179 | + saveVideoUpload, | |
| 158 | 180 | forwardToH3Assets, |
| 159 | 181 | }; | ... | ... |
uploads.test.js
| ... | ... | @@ -8,7 +8,9 @@ const { |
| 8 | 8 | parseMultipart, |
| 9 | 9 | boundaryFromContentType, |
| 10 | 10 | sniffImageType, |
| 11 | + sniffVideoType, | |
| 11 | 12 | stripMetadataAndSave, |
| 13 | + saveVideoUpload, | |
| 12 | 14 | UPLOADS_DIR, |
| 13 | 15 | } = require("./uploads.js"); |
| 14 | 16 | |
| ... | ... | @@ -38,6 +40,27 @@ test("parseMultipart extracts a file part from a real multipart body (built via |
| 38 | 40 | assert.deepEqual([...parts[0].data], [1, 2, 3, 4]); |
| 39 | 41 | }); |
| 40 | 42 | |
| 43 | +test("sniffVideoType recognizes a real mp4 (reused from the S1-S4 spike) and rejects a zip disguised as .mp4", () => { | |
| 44 | + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); | |
| 45 | + assert.equal(sniffVideoType(realMp4).type, "video/mp4"); | |
| 46 | + | |
| 47 | + const zipDisguisedAsMp4 = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]); | |
| 48 | + assert.equal(sniffVideoType(zipDisguisedAsMp4), null); | |
| 49 | +}); | |
| 50 | + | |
| 51 | +test("saveVideoUpload stores the video byte-for-byte under uploads/", () => { | |
| 52 | + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); | |
| 53 | + const sniffed = sniffVideoType(realMp4); | |
| 54 | + const { absPath, relPath, ext } = saveVideoUpload(realMp4, sniffed); | |
| 55 | + try { | |
| 56 | + assert.equal(ext, "mp4"); | |
| 57 | + assert.match(relPath, /^\/uploads\/\d{6}\/[0-9a-f-]+\.mp4$/); | |
| 58 | + assert.deepEqual(fs.readFileSync(absPath), realMp4); | |
| 59 | + } finally { | |
| 60 | + fs.rmSync(absPath); | |
| 61 | + } | |
| 62 | +}); | |
| 63 | + | |
| 41 | 64 | // Walks a JPEG's APP1 "Exif\0\0" TIFF block (if any) and reports whether |
| 42 | 65 | // IFD0 has a GPS IFD pointer (tag 0x8825) — i.e. whether the file carries |
| 43 | 66 | // GPS EXIF data. Dependency-free stand-in for what the original acceptance | ... | ... |