Hello
World
diff --git a/apps/desktop/src/components/grid/DataGridValueTransform.vue b/apps/desktop/src/components/grid/DataGridValueTransform.vue index 0c5f0ea472..a4b5650295 100644 --- a/apps/desktop/src/components/grid/DataGridValueTransform.vue +++ b/apps/desktop/src/components/grid/DataGridValueTransform.vue @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { CELL_TRANSFORM_KINDS, CELL_TRANSFORM_MAX_INPUT, CELL_TRANSFORM_MAX_OUTPUT, CELL_TRANSFORM_MAX_RADIX_DIGITS, transformCellValue, type CellTransformKind, type CellTransformResult } from "@/lib/dataGrid/cellValueTransform"; import { DataGridDateTimePatterns, getSupportedTimeZoneOptions } from "@/lib/dataGrid/columnFormatter"; +import { buildSafeHtmlPreview } from "@/lib/common/safeHtmlPreview"; const props = defineProps<{ source: string | null; @@ -81,6 +82,8 @@ function convert() { if (blockedReason.value || props.source === null) return; result.value = transformCellValue(props.source, { kind: kind.value, unit: unit.value, timezone: timezone.value, pattern: pattern.value, fromBase: fromBase.value, toBase: toBase.value }); } +const htmlPreviewDocument = computed(() => (kind.value === "html" && result.value?.ok ? buildSafeHtmlPreview(result.value.text) : "")); + async function copyResult() { if (!result.value?.ok) return; try { @@ -155,6 +158,7 @@ async function copyResult() { > + diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 5ab809cdbd..6086e95418 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -132,6 +132,7 @@ export default withEnglishFallback({ convert: "Çevir", result: "Nəticə", copy: "Nəticəni köçür", + htmlPreview: "HTML preview", radixHint: "Yalnız tam ədədlər. 0x, 0o və ya 0b prefiksi olmadan rəqəmlər daxil edin; işarə istifadə oluna bilər.", urlHint: "URL komponentləri UTF-8 ilə kodlanır və ya dekodlaşdırılır. Dekodlaşdırma + işarəsini boşluğa çevirmir.", kinds: { @@ -139,6 +140,7 @@ export default withEnglishFallback({ json: "JSON formatla", jsonCompact: "JSON sıxlaşdır", xml: "XML formatla", + html: "HTML preview", base64Encode: "Mətn → Base64 (UTF-8)", base64Decode: "Base64 → mətn (UTF-8)", urlEncode: "URL komponentini kodla", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fcd3b2f009..756c4e5b9e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -94,6 +94,7 @@ export default { convert: "Convert", result: "Result", copy: "Copy result", + htmlPreview: "HTML preview", radixHint: "Integers only. Enter digits without a 0x, 0o or 0b prefix; an optional sign is supported.", urlHint: "URL component encoding (UTF-8). Decoding preserves + as a literal plus sign.", kinds: { @@ -101,6 +102,7 @@ export default { json: "Format JSON", jsonCompact: "Compact JSON", xml: "Format XML", + html: "HTML preview", base64Encode: "Text → Base64 (UTF-8)", base64Decode: "Base64 → text (UTF-8)", urlEncode: "URL component encode", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f12f98e481..277631488f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -98,6 +98,7 @@ export default withEnglishFallback({ convert: "Convertir", result: "Resultado", copy: "Copiar resultado", + htmlPreview: "HTML preview", radixHint: "Solo enteros. Introduce dígitos sin los prefijos 0x, 0o o 0b; se admite un signo opcional.", urlHint: "Codificación de componentes URL en UTF-8. La decodificación conserva + como signo más.", kinds: { @@ -105,6 +106,7 @@ export default withEnglishFallback({ json: "Formatear JSON", jsonCompact: "Compactar JSON", xml: "Formatear XML", + html: "HTML preview", base64Encode: "Texto → Base64 (UTF-8)", base64Decode: "Base64 → texto (UTF-8)", urlEncode: "Codificar componente URL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ea483d47da..47196d4d14 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -97,6 +97,7 @@ export default withEnglishFallback({ convert: "Converti", result: "Risultato", copy: "Copia risultato", + htmlPreview: "HTML preview", radixHint: "Solo interi. Inserisci cifre senza prefissi 0x, 0o o 0b; è ammesso un segno facoltativo.", urlHint: "Codifica dei componenti URL in UTF-8. La decodifica mantiene + come segno più.", kinds: { @@ -104,6 +105,7 @@ export default withEnglishFallback({ json: "Formatta JSON", jsonCompact: "Compatta JSON", xml: "Formatta XML", + html: "HTML preview", base64Encode: "Testo → Base64 (UTF-8)", base64Decode: "Base64 → testo (UTF-8)", urlEncode: "Codifica componente URL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 79f51ea6e4..3c1d0bcfb4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -98,6 +98,7 @@ export default withEnglishFallback({ convert: "変換", result: "変換結果", copy: "結果をコピー", + htmlPreview: "HTML preview", radixHint: "整数のみ対応します。0x、0o、0b 接頭辞は不要です。正負の符号を使用できます。", urlHint: "URL コンポーネントを UTF-8 でエンコード・デコードします。デコード時に + は空白に変換されません。", kinds: { @@ -105,6 +106,7 @@ export default withEnglishFallback({ json: "JSON 整形", jsonCompact: "JSON 圧縮", xml: "XML 整形", + html: "HTML preview", base64Encode: "テキスト → Base64(UTF-8)", base64Decode: "Base64 → テキスト(UTF-8)", urlEncode: "URL コンポーネントのエンコード", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 4f6997e7ea..fd8d975c50 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -97,6 +97,7 @@ export default withEnglishFallback({ convert: "변환", result: "변환 결과", copy: "결과 복사", + htmlPreview: "HTML preview", radixHint: "정수만 지원합니다. 0x, 0o, 0b 접두사 없이 입력하세요. 부호는 사용할 수 있습니다.", urlHint: "URL 구성 요소를 UTF-8로 인코딩하거나 디코딩합니다. 디코딩 시 +를 공백으로 바꾸지 않습니다.", kinds: { @@ -104,6 +105,7 @@ export default withEnglishFallback({ json: "JSON 서식 지정", jsonCompact: "JSON 압축", xml: "XML 서식 지정", + html: "HTML preview", base64Encode: "텍스트 → Base64(UTF-8)", base64Decode: "Base64 → 텍스트(UTF-8)", urlEncode: "URL 구성 요소 인코딩", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 600f62d20b..49d10c3040 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -98,6 +98,7 @@ export default withEnglishFallback({ convert: "Converter", result: "Resultado", copy: "Copiar resultado", + htmlPreview: "HTML preview", radixHint: "Somente inteiros. Insira dígitos sem os prefixos 0x, 0o ou 0b; um sinal opcional é permitido.", urlHint: "Codificação de componentes URL em UTF-8. A decodificação preserva + como sinal de adição.", kinds: { @@ -105,6 +106,7 @@ export default withEnglishFallback({ json: "Formatar JSON", jsonCompact: "Compactar JSON", xml: "Formatar XML", + html: "HTML preview", base64Encode: "Texto → Base64 (UTF-8)", base64Decode: "Base64 → texto (UTF-8)", urlEncode: "Codificar componente URL", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 8006254f89..650ee4778f 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -19,6 +19,7 @@ export default withEnglishFallback({ convert: "Преобразовать", result: "Результат", copy: "Копировать результат", + htmlPreview: "HTML preview", radixHint: "Только целые числа. Вводите цифры без префикса 0x, 0o или 0b; допускается необязательный знак.", urlHint: "Кодирование компонента URL (UTF-8). При декодировании + сохраняется как знак плюс.", kinds: { @@ -26,6 +27,7 @@ export default withEnglishFallback({ json: "Форматировать JSON", jsonCompact: "Сжать JSON", xml: "Форматировать XML", + html: "HTML preview", base64Encode: "Текст → Base64 (UTF-8)", base64Decode: "Base64 → текст (UTF-8)", urlEncode: "Кодировать компонент URL", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 3a14c4b7a5..0b87c86a64 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -134,6 +134,7 @@ export default withEnglishFallback({ convert: "Dönüştür", result: "Sonuç", copy: "Sonucu kopyala", + htmlPreview: "HTML preview", radixHint: "Yalnızca tam sayılar. 0x, 0o veya 0b öneki olmadan rakam girin; isteğe bağlı işaret kullanılabilir.", urlHint: "URL bileşenleri UTF-8 ile kodlanır veya çözülür. Çözme işlemi + işaretini boşluğa dönüştürmez.", kinds: { @@ -141,6 +142,7 @@ export default withEnglishFallback({ json: "JSON biçimlendir", jsonCompact: "JSON sıkıştır", xml: "XML biçimlendir", + html: "HTML preview", base64Encode: "Metin → Base64 (UTF-8)", base64Decode: "Base64 → metin (UTF-8)", urlEncode: "URL bileşenini kodla", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 23ecc3028d..12bd1a735d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -19,6 +19,7 @@ export default withEnglishFallback({ convert: "转换", result: "转换结果", copy: "复制结果", + htmlPreview: "HTML 渲染预览", radixHint: "仅支持整数,请勿输入 0x、0o 或 0b 前缀,可使用正负号。", urlHint: "按 URL 组件进行 UTF-8 编解码,解码时保留加号,不将其替换为空格。", kinds: { @@ -26,6 +27,7 @@ export default withEnglishFallback({ json: "JSON 格式化", jsonCompact: "JSON 压缩", xml: "XML 格式化", + html: "HTML 渲染预览", base64Encode: "文本 → Base64(UTF-8)", base64Decode: "Base64 → 文本(UTF-8)", urlEncode: "URL 组件编码", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index ee9b944fe4..42fa777389 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -97,6 +97,7 @@ export default withEnglishFallback({ convert: "轉換", result: "轉換結果", copy: "複製結果", + htmlPreview: "HTML 渲染預覽", radixHint: "僅支援整數,請勿輸入 0x、0o 或 0b 前綴,可使用正負號。", urlHint: "按 URL 元件進行 UTF-8 編解碼,解碼時保留加號,不將其替換為空格。", kinds: { @@ -104,6 +105,7 @@ export default withEnglishFallback({ json: "JSON 格式化", jsonCompact: "JSON 壓縮", xml: "XML 格式化", + html: "HTML 渲染預覽", base64Encode: "文字 → Base64(UTF-8)", base64Decode: "Base64 → 文字(UTF-8)", urlEncode: "URL 元件編碼", diff --git a/apps/desktop/src/lib/ai/richContent/aiHtmlPreview.ts b/apps/desktop/src/lib/ai/richContent/aiHtmlPreview.ts index 3b12094473..c1f4853bcc 100644 --- a/apps/desktop/src/lib/ai/richContent/aiHtmlPreview.ts +++ b/apps/desktop/src/lib/ai/richContent/aiHtmlPreview.ts @@ -1,3 +1,7 @@ +import { buildSafeHtmlPreview, SAFE_HTML_PREVIEW_CSP } from "@/lib/common/safeHtmlPreview"; + +export { buildSafeHtmlPreview }; + /** * Safe HTML preview shell (V1). * @@ -30,40 +34,13 @@ /** Max raw ```html fence body length (characters) that may enter the iframe `srcdoc`. */ export const AI_HTML_MAX_CONTENT_CHARS = 512 * 1024; -export const AI_HTML_PREVIEW_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:"; +export const AI_HTML_PREVIEW_CSP = SAFE_HTML_PREVIEW_CSP; /** True when the raw fence body may be previewed / saved. Oversize stays a plain code segment. */ export function isAiHtmlPreviewEligible(content: string): boolean { return content.length > 0 && content.length <= AI_HTML_MAX_CONTENT_CHARS; } -/** - * Matches any `` carrying `http-equiv=refresh`: case-insensitive on the - * tag name and the `http-equiv` key/value, tolerant of unquoted, single- or - * double-quoted values in any attribute order with extra attributes present. - */ -const AI_HTML_META_REFRESH_PATTERN = /]*?\bhttp-equiv\s*=\s*(?:"\s*refresh\s*"|'\s*refresh\s*'|refresh\b)[^>]*>/gi; - -/** Inert replacement for a defused refresh meta: a comment navigates nowhere. */ -const AI_HTML_META_REFRESH_REPLACEMENT = ""; - -/** Strip every `http-equiv=refresh` meta; everything else passes through byte-identical. */ -function defuseMetaRefresh(content: string): string { - return content.replace(AI_HTML_META_REFRESH_PATTERN, AI_HTML_META_REFRESH_REPLACEMENT); -} - -/** - * Wrap raw AI HTML in the safe standalone document. The same bytes back BOTH - * the iframe preview and the "Save Safe HTML" payload, so what is previewed is - * exactly what gets saved. If the AI content is itself a complete document, - * the browser's error recovery nests it inside the body while the outer CSP - * meta still applies document-wide. The only rewrite is the refresh meta - * defused above — navigation sits outside the CSP fetch model. - */ -export function buildSafeHtmlPreview(content: string): string { - return ["", "", "
", '', ``, "", "", defuseMetaRefresh(content), "", ""].join("\n"); -} - /** * "Copy source" puts the raw AI HTML on the clipboard where it may later be * opened outside DBX. The first copy of a session requires an explicit risk diff --git a/apps/desktop/src/lib/common/safeHtmlPreview.ts b/apps/desktop/src/lib/common/safeHtmlPreview.ts new file mode 100644 index 0000000000..877c1b17c0 --- /dev/null +++ b/apps/desktop/src/lib/common/safeHtmlPreview.ts @@ -0,0 +1,10 @@ +/** CSP used by read-only HTML previews rendered inside sandboxed iframes. */ +export const SAFE_HTML_PREVIEW_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:"; + +const META_REFRESH_PATTERN = /]*?\bhttp-equiv\s*=\s*(?:"\s*refresh\s*"|'\s*refresh\s*'|refresh\b)[^>]*>/gi; + +/** Wrap untrusted HTML in a CSP-protected document and remove meta refresh navigation. */ +export function buildSafeHtmlPreview(content: string): string { + const safeContent = content.replace(META_REFRESH_PATTERN, ""); + return ["", "", "", '', ``, "", "", safeContent, "", ""].join("\n"); +} diff --git a/apps/desktop/src/lib/dataGrid/cellValueTransform.ts b/apps/desktop/src/lib/dataGrid/cellValueTransform.ts index dd69ba8cd9..30c685117f 100644 --- a/apps/desktop/src/lib/dataGrid/cellValueTransform.ts +++ b/apps/desktop/src/lib/dataGrid/cellValueTransform.ts @@ -5,7 +5,7 @@ import { applyColumnFormatter, DataGridDateTimePatterns } from "@/lib/dataGrid/c export const CELL_TRANSFORM_MAX_INPUT = 50_000; export const CELL_TRANSFORM_MAX_OUTPUT = 200_000; export const CELL_TRANSFORM_MAX_RADIX_DIGITS = 4_096; -export const CELL_TRANSFORM_KINDS = ["timestamp", "json", "jsonCompact", "xml", "base64Encode", "base64Decode", "urlEncode", "urlDecode", "radix"] as const; +export const CELL_TRANSFORM_KINDS = ["timestamp", "json", "jsonCompact", "xml", "html", "base64Encode", "base64Decode", "urlEncode", "urlDecode", "radix"] as const; export type CellTransformKind = (typeof CELL_TRANSFORM_KINDS)[number]; export type CellTransformError = "tooLarge" | "invalid" | "invalidTimestamp" | "ambiguousUnit" | "invalidInteger" | "invalidBase64"; export interface CellTransformOptions { @@ -65,6 +65,9 @@ export function transformCellValue(source: string, options: CellTransformOptions case "xml": text = formatXmlSource(source); break; + case "html": + text = source; + break; case "urlEncode": text = encodeURIComponent(source); break; diff --git a/packages/app-tests/cellValueTransform.test.ts b/packages/app-tests/cellValueTransform.test.ts index be62f9b02b..12d1b82aee 100644 --- a/packages/app-tests/cellValueTransform.test.ts +++ b/packages/app-tests/cellValueTransform.test.ts @@ -85,6 +85,11 @@ test("xml rejects malformed markup", () => { assert.deepEqual(transformCellValue("", { kind: "xml" }), { ok: false, error: "invalid" }); }); +test("html preview preserves source markup for rendering", () => { + const source = 'World