import { PermissionKey } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; import { findUniqueOrThrow } from "../db/helpers.js"; import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js"; import { validateImageDataUrl } from "../lib/image-validation.js"; import { checkPromptInjection } from "../lib/prompt-guard.js"; import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; import { managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) async function readImageGenerationStatus(db: { systemSettings: { findUnique: (args: { where: { id: string } }) => Promise | null>; }; }) { const settings = await db.systemSettings.findUnique({ where: { id: "singleton" }, }); const imageProvider = settings?.["imageProvider"] === "gemini" ? "gemini" : "dalle"; const configured = imageProvider === "gemini" ? isGeminiConfigured(settings) : isDalleConfigured(settings); return { configured, provider: imageProvider, }; } export const projectCoverProcedures = { generateCover: managerProcedure .input( z.object({ projectId: z.string(), prompt: z.string().max(500).optional(), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); // The user's free-text "Additional direction" is concatenated into the // image-generation prompt. Run the same injection guard we apply to // assistant chat (EGAI 4.6.3.2) so a manager-role user can't pivot the // image model into "ignore previous instructions" / role-override // attacks against downstream prompt-aware infra. if (input.prompt) { const guard = checkPromptInjection(input.prompt); if (!guard.safe) { throw new TRPCError({ code: "BAD_REQUEST", message: "Prompt rejected: contains an injection pattern.", }); } } const project = await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, include: { client: { select: { name: true } } }, }), "Project", ); const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); const runtimeSettings = resolveSystemSettingsRuntime(settings); const imageProvider = runtimeSettings.imageProvider ?? "dalle"; const useGemini = imageProvider === "gemini" && isGeminiConfigured(runtimeSettings); const useDalle = imageProvider === "dalle" && isDalleConfigured(runtimeSettings); if (!useGemini && !useDalle) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings.", }); } const clientName = project.client?.name ? ` for ${project.client.name}` : ""; const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`; const finalPrompt = input.prompt ? `${basePrompt} Additional direction: ${input.prompt}` : basePrompt; let coverImageUrl: string; if (useGemini) { try { coverImageUrl = await generateGeminiImage( runtimeSettings.geminiApiKey!, finalPrompt, runtimeSettings.geminiModel ?? undefined, ); } catch (err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Gemini error: ${parseGeminiError(err)}`, }); } // Provider-generated output is still untrusted — a compromised or // misconfigured upstream could return a polyglot payload. Run the // same magic-byte + trailer + marker check we apply to user uploads // before we persist the data URL to the database. const providerCheck = validateImageDataUrl(coverImageUrl); if (!providerCheck.valid) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Provider image rejected by validator: ${providerCheck.reason}`, }); } } else { const dalleClient = createDalleClient(runtimeSettings); const model = runtimeSettings.aiProvider === "azure" ? runtimeSettings.azureDalleDeployment! : "dall-e-3"; // eslint-disable-next-line @typescript-eslint/no-explicit-any let response: any; try { response = await loggedAiCall("dalle", model, finalPrompt.length, () => dalleClient.images.generate({ model, prompt: finalPrompt, size: "1024x1024", n: 1, response_format: "b64_json", }), ); } catch (err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `DALL-E error: ${parseAiError(err)}`, }); } const b64 = response.data?.[0]?.b64_json; if (!b64) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No image data returned from DALL-E", }); } coverImageUrl = `data:image/png;base64,${b64}`; const providerCheck = validateImageDataUrl(coverImageUrl); if (!providerCheck.valid) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Provider image rejected by validator: ${providerCheck.reason}`, }); } } await ctx.db.project.update({ where: { id: input.projectId }, data: { coverImageUrl }, }); return { coverImageUrl }; }), uploadCover: managerProcedure .input( z.object({ projectId: z.string(), imageDataUrl: z.string(), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); if (!input.imageDataUrl.startsWith("data:image/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid image format. Must be a data URL starting with 'data:image/'.", }); } const magicCheck = validateImageDataUrl(input.imageDataUrl); if (!magicCheck.valid) { throw new TRPCError({ code: "BAD_REQUEST", message: `File validation failed: ${magicCheck.reason}`, }); } if (input.imageDataUrl.length > MAX_COVER_SIZE) { throw new TRPCError({ code: "BAD_REQUEST", message: "Image too large. Maximum compressed size is 4 MB.", }); } await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId } }), "Project", ); await ctx.db.project.update({ where: { id: input.projectId }, data: { coverImageUrl: input.imageDataUrl }, }); return { coverImageUrl: input.imageDataUrl }; }), removeCover: managerProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId } }), "Project", ); await ctx.db.project.update({ where: { id: input.projectId }, data: { coverImageUrl: null }, }); return { ok: true }; }), updateCoverFocus: managerProcedure .input( z.object({ projectId: z.string(), coverFocusY: z.number().int().min(0).max(100), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); await ctx.db.project.update({ where: { id: input.projectId }, data: { coverFocusY: input.coverFocusY }, }); return { ok: true }; }), isImageGenConfigured: protectedProcedure.query(async ({ ctx }) => readImageGenerationStatus(ctx.db), ), /** @deprecated Use isImageGenConfigured instead */ isDalleConfigured: protectedProcedure.query(async ({ ctx }) => { const { configured } = await readImageGenerationStatus(ctx.db); return { configured }; }), };