capsule AI-native Unix-like composition layer

src/api/upload.js

2,777 bytes · 84 lines · capsule://quake0day/[email protected] raw on github

import { isAuthed, json, unauthorized } from "../_lib/auth.js";

// POST /api/upload (multipart/form-data)
//   field "file": image blob (already resized client-side)
//   field "name": optional desired filename (will be sanitized + uniquified)
// → returns { ok, key, url, w, h }
//
// The client is expected to have already resized the image to a sensible
// max dimension (we accept anything up to 8MB to be safe).

const MAX_BYTES = 8 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);

function sanitizeBaseName(name) {
  let base = (name || "upload").replace(/\.[a-z0-9]+$/i, "");
  base = base.toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "")
    .slice(0, 60);
  return base || "upload";
}

function pickExt(type) {
  if (type === "image/png") return "png";
  if (type === "image/webp") return "webp";
  return "jpg";
}

export async function onRequest(context) {
  const { request, env } = context;
  if (request.method.toUpperCase() !== "POST")
    return json({ error: "Method not allowed" }, { status: 405 });
  if (!await isAuthed(request, env)) return unauthorized();
  if (!env.YL_IMAGES) return json({ error: "R2 bucket YL_IMAGES is not bound" }, { status: 500 });

  const ct = request.headers.get("Content-Type") || "";
  if (!ct.startsWith("multipart/form-data"))
    return json({ error: "Expected multipart/form-data" }, { status: 400 });

  let form;
  try { form = await request.formData(); } catch {
    return json({ error: "Invalid form data" }, { status: 400 });
  }

  const file = form.get("file");
  if (!file || typeof file === "string")
    return json({ error: "Missing file field" }, { status: 400 });

  const type = file.type || "image/jpeg";
  if (!ALLOWED_TYPES.has(type))
    return json({ error: `Unsupported type: ${type}. Use JPEG/PNG/WebP.` }, { status: 400 });

  const ab = await file.arrayBuffer();
  if (ab.byteLength > MAX_BYTES)
    return json({ error: `File too large (${ab.byteLength} bytes, max ${MAX_BYTES})` }, { status: 413 });

  const desired = (form.get("name") || file.name || "upload").toString();
  const base = sanitizeBaseName(desired);
  const ext = pickExt(type);
  const stamp = Date.now().toString(36);
  const rand = Math.random().toString(36).slice(2, 6);
  const key = `${base}_${stamp}${rand}.${ext}`;

  await env.YL_IMAGES.put(key, ab, {
    httpMetadata: {
      contentType: type,
      cacheControl: "public, max-age=31536000, immutable"
    }
  });

  // Width/height passed by client (post-resize)
  const w = parseInt(form.get("w") || "0", 10) || null;
  const h = parseInt(form.get("h") || "0", 10) || null;

  return json({
    ok: true,
    key,
    url: `/api/img/${key}`,
    w, h,
    bytes: ab.byteLength,
    type
  });
}