Files
homarr/packages/api/src/router/widgets/custom-api.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

329 lines
14 KiB
TypeScript

import { getCustomWidgetIntegrationCacheVersion, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import type { RequestLimitInput } from "@homarr/custom-widgets/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
const itemInputSchema = z.object({ itemId: z.string().min(1) });
const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64),
params: runtimeParamsSchema.default({}),
});
interface CustomWidgetItemOptions {
definitionId: string;
configuration: Record<string, unknown>;
configurationVersion: number;
refreshInterval?: number;
}
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
try {
const options = parseSuperJson(raw) as Record<string, unknown>;
if (typeof options.definitionId !== "string" || options.definitionId.length === 0) throw new Error();
return {
definitionId: options.definitionId,
configuration: isRecord(options.configuration) ? options.configuration : {},
configurationVersion:
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
? options.configurationVersion
: 1,
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : undefined,
};
} catch {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
};
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, itemId),
columns: { id: true, boardId: true, kind: true, options: true },
});
if (!item || item.kind !== "customApi") {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const itemOptions = parseItemOptions(item.options);
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
if (!stored) {
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
columns: { id: true },
});
if (legacy) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
});
}
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
}
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration =
itemOptions.configurationVersion === stored.updatedAt.getTime()
? { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration }
: normalizeCustomWidgetOptions(definition.options, itemOptions.configuration);
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Custom widget configuration needs repair: ${issues[0]?.path} ${issues[0]?.message}`,
});
}
return { item, stored, definition, itemOptions, configuration };
}
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
const request = resolved.definition.requests[requestId];
if (request?.kind !== kind) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
return { id: requestId, ...request };
};
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
const source = resolved.definition.sources[sourceId];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
return { id: sourceId, ...source };
};
type IdentifiedRequest = CustomJsxRequest & { id: string };
const withRequestLimit = async <T>(
ctx: RouterContext,
resolved: ResolvedDefinition,
category: RequestLimitInput["category"],
callback: () => Promise<T>,
) => {
const release = await acquireCustomWidgetRequestLimit({
category,
userId: ctx.session?.user.id,
itemId: resolved.item.id,
definitionId: resolved.stored.id,
});
try {
return await callback();
} finally {
await release();
}
};
const getCacheKey = (resolved: ResolvedDefinition, request: IdentifiedRequest, params: Record<string, unknown>) =>
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
const executeRequest = async (
ctx: RouterContext,
resolved: ResolvedDefinition,
request: IdentifiedRequest,
params: Record<string, string | number | boolean>,
) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
let category: RequestLimitInput["category"] = "query";
if (request.kind === "action") category = request.method === "DELETE" ? "delete" : "action";
return withRequestLimit(ctx, resolved, category, async () => {
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
);
const targetUrl = renderRequestTarget(connection.baseUrl, request, values);
const response = await executeCustomWidgetRequest({
...connection,
targetUrl,
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
kind: request.kind,
cacheKey:
request.kind === "query" ? `${getCacheKey(resolved, request, values)}:${connection.cacheVersion}` : undefined,
cacheTtlSeconds: request.cacheSeconds,
});
return { ...response, sourceCacheVersion: connection.cacheVersion };
});
};
export const customApiRouter = createTRPCRouter({
refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
invalidateCustomWidgetResponseCache([
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:`,
]);
}),
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async ([requestId, request]) => {
try {
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
requestId,
{
sourceId: request.source,
sourceCacheVersion: response.sourceCacheVersion,
data: response.data,
status: {
loading: false,
ok: response.ok,
status: response.status,
statusText: response.statusText,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
},
},
] as const;
} catch (error) {
return [
requestId,
{
sourceId: request.source,
sourceCacheVersion:
resolved.definition.sources[request.source]?.type === "integration" ? "unavailable" : "",
data: null,
status: {
loading: false,
ok: false,
status: 0,
error: error instanceof Error ? error.message : "Request failed",
},
},
] as const;
}
}),
);
const integrationVersions = new Map<string, string>();
for (const [, result] of entries) {
if (!result.sourceCacheVersion) continue;
if (result.sourceCacheVersion === "unavailable" && integrationVersions.has(result.sourceId)) continue;
integrationVersions.set(result.sourceId, result.sourceCacheVersion);
}
const loadSourceIds = new Set(loadRequests.map(([, request]) => request.source));
const manualSourceIds = new Set<string>();
for (const request of Object.values(resolved.definition.requests)) {
if (request.kind !== "query" || request.trigger !== "manual" || loadSourceIds.has(request.source)) continue;
if (resolved.definition.sources[request.source]?.type !== "integration") continue;
manualSourceIds.add(request.source);
}
const manualIntegrationVersions = await Promise.all(
[...manualSourceIds].map(async (sourceId) => {
const source = resolved.definition.sources[sourceId];
if (source?.type !== "integration") return [sourceId, "unavailable"] as const;
try {
const version = await withRequestLimit(ctx, resolved, "metadata", () =>
getCustomWidgetIntegrationCacheVersion(ctx, source),
);
return [sourceId, version] as const;
} catch {
return [sourceId, "unavailable"] as const;
}
}),
);
for (const [sourceId, version] of manualIntegrationVersions) integrationVersions.set(sourceId, version);
const integrationCacheKey = [...integrationVersions]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([sourceId, version]) => `${sourceId}:${version}`)
.join(":");
return {
type: "customJsx" as const,
template: resolved.definition.template,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationCacheKey}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
trigger: request.trigger,
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
})),
};
}),
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "query");
if (request.trigger !== "manual") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Load queries cannot be invoked manually" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
}),
executeAction: protectedProcedure
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "action");
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
if (needsConfirmation && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
request.invalidates.flatMap((requestId) => [
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
`custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
]),
);
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
invalidates: request.invalidates ?? [],
};
}),
});