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,24 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { constantTimeCompare } from "@/lib/security";
|
||||
|
||||
export function getExpectedToken(): string {
|
||||
const secret = process.env.ADMIN_PASSWORD || "admin123";
|
||||
if (!process.env.ADMIN_PASSWORD && process.env.NODE_ENV === "production") {
|
||||
console.warn("[SECURITY WARNING] ADMIN_PASSWORD is not set in environment variables! Using insecure default.");
|
||||
}
|
||||
return crypto.createHash("sha256").update(`booth-portal-session-${secret}`).digest("hex");
|
||||
}
|
||||
|
||||
export function isAuthenticated(req: NextRequest): boolean {
|
||||
const cookieToken = req.cookies.get("booth_portal_token")?.value;
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
|
||||
|
||||
const expected = getExpectedToken();
|
||||
const tokenToTest = cookieToken || bearerToken || "";
|
||||
|
||||
if (!tokenToTest) return false;
|
||||
|
||||
return constantTimeCompare(tokenToTest, expected);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Provider cooldown is completely disabled: transient failures or safety rejections
|
||||
// will never lock out the API for subsequent requests.
|
||||
export function isProviderAvailable(_providerName: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function triggerCooldown(
|
||||
_providerName: string,
|
||||
_durationMinutes: number,
|
||||
_reason: string
|
||||
): void {
|
||||
// No-op: Cooldown disabled
|
||||
}
|
||||
|
||||
export function resetProvider(_providerName: string): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ContentSafetyError } from "./safety";
|
||||
export interface GeminiGenerateOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
timeoutMs: number;
|
||||
enableSfwCheck: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiResult {
|
||||
imageUrl: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function generateWithGemini(
|
||||
options: GeminiGenerateOptions
|
||||
): Promise<GeminiResult> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY is not configured in environment variables");
|
||||
}
|
||||
|
||||
// Clean base64 string
|
||||
const cleanBase64 = options.imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${options.model}:generateContent?key=${apiKey}`;
|
||||
|
||||
const requestBody: any = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
text: `${options.prompt}\n\nNegative directives: ${options.negativePrompt || "none"}`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: options.mimeType || "image/jpeg",
|
||||
data: cleanBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (options.enableSfwCheck) {
|
||||
requestBody.safetySettings = [
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HARASSMENT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("PROHIBITED_CONTENT") ||
|
||||
errorText.includes("HARM_CATEGORY") ||
|
||||
errorText.includes("SAFETY")
|
||||
) {
|
||||
throw new ContentSafetyError(
|
||||
`Image rejected by content safety filter: PROHIBITED_CONTENT`
|
||||
);
|
||||
}
|
||||
throw new Error(`Gemini API error [${response.status}]: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check safety feedback
|
||||
if (data.promptFeedback?.blockReason) {
|
||||
throw new ContentSafetyError(
|
||||
`Image rejected by content safety filter: ${data.promptFeedback.blockReason}`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract generated image from candidates
|
||||
const candidate = data.candidates?.[0];
|
||||
if (!candidate) {
|
||||
throw new Error("Gemini returned no candidates in response");
|
||||
}
|
||||
|
||||
// Check candidate finishReason for safety blocks
|
||||
if (candidate.finishReason === "SAFETY" || candidate.finishReason === "BLOCKLIST") {
|
||||
throw new ContentSafetyError(
|
||||
"Image rejected by content safety filter: output triggered Safe-for-Work (SFW) protection."
|
||||
);
|
||||
}
|
||||
|
||||
// Look for inline image in response parts
|
||||
const parts = candidate.content?.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
const mime = part.inlineData.mimeType || "image/png";
|
||||
return {
|
||||
imageUrl: `data:${mime};base64,${part.inlineData.data}`,
|
||||
provider: "gemini",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If text returned or URL in text
|
||||
const textPart = parts.find((p: any) => p.text)?.text || "";
|
||||
const urlMatch = textPart.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) {
|
||||
return {
|
||||
imageUrl: urlMatch[0],
|
||||
provider: "gemini",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Gemini did not return an image part in the response payload. Please verify model image generation capabilities."
|
||||
);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`Gemini request timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export interface OpenRouterGenerateOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface OpenRouterResult {
|
||||
imageUrl: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function generateWithOpenRouter(
|
||||
options: OpenRouterGenerateOptions
|
||||
): Promise<OpenRouterResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is not configured in environment variables");
|
||||
}
|
||||
|
||||
const cleanBase64 = options.imageBase64.startsWith("data:")
|
||||
? options.imageBase64
|
||||
: `data:${options.mimeType || "image/jpeg"};base64,${options.imageBase64}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://photobooth.local",
|
||||
"X-Title": "AI PhotoBooth",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${options.prompt}\n\nNegative prompt: ${options.negativePrompt || "none"}`,
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: cleanBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenRouter API error [${response.status}]: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
|
||||
// 1. Check OpenRouter image output attachments (message.images: [{ image_url: { url: ... } }])
|
||||
if (Array.isArray(message?.images) && message.images.length > 0) {
|
||||
for (const img of message.images) {
|
||||
const url = img?.image_url?.url || img?.url;
|
||||
if (url) {
|
||||
return {
|
||||
imageUrl: url,
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("OpenRouter returned an empty completion");
|
||||
}
|
||||
|
||||
// Check if content is or contains an image URL or base64 data
|
||||
const base64Match = content.match(/data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+/);
|
||||
if (base64Match) {
|
||||
return {
|
||||
imageUrl: base64Match[0],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) {
|
||||
return {
|
||||
imageUrl: urlMatch[0],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Some models return markdown image: 
|
||||
const mdMatch = content.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
|
||||
if (mdMatch) {
|
||||
return {
|
||||
imageUrl: mdMatch[1],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`OpenRouter response did not contain an image. Output received: ${content.slice(0, 120)}...`
|
||||
);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`OpenRouter request timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BoothConfig, BoothStyle } from "@/types/booth";
|
||||
import { generateWithGemini } from "./gemini";
|
||||
import { generateWithOpenRouter } from "./openrouter";
|
||||
import {
|
||||
isProviderAvailable,
|
||||
triggerCooldown,
|
||||
resetProvider,
|
||||
} from "./circuit-breaker";
|
||||
import { ContentSafetyError, isContentSafetyError } from "./safety";
|
||||
|
||||
export interface OrchestrationRequest {
|
||||
config: BoothConfig;
|
||||
style: BoothStyle;
|
||||
imageBase64: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface OrchestrationResult {
|
||||
imageUrl: string;
|
||||
providerUsed: "gemini" | "openrouter" | "demo_mock";
|
||||
modelUsed: string;
|
||||
durationMs: number;
|
||||
fallbackTriggered: boolean;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export async function orchestrateGeneration(
|
||||
request: OrchestrationRequest
|
||||
): Promise<OrchestrationResult> {
|
||||
const startTime = Date.now();
|
||||
const { config, style, imageBase64 } = request;
|
||||
const mimeType = request.mimeType || "image/jpeg";
|
||||
const warnings: string[] = [];
|
||||
|
||||
const hasGeminiKey = Boolean(process.env.GEMINI_API_KEY);
|
||||
const hasOpenRouterKey = Boolean(process.env.OPENROUTER_API_KEY);
|
||||
|
||||
// Demo / Offline Mode fallback when no API keys are provided yet
|
||||
if (!hasGeminiKey && !hasOpenRouterKey) {
|
||||
console.log("[AI Orchestrator] No API keys configured. Using Demo Mode sample image.");
|
||||
// Simulate generation delay (2 seconds)
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
return {
|
||||
imageUrl: style.sampleResult || style.thumbnail || "/assets/styles/sample_clay_pink.png",
|
||||
providerUsed: "demo_mock",
|
||||
modelUsed: "demo-simulator",
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: false,
|
||||
warnings: ["Demo Mode active: Set GEMINI_API_KEY or OPENROUTER_API_KEY in .env.local to enable live AI generation."],
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Try Primary Provider (Google Gemini)
|
||||
if (isProviderAvailable("gemini") && hasGeminiKey) {
|
||||
try {
|
||||
console.log(`[AI Orchestrator] Attempting Primary (Gemini: ${config.ai.primary.model})...`);
|
||||
const result = await generateWithGemini({
|
||||
model: config.ai.primary.model,
|
||||
prompt: style.prompt,
|
||||
negativePrompt: style.negativePrompt,
|
||||
imageBase64,
|
||||
mimeType,
|
||||
timeoutMs: config.ai.primary.timeoutMs,
|
||||
enableSfwCheck: config.ai.enableSfwCheck,
|
||||
});
|
||||
|
||||
resetProvider("gemini");
|
||||
return {
|
||||
imageUrl: result.imageUrl,
|
||||
providerUsed: "gemini",
|
||||
modelUsed: result.model,
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: false,
|
||||
};
|
||||
} catch (primaryError: any) {
|
||||
// Content safety rejections are caused by user input, NOT provider downtime
|
||||
if (isContentSafetyError(primaryError)) {
|
||||
console.warn(`[AI Orchestrator] Primary provider (Gemini) rejected image for safety:`, primaryError.message);
|
||||
throw new ContentSafetyError(primaryError.message);
|
||||
}
|
||||
|
||||
console.warn(`[AI Orchestrator] Primary provider (Gemini) failed:`, primaryError.message);
|
||||
warnings.push(`Primary failed: ${primaryError.message}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push("Primary provider key missing. Skipping to backup.");
|
||||
}
|
||||
|
||||
// 2. Try Backup Provider (OpenRouter)
|
||||
if (isProviderAvailable("openrouter") && hasOpenRouterKey) {
|
||||
try {
|
||||
console.log(`[AI Orchestrator] Attempting Backup (OpenRouter: ${config.ai.backup.model})...`);
|
||||
const result = await generateWithOpenRouter({
|
||||
model: config.ai.backup.model,
|
||||
prompt: style.prompt,
|
||||
negativePrompt: style.negativePrompt,
|
||||
imageBase64,
|
||||
mimeType,
|
||||
timeoutMs: config.ai.backup.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
imageUrl: result.imageUrl,
|
||||
providerUsed: "openrouter",
|
||||
modelUsed: result.model,
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: true,
|
||||
warnings,
|
||||
};
|
||||
} catch (backupError: any) {
|
||||
if (isContentSafetyError(backupError)) {
|
||||
console.warn(`[AI Orchestrator] Backup provider (OpenRouter) rejected image for safety:`, backupError.message);
|
||||
throw new ContentSafetyError(backupError.message);
|
||||
}
|
||||
|
||||
console.error(`[AI Orchestrator] Backup provider (OpenRouter) failed:`, backupError.message);
|
||||
warnings.push(`Backup failed: ${backupError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If both providers failed or were unavailable, throw descriptive error
|
||||
throw new Error(
|
||||
`AI Generation failed on all available providers. Details: ${warnings.join(" | ")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export class ContentSafetyError extends Error {
|
||||
public readonly isSafetyRejection = true;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ContentSafetyError";
|
||||
Object.setPrototypeOf(this, ContentSafetyError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export function isContentSafetyError(err: any): boolean {
|
||||
if (!err) return false;
|
||||
if (err.isSafetyRejection || err.name === "ContentSafetyError") return true;
|
||||
|
||||
const msg = String(err.message || err.error || err || "").toLowerCase();
|
||||
return (
|
||||
msg.includes("safety filter") ||
|
||||
msg.includes("prohibited_content") ||
|
||||
msg.includes("harm_category") ||
|
||||
msg.includes("safe-for-work") ||
|
||||
msg.includes("finishreason: safety") ||
|
||||
msg.includes("finishreason: blocklist") ||
|
||||
msg.includes("blockreason") ||
|
||||
msg.includes("content safety") ||
|
||||
msg.includes("safety policy") ||
|
||||
msg.includes("violates safety")
|
||||
);
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { BoothConfig, BoothStyle } from "@/types/booth";
|
||||
|
||||
export const GENERATION_MODEL_MAP: Record<string, { primary: string; backup: string }> = {
|
||||
"nanobanana 2": {
|
||||
primary: "gemini-3.1-flash-image",
|
||||
backup: "google/gemini-3.1-flash-image",
|
||||
},
|
||||
"nanobanana": {
|
||||
primary: "gemini-2.5-flash-image",
|
||||
backup: "google/gemini-2.5-flash-image",
|
||||
},
|
||||
"lite": {
|
||||
primary: "gemini-3.1-flash-lite-image",
|
||||
backup: "google/gemini-3.1-flash-lite-image",
|
||||
},
|
||||
"nanobanana 2 lite": {
|
||||
primary: "gemini-3.1-flash-lite-image",
|
||||
backup: "google/gemini-3.1-flash-lite-image",
|
||||
},
|
||||
"pro": {
|
||||
primary: "gemini-3-pro-image",
|
||||
backup: "google/gemini-3-pro-image",
|
||||
},
|
||||
"nanobanana pro": {
|
||||
primary: "gemini-3-pro-image",
|
||||
backup: "google/gemini-3-pro-image",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveGenerationModels(): { primary: string; backup: string } {
|
||||
const envModel = (process.env.GENERATION_MODEL || "").trim().toLowerCase();
|
||||
if (envModel && GENERATION_MODEL_MAP[envModel]) {
|
||||
return GENERATION_MODEL_MAP[envModel];
|
||||
}
|
||||
return GENERATION_MODEL_MAP["nanobanana 2"];
|
||||
}
|
||||
|
||||
const defaultConfig: BoothConfig = {
|
||||
appName: "Spooky Hand · 万圣空手",
|
||||
kioskMode: true,
|
||||
theme: {
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#0b0714",
|
||||
primaryColor: "#ff6d00",
|
||||
accentColor: "#06b6d4",
|
||||
surfaceColor: "#170f29",
|
||||
textColor: "#ffffff",
|
||||
logoUrl: "/assets/brand/shanghai_karate_logo.png",
|
||||
logoWidth: 260,
|
||||
},
|
||||
camera: {
|
||||
defaultFacingMode: "user",
|
||||
aspectRatio: "9:16",
|
||||
countdownSeconds: 3,
|
||||
allowUpload: true,
|
||||
maxUploadSizeMb: 10,
|
||||
},
|
||||
features: {
|
||||
showQrCode: true,
|
||||
showDownloadButton: true,
|
||||
showPrintButton: false,
|
||||
showRetakeButton: true,
|
||||
showInstagramShare: false,
|
||||
},
|
||||
limits: {
|
||||
maxGenerationsPerDevice: 0,
|
||||
cooldownMinutes: 60,
|
||||
},
|
||||
ai: {
|
||||
primary: {
|
||||
provider: "gemini",
|
||||
model: "gemini-3.1-flash-image",
|
||||
timeoutMs: 28000,
|
||||
},
|
||||
backup: {
|
||||
provider: "openrouter",
|
||||
model: "google/gemini-3.1-flash-image",
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
cooldownDurationMinutes: 10,
|
||||
enableSfwCheck: true,
|
||||
},
|
||||
kiosk: {
|
||||
enableAutoReset: true,
|
||||
idleTimeoutSeconds: 90,
|
||||
},
|
||||
labels: {
|
||||
welcomeBadge: {
|
||||
en: "SHANGHAI KARATE CLUB • EST. 2008",
|
||||
zh: "上海健志空手道馆 · 始于2008",
|
||||
},
|
||||
welcomeTitle: {
|
||||
en: "SPOOKY HAND 🥋🎃",
|
||||
zh: "万圣“空手”道馆 🥋🎃",
|
||||
},
|
||||
welcomeSubtitle: {
|
||||
en: "Channel Your Spooky Ki & Martial Pose!",
|
||||
zh: "凝聚万圣气合,定格武道英姿!",
|
||||
},
|
||||
welcomeDesc: {
|
||||
en: "Strike your fiercest martial arts pose! Turn your Halloween costume or karate gi into an epic Shanghai legend.",
|
||||
zh: "摆出你最帅气的武道姿势!无论身穿万圣盛装还是空手道服,一键变身魔都万圣武道传奇!",
|
||||
},
|
||||
welcomeCta: {
|
||||
en: "Enter The Haunted Dojo! ⚡",
|
||||
zh: "开启万圣武道之旅!⚡",
|
||||
},
|
||||
cameraInstruction: {
|
||||
en: "Strike a punch, block, or karate victory pose!",
|
||||
zh: "摆出冲拳、格挡或胜利姿态,展现你的气魄!",
|
||||
},
|
||||
cameraSnap: {
|
||||
en: "Snap Martial Shot",
|
||||
zh: "拍摄武道照",
|
||||
},
|
||||
cameraUpload: {
|
||||
en: "Upload Photo",
|
||||
zh: "上传照片",
|
||||
},
|
||||
confirmTitle: {
|
||||
en: "Review Your Martial Shot",
|
||||
zh: "确认你的英姿",
|
||||
},
|
||||
retakeButton: {
|
||||
en: "Retake",
|
||||
zh: "重拍",
|
||||
},
|
||||
confirmButton: {
|
||||
en: "Looks Epic! Choose Style",
|
||||
zh: "超帅气!选择风格",
|
||||
},
|
||||
styleSelectionTitle: {
|
||||
en: "Select Your Halloween Kata",
|
||||
zh: "选择你的万圣奥义风格",
|
||||
},
|
||||
styleSelectionSubtitle: {
|
||||
en: "Choose your legendary martial transformation",
|
||||
zh: "挑选专属的万圣节武道变身效果",
|
||||
},
|
||||
confirmStyleButton: {
|
||||
en: "Cast Martial Magic ⚡",
|
||||
zh: "施展武道魔法 ⚡",
|
||||
},
|
||||
backButton: {
|
||||
en: "Back",
|
||||
zh: "返回",
|
||||
},
|
||||
processingTitle: {
|
||||
en: "Gathering Martial Ki & Spooky Spirits...",
|
||||
zh: "正在凝聚万圣之气与道馆灵力...",
|
||||
},
|
||||
processingSubtitle: {
|
||||
en: "Summoning Huangpu mist, pumpkin embers & black belt magic...",
|
||||
zh: "融合黄浦江晨雾、南瓜火光与黑带武道魔法...",
|
||||
},
|
||||
resultTitle: {
|
||||
en: "Your Spooky Martial Portrait! 🥋🎃",
|
||||
zh: "你的万圣武道神采!🥋🎃",
|
||||
},
|
||||
scanQrLabel: {
|
||||
en: "Scan to save to your phone",
|
||||
zh: "扫码保存至手机相册",
|
||||
},
|
||||
downloadLabel: {
|
||||
en: "Save Portrait",
|
||||
zh: "保存高清照",
|
||||
},
|
||||
wechatSaveTip: {
|
||||
en: "In WeChat: Long press photo above to save directly!",
|
||||
zh: "微信提示:长按上方图片即可直接保存到相册!",
|
||||
},
|
||||
printLabel: {
|
||||
en: "Print Photo",
|
||||
zh: "打印照片",
|
||||
},
|
||||
startOverLabel: {
|
||||
en: "Train Again (Start Over)",
|
||||
zh: "再拍一张",
|
||||
},
|
||||
instagramShareLabel: {
|
||||
en: "Share Photo",
|
||||
zh: "分享照片",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getBoothConfig(): BoothConfig {
|
||||
try {
|
||||
const configPath = path.join(process.cwd(), "config", "booth.config.json");
|
||||
const resolvedModels = resolveGenerationModels();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const fileContent = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = JSON.parse(fileContent);
|
||||
|
||||
const primaryModel =
|
||||
process.env.GENERATION_MODEL ||
|
||||
parsed.ai?.primary?.model === "gemini-2.5-flash" ||
|
||||
!parsed.ai?.primary?.model
|
||||
? resolvedModels.primary
|
||||
: parsed.ai.primary.model;
|
||||
|
||||
const backupModel =
|
||||
process.env.GENERATION_MODEL ||
|
||||
parsed.ai?.backup?.model === "google/gemini-2.5-flash" ||
|
||||
!parsed.ai?.backup?.model
|
||||
? resolvedModels.backup
|
||||
: parsed.ai.backup.model;
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...parsed,
|
||||
ai: {
|
||||
...defaultConfig.ai,
|
||||
...(parsed.ai || {}),
|
||||
primary: {
|
||||
...defaultConfig.ai.primary,
|
||||
...(parsed.ai?.primary || {}),
|
||||
model: primaryModel,
|
||||
},
|
||||
backup: {
|
||||
...defaultConfig.ai.backup,
|
||||
...(parsed.ai?.backup || {}),
|
||||
model: backupModel,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
...defaultConfig.labels,
|
||||
...(parsed.labels || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not load booth.config.json, using fallback configuration:", error);
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
export function getBoothStyles(): BoothStyle[] {
|
||||
try {
|
||||
const stylesPath = path.join(process.cwd(), "config", "styles.json");
|
||||
if (fs.existsSync(stylesPath)) {
|
||||
const fileContent = fs.readFileSync(stylesPath, "utf-8");
|
||||
return JSON.parse(fileContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not load styles.json, returning empty list:", error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { LocalizedString } from "@/types/booth";
|
||||
|
||||
export type Language = "en" | "zh";
|
||||
|
||||
interface I18nContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
toggleLanguage: () => void;
|
||||
t: (field: LocalizedString | string | undefined, fallback?: string) => string;
|
||||
isWeChat: boolean;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>("zh");
|
||||
const [isWeChat, setIsWeChat] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
// 1. Detect WeChat browser environment
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const inWeChat = ua.includes("micromessenger");
|
||||
setIsWeChat(inWeChat);
|
||||
|
||||
// 2. Check stored preference first
|
||||
const stored = localStorage.getItem("booth_lang") as Language | null;
|
||||
if (stored === "en" || stored === "zh") {
|
||||
setLanguageState(stored);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Fallback to system / browser language
|
||||
const browserLang = (navigator.language || "").toLowerCase();
|
||||
if (browserLang.startsWith("zh")) {
|
||||
setLanguageState("zh");
|
||||
} else {
|
||||
setLanguageState("en");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("booth_lang", lang);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = language === "zh" ? "en" : "zh";
|
||||
setLanguage(nextLang);
|
||||
};
|
||||
|
||||
const t = (field: LocalizedString | string | undefined, fallback: string = ""): string => {
|
||||
if (!field) return fallback;
|
||||
if (typeof field === "string") return field;
|
||||
return field[language] || field.en || fallback;
|
||||
};
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ language, setLanguage, toggleLanguage, t, isWeChat }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
return {
|
||||
language: "zh" as Language,
|
||||
setLanguage: () => {},
|
||||
toggleLanguage: () => {},
|
||||
t: (field: LocalizedString | string | undefined, fallback: string = "") => {
|
||||
if (!field) return fallback;
|
||||
if (typeof field === "string") return field;
|
||||
return field.zh || field.en || fallback;
|
||||
},
|
||||
isWeChat: false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
interface DeviceUsage {
|
||||
count: number;
|
||||
firstUsedAt: number;
|
||||
}
|
||||
|
||||
// In-memory usage map (or keyed by hashed client token)
|
||||
const usageStore = new Map<string, DeviceUsage>();
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetMinutes: number;
|
||||
}
|
||||
|
||||
export function checkRateLimit(
|
||||
clientId: string,
|
||||
maxAllowed: number,
|
||||
cooldownMinutes: number
|
||||
): RateLimitResult {
|
||||
if (!maxAllowed || maxAllowed <= 0) {
|
||||
return { allowed: true, remaining: 999999, resetMinutes: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000;
|
||||
const usage = usageStore.get(clientId);
|
||||
|
||||
if (!usage) {
|
||||
return { allowed: true, remaining: maxAllowed, resetMinutes: 0 };
|
||||
}
|
||||
|
||||
// Check if cooldown period has elapsed since first use
|
||||
if (now - usage.firstUsedAt > cooldownMs) {
|
||||
usageStore.delete(clientId);
|
||||
return { allowed: true, remaining: maxAllowed, resetMinutes: 0 };
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, maxAllowed - usage.count);
|
||||
const remainingCooldownMs = cooldownMs - (now - usage.firstUsedAt);
|
||||
const resetMinutes = Math.ceil(remainingCooldownMs / (60 * 1000));
|
||||
|
||||
return {
|
||||
allowed: usage.count < maxAllowed,
|
||||
remaining,
|
||||
resetMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
export function incrementRateLimit(clientId: string): void {
|
||||
const now = Date.now();
|
||||
const usage = usageStore.get(clientId);
|
||||
|
||||
if (!usage) {
|
||||
usageStore.set(clientId, { count: 1, firstUsedAt: now });
|
||||
} else {
|
||||
usage.count += 1;
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import crypto from "crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export interface ImageValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
mimeType?: "image/jpeg" | "image/png" | "image/webp";
|
||||
extension?: "jpg" | "png" | "webp";
|
||||
sanitizedBase64?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known file signatures (magic bytes) for allowed image formats
|
||||
*/
|
||||
const MAGIC_SIGNATURES = {
|
||||
// JPEG: starts with FF D8 FF
|
||||
jpeg: [0xff, 0xd8, 0xff],
|
||||
// PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
||||
png: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
||||
// WebP: starts with "RIFF" (52 49 46 46) and at offset 8 has "WEBP" (57 45 42 50)
|
||||
riff: [0x52, 0x49, 0x46, 0x46],
|
||||
webp: [0x57, 0x45, 0x42, 0x50],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dangerous executable & script signatures to explicitly detect and reject
|
||||
*/
|
||||
const DANGEROUS_SIGNATURES = [
|
||||
// Windows DOS/PE executable ('MZ' - 4D 5A)
|
||||
{ name: "Windows Executable (PE/DOS)", bytes: [0x4d, 0x5a] },
|
||||
// Linux ELF binary (7F 45 4C 46)
|
||||
{ name: "Linux ELF Binary", bytes: [0x7f, 0x45, 0x4c, 0x46] },
|
||||
// Mach-O binary (FE ED FA CE / FE ED FA CF / CE FA ED FE / CF FA ED FE)
|
||||
{ name: "Mach-O Binary (32-bit)", bytes: [0xfe, 0xed, 0xfa, 0xce] },
|
||||
{ name: "Mach-O Binary (64-bit)", bytes: [0xfe, 0xed, 0xfa, 0xcf] },
|
||||
{ name: "Mach-O Binary (rev 32)", bytes: [0xce, 0xfa, 0xed, 0xfe] },
|
||||
{ name: "Mach-O Binary (rev 64)", bytes: [0xcf, 0xfa, 0xed, 0xfe] },
|
||||
// Java class file (CA FE BA BE)
|
||||
{ name: "Java Class File", bytes: [0xca, 0xfe, 0xba, 0xbe] },
|
||||
// Shell script shebang ('#!' - 23 21)
|
||||
{ name: "Shell Script", bytes: [0x23, 0x21] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Matches bytes at specific offset
|
||||
*/
|
||||
function matchesBytes(buffer: Buffer, expected: number[], offset = 0): boolean {
|
||||
if (buffer.length < offset + expected.length) return false;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (buffer[offset + i] !== expected[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a base64 string or data URL:
|
||||
* 1. Checks max size limits
|
||||
* 2. Decodes binary buffer
|
||||
* 3. Scans for known executable/script signatures
|
||||
* 4. Verifies genuine image magic bytes (JPEG, PNG, WebP)
|
||||
* 5. Rejects scripts / HTML / SVG content
|
||||
*/
|
||||
export function validateAndSanitizeImage(
|
||||
input: string,
|
||||
maxSizeBytes: number = 10 * 1024 * 1024
|
||||
): ImageValidationResult {
|
||||
if (!input || typeof input !== "string") {
|
||||
return { valid: false, error: "Missing or invalid image payload" };
|
||||
}
|
||||
|
||||
// Check approximate string size (base64 string is ~4/3 larger than binary)
|
||||
// 10MB binary ≈ 13.5MB base64 string
|
||||
const maxStringLen = Math.ceil(maxSizeBytes * 1.4) + 1024;
|
||||
if (input.length > maxStringLen) {
|
||||
return { valid: false, error: `Image exceeds maximum allowed size (${Math.round(maxSizeBytes / (1024 * 1024))}MB)` };
|
||||
}
|
||||
|
||||
// Strip possible Data URL prefix
|
||||
const dataUrlMatch = input.match(/^data:([a-zA-Z0-9/+-]+);base64,(.+)$/);
|
||||
const rawBase64 = dataUrlMatch ? dataUrlMatch[2] : input.trim();
|
||||
|
||||
// Basic Base64 character validation (safe regex pattern)
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(rawBase64)) {
|
||||
return { valid: false, error: "Invalid base64 encoding" };
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(rawBase64, "base64");
|
||||
} catch {
|
||||
return { valid: false, error: "Failed to decode base64 payload" };
|
||||
}
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return { valid: false, error: "Empty image payload" };
|
||||
}
|
||||
|
||||
if (buffer.length > maxSizeBytes) {
|
||||
return { valid: false, error: `Image binary size exceeds ${Math.round(maxSizeBytes / (1024 * 1024))}MB limit` };
|
||||
}
|
||||
|
||||
// 1. Explicitly check for known executable or script signatures
|
||||
for (const sig of DANGEROUS_SIGNATURES) {
|
||||
if (matchesBytes(buffer, sig.bytes)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Executable or script payload detected (${sig.name}). Upload rejected.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for script/text content (e.g. <?php, <script, <svg, <html>)
|
||||
const headerPreview = buffer.slice(0, Math.min(buffer.length, 512)).toString("utf8").toLowerCase();
|
||||
const dangerousPatterns = [
|
||||
"<?php",
|
||||
"<% ",
|
||||
"<script",
|
||||
"<html",
|
||||
"<body",
|
||||
"<svg",
|
||||
"#!/",
|
||||
"eval(",
|
||||
"system(",
|
||||
"exec(",
|
||||
];
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (headerPreview.includes(pattern)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Script or HTML content detected in image payload. Upload rejected.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify legitimate image magic bytes
|
||||
let detectedType: "image/jpeg" | "image/png" | "image/webp" | null = null;
|
||||
let extension: "jpg" | "png" | "webp" = "jpg";
|
||||
|
||||
if (matchesBytes(buffer, MAGIC_SIGNATURES.jpeg)) {
|
||||
detectedType = "image/jpeg";
|
||||
extension = "jpg";
|
||||
} else if (matchesBytes(buffer, MAGIC_SIGNATURES.png)) {
|
||||
detectedType = "image/png";
|
||||
extension = "png";
|
||||
} else if (
|
||||
matchesBytes(buffer, MAGIC_SIGNATURES.riff, 0) &&
|
||||
matchesBytes(buffer, MAGIC_SIGNATURES.webp, 8)
|
||||
) {
|
||||
detectedType = "image/webp";
|
||||
extension = "webp";
|
||||
}
|
||||
|
||||
if (!detectedType) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Unsupported or invalid image format. Only authentic JPEG, PNG, or WebP images are permitted.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
mimeType: detectedType,
|
||||
extension,
|
||||
sanitizedBase64: `data:${detectedType};base64,${buffer.toString("base64")}`,
|
||||
sizeBytes: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes the client IP address from request headers
|
||||
*/
|
||||
export function getClientIp(req: NextRequest): string {
|
||||
const forwardedFor = req.headers.get("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
const firstIp = forwardedFor.split(",")[0].trim();
|
||||
if (firstIp) return firstIp;
|
||||
}
|
||||
|
||||
const realIp = req.headers.get("x-real-ip");
|
||||
if (realIp && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison using SHA-256 digests to prevent timing attacks
|
||||
*/
|
||||
export function constantTimeCompare(a: string, b: string): boolean {
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
|
||||
const hashA = crypto.createHash("sha256").update(a).digest();
|
||||
const hashB = crypto.createHash("sha256").update(b).digest();
|
||||
|
||||
return crypto.timingSafeEqual(hashA, hashB);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory brute-force protection for sensitive endpoints (e.g. portal login)
|
||||
*/
|
||||
interface AuthAttemptRecord {
|
||||
failedAttempts: number;
|
||||
lockedUntil: number;
|
||||
firstAttemptAt: number;
|
||||
}
|
||||
|
||||
const authAttemptsStore = new Map<string, AuthAttemptRecord>();
|
||||
|
||||
export function checkAuthRateLimit(
|
||||
ip: string,
|
||||
maxFailures: number = 5,
|
||||
lockoutMinutes: number = 15
|
||||
): { allowed: boolean; remainingAttempts: number; lockoutSeconds: number } {
|
||||
const now = Date.now();
|
||||
const record = authAttemptsStore.get(ip);
|
||||
|
||||
if (!record) {
|
||||
return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 };
|
||||
}
|
||||
|
||||
// If currently locked out
|
||||
if (record.lockedUntil > now) {
|
||||
const lockoutSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
||||
return { allowed: false, remainingAttempts: 0, lockoutSeconds };
|
||||
}
|
||||
|
||||
// If lockout or window has expired (15 minutes), reset
|
||||
const windowMs = lockoutMinutes * 60 * 1000;
|
||||
if (now - record.firstAttemptAt > windowMs) {
|
||||
authAttemptsStore.delete(ip);
|
||||
return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 };
|
||||
}
|
||||
|
||||
const remainingAttempts = Math.max(0, maxFailures - record.failedAttempts);
|
||||
return {
|
||||
allowed: record.failedAttempts < maxFailures,
|
||||
remainingAttempts,
|
||||
lockoutSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordAuthFailure(
|
||||
ip: string,
|
||||
maxFailures: number = 5,
|
||||
lockoutMinutes: number = 15
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const record = authAttemptsStore.get(ip);
|
||||
|
||||
if (!record) {
|
||||
authAttemptsStore.set(ip, {
|
||||
failedAttempts: 1,
|
||||
lockedUntil: 0,
|
||||
firstAttemptAt: now,
|
||||
});
|
||||
} else {
|
||||
record.failedAttempts += 1;
|
||||
if (record.failedAttempts >= maxFailures) {
|
||||
record.lockedUntil = now + lockoutMinutes * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recordAuthSuccess(ip: string): void {
|
||||
authAttemptsStore.delete(ip);
|
||||
}
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { validateAndSanitizeImage } from "@/lib/security";
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
timestamp: string; // ISO 8601 string
|
||||
dateStr: string; // YYYY-MM-DD for fast grouping
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
|
||||
model: string;
|
||||
durationMs: number;
|
||||
status: "success" | "refused" | "error";
|
||||
imageUrl?: string;
|
||||
imageFilename?: string;
|
||||
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
|
||||
errorMessage?: string;
|
||||
refusalReason?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
period: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
totalAttempts: number;
|
||||
successCount: number;
|
||||
refusedCount: number;
|
||||
errorCount: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
dailyTrends: {
|
||||
date: string;
|
||||
total: number;
|
||||
success: number;
|
||||
refused: number;
|
||||
error: number;
|
||||
}[];
|
||||
styleBreakdown: {
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
providerBreakdown: {
|
||||
provider: string;
|
||||
model: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
images: GenerationRecord[];
|
||||
errorLogs: GenerationRecord[];
|
||||
}
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const IMAGES_DIR = path.join(DATA_DIR, "images");
|
||||
const RECORDS_FILE = path.join(DATA_DIR, "records.json");
|
||||
const BACKUP_FILE = path.join(DATA_DIR, "records.json.bak");
|
||||
|
||||
function ensureDirectories() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(IMAGES_DIR)) {
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECORDS_FILE)) {
|
||||
fs.writeFileSync(RECORDS_FILE, JSON.stringify([], null, 2), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutex / Write Queue to prevent concurrent write collisions
|
||||
*/
|
||||
let writeQueuePromise: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueWrite(task: () => Promise<void> | void): Promise<void> {
|
||||
const next = writeQueuePromise.then(() => task()).catch((err) => {
|
||||
console.error("[Storage Mutex] Write task error:", err);
|
||||
});
|
||||
writeQueuePromise = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all records from disk safely with automatic backup recovery
|
||||
*/
|
||||
export function getAllRecords(): GenerationRecord[] {
|
||||
ensureDirectories();
|
||||
try {
|
||||
const raw = fs.readFileSync(RECORDS_FILE, "utf8");
|
||||
if (!raw.trim()) {
|
||||
return recoverFromBackup();
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.warn("[Storage] records.json is not an array, attempting backup recovery...");
|
||||
return recoverFromBackup();
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error("[Storage] JSON parse error in records.json, attempting backup recovery:", err);
|
||||
return recoverFromBackup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover from backup file if primary file was corrupted
|
||||
*/
|
||||
function recoverFromBackup(): GenerationRecord[] {
|
||||
try {
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
const backupRaw = fs.readFileSync(BACKUP_FILE, "utf8");
|
||||
const parsed = JSON.parse(backupRaw);
|
||||
if (Array.isArray(parsed)) {
|
||||
console.warn("[Storage] Successfully recovered records from records.json.bak!");
|
||||
// Re-write valid records back to records.json
|
||||
fs.writeFileSync(RECORDS_FILE, backupRaw, "utf8");
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (backupErr) {
|
||||
console.error("[Storage] Backup recovery failed:", backupErr);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist records to disk atomically with fsync and backup rotation
|
||||
*/
|
||||
async function saveAllRecordsAtomic(records: GenerationRecord[]): Promise<void> {
|
||||
await queueWrite(() => {
|
||||
ensureDirectories();
|
||||
|
||||
const serialized = JSON.stringify(records, null, 2);
|
||||
// Safety check: Never write invalid or empty string if we had records
|
||||
if (!serialized || serialized.length < 2) {
|
||||
console.error("[Storage] Refusing to write invalid empty JSON payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Maintain backup copy of current good records file before replacing
|
||||
if (fs.existsSync(RECORDS_FILE)) {
|
||||
try {
|
||||
const currentContent = fs.readFileSync(RECORDS_FILE, "utf8");
|
||||
if (currentContent.trim().length > 2) {
|
||||
fs.writeFileSync(BACKUP_FILE, currentContent, "utf8");
|
||||
}
|
||||
} catch (backupErr) {
|
||||
console.warn("[Storage] Failed to update backup copy:", backupErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write to unique temporary file
|
||||
const tempFile = path.join(
|
||||
DATA_DIR,
|
||||
`records.${Date.now()}_${Math.random().toString(36).substring(2, 8)}.tmp`
|
||||
);
|
||||
|
||||
const fd = fs.openSync(tempFile, "w");
|
||||
fs.writeSync(fd, serialized, 0, "utf8");
|
||||
// Ensure data is physically flushed to disk platter/SSD before rename
|
||||
fs.fsyncSync(fd);
|
||||
fs.closeSync(fd);
|
||||
|
||||
// 3. Atomic rename replaces the destination file instantaneously
|
||||
fs.renameSync(tempFile, RECORDS_FILE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an image file from base64 or URL buffer safely with binary validation
|
||||
*/
|
||||
export async function persistImageFile(
|
||||
id: string,
|
||||
imageInput: string
|
||||
): Promise<{ imageUrl: string; filename: string }> {
|
||||
ensureDirectories();
|
||||
|
||||
// Sanitize id to alphanumeric characters, hyphens, and underscores only
|
||||
const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!safeId) {
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
// If it's a data URL or raw base64
|
||||
if (imageInput.startsWith("data:image/") || imageInput.startsWith("data:application/")) {
|
||||
const validation = validateAndSanitizeImage(imageInput, 15 * 1024 * 1024);
|
||||
if (validation.valid && validation.extension) {
|
||||
const ext = validation.extension; // Strictly "jpg" | "png" | "webp"
|
||||
const filename = `${safeId}.${ext}`;
|
||||
const filePath = path.join(IMAGES_DIR, filename);
|
||||
|
||||
const base64Data = validation.sanitizedBase64!.split(",")[1];
|
||||
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
||||
|
||||
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
return { imageUrl: `/api/images/${filename}`, filename };
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a remote URL
|
||||
if (imageInput.startsWith("http://") || imageInput.startsWith("https://")) {
|
||||
try {
|
||||
const parsedUrl = new URL(imageInput);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
|
||||
// Block SSRF loopback / private addresses
|
||||
const isPrivateOrLoopback =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
||||
hostname.startsWith("169.254.");
|
||||
|
||||
if (isPrivateOrLoopback) {
|
||||
console.warn(`[Storage SSRF Blocked] Attempted fetch to private/local address: ${hostname}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
const res = await fetch(imageInput);
|
||||
if (res.ok) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
// Validate downloaded buffer via data URL representation
|
||||
const validation = validateAndSanitizeImage(
|
||||
`data:image/png;base64,${buffer.toString("base64")}`,
|
||||
15 * 1024 * 1024
|
||||
);
|
||||
|
||||
if (validation.valid && validation.extension) {
|
||||
const filename = `${safeId}.${validation.extension}`;
|
||||
const filePath = path.join(IMAGES_DIR, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
return { imageUrl: `/api/images/${filename}`, filename };
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Storage] Could not fetch remote image to cache locally, storing direct URL:", e);
|
||||
}
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
// Local sample asset
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a generation event (both successes, safety refusals, and server errors)
|
||||
*/
|
||||
export async function logGenerationEvent(data: {
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
|
||||
model: string;
|
||||
durationMs: number;
|
||||
status: "success" | "refused" | "error";
|
||||
rawImageUrl?: string;
|
||||
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
|
||||
errorMessage?: string;
|
||||
refusalReason?: string;
|
||||
clientId?: string;
|
||||
}): Promise<GenerationRecord> {
|
||||
const now = new Date();
|
||||
const id = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
let imageUrl = data.rawImageUrl;
|
||||
let imageFilename = "";
|
||||
|
||||
if (data.status === "success" && data.rawImageUrl) {
|
||||
const persisted = await persistImageFile(id, data.rawImageUrl);
|
||||
imageUrl = persisted.imageUrl;
|
||||
imageFilename = persisted.filename;
|
||||
}
|
||||
|
||||
const record: GenerationRecord = {
|
||||
id,
|
||||
timestamp: now.toISOString(),
|
||||
dateStr,
|
||||
styleId: data.styleId,
|
||||
styleName: data.styleName,
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
durationMs: data.durationMs,
|
||||
status: data.status,
|
||||
imageUrl,
|
||||
imageFilename,
|
||||
errorType: data.errorType,
|
||||
errorMessage: data.errorMessage,
|
||||
refusalReason: data.refusalReason,
|
||||
clientId: data.clientId,
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = getAllRecords();
|
||||
existing.unshift(record); // Prepend so newest is first
|
||||
await saveAllRecordsAtomic(existing);
|
||||
} catch (err) {
|
||||
console.error("[Storage] Failed to append record:", err);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query statistics with date filtering
|
||||
*/
|
||||
export function getGenerationStats(query: {
|
||||
period?: "day" | "week" | "month" | "all" | "custom";
|
||||
fromDate?: string; // YYYY-MM-DD
|
||||
toDate?: string; // YYYY-MM-DD
|
||||
}): StatsSummary {
|
||||
const records = getAllRecords();
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
|
||||
const period = query.period || "week";
|
||||
let filtered = records;
|
||||
|
||||
let computedFrom = query.fromDate;
|
||||
let computedTo = query.toDate;
|
||||
|
||||
if (period === "day") {
|
||||
computedFrom = todayStr;
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr === todayStr);
|
||||
} else if (period === "week") {
|
||||
const sevenDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
|
||||
computedFrom = sevenDaysAgo.toISOString().slice(0, 10);
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
|
||||
} else if (period === "month") {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
computedFrom = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
|
||||
} else if (period === "custom") {
|
||||
if (query.fromDate) {
|
||||
filtered = filtered.filter((r) => r.dateStr >= query.fromDate!);
|
||||
}
|
||||
if (query.toDate) {
|
||||
filtered = filtered.filter((r) => r.dateStr <= query.toDate!);
|
||||
}
|
||||
}
|
||||
|
||||
const totalAttempts = filtered.length;
|
||||
const successCount = filtered.filter((r) => r.status === "success").length;
|
||||
const refusedCount = filtered.filter((r) => r.status === "refused").length;
|
||||
const errorCount = filtered.filter((r) => r.status === "error").length;
|
||||
|
||||
const successRate = totalAttempts > 0 ? Math.round((successCount / totalAttempts) * 1000) / 10 : 0;
|
||||
|
||||
const successfulRecords = filtered.filter((r) => r.status === "success");
|
||||
const totalDuration = successfulRecords.reduce((sum, r) => sum + (r.durationMs || 0), 0);
|
||||
const avgDurationMs = successfulRecords.length > 0 ? Math.round(totalDuration / successfulRecords.length) : 0;
|
||||
|
||||
// Daily Trends aggregation
|
||||
const dailyMap = new Map<string, { total: number; success: number; refused: number; error: number }>();
|
||||
|
||||
// If week or day or month, pre-populate all dates in range so charts are continuous
|
||||
if (computedFrom && computedTo) {
|
||||
const start = new Date(computedFrom);
|
||||
const end = new Date(computedTo);
|
||||
const cur = new Date(start);
|
||||
while (cur <= end) {
|
||||
const d = cur.toISOString().slice(0, 10);
|
||||
dailyMap.set(d, { total: 0, success: 0, refused: 0, error: 0 });
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of filtered) {
|
||||
const entry = dailyMap.get(r.dateStr) || { total: 0, success: 0, refused: 0, error: 0 };
|
||||
entry.total += 1;
|
||||
if (r.status === "success") entry.success += 1;
|
||||
else if (r.status === "refused") entry.refused += 1;
|
||||
else if (r.status === "error") entry.error += 1;
|
||||
dailyMap.set(r.dateStr, entry);
|
||||
}
|
||||
|
||||
const dailyTrends = Array.from(dailyMap.entries())
|
||||
.map(([date, counts]) => ({ date, ...counts }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Style Breakdown
|
||||
const styleCountMap = new Map<string, { name: string; count: number }>();
|
||||
for (const r of filtered) {
|
||||
if (r.styleId) {
|
||||
const existing = styleCountMap.get(r.styleId) || { name: r.styleName || r.styleId, count: 0 };
|
||||
existing.count += 1;
|
||||
styleCountMap.set(r.styleId, existing);
|
||||
}
|
||||
}
|
||||
const styleBreakdown = Array.from(styleCountMap.entries())
|
||||
.map(([styleId, { name, count }]) => ({
|
||||
styleId,
|
||||
styleName: name,
|
||||
count,
|
||||
percentage: totalAttempts > 0 ? Math.round((count / totalAttempts) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Provider Breakdown
|
||||
const providerCountMap = new Map<string, { provider: string; model: string; count: number }>();
|
||||
for (const r of filtered) {
|
||||
const key = `${r.provider}::${r.model || "default"}`;
|
||||
const existing = providerCountMap.get(key) || { provider: r.provider, model: r.model || "default", count: 0 };
|
||||
existing.count += 1;
|
||||
providerCountMap.set(key, existing);
|
||||
}
|
||||
const providerBreakdown = Array.from(providerCountMap.values())
|
||||
.map((item) => ({
|
||||
...item,
|
||||
percentage: totalAttempts > 0 ? Math.round((item.count / totalAttempts) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Filtered Images Gallery (success only)
|
||||
const images = filtered.filter((r) => r.status === "success" && r.imageUrl);
|
||||
|
||||
// Filtered Error & Refusal Logs
|
||||
const errorLogs = filtered.filter((r) => r.status !== "success");
|
||||
|
||||
return {
|
||||
period,
|
||||
fromDate: computedFrom,
|
||||
toDate: computedTo,
|
||||
totalAttempts,
|
||||
successCount,
|
||||
refusedCount,
|
||||
errorCount,
|
||||
successRate,
|
||||
avgDurationMs,
|
||||
dailyTrends,
|
||||
styleBreakdown,
|
||||
providerBreakdown,
|
||||
images,
|
||||
errorLogs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import io
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def watermark_portrait(image_path_or_buffer):
|
||||
"""
|
||||
Overlays:
|
||||
1. Shanghai Karate Club transparent logo in top-left corner
|
||||
2. WeChat-scannable school QR code with clean high-contrast backing in bottom-right corner
|
||||
3. Bottom branding text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
"""
|
||||
if isinstance(image_path_or_buffer, str):
|
||||
base = Image.open(image_path_or_buffer).convert("RGBA")
|
||||
else:
|
||||
base = Image.open(io.BytesIO(image_path_or_buffer)).convert("RGBA")
|
||||
|
||||
bw, bh = base.size
|
||||
|
||||
# 1. School Logo (Top-Left)
|
||||
logo_path = os.path.join(process_cwd(), "public", "assets", "brand", "shanghai_karate_logo.png")
|
||||
if os.path.exists(logo_path):
|
||||
logo = Image.open(logo_path).convert("RGBA")
|
||||
target_logo_w = int(bw * 0.28)
|
||||
target_logo_h = int(logo.height * (target_logo_w / logo.width))
|
||||
logo_resized = logo.resize((target_logo_w, target_logo_h), Image.Resampling.LANCZOS)
|
||||
margin = int(bw * 0.035)
|
||||
base.paste(logo_resized, (margin, margin), logo_resized)
|
||||
else:
|
||||
margin = int(bw * 0.035)
|
||||
|
||||
# 2. QR Code (Bottom-Right)
|
||||
qr_path = os.path.join(process_cwd(), "public", "assets", "brand", "dojo_qr.png")
|
||||
if os.path.exists(qr_path):
|
||||
qr = Image.open(qr_path).convert("RGBA")
|
||||
target_qr_w = int(bw * 0.17)
|
||||
qr_resized = qr.resize((target_qr_w, target_qr_w), Image.Resampling.LANCZOS)
|
||||
qr_pad = 5
|
||||
# Clean white backing ensures WeChat scans perfectly regardless of photo background
|
||||
qr_bg = Image.new("RGBA", (target_qr_w + qr_pad * 2, target_qr_w + qr_pad * 2), (255, 255, 255, 250))
|
||||
qr_pos_x = bw - (target_qr_w + qr_pad * 2) - margin
|
||||
qr_pos_y = bh - (target_qr_w + qr_pad * 2) - int(margin * 1.5)
|
||||
|
||||
base.paste(qr_bg, (qr_pos_x, qr_pos_y), qr_bg)
|
||||
base.paste(qr_resized, (qr_pos_x + qr_pad, qr_pos_y + qr_pad), qr_resized)
|
||||
|
||||
# 3. Bottom Text
|
||||
draw = ImageDraw.Draw(base)
|
||||
font_path = os.path.join(process_cwd(), "public", "fonts", "ZCOOLKuaiLe.ttf")
|
||||
font_size = max(14, int(bw * 0.026))
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = "上海健志空手道馆 · Shanghai Karate Club · Est. 2008"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
th = bbox[3] - bbox[1]
|
||||
tx = margin
|
||||
ty = bh - int(margin * 1.2) - th
|
||||
|
||||
# Subtle drop shadow + crisp warm white text
|
||||
draw.text((tx + 1, ty + 1), text, font=font, fill=(0, 0, 0, 210))
|
||||
draw.text((tx, ty), text, font=font, fill=(255, 240, 200, 255))
|
||||
|
||||
out_bytes = io.BytesIO()
|
||||
base.convert("RGB").save(out_bytes, format="JPEG", quality=94)
|
||||
return out_bytes.getvalue()
|
||||
|
||||
def process_cwd():
|
||||
return r"C:\Users\user\Desktop\katare_helloween"
|
||||
@@ -0,0 +1,35 @@
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Executes the Python watermark processor on an image file on disk.
|
||||
* Applies:
|
||||
* 1. School logo without background in top-left corner
|
||||
* 2. Scannable official WeChat QR code in bottom-right corner
|
||||
* 3. Bottom text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
*/
|
||||
export async function applyWatermarkToFile(filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const scriptPath = path.join(process.cwd(), "watermark_processor.py");
|
||||
const py = spawn("python", [scriptPath, filePath, filePath]);
|
||||
|
||||
py.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.warn(`[Watermark] Python watermark process exited with code ${code}`);
|
||||
resolve(); // Graceful fallback
|
||||
}
|
||||
});
|
||||
|
||||
py.on("error", (err) => {
|
||||
console.warn("[Watermark] Failed to spawn watermark process:", err);
|
||||
resolve(); // Graceful fallback
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user