Commit 59183b0ed58105ea8bb2b90d1318b111d35d0f28

Authored by yibo
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.
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