// Image upload handling for the dressup dance room (dev-plan.md T2). // No multipart-parsing or image-processing dependency is installed in this // project (it's a plain http.Server app, no package.json), so this hand-rolls // the parser and shells out to macOS's built-in `sips` for re-encoding — // ponytail: `sips` is macOS-only; this app currently only runs on this // machine (pm2, see H3-CONSOLE.md), if it's ever deployed to Linux this // needs to swap to ImageMagick/`convert` or a JS image library. const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { execFileSync } = require("child_process"); const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; const UPLOADS_DIR = path.join(__dirname, "uploads"); function readRawBody(req, maxBytes = MAX_UPLOAD_BYTES) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; req.on("data", (chunk) => { total += chunk.length; if (total > maxBytes) { req.destroy(); reject(Object.assign(new Error("payload too large"), { code: "PAYLOAD_TOO_LARGE" })); return; } chunks.push(chunk); }); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } // Minimal multipart/form-data parser. Buffers the whole body (bounded by // MAX_UPLOAD_BYTES above) rather than streaming — simple and correct beats // a streaming parser for files this small. function parseMultipart(body, boundary) { const marker = Buffer.from(`--${boundary}`); const parts = []; let pos = body.indexOf(marker); if (pos === -1) return parts; pos += marker.length; while (true) { const two = body.slice(pos, pos + 2).toString("latin1"); if (two === "--") break; // final boundary pos += 2; // CRLF right after the boundary marker const next = body.indexOf(marker, pos); if (next === -1) break; const raw = body.slice(pos, next - 2); // strip the CRLF before the next boundary const headerEnd = raw.indexOf("\r\n\r\n"); if (headerEnd !== -1) { const headerText = raw.slice(0, headerEnd).toString("utf8"); const content = raw.slice(headerEnd + 4); const nameMatch = headerText.match(/name="([^"]*)"/); const filenameMatch = headerText.match(/filename="([^"]*)"/); parts.push({ name: nameMatch ? nameMatch[1] : null, filename: filenameMatch ? filenameMatch[1] : null, data: content, }); } pos = next + marker.length; } return parts; } function boundaryFromContentType(contentType) { const match = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType || ""); return match ? (match[1] || match[2]).trim() : null; } // Real magic-byte sniffing — trust boundary, so this must not trust the // client's Content-Type header or the uploaded filename's extension. const MAGIC = [ { type: "image/jpeg", ext: "jpg", check: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, { type: "image/png", ext: "png", check: (b) => b.length > 8 && b.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), }, { type: "image/webp", ext: "webp", check: (b) => b.length >= 12 && b.slice(0, 4).toString("latin1") === "RIFF" && b.slice(8, 12).toString("latin1") === "WEBP", }, ]; function sniffImageType(buffer) { return MAGIC.find((m) => m.check(buffer)) || null; } // sips can't write webp (`sips --formats` lists it read-only), so webp // uploads get normalized to png. const SIPS_OUT_FORMAT = { jpg: "jpeg", png: "png", webp: "png" }; // Re-encodes through a BMP intermediate — BMP has no metadata block at all, // so this round-trip strips EXIF/GPS/ICC regardless of source format // (verified: sips's direct jpeg->jpeg re-encode alone does NOT strip EXIF, // only the bmp round-trip does). function stripMetadataAndSave(buffer, sniffed) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "upload-")); try { const srcPath = path.join(tmpDir, `src.${sniffed.ext}`); fs.writeFileSync(srcPath, buffer); const bmpPath = path.join(tmpDir, "mid.bmp"); execFileSync("sips", ["-s", "format", "bmp", srcPath, "--out", bmpPath], { stdio: "pipe" }); const outExt = sniffed.ext === "webp" ? "png" : sniffed.ext; const strippedPath = path.join(tmpDir, `out.${outExt}`); execFileSync("sips", ["-s", "format", SIPS_OUT_FORMAT[sniffed.ext], bmpPath, "--out", strippedPath], { stdio: "pipe" }); 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. async function forwardToH3Assets(absPath, base, token) { const buffer = fs.readFileSync(absPath); const boundary = `----h3assets${crypto.randomBytes(8).toString("hex")}`; const filename = path.basename(absPath); const head = Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n` ); const tail = Buffer.from(`\r\n--${boundary}--\r\n`); const body = Buffer.concat([head, buffer, tail]); const res = await fetch(`${base}/v1/assets`, { method: "POST", headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, Authorization: `Bearer ${token}` }, body, }); if (!res.ok) throw new Error(`H3 asset upload failed: ${res.status} ${await res.text()}`); return res.json(); // { id, url, kind } } const EXT_MIME = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", mp4: "video/mp4" }; // T33 (dev-plan-5.md): fal's storage isn't the same service as H3 // Console's /v1/assets (S4 spike's finding only covered H3), so this // forwards a local upload through fal's own two-step contract instead — // verified against fal's docs (https://fal.ai/docs/model-apis/file-uploads) // and the fal-js client's storage.ts source, not guessed: // 1. POST {restBase}/storage/upload/initiate {content_type, file_name} // -> {file_url, upload_url} // 2. PUT the raw bytes to upload_url // file_url is then a publicly-fetchable URL usable directly as a // reference_image_urls/reference_video_urls entry. async function forwardToFalAssets(absPath, apiKey, restBase = "https://rest.alpha.fal.ai") { const buffer = fs.readFileSync(absPath); const ext = path.extname(absPath).slice(1).toLowerCase(); const contentType = EXT_MIME[ext] || "application/octet-stream"; const fileName = path.basename(absPath); const initiateRes = await fetch(`${restBase}/storage/upload/initiate?storage_type=fal-cdn-v3`, { method: "POST", headers: { Authorization: `Key ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ content_type: contentType, file_name: fileName }), }); if (!initiateRes.ok) throw new Error(`fal upload initiate failed: ${initiateRes.status} ${await initiateRes.text()}`); const { file_url: fileUrl, upload_url: uploadUrl } = await initiateRes.json(); const putRes = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: buffer }); if (!putRes.ok) throw new Error(`fal upload PUT failed: ${putRes.status} ${await putRes.text()}`); return { url: fileUrl }; } module.exports = { MAX_UPLOAD_BYTES, MAX_VIDEO_UPLOAD_BYTES, UPLOADS_DIR, readRawBody, parseMultipart, boundaryFromContentType, sniffImageType, sniffVideoType, stripMetadataAndSave, saveVideoUpload, forwardToH3Assets, forwardToFalAssets, };