feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates
This commit is contained in:
commit
e04672db2a
104 files changed
+8262
No files matched your search
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getBoothConfig, getBoothStyles } from "@/lib/config";
|
||||
|
||||
export async function GET() {
|
||||
const config = getBoothConfig();
|
||||
const styles = getBoothStyles();
|
||||
|
||||
// Return public safe configuration (omit any secret internal options)
|
||||
return NextResponse.json({
|
||||
appName: config.appName,
|
||||
kioskMode: config.kioskMode,
|
||||
theme: config.theme,
|
||||
camera: config.camera,
|
||||
features: config.features,
|
||||
limits: config.limits,
|
||||
kiosk: config.kiosk,
|
||||
labels: config.labels,
|
||||
styles,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { filename: string } }
|
||||
) {
|
||||
try {
|
||||
const rawFilename = params.filename;
|
||||
|
||||
// 1. Strict filename validation: alphanumeric + hyphens/underscores + safe image extensions only
|
||||
const validFilenameRegex = /^[a-zA-Z0-9_-]+\.(jpg|jpeg|png|webp)$/i;
|
||||
if (!rawFilename || !validFilenameRegex.test(rawFilename)) {
|
||||
return new NextResponse("Bad Request: Invalid or disallowed image filename", { status: 400 });
|
||||
}
|
||||
|
||||
const filename = path.basename(rawFilename);
|
||||
const imagesDir = path.resolve(process.cwd(), "data", "images");
|
||||
const filePath = path.resolve(imagesDir, filename);
|
||||
|
||||
// 2. Prevent path traversal outside data/images directory
|
||||
if (!filePath.startsWith(imagesDir)) {
|
||||
return new NextResponse("Access Denied", { status: 403 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new NextResponse("Image Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
let contentType = "image/png";
|
||||
if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
|
||||
else if (ext === ".webp") contentType = "image/webp";
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API Image Serve] Error reading file:", error);
|
||||
return new NextResponse("Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getExpectedToken, isAuthenticated } from "@/lib/admin-auth";
|
||||
import {
|
||||
getClientIp,
|
||||
constantTimeCompare,
|
||||
checkAuthRateLimit,
|
||||
recordAuthFailure,
|
||||
recordAuthSuccess,
|
||||
} from "@/lib/security";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const ip = getClientIp(req);
|
||||
|
||||
// 1. Check brute-force lockout
|
||||
const authLimit = checkAuthRateLimit(ip, 5, 15);
|
||||
if (!authLimit.allowed) {
|
||||
const waitMins = Math.ceil(authLimit.lockoutSeconds / 60);
|
||||
console.warn(`[Portal Auth Security] IP ${ip} blocked due to too many failed login attempts.`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Too many failed login attempts. IP temporarily blocked. Try again in ${waitMins} minute${waitMins > 1 ? "s" : ""}.`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const password = body.password;
|
||||
const expectedPassword = process.env.ADMIN_PASSWORD || "admin123";
|
||||
|
||||
// 2. Validate password using timing-safe comparison
|
||||
if (!password || typeof password !== "string" || !constantTimeCompare(password, expectedPassword)) {
|
||||
recordAuthFailure(ip, 5, 15);
|
||||
const remaining = authLimit.remainingAttempts - 1;
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Incorrect password",
|
||||
remainingAttempts: Math.max(0, remaining),
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Clear failed attempts on successful login
|
||||
recordAuthSuccess(ip);
|
||||
|
||||
const token = getExpectedToken();
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
token,
|
||||
message: "Authentication successful",
|
||||
});
|
||||
|
||||
response.cookies.set({
|
||||
name: "booth_portal_token",
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
console.error("[Portal Auth] Login error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Internal authentication error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const authed = isAuthenticated(req);
|
||||
if (authed) {
|
||||
return NextResponse.json({ authenticated: true });
|
||||
}
|
||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const response = NextResponse.json({ success: true, message: "Logged out" });
|
||||
response.cookies.set({
|
||||
name: "booth_portal_token",
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
sameSite: "lax",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/lib/admin-auth";
|
||||
import { getGenerationStats } from "@/lib/storage";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized. Please login." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const periodParam = searchParams.get("period") as any;
|
||||
const rawFromDate = searchParams.get("from") || undefined;
|
||||
const rawToDate = searchParams.get("to") || undefined;
|
||||
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const fromDate = rawFromDate && dateRegex.test(rawFromDate) ? rawFromDate : undefined;
|
||||
const toDate = rawToDate && dateRegex.test(rawToDate) ? rawToDate : undefined;
|
||||
|
||||
const validPeriods = ["day", "week", "month", "all", "custom"];
|
||||
const period = validPeriods.includes(periodParam) ? periodParam : "week";
|
||||
|
||||
const stats = getGenerationStats({
|
||||
period,
|
||||
fromDate,
|
||||
toDate,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Portal Stats API] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Failed to retrieve statistics" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user