Files
karate_halloween/app/api/generate/route.ts
T

169 lines
5.7 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getBoothConfig, getBoothStyles } from "@/lib/config";
import { checkRateLimit, incrementRateLimit } from "@/lib/rate-limit";
import { orchestrateGeneration } from "@/lib/ai/orchestrator";
import { isContentSafetyError } from "@/lib/ai/safety";
import { logGenerationEvent } from "@/lib/storage";
import { validateAndSanitizeImage, getClientIp } from "@/lib/security";
export async function POST(req: NextRequest) {
const requestStartTime = Date.now();
let clientId = "127.0.0.1";
let selectedStyle: any = null;
let requestedStyleId = "";
try {
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Invalid JSON request body" },
{ status: 400 }
);
}
const { imageBase64, styleId, clientId: clientProvidedId, mimeType } = body;
requestedStyleId = typeof styleId === "string" ? styleId.slice(0, 64) : "";
const config = getBoothConfig();
// 1. Image binary validation & magic bytes verification (rejects executables, scripts, etc.)
const maxUploadBytes = (config.camera.maxUploadSizeMb || 10) * 1024 * 1024;
const validation = validateAndSanitizeImage(imageBase64, maxUploadBytes);
if (!validation.valid) {
console.warn(`[Security Alert] Rejected image upload: ${validation.error}`);
return NextResponse.json(
{ error: validation.error || "Invalid or disallowed image file" },
{ status: 400 }
);
}
const styles = getBoothStyles();
// Find requested style
selectedStyle = styles.find((s) => s.id === styleId) || styles[0];
if (!selectedStyle) {
return NextResponse.json(
{ error: "No styles available in configuration" },
{ status: 400 }
);
}
// Determine client identifier for rate-limiting (bind strictly to verified IP)
const ip = getClientIp(req);
// Sanitize optional device token if provided, but always prefix with verified IP so clients cannot evade IP quotas
const safeClientTag = typeof clientProvidedId === "string"
? clientProvidedId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32)
: "";
clientId = safeClientTag ? `${ip}#${safeClientTag}` : ip;
// Check device rate limit
const limitCheck = checkRateLimit(
clientId,
config.limits.maxGenerationsPerDevice,
config.limits.cooldownMinutes
);
if (!limitCheck.allowed) {
// Record rate limit refusal
await logGenerationEvent({
styleId: selectedStyle.id,
styleName: selectedStyle.name,
provider: "unknown",
model: "rate_limiter",
durationMs: Date.now() - requestStartTime,
status: "refused",
errorType: "rate_limit",
errorMessage: "Device reached maximum generation quota within cooldown window.",
refusalReason: `Rate limit hit. Wait ${limitCheck.resetMinutes} min.`,
clientId,
});
return NextResponse.json(
{
error: "Rate limit reached for this device.",
remaining: 0,
resetMinutes: limitCheck.resetMinutes,
},
{ status: 429 }
);
}
// Execute generation pipeline with sanitized image and verified MIME type
const result = await orchestrateGeneration({
config,
style: selectedStyle,
imageBase64: validation.sanitizedBase64!,
mimeType: validation.mimeType,
});
// Save successful generation & image file to persistent storage
const record = await logGenerationEvent({
styleId: selectedStyle.id,
styleName: selectedStyle.name,
provider: result.providerUsed,
model: result.modelUsed,
durationMs: result.durationMs,
status: "success",
rawImageUrl: result.imageUrl,
clientId,
});
// Increment usage for this device
incrementRateLimit(clientId);
const updatedLimit = checkRateLimit(
clientId,
config.limits.maxGenerationsPerDevice,
config.limits.cooldownMinutes
);
const servedImageUrl = record.imageUrl || result.imageUrl;
return NextResponse.json({
success: true,
imageUrl: servedImageUrl,
providerUsed: result.providerUsed,
modelUsed: result.modelUsed,
durationMs: result.durationMs,
fallbackTriggered: result.fallbackTriggered,
remainingQuota: updatedLimit.remaining,
warnings: result.warnings,
});
} catch (error: any) {
console.error("[API Generate] Exception:", error);
const isSafety = isContentSafetyError(error);
// Record error or content safety refusal in admin logs
try {
await logGenerationEvent({
styleId: selectedStyle?.id || requestedStyleId || "unknown",
styleName: selectedStyle?.name || "Unknown Disguise",
provider: error.provider || "unknown",
model: error.model || "ai-pipeline",
durationMs: Date.now() - requestStartTime,
status: isSafety ? "refused" : "error",
errorType: isSafety ? "safety_refusal" : "server_error",
errorMessage: error.message || "An unexpected error occurred during generation.",
refusalReason: isSafety
? (error.message || "Content safety filter blocked output")
: undefined,
clientId,
});
} catch (logErr) {
console.error("[API Generate] Failed to log error event:", logErr);
}
return NextResponse.json(
{
success: false,
isSafetyRejection: isSafety,
error: isSafety
? "This photo was rejected by the content safety filter. Please snap or select a different photo."
: (error.message || "An unexpected error occurred during generation."),
},
{ status: isSafety ? 422 : 500 }
);
}
}