← GPT-AI-BANNER
CODE · 2.8 KB

api/download-banner.js

Workspace snapshot · 09/04 13:35

import { callRuntimeRpc, RUNTIME_SERVICE_ID, runtimeEnvironment } from "./banner-core.js";

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;

export function safeDownloadName(value = "banner.png") {
  const base = String(value)
    .replace(/[\\/:*?"<>|\u0000-\u001f]/g, "-")
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, 120) || "banner";
  return /\.(png|jpe?g|webp)$/i.test(base) ? base : `${base}.png`;
}

export function isAllowedArtifactUrl(value) {
  try {
    const url = new URL(value);
    return url.protocol === "https:" && url.hostname.endsWith(".supabase.co");
  } catch {
    return false;
  }
}

export default async function handler(req, res) {
  if (req.method !== "GET") return res.status(405).json({ error: "GETのみ利用できます" });

  const jobId = String(req.query?.jobId || "");
  if (!UUID_PATTERN.test(jobId)) return res.status(400).json({ error: "有効なjobIdが必要です" });

  const runtime = runtimeEnvironment();
  if (!runtime.connected) return res.status(503).json({ error: "画像生成基盤が未接続です" });

  try {
    const job = await callRuntimeRpc({
      rpc: "avs_llm_public_get_job",
      payload: { p_service_id: RUNTIME_SERVICE_ID, p_token: runtime.token, p_job_id: jobId },
    });
    const artifactUrl = job?.status === "COMPLETED" ? job.output?.artifacts?.[0]?.signedUrl : null;
    if (!isAllowedArtifactUrl(artifactUrl)) return res.status(404).json({ error: "ダウンロード可能な画像がありません" });

    const imageResponse = await fetch(artifactUrl);
    if (!imageResponse.ok) return res.status(502).json({ error: "画像を取得できませんでした" });
    const contentType = String(imageResponse.headers.get("content-type") || "").split(";")[0].toLowerCase();
    if (!ALLOWED_IMAGE_TYPES.has(contentType)) return res.status(502).json({ error: "画像形式を確認できませんでした" });
    const bytes = Buffer.from(await imageResponse.arrayBuffer());
    if (!bytes.length || bytes.length > MAX_IMAGE_BYTES) return res.status(502).json({ error: "画像サイズが不正です" });

    const filename = safeDownloadName(req.query?.filename);
    const asciiFallback = filename.replace(/[^\x20-\x7e]/g, "_");
    res.setHeader("Content-Type", contentType);
    res.setHeader("Content-Length", String(bytes.length));
    res.setHeader("Cache-Control", "private, no-store");
    res.setHeader("Content-Disposition", `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
    return res.status(200).send(bytes);
  } catch (error) {
    return res.status(error.status || 502).json({ error: error.message || "画像をダウンロードできませんでした" });
  }
}