feat: integrate XSLT Tools with toolbox portal

This commit is contained in:
2026-07-20 18:16:18 +02:00
parent 3193cc395c
commit 8f8a59e76c
36 changed files with 1737 additions and 243 deletions

View File

@@ -1,6 +1,8 @@
export async function createSha256Hash(text: string): Promise<string> {
if (!window.crypto?.subtle) {
return createFallbackHash(text);
throw new Error(
'SHA-256 is unavailable. Open this app in a secure browser context before trusting or running a stylesheet.'
);
}
const data = new TextEncoder().encode(text);
@@ -8,13 +10,3 @@ export async function createSha256Hash(text: string): Promise<string> {
const bytes = Array.from(new Uint8Array(digest));
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function createFallbackHash(text: string): string {
let hash = 0;
for (let index = 0; index < text.length; index += 1) {
hash = (hash << 5) - hash + text.charCodeAt(index);
hash |= 0;
}
return `fallback-${Math.abs(hash).toString(16)}`;
}

View File

@@ -9,10 +9,16 @@ type SaxonXdmMap = {
inSituPut: (key: unknown, value: unknown[]) => void;
};
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
let saxonLoadPromise: Promise<void> | null = null;
export function resolveSaxonScriptUrl(
baseUrl = import.meta.env.BASE_URL,
pageUrl = window.location.href
): string {
const appBase = new URL(baseUrl, pageUrl);
return new URL('vendor/saxon/SaxonJS2.js', appBase).href;
}
type SaxonPrivateRuntime = {
getPlatform?: () => {
resource?: (name: string) => unknown;
@@ -60,8 +66,9 @@ async function ensureSaxonLoaded(): Promise<void> {
if (window.SaxonJS) return;
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
const saxonScriptUrl = resolveSaxonScriptUrl();
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${SAXON_SCRIPT_URL}"]`
`script[src="${saxonScriptUrl}"]`
);
if (existingScript) {
@@ -75,7 +82,7 @@ async function ensureSaxonLoaded(): Promise<void> {
}
const script = document.createElement('script');
script.src = SAXON_SCRIPT_URL;
script.src = saxonScriptUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));

View File

@@ -0,0 +1,16 @@
export interface TrustedStylesheet {
hash: string;
revision: number;
}
export function isStylesheetExecutionTrusted(
trust: TrustedStylesheet | null,
currentHash: string,
currentRevision: number
): boolean {
return (
trust !== null &&
trust.hash === currentHash &&
trust.revision === currentRevision
);
}

View File

@@ -0,0 +1,65 @@
import type { TransformRequest, TransformResult } from './transformTypes';
export const TRANSFORM_LIMITS = {
maxXmlBytes: 5 * 1024 * 1024,
maxXsltBytes: 2 * 1024 * 1024,
maxOutputBytes: 10 * 1024 * 1024,
timeoutMs: 20_000,
} as const;
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
export function assertTransformInputWithinLimits(
request: TransformRequest
): void {
const xmlBytes = utf8Bytes(request.xmlText);
if (xmlBytes > TRANSFORM_LIMITS.maxXmlBytes) {
throw new Error(
`XML input exceeds the ${TRANSFORM_LIMITS.maxXmlBytes}-byte execution limit.`
);
}
const xsltBytes = utf8Bytes(request.xsltText);
if (xsltBytes > TRANSFORM_LIMITS.maxXsltBytes) {
throw new Error(
`XSLT stylesheet exceeds the ${TRANSFORM_LIMITS.maxXsltBytes}-byte execution limit.`
);
}
}
export function assertTransformOutputWithinLimits(
result: TransformResult
): void {
if (utf8Bytes(result.output) > TRANSFORM_LIMITS.maxOutputBytes) {
throw new Error(
`Transformation output exceeds the ${TRANSFORM_LIMITS.maxOutputBytes}-byte execution limit.`
);
}
}
export async function withTransformationTimeout<T>(
operation: () => Promise<T>,
timeoutMs: number = TRANSFORM_LIMITS.timeoutMs
): Promise<T> {
let timer: number | undefined;
try {
return await Promise.race([
Promise.resolve().then(operation),
new Promise<never>((_resolve, reject) => {
timer = window.setTimeout(
() =>
reject(
new Error(
`Transformation exceeded the ${timeoutMs}-millisecond execution limit.`
)
),
timeoutMs
);
}),
]);
} finally {
if (timer !== undefined) window.clearTimeout(timer);
}
}

View File

@@ -7,6 +7,21 @@ import type {
TransformRequest,
TransformResult,
} from './transformTypes';
import {
assertTransformInputWithinLimits,
assertTransformOutputWithinLimits,
withTransformationTimeout,
} from './transformLimits';
import { createSha256Hash } from './hash';
import {
isStylesheetExecutionTrusted,
type TrustedStylesheet,
} from './stylesheetTrust';
export interface TransformationAuthorization {
trustedStylesheet: TrustedStylesheet;
currentRevision: number;
}
const engines: Record<TransformEngineId, TransformEngine> = {
'saxon-js-dynamic': saxonJsDynamicEngine,
@@ -18,10 +33,30 @@ export function getTransformEngine(id: TransformEngineId): TransformEngine {
}
export async function runTransformation(
request: TransformRequest
request: TransformRequest,
authorization: TransformationAuthorization
): Promise<TransformResult> {
const stylesheetHash = await createSha256Hash(request.xsltText);
if (
!authorization ||
!isStylesheetExecutionTrusted(
authorization.trustedStylesheet,
stylesheetHash,
authorization.currentRevision
)
) {
throw new Error(
'Transformation refused: review and trust this exact stylesheet first.'
);
}
assertTransformInputWithinLimits(request);
const engine = getTransformEngine(request.engine);
return engine.transform(request);
const result = await withTransformationTimeout(() =>
engine.transform(request)
);
assertTransformOutputWithinLimits(result);
return result;
}
export const availableTransformEngines = Object.values(engines);