import type { ComponentType, ReactNode } from "react"; import { createElement, useEffect, useRef, useState } from "react"; import { Accordion, ActionIcon, Alert, Anchor, AspectRatio, Avatar, BackgroundImage, Badge, Blockquote, Box, Breadcrumbs, Burger, Button, Card, Center, Chip, CloseButton, Code, Collapse, ColorSwatch, Container, DataList, Divider, EmptyState, Fieldset, Flex, FloatingIndicator, Grid, Group, Highlight, HoverCard, Image, Indicator, Kbd, List, Loader, LoadingOverlay, Mark, Marquee, Menu, NavLink, Notification, NumberFormatter, Overlay, Pagination, Paper, Pill, Progress, Rating, RingProgress, RollingNumber, ScrollArea, SegmentedControl, SemiCircleProgress, SimpleGrid, Skeleton, Slider, Space, Spoiler, Stack, Stepper, Switch, Table, Tabs, Text, ThemeIcon, Timeline, Title, Tooltip, Transition, Tree, Typography, VisuallyHidden, } from "@mantine/core"; import { AreaChart, BarChart, BarsList, BubbleChart, ChartLegend, ChartTooltip, CompositeChart, DonutChart, FunnelChart, Heatmap, LineChart, PieChart, RadarChart, RadialBarChart, SankeyChart, ScatterChart, Sparkline, Treemap, } from "@mantine/charts"; 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; function isSafeUrl(url: unknown): boolean { if (typeof url !== "string") return false; return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#"); } function SafeAnchor(props: Record) { const href = props.href; const safeHref = href && isSafeUrl(href) ? href : undefined; const sanitized = sanitizeCustomJsxProps(props); const opensNewWindow = sanitized.target === "_blank"; return createElement(Anchor as never, { ...sanitized, href: safeHref, rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel, }); } function SafeNavLink(props: Record) { const href = props.href; const safeHref = href && isSafeUrl(href) ? href : undefined; const sanitized = sanitizeCustomJsxProps(props); const opensNewWindow = sanitized.target === "_blank"; return createElement(NavLink as never, { ...sanitized, href: safeHref, rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel, }); } function SafeButton(props: Record) { return createElement(Button as never, sanitizeCustomJsxProps(props)); } function SafeActionIcon(props: Record) { return createElement(ActionIcon as never, sanitizeCustomJsxProps(props)); } function SafeBurger(props: Record) { return createElement(Burger as never, sanitizeCustomJsxProps(props)); } function SafeCloseButton(props: Record) { return createElement(CloseButton as never, sanitizeCustomJsxProps(props)); } function SafeChip(props: Record) { return createElement(Chip as never, sanitizeCustomJsxProps(props)); } function SafeNotification(props: Record) { return createElement(Notification as never, sanitizeCustomJsxProps(props)); } function SafeRating(props: Record) { return createElement(Rating as never, { ...sanitizeCustomJsxProps(props), readOnly: true }); } function SafeSlider(props: Record) { return createElement(Slider as never, { ...sanitizeCustomJsxProps(props), readOnly: true }); } function SafeSwitch(props: Record) { return createElement(Switch as never, { ...sanitizeCustomJsxProps(props), readOnly: true }); } function SafeSegmentedControl(props: Record) { return createElement(SegmentedControl as never, { ...sanitizeCustomJsxProps(props), readOnly: true }); } function SafePagination(props: Record) { return createElement(Pagination as never, { ...sanitizeCustomJsxProps(props), disabled: true }); } function SafeStepper(props: Record) { return createElement(Stepper as never, sanitizeCustomJsxProps(props)); } function SafeCalendar(props: Record) { return createElement(Calendar as never, { ...sanitizeCustomJsxProps(props), static: true }); } function SafeMiniCalendar(props: Record) { return createElement(MiniCalendar as never, { ...sanitizeCustomJsxProps(props), style: { pointerEvents: "none" }, previousControlProps: { disabled: true, tabIndex: -1 }, nextControlProps: { disabled: true, tabIndex: -1 }, getDayProps: () => ({ disabled: true, tabIndex: -1 }), }); } function SafeTree(props: Record) { return createElement(Tree as never, sanitizeCustomJsxProps(props)); } function SafeDatePicker(props: Record) { return createElement(DatePicker as never, { ...sanitizeCustomJsxProps(props), style: { pointerEvents: "none" }, enableKeyboardNavigation: false, getDayProps: () => ({ disabled: true, tabIndex: -1 }), }); } function createSafeDateComponent(component: ComponentType) { return function SafeDateComponent(props: Record) { 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> = { ...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) { const name = props.name; if (typeof name !== "string") return null; const IconComponent = TABLER_ICON_MAP[name]; if (!IconComponent) return null; const iconProps: Record = {}; 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 resetTimer = useRef>(undefined); useEffect(() => () => clearTimeout(resetTimer.current), []); const handleCopy = async () => { if (!value) return; try { await navigator.clipboard.writeText(value); setCopied(true); clearTimeout(resetTimer.current); resetTimer.current = setTimeout(() => setCopied(false), 2000); } catch { setCopied(false); } }; 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, ); } const RUNTIME_COMPONENTS: Record> = { // Layout Box, Stack, Group, Flex, Grid, "Grid.Col": Grid.Col, SimpleGrid, Center, Space, Container, AspectRatio, Overlay, ScrollArea, VisuallyHidden, Typography, FloatingIndicator, // Typography & text Text, Title, Code, Highlight, Mark, Kbd, Blockquote, Anchor: SafeAnchor, NumberFormatter, Marquee, RollingNumber, // Data display Badge, Card, "Card.Section": Card.Section, Paper, Alert, ThemeIcon, ColorSwatch, Table, "Table.Thead": Table.Thead, "Table.Tbody": Table.Tbody, "Table.Tfoot": Table.Tfoot, "Table.Caption": Table.Caption, "Table.Tr": Table.Tr, "Table.Th": Table.Th, "Table.Td": Table.Td, List, "List.Item": List.Item, Timeline, "Timeline.Item": Timeline.Item, Accordion, "Accordion.Item": Accordion.Item, "Accordion.Control": Accordion.Control, "Accordion.Panel": Accordion.Panel, Indicator, Pill, Spoiler, Progress, "Progress.Section": Progress.Section, RingProgress, SemiCircleProgress, Skeleton, Loader, Image, Avatar, "Avatar.Group": Avatar.Group, BackgroundImage, Tooltip, Divider, DataList, "DataList.Item": DataList.Item, "DataList.ItemLabel": DataList.ItemLabel, "DataList.ItemValue": DataList.ItemValue, EmptyState, "EmptyState.Indicator": EmptyState.Indicator, "EmptyState.Title": EmptyState.Title, "EmptyState.Description": EmptyState.Description, "EmptyState.Actions": EmptyState.Actions, Fieldset, Notification: SafeNotification, LoadingOverlay, Rating: SafeRating, // Navigation & structure Breadcrumbs, NavLink: SafeNavLink, Stepper: SafeStepper, "Stepper.Step": Stepper.Step, Tabs, "Tabs.List": Tabs.List, "Tabs.Tab": Tabs.Tab, "Tabs.Panel": Tabs.Panel, Tree: SafeTree, // Interactive display (handlers stripped — wired via SubFetch separately) Button: SafeButton, ActionIcon: SafeActionIcon, Burger: SafeBurger, CloseButton: SafeCloseButton, Chip: SafeChip, "Chip.Group": Chip.Group, Pagination: SafePagination, SegmentedControl: SafeSegmentedControl, Slider: SafeSlider, Switch: SafeSwitch, // Hover overlays & menus HoverCard, "HoverCard.Target": HoverCard.Target, "HoverCard.Dropdown": HoverCard.Dropdown, Menu, "Menu.Target": Menu.Target, "Menu.Dropdown": Menu.Dropdown, "Menu.Item": Menu.Item, "Menu.Label": Menu.Label, "Menu.Divider": Menu.Divider, // Utility CopyButton: SafeCopyButton, Collapse, Transition, // Charts AreaChart, BarChart, LineChart, DonutChart, PieChart, RadarChart, RadialBarChart, Sparkline, BubbleChart, CompositeChart, FunnelChart, Heatmap, ScatterChart, 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) PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge, TablerIcon: SafeTablerIcon, // SubFetch — server-proxied HTTP from within templates SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton, }; const enabledComponentNames = new Set(enabledCustomJsxComponents.map(({ name }) => name)); export const WHITELISTED_COMPONENTS: Record> = 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; if (Array.isArray(obj)) return obj.map(sanitizeData); const safe: Record = Object.create(null); for (const [key, value] of Object.entries(obj as Record)) { if (key === "constructor" || key === "__proto__" || key === "prototype") continue; safe[key] = sanitizeData(value); } return safe; } const safeMath: Record = Object.create(null); 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 = Object.create(null); safeJSON.stringify = createSafeCallable((v: unknown) => JSON.stringify(v)); Object.freeze(safeJSON); const safeArray: Record = Object.create(null); safeArray.isArray = createSafeCallable((v: unknown) => Array.isArray(v)); safeArray.from = createSafeCallable((v: ArrayLike | Iterable) => Array.from(v).slice(0, 2_000)); Object.freeze(safeArray); const safeObject: Record = Object.create(null); 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 = Object.create(null); 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).getUTCDay()); Object.freeze(safeDate); export const SAFE_BINDINGS = (apiData: unknown) => ({ data: sanitizeData(apiData), 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: 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))), });