feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates

This commit is contained in:
Ajasra committed 2026-10-28 01:20:01 +08:00
commit e04672db2a
104 files changed
+8262

No files matched your search

+20
View File
@@ -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,
});
}
+168
View File
@@ -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 }
);
}
}
+52
View File
@@ -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 });
}
}
+95
View File
@@ -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;
}
+43
View File
@@ -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 }
);
}
}