feat(custom-widgets): add secure v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-12 14:35:35 +02:00
1 parent d37545c98e
commit 46add018bf
56 files changed
+12613 -1726

No files matched your search

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -16,18 +17,16 @@ export const WidgetCustomWidgetSelectInput = ({
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data, isLoading } = clientApi.customWidget.all.useQuery();
const board = useOptionalBoard();
const { data, isLoading } = clientApi.customWidget.available.useQuery(
{ boardId: board?.id ?? "" },
{ enabled: board !== null },
);
const [search, setSearch] = useState("");
const currentValue = form.values.options[property] as string;
const definitions = useMemo(
() =>
(data ?? [])
.filter((def) => def.enabled || def.id === currentValue)
.map((def) => ({ value: def.id, label: def.name })),
[data, currentValue],
);
const definitions = useMemo(() => (data ?? []).map((def) => ({ value: def.id, label: def.name })), [data]);
const filteredOptions = useMemo(
() =>
+57 -16
View File
@@ -333,20 +333,22 @@ function RawDisplay({ data }: { data: Record<string, unknown> }) {
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.customWidget.execute.useMutation();
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
const buttonColor = (data.buttonColor as string) ?? "blue";
const confirmText = (data.confirmText as string) || "";
const requiresConfirmation = data.requiresConfirmation === true;
const successMessage = (data.successMessage as string) || t("executeSuccess");
const definitionId = data.widgetDefinitionId as string | undefined;
const itemId = data.widgetItemId as string | undefined;
const isEditMode = data.isEditMode === true;
const handleExecute = async () => {
if (!definitionId) return;
const handleExecute = async (confirmed: boolean) => {
if (!itemId || isEditMode) return;
setLastSuccess(false);
try {
const result = await executeMutation.mutateAsync({ definitionId });
const result = await executeMutation.mutateAsync({ itemId, confirmed });
if (result.success) {
setLastSuccess(true);
showSuccessNotification({ title: buttonLabel, message: successMessage });
@@ -360,14 +362,14 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
};
const handleClick = () => {
if (confirmText) {
if (confirmText || requiresConfirmation) {
openConfirmModal({
title: buttonLabel,
children: confirmText,
onConfirm: () => void handleExecute(),
children: confirmText || t("executeConfirm"),
onConfirm: () => void handleExecute(true),
});
} else {
void handleExecute();
void handleExecute(false);
}
};
@@ -380,6 +382,7 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
loading={executeMutation.isPending}
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
variant={lastSuccess ? "light" : "filled"}
disabled={!itemId || isEditMode}
>
{executeMutation.isPending ? t("executing") : buttonLabel}
</Button>
@@ -400,7 +403,7 @@ export const displayComponents: Record<string, ComponentType<{ data: Record<stri
customJsx: CustomJsxDisplay,
};
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
export default function CustomApiWidget({ options, itemId, isEditMode }: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
@@ -417,20 +420,45 @@ export default function CustomApiWidget({ options }: WidgetComponentProps<"custo
);
}
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
return (
<CustomApiWidgetInner
definitionId={definitionId}
itemId={itemId}
isEditMode={isEditMode}
refreshInterval={refreshInterval as number}
/>
);
}
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
function CustomApiWidgetInner({
definitionId,
itemId,
isEditMode,
refreshInterval,
}: {
definitionId: string;
itemId: string | undefined;
isEditMode: boolean;
refreshInterval: number;
}) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
{ definitionId },
{ itemId: itemId ?? "" },
{
enabled: Boolean(itemId),
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (result?.type === "actionButton" || result?.type === "disabled") return false;
if (
result?.type === "actionButton" ||
result?.type === "disabled" ||
result?.type === "networkAccessNeedsReview"
) {
return false;
}
return intervalMs;
},
retry: (failureCount, err) => {
@@ -455,7 +483,7 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{isNotFound ? t("definitionNotFound") : error.message}
{isNotFound ? t("definitionNotFound") : tCustomJsx("requestFailed")}
</Text>
</Stack>
</Center>
@@ -477,11 +505,24 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
);
}
if (dataType === "networkAccessNeedsReview") {
return (
<Center h="100%" p="sm">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
<Text c="dimmed" size="sm" ta="center">
{tCustomJsx("networkAccessNeedsReview")}
</Text>
</Stack>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
const enrichedData =
dataType === "actionButton" || dataType === "customJsx"
? { ...widgetData, widgetDefinitionId: definitionId }
? { ...widgetData, widgetDefinitionId: definitionId, widgetItemId: itemId, isEditMode }
: widgetData;
return <Component data={enrichedData} />;
}
@@ -1,12 +1,32 @@
import { MantineProvider } from "@mantine/core";
import * as MantineCharts from "@mantine/charts";
import * as MantineCore from "@mantine/core";
import * as MantineDates from "@mantine/dates";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS } from "./jsx-whitelist";
import { SAFE_BINDINGS, WHITELISTED_COMPONENTS } from "./jsx-whitelist";
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
}));
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
if (typeof value === "function") return true;
if (!value || typeof value !== "object") return false;
const reactType = (value as { $$typeof?: unknown }).$$typeof;
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
};
describe("CustomJsxDisplay", () => {
let container: HTMLDivElement;
@@ -29,8 +49,8 @@ describe("CustomJsxDisplay", () => {
});
});
afterEach(() => {
root?.unmount();
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
@@ -64,6 +84,147 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("Safe");
});
it("supports bounded collection callbacks and safe string helpers", async () => {
await renderDisplay({
template:
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
data: {
items: [
{ name: "alpha", enabled: true },
{ name: "hidden", enabled: false },
{ name: "beta", enabled: true },
{ name: "gamma", enabled: true },
],
},
});
expect(container.textContent).toContain("1. ALPHA");
expect(container.textContent).toContain("2. BETA");
expect(container.textContent).not.toContain("GAMMA");
});
it("renders fetched data through an interpreter-created SubFetch child", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const rendered = renderSafeJsx({
template:
"<SubFetch>{(detail, meta) => <Text>{detail.types.map((entry) => entry.name).join(', ')} · {meta.status}</Text>}</SubFetch>",
components: {
SubFetch: ((props: { render?: (data: unknown, metadata: unknown) => ReactNode }) =>
props.render?.({ types: [{ name: "grass" }, { name: "poison" }] }, { status: 200 })) as never,
Text: ((props: { children?: ReactNode }) => createElement("span", null, props.children)) as never,
},
bindings: {},
});
await act(async () => root.render(rendered.node));
expect(container.textContent).toBe("grass, poison · 200");
});
it("blocks reflective properties assembled with string concatenation", async () => {
await renderDisplay({
template: '<Text>{data["con" + "structor"]}</Text>',
data: { value: 1 },
});
expect(container.textContent).toContain("reflective property");
});
it.each([
'<Text>{data["constr\\u0075ctor"]}</Text>',
'<Text>{data["__pro" + "to__"]}</Text>',
'<Text>{String["b" + "ind"](null)}</Text>',
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
"<Text>{data.items.map.apply(null, [])}</Text>",
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
expect(() =>
renderSafeJsx({
template,
components: { Text: (() => null) as never },
bindings: SAFE_BINDINGS({ items: [1] }),
}),
).toThrow(/reflective property/);
});
it("never resolves inherited data properties", () => {
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
inherited.visible = "safe";
const bindings = SAFE_BINDINGS(inherited);
const data = bindings.data as Record<string, unknown>;
expect(data.visible).toBe("safe");
expect(data.secret).toBeUndefined();
expect(Object.getPrototypeOf(data)).toBeNull();
});
it("blocks direct global calls but allows benign text containing fetch", async () => {
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
expect(container.textContent).toContain("fetch is available through controlled requests");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
),
);
});
expect(container.textContent).toContain("Unknown binding: fetch");
});
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
const props = sanitizeCustomJsxProps({
component: "iframe",
renderRoot: "iframe",
withinPortal: true,
onClick: () => undefined,
href: "javascript:alert(1)",
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
});
expect(props).toEqual({ style: { flex: 1, color: "red" } });
});
it("enforces collection and template length budgets", () => {
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
expect(() =>
renderSafeJsx({
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
components: {
Text: ((props: { children?: unknown }) => props.children) as never,
Stack: (() => null) as never,
},
bindings,
budgets: { maxCollectionItems: 2 },
}),
).toThrow(/collection limit/);
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
new SafeJsxError("Template exceeds the 50000 character limit"),
);
expect(() =>
renderSafeJsx({
template: '<Text>{"x".padStart(1000, "x")}</Text>',
components: { Text: (() => null) as never },
bindings: {},
budgets: { maxStringLength: 10 },
}),
).toThrow(/string length limit/);
expect(() =>
renderSafeJsx({
template: "<B/>".repeat(10_000),
components: { B: (() => null) as never },
bindings: {},
}),
).toThrow("Template exceeded the rendered node limit (10000)");
});
describe("safe bindings security", () => {
it("exposes wrapper functions, not native constructors", () => {
const bindings = SAFE_BINDINGS({ value: 7 });
@@ -153,6 +314,17 @@ describe("CustomJsxDisplay", () => {
expect(math.abs(-5)).toBe(5);
});
it("Date helpers only return primitives and getDay returns the weekday", () => {
const bindings = SAFE_BINDINGS({});
const date = bindings.Date as {
create: (value: string) => number;
getDay: (value: string) => number;
};
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
});
it("JSON.stringify works correctly", () => {
const bindings = SAFE_BINDINGS({});
const json = bindings.JSON as { stringify: (v: unknown) => string };
@@ -176,3 +348,205 @@ describe("CustomJsxDisplay", () => {
});
});
});
describe("Custom JSX component registry", () => {
it.each([
["@mantine/core", MantineCore],
["@mantine/charts", MantineCharts],
["@mantine/dates", MantineDates],
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
const classified = new Set(
customJsxComponentRegistry
.filter((component) => component.package === packageName)
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
);
const unclassified = Object.entries(moduleExports)
.filter(isComponentLikeExport)
.map(([name]) => name)
.filter((name) => !classified.has(name));
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
});
it("has a runtime implementation for every enabled registry component", () => {
const missing = enabledCustomJsxComponents
.map(({ name }) => name)
.filter((name) => !Object.hasOwn(WHITELISTED_COMPONENTS, name));
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
});
});
describe("new collection/string methods", () => {
let container: HTMLDivElement;
let root: Root;
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
});
return container.textContent ?? "";
};
const render = (template: string, data: Record<string, unknown> = {}) =>
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
it("find returns the first matching item", async () => {
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
items: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
],
});
expect(text).toContain("b");
});
it("findIndex returns correct index", async () => {
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
expect(text).toContain("2");
});
it("some returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("no");
});
it("every returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("all");
});
it("sort with comparator returns stable order on NaN", async () => {
const text = await renderAndGetText(
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
);
expect(text).toContain("a,");
expect(text).toContain("b");
});
it("reduce accumulates values", async () => {
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
nums: [1, 2, 3],
});
expect(text).toContain("6");
});
it("at returns element by negative index", async () => {
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
expect(text).toContain("c");
});
it("flat(2) throws SafeJsxError", () => {
expect(() => render(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
});
it("flat(1) flattens one level", async () => {
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
expect(text).toContain("1,2,3");
});
it("reverse returns reversed copy", async () => {
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
expect(text).toContain("3,2,1");
});
it("string replaceAll works", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
expect(text).toContain("f00bar");
});
it("string replaceAll with empty search is a no-op", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
expect(text).toContain("abc");
});
it("string repeat caps at budget", async () => {
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
expect(text).toContain("hihihi");
});
it("string indexOf and lastIndexOf return positions", async () => {
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
s: "hello",
});
expect(text).toContain("2-3");
});
it("string trimStart and trimEnd work", async () => {
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
expect(text).toContain("hi");
});
it("filter(Boolean) removes falsy values", async () => {
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
items: ["a", "", "b", null, "c"],
});
expect(text).toContain("a,b,c");
});
it("pop returns last element", async () => {
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
url: "https://pokeapi.co/api/v2/pokemon/1/",
});
expect(text).toContain("1");
});
it("String(n).padStart works for number formatting", async () => {
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
expect(text).toContain("0006");
});
it("number arithmetic in string concatenation", async () => {
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
index: 3,
});
expect(text).toContain("https://example.com/4.png");
});
});
describe("shared Custom JSX examples", () => {
const sampleData: Record<string, unknown> = {
name: "Server A",
status: "online",
value: 42,
count: 1351,
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
timeline: [
{ date: "2026-07-11", value: 40 },
{ date: "2026-07-12", value: 42.5 },
],
albumArt: "https://example.com/cover.jpg",
album: "Album",
title: "Track",
artist: "Artist",
progress: 55,
playing: true,
shuffle: false,
state: "on",
};
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
const rendered = renderSafeJsx({
template: example.template,
components: WHITELISTED_COMPONENTS,
bindings: SAFE_BINDINGS(sampleData),
});
expect(rendered.warnings).toEqual([]);
expect(rendered.node).not.toBeNull();
});
});
@@ -1,11 +1,15 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
import type { ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
import { renderSafeJsx } from "./safe-jsx-interpreter";
import type { CustomJsxRequestCapability } from "./widget-definition-context";
import { WidgetDefinitionProvider } from "./widget-definition-context";
const MAX_PARSE_ERRORS = 5;
@@ -16,25 +20,6 @@ function appendParseError(prev: string[], message: string): string[] {
return [...prev, message];
}
const METHOD_PATTERN = /(?:method|method)\s*=\s*["'](\w+)["']/gi;
const SUBFETCH_PATTERN = /<(?:SubFetch|ActionButton|ToggleSwitch)\b/g;
export function extractHttpMethods(template: string): string[] {
const methods = new Set<string>();
if (SUBFETCH_PATTERN.test(template)) {
SUBFETCH_PATTERN.lastIndex = 0;
let match = METHOD_PATTERN.exec(template);
while (match) {
methods.add(match[1].toUpperCase());
match = METHOD_PATTERN.exec(template);
}
if (/<ActionButton\b/.test(template) && methods.size === 0) methods.add("POST");
if (/<ToggleSwitch\b/.test(template) && methods.size === 0) methods.add("POST");
if (/<SubFetch\b/.test(template) && methods.size === 0) methods.add("GET");
}
return [...methods].toSorted();
}
const METHOD_COLORS: Record<string, string> = {
GET: "blue",
POST: "orange",
@@ -43,13 +28,90 @@ const METHOD_COLORS: Record<string, string> = {
PATCH: "grape",
};
const CAPABILITY_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const CAPABILITY_KINDS = new Set(["query", "action"]);
const CAPABILITY_PERMISSIONS = new Set(["view", "modify", "full"]);
function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
if (!Array.isArray(value)) return [];
return value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const record = candidate as Record<string, unknown>;
if (
typeof record.id !== "string" ||
typeof record.kind !== "string" ||
!CAPABILITY_KINDS.has(record.kind) ||
typeof record.method !== "string" ||
!CAPABILITY_METHODS.has(record.method) ||
typeof record.minimumBoardPermission !== "string" ||
!CAPABILITY_PERMISSIONS.has(record.minimumBoardPermission)
) {
return [];
}
return [record as unknown as CustomJsxRequestCapability];
});
}
interface RendererErrorBoundaryProps {
children: ReactNode;
fallback: (error: Error) => ReactNode;
onError: (error: Error) => void;
}
interface RendererErrorBoundaryState {
error: Error | null;
}
class RendererErrorBoundary extends Component<RendererErrorBoundaryProps, RendererErrorBoundaryState> {
public state: RendererErrorBoundaryState = { error: null };
public static getDerivedStateFromError(error: Error): RendererErrorBoundaryState {
return { error };
}
public componentDidCatch(error: Error, _info: ErrorInfo): void {
this.props.onError(error);
}
public render(): ReactNode {
return this.state.error ? this.props.fallback(this.state.error) : this.props.children;
}
}
function ErrorAlert({ error }: { error: Error }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{error.message}</Text>
</Alert>
);
}
const renderErrorFallback = (error: Error) => <ErrorAlert error={error} />;
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi.customJsx");
const template = String(data.template ?? "");
const apiData = data.data;
const definitionId = String(data.widgetDefinitionId ?? "");
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
const previewLiveActions = data.previewLiveActions === true;
const isEditMode = data.isEditMode === true;
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
const httpMethods = useMemo(() => extractHttpMethods(template), [template]);
const requestCapabilities = useMemo(
() => parseRequestCapabilities(data.requestCapabilities),
[data.requestCapabilities],
);
const rendered = useMemo(() => {
try {
const result = renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings });
return { ...result, error: null };
} catch (error) {
const normalized = error instanceof Error ? error : new Error(String(error));
return { node: null, warnings: [], error: normalized };
}
}, [bindings, template]);
useEffect(() => {
setParseErrors([]);
@@ -59,53 +121,85 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
setParseErrors((prev) => appendParseError(prev, error.message));
}, []);
useEffect(() => {
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
if (rendered.error) handleError(rendered.error);
}, [handleError, rendered]);
if (!template.trim()) {
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
No JSX template configured
{t("noTemplate")}
</Text>
</Alert>
);
}
return (
<WidgetDefinitionProvider definitionId={definitionId}>
<WidgetDefinitionProvider
definitionId={definitionId}
itemId={itemId}
previewSessionId={previewSessionId}
previewLiveActions={previewLiveActions}
isEditMode={isEditMode}
requestCapabilities={requestCapabilities}
>
<Stack gap={0} h="100%">
{httpMethods.length > 0 && (
<Alert color="yellow" variant="light" p={4} mb={4} icon={<IconNetwork size={14} />}>
<Group gap={4} align="center">
<Text size="xs" c="dimmed">
HTTP:
</Text>
{httpMethods.map((m) => (
<Badge key={m} size="xs" color={METHOD_COLORS[m] ?? "gray"} variant="filled">
{m}
{requestCapabilities.length > 0 && (
<Group justify="flex-end" mb={4}>
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
<Popover.Target>
<Badge
component="button"
type="button"
size="sm"
color="gray"
variant="light"
leftSection={<IconNetwork size={12} />}
style={{ cursor: "pointer" }}
>
{t("interactive")}
</Badge>
))}
</Group>
</Alert>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("networkCapabilities")}
</Text>
{requestCapabilities.map((capability) => (
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{capability.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={METHOD_COLORS[capability.method] ?? "gray"} variant="light">
{capability.method}
</Badge>
<Badge size="xs" color="gray" variant="outline">
{capability.minimumBoardPermission}
</Badge>
</Group>
</Group>
))}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
)}
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={bindings}
disableKeyGeneration
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
onError={handleError}
renderError={({ error }) => (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{String(error)}</Text>
</Alert>
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<RendererErrorBoundary key={template} onError={handleError} fallback={renderErrorFallback}>
{rendered.node}
</RendererErrorBoundary>
)}
/>
</Box>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{parseErrors.length} template warning(s):
{t("templateWarnings", { count: String(parseErrors.length) })}
</Text>
{parseErrors.map((msg) => (
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
+356 -208
View File
@@ -1,26 +1,34 @@
"use client";
import type { ComponentType, ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { Alert, Badge, Button, Code, Skeleton, Switch, Text, Title } from "@mantine/core";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Badge, Button, Code, Image, Skeleton, Switch, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useWidgetDefinitionId } from "./widget-definition-context";
import { useCustomJsxRuntime } from "./widget-definition-context";
const MAX_CALLS_PER_MINUTE = 10;
const RATE_WINDOW_MS = 60_000;
const callTimestamps = new Map<string, number[]>();
type RuntimeParam = string | number | boolean;
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
interface RequestResult {
ok: boolean;
status: number;
statusText?: string;
data: unknown;
error?: string;
simulated?: boolean;
}
type SubFetchResult =
| { ok: true; status: number; data: unknown }
| { ok: false; status: number; data: unknown; error?: string };
export interface SubFetchMetadata {
ok: boolean;
status: number;
statusText?: string;
}
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
play: IconPlayerPlay,
@@ -32,16 +40,6 @@ const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
const SubFetchDataContext = createContext<unknown>(undefined);
function checkRateLimit(definitionId: string): boolean {
const now = Date.now();
const calls = callTimestamps.get(definitionId) ?? [];
const recent = calls.filter((t) => now - t < RATE_WINDOW_MS);
if (recent.length >= MAX_CALLS_PER_MINUTE) return false;
recent.push(now);
callTimestamps.set(definitionId, recent);
return true;
}
function parseBool(value: unknown, defaultValue = false): boolean {
if (typeof value === "boolean") return value;
if (value === "true") return true;
@@ -49,32 +47,24 @@ function parseBool(value: unknown, defaultValue = false): boolean {
return defaultValue;
}
function parseMethod(method?: string): HttpMethod {
const upper = (method ?? "GET").toUpperCase();
if (HTTP_METHODS.includes(upper as HttpMethod)) return upper as HttpMethod;
return "GET";
}
function parseJsonRecord(str?: string): Record<string, string> | undefined {
if (!str?.trim()) return undefined;
try {
const parsed: unknown = JSON.parse(str);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
result[key] = String(value);
}
return result;
} catch {
return undefined;
function normalizeParams(value: unknown): CustomJsxRuntimeParams | null {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const result: CustomJsxRuntimeParams = {};
for (const [key, param] of Object.entries(value)) {
if (key === "constructor" || key === "prototype" || key === "__proto__") return null;
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
result[key] = param;
}
return result;
}
function getByPath(obj: unknown, path?: string): unknown {
if (!path?.trim()) return obj;
return path.split(".").reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== "object") return undefined;
return (acc as Record<string, unknown>)[key];
if (key === "constructor" || key === "prototype" || key === "__proto__") return undefined;
return Object.hasOwn(acc, key) ? (acc as Record<string, unknown>)[key] : undefined;
}, obj);
}
@@ -84,145 +74,165 @@ function formatDisplayValue(value: unknown): string {
return String(value);
}
function useSubFetchExecutor() {
const definitionId = useWidgetDefinitionId();
const mutation = clientApi.widget.customApi.subFetch.useMutation();
const execute = useCallback(
async (params: { url: string; method?: string; body?: string; headers?: string }): Promise<SubFetchResult> => {
if (!definitionId) {
return { ok: false as const, status: 0, data: null, error: "Widget definition not available" };
}
if (!checkRateLimit(definitionId)) {
return { ok: false as const, status: 0, data: null, error: "Rate limit exceeded (10 calls/minute)" };
}
return mutation.mutateAsync({
definitionId,
url: params.url,
method: parseMethod(params.method),
body: params.body,
headers: parseJsonRecord(params.headers),
});
},
[definitionId, mutation],
function MigrationRequiredAlert() {
const t = useScopedI18n("widget.customApi.customJsx");
return (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{t("migrationRequired")}</Text>
</Alert>
);
return { execute, isPending: mutation.isPending };
}
export interface SubFetchProps {
url: string;
method?: string;
body?: string;
headers?: string;
trigger?: "auto" | "manual";
label?: string;
color?: string;
variant?: string;
requestId?: string;
params?: CustomJsxRuntimeParams;
refreshInterval?: number;
children?: ReactNode;
loadingText?: string;
errorText?: string;
display?: "json" | "text";
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
loadingLabel?: string;
errorMessage?: string;
fallback?: ReactNode;
path?: string;
as?: "json" | "text";
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated The method is defined by the named request manifest. */
method?: string;
/** @deprecated The body is defined by the named request manifest. */
body?: string;
/** @deprecated Headers are defined by the named request manifest. */
headers?: string;
/** Set to manual to wait for an explicit user gesture before the first query. */
trigger?: "auto" | "manual";
}
export function SubFetch({
url,
method,
body,
headers,
trigger = "auto",
label = "Fetch",
color = "blue",
variant = "filled",
requestId,
params,
refreshInterval,
children,
loadingText = "Loading...",
errorText,
display,
render,
loadingLabel,
errorMessage,
fallback,
path,
as,
trigger = "auto",
}: SubFetchProps) {
const { execute, isPending } = useSubFetchExecutor();
const [subData, setSubData] = useState<unknown>(undefined);
const [error, setError] = useState<string | null>(null);
const [fetched, setFetched] = useState(false);
const t = useScopedI18n("widget.customApi.customJsx");
const { itemId, previewSessionId } = useCustomJsxRuntime();
const [manualRun, setManualRun] = useState(false);
const normalizedParams = useMemo(() => normalizeParams(params), [params]);
const requestKey = useMemo(() => JSON.stringify(normalizedParams), [normalizedParams]);
useEffect(() => setManualRun(false), [requestId, requestKey, trigger]);
const canRun = Boolean((itemId || previewSessionId) && requestId && normalizedParams);
const boardQuery = clientApi.widget.customApi.queryRequest.useQuery(
{
itemId: itemId ?? "",
requestId: requestId ?? "",
params: normalizedParams ?? {},
},
{
enabled: Boolean(itemId) && canRun && (trigger === "auto" || manualRun),
retry: 2,
refetchInterval:
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
? Math.max(1_000, refreshInterval * 1_000)
: false,
},
);
const previewQuery = clientApi.customWidget.previewQuery.useQuery(
{
sessionId: previewSessionId ?? "",
requestId: requestId ?? "",
params: normalizedParams ?? {},
},
{
enabled: Boolean(previewSessionId && !itemId) && canRun && (trigger === "auto" || manualRun),
retry: 2,
refetchInterval:
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
? Math.max(1_000, refreshInterval * 1_000)
: false,
},
);
const query = itemId ? boardQuery : previewQuery;
const runFetch = useCallback(async () => {
setError(null);
const result = await execute({ url, method, body, headers });
if (result.ok) {
setSubData(result.data);
setFetched(true);
} else {
setError(result.error ?? errorText ?? `Request failed (${result.status})`);
setFetched(true);
}
}, [execute, url, method, body, headers, errorText]);
useEffect(() => {
if (trigger === "auto") {
void runFetch();
}
}, [trigger, runFetch]);
if (trigger === "manual" && !fetched && !isPending) {
if (!requestId) return <MigrationRequiredAlert />;
if (!itemId && !previewSessionId) {
return (
<Button color={color} variant={variant as never} onClick={() => void runFetch()} loading={isPending}>
{label}
fallback ?? (
<Text size="xs" c="dimmed">
{t("unsavedPreview")}
</Text>
)
);
}
if (!normalizedParams) {
return (
<Alert color="red" variant="light" p="xs">
<Text size="xs">{t("invalidParams")}</Text>
</Alert>
);
}
if (trigger === "manual" && !manualRun) {
return (
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
{t("loadRequest")}
</Button>
);
}
if (isPending) {
const result = query.data as RequestResult | undefined;
const requestError =
(query.error ? t("requestFailed") : null) ??
(!result?.ok ? (result?.error ?? `${t("requestFailed")} (${result?.status ?? 0})`) : null);
if (!result && query.isPending) {
return (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text size="xs" c="dimmed">
{loadingText}
</Text>
</div>
fallback ?? (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text size="xs" c="dimmed">
{loadingLabel ?? t("loading")}
</Text>
</div>
)
);
}
if (error) {
if (requestError) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{error}</Text>
<Text size="xs">{errorMessage ?? requestError}</Text>
<Button
size="compact-xs"
variant="subtle"
mt={4}
onClick={() => void query.refetch()}
loading={query.isFetching}
>
{t("retry")}
</Button>
</Alert>
);
}
const contextValue = subData;
if (display === "json") {
return (
<SubFetchDataContext.Provider value={contextValue}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(subData, null, 2)}
</Code>
</SubFetchDataContext.Provider>
const subData = result?.data;
let content = render
? render(subData, { ok: result?.ok ?? false, status: result?.status ?? 0, statusText: result?.statusText })
: children;
if (!render && (as === "json" || (!children && as !== "text"))) {
content = (
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(path ? getByPath(subData, path) : subData, null, 2)}
</Code>
);
} else if (!render && as === "text") {
content = <Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>;
}
if (display === "text" && path) {
return (
<SubFetchDataContext.Provider value={contextValue}>
<Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>
</SubFetchDataContext.Provider>
);
}
if (!children) {
return (
<SubFetchDataContext.Provider value={contextValue}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(subData, null, 2)}
</Code>
</SubFetchDataContext.Provider>
);
}
return <SubFetchDataContext.Provider value={contextValue}>{children}</SubFetchDataContext.Provider>;
return <SubFetchDataContext.Provider value={subData}>{content}</SubFetchDataContext.Provider>;
}
export interface SubDataProps {
@@ -234,6 +244,11 @@ export interface SubDataProps {
variant?: string;
fw?: number;
c?: string;
alt?: string;
fit?: string;
w?: string | number;
h?: string | number;
radius?: string | number;
}
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
@@ -266,8 +281,14 @@ export function SubData({ path, as = "Text", ...props }: SubDataProps) {
);
}
if (as === "Code") {
return <Code block>{displayValue}</Code>;
if (as === "Code") return <Code block>{displayValue}</Code>;
if (as === "Image") {
const src = safeDisplayImageUrl(value);
if (!src) return null;
return (
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
);
}
return (
@@ -277,64 +298,158 @@ export function SubData({ path, as = "Text", ...props }: SubDataProps) {
);
}
function safeDisplayImageUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (value.startsWith("/") && !value.startsWith("//")) return value;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
} catch {
return undefined;
}
}
function useActionExecutor() {
const t = useScopedI18n("widget.customApi.customJsx");
const runtime = useCustomJsxRuntime();
const boardMutation = clientApi.widget.customApi.executeAction.useMutation();
const previewMutation = clientApi.customWidget.previewAction.useMutation();
const execute = useCallback(
async (requestId: string, params: CustomJsxRuntimeParams, confirmed: boolean): Promise<RequestResult> => {
if (!runtime.itemId && !runtime.previewSessionId) {
return { ok: false, status: 0, data: null, error: t("widgetItemUnavailable") };
}
if (runtime.isEditMode) {
return { ok: false, status: 0, data: null, error: t("actionsDisabledEditMode") };
}
if (runtime.itemId) {
return boardMutation.mutateAsync({ itemId: runtime.itemId, requestId, params, confirmed });
}
return previewMutation.mutateAsync({
sessionId: runtime.previewSessionId ?? "",
requestId,
params,
confirmed,
});
},
[boardMutation, previewMutation, runtime.isEditMode, runtime.itemId, runtime.previewSessionId, t],
);
return {
execute,
isPending: boardMutation.isPending || previewMutation.isPending,
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
canExecute: Boolean(runtime.itemId || runtime.previewSessionId),
isEditMode: runtime.isEditMode,
requestCapabilities: runtime.requestCapabilities ?? [],
};
}
async function invalidateAfterAction(
utils: ReturnType<typeof clientApi.useUtils>,
itemId: string | undefined,
previewSessionId: string | undefined,
invalidate: readonly string[] | undefined,
) {
if (!invalidate?.length) return;
if (previewSessionId) {
await utils.customWidget.previewQuery.invalidate();
return;
}
if (!itemId) return;
if (invalidate.includes("parent")) await utils.widget.customApi.getData.invalidate({ itemId });
if (invalidate.some((entry) => entry !== "parent")) await utils.widget.customApi.queryRequest.invalidate();
}
export interface ActionButtonProps {
url: string;
method?: string;
body?: string;
headers?: string;
requestId?: string;
params?: CustomJsxRuntimeParams;
label: string;
color?: string;
variant?: string;
size?: string;
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
icon?: string;
fullWidth?: boolean | string;
invalidate?: string[];
disabled?: boolean | string;
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated The method is defined by the named request manifest. */
method?: string;
/** @deprecated The body is defined by the named request manifest. */
body?: string;
/** @deprecated Headers are defined by the named request manifest. */
headers?: string;
/** @deprecated Use normal Mantine sizing props. */
fullWidth?: boolean | string;
}
export function ActionButton({
url,
method = "POST",
body,
headers,
requestId,
params,
label,
color = "blue",
variant = "filled",
size = "sm",
confirmMessage,
successMessage,
errorMessage,
icon,
fullWidth,
invalidate,
disabled,
}: ActionButtonProps) {
const { execute, isPending } = useSubFetchExecutor();
const t = useScopedI18n("widget.customApi.customJsx");
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode, requestCapabilities } =
useActionExecutor();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const Icon = icon ? ICON_MAP[icon.toLowerCase()] : undefined;
const normalizedParams = normalizeParams(params);
const requiresConfirmation = requestCapabilities.some(
(capability) => capability.id === requestId && capability.kind === "action" && capability.method === "DELETE",
);
const runAction = async () => {
const result = await execute({ url, method, body, headers });
if (result.ok) {
if (successMessage) {
showSuccessNotification({ title: label, message: successMessage });
const runAction = async (confirmed: boolean) => {
if (!requestId || !normalizedParams) return;
try {
const result = await execute(requestId, normalizedParams, confirmed);
if (result.ok) {
showSuccessNotification({
title: label,
message: successMessage ?? (result.simulated ? t("actionSimulated") : t("actionCompleted")),
});
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
} else {
showErrorNotification({
title: label,
message: errorMessage ?? result.error ?? `${t("requestFailed")} (${result.status})`,
});
}
} else {
showErrorNotification({ title: label, message: result.error ?? `Request failed (${result.status})` });
} catch {
showErrorNotification({
title: label,
message: errorMessage ?? t("requestFailed"),
});
}
};
const handleClick = () => {
if (confirmMessage) {
if (confirmMessage || requiresConfirmation) {
openConfirmModal({
title: label,
children: confirmMessage,
onConfirm: () => void runAction(),
children: confirmMessage ?? t("confirmDelete"),
onConfirm: () => void runAction(true),
});
} else {
void runAction();
void runAction(false);
}
};
if (!requestId) return <MigrationRequiredAlert />;
return (
<Button
color={color}
@@ -343,8 +458,7 @@ export function ActionButton({
onClick={handleClick}
loading={isPending}
leftSection={Icon ? <Icon size={16} /> : undefined}
fullWidth={parseBool(fullWidth)}
disabled={parseBool(disabled)}
disabled={parseBool(disabled) || isEditMode || !canExecute || !normalizedParams}
>
{label}
</Button>
@@ -352,51 +466,81 @@ export function ActionButton({
}
export interface ToggleSwitchProps {
url: string;
method?: string;
onBody?: string;
offBody?: string;
requestId?: string;
onParams?: CustomJsxRuntimeParams;
offParams?: CustomJsxRuntimeParams;
initialValue?: boolean | string;
label?: string;
color?: string;
size?: string;
errorMessage?: string;
invalidate?: string[];
disabled?: boolean | string;
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated The method is defined by the named request manifest. */
method?: string;
/** @deprecated Use onParams. */
onBody?: string;
/** @deprecated Use offParams. */
offBody?: string;
}
export function ToggleSwitch({
url,
method = "POST",
onBody,
offBody,
requestId,
onParams,
offParams,
initialValue = false,
label,
color = "blue",
size = "sm",
errorMessage,
invalidate,
disabled,
}: ToggleSwitchProps) {
const { execute, isPending } = useSubFetchExecutor();
const [checked, setChecked] = useState(parseBool(initialValue));
const t = useScopedI18n("widget.customApi.customJsx");
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode } = useActionExecutor();
const utils = clientApi.useUtils();
const initial = parseBool(initialValue);
const [checked, setChecked] = useState(initial);
const pendingRef = useRef(false);
const normalizedOnParams = normalizeParams(onParams);
const normalizedOffParams = normalizeParams(offParams);
useEffect(() => {
if (!pendingRef.current) setChecked(initial);
}, [initial]);
const handleChange = async (next: boolean) => {
if (pendingRef.current) return;
if (!requestId || pendingRef.current) return;
const requestParams = next ? normalizedOnParams : normalizedOffParams;
if (!requestParams) return;
pendingRef.current = true;
const prev = checked;
const previous = checked;
setChecked(next);
const result = await execute({
url,
method,
body: next ? onBody : offBody,
});
pendingRef.current = false;
if (!result.ok) {
setChecked(prev);
showErrorNotification({ title: label ?? "Toggle", message: result.error ?? "Request failed" });
try {
const result = await execute(requestId, requestParams, false);
if (!result.ok) {
setChecked(previous);
showErrorNotification({
title: label ?? t("toggle"),
message: errorMessage ?? result.error ?? t("requestFailed"),
});
return;
}
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
} catch {
setChecked(previous);
showErrorNotification({
title: label ?? t("toggle"),
message: errorMessage ?? t("requestFailed"),
});
} finally {
pendingRef.current = false;
}
};
if (!requestId) return <MigrationRequiredAlert />;
return (
<Switch
label={label}
@@ -404,7 +548,9 @@ export function ToggleSwitch({
size={size as never}
checked={checked}
onChange={(event) => void handleChange(event.currentTarget.checked)}
disabled={parseBool(disabled) || isPending}
disabled={
parseBool(disabled) || isPending || isEditMode || !canExecute || !normalizedOnParams || !normalizedOffParams
}
/>
);
}
@@ -416,21 +562,22 @@ export interface RefreshButtonProps {
size?: string;
}
export function RefreshButton({
label = "Refresh",
color = "blue",
variant = "light",
size = "sm",
}: RefreshButtonProps) {
const definitionId = useWidgetDefinitionId();
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const { itemId, previewSessionId, isEditMode } = useCustomJsxRuntime();
const utils = clientApi.useUtils();
const [loading, setLoading] = useState(false);
const handleRefresh = async () => {
if (!definitionId) return;
if (!itemId && !previewSessionId) return;
setLoading(true);
try {
await utils.widget.customApi.getData.invalidate({ definitionId });
if (previewSessionId) await utils.customWidget.previewQuery.invalidate();
else
await Promise.all([
utils.widget.customApi.getData.invalidate({ itemId: itemId ?? "" }),
utils.widget.customApi.queryRequest.invalidate(),
]);
} finally {
setLoading(false);
}
@@ -444,8 +591,9 @@ export function RefreshButton({
leftSection={<IconRefresh size={16} />}
onClick={() => void handleRefresh()}
loading={loading}
disabled={(!itemId && !previewSessionId) || isEditMode}
>
{label}
{label ?? t("refresh")}
</Button>
);
}
+390 -76
View File
@@ -1,5 +1,5 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, useState } from "react";
import { createElement, useEffect, useRef, useState } from "react";
import {
Accordion,
ActionIcon,
@@ -22,12 +22,12 @@ import {
Collapse,
ColorSwatch,
Container,
CopyButton,
DataList,
Divider,
EmptyState,
Fieldset,
Flex,
FloatingIndicator,
Grid,
Group,
Highlight,
@@ -37,6 +37,7 @@ import {
Kbd,
List,
Loader,
LoadingOverlay,
Mark,
Marquee,
Menu,
@@ -71,12 +72,16 @@ import {
Tooltip,
Transition,
Tree,
Typography,
VisuallyHidden,
} from "@mantine/core";
import {
AreaChart,
BarChart,
BarsList,
BubbleChart,
ChartLegend,
ChartTooltip,
CompositeChart,
DonutChart,
FunnelChart,
@@ -90,10 +95,139 @@ import {
Sparkline,
Treemap,
} from "@mantine/charts";
import { Calendar, DatePicker, MiniCalendar, TimeValue } from "@mantine/dates";
import {
IconActivity,
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBell,
IconBluetooth,
IconBolt,
IconBookmark,
IconBug,
IconCalendar,
IconCamera,
IconChartBar,
IconChartLine,
IconChartPie,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCircleCheck,
IconCircleX,
IconClipboard,
IconClock,
IconCloud,
IconCloudOff,
IconCode,
IconCompass,
IconCopy,
IconCpu,
IconDatabase,
IconDeviceDesktop,
IconDeviceMobile,
IconDownload,
IconDroplet,
IconEdit,
IconExternalLink,
IconEye,
IconEyeOff,
IconFile,
IconFlag,
IconFlame,
IconFolder,
IconGauge,
IconGitBranch,
IconHeart,
IconHistory,
IconHome,
IconInfoCircle,
IconKey,
IconLink,
IconLoader,
IconLock,
IconLockOpen,
IconMail,
IconMap,
IconMapPin,
IconMinus,
IconMoon,
IconMusic,
IconNetwork,
IconPhoto,
IconPin,
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconPlayerStop,
IconPlus,
IconPower,
IconProgress,
IconHeartbeat,
IconRefresh,
IconReload,
IconSearch,
IconServer,
IconSettings,
IconShield,
IconStar,
IconSun,
IconTerminal,
IconThermometer,
IconTrash,
IconTrendingDown,
IconTrendingUp,
IconUpload,
IconUser,
IconUsers,
IconVideo,
IconVolume,
IconVolume2,
IconVolumeOff,
IconWifi,
IconWifiOff,
IconWorld,
IconX,
IconSpeedboat,
} from "@tabler/icons-react";
import {
Calendar,
CalendarHeader,
DatePicker,
Day,
DecadeLevel,
DecadeLevelGroup,
LevelsGroup,
MiniCalendar,
Month,
MonthLevel,
MonthLevelGroup,
MonthPicker,
MonthsList,
PickerControl,
TimeGrid,
TimeValue,
WeekdaysRow,
YearLevel,
YearLevelGroup,
YearPicker,
YearsList,
} from "@mantine/dates";
import { enabledCustomJsxComponents } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
import { SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton } from "./jsx-sub-fetch";
import { createSafeCallable } from "./safe-bindings";
import { sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
const SAFE_URL_PATTERN = /^https?:\/\//i;
@@ -102,110 +236,257 @@ function isSafeUrl(url: unknown): boolean {
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
}
function stripEventHandlers(props: Record<string, unknown>): Record<string, unknown> {
const safe: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (/^on[A-Z]/.test(key)) continue;
safe[key] = value;
}
return safe;
}
function SafeAnchor(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(Anchor as never, { ...stripEventHandlers(props), href: safeHref });
return createElement(Anchor as never, { ...sanitizeCustomJsxProps(props), href: safeHref });
}
function SafeNavLink(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(NavLink as never, { ...stripEventHandlers(props), href: safeHref });
return createElement(NavLink as never, { ...sanitizeCustomJsxProps(props), href: safeHref });
}
function SafeButton(props: Record<string, unknown>) {
return createElement(Button as never, stripEventHandlers(props));
return createElement(Button as never, sanitizeCustomJsxProps(props));
}
function SafeActionIcon(props: Record<string, unknown>) {
return createElement(ActionIcon as never, stripEventHandlers(props));
return createElement(ActionIcon as never, sanitizeCustomJsxProps(props));
}
function SafeBurger(props: Record<string, unknown>) {
return createElement(Burger as never, stripEventHandlers(props));
return createElement(Burger as never, sanitizeCustomJsxProps(props));
}
function SafeCloseButton(props: Record<string, unknown>) {
return createElement(CloseButton as never, stripEventHandlers(props));
return createElement(CloseButton as never, sanitizeCustomJsxProps(props));
}
function SafeChip(props: Record<string, unknown>) {
return createElement(Chip as never, stripEventHandlers(props));
return createElement(Chip as never, sanitizeCustomJsxProps(props));
}
function SafeNotification(props: Record<string, unknown>) {
return createElement(Notification as never, stripEventHandlers(props));
return createElement(Notification as never, sanitizeCustomJsxProps(props));
}
function SafeRating(props: Record<string, unknown>) {
return createElement(Rating as never, { ...stripEventHandlers(props), readOnly: true });
return createElement(Rating as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafeSlider(props: Record<string, unknown>) {
return createElement(Slider as never, { ...stripEventHandlers(props), readOnly: true });
return createElement(Slider as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafeSwitch(props: Record<string, unknown>) {
return createElement(Switch as never, { ...stripEventHandlers(props), readOnly: true });
return createElement(Switch as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafeSegmentedControl(props: Record<string, unknown>) {
return createElement(SegmentedControl as never, stripEventHandlers(props));
return createElement(SegmentedControl as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafePagination(props: Record<string, unknown>) {
return createElement(Pagination as never, stripEventHandlers(props));
return createElement(Pagination as never, { ...sanitizeCustomJsxProps(props), disabled: true });
}
function SafeStepper(props: Record<string, unknown>) {
return createElement(Stepper as never, stripEventHandlers(props));
return createElement(Stepper as never, sanitizeCustomJsxProps(props));
}
function SafeCalendar(props: Record<string, unknown>) {
return createElement(Calendar as never, { ...stripEventHandlers(props), static: true });
return createElement(Calendar as never, { ...sanitizeCustomJsxProps(props), static: true });
}
function SafeMiniCalendar(props: Record<string, unknown>) {
return createElement(MiniCalendar as never, { ...stripEventHandlers(props), static: true });
return createElement(MiniCalendar as never, { ...sanitizeCustomJsxProps(props), static: true });
}
function SafeTree(props: Record<string, unknown>) {
return createElement(Tree as never, stripEventHandlers(props));
return createElement(Tree as never, sanitizeCustomJsxProps(props));
}
function SafeDatePicker(props: Record<string, unknown>) {
return createElement(DatePicker as never, { ...stripEventHandlers(props), static: true });
return createElement(DatePicker as never, { ...sanitizeCustomJsxProps(props), static: true });
}
function createSafeDateComponent(component: ComponentType<never>) {
return function SafeDateComponent(props: Record<string, unknown>) {
return createElement(component, { ...sanitizeCustomJsxProps(props), static: true } as never);
};
}
function tablerIconExportToKey(exportName: string): string {
return exportName
.slice(4)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
}
const TABLER_ICON_IMPORTS = {
IconCheck,
IconX,
IconPlus,
IconMinus,
IconArrowUp,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconChevronUp,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconHome,
IconSettings,
IconUser,
IconUsers,
IconSearch,
IconBell,
IconMail,
IconHeart,
IconStar,
IconBookmark,
IconFlag,
IconPin,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconPlayerSkipForward,
IconPlayerSkipBack,
IconVolume,
IconVolumeOff,
IconVolume2,
IconWifi,
IconWifiOff,
IconBluetooth,
IconCloud,
IconCloudOff,
IconServer,
IconDatabase,
IconCpu,
IconDeviceDesktop,
IconDeviceMobile,
IconFolder,
IconFile,
IconDownload,
IconUpload,
IconRefresh,
IconReload,
IconPower,
IconBolt,
IconFlame,
IconDroplet,
IconSun,
IconMoon,
IconEye,
IconEyeOff,
IconLock,
IconLockOpen,
IconShield,
IconKey,
IconAlertTriangle,
IconAlertCircle,
IconInfoCircle,
IconCircleCheck,
IconCircleX,
IconTrash,
IconEdit,
IconCopy,
IconClipboard,
IconExternalLink,
IconLink,
IconPhoto,
IconCamera,
IconMusic,
IconVideo,
IconCalendar,
IconClock,
IconHistory,
IconMap,
IconMapPin,
IconCompass,
IconTerminal,
IconCode,
IconBug,
IconGitBranch,
IconNetwork,
IconWorld,
IconApi,
IconChartBar,
IconChartLine,
IconChartPie,
IconTrendingUp,
IconTrendingDown,
IconActivity,
IconProgress,
IconLoader,
IconThermometer,
IconGauge,
IconSpeedboat,
IconHeartbeat,
} as const;
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
...Object.fromEntries(
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
tablerIconExportToKey(exportName),
IconComponent,
]),
),
pulse: IconHeartbeat,
};
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
function SafeTablerIcon(props: Record<string, unknown>) {
const name = props.name;
if (typeof name !== "string") return null;
const IconComponent = TABLER_ICON_MAP[name];
if (!IconComponent) return null;
const iconProps: Record<string, unknown> = {};
if (isValidIconProp(props.size)) iconProps.size = props.size;
if (typeof props.color === "string") iconProps.color = props.color;
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
return createElement(IconComponent as never, iconProps);
}
function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
const t = useScopedI18n("widget.customApi.customJsx");
const [copied, setCopied] = useState(false);
const handleCopy = () => {
const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(resetTimer.current), []);
const handleCopy = async () => {
if (!value) return;
void navigator.clipboard.writeText(value).then(() => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return createElement(Button as never, {
size: "xs",
variant: copied ? "filled" : "light",
color: copied ? "teal" : "blue",
onClick: handleCopy,
children: copied ? "Copied!" : "Copy",
});
const COPY_STATES = {
idle: { variant: "light", color: "blue", label: t("copy") },
copied: { variant: "filled", color: "teal", label: t("copied") },
} as const;
const state = COPY_STATES[copied ? "copied" : "idle"];
return createElement(
Button as never,
{ size: "xs", variant: state.variant, color: state.color, onClick: () => void handleCopy() },
state.label,
);
}
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
const RUNTIME_COMPONENTS: Record<string, ComponentType<never>> = {
// Layout
Box,
Stack,
@@ -220,6 +501,9 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
AspectRatio,
Overlay,
ScrollArea,
VisuallyHidden,
Typography,
FloatingIndicator,
// Typography & text
Text,
@@ -284,6 +568,7 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
"EmptyState.Actions": EmptyState.Actions,
Fieldset,
Notification: SafeNotification,
LoadingOverlay,
Rating: SafeRating,
// Navigation & structure
@@ -342,11 +627,30 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
SankeyChart,
Treemap,
BarsList,
ChartLegend,
ChartTooltip,
// Dates
Calendar: SafeCalendar,
MiniCalendar: SafeMiniCalendar,
DatePicker: SafeDatePicker,
Day: createSafeDateComponent(Day),
WeekdaysRow: createSafeDateComponent(WeekdaysRow),
Month: createSafeDateComponent(Month),
CalendarHeader: createSafeDateComponent(CalendarHeader),
YearPicker: createSafeDateComponent(YearPicker),
MonthPicker: createSafeDateComponent(MonthPicker),
TimeGrid: createSafeDateComponent(TimeGrid),
PickerControl: createSafeDateComponent(PickerControl),
YearsList: createSafeDateComponent(YearsList),
MonthsList: createSafeDateComponent(MonthsList),
DecadeLevel: createSafeDateComponent(DecadeLevel),
YearLevel: createSafeDateComponent(YearLevel),
MonthLevel: createSafeDateComponent(MonthLevel),
LevelsGroup: createSafeDateComponent(LevelsGroup),
DecadeLevelGroup: createSafeDateComponent(DecadeLevelGroup),
YearLevelGroup: createSafeDateComponent(YearLevelGroup),
MonthLevelGroup: createSafeDateComponent(MonthLevelGroup),
TimeValue,
// Custom interactive components (own state management)
@@ -356,6 +660,7 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Collapsible,
StatBar,
TypeBadge,
TablerIcon: SafeTablerIcon,
// SubFetch — server-proxied HTTP from within templates
SubFetch,
@@ -365,6 +670,12 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
RefreshButton,
};
const enabledComponentNames = new Set(enabledCustomJsxComponents.map(({ name }) => name));
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = Object.fromEntries(
Object.entries(RUNTIME_COMPONENTS).filter(([name]) => enabledComponentNames.has(name)),
);
function sanitizeData(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
@@ -379,60 +690,63 @@ function sanitizeData(obj: unknown): unknown {
}
const safeMath: Record<string, unknown> = Object.create(null);
safeMath.round = (v: number) => Math.round(v);
safeMath.floor = (v: number) => Math.floor(v);
safeMath.ceil = (v: number) => Math.ceil(v);
safeMath.abs = (v: number) => Math.abs(v);
safeMath.min = (...args: number[]) => Math.min(...args);
safeMath.max = (...args: number[]) => Math.max(...args);
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
safeMath.sqrt = (v: number) => Math.sqrt(v);
safeMath.round = createSafeCallable((v: number) => Math.round(v));
safeMath.floor = createSafeCallable((v: number) => Math.floor(v));
safeMath.ceil = createSafeCallable((v: number) => Math.ceil(v));
safeMath.abs = createSafeCallable((v: number) => Math.abs(v));
safeMath.min = createSafeCallable((...args: number[]) => Math.min(...args));
safeMath.max = createSafeCallable((...args: number[]) => Math.max(...args));
safeMath.pow = createSafeCallable((b: number, e: number) => Math.pow(b, e));
safeMath.sqrt = createSafeCallable((v: number) => Math.sqrt(v));
safeMath.PI = Math.PI;
Object.freeze(safeMath);
const safeJSON: Record<string, unknown> = Object.create(null);
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
safeJSON.stringify = createSafeCallable((v: unknown) => JSON.stringify(v));
Object.freeze(safeJSON);
const safeArray: Record<string, unknown> = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
safeArray.from = <T>(v: ArrayLike<T> | Iterable<T>) => Array.from(v);
safeArray.isArray = createSafeCallable((v: unknown) => Array.isArray(v));
safeArray.from = createSafeCallable((v: ArrayLike<unknown> | Iterable<unknown>) => Array.from(v).slice(0, 2_000));
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = Object.create(null);
safeObject.keys = (v: object) => Object.keys(v);
safeObject.values = (v: object) => Object.values(v);
safeObject.entries = (v: object) => Object.entries(v);
safeObject.keys = createSafeCallable((v: object) => Object.keys(v));
safeObject.values = createSafeCallable((v: object) => Object.values(v));
safeObject.entries = createSafeCallable((v: object) => Object.entries(v));
Object.freeze(safeObject);
const safeDate: Record<string, unknown> = Object.create(null);
safeDate.now = () => Date.now();
safeDate.create = (v?: string | number) => (v != null ? new Date(v) : new Date());
safeDate.toISOString = (v: string | number) => new Date(v).toISOString();
safeDate.toLocaleDateString = (v: string | number, locale?: string) =>
new Date(v).toLocaleDateString(locale ?? "en-US");
safeDate.toLocaleTimeString = (v: string | number, locale?: string) =>
new Date(v).toLocaleTimeString(locale ?? "en-US");
safeDate.getTime = (v: string | number) => new Date(v).getTime();
safeDate.getYear = (v: string | number) => new Date(v).getFullYear();
safeDate.getMonth = (v: string | number) => new Date(v).getMonth();
safeDate.getDay = (v: string | number) => new Date(v).getDate();
safeDate.now = createSafeCallable(() => Date.now());
// Date helpers return primitives only; native Date instances never enter the interpreter.
safeDate.create = createSafeCallable((v?: string | number) => (v != null ? new Date(v).getTime() : Date.now()));
safeDate.toISOString = createSafeCallable((v: string | number) => new Date(v).toISOString());
safeDate.toLocaleDateString = createSafeCallable((v: string | number, locale?: string) =>
new Date(v).toLocaleDateString(locale ?? "en-US"),
);
safeDate.toLocaleTimeString = createSafeCallable((v: string | number, locale?: string) =>
new Date(v).toLocaleTimeString(locale ?? "en-US"),
);
safeDate.getTime = createSafeCallable((v: string | number) => new Date(v).getTime());
safeDate.getYear = createSafeCallable((v: string | number) => new Date(v).getFullYear());
safeDate.getMonth = createSafeCallable((v: string | number) => new Date(v).getMonth());
safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getDay());
Object.freeze(safeDate);
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: (v: unknown) => String(v),
Number: (v: unknown) => Number(v),
Boolean: (v: unknown) => Boolean(v),
String: createSafeCallable((v: unknown) => String(v)),
Number: createSafeCallable((v: unknown) => Number(v)),
Boolean: createSafeCallable((v: unknown) => Boolean(v)),
Math: safeMath,
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
Date: safeDate,
parseInt: (v: string, radix?: number) => parseInt(v, radix),
parseFloat: (v: string) => parseFloat(v),
encodeURIComponent: (v: string) => encodeURIComponent(v),
decodeURIComponent: (v: string) => decodeURIComponent(v),
isNaN: (v: unknown) => Number.isNaN(Number(v)),
isFinite: (v: unknown) => Number.isFinite(Number(v)),
parseInt: createSafeCallable((v: string, radix?: number) => parseInt(v, radix)),
parseFloat: createSafeCallable((v: string) => parseFloat(v)),
encodeURIComponent: createSafeCallable((v: string) => encodeURIComponent(v)),
decodeURIComponent: createSafeCallable((v: string) => decodeURIComponent(v)),
isNaN: createSafeCallable((v: unknown) => Number.isNaN(Number(v))),
isFinite: createSafeCallable((v: unknown) => Number.isFinite(Number(v))),
});
@@ -0,0 +1,16 @@
const safeCallables = new WeakSet<(...args: never[]) => unknown>();
/**
* Marks a host function as callable by the Custom JSX interpreter.
*
* The interpreter never calls an arbitrary function obtained from data or a
* prototype. Host helpers must opt in through this module instead.
*/
export function createSafeCallable<T extends (...args: never[]) => unknown>(callable: T): T {
safeCallables.add(callable);
return callable;
}
export function isSafeCallable(value: unknown): value is (...args: never[]) => unknown {
return typeof value === "function" && safeCallables.has(value as (...args: never[]) => unknown);
}
@@ -0,0 +1,944 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, Fragment } from "react";
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { customJsxSupportedPropsByName } from "@homarr/definitions";
import { isSafeCallable } from "./safe-bindings";
interface AstNode {
type: string;
start?: number;
end?: number;
[key: string]: unknown;
}
interface InterpreterCallback {
readonly kind: typeof INTERPRETER_CALLBACK;
readonly params: readonly string[];
readonly body: AstNode;
readonly environment: Environment;
}
interface EvaluationBudgets {
maxAstDepth: number;
maxOperations: number;
maxCollectionItems: number;
maxRenderedNodes: number;
maxStringLength: number;
}
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
export interface RenderSafeJsxOptions {
template: string;
components: Readonly<Record<string, ComponentType<never>>>;
bindings: Readonly<Record<string, unknown>>;
budgets?: SafeJsxBudgets;
}
export interface SafeJsxRenderResult {
node: ReactNode;
warnings: string[];
}
const JsxParser = Parser.extend(jsx());
const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
const MAX_TEMPLATE_LENGTH = 50_000;
const DEFAULT_BUDGETS: EvaluationBudgets = {
maxAstDepth: 64,
maxOperations: 25_000,
maxCollectionItems: 2_000,
maxRenderedNodes: 10_000,
maxStringLength: 200_000,
};
const BLOCKED_PROPERTIES = new Set([
"__proto__",
"arguments",
"bind",
"call",
"callee",
"caller",
"constructor",
"prototype",
"apply",
]);
const BLOCKED_TAGS = new Set(["base", "embed", "form", "iframe", "link", "meta", "object", "script", "style"]);
const BLOCKED_PROPS = new Set([
"children",
"className",
"classNames",
"classes",
"component",
"dangerouslySetInnerHTML",
"innerRef",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"withinPortal",
]);
const URL_PROPS = new Set(["backgroundImage", "href", "src"]);
const BLOCKED_STYLE_KEYS = new Set([
"backdropFilter",
"behavior",
"bottom",
"clipPath",
"content",
"filter",
"inset",
"left",
"mask",
"pointerEvents",
"position",
"right",
"top",
"zIndex",
]);
export class SafeJsxError extends Error {
public constructor(message: string) {
super(message);
this.name = "SafeJsxError";
}
}
class Environment {
public constructor(
private readonly values: Readonly<Record<string, unknown>>,
private readonly parent?: Environment,
) {}
public get(name: string): unknown {
if (Object.hasOwn(this.values, name)) return this.values[name];
if (this.parent) return this.parent.get(name);
if (name === "undefined") return undefined;
if (name === "NaN") return Number.NaN;
if (name === "Infinity") return Number.POSITIVE_INFINITY;
throw new SafeJsxError(`Unknown binding: ${name}`);
}
}
class Budget {
private operations = 0;
private collectionItems = 0;
private renderedNodes = 0;
public constructor(private readonly limits: EvaluationBudgets) {}
public operation(depth: number): void {
if (depth > this.limits.maxAstDepth) {
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
}
this.operations += 1;
if (this.operations > this.limits.maxOperations) {
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
}
}
public collection(count = 1): void {
this.collectionItems += count;
if (this.collectionItems > this.limits.maxCollectionItems) {
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
}
}
public rendered(): void {
this.renderedNodes += 1;
if (this.renderedNodes > this.limits.maxRenderedNodes) {
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
}
}
public string(value: string): string {
if (value.length > this.limits.maxStringLength) {
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
}
return value;
}
}
function asNode(value: unknown, context: string): AstNode {
if (!value || typeof value !== "object" || typeof (value as { type?: unknown }).type !== "string") {
throw new SafeJsxError(`Invalid ${context} node`);
}
return value as AstNode;
}
function asNodeArray(value: unknown, context: string): AstNode[] {
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
return value.map((item) => asNode(item, context));
}
function normalizedProperty(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number") {
throw new SafeJsxError("Property names must be strings or numbers");
}
const property = String(value);
const normalized = property.normalize("NFKC").toLowerCase();
if (BLOCKED_PROPERTIES.has(normalized)) {
throw new SafeJsxError(`Access to reflective property '${property}' is not allowed`);
}
return property;
}
function isArrayIndex(property: string): boolean {
if (!/^(?:0|[1-9]\d*)$/.test(property)) return false;
const index = Number(property);
return Number.isSafeInteger(index) && index >= 0;
}
function ownProperty(object: unknown, propertyValue: unknown): unknown {
const property = normalizedProperty(propertyValue);
if (object == null) return undefined;
if (Array.isArray(object)) {
if (property === "length") return object.length;
if (!isArrayIndex(property)) return undefined;
return Object.hasOwn(object, property) ? object[Number(property)] : undefined;
}
if (typeof object === "string") {
if (property === "length") return object.length;
if (!isArrayIndex(property)) return undefined;
return object[Number(property)];
}
if (typeof object !== "object") return undefined;
return Object.hasOwn(object, property) ? (object as Record<string, unknown>)[property] : undefined;
}
function isSafeUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
if (value.startsWith("#")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const parsed = new URL(value);
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password;
} catch {
return false;
}
}
function sanitizeStyle(value: unknown): Record<string, string | number> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, string | number> = {};
for (const [key, styleValue] of Object.entries(value)) {
if (BLOCKED_STYLE_KEYS.has(key) || key.startsWith("--")) continue;
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
continue;
}
result[key] = styleValue;
}
return result;
}
function isInterpreterCallback(value: unknown): value is InterpreterCallback {
return Boolean(value && typeof value === "object" && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK);
}
export function sanitizeCustomJsxProps(
props: Readonly<Record<string, unknown>>,
componentName?: string,
): Record<string, unknown> {
const safe: Record<string, unknown> = {};
const supportedProps = componentName ? customJsxSupportedPropsByName.get(componentName) : undefined;
for (const [key, value] of Object.entries(props)) {
if ((/^on/i.test(key) && !supportedProps?.has(key)) || BLOCKED_PROPS.has(key)) continue;
if (componentName && !supportedProps?.has(key)) continue;
if (typeof value === "function" || isInterpreterCallback(value)) continue;
if (URL_PROPS.has(key)) {
if (isSafeUrl(value)) safe[key] = value;
continue;
}
if (key === "style") {
const style = sanitizeStyle(value);
if (style) safe.style = style;
continue;
}
safe[key] = value;
}
return safe;
}
function jsxText(value: string): string {
const lines = value.replace(/\r/g, "").split("\n");
let result = "";
for (let index = 0; index < lines.length; index += 1) {
let line = lines[index] ?? "";
line = line.replace(/\t/g, " ");
if (index !== 0) line = line.replace(/^\s+/, "");
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
if (!line) continue;
if (result) result += " ";
result += line;
}
return result;
}
function numericArgument(value: unknown, fallback: number): number {
if (value === undefined) return fallback;
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.trunc(number);
}
function normalizedSliceIndex(value: unknown, length: number, fallback: number): number {
const index = numericArgument(value, fallback);
if (index < 0) return Math.max(length + index, 0);
return Math.min(index, length);
}
class Interpreter {
private readonly budget: Budget;
private readonly warnings = new Set<string>();
public constructor(
private readonly components: Readonly<Record<string, ComponentType<never>>>,
private readonly environment: Environment,
private readonly limits: EvaluationBudgets,
) {
this.budget = new Budget(limits);
}
public render(root: AstNode): SafeJsxRenderResult {
return { node: this.evaluate(root, this.environment, 0) as ReactNode, warnings: [...this.warnings] };
}
private evaluate(node: AstNode, environment: Environment, depth: number): unknown {
this.budget.operation(depth);
switch (node.type) {
case "Literal":
return node.value;
case "Identifier":
return environment.get(String(node.name));
case "JSXElement":
return this.evaluateJsxElement(node, environment, depth + 1);
case "JSXFragment":
return this.evaluateJsxFragment(node, environment, depth + 1);
case "JSXText": {
const text = this.budget.string(jsxText(String(node.value ?? "")));
if (text) this.budget.rendered();
return text || null;
}
case "JSXExpressionContainer": {
const expression = asNode(node.expression, "JSX expression");
if (expression.type === "JSXEmptyExpression") return null;
const value = this.evaluate(expression, environment, depth + 1);
return typeof value === "string" ? this.budget.string(value) : value;
}
case "ArrayExpression":
return this.evaluateArray(node, environment, depth + 1);
case "ObjectExpression":
return this.evaluateObject(node, environment, depth + 1);
case "UnaryExpression":
return this.evaluateUnary(node, environment, depth + 1);
case "BinaryExpression":
return this.evaluateBinary(node, environment, depth + 1);
case "LogicalExpression":
return this.evaluateLogical(node, environment, depth + 1);
case "ConditionalExpression":
return this.evaluate(node.test as AstNode, environment, depth + 1)
? this.evaluate(asNode(node.consequent, "conditional consequent"), environment, depth + 1)
: this.evaluate(asNode(node.alternate, "conditional alternate"), environment, depth + 1);
case "MemberExpression":
return this.evaluateMember(node, environment, depth + 1);
case "CallExpression":
return this.evaluateCall(node, environment, depth + 1);
case "ArrowFunctionExpression":
return this.createCallback(node, environment);
case "TemplateLiteral":
return this.evaluateTemplateLiteral(node, environment, depth + 1);
case "ChainExpression":
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
default:
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
}
}
private evaluateJsxFragment(node: AstNode, environment: Environment, depth: number): ReactNode {
const children = asNodeArray(node.children, "fragment children").map((child) =>
this.evaluate(child, environment, depth + 1),
);
this.budget.rendered();
return createElement(Fragment, null, ...(children as ReactNode[]));
}
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
const opening = asNode(node.openingElement, "JSX opening element");
const tag = this.jsxTagName(asNode(opening.name, "JSX tag"));
if (BLOCKED_TAGS.has(tag.toLowerCase())) {
this.warnings.add(`Blocked element: ${tag}`);
return null;
}
const component = this.components[tag];
if (!component) {
this.warnings.add(`Unknown or unavailable component: ${tag}`);
return null;
}
const rawProps: Record<string, unknown> = {};
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
if (attribute.type === "JSXSpreadAttribute") {
const spread = this.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
Object.assign(rawProps, spread);
}
continue;
}
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
const nameNode = asNode(attribute.name, "attribute name");
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
const name = normalizedProperty(nameNode.name);
if (attribute.value == null) {
rawProps[name] = true;
} else {
const valueNode = asNode(attribute.value, "attribute value");
rawProps[name] =
valueNode.type === "Literal" ? valueNode.value : this.evaluate(valueNode, environment, depth + 1);
}
}
let children = asNodeArray(node.children, "JSX children").map((child) =>
this.evaluate(child, environment, depth + 1),
);
const props = sanitizeCustomJsxProps(rawProps, tag);
if (tag === "SubFetch") {
const callbackChildren = children.filter(isInterpreterCallback);
const callbackProp = rawProps.render;
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
}
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
throw new SafeJsxError("SubFetch accepts only one render callback");
}
const renderCallback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
if (renderCallback) {
props.render = (value: unknown, metadata: unknown) =>
new Interpreter(this.components, renderCallback.environment, this.limits).runCallback(
renderCallback,
[value, metadata],
0,
) as ReactNode;
children = children.filter((child) => child !== renderCallback);
}
}
this.budget.rendered();
return createElement(component, props as never, ...(children as ReactNode[]));
}
private jsxTagName(node: AstNode): string {
if (node.type === "JSXIdentifier") return String(node.name);
if (node.type === "JSXMemberExpression") {
return `${this.jsxTagName(asNode(node.object, "JSX member object"))}.${this.jsxTagName(
asNode(node.property, "JSX member property"),
)}`;
}
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
}
private evaluateArray(node: AstNode, environment: Environment, depth: number): unknown[] {
const result: unknown[] = [];
const elements = node.elements;
if (!Array.isArray(elements)) throw new SafeJsxError("Invalid array expression");
for (const elementValue of elements) {
if (elementValue == null) {
result.push(undefined);
continue;
}
const element = asNode(elementValue, "array element");
if (element.type === "SpreadElement") {
const spread = this.evaluate(asNode(element.argument, "array spread"), environment, depth + 1);
if (!Array.isArray(spread)) throw new SafeJsxError("Only arrays can be spread into arrays");
this.budget.collection(spread.length);
result.push(...spread);
} else {
this.budget.collection();
result.push(this.evaluate(element, environment, depth + 1));
}
}
return result;
}
private evaluateObject(node: AstNode, environment: Environment, depth: number): Record<string, unknown> {
const result: Record<string, unknown> = Object.create(null);
for (const property of asNodeArray(node.properties, "object properties")) {
if (property.type === "SpreadElement") {
const spread = this.evaluate(asNode(property.argument, "object spread"), environment, depth + 1);
if (!spread || typeof spread !== "object" || Array.isArray(spread)) {
throw new SafeJsxError("Only objects can be spread into objects");
}
for (const [key, value] of Object.entries(spread)) result[normalizedProperty(key)] = value;
continue;
}
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
throw new SafeJsxError("Only explicit object properties are supported");
}
const keyNode = asNode(property.key, "object key");
const key = normalizedProperty(
property.computed
? this.evaluate(keyNode, environment, depth + 1)
: keyNode.type === "Identifier"
? keyNode.name
: keyNode.value,
);
result[key] = this.evaluate(asNode(property.value, "object value"), environment, depth + 1);
}
return result;
}
private evaluateUnary(node: AstNode, environment: Environment, depth: number): unknown {
const value = this.evaluate(asNode(node.argument, "unary argument"), environment, depth + 1);
switch (node.operator) {
case "!":
return !value;
case "+":
return Number(value);
case "-":
return -Number(value);
case "typeof":
return typeof value;
default:
throw new SafeJsxError(`Unsupported unary operator: ${String(node.operator)}`);
}
}
private evaluateBinary(node: AstNode, environment: Environment, depth: number): unknown {
const left = this.evaluate(asNode(node.left, "binary left operand"), environment, depth + 1);
const right = this.evaluate(asNode(node.right, "binary right operand"), environment, depth + 1);
switch (node.operator) {
case "+":
return typeof left === "string" || typeof right === "string"
? this.budget.string(String(left) + String(right))
: Number(left) + Number(right);
case "-":
return Number(left) - Number(right);
case "*":
return Number(left) * Number(right);
case "/":
return Number(left) / Number(right);
case "%":
return Number(left) % Number(right);
case "**":
return Number(left) ** Number(right);
case "<":
return (left as number) < (right as number);
case "<=":
return (left as number) <= (right as number);
case ">":
return (left as number) > (right as number);
case ">=":
return (left as number) >= (right as number);
case "==":
return String(left) === String(right);
case "!=":
return String(left) !== String(right);
case "===":
return left === right;
case "!==":
return left !== right;
default:
throw new SafeJsxError(`Unsupported binary operator: ${String(node.operator)}`);
}
}
private evaluateLogical(node: AstNode, environment: Environment, depth: number): unknown {
const left = this.evaluate(asNode(node.left, "logical left operand"), environment, depth + 1);
if (node.operator === "&&") {
return left ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
}
if (node.operator === "||") {
return left ? left : this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1);
}
if (node.operator === "??") {
return left == null ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
}
throw new SafeJsxError(`Unsupported logical operator: ${String(node.operator)}`);
}
private memberProperty(node: AstNode, environment: Environment, depth: number): string {
const propertyNode = asNode(node.property, "member property");
return normalizedProperty(
node.computed
? this.evaluate(propertyNode, environment, depth + 1)
: propertyNode.type === "Identifier"
? propertyNode.name
: propertyNode.value,
);
}
private evaluateMember(node: AstNode, environment: Environment, depth: number): unknown {
const object = this.evaluate(asNode(node.object, "member object"), environment, depth + 1);
const property = this.memberProperty(node, environment, depth + 1);
return ownProperty(object, property);
}
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
return this.evaluate(argument, environment, depth + 1);
});
const callee = asNode(node.callee, "call target");
if (callee.type === "MemberExpression") {
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
const method = this.memberProperty(callee, environment, depth + 1);
const collectionResult = this.callCollectionMethod(receiver, method, args, depth + 1);
if (collectionResult.handled) return collectionResult.value;
const callable = ownProperty(receiver, method);
if (!isSafeCallable(callable)) throw new SafeJsxError(`Calling method '${method}' is not allowed`);
return this.accountHostResult(callable(...(args as never[])));
}
const callable = this.evaluate(callee, environment, depth + 1);
if (!isSafeCallable(callable)) throw new SafeJsxError("Calling this value is not allowed");
return this.accountHostResult(callable(...(args as never[])));
}
private callCollectionMethod(
receiver: unknown,
method: string,
args: unknown[],
depth: number,
): { handled: boolean; value?: unknown } {
if (Array.isArray(receiver)) {
if (method === "map" || method === "filter") {
const callback = args[0];
if (isSafeCallable(callback)) {
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
this.budget.collection();
const callbackResult = callback(receiver[index] as never);
if (method === "map") result.push(callbackResult);
else if (callbackResult) result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (!isInterpreterCallback(callback)) throw new SafeJsxError(`${method} requires an inline arrow callback`);
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
this.budget.collection();
const callbackResult = this.runCallback(callback, [receiver[index], index, receiver], depth + 1);
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
}
return { handled: true, value: result };
}
if (method === "pop") {
this.budget.collection();
return { handled: true, value: receiver.length > 0 ? receiver[receiver.length - 1] : undefined };
}
if (method === "slice") {
const start = normalizedSliceIndex(args[0], receiver.length, 0);
const end = normalizedSliceIndex(args[1], receiver.length, receiver.length);
const result: unknown[] = [];
for (let index = start; index < Math.max(start, end); index += 1) {
this.budget.collection();
result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (method === "join") {
this.budget.collection(receiver.length);
const separator = String(args[0] ?? ",");
let result = "";
receiver.forEach((item, index) => {
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
this.budget.string(result);
});
return { handled: true, value: result };
}
if (method === "includes") {
this.budget.collection(receiver.length);
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
}
if (method === "indexOf") {
this.budget.collection(receiver.length);
return { handled: true, value: receiver.findIndex((item) => Object.is(item, args[0])) };
}
if (method === "find" || method === "findIndex" || method === "some" || method === "every") {
const callback = args[0];
const useSafe = isSafeCallable(callback);
if (!useSafe && !isInterpreterCallback(callback))
throw new SafeJsxError(`${method} requires an inline arrow callback`);
for (let index = 0; index < receiver.length; index += 1) {
this.budget.collection();
const result = useSafe
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
: this.runCallback(callback as InterpreterCallback, [receiver[index], index, receiver], depth + 1);
if (method === "find" && result) return { handled: true, value: receiver[index] };
if (method === "findIndex" && result) return { handled: true, value: index };
if (method === "some" && result) return { handled: true, value: true };
if (method === "every" && !result) return { handled: true, value: false };
}
const defaults: Record<string, unknown> = { find: undefined, findIndex: -1, some: false, every: true };
return { handled: true, value: defaults[method] };
}
if (method === "sort") {
const callback = args[0];
if (!isInterpreterCallback(callback)) throw new SafeJsxError("sort requires an inline arrow callback");
const copy = [...receiver];
copy.sort((left, right) => {
this.budget.collection();
const delta = Number(this.runCallback(callback, [left, right], depth + 1));
if (!Number.isFinite(delta)) return 0;
return delta;
});
return { handled: true, value: copy };
}
if (method === "reduce") {
const callback = args[0];
if (!isInterpreterCallback(callback)) throw new SafeJsxError("reduce requires an inline arrow callback");
if (receiver.length === 0 && args.length < 2) {
throw new SafeJsxError("Reduce of empty array with no initial value");
}
let startIndex = 0;
let accumulator: unknown;
if (args.length < 2) {
accumulator = receiver[0];
startIndex = 1;
} else {
accumulator = args[1];
}
for (let index = startIndex; index < receiver.length; index += 1) {
this.budget.collection();
accumulator = this.runCallback(callback, [accumulator, receiver[index], index, receiver], depth + 1);
}
return { handled: true, value: accumulator };
}
if (method === "at") {
const index = numericArgument(args[0], 0);
let normalizedIndex = index;
if (index < 0) normalizedIndex = receiver.length + index;
this.budget.collection();
const outOfBounds = normalizedIndex < 0 || normalizedIndex >= receiver.length;
return { handled: true, value: outOfBounds ? undefined : receiver[normalizedIndex] };
}
if (method === "flat") {
// ponytail: only depth=1 supported; deeper nesting requires recursive budget tracking
const depth = numericArgument(args[0], 1);
if (depth !== 1) throw new SafeJsxError("flat() only supports depth 1");
const result: unknown[] = [];
for (const item of receiver) {
this.budget.collection();
if (Array.isArray(item)) {
for (const nested of item) {
this.budget.collection();
result.push(nested);
}
} else {
result.push(item);
}
}
return { handled: true, value: result };
}
if (method === "reverse") {
const copy = [...receiver];
const result: unknown[] = [];
for (let index = copy.length - 1; index >= 0; index -= 1) {
this.budget.collection();
result.push(copy[index]);
}
return { handled: true, value: result };
}
}
if (typeof receiver === "string") {
switch (method) {
case "toUpperCase":
return { handled: true, value: this.budget.string(receiver.toUpperCase()) };
case "toLowerCase":
return { handled: true, value: this.budget.string(receiver.toLowerCase()) };
case "trim":
return { handled: true, value: this.budget.string(receiver.trim()) };
case "trimStart":
return { handled: true, value: this.budget.string(receiver.trimStart()) };
case "trimEnd":
return { handled: true, value: this.budget.string(receiver.trimEnd()) };
case "replace": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
const matchIndex = receiver.indexOf(search);
if (matchIndex === -1) return { handled: true, value: this.budget.string(receiver) };
return {
handled: true,
value: this.budget.string(
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
),
};
}
case "replaceAll": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
if (search.length === 0) return { handled: true, value: this.budget.string(receiver) };
let result = "";
let cursor = 0;
while (cursor < receiver.length) {
const matchIndex = receiver.indexOf(search, cursor);
if (matchIndex === -1) {
result += receiver.slice(cursor);
break;
}
result += receiver.slice(cursor, matchIndex) + replacement;
cursor = matchIndex + search.length;
}
return { handled: true, value: this.budget.string(result) };
}
case "indexOf":
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "lastIndexOf": {
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
}
case "repeat": {
let maxCount = 0;
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
return { handled: true, value: this.budget.string(receiver.repeat(count).slice(0, 200_001)) };
}
case "slice":
return {
handled: true,
value: this.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "substring":
return {
handled: true,
value: this.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "includes":
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "startsWith":
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "endsWith":
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
case "padStart":
return {
handled: true,
value: this.budget.string(
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "padEnd":
return {
handled: true,
value: this.budget.string(
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "charAt":
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
case "split": {
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 100)));
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
this.budget.collection(split.length);
return { handled: true, value: split };
}
}
}
if (typeof receiver === "number") {
if (method === "toFixed") {
const digits = Math.max(0, Math.min(20, numericArgument(args[0], 0)));
return { handled: true, value: receiver.toFixed(digits) };
}
if (method === "toPrecision") {
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
return { handled: true, value: receiver.toPrecision(precision) };
}
}
if (
(typeof receiver === "number" || typeof receiver === "boolean" || typeof receiver === "string") &&
method === "toString"
) {
return { handled: true, value: this.budget.string(String(receiver)) };
}
return { handled: false };
}
private accountHostResult(value: unknown): unknown {
if (typeof value === "string") return this.budget.string(value);
if (Array.isArray(value)) this.budget.collection(value.length);
return value;
}
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
if (node.async || node.generator || node.expression === false) {
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
}
const params = asNodeArray(node.params, "callback parameters").map((param) => {
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
return normalizedProperty(param.name);
});
return {
kind: INTERPRETER_CALLBACK,
params,
body: asNode(node.body, "callback body"),
environment,
};
}
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
const values: Record<string, unknown> = Object.create(null);
callback.params.forEach((name, index) => {
values[name] = args[index];
});
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
}
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
const quasis = asNodeArray(node.quasis, "template literal parts");
const expressions = asNodeArray(node.expressions, "template literal expressions");
let result = "";
quasis.forEach((quasi, index) => {
const value = quasi.value;
if (!value || typeof value !== "object") throw new SafeJsxError("Invalid template literal part");
result += String((value as { cooked?: unknown }).cooked ?? "");
const expression = expressions[index];
if (expression) result += String(this.evaluate(expression, environment, depth + 1));
});
return this.budget.string(result);
}
}
function parseTemplate(template: string): AstNode {
if (template.length > MAX_TEMPLATE_LENGTH) {
throw new SafeJsxError(`Template exceeds the ${MAX_TEMPLATE_LENGTH} character limit`);
}
let program: AstNode;
try {
program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
allowAwaitOutsideFunction: false,
allowReturnOutsideFunction: false,
}) as unknown as AstNode;
} catch (error) {
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
}
const body = asNodeArray(program.body, "program body");
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
throw new SafeJsxError("Template must contain JSX only");
}
return asNode(body[0].expression, "template expression");
}
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
}
const root = parseTemplate(template);
return new Interpreter(components, new Environment(bindings), limits).render(root);
}
@@ -3,12 +3,49 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
const WidgetDefinitionContext = createContext<string>("");
export interface CustomJsxRuntimeContextValue {
/** The placed board item used for all authorization decisions. */
itemId?: string;
/** Kept temporarily for display-only v1 templates. Never used for network authorization. */
definitionId?: string;
/** Short-lived, user-bound session used only by the admin authoring preview. */
previewSessionId?: string;
previewLiveActions?: boolean;
isEditMode: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
}
export function WidgetDefinitionProvider({ definitionId, children }: { definitionId: string; children: ReactNode }) {
return <WidgetDefinitionContext.Provider value={definitionId}>{children}</WidgetDefinitionContext.Provider>;
export interface CustomJsxRequestCapability {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
minimumBoardPermission: "view" | "modify" | "full";
}
const CustomJsxRuntimeContext = createContext<CustomJsxRuntimeContextValue>({ isEditMode: false });
export function WidgetDefinitionProvider({
itemId,
definitionId,
previewSessionId,
previewLiveActions = false,
isEditMode = false,
requestCapabilities = [],
children,
}: CustomJsxRuntimeContextValue & { children: ReactNode }) {
return (
<CustomJsxRuntimeContext.Provider
value={{ itemId, definitionId, previewSessionId, previewLiveActions, isEditMode, requestCapabilities }}
>
{children}
</CustomJsxRuntimeContext.Provider>
);
}
export function useWidgetDefinitionId(): string {
return useContext(WidgetDefinitionContext);
return useContext(CustomJsxRuntimeContext).definitionId ?? "";
}
export function useCustomJsxRuntime(): CustomJsxRuntimeContextValue {
return useContext(CustomJsxRuntimeContext);
}