refactor(custom-widgets): extract domain package
This commit is contained in:
167 files changed
+12282
-10033
No files matched your search
@@ -1,221 +1,40 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetCreateSchema,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetImportSchema,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
customWidgetUpdateSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "./display-data";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
import { metadataProcedures } from "./metadata-procedures";
|
||||
import { previewActionProcedures } from "./preview-action-procedures";
|
||||
import { previewBaseProcedures } from "./preview-base-procedures";
|
||||
import { previewQueryProcedures } from "./preview-query-procedures";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
createPreviewSession,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "./preview-sessions";
|
||||
import { transferProcedures } from "./transfer-procedures";
|
||||
import { templateProcedures } from "./template-procedures";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
|
||||
try {
|
||||
await appendPreviewJournal(...args);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to append custom widget preview journal", {
|
||||
sessionId: args[0].id,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const previewRuntimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
||||
const previewSessionRequestSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: previewRuntimeParamsSchema.default({}),
|
||||
});
|
||||
|
||||
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
|
||||
displayConfig: (value) => stringifySuperJson(value),
|
||||
};
|
||||
|
||||
let importJsonSchemaCache: Record<string, unknown> | null = null;
|
||||
function getImportJsonSchema() {
|
||||
if (!importJsonSchemaCache) {
|
||||
importJsonSchemaCache = {
|
||||
...z.toJSONSchema(customWidgetImportSchema),
|
||||
title: "Homarr Custom Widget",
|
||||
description:
|
||||
"Schema for importing/exporting custom widget definitions in Homarr. " +
|
||||
"All jsonPath fields use JSONPath syntax (e.g. $.data.count, $.items[0].name). " +
|
||||
"The displayConfig must match the chosen displayType. " +
|
||||
"Secrets (API keys, passwords) are not included in exports and must be configured separately after import.",
|
||||
};
|
||||
}
|
||||
return importJsonSchemaCache;
|
||||
}
|
||||
|
||||
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
|
||||
|
||||
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
|
||||
displayConfig.template = template;
|
||||
const result = displayConfigSchema.safeParse(displayConfig);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: result.error.issues
|
||||
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
|
||||
.join("; "),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
schema: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
|
||||
},
|
||||
})
|
||||
.query(() => getImportJsonSchema()),
|
||||
...metadataProcedures,
|
||||
|
||||
validate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate a complete custom-widget JSON draft without saving or making network requests. REQUIRED: widget (the homarr-custom-widget-v3 object). Returns structured issue paths and messages so an agent can correct the draft and validate again.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: true as const,
|
||||
issues: [],
|
||||
widget: result.data,
|
||||
};
|
||||
}),
|
||||
|
||||
all: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
|
||||
return definitions.map((def) => ({
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
iconUrl: def.iconUrl,
|
||||
url: def.url,
|
||||
method: def.method,
|
||||
displayType: def.displayType,
|
||||
authType: def.authType,
|
||||
enabled: def.enabled,
|
||||
}));
|
||||
}),
|
||||
|
||||
byId: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
enabled: definition.enabled,
|
||||
displayConfig,
|
||||
secrets: definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
hasValue: true,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
available: protectedProcedure
|
||||
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
return ctx.db.query.customWidgetDefinitions.findMany({
|
||||
where: input.currentId
|
||||
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
|
||||
: eq(customWidgetDefinitions.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
iconUrl: true,
|
||||
displayType: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
...managementQueryProcedures,
|
||||
|
||||
create: adminProcedure
|
||||
.meta({
|
||||
@@ -357,582 +176,13 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
}),
|
||||
|
||||
readTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
...templateProcedures,
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
...transferProcedures,
|
||||
|
||||
const config = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget readTemplate",
|
||||
);
|
||||
|
||||
if (config.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
const template = (config.template as string | undefined) ?? "";
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
writeTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
template: z.string().optional(),
|
||||
templateLines: z.array(z.string()).optional(),
|
||||
})
|
||||
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
|
||||
message: "Provide either template or templateLines",
|
||||
})
|
||||
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
|
||||
message: "Provide template or templateLines, not both",
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolvedTemplate =
|
||||
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
|
||||
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget writeTemplate",
|
||||
);
|
||||
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedTemplate.trim().length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
|
||||
}
|
||||
if (resolvedTemplate.length > 50_000) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
|
||||
}
|
||||
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Updated custom widget template", { id: input.id });
|
||||
return {
|
||||
id: input.id,
|
||||
template: resolvedTemplate,
|
||||
templateLines: resolvedTemplate.split("\n"),
|
||||
revision: getTemplateRevision(resolvedTemplate),
|
||||
};
|
||||
}),
|
||||
|
||||
patchTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
expectedRevision: z.string().length(16),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
startLine: z.number().int().min(1),
|
||||
deleteCount: z.number().int().min(0),
|
||||
replacementLines: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget patchTemplate",
|
||||
);
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
|
||||
}
|
||||
|
||||
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
|
||||
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Template changed since it was read. Read the template again and retry the patch.",
|
||||
});
|
||||
}
|
||||
|
||||
const lines = currentTemplate.split("\n");
|
||||
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
|
||||
let nextHigherStart = lines.length + 2;
|
||||
for (const edit of edits) {
|
||||
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
|
||||
}
|
||||
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
|
||||
}
|
||||
nextHigherStart = edit.startLine;
|
||||
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
|
||||
}
|
||||
|
||||
const template = lines.join("\n");
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
|
||||
return {
|
||||
id: input.id,
|
||||
template,
|
||||
templateLines: lines,
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
export: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Export one custom widget as a homarr-custom-widget-v3 object suitable for validation, modification, and re-import. REQUIRED: id. Secrets are excluded.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
return {
|
||||
$schema: "homarr-custom-widget-v3" as const,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName,
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody,
|
||||
displayType: definition.displayType,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig during export",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
import: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a custom widget directly from a complete homarr-custom-widget-v3 import object. Admin only. Use customWidget_validate first. This is the preferred creation tool for AI-generated authoring bundles because secrets are omitted and configured separately.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetImportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: stringifySuperJson(input.displayConfig),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
logger.info("Imported custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
preview: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(customWidgetMethods),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().optional(),
|
||||
requestBody: z.string().optional(),
|
||||
displayType: z.enum(customWidgetDisplayTypes),
|
||||
displayConfig: displayConfigSchema,
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
|
||||
definitionId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secrets = [...input.secrets];
|
||||
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
|
||||
const previewSession = v2Config.success
|
||||
? await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
baseUrl: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
secrets,
|
||||
networkScope: v2Config.data.networkScope,
|
||||
requests: v2Config.data.requests,
|
||||
definitionId: input.definitionId,
|
||||
})
|
||||
: null;
|
||||
const storedPreviewSession = previewSession
|
||||
? await getPreviewSession(previewSession.id, ctx.session.user.id)
|
||||
: null;
|
||||
|
||||
if (input.method !== "GET") {
|
||||
return {
|
||||
success: true as const,
|
||||
simulated: true as const,
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${ctx.session.user.id}`,
|
||||
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: input.url,
|
||||
method: input.method,
|
||||
body: undefined,
|
||||
auth: { type: input.authType, secrets, headerName: input.headerName },
|
||||
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
|
||||
kind: "query",
|
||||
});
|
||||
if (storedPreviewSession) {
|
||||
await recordPreviewJournal(storedPreviewSession, {
|
||||
requestId: "base",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: new URL(input.url).pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
displayData,
|
||||
previewSession,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget preview failed", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to fetch data",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
previewSession,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
|
||||
previewQuery: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
|
||||
setPreviewLiveActions: adminProcedure
|
||||
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
|
||||
),
|
||||
|
||||
previewJournal: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ sessionId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
|
||||
|
||||
simulatePreviewAction: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
renderRequestTarget(session.baseUrl, request, input.params);
|
||||
renderRequestBody(request.bodyTemplate, input.params);
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
simulated: true as const,
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
requiredPermission: request.minimumBoardPermission,
|
||||
};
|
||||
}),
|
||||
|
||||
previewAction: adminProcedure
|
||||
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
if (!session.liveActions) {
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
|
||||
}
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: request.method === "DELETE" ? "delete" : "action",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "action",
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
simulated: false as const,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
...previewBaseProcedures,
|
||||
...previewQueryProcedures,
|
||||
...previewActionProcedures,
|
||||
|
||||
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
type Config = Record<string, unknown>;
|
||||
type Extractor = (json: unknown, config: Config) => unknown;
|
||||
|
||||
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false });
|
||||
|
||||
const queryRows = (json: unknown, path: string): unknown[] => {
|
||||
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
|
||||
const first = rows[0];
|
||||
if (Array.isArray(first)) return first as unknown[];
|
||||
return rows;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback: number): number => {
|
||||
const num = Number(value);
|
||||
if (num) return num;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const optionalMax = (max: unknown): number | undefined => {
|
||||
if (max === undefined) return undefined;
|
||||
return toNumber(max, 100);
|
||||
};
|
||||
|
||||
const isGoodValue = (value: unknown, goodValues: string[]): boolean =>
|
||||
goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
|
||||
|
||||
const extractors: Record<string, Extractor> = {
|
||||
singleValue: (json, c) => ({
|
||||
type: "singleValue",
|
||||
label: (c.label as string) ?? "",
|
||||
unit: (c.unit as string) ?? "",
|
||||
value: query(json, (c.jsonPath as string) ?? "$"),
|
||||
valueSize: c.valueSize ?? "lg",
|
||||
labelPosition: c.labelPosition ?? "below",
|
||||
}),
|
||||
keyValue: (json, c) => ({
|
||||
type: "keyValue",
|
||||
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
|
||||
label: m.label,
|
||||
unit: m.unit,
|
||||
value: query(json, m.jsonPath),
|
||||
})),
|
||||
layout: c.layout ?? "list",
|
||||
columns: c.columns ?? 2,
|
||||
}),
|
||||
table: (json, c) => {
|
||||
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
|
||||
const flatRows = queryRows(json, (c.tablePath as string) ?? "$");
|
||||
return {
|
||||
type: "table",
|
||||
columns: columns.map((col) => col.header),
|
||||
rows: flatRows.map((row) => columns.map((col) => query(row, col.jsonPath))),
|
||||
striped: c.striped ?? true,
|
||||
compact: c.compact ?? false,
|
||||
};
|
||||
},
|
||||
statGrid: (json, c) => ({
|
||||
type: "statGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
value: query(json, item.jsonPath),
|
||||
}),
|
||||
),
|
||||
columns: c.columns ?? 2,
|
||||
cardStyle: c.cardStyle ?? "filled",
|
||||
}),
|
||||
progressBars: (json, c) => ({
|
||||
type: "progressBars",
|
||||
bars: (
|
||||
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
|
||||
).map((bar) => {
|
||||
const value = query(json, bar.valuePath);
|
||||
const max = bar.maxPath ? query(json, bar.maxPath) : undefined;
|
||||
return {
|
||||
label: bar.label,
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
value: toNumber(value, 0),
|
||||
max: optionalMax(max),
|
||||
};
|
||||
}),
|
||||
showPercentage: c.showPercentage ?? true,
|
||||
barSize: c.barSize ?? "md",
|
||||
}),
|
||||
statusIndicator: (json, c) => ({
|
||||
type: "statusIndicator",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
|
||||
const value = query(json, item.jsonPath);
|
||||
return { label: item.label, value: String(value ?? "unknown"), isGood: isGoodValue(value, item.goodValues) };
|
||||
}),
|
||||
layout: c.layout ?? "list",
|
||||
dotSize: c.dotSize ?? "md",
|
||||
}),
|
||||
countGrid: (json, c) => ({
|
||||
type: "countGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
value: query(json, item.jsonPath),
|
||||
})),
|
||||
columns: c.columns ?? 2,
|
||||
valueSize: c.valueSize ?? "md",
|
||||
}),
|
||||
raw: (json, c) => ({
|
||||
type: "raw",
|
||||
data: query(json, (c.jsonPath as string) ?? "$"),
|
||||
maxHeight: c.maxHeight ?? 300,
|
||||
}),
|
||||
actionButton: (_json, c) => ({
|
||||
type: "actionButton",
|
||||
buttonLabel: c.buttonLabel ?? "Execute",
|
||||
buttonColor: c.buttonColor ?? "blue",
|
||||
confirmText: c.confirmText ?? "",
|
||||
successMessage: c.successMessage ?? "",
|
||||
}),
|
||||
customJsx: (json, config) => ({
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
data: json,
|
||||
jsxApiVersion: config.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestCapabilities:
|
||||
config.jsxApiVersion === 2 && Array.isArray(config.requests)
|
||||
? (config.requests as Array<Record<string, unknown>>).map((request) => ({
|
||||
id: request.id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.minimumBoardPermission,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
};
|
||||
|
||||
export function extractDisplayData(json: unknown, displayType: string, displayConfig: Config): unknown {
|
||||
const type = (displayConfig.type as string) ?? displayType;
|
||||
const extractor = extractors[type];
|
||||
if (!extractor) {
|
||||
throw new Error(`Unknown display type: ${type}`);
|
||||
}
|
||||
return extractor(json, displayConfig);
|
||||
}
|
||||
|
||||
export function extractDisplayDataWithFallback(json: unknown, displayType: string, displayConfig: Config): unknown {
|
||||
const type = (displayConfig.type as string) ?? displayType;
|
||||
const extractor = extractors[type] ?? extractors.singleValue;
|
||||
return extractor?.(json, displayConfig);
|
||||
}
|
||||
|
||||
export function extractActionButtonDisplay(displayConfig: Config): unknown {
|
||||
return extractors.actionButton?.(null, displayConfig);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { CustomWidgetDomainError } from "@homarr/custom-widgets/server";
|
||||
|
||||
class RequestLimitTransportError extends FlattenError {}
|
||||
|
||||
export function toTrpcError(error: unknown): never {
|
||||
if (error instanceof CustomWidgetDomainError) {
|
||||
const cause = error.retryAfterMs
|
||||
? new RequestLimitTransportError(error.message, { retryAfterMs: error.retryAfterMs })
|
||||
: error;
|
||||
throw new TRPCError({ code: error.code, message: error.message, cause });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq, or } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const managementQueryProcedures = {
|
||||
all: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
return definitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
method: definition.method,
|
||||
displayType: definition.displayType,
|
||||
authType: definition.authType,
|
||||
enabled: definition.enabled,
|
||||
}));
|
||||
}),
|
||||
|
||||
byId: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
return {
|
||||
...definition,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget",
|
||||
),
|
||||
secrets: definition.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
hasValue: true,
|
||||
updatedAt: secret.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
available: protectedProcedure
|
||||
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
return ctx.db.query.customWidgetDefinitions.findMany({
|
||||
where: input.currentId
|
||||
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
|
||||
: eq(customWidgetDefinitions.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
columns: { id: true, name: true, description: true, iconUrl: true, displayType: true },
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
let schemaCache: Record<string, unknown> | undefined;
|
||||
|
||||
function getImportJsonSchema() {
|
||||
schemaCache ??= {
|
||||
...z.toJSONSchema(customWidgetImportSchema),
|
||||
title: "Homarr Custom Widget",
|
||||
description:
|
||||
"Schema for importing/exporting custom widget definitions in Homarr. All jsonPath fields use JSONPath syntax. The displayConfig must match displayType. Secrets are configured separately after import.",
|
||||
};
|
||||
return schemaCache;
|
||||
}
|
||||
|
||||
export const metadataProcedures = {
|
||||
schema: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
|
||||
},
|
||||
})
|
||||
.query(() => getImportJsonSchema()),
|
||||
|
||||
validate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate a complete custom-widget JSON draft without saving or making network requests. Returns structured issue paths so an agent can correct and validate again.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { valid: true as const, issues: [], widget: result.data };
|
||||
}),
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
|
||||
import type { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { displayConfigSchema } from "@homarr/custom-widgets/core";
|
||||
import type { DisplayConfig } from "@homarr/custom-widgets/core";
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): Record<string, unknown> {
|
||||
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): DisplayConfig {
|
||||
try {
|
||||
return superjson.parse(raw) as Record<string, unknown>;
|
||||
return displayConfigSchema.parse(parseSuperJson(raw));
|
||||
} catch {
|
||||
logger.error(context, { id });
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
export const previewActionProcedures = {
|
||||
simulatePreviewAction: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
renderRequestTarget(session.baseUrl, request, input.params);
|
||||
renderRequestBody(request.bodyTemplate, input.params);
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
simulated: true as const,
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
requiredPermission: request.minimumBoardPermission,
|
||||
};
|
||||
}),
|
||||
previewAction: adminProcedure
|
||||
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const body = renderRequestBody(request.bodyTemplate, input.params);
|
||||
if (!session.liveActions) {
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
|
||||
}
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: request.method === "DELETE" ? "delete" : "action",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body,
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "action",
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "action",
|
||||
method: request.method,
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
simulated: false as const,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
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 } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
extractActionButtonDisplay,
|
||||
extractDisplayDataWithFallback,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const previewBaseProcedures = {
|
||||
preview: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(customWidgetMethods),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().optional(),
|
||||
requestBody: z.string().optional(),
|
||||
displayType: z.enum(customWidgetDisplayTypes),
|
||||
displayConfig: displayConfigSchema,
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
|
||||
definitionId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secrets = [...input.secrets];
|
||||
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (existing) {
|
||||
for (const dbSecret of existing.secrets) {
|
||||
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
|
||||
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
|
||||
const previewSession = v2Config.success
|
||||
? await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
baseUrl: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
secrets,
|
||||
networkScope: v2Config.data.networkScope,
|
||||
requests: v2Config.data.requests,
|
||||
definitionId: input.definitionId,
|
||||
})
|
||||
: null;
|
||||
const storedPreviewSession = previewSession
|
||||
? await getPreviewSession(previewSession.id, ctx.session.user.id)
|
||||
: null;
|
||||
|
||||
if (input.method !== "GET") {
|
||||
return {
|
||||
success: true as const,
|
||||
simulated: true as const,
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${ctx.session.user.id}`,
|
||||
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: input.url,
|
||||
method: input.method,
|
||||
body: undefined,
|
||||
auth: { type: input.authType, secrets, headerName: input.headerName },
|
||||
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
|
||||
kind: "query",
|
||||
});
|
||||
if (storedPreviewSession) {
|
||||
await recordPreviewJournal(storedPreviewSession, {
|
||||
requestId: "base",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: new URL(input.url).pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
const responseInfo = { status: response.status, statusText: response.statusText };
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
previewSession,
|
||||
};
|
||||
}
|
||||
|
||||
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
responseInfo,
|
||||
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
|
||||
displayData,
|
||||
previewSession,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget preview failed", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to fetch data",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
previewSession,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { appendPreviewJournal } from "./preview-sessions";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget-preview" });
|
||||
|
||||
export const previewSessionRequestSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
|
||||
});
|
||||
|
||||
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
|
||||
try {
|
||||
await appendPreviewJournal(...args);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to append custom widget preview journal", {
|
||||
sessionId: args[0].id,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestTarget } from "./request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import {
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "./preview-sessions";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
export const previewQueryProcedures = {
|
||||
previewQuery: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
const request = session.requests.find(
|
||||
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
|
||||
);
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: "query",
|
||||
userId: ctx.session.user.id,
|
||||
itemId: `preview:${session.id}`,
|
||||
definitionId: session.definitionId ?? `preview:${session.id}`,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: session.baseUrl,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth:
|
||||
request.auth === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: session.authType,
|
||||
secrets: getPreviewSessionSecrets(session),
|
||||
headerName: session.headerName,
|
||||
},
|
||||
networkScope: session.networkScope,
|
||||
kind: "query",
|
||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
});
|
||||
await recordPreviewJournal(session, {
|
||||
requestId: request.id,
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: request.pathTemplate,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
simulated: false,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}),
|
||||
|
||||
setPreviewLiveActions: adminProcedure
|
||||
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
|
||||
),
|
||||
|
||||
previewJournal: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ sessionId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
|
||||
};
|
||||
@@ -1,175 +1,79 @@
|
||||
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";
|
||||
import { CustomWidgetPreviewSessionService } from "@homarr/custom-widgets/server";
|
||||
import type {
|
||||
CreatePreviewSessionInput,
|
||||
CustomWidgetPreviewJournalEntry,
|
||||
CustomWidgetPreviewSession,
|
||||
PreviewSessionStore,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
|
||||
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;
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
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(),
|
||||
});
|
||||
const SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof storedPreviewSessionSchema>;
|
||||
class RedisPreviewSessionStore implements PreviewSessionStore {
|
||||
public constructor(private readonly redis: RedisClient) {}
|
||||
|
||||
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>;
|
||||
public async saveSession(id: string, value: unknown, ttlMs: number) {
|
||||
await this.redis.set(`${SESSION_PREFIX}${id}`, JSON.stringify(value), "PX", ttlMs);
|
||||
}
|
||||
|
||||
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;
|
||||
public getSession(id: string) {
|
||||
return this.redis.get(`${SESSION_PREFIX}${id}`);
|
||||
}
|
||||
public async deleteSession(id: string) {
|
||||
await this.redis.del(`${SESSION_PREFIX}${id}`);
|
||||
}
|
||||
|
||||
public async appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number) {
|
||||
const key = `${JOURNAL_PREFIX}${id}`;
|
||||
await this.redis
|
||||
.multi()
|
||||
.lpush(key, JSON.stringify(value))
|
||||
.ltrim(key, 0, maxEntries - 1)
|
||||
.pexpire(key, ttlMs)
|
||||
.exec();
|
||||
}
|
||||
|
||||
public getJournal(id: string, maxEntries: number) {
|
||||
return this.redis.lrange(`${JOURNAL_PREFIX}${id}`, 0, maxEntries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const localSessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
const localJournals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
||||
let service: CustomWidgetPreviewSessionService | undefined;
|
||||
function getService() {
|
||||
if (service) return service;
|
||||
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
|
||||
service = new CustomWidgetPreviewSessionService({
|
||||
createId,
|
||||
encrypt: encryptSecret,
|
||||
decrypt: (value) => decryptSecret(value as `${string}.${string}`),
|
||||
store: useLocal ? undefined : new RedisPreviewSessionStore(createRedisClient()),
|
||||
});
|
||||
return service;
|
||||
}
|
||||
|
||||
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;
|
||||
async function call<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
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 type { CreatePreviewSessionInput, CustomWidgetPreviewJournalEntry, CustomWidgetPreviewSession };
|
||||
|
||||
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 (
|
||||
export const createPreviewSession = (input: CreatePreviewSessionInput) => call(() => getService().create(input));
|
||||
export const getPreviewSession = (id: string, userId: string) => call(() => getService().get(id, userId));
|
||||
export const setPreviewSessionLiveActions = (id: string, userId: string, enabled: boolean) =>
|
||||
call(() => getService().setLiveActions(id, userId, enabled));
|
||||
export const appendPreviewJournal = (
|
||||
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}`),
|
||||
}));
|
||||
) => call(() => getService().appendJournal(session, entry));
|
||||
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) => getService().getSecrets(session);
|
||||
@@ -1,429 +1,83 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Agent, fetch, Headers } from "undici";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "@homarr/validation/custom-widget";
|
||||
import {
|
||||
assertJsonBudget as assertDomainJsonBudget,
|
||||
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest as executeDomainRequest,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
resolveAndValidateHost as resolveDomainHost,
|
||||
resolveSameOriginTarget as resolveDomainTarget,
|
||||
validateCustomWidgetUrl as validateDomainUrl,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { applyAuth } from "./auth";
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
export type {
|
||||
CustomWidgetAuthConfig,
|
||||
CustomWidgetHttpRequest,
|
||||
CustomWidgetHttpResponse,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
export {
|
||||
classifyAddress,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
};
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:http" });
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
||||
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
||||
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
|
||||
const RESERVED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
]);
|
||||
|
||||
const alwaysBlockedAddresses = new BlockList();
|
||||
alwaysBlockedAddresses.addSubnet("0.0.0.0", 8, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("100.64.0.0", 10, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.0.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.2.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.18.0.0", 15, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.51.100.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("203.0.113.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("224.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("240.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addAddress("::", "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("64:ff9b::", 96, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("100::", 64, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("2001:db8::", 32, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("ff00::", 8, "ipv6");
|
||||
alwaysBlockedAddresses.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
|
||||
const loopbackAddresses = new BlockList();
|
||||
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
loopbackAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
type AddressFamily = 4 | 6;
|
||||
type ResolvedAddress = { address: string; family: AddressFamily };
|
||||
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
headerName?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod;
|
||||
body?: string;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const responseCache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlightRequests = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
|
||||
const pruneResponseCache = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of responseCache) {
|
||||
if (entry.expiresAt <= now) responseCache.delete(key);
|
||||
}
|
||||
while (responseCache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
const oldestKey = responseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
responseCache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) =>
|
||||
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
|
||||
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
||||
|
||||
export const classifyAddress = (address: string): AddressClass => {
|
||||
const normalized = normalizeHostname(address).toLowerCase();
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
if (family === 0) return "blocked";
|
||||
|
||||
if (normalized.includes("::ffff:")) return "blocked";
|
||||
if (alwaysBlockedAddresses.check(normalized, familyName(family))) return "blocked";
|
||||
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
||||
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
||||
return "public";
|
||||
};
|
||||
|
||||
const isAddressAllowed = (classification: AddressClass, scope: CustomJsxNetworkScope) => {
|
||||
if (classification === "blocked") return false;
|
||||
if (classification === "public") return true;
|
||||
if (classification === "private") return scope === "private" || scope === "loopback";
|
||||
return scope === "loopback";
|
||||
};
|
||||
|
||||
export const resolveAndValidateHost = async (
|
||||
hostname: string,
|
||||
scope: CustomJsxNetworkScope,
|
||||
): Promise<ResolvedAddress[]> => {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const literalFamily = isIP(normalized) as AddressFamily | 0;
|
||||
const addresses = literalFamily
|
||||
? [{ address: normalized, family: literalFamily }]
|
||||
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
||||
}
|
||||
|
||||
for (const resolved of addresses) {
|
||||
const classification = classifyAddress(resolved.address);
|
||||
if (!isAddressAllowed(classification, scope)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Target address is not allowed by the ${scope} network scope`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
};
|
||||
|
||||
export const validateCustomWidgetUrl = (value: string | URL): URL => {
|
||||
let url: URL;
|
||||
export function assertJsonBudget(value: unknown): void {
|
||||
try {
|
||||
url = value instanceof URL ? new URL(value) : new URL(value);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
||||
}
|
||||
if (url.hash) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const resolveSameOriginTarget = (baseUrlValue: string, targetUrlValue?: string | URL): URL => {
|
||||
const baseUrl = validateCustomWidgetUrl(baseUrlValue);
|
||||
const targetUrl = validateCustomWidgetUrl(targetUrlValue ?? baseUrl);
|
||||
if (targetUrl.origin !== baseUrl.origin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Named requests must stay on the widget's origin" });
|
||||
}
|
||||
return targetUrl;
|
||||
};
|
||||
|
||||
export const assertSafeStaticHeaders = (headers: Record<string, string> | undefined): void => {
|
||||
for (const name of Object.keys(headers ?? {})) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (
|
||||
RESERVED_HEADERS.has(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createPinnedAgent = (addresses: ResolvedAddress[]) => {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter((address) => address.family === requestedFamily) : addresses;
|
||||
const selected = candidates[0];
|
||||
if (!selected) {
|
||||
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
|
||||
return new Agent({
|
||||
connect: { lookup: customLookup },
|
||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
||||
headersTimeout: REQUEST_TIMEOUT_MS,
|
||||
bodyTimeout: REQUEST_TIMEOUT_MS,
|
||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
||||
});
|
||||
};
|
||||
|
||||
const readLimitedBody = async (response: Response): Promise<string> => {
|
||||
const contentLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
totalBytes += chunk.value.byteLength;
|
||||
if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
};
|
||||
|
||||
export const assertJsonBudget = (value: unknown): void => {
|
||||
let nodes = 0;
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
nodes += 1;
|
||||
if (nodes > MAX_RESPONSE_JSON_NODES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
||||
}
|
||||
if (depth > MAX_RESPONSE_JSON_DEPTH) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const entry of current) visit(entry, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (current !== null && typeof current === "object") {
|
||||
for (const entry of Object.values(current)) visit(entry, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, 0);
|
||||
};
|
||||
|
||||
const parseResponseBody = async (response: Response): Promise<unknown> => {
|
||||
const text = await readLimitedBody(response);
|
||||
if (text === "") return null;
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("json")) {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text) as unknown;
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
assertDomainJsonBudget(value);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
return text;
|
||||
toTrpcError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const performRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
if (input.kind === "query" && input.method !== "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Queries must use GET" });
|
||||
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
|
||||
try {
|
||||
assertDomainSafeStaticHeaders(headers);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
if (input.kind === "action" && input.method === "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
|
||||
}
|
||||
|
||||
export function validateCustomWidgetUrl(value: string | URL): URL {
|
||||
try {
|
||||
return validateDomainUrl(value);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
|
||||
}
|
||||
|
||||
export function resolveSameOriginTarget(baseUrl: string, target?: string | URL): URL {
|
||||
try {
|
||||
return resolveDomainTarget(baseUrl, target);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
}
|
||||
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
const initialOrigin = baseUrl.origin;
|
||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
const addresses = await resolveAndValidateHost(currentUrl.hostname, input.networkScope);
|
||||
const dispatcher = createPinnedAgent(addresses);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") {
|
||||
assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
}
|
||||
applyAuth(headers, currentUrl, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(currentUrl, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method === "GET" ? undefined : input.body,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget request failed", {
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "External request failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const isRedirect = [301, 302, 303, 307, 308].includes(response.status);
|
||||
if (!isRedirect) {
|
||||
try {
|
||||
const data = await parseResponseBody(response);
|
||||
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
await response.body?.cancel();
|
||||
await dispatcher.close();
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
}
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
|
||||
}
|
||||
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
|
||||
if (redirected.origin !== initialOrigin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
}
|
||||
currentUrl = redirected;
|
||||
export async function resolveAndValidateHost(hostname: string, scope: CustomWidgetHttpRequest["networkScope"]) {
|
||||
try {
|
||||
return await resolveDomainHost(hostname, scope);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const executeCustomWidgetRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
const cacheKey = input.method === "GET" ? input.cacheKey : undefined;
|
||||
if (!cacheKey) return performRequest(input);
|
||||
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.response;
|
||||
if (cached) responseCache.delete(cacheKey);
|
||||
|
||||
const inFlight = inFlightRequests.get(cacheKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
|
||||
pruneResponseCache();
|
||||
responseCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000,
|
||||
response,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => inFlightRequests.delete(cacheKey));
|
||||
inFlightRequests.set(cacheKey, promise);
|
||||
return promise;
|
||||
};
|
||||
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
try {
|
||||
return await executeDomainRequest({
|
||||
...input,
|
||||
logError: (event) => logger.error("Custom widget request failed", event),
|
||||
});
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import { CustomWidgetRequestLimiter } from "@homarr/custom-widgets/server";
|
||||
import type { RequestLimitInput, RequestLimitStore } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:limits" });
|
||||
const WINDOW_MS = 60_000;
|
||||
const CONCURRENCY_TTL_MS = 30_000;
|
||||
|
||||
const categoryLimits = {
|
||||
query: 60,
|
||||
action: 10,
|
||||
delete: 3,
|
||||
} as const;
|
||||
|
||||
type RequestCategory = keyof typeof categoryLimits;
|
||||
|
||||
export interface RequestLimitInput {
|
||||
category: RequestCategory;
|
||||
userId?: string;
|
||||
itemId: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
class RequestLimitError extends FlattenError {
|
||||
constructor(message: string, retryAfterMs: number) {
|
||||
super(message, { retryAfterMs });
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
|
||||
|
||||
const rateScript = `
|
||||
const RATE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
|
||||
const concurrencyScript = `
|
||||
const ACQUIRE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
||||
if count > tonumber(ARGV[1]) then
|
||||
redis.call('DECR', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
|
||||
return count
|
||||
`;
|
||||
|
||||
const releaseConcurrencyScript = `
|
||||
const RELEASE_SCRIPT = `
|
||||
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if count <= 1 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
|
||||
return redis.call('DECR', KEYS[1])
|
||||
`;
|
||||
|
||||
const localRateBuckets = new Map<string, number[]>();
|
||||
const localConcurrency = new Map<string, number>();
|
||||
class RedisRequestLimitStore implements RequestLimitStore {
|
||||
public constructor(private readonly redis: RedisClient) {}
|
||||
|
||||
const throwLimitExceeded = (retryAfterMs: number): never => {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Custom widget request limit exceeded",
|
||||
cause: new RequestLimitError("Custom widget request limit exceeded", Math.max(1, retryAfterMs)),
|
||||
});
|
||||
};
|
||||
|
||||
const enforceLocalRateLimit = (key: string, limit: number) => {
|
||||
const now = Date.now();
|
||||
const recent = (localRateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
|
||||
if (recent.length >= limit) {
|
||||
throwLimitExceeded(WINDOW_MS - (now - (recent[0] ?? now)));
|
||||
}
|
||||
recent.push(now);
|
||||
localRateBuckets.set(key, recent);
|
||||
};
|
||||
|
||||
const acquireLocalConcurrency = (key: string, limit: number) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current >= limit) throwLimitExceeded(1_000);
|
||||
localConcurrency.set(key, current + 1);
|
||||
};
|
||||
|
||||
const releaseLocalConcurrency = (key: string) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current <= 1) localConcurrency.delete(key);
|
||||
else localConcurrency.set(key, current - 1);
|
||||
};
|
||||
|
||||
const enforceRedisRateLimit = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const result = (await redis.eval(rateScript, 1, key, WINDOW_MS)) as [number, number];
|
||||
const [count, ttl] = result.map(Number) as [number, number];
|
||||
if (count > limit) throwLimitExceeded(ttl > 0 ? ttl : WINDOW_MS);
|
||||
};
|
||||
|
||||
const acquireRedisConcurrency = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const count = Number(await redis.eval(concurrencyScript, 1, key, limit, CONCURRENCY_TTL_MS));
|
||||
if (count === 0) throwLimitExceeded(1_000);
|
||||
};
|
||||
|
||||
const releaseRedisConcurrency = async (redis: RedisClient, key: string) => {
|
||||
await redis.eval(releaseConcurrencyScript, 1, key);
|
||||
};
|
||||
|
||||
export const acquireCustomWidgetRequestLimit = async (input: RequestLimitInput): Promise<() => Promise<void>> => {
|
||||
const identity = input.userId ?? "anonymous";
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const userConcurrencyKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionConcurrencyKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
|
||||
const redis = getRedisClient();
|
||||
|
||||
if (!redis) {
|
||||
enforceLocalRateLimit(rateKey, categoryLimits[input.category]);
|
||||
acquireLocalConcurrency(userConcurrencyKey, 4);
|
||||
try {
|
||||
acquireLocalConcurrency(definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
releaseLocalConcurrency(definitionConcurrencyKey);
|
||||
};
|
||||
public async incrementRate(key: string, windowMs: number) {
|
||||
const [count, retryAfterMs] = (await this.redis.eval(RATE_SCRIPT, 1, key, windowMs)) as [number, number];
|
||||
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
|
||||
}
|
||||
|
||||
try {
|
||||
await enforceRedisRateLimit(redis, rateKey, categoryLimits[input.category]);
|
||||
await acquireRedisConcurrency(redis, userConcurrencyKey, 4);
|
||||
try {
|
||||
await acquireRedisConcurrency(redis, definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
await releaseRedisConcurrency(redis, userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget rate limiter is unavailable", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Request limiter is unavailable" });
|
||||
public async acquireConcurrency(key: string, limit: number, ttlMs: number) {
|
||||
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
releaseRedisConcurrency(redis, userConcurrencyKey),
|
||||
releaseRedisConcurrency(redis, definitionConcurrencyKey),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error("Failed to release custom widget request capacity", {
|
||||
public async releaseConcurrency(key: string) {
|
||||
await this.redis.eval(RELEASE_SCRIPT, 1, key);
|
||||
}
|
||||
}
|
||||
|
||||
let limiter: CustomWidgetRequestLimiter | undefined;
|
||||
function getLimiter() {
|
||||
if (limiter) return limiter;
|
||||
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
|
||||
const store = useLocal ? undefined : new RedisRequestLimitStore(createRedisClient());
|
||||
limiter = new CustomWidgetRequestLimiter({
|
||||
store,
|
||||
onStoreError: (error) =>
|
||||
logger.error("Custom widget rate limiter is unavailable", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}),
|
||||
});
|
||||
return limiter;
|
||||
}
|
||||
|
||||
export async function acquireCustomWidgetRequestLimit(input: RequestLimitInput): Promise<() => Promise<void>> {
|
||||
try {
|
||||
const release = await getLimiter().acquire(input);
|
||||
return async () => {
|
||||
try {
|
||||
await release();
|
||||
} catch (error) {
|
||||
logger.error("Failed to release custom widget request capacity", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
renderRequestBody as renderDomainBody,
|
||||
renderRequestTarget as renderDomainTarget,
|
||||
validateRuntimeParams as validateDomainParams,
|
||||
} from "@homarr/custom-widgets/server";
|
||||
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { toTrpcError } from "./domain-error";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/validation/custom-widget";
|
||||
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
|
||||
export { hashRuntimeParams };
|
||||
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
|
||||
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
const declaredNames = Object.keys(request.parameters).toSorted();
|
||||
const suppliedNames = Object.keys(params).toSorted();
|
||||
if (
|
||||
declaredNames.length !== suppliedNames.length ||
|
||||
declaredNames.some((name, index) => name !== suppliedNames[index])
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
|
||||
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
|
||||
try {
|
||||
validateDomainParams(request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
for (const [name, expectedType] of Object.entries(request.parameters)) {
|
||||
if (typeof params[name] !== expectedType) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Request parameter '${name}' must be ${expectedType}` });
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
|
||||
try {
|
||||
return renderDomainBody(request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const renderRequestBody = (
|
||||
template: CustomJsxRequest["bodyTemplate"],
|
||||
params: CustomJsxRuntimeParams,
|
||||
): string | undefined => {
|
||||
if (template === undefined) return undefined;
|
||||
const substitute = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(substitute);
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
const parameter = params[entries[0][1]];
|
||||
if (parameter === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown body parameter '${entries[0][1]}'` });
|
||||
}
|
||||
return parameter;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(substitute(template));
|
||||
};
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
validateRuntimeParams(request, params);
|
||||
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
if (/[{}]/.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Path template contains an invalid placeholder" });
|
||||
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
|
||||
try {
|
||||
return renderDomainTarget(baseUrl, request, params);
|
||||
} catch (error) {
|
||||
toTrpcError(error);
|
||||
}
|
||||
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
|
||||
};
|
||||
|
||||
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
|
||||
const stableParams = Object.fromEntries(
|
||||
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { displayConfigSchema } from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
|
||||
|
||||
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
|
||||
displayConfig.template = template;
|
||||
const result = displayConfigSchema.safeParse(displayConfig);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: result.error.issues
|
||||
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
|
||||
.join("; "),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const templateProcedures = {
|
||||
readTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const config = parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget readTemplate",
|
||||
);
|
||||
|
||||
if (config.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
const template = (config.template as string | undefined) ?? "";
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
writeTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
template: z.string().optional(),
|
||||
templateLines: z.array(z.string()).optional(),
|
||||
})
|
||||
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
|
||||
message: "Provide either template or templateLines",
|
||||
})
|
||||
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
|
||||
message: "Provide template or templateLines, not both",
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolvedTemplate =
|
||||
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
|
||||
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget writeTemplate",
|
||||
);
|
||||
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Widget is not using customJsx display type",
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedTemplate.trim().length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
|
||||
}
|
||||
if (resolvedTemplate.length > 50_000) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
|
||||
}
|
||||
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Updated custom widget template", { id: input.id });
|
||||
return {
|
||||
id: input.id,
|
||||
template: resolvedTemplate,
|
||||
templateLines: resolvedTemplate.split("\n"),
|
||||
revision: getTemplateRevision(resolvedTemplate),
|
||||
};
|
||||
}),
|
||||
|
||||
patchTemplate: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
expectedRevision: z.string().length(16),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
startLine: z.number().int().min(1),
|
||||
deleteCount: z.number().int().min(0),
|
||||
replacementLines: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
|
||||
const displayConfig = parseDisplayConfig(
|
||||
existing.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig in custom widget patchTemplate",
|
||||
);
|
||||
if (displayConfig.type !== "customJsx") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
|
||||
}
|
||||
|
||||
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
|
||||
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Template changed since it was read. Read the template again and retry the patch.",
|
||||
});
|
||||
}
|
||||
|
||||
const lines = currentTemplate.split("\n");
|
||||
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
|
||||
let nextHigherStart = lines.length + 2;
|
||||
for (const edit of edits) {
|
||||
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
|
||||
}
|
||||
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
|
||||
}
|
||||
nextHigherStart = edit.startLine;
|
||||
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
|
||||
}
|
||||
|
||||
const template = lines.join("\n");
|
||||
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
|
||||
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
|
||||
return {
|
||||
id: input.id,
|
||||
template,
|
||||
templateLines: lines,
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseDisplayConfig } from "./parse-display-config";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const transferProcedures = {
|
||||
export: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Export one custom widget as a homarr-custom-widget-v3 object. REQUIRED: id. Secrets are excluded.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return {
|
||||
$schema: "homarr-custom-widget-v3" as const,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName,
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody,
|
||||
displayType: definition.displayType,
|
||||
displayConfig: parseDisplayConfig(
|
||||
definition.displayConfig,
|
||||
input.id,
|
||||
logger,
|
||||
"Corrupt displayConfig during export",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
import: adminProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a custom widget from a validated homarr-custom-widget-v3 import object. Secrets are omitted and configured separately.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetImportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
url: input.url,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName,
|
||||
method: input.method,
|
||||
requestBody: input.requestBody,
|
||||
displayType: input.displayType,
|
||||
displayConfig: stringifySuperJson(input.displayConfig),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
logger.info("Imported custom widget definition", { id, name: input.name });
|
||||
return { id };
|
||||
}),
|
||||
};
|
||||
@@ -1,252 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
import {
|
||||
sonarrResponse,
|
||||
sonarrStatGridConfig,
|
||||
proxmoxResponse,
|
||||
proxmoxProgressBarsConfig,
|
||||
diskUsageResponse,
|
||||
diskTableConfig,
|
||||
piholeResponse,
|
||||
piholeStatusIndicatorConfig,
|
||||
piholeCountGridConfig,
|
||||
radarrResponse,
|
||||
radarrStatGridConfig,
|
||||
jellyfinItemCountsResponse,
|
||||
jellyfinCountGridConfig,
|
||||
} from "./custom-api-fixtures";
|
||||
|
||||
const extractors: Record<string, (json: unknown, config: Record<string, unknown>) => unknown> = {
|
||||
singleValue: (json, c) => ({
|
||||
type: "singleValue",
|
||||
label: (c.label as string) ?? "",
|
||||
unit: (c.unit as string) ?? "",
|
||||
value: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
|
||||
valueSize: c.valueSize ?? "lg",
|
||||
labelPosition: c.labelPosition ?? "below",
|
||||
}),
|
||||
keyValue: (json, c) => ({
|
||||
type: "keyValue",
|
||||
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
|
||||
label: m.label,
|
||||
unit: m.unit,
|
||||
value: JSONPath({ path: m.jsonPath, json: json as object, wrap: false }),
|
||||
})),
|
||||
layout: c.layout ?? "list",
|
||||
columns: c.columns ?? 2,
|
||||
}),
|
||||
table: (json, c) => {
|
||||
const tablePath = (c.tablePath as string) ?? "$";
|
||||
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
|
||||
const rows = JSONPath({ path: tablePath, json: json as object, wrap: true }) as unknown[];
|
||||
const flatRows = Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
|
||||
return {
|
||||
type: "table",
|
||||
columns: columns.map((col) => col.header),
|
||||
rows: flatRows.map((row) =>
|
||||
columns.map((col) => JSONPath({ path: col.jsonPath, json: row as object, wrap: false })),
|
||||
),
|
||||
striped: c.striped ?? true,
|
||||
compact: c.compact ?? false,
|
||||
};
|
||||
},
|
||||
statGrid: (json, c) => ({
|
||||
type: "statGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
|
||||
}),
|
||||
),
|
||||
columns: c.columns ?? 2,
|
||||
cardStyle: c.cardStyle ?? "filled",
|
||||
}),
|
||||
progressBars: (json, c) => ({
|
||||
type: "progressBars",
|
||||
bars: (
|
||||
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
|
||||
).map((bar) => {
|
||||
const value = JSONPath({ path: bar.valuePath, json: json as object, wrap: false });
|
||||
const max = bar.maxPath ? JSONPath({ path: bar.maxPath, json: json as object, wrap: false }) : undefined;
|
||||
return {
|
||||
label: bar.label,
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
value: Number(value) || 0,
|
||||
max: max !== undefined ? Number(max) || 100 : undefined,
|
||||
};
|
||||
}),
|
||||
showPercentage: c.showPercentage ?? true,
|
||||
barSize: c.barSize ?? "md",
|
||||
}),
|
||||
statusIndicator: (json, c) => ({
|
||||
type: "statusIndicator",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
|
||||
const value = JSONPath({ path: item.jsonPath, json: json as object, wrap: false });
|
||||
const isGood = item.goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
|
||||
return { label: item.label, value: String(value ?? "unknown"), isGood };
|
||||
}),
|
||||
layout: c.layout ?? "list",
|
||||
dotSize: c.dotSize ?? "md",
|
||||
}),
|
||||
countGrid: (json, c) => ({
|
||||
type: "countGrid",
|
||||
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
unit: item.unit,
|
||||
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
|
||||
})),
|
||||
columns: c.columns ?? 2,
|
||||
valueSize: c.valueSize ?? "md",
|
||||
}),
|
||||
raw: (json, c) => ({
|
||||
type: "raw",
|
||||
data: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
|
||||
maxHeight: c.maxHeight ?? 300,
|
||||
}),
|
||||
customJsx: (json, config) => ({
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
data: json,
|
||||
}),
|
||||
};
|
||||
|
||||
function extract(type: string, json: unknown, config: Record<string, unknown>) {
|
||||
const fn = extractors[type];
|
||||
if (!fn) throw new Error(`No extractor for type: ${type}`);
|
||||
return fn(json, config);
|
||||
}
|
||||
|
||||
describe("custom-api extractors", () => {
|
||||
it("sonarr statGrid extracts missing and queue counts", () => {
|
||||
const result = extract("statGrid", sonarrResponse, sonarrStatGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown; color: string }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("statGrid");
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]?.value).toBe(42);
|
||||
expect(result.items[0]?.label).toBe("Missing");
|
||||
expect(result.items[0]?.color).toBe("red");
|
||||
expect(result.items[1]?.value).toBe(5);
|
||||
});
|
||||
|
||||
it("proxmox progressBars extracts memory and disk usage", () => {
|
||||
const result = extract("progressBars", proxmoxResponse, proxmoxProgressBarsConfig) as {
|
||||
type: string;
|
||||
bars: Array<{ label: string; value: number; max: number }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("progressBars");
|
||||
expect(result.bars).toHaveLength(2);
|
||||
expect(result.bars[0]?.value).toBe(12_884_901_888);
|
||||
expect(result.bars[0]?.max).toBe(34_359_738_368);
|
||||
expect(result.bars[1]?.label).toBe("Disk");
|
||||
});
|
||||
|
||||
it("disk table extracts filesystem rows", () => {
|
||||
const result = extract("table", diskUsageResponse, diskTableConfig) as {
|
||||
type: string;
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
};
|
||||
|
||||
expect(result.type).toBe("table");
|
||||
expect(result.columns).toEqual(["Mount", "Size", "Used"]);
|
||||
expect(result.rows).toHaveLength(3);
|
||||
expect(result.rows[0]?.[0]).toBe("/");
|
||||
expect(result.rows[2]?.[2]).toBe("95%");
|
||||
});
|
||||
|
||||
it("pihole statusIndicator detects enabled state", () => {
|
||||
const result = extract("statusIndicator", piholeResponse, piholeStatusIndicatorConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: string; isGood: boolean }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("statusIndicator");
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]?.isGood).toBe(true);
|
||||
expect(result.items[0]?.value).toBe("enabled");
|
||||
});
|
||||
|
||||
it("pihole countGrid extracts all 4 stats", () => {
|
||||
const result = extract("countGrid", piholeResponse, piholeCountGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown; unit: string }>;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("countGrid");
|
||||
expect(result.items).toHaveLength(4);
|
||||
expect(result.items[0]?.value).toBe(12_847);
|
||||
expect(result.items[1]?.value).toBe(48_291);
|
||||
expect(result.items[2]?.value).toBe(26.6);
|
||||
expect(result.items[2]?.unit).toBe("%");
|
||||
expect(result.items[3]?.value).toBe(14);
|
||||
});
|
||||
|
||||
it("radarr statGrid with outline card style", () => {
|
||||
const result = extract("statGrid", radarrResponse, radarrStatGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown }>;
|
||||
cardStyle: string;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("statGrid");
|
||||
expect(result.cardStyle).toBe("outline");
|
||||
expect(result.items[0]?.value).toBe(15);
|
||||
expect(result.items[1]?.value).toBe(3);
|
||||
});
|
||||
|
||||
it("raw extractor returns raw data at path", () => {
|
||||
const result = extract("raw", piholeResponse, { type: "raw", jsonPath: "$.status", maxHeight: 200 }) as {
|
||||
type: string;
|
||||
data: unknown;
|
||||
maxHeight: number;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("raw");
|
||||
expect(result.data).toBe("enabled");
|
||||
expect(result.maxHeight).toBe(200);
|
||||
});
|
||||
|
||||
it("customJsx passes full JSON and template through", () => {
|
||||
const json = { title: "Status", items: [{ name: "CPU", value: 42 }] };
|
||||
const config = {
|
||||
type: "customJsx" as const,
|
||||
template: "<Stack><Title>{data.title}</Title></Stack>",
|
||||
};
|
||||
|
||||
const result = extract("customJsx", json, config) as {
|
||||
type: string;
|
||||
template: string;
|
||||
data: typeof json;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("customJsx");
|
||||
expect(result.template).toBe(config.template);
|
||||
expect(result.data).toEqual(json);
|
||||
});
|
||||
|
||||
it("jellyfin countGrid extracts movies/series/episodes/songs in 4-column grid", () => {
|
||||
const result = extract("countGrid", jellyfinItemCountsResponse, jellyfinCountGridConfig) as {
|
||||
type: string;
|
||||
items: Array<{ label: string; value: unknown; unit: string }>;
|
||||
columns: number;
|
||||
valueSize: string;
|
||||
};
|
||||
|
||||
expect(result.type).toBe("countGrid");
|
||||
expect(result.columns).toBe(4);
|
||||
expect(result.valueSize).toBe("lg");
|
||||
expect(result.items).toHaveLength(4);
|
||||
expect(result.items[0]).toEqual({ label: "Movies", value: 185, unit: "" });
|
||||
expect(result.items[1]).toEqual({ label: "Series", value: 38, unit: "" });
|
||||
expect(result.items[2]).toEqual({ label: "Episodes", value: 770, unit: "" });
|
||||
expect(result.items[3]).toEqual({ label: "Songs", value: 0, unit: "" });
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
export const sonarrResponse = {
|
||||
wanted: { totalRecords: 42 },
|
||||
queue: { totalRecords: 5 },
|
||||
series: [
|
||||
{ title: "Breaking Bad", seasons: 5, episodeCount: 62, episodeFileCount: 60 },
|
||||
{ title: "The Wire", seasons: 5, episodeCount: 60, episodeFileCount: 55 },
|
||||
],
|
||||
diskspace: [
|
||||
{ path: "/tv", label: "TV Shows", freeSpace: 150_000_000_000, totalSpace: 500_000_000_000 },
|
||||
{ path: "/movies", label: "Movies", freeSpace: 80_000_000_000, totalSpace: 250_000_000_000 },
|
||||
],
|
||||
};
|
||||
|
||||
export const sonarrStatGridConfig = {
|
||||
type: "statGrid" as const,
|
||||
items: [
|
||||
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
|
||||
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "blue" },
|
||||
],
|
||||
columns: 2,
|
||||
cardStyle: "filled" as const,
|
||||
};
|
||||
|
||||
export const proxmoxResponse = {
|
||||
data: {
|
||||
cpu: 0.23,
|
||||
memory: { used: 12_884_901_888, total: 34_359_738_368 },
|
||||
rootfs: { used: 42_949_672_960, total: 107_374_182_400 },
|
||||
uptime: 864_000,
|
||||
status: "running",
|
||||
maxcpu: 8,
|
||||
maxmem: 34_359_738_368,
|
||||
name: "proxmox-node1",
|
||||
},
|
||||
};
|
||||
|
||||
export const proxmoxProgressBarsConfig = {
|
||||
type: "progressBars" as const,
|
||||
bars: [
|
||||
{ label: "Memory", valuePath: "$.data.memory.used", maxPath: "$.data.memory.total", unit: "B", color: "blue" },
|
||||
{ label: "Disk", valuePath: "$.data.rootfs.used", maxPath: "$.data.rootfs.total", unit: "B", color: "orange" },
|
||||
],
|
||||
showPercentage: true,
|
||||
barSize: "md" as const,
|
||||
};
|
||||
|
||||
export const diskUsageResponse = {
|
||||
filesystems: [
|
||||
{ filesystem: "/dev/sda1", size: "500G", used: "350G", avail: "150G", usePct: "70%", mountedOn: "/" },
|
||||
{ filesystem: "/dev/sdb1", size: "2T", used: "1.2T", avail: "800G", usePct: "60%", mountedOn: "/data" },
|
||||
{ filesystem: "/dev/sdc1", size: "4T", used: "3.8T", avail: "200G", usePct: "95%", mountedOn: "/media" },
|
||||
],
|
||||
};
|
||||
|
||||
export const diskTableConfig = {
|
||||
type: "table" as const,
|
||||
tablePath: "$.filesystems",
|
||||
columns: [
|
||||
{ header: "Mount", jsonPath: "$.mountedOn" },
|
||||
{ header: "Size", jsonPath: "$.size" },
|
||||
{ header: "Used", jsonPath: "$.usePct" },
|
||||
],
|
||||
striped: true,
|
||||
compact: false,
|
||||
};
|
||||
|
||||
export const piholeResponse = {
|
||||
domains_being_blocked: 125_432,
|
||||
dns_queries_today: 48_291,
|
||||
ads_blocked_today: 12_847,
|
||||
ads_percentage_today: 26.6,
|
||||
unique_clients: 14,
|
||||
status: "enabled",
|
||||
gravity_last_updated: { relative: { days: 2, hours: 3 } },
|
||||
};
|
||||
|
||||
export const piholeStatusIndicatorConfig = {
|
||||
type: "statusIndicator" as const,
|
||||
items: [{ label: "Pi-Hole", jsonPath: "$.status", goodValues: ["enabled"] }],
|
||||
layout: "list" as const,
|
||||
dotSize: "md" as const,
|
||||
};
|
||||
|
||||
export const piholeCountGridConfig = {
|
||||
type: "countGrid" as const,
|
||||
items: [
|
||||
{ label: "Blocked Today", jsonPath: "$.ads_blocked_today", unit: "" },
|
||||
{ label: "DNS Queries", jsonPath: "$.dns_queries_today", unit: "" },
|
||||
{ label: "Block Rate", jsonPath: "$.ads_percentage_today", unit: "%" },
|
||||
{ label: "Clients", jsonPath: "$.unique_clients", unit: "" },
|
||||
],
|
||||
columns: 2,
|
||||
valueSize: "md" as const,
|
||||
};
|
||||
|
||||
export const radarrResponse = {
|
||||
wanted: { totalRecords: 15 },
|
||||
queue: { totalRecords: 3 },
|
||||
movie: [
|
||||
{ title: "Inception", hasFile: true, monitored: true, runtime: 148, year: 2010 },
|
||||
{ title: "Interstellar", hasFile: false, monitored: true, runtime: 169, year: 2014 },
|
||||
{ title: "The Dark Knight", hasFile: true, monitored: false, runtime: 152, year: 2008 },
|
||||
],
|
||||
rootfolder: [{ path: "/movies", freeSpace: 200_000_000_000, totalSpace: 1_000_000_000_000 }],
|
||||
};
|
||||
|
||||
export const radarrStatGridConfig = {
|
||||
type: "statGrid" as const,
|
||||
items: [
|
||||
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
|
||||
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "teal" },
|
||||
],
|
||||
columns: 2,
|
||||
cardStyle: "outline" as const,
|
||||
};
|
||||
|
||||
export const jellyfinItemCountsResponse = {
|
||||
MovieCount: 185,
|
||||
SeriesCount: 38,
|
||||
EpisodeCount: 770,
|
||||
ArtistCount: 0,
|
||||
ProgramCount: 0,
|
||||
TrailerCount: 0,
|
||||
SongCount: 0,
|
||||
AlbumCount: 0,
|
||||
MusicVideoCount: 0,
|
||||
BoxSetCount: 12,
|
||||
BookCount: 0,
|
||||
ItemCount: 0,
|
||||
};
|
||||
|
||||
export const jellyfinCountGridConfig = {
|
||||
type: "countGrid" as const,
|
||||
items: [
|
||||
{ label: "Movies", jsonPath: "$.MovieCount", unit: "" },
|
||||
{ label: "Series", jsonPath: "$.SeriesCount", unit: "" },
|
||||
{ label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" },
|
||||
{ label: "Songs", jsonPath: "$.SongCount", unit: "" },
|
||||
],
|
||||
columns: 4,
|
||||
valueSize: "lg" as const,
|
||||
};
|
||||
@@ -7,12 +7,16 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { customJsxDisplayConfigV2Schema, displayConfigSchema } from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/validation/custom-widget";
|
||||
import {
|
||||
customJsxDisplayConfigV2Schema,
|
||||
displayConfigSchema,
|
||||
extractActionButtonDisplay,
|
||||
extractDisplayDataWithFallback,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
|
||||
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
|
||||
|
||||
@@ -62,3 +62,31 @@ test("MCP tools should have descriptions", () => {
|
||||
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("custom widget public procedures remain available", () => {
|
||||
expect(Object.keys(customWidgetRouter._def.procedures)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"all",
|
||||
"byId",
|
||||
"available",
|
||||
"create",
|
||||
"update",
|
||||
"toggleEnabled",
|
||||
"delete",
|
||||
"duplicate",
|
||||
"export",
|
||||
"import",
|
||||
"schema",
|
||||
"validate",
|
||||
"readTemplate",
|
||||
"writeTemplate",
|
||||
"patchTemplate",
|
||||
"preview",
|
||||
"previewQuery",
|
||||
"previewAction",
|
||||
"simulatePreviewAction",
|
||||
"setPreviewLiveActions",
|
||||
"previewJournal",
|
||||
]),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user