Read a Markdown file as the document it is

A .md previewed as its own source, which is reading the punctuation
rather than the notes. It now opens rendered, with Rendered | Source in
the dialog footer for anyone who wants what the file actually says.
Markdown only; a .txt has nothing to toggle between.

Rendering is `marked`, sanitised by DOMPurify -- the one the app already
carries for mail. Markdown is not a safe subset of anything: raw HTML
passes through it by design, so a <script> in a file somebody uploaded or
shared into the account is a script tag unless something takes it out.

Images become links rather than pictures. An image in a Markdown file is
either a relative path, which has no base to resolve against here, or a
URL somewhere else, which fetches on open and tells that server the file
was read -- the tracking pixel this app blocks in mail. The link keeps
the alt text and the address, so nothing vanishes silently.

Fixes the PDF preview while here, which never worked: securityHeaders
put X-Frame-Options: DENY on every response including the blob route, so
the iframe showed Chrome's "refused to connect" where the file should
have been -- in Files today and in mail attachments long before that.
The middleware now leaves a header the route has set, and a PDF served
inline says SAMEORIGIN. Nothing else on the server is framable.
This commit is contained in:
2026-09-01 20:32:59 -07:00
parent c170b8554c
commit 15f2c3d357
8 changed files with 273 additions and 13 deletions
+24 -7
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Download, Printer } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Code2, Download, Eye, Printer } from "lucide-react";
import { Dialog } from "./dialog";
import { formatSize } from "@/lib/format";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
import { t } from "@/lib/i18n";
/**
@@ -31,6 +32,10 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
const kind = file ? previewKind(file.type, file.name) : null;
const tooBig = kind === "text" && typeof file?.size === "number" && file.size > TEXT_PREVIEW_MAX;
const pdfRef = useRef<HTMLIFrameElement>(null);
const markdown = Boolean(file) && kind === "text" && isMarkdown(file!.type, file!.name);
/* Markdown opens as the document it is meant to be; the source is a click
away for anyone who wants to see what it actually says. */
const [rendered, setRendered] = useState(true);
/*
* Print what is on screen, not the mail or the file list behind it.
@@ -79,6 +84,12 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
size="xl"
footer={file && (
<>
{markdown && !tooBig && (
<div className="segmented left" role="group" aria-label={t("View as")}>
<button className={rendered ? "active" : ""} aria-pressed={rendered} onClick={() => setRendered(true)}><Eye size={14} /> {t("Rendered")}</button>
<button className={rendered ? "" : "active"} aria-pressed={!rendered} onClick={() => setRendered(false)}><Code2 size={14} /> {t("Source")}</button>
</div>
)}
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
</>
@@ -96,7 +107,7 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
/* `url`, not `inlineUrl`: fetch pays no attention to
Content-Disposition, so this works for the text types the server
will not serve inline -- Markdown among them. */
<TextPreview url={file.url} />
<TextPreview url={file.url} markdown={markdown && rendered} />
) : (
<p className="hint">{t("There is no preview for this kind of file.")}</p>
)}
@@ -107,7 +118,7 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
);
}
function TextPreview({ url }: { url: string }) {
function TextPreview({ url, markdown }: { url: string; markdown: boolean }) {
const [text, setText] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
@@ -126,12 +137,18 @@ function TextPreview({ url }: { url: string }) {
live = false;
};
}, [url]);
/* Rendering is not free on a long file, and the toggle flips back and forth. */
const html = useMemo(() => (markdown && text ? renderMarkdown(text) : null), [markdown, text]);
return (
<>
{/* Someone else's file: not ours to translate, and not ours to reflow. */}
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
{text ?? t("Loading…")}
</pre>
{html !== null ? (
<div className="md-body notranslate" translate="no" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
{text ?? t("Loading…")}
</pre>
)}
{truncated && <p className="hint">{t("Only the beginning is shown — download the file for the rest.")}</p>}
</>
);