fix(custom-widgets): isolate invalid definitions

This commit is contained in:
Thomas Camlong committed 2026-07-20 18:32:48 +02:00
1 parent a49b0869f7
commit 80d364c050
8 files changed
+223 -49

No files matched your search

@@ -18,7 +18,11 @@ import { metadataProcedures } from "./metadata-procedures";
import { previewActionProcedures } from "./preview-action-procedures";
import { previewBaseProcedures } from "./preview-base-procedures";
import { previewQueryProcedures } from "./preview-query-procedures";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
import {
parseStoredCustomWidgetDefinition,
safeParseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "./stored-definition";
import { templateProcedures } from "./template-procedures";
import { transferProcedures } from "./transfer-procedures";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
@@ -136,6 +140,19 @@ export const customWidgetRouter = createTRPCRouter({
toggleEnabled: manageProcedure
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (input.enabled) {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
if (!safeParseStoredCustomWidgetDefinition(definition).success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid custom widget definition cannot be enabled",
});
}
}
await ctx.db
.update(customWidgetDefinitions)
.set({ enabled: input.enabled, updatedAt: new Date() })
@@ -2,19 +2,21 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import { getCustomWidgetSecretRequirements } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getCustomWidgetCacheVersion } from "./cache-version";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget:management" });
export const managementQueryProcedures = {
list: manageProcedure
@@ -25,7 +27,29 @@ export const managementQueryProcedures = {
with: { secrets: true },
});
return definitions.map((definition) => {
const widget = parseStoredCustomWidgetDefinition(definition);
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Skipped parsing invalid custom widget definition", {
id: definition.id,
issueCount: result.issues.length,
});
return {
id: definition.id,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
sources: [],
requestCount: 0,
missingSecrets: [],
defaultOptions: {},
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: false as const,
validationIssues: result.issues,
};
}
const widget = result.widget;
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
@@ -46,6 +70,8 @@ export const managementQueryProcedures = {
defaultOptions: widget.defaultOptions,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: true as const,
validationIssues: [],
};
});
}),
@@ -84,8 +110,14 @@ export const managementQueryProcedures = {
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.map((definition) => {
const widget = parseStoredCustomWidgetDefinition(definition);
return definitions.flatMap((definition) => {
const result = safeParseStoredCustomWidgetDefinition(definition);
if (!result.success) {
logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id });
return [];
}
const widget = result.widget;
return {
id: definition.id,
name: definition.name,
@@ -4,6 +4,20 @@ import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetDefinition } from "@homarr/db/schema";
type StoredCustomWidgetDefinition = Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template"
>;
export interface StoredCustomWidgetIssue {
path?: string;
message: string;
}
export type StoredCustomWidgetParseResult =
| { success: true; widget: HomarrCustomWidgetV2 }
| { success: false; issues: StoredCustomWidgetIssue[] };
export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2) {
return {
name: definition.name,
@@ -17,12 +31,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
};
}
export function parseStoredCustomWidgetDefinition(
definition: Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template"
>,
): HomarrCustomWidgetV2 {
export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidgetDefinition): HomarrCustomWidgetV2 {
return customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: definition.name,
@@ -35,3 +44,26 @@ export function parseStoredCustomWidgetDefinition(
template: definition.template,
});
}
export function safeParseStoredCustomWidgetDefinition(
definition: StoredCustomWidgetDefinition,
): StoredCustomWidgetParseResult {
try {
return { success: true, widget: parseStoredCustomWidgetDefinition(definition) };
} catch (error) {
if (error instanceof Error && "issues" in error && Array.isArray(error.issues)) {
return {
success: false,
issues: error.issues.slice(0, 10).map((issue: { path?: PropertyKey[]; message?: unknown }) => ({
path: issue.path?.map(String).join("."),
message: typeof issue.message === "string" ? issue.message : "Invalid stored widget value",
})),
};
}
return {
success: false,
issues: [{ message: "Stored widget data could not be read" }],
};
}
}
@@ -0,0 +1,49 @@
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import {
safeParseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "../../custom-widget/stored-definition";
describe("stored custom widget definitions", () => {
test("parses a valid stored definition", () => {
const result = safeParseStoredCustomWidgetDefinition(serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER));
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
});
test("returns all useful issues for a stale load request instead of throwing", () => {
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
const stored = serializeCustomWidgetDefinition(currency.widget);
const requests = parseSuperJson(stored.requests) as Array<Record<string, unknown>>;
delete requests[0]?.optionsBinding;
const result = safeParseStoredCustomWidgetDefinition({
...stored,
requests: stringifySuperJson(requests),
});
expect(result).toEqual({
success: false,
issues: expect.arrayContaining([
expect.objectContaining({ path: "requests.0.optionsBinding.from" }),
expect.objectContaining({ path: "requests.0.optionsBinding.to" }),
expect.objectContaining({ path: "requests.0.optionsBinding.amount" }),
]),
});
});
test("contains malformed serialized data", () => {
const stored = serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER);
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
success: false,
issues: [{ message: "Stored widget data could not be read" }],
});
});
});