import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { createId } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createRedisClient } from "@homarr/core/infrastructure/redis"; import type { RedisClient } from "@homarr/core/infrastructure/redis"; import { customJsxNetworkScopes, customJsxRequestSchema, customWidgetAuthTypes, customWidgetSecretKinds, } from "@homarr/validation/custom-widget"; import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "@homarr/validation/custom-widget"; const PREVIEW_SESSION_TTL_MS = 5 * 60_000; const PREVIEW_SESSION_PREFIX = "custom-widget:preview-session:"; const PREVIEW_JOURNAL_PREFIX = "custom-widget:preview-journal:"; const MAX_PREVIEW_JOURNAL_ENTRIES = 50; const storedPreviewSessionSchema = z.object({ id: z.string(), userId: z.string(), expiresAt: z.number(), baseUrl: z.string(), authType: z.enum(customWidgetAuthTypes), headerName: z.string().nullable(), secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })), networkScope: z.enum(customJsxNetworkScopes), requests: z.array(customJsxRequestSchema), definitionId: z.string().optional(), liveActions: z.boolean(), }); export type CustomWidgetPreviewSession = z.infer; const previewJournalEntrySchema = z.object({ id: z.string(), requestId: z.string(), kind: z.enum(["query", "action"]), method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), pathTemplate: z.string(), status: z.number().nullable(), durationMs: z.number().nonnegative(), simulated: z.boolean(), timestamp: z.number(), }); export type CustomWidgetPreviewJournalEntry = z.infer; export interface CreatePreviewSessionInput { userId: string; baseUrl: string; authType: CustomWidgetAuthType; headerName?: string; secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>; networkScope: CustomJsxNetworkScope; requests: CustomJsxRequest[]; definitionId?: string; } let redisClient: RedisClient | null | undefined; const localSessions = new Map(); const localJournals = new Map(); const getRedisClient = () => { if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null; redisClient ??= createRedisClient(); return redisClient; }; const sessionKey = (id: string) => `${PREVIEW_SESSION_PREFIX}${id}`; const journalKey = (id: string) => `${PREVIEW_JOURNAL_PREFIX}${id}`; const saveSession = async (session: CustomWidgetPreviewSession) => { const ttlMs = Math.max(0, session.expiresAt - Date.now()); if (ttlMs === 0) throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired" }); const redis = getRedisClient(); if (!redis) { localSessions.set(session.id, session); return; } await redis.set(sessionKey(session.id), JSON.stringify(session), "PX", ttlMs); }; export const createPreviewSession = async (input: CreatePreviewSessionInput) => { const session: CustomWidgetPreviewSession = { id: createId(), userId: input.userId, expiresAt: Date.now() + PREVIEW_SESSION_TTL_MS, baseUrl: input.baseUrl, authType: input.authType, headerName: input.headerName ?? null, secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: encryptSecret(secret.value) })), networkScope: input.networkScope, requests: input.requests, definitionId: input.definitionId, liveActions: false, }; await saveSession(session); return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const }; }; export const getPreviewSession = async (id: string, userId: string): Promise => { const redis = getRedisClient(); const raw = redis ? await redis.get(sessionKey(id)) : localSessions.get(id); let candidate: unknown = raw; if (typeof raw === "string") { try { candidate = JSON.parse(raw) as unknown; } catch { candidate = null; } } const parsed = storedPreviewSessionSchema.safeParse(candidate); if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= Date.now()) { if (!redis) localSessions.delete(id); throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired or was not found" }); } return parsed.data; }; export const setPreviewSessionLiveActions = async (id: string, userId: string, enabled: boolean) => { const session = await getPreviewSession(id, userId); const updated = { ...session, liveActions: enabled }; await saveSession(updated); return { id, expiresAt: updated.expiresAt, liveActions: enabled }; }; export const appendPreviewJournal = async ( session: CustomWidgetPreviewSession, entry: Omit, ) => { const value = previewJournalEntrySchema.parse({ ...entry, id: createId(), timestamp: Date.now() }); const redis = getRedisClient(); if (!redis) { localJournals.set( session.id, [value, ...(localJournals.get(session.id) ?? [])].slice(0, MAX_PREVIEW_JOURNAL_ENTRIES), ); return; } const ttlMs = Math.max(1, session.expiresAt - Date.now()); await redis .multi() .lpush(journalKey(session.id), JSON.stringify(value)) .ltrim(journalKey(session.id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1) .pexpire(journalKey(session.id), ttlMs) .exec(); }; export const getPreviewJournal = async (id: string, userId: string) => { await getPreviewSession(id, userId); const redis = getRedisClient(); const entries = redis ? await redis.lrange(journalKey(id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1) : (localJournals.get(id) ?? []); return entries.flatMap((entry) => { let candidate: unknown = entry; if (typeof entry === "string") { try { candidate = JSON.parse(entry) as unknown; } catch { return []; } } const parsed = previewJournalEntrySchema.safeParse(candidate); return parsed.success ? [parsed.data] : []; }); }; export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) => session.secrets.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.value as `${string}.${string}`), }));