uploads.js 8.57 KB
// 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,
  placeInUploads,
  forwardToH3Assets,
  forwardToFalAssets,
};