Skip to content

Commit a25aecf

Browse files
Add sandboxed HTML preview in the file viewer
Opening an HTML file now offers a Preview/Source toggle (like Markdown). Preview renders the page in an isolated <iframe>: sandbox without allow-same-origin, so the page gets an opaque origin and can never reach the app's cookies, storage or session. Page JavaScript is OFF by default; a 'JS' button enables it on demand (sandbox='allow-scripts'), re-mounting the frame so the flags take effect. Generalized the viewer's Preview/Source toggle to cover both Markdown and HTML; added isHtml() and the viewer.runJs/jsOnTitle/jsOffTitle keys (FR/EN, 335 each). Note: JSX/TSX can't be rendered without a bundler, so those stay syntax-highlighted.
1 parent efa2136 commit a25aecf

3 files changed

Lines changed: 64 additions & 18 deletions

File tree

‎apps/web/src/components/TextDocument.tsx‎

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
'use client';
22

33
/**
4-
* In-app document surface for text-like files. Renders three ways:
5-
* - Markdown (.md …) → formatted preview (GFM + syntax-highlighted code fences),
6-
* with a Preview/Source toggle.
4+
* In-app document surface for text-like files. Renders several ways:
5+
* - Markdown (.md …) → formatted preview (GFM + syntax-highlighted code fences).
6+
* - HTML (.html …) → rendered in a sandboxed <iframe> (isolated, opaque origin — it can
7+
* never reach your session/cookies). Page JavaScript is OFF by default
8+
* and toggled on demand (sandbox="allow-scripts", no allow-same-origin).
79
* - Code / text → syntax-highlighted, read-only.
810
* - Edit mode → a plain monospace editor with Save, when the file is editable.
911
*
10-
* Saving is delegated to `onSave` (the Drive page re-uploads server files as a new version,
11-
* or re-encrypts and replaces Zero-Knowledge files).
12+
* Renderable files (Markdown / HTML) get a Preview/Source toggle. Saving is delegated to
13+
* `onSave` (the Drive page re-uploads server files as a new version, or re-encrypts and
14+
* replaces Zero-Knowledge files).
1215
*/
1316
import { useMemo, useState } from 'react';
1417
import Markdown from 'react-markdown';
1518
import remarkGfm from 'remark-gfm';
1619
import rehypeHighlight from 'rehype-highlight';
1720
import hljs from 'highlight.js';
18-
import { Eye, Code2, Pencil, Save, Loader2 } from 'lucide-react';
19-
import { codeLanguage, isMarkdown } from '@/lib/fileType';
21+
import { Eye, Code2, Pencil, Save, Loader2, Play } from 'lucide-react';
22+
import { codeLanguage, isHtml, isMarkdown } from '@/lib/fileType';
2023
import { useT } from '@/lib/i18n';
2124
import { confirm, toast } from '@/components/ui/overlays';
2225

@@ -38,9 +41,13 @@ export function TextDocument({
3841
}) {
3942
const { t } = useT();
4043
const md = isMarkdown(name, mime);
44+
const html = isHtml(name, mime);
45+
const renderable = md || html;
46+
4147
const [text, setText] = useState(initialText);
4248
const [editing, setEditing] = useState(false);
43-
const [showSource, setShowSource] = useState(false);
49+
const [mode, setMode] = useState<'preview' | 'source'>(renderable ? 'preview' : 'source');
50+
const [jsEnabled, setJsEnabled] = useState(false);
4451
const [saving, setSaving] = useState(false);
4552
const dirty = text !== initialText;
4653

@@ -64,27 +71,42 @@ export function TextDocument({
6471
setEditing(false);
6572
}
6673

74+
const showPreview = renderable && mode === 'preview' && !editing;
75+
6776
return (
6877
<div className="flex h-full w-full max-w-5xl flex-col self-stretch overflow-hidden rounded-lg border border-white/10 bg-ink-900">
6978
{/* Mini toolbar */}
7079
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-3 py-2">
71-
<div className="flex items-center gap-1">
72-
{md && !editing && (
80+
<div className="flex items-center gap-2">
81+
{renderable && !editing && (
7382
<div className="flex rounded-lg border border-white/10 p-0.5 text-xs">
7483
<button
75-
className={`flex items-center gap-1 rounded-md px-2 py-1 ${!showSource ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}`}
76-
onClick={() => setShowSource(false)}
84+
className={`flex items-center gap-1 rounded-md px-2 py-1 ${mode === 'preview' ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}`}
85+
onClick={() => setMode('preview')}
7786
>
7887
<Eye size={13} /> {t('viewer.preview')}
7988
</button>
8089
<button
81-
className={`flex items-center gap-1 rounded-md px-2 py-1 ${showSource ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}`}
82-
onClick={() => setShowSource(true)}
90+
className={`flex items-center gap-1 rounded-md px-2 py-1 ${mode === 'source' ? 'bg-white/10 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}`}
91+
onClick={() => setMode('source')}
8392
>
8493
<Code2 size={13} /> {t('viewer.source')}
8594
</button>
8695
</div>
8796
)}
97+
{html && showPreview && (
98+
<button
99+
onClick={() => setJsEnabled((v) => !v)}
100+
title={jsEnabled ? t('viewer.jsOnTitle') : t('viewer.jsOffTitle')}
101+
className={`flex items-center gap-1 rounded-lg border px-2 py-1 text-xs ${
102+
jsEnabled
103+
? 'border-amber-400/40 bg-amber-400/10 text-amber-300'
104+
: 'border-white/10 text-zinc-400 hover:text-zinc-200'
105+
}`}
106+
>
107+
<Play size={12} /> {t('viewer.runJs')}
108+
</button>
109+
)}
88110
</div>
89111
{editable && onSave && (
90112
<div className="flex items-center gap-2">
@@ -116,12 +138,24 @@ export function TextDocument({
116138
spellCheck={false}
117139
className="h-full w-full resize-none bg-transparent p-4 font-mono text-xs leading-relaxed text-zinc-200 outline-none"
118140
/>
119-
) : md && !showSource ? (
141+
) : showPreview && md ? (
120142
<div className="md-prose p-5">
121143
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={text.length < HIGHLIGHT_LIMIT ? [rehypeHighlight] : []}>
122144
{text}
123145
</Markdown>
124146
</div>
147+
) : showPreview && html ? (
148+
<iframe
149+
// Re-mount on JS toggle so the sandbox flags actually change. allow-scripts WITHOUT
150+
// allow-same-origin gives the page an opaque origin: it can run but can't touch the
151+
// app's cookies, storage or session.
152+
key={jsEnabled ? 'js' : 'nojs'}
153+
title={name}
154+
srcDoc={text}
155+
sandbox={jsEnabled ? 'allow-scripts' : ''}
156+
referrerPolicy="no-referrer"
157+
className="h-full w-full border-0 bg-white"
158+
/>
125159
) : (
126160
<CodeBlock name={name} text={text} highlight={!md} />
127161
)}
@@ -132,7 +166,7 @@ export function TextDocument({
132166

133167
/** Read-only, syntax-highlighted code/text block. */
134168
function CodeBlock({ name, text, highlight }: { name: string; text: string; highlight: boolean }) {
135-
const html = useMemo(() => {
169+
const htmlOut = useMemo(() => {
136170
if (!highlight || text.length > HIGHLIGHT_LIMIT) return null;
137171
try {
138172
const lang = codeLanguage(name);
@@ -145,8 +179,8 @@ function CodeBlock({ name, text, highlight }: { name: string; text: string; high
145179

146180
return (
147181
<pre className="h-full w-full overflow-auto p-4 text-xs leading-relaxed">
148-
{html ? (
149-
<code className="hljs bg-transparent !p-0" dangerouslySetInnerHTML={{ __html: html }} />
182+
{htmlOut ? (
183+
<code className="hljs bg-transparent !p-0" dangerouslySetInnerHTML={{ __html: htmlOut }} />
150184
) : (
151185
<code className="text-zinc-300">{text}</code>
152186
)}

‎apps/web/src/lib/fileType.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export function isMarkdown(name: string, mime?: string): boolean {
8787
return ['md', 'markdown', 'mdown', 'mkd', 'mdx'].includes(e) || (mime ?? '').includes('markdown');
8888
}
8989

90+
/** True for HTML files, which the viewer can render in a sandboxed iframe. */
91+
export function isHtml(name: string, mime?: string): boolean {
92+
const e = ext(name);
93+
return ['html', 'htm', 'xhtml'].includes(e) || (mime ?? '').includes('text/html');
94+
}
95+
9096
/** Map a filename to a highlight.js language hint (empty = let it auto-detect). */
9197
export function codeLanguage(name: string): string {
9298
const e = ext(name);

‎apps/web/src/lib/i18n.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ const fr: Dict = {
4949
// file viewer / editor
5050
'viewer.preview': 'Aperçu',
5151
'viewer.source': 'Source',
52+
'viewer.runJs': 'JS',
53+
'viewer.jsOnTitle': 'JavaScript activé — cliquer pour désactiver',
54+
'viewer.jsOffTitle': 'Activer le JavaScript de la page (bac à sable isolé)',
5255
'viewer.edit': 'Éditer',
5356
'viewer.save': 'Enregistrer',
5457
'viewer.saving': 'Enregistrement…',
@@ -412,6 +415,9 @@ const en: Dict = {
412415
'redirect.loading': 'Redirecting…',
413416
'viewer.preview': 'Preview',
414417
'viewer.source': 'Source',
418+
'viewer.runJs': 'JS',
419+
'viewer.jsOnTitle': 'JavaScript enabled — click to disable',
420+
'viewer.jsOffTitle': 'Enable the page’s JavaScript (isolated sandbox)',
415421
'viewer.edit': 'Edit',
416422
'viewer.save': 'Save',
417423
'viewer.saving': 'Saving…',

0 commit comments

Comments
 (0)