feat(custom-widgets): add secure v2 authoring
This commit is contained in:
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(
|
||||
() =>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user