feat: integrate XSLT Tools with toolbox portal
This commit is contained in:
174
src/App.tsx
174
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Layout from './components/Layout';
|
||||
import { AppShell } from '@add-ideas/toolbox-shell-react';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import EditorPanel from './components/EditorPanel';
|
||||
import ActionDialog, {
|
||||
@@ -19,8 +19,14 @@ import {
|
||||
} from './validation/validationTypes';
|
||||
import { runTransformation } from './transform/transformService';
|
||||
import { createTransformationRun } from './transform/nativeXsltEngine';
|
||||
import { createSha256Hash } from './transform/hash';
|
||||
import {
|
||||
isStylesheetExecutionTrusted,
|
||||
type TrustedStylesheet,
|
||||
} from './transform/stylesheetTrust';
|
||||
import type { TransformEngineId } from './transform/transformTypes';
|
||||
import { createApproximateTrace } from './transform/traceAnalyzer';
|
||||
import { toolboxApp } from './toolboxApp';
|
||||
|
||||
interface DialogState {
|
||||
title: string;
|
||||
@@ -52,6 +58,19 @@ const App: React.FC = () => {
|
||||
DiagnosticMessage[]
|
||||
>([]);
|
||||
const [validationTouched, setValidationTouched] = useState(false);
|
||||
const [trustedStylesheet, setTrustedStylesheet] =
|
||||
useState<TrustedStylesheet | null>(null);
|
||||
const stylesheetRevisionRef = useRef(0);
|
||||
const stateRef = useRef(state);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const invalidateStylesheetTrust = () => {
|
||||
stylesheetRevisionRef.current += 1;
|
||||
setTrustedStylesheet(null);
|
||||
};
|
||||
|
||||
const xmlDiagnostics = useMemo(
|
||||
() => validateXml(state.xmlInput.text, 'XML input'),
|
||||
@@ -161,6 +180,7 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
replaceDocument(kind, opened.text, opened.name, false);
|
||||
if (kind === 'xsltCode') invalidateStylesheetTrust();
|
||||
setStatusMessage(`Loaded ${opened.name}.`);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) return;
|
||||
@@ -203,6 +223,16 @@ const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!trustedStylesheet ||
|
||||
trustedStylesheet.revision !== stylesheetRevisionRef.current
|
||||
) {
|
||||
setStatusMessage(
|
||||
'Review and trust this exact stylesheet before transforming.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.options.askBeforeOverwritingOutput) {
|
||||
const mayOverwriteOutput = await confirmOverwrite(
|
||||
'xmlOutput',
|
||||
@@ -213,13 +243,33 @@ const App: React.FC = () => {
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const currentRevision = stylesheetRevisionRef.current;
|
||||
const currentState = stateRef.current;
|
||||
const currentHash = await createSha256Hash(currentState.xsltCode.text);
|
||||
if (
|
||||
currentRevision !== stylesheetRevisionRef.current ||
|
||||
!isStylesheetExecutionTrusted(
|
||||
trustedStylesheet,
|
||||
currentHash,
|
||||
currentRevision
|
||||
)
|
||||
) {
|
||||
setTrustedStylesheet(null);
|
||||
setStatusMessage(
|
||||
'The stylesheet changed. Review and trust it again before transforming.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = {
|
||||
xmlText: state.xmlInput.text,
|
||||
xsltText: state.xsltCode.text,
|
||||
engine: state.selectedEngine,
|
||||
xmlText: currentState.xmlInput.text,
|
||||
xsltText: currentState.xsltCode.text,
|
||||
engine: currentState.selectedEngine,
|
||||
};
|
||||
|
||||
const result = await runTransformation(request);
|
||||
const result = await runTransformation(request, {
|
||||
trustedStylesheet,
|
||||
currentRevision,
|
||||
});
|
||||
setRuntimeDiagnostics(result.diagnostics);
|
||||
|
||||
if (hasErrors(result.diagnostics)) {
|
||||
@@ -229,11 +279,16 @@ const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const output = state.options.prettifyOutputAfterTransform
|
||||
const output = currentState.options.prettifyOutputAfterTransform
|
||||
? tryFormatXml(result.output)
|
||||
: result.output;
|
||||
|
||||
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
|
||||
replaceDocument(
|
||||
'xmlOutput',
|
||||
output,
|
||||
currentState.xmlOutput.fileName,
|
||||
true
|
||||
);
|
||||
const run = await createTransformationRun(request, output);
|
||||
|
||||
setLastTransformation(run);
|
||||
@@ -253,6 +308,66 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrustStylesheet = async () => {
|
||||
const revision = stylesheetRevisionRef.current;
|
||||
const stylesheetText = stateRef.current.xsltCode.text;
|
||||
try {
|
||||
const hash = await createSha256Hash(stylesheetText);
|
||||
const confirmed = await askForConfirmation({
|
||||
title: 'Trust this executable stylesheet?',
|
||||
content: (
|
||||
<>
|
||||
<p>
|
||||
XSLT is executable code. Both engines may access browser or
|
||||
network resources. Only continue if you trust the source of this
|
||||
exact stylesheet.
|
||||
</p>
|
||||
<p>
|
||||
A trusted SaxonJS stylesheet may access data stored by other
|
||||
Toolbox apps on this same site, including PDF workspaces and
|
||||
portal preferences. It may send that data over the network or
|
||||
navigate this page away.
|
||||
</p>
|
||||
<p>
|
||||
Size limits and a best-effort asynchronous deadline reduce
|
||||
accidental overload, but the engines do not provide complete
|
||||
process or network isolation. Synchronous work cannot be forcibly
|
||||
stopped. Any edit, opened replacement, formatting change, or reset
|
||||
revokes trust.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
proceedLabel: 'Trust this exact stylesheet',
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
setStatusMessage('Stylesheet remains untrusted.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
revision !== stylesheetRevisionRef.current ||
|
||||
stylesheetText !== stateRef.current.xsltCode.text
|
||||
) {
|
||||
setTrustedStylesheet(null);
|
||||
setStatusMessage(
|
||||
'The stylesheet changed during review. Review it again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTrustedStylesheet({ hash, revision });
|
||||
setStatusMessage(
|
||||
'This exact stylesheet is trusted for the current session.'
|
||||
);
|
||||
} catch (error) {
|
||||
setTrustedStylesheet(null);
|
||||
setStatusMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Could not fingerprint the stylesheet.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveOutputToInput = async () => {
|
||||
if (!state.xmlOutput.text.trim()) {
|
||||
setStatusMessage('The output editor is empty. Nothing to move.');
|
||||
@@ -292,6 +407,7 @@ const App: React.FC = () => {
|
||||
|
||||
if (!mayProceed) return;
|
||||
replaceDocument(kind, formatted, documentState.fileName, true);
|
||||
if (kind === 'xsltCode') invalidateStylesheetTrust();
|
||||
setStatusMessage(`Formatted ${documentState.label}.`);
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
@@ -336,6 +452,7 @@ const App: React.FC = () => {
|
||||
|
||||
if (!mayProceed) return;
|
||||
resetWorkspace();
|
||||
invalidateStylesheetTrust();
|
||||
setRuntimeDiagnostics([]);
|
||||
setValidationTouched(false);
|
||||
setStatusMessage('Workspace reset.');
|
||||
@@ -402,7 +519,8 @@ const App: React.FC = () => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!busy) {
|
||||
void handleApplyTransformation();
|
||||
if (trustedStylesheet) void handleApplyTransformation();
|
||||
else void handleTrustStylesheet();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -412,10 +530,25 @@ const App: React.FC = () => {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
};
|
||||
}, [busy, handleApplyTransformation, handleOpenHelp]);
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout onOpenHelp={() => handleOpenHelp()}>
|
||||
<AppShell
|
||||
app={toolboxApp}
|
||||
className="xslt-app-shell"
|
||||
appActions={
|
||||
<button
|
||||
type="button"
|
||||
className="app-help-button"
|
||||
onClick={handleOpenHelp}
|
||||
aria-haspopup="dialog"
|
||||
title="Open help and keyboard shortcuts (F1)"
|
||||
aria-label="Open help"
|
||||
>
|
||||
<i className="bi bi-question-circle" aria-hidden="true" /> Help
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="workspace-grid">
|
||||
<EditorPanel
|
||||
ref={xmlInputEditorRef}
|
||||
@@ -443,7 +576,10 @@ const App: React.FC = () => {
|
||||
fileName={state.xsltCode.fileName}
|
||||
dirty={state.xsltCode.dirty}
|
||||
diagnostics={xsltDiagnostics}
|
||||
onChange={(value) => updateDocumentText('xsltCode', value)}
|
||||
onChange={(value) => {
|
||||
invalidateStylesheetTrust();
|
||||
updateDocumentText('xsltCode', value);
|
||||
}}
|
||||
onOpen={() => void handleOpenFile('xsltCode')}
|
||||
onSave={() => void handleSaveFile('xsltCode')}
|
||||
onUndo={() => xsltEditorRef.current?.undo()}
|
||||
@@ -487,10 +623,13 @@ const App: React.FC = () => {
|
||||
selectedEngine={state.selectedEngine}
|
||||
onEngineChange={handleEngineChange}
|
||||
onApplyTransformation={() => void handleApplyTransformation()}
|
||||
onTrustStylesheet={() => void handleTrustStylesheet()}
|
||||
onMoveOutputToInput={() => void handleMoveOutputToInput()}
|
||||
onValidate={handleValidate}
|
||||
onReset={() => void handleReset()}
|
||||
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
|
||||
prettifyOutputAfterTransform={
|
||||
state.options.prettifyOutputAfterTransform
|
||||
}
|
||||
onPrettifyOutputAfterTransformChange={
|
||||
handlePrettifyOutputAfterTransformChange
|
||||
}
|
||||
@@ -499,6 +638,7 @@ const App: React.FC = () => {
|
||||
handleAskBeforeOverwritingOutputChange
|
||||
}
|
||||
busy={busy}
|
||||
stylesheetTrusted={trustedStylesheet !== null}
|
||||
/>
|
||||
|
||||
{statusMessage && <div className="status-banner">{statusMessage}</div>}
|
||||
@@ -520,7 +660,6 @@ const App: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
<section className="trace-panel card">
|
||||
<div className="trace-header">
|
||||
<div>
|
||||
@@ -586,13 +725,8 @@ const App: React.FC = () => {
|
||||
{dialog?.content}
|
||||
</ActionDialog>
|
||||
|
||||
{helpOpen && (
|
||||
<HelpDialog
|
||||
open={helpOpen}
|
||||
onClose={handleCloseHelp}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
{helpOpen && <HelpDialog open={helpOpen} onClose={handleCloseHelp} />}
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
<div className="help-dialog-header">
|
||||
<div>
|
||||
<h2>XSLT tools help</h2>
|
||||
<p>Local-only XML/XSLT transformation workbench.</p>
|
||||
<p>Browser XML/XSLT transformation workbench.</p>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={onClose}>
|
||||
×
|
||||
@@ -32,9 +32,25 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
<h3>Transformation engine</h3>
|
||||
<p>
|
||||
The first version used the browser-native <code>XSLTProcessor</code>
|
||||
, which is suitable for XSLT 1.0. This engine likely is to be retired
|
||||
in late 2026. For XSLT 3.0, a SaxonJS engine was added that fully runs
|
||||
in the browser (JS-only).
|
||||
, which is suitable for XSLT 1.0. This engine likely is to be
|
||||
retired in late 2026. For XSLT 3.0, a SaxonJS engine was added that
|
||||
fully runs in the browser (JS-only).
|
||||
</p>
|
||||
|
||||
<h3>Executable stylesheet safety</h3>
|
||||
<p>
|
||||
XSLT is executable code. Before either engine can run, you must
|
||||
explicitly trust the SHA-256 fingerprint of the exact current
|
||||
stylesheet. Every edit revokes that trust. Run only stylesheets from
|
||||
sources you trust.
|
||||
</p>
|
||||
<p>
|
||||
A trusted SaxonJS stylesheet may read same-site data belonging to
|
||||
other Toolbox apps, including PDF workspaces and portal preferences,
|
||||
then send it over the network or navigate this page away. Input and
|
||||
output limits plus a best-effort asynchronous deadline reduce
|
||||
accidental overload, but transformations are not isolated and
|
||||
synchronous work cannot be forcibly stopped.
|
||||
</p>
|
||||
|
||||
<h3>Keyboard shortcuts</h3>
|
||||
@@ -43,8 +59,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Z</kbd>: undo
|
||||
</li>
|
||||
<li>
|
||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>:
|
||||
redo
|
||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd>{' '}
|
||||
or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>: redo
|
||||
</li>
|
||||
<li>
|
||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>F</kbd>: find in the active
|
||||
@@ -60,8 +76,12 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
|
||||
<h3>Privacy</h3>
|
||||
<p>
|
||||
No upload code is included. Files are read locally by the browser
|
||||
and the workspace is stored in your browser storage.
|
||||
The application itself does not intentionally upload opened files
|
||||
and stores the workspace in browser storage. A stylesheet you choose
|
||||
to trust and execute may nevertheless access same-site data, request
|
||||
external resources, or navigate away, so processing is
|
||||
conservatively classified as mixed rather than guaranteed
|
||||
local-only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { APP_VERSION } from '../version';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
onOpenHelp?: () => void;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<header className="app-header">
|
||||
<div className="app-header-content">
|
||||
<div className="app-header-title">
|
||||
<span className="app-logo" aria-hidden="true">
|
||||
⚙️
|
||||
</span>
|
||||
<div>
|
||||
<h1>XSLT tools</h1>
|
||||
<small>All transformations stay in your browser</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-header-actions">
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-help-button"
|
||||
onClick={onOpenHelp}
|
||||
aria-haspopup="dialog"
|
||||
title="Open help and keyboard shortcuts (F1)"
|
||||
aria-label="Open help"
|
||||
>
|
||||
<i className="bi bi-question-circle" aria-hidden="true" /> Help
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="app-version" title={`Version ${APP_VERSION}`}>
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -6,6 +6,7 @@ interface ToolbarProps {
|
||||
selectedEngine: TransformEngineId;
|
||||
onEngineChange: (engine: TransformEngineId) => void;
|
||||
onApplyTransformation: () => void;
|
||||
onTrustStylesheet: () => void;
|
||||
onMoveOutputToInput: () => void;
|
||||
onValidate: () => void;
|
||||
onReset: () => void;
|
||||
@@ -14,12 +15,14 @@ interface ToolbarProps {
|
||||
askBeforeOverwritingOutput: boolean;
|
||||
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
|
||||
busy?: boolean;
|
||||
stylesheetTrusted: boolean;
|
||||
}
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
selectedEngine,
|
||||
onEngineChange,
|
||||
onApplyTransformation,
|
||||
onTrustStylesheet,
|
||||
onMoveOutputToInput,
|
||||
onValidate,
|
||||
onReset,
|
||||
@@ -28,6 +31,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
askBeforeOverwritingOutput = true,
|
||||
onAskBeforeOverwritingOutputChange,
|
||||
busy = false,
|
||||
stylesheetTrusted,
|
||||
}) => {
|
||||
return (
|
||||
<section className="toolbar card" aria-label="Workbench actions">
|
||||
@@ -36,11 +40,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
type="button"
|
||||
className="button primary"
|
||||
onClick={onApplyTransformation}
|
||||
disabled={busy}
|
||||
title="Apply transformation (Ctrl/Cmd+Enter)"
|
||||
disabled={busy || !stylesheetTrusted}
|
||||
title={
|
||||
stylesheetTrusted
|
||||
? 'Apply transformation (Ctrl/Cmd+Enter)'
|
||||
: 'Review and trust this exact stylesheet before running it'
|
||||
}
|
||||
>
|
||||
{busy ? 'Transforming…' : 'Apply transformation'}
|
||||
</button>
|
||||
{!stylesheetTrusted && (
|
||||
<button
|
||||
type="button"
|
||||
className="button danger-outline"
|
||||
onClick={onTrustStylesheet}
|
||||
disabled={busy}
|
||||
>
|
||||
Review & trust stylesheet
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
@@ -61,6 +79,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`stylesheet-trust-status ${
|
||||
stylesheetTrusted ? 'is-trusted' : 'is-untrusted'
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
<strong>
|
||||
{stylesheetTrusted
|
||||
? 'This exact stylesheet is trusted for this session.'
|
||||
: 'Transformation is disabled until this exact stylesheet is reviewed and trusted.'}
|
||||
</strong>{' '}
|
||||
Stylesheets are executable code and may access browser or network
|
||||
resources. Any edit revokes trust.
|
||||
</div>
|
||||
|
||||
<div className="toolbar-row toolbar-options">
|
||||
<label className="toggle-option">
|
||||
<input
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
import '@add-ideas/toolbox-shell-react/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
|
||||
@@ -35,69 +35,28 @@ kbd {
|
||||
monospace;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
.xslt-app-shell {
|
||||
color-scheme: light;
|
||||
--toolbox-background: #f3f4f6;
|
||||
--toolbox-surface: #ffffff;
|
||||
--toolbox-text: #111827;
|
||||
--toolbox-muted: #6b7280;
|
||||
--toolbox-border: #d1d5db;
|
||||
--toolbox-accent: #2563eb;
|
||||
--toolbox-accent-contrast: #ffffff;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
padding: 0.65rem 1rem;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.app-header-content {
|
||||
.xslt-app-shell > .toolbox-shell__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-header small {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.app-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
border-radius: 999px;
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-help-button {
|
||||
border: 1px solid #4b5563;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
color: var(--toolbox-text);
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -107,17 +66,10 @@ kbd {
|
||||
|
||||
.app-help-button:hover,
|
||||
.app-help-button:focus-visible {
|
||||
background: #374151;
|
||||
background: var(--toolbox-background);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
@@ -207,6 +159,26 @@ kbd {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stylesheet-trust-status {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid;
|
||||
border-radius: 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stylesheet-trust-status.is-untrusted {
|
||||
border-color: #fca5a5;
|
||||
background: #fff1f2;
|
||||
color: #881337;
|
||||
}
|
||||
|
||||
.stylesheet-trust-status.is-trusted {
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.toggle-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
49
src/toolboxApp.ts
Normal file
49
src/toolboxApp.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import { APP_VERSION } from './version.ts';
|
||||
|
||||
export const toolboxAppDefinition = {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.xslt-tools',
|
||||
name: 'XSLT tools',
|
||||
version: APP_VERSION,
|
||||
description:
|
||||
'Browser-based XML and XSLT transformations with executable-stylesheet trust controls.',
|
||||
entry: './',
|
||||
icon: './favicon.svg',
|
||||
categories: ['documents', 'xml', 'xslt'],
|
||||
tags: ['transform', 'validate', 'format', 'explain'],
|
||||
actions: [
|
||||
{
|
||||
id: 'source',
|
||||
label: 'Source',
|
||||
url: 'https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.3.2',
|
||||
},
|
||||
],
|
||||
assets: ['./vendor/saxon/SaxonJS2.js', './vendor/saxon/LICENSE.txt'],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ['navigate', 'new-tab'],
|
||||
embedding: 'unsupported',
|
||||
},
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: false,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: 'mixed',
|
||||
fileUploads: true,
|
||||
telemetry: false,
|
||||
label:
|
||||
'Stylesheets are executable: after explicit trust they may read same-site app data, contact the network, or navigate away.',
|
||||
},
|
||||
source: {
|
||||
repository: 'https://git.add-ideas.de/zemion/xslt-tools',
|
||||
license: 'AGPL-3.0-only',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const toolboxApp = defineToolboxApp(toolboxAppDefinition);
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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.'));
|
||||
|
||||
16
src/transform/stylesheetTrust.ts
Normal file
16
src/transform/stylesheetTrust.ts
Normal 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
|
||||
);
|
||||
}
|
||||
65
src/transform/transformLimits.ts
Normal file
65
src/transform/transformLimits.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user