Files
homarr/packages/api/src/router/custom-widget/preview-sessions.ts
T

176 lines
6.2 KiB
TypeScript

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<typeof storedPreviewSessionSchema>;
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<typeof previewJournalEntrySchema>;
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<string, CustomWidgetPreviewSession>();
const localJournals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
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<CustomWidgetPreviewSession> => {
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<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
) => {
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}`),
}));