169 lines
5.7 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|