fix(custom-widgets): isolate invalid definitions
This commit is contained in:
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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user