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 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
... ...