Files
homarr/packages/custom-widgets/src/jsx/bindings.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

165 lines
6.8 KiB
TypeScript

import { createSafeCallable } from "./safe-bindings";
import { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export const CUSTOM_JSX_DATA_LIMITS = Object.freeze({
depth: 32,
nodes: 50_000,
stringLength: 200_000,
});
interface SanitizeTask {
value: unknown;
depth: number;
assign(value: unknown): void;
}
function sanitizeData(value: unknown): unknown {
let result: unknown;
let nodes = 0;
const seen = new WeakSet<object>();
const tasks: SanitizeTask[] = [{ value, depth: 0, assign: (sanitized) => (result = sanitized) }];
while (tasks.length > 0) {
const task = tasks.pop();
if (!task) break;
if (++nodes > CUSTOM_JSX_DATA_LIMITS.nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
if (task.depth > CUSTOM_JSX_DATA_LIMITS.depth) {
throw new SafeJsxBudgetError(`Response data exceeded the depth limit (${CUSTOM_JSX_DATA_LIMITS.depth})`);
}
if (typeof task.value === "string" && task.value.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
if (task.value === null || typeof task.value !== "object") {
task.assign(task.value);
continue;
}
if (seen.has(task.value)) throw new SafeJsxError("Response data must not contain circular or repeated objects");
seen.add(task.value);
if (Array.isArray(task.value)) {
if (task.value.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe = Array.from<unknown>({ length: task.value.length });
task.assign(safe);
for (let index = task.value.length - 1; index >= 0; index -= 1) {
if (!Object.hasOwn(task.value, index)) continue;
tasks.push({
value: task.value[index],
depth: task.depth + 1,
assign: (sanitized) => (safe[index] = sanitized),
});
}
continue;
}
const entries = Object.entries(task.value);
if (entries.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe: Record<string, unknown> = Object.create(null);
task.assign(safe);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index] as [string, unknown];
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
if (key.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
tasks.push({
value: child,
depth: task.depth + 1,
assign: (sanitized) => (safe[key] = sanitized),
});
}
}
return result;
}
const safeMath = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
round: createSafeCallable((value: number) => Math.round(value)),
floor: createSafeCallable((value: number) => Math.floor(value)),
ceil: createSafeCallable((value: number) => Math.ceil(value)),
abs: createSafeCallable((value: number) => Math.abs(value)),
min: createSafeCallable((...values: number[]) => Math.min(...values)),
max: createSafeCallable((...values: number[]) => Math.max(...values)),
pow: createSafeCallable((base: number, exponent: number) => Math.pow(base, exponent)),
sqrt: createSafeCallable((value: number) => Math.sqrt(value)),
PI: Math.PI,
}),
);
const formatLocaleDateTime = (value: string | number, locale = "en-US", timeZone?: string) => {
const options: Intl.DateTimeFormatOptions = { dateStyle: "medium", timeStyle: "short" };
if (timeZone) options.timeZone = timeZone;
return new Intl.DateTimeFormat(locale, options).format(new Date(value));
};
const safeDate = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
now: createSafeCallable(() => Date.now()),
create: createSafeCallable((value?: string | number) =>
value === undefined ? Date.now() : new Date(value).getTime(),
),
toISOString: createSafeCallable((value: string | number) => new Date(value).toISOString()),
toLocaleDateString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleDateString(locale ?? "en-US"),
),
toLocaleTimeString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleTimeString(locale ?? "en-US"),
),
toLocaleString: createSafeCallable(formatLocaleDateTime),
getTime: createSafeCallable((value: string | number) => new Date(value).getTime()),
getYear: createSafeCallable((value: string | number) => new Date(value).getFullYear()),
getMonth: createSafeCallable((value: string | number) => new Date(value).getMonth()),
getDay: createSafeCallable((value: string | number) => new Date(value).getUTCDay()),
}),
);
export function createCustomJsxBindings(apiData: unknown) {
const safeJson = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
stringify: createSafeCallable((value: unknown, _replacer?: null, space?: string | number) =>
JSON.stringify(value, null, space),
),
}),
);
const safeArray = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
isArray: createSafeCallable(Array.isArray),
from: createSafeCallable((value: ArrayLike<unknown> | Iterable<unknown>) => Array.from(value).slice(0, 2_000)),
}),
);
const safeObject = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
keys: createSafeCallable(Object.keys),
values: createSafeCallable(Object.values),
entries: createSafeCallable(Object.entries),
}),
);
return {
data: sanitizeData(apiData),
String: createSafeCallable((value: unknown) => String(value)),
Number: createSafeCallable((value: unknown) => Number(value)),
Boolean: createSafeCallable((value: unknown) => Boolean(value)),
Math: safeMath,
JSON: safeJson,
Array: safeArray,
Object: safeObject,
Date: safeDate,
parseInt: createSafeCallable((value: string, radix?: number) => parseInt(value, radix)),
parseFloat: createSafeCallable((value: string) => parseFloat(value)),
encodeURIComponent: createSafeCallable((value: string) => encodeURIComponent(value)),
decodeURIComponent: createSafeCallable((value: string) => decodeURIComponent(value)),
isNaN: createSafeCallable((value: unknown) => Number.isNaN(Number(value))),
isFinite: createSafeCallable((value: unknown) => Number.isFinite(Number(value))),
};
}