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,7 +48,9 @@ const { | ||
| 48 | parseMultipart, | 48 | parseMultipart, |
| 49 | boundaryFromContentType, | 49 | boundaryFromContentType, |
| 50 | sniffImageType, | 50 | sniffImageType, |
| 51 | + sniffVideoType, | ||
| 51 | stripMetadataAndSave, | 52 | stripMetadataAndSave, |
| 53 | + saveVideoUpload, | ||
| 52 | } = require("./uploads.js"); | 54 | } = require("./uploads.js"); |
| 53 | const { characters, garments, danceRefs } = require("./dressup-store.js"); | 55 | const { characters, garments, danceRefs } = require("./dressup-store.js"); |
| 54 | 56 | ||
| @@ -1006,6 +1008,44 @@ http | @@ -1006,6 +1008,44 @@ http | ||
| 1006 | return; | 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 | if (pathname.startsWith("/uploads/")) { | 1049 | if (pathname.startsWith("/uploads/")) { |
| 1010 | const rel = pathname.slice("/uploads/".length); | 1050 | const rel = pathname.slice("/uploads/".length); |
| 1011 | const abs = path.join(UPLOADS_DIR, rel); | 1051 | const abs = path.join(UPLOADS_DIR, rel); |
uploads-route.test.js
| @@ -64,6 +64,21 @@ test.after(() => { | @@ -64,6 +64,21 @@ test.after(() => { | ||
| 64 | fs.rmSync(dir, { recursive: true, force: true }); | 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 | test("uploading a real PNG returns a URL that GET /uploads/* serves back with the right content-type", async () => { | 82 | test("uploading a real PNG returns a URL that GET /uploads/* serves back with the right content-type", async () => { |
| 68 | const fd = new FormData(); | 83 | const fd = new FormData(); |
| 69 | fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png"); | 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,19 +112,38 @@ function stripMetadataAndSave(buffer, sniffed) { | ||
| 112 | const strippedPath = path.join(tmpDir, `out.${outExt}`); | 112 | const strippedPath = path.join(tmpDir, `out.${outExt}`); |
| 113 | execFileSync("sips", ["-s", "format", SIPS_OUT_FORMAT[sniffed.ext], bmpPath, "--out", strippedPath], { stdio: "pipe" }); | 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 | } finally { | 116 | } finally { |
| 124 | fs.rmSync(tmpDir, { recursive: true, force: true }); | 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 | // Forwards an already-saved local upload to H3 Console's own asset endpoint | 147 | // 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 | 148 | // (H3-CONSOLE.md, found via S4 spike) so it's reachable when used in |
| 130 | // reference_image_urls/reference_video_urls. | 149 | // reference_image_urls/reference_video_urls. |
| @@ -149,11 +168,14 @@ async function forwardToH3Assets(absPath, base, token) { | @@ -149,11 +168,14 @@ async function forwardToH3Assets(absPath, base, token) { | ||
| 149 | 168 | ||
| 150 | module.exports = { | 169 | module.exports = { |
| 151 | MAX_UPLOAD_BYTES, | 170 | MAX_UPLOAD_BYTES, |
| 171 | + MAX_VIDEO_UPLOAD_BYTES, | ||
| 152 | UPLOADS_DIR, | 172 | UPLOADS_DIR, |
| 153 | readRawBody, | 173 | readRawBody, |
| 154 | parseMultipart, | 174 | parseMultipart, |
| 155 | boundaryFromContentType, | 175 | boundaryFromContentType, |
| 156 | sniffImageType, | 176 | sniffImageType, |
| 177 | + sniffVideoType, | ||
| 157 | stripMetadataAndSave, | 178 | stripMetadataAndSave, |
| 179 | + saveVideoUpload, | ||
| 158 | forwardToH3Assets, | 180 | forwardToH3Assets, |
| 159 | }; | 181 | }; |
uploads.test.js
| @@ -8,7 +8,9 @@ const { | @@ -8,7 +8,9 @@ const { | ||
| 8 | parseMultipart, | 8 | parseMultipart, |
| 9 | boundaryFromContentType, | 9 | boundaryFromContentType, |
| 10 | sniffImageType, | 10 | sniffImageType, |
| 11 | + sniffVideoType, | ||
| 11 | stripMetadataAndSave, | 12 | stripMetadataAndSave, |
| 13 | + saveVideoUpload, | ||
| 12 | UPLOADS_DIR, | 14 | UPLOADS_DIR, |
| 13 | } = require("./uploads.js"); | 15 | } = require("./uploads.js"); |
| 14 | 16 | ||
| @@ -38,6 +40,27 @@ test("parseMultipart extracts a file part from a real multipart body (built via | @@ -38,6 +40,27 @@ test("parseMultipart extracts a file part from a real multipart body (built via | ||
| 38 | assert.deepEqual([...parts[0].data], [1, 2, 3, 4]); | 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 | // Walks a JPEG's APP1 "Exif\0\0" TIFF block (if any) and reports whether | 64 | // 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 | 65 | // 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 | 66 | // GPS EXIF data. Dependency-free stand-in for what the original acceptance |