Skip to content

Commit b5d2b6c

Browse files
authored
Merge pull request #87 from kleros/feat/file-viewer
feat: file viewer
2 parents 407e2f3 + 1f54562 commit b5d2b6c

9 files changed

Lines changed: 1860 additions & 17 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,6 @@ yarn-debug.log*
2525
yarn-error.log*
2626

2727
*storybook.log
28+
29+
# "yarn pack" output
30+
*.tgz

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@kleros/ui-components-library",
3-
"version": "3.7.0",
3+
"version": "3.8.0",
44
"description": "UI components library which implements the Kleros design system.",
55
"source": "./src/lib/index.ts",
66
"main": "./dist/index.js",
@@ -91,6 +91,7 @@
9191
"tailwindcss": "^4.0.11"
9292
},
9393
"dependencies": {
94+
"@cyntler/react-doc-viewer": "^1.17.0",
9495
"@internationalized/date": "^3.7.0",
9596
"bignumber.js": "^9.1.2",
9697
"clsx": "^2.1.1",
@@ -99,6 +100,7 @@
99100
"react-aria-components": "^1.7.1",
100101
"react-dom": "^18.0.0",
101102
"react-is": "^18.0.0",
103+
"react-markdown": "^9.0.1",
102104
"simplebar": "^5.3.6",
103105
"simplebar-react": "^2.3.6",
104106
"tailwind-merge": "^3.0.2",

src/lib/file-viewer/index.tsx

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import React, { useMemo } from "react";
2+
import DocViewer, {
3+
DocViewerRenderers,
4+
type IConfig,
5+
type ITheme,
6+
type IDocument,
7+
} from "@cyntler/react-doc-viewer";
8+
9+
import { cn } from "../../utils";
10+
import MarkdownDocRenderer from "./markdown-viewer";
11+
import SvgDocRenderer from "./svg-viewer";
12+
import "@cyntler/react-doc-viewer/dist/index.css";
13+
14+
interface FileViewerProps {
15+
/** URL of the file to display. Supports https:, http:, blob:, and data: URIs. */
16+
url: string;
17+
/** Optional file name override (used for download). Useful when the URL path lacks a meaningful filename. */
18+
fileName?: string;
19+
/** Override the DocViewer config. Merged shallowly over the defaults. */
20+
config?: IConfig;
21+
/** Class applied to the outer wrapper. */
22+
className?: string;
23+
/**
24+
* Opt-in allowlist of `data:` URI MIME types that bypass the script-execution
25+
* gate. By default the viewer rejects `data:` URLs whose MIME can execute
26+
* code on top-frame navigation: `text/html`, `application/xhtml+xml`,
27+
* `application/xml`, `text/xml`, `image/svg+xml`.
28+
*
29+
* Pass entries here only when the URL source is trusted. `image/svg+xml`
30+
* is rendered through `<img>` (W3C secure static mode — scripts and
31+
* external references are disabled by the browser) so opting it in stays
32+
* safe; the other entries lose the defense-in-depth gate against the
33+
* fallback "open in new tab" link.
34+
*/
35+
allowedDataMimes?: readonly string[];
36+
}
37+
38+
const SAFE_URL_SCHEMES = new Set(["http:", "https:", "blob:", "data:"]);
39+
40+
// Data-URL MIMEs that execute script on top-frame navigation. Modern Firefox
41+
// and Chrome already block most of these via their data:-navigation
42+
// restrictions, but coverage varies by version and type (XHTML and XML have
43+
// historically slipped through), so re-enforcing at the gate keeps the threat
44+
// model trivially auditable. SVG is included because `<svg onload>` runs
45+
// script when navigated to (it does not when loaded via `<img src>`). XML and
46+
// XHTML can execute script via xml-stylesheet processing instructions or
47+
// inline `<script>` once parsed as a document.
48+
const UNSAFE_DATA_MIMES = new Set([
49+
"text/html",
50+
"application/xhtml+xml",
51+
"application/xml",
52+
"text/xml",
53+
"image/svg+xml",
54+
]);
55+
56+
/**
57+
* Returns true if `raw` is a relative URL or uses an allowlisted scheme.
58+
* Blocks `javascript:`, `vbscript:`, `file:`, `about:`, and anything else
59+
* that could execute code or escape sandboxing when clicked.
60+
*
61+
* `allowedDataMimes` is a consumer-supplied override for the default
62+
* `UNSAFE_DATA_MIMES` blocklist — entries here pass even if blocklisted.
63+
*/
64+
const isSafeUrl = (
65+
raw: string,
66+
allowedDataMimes: ReadonlySet<string>,
67+
): boolean => {
68+
if (typeof raw !== "string" || raw.length === 0) return false;
69+
let parsed: URL;
70+
try {
71+
parsed = new URL(raw);
72+
} catch {
73+
// Relative URLs (e.g. "/foo", "./bar.pdf") throw — they resolve against
74+
// the page origin and inherit its safety, so they're allowed.
75+
return true;
76+
}
77+
const protocol = parsed.protocol.toLowerCase();
78+
if (!SAFE_URL_SCHEMES.has(protocol)) return false;
79+
if (protocol === "data:") {
80+
const rawMime = parsed.pathname.split(/[,;]/)[0].trim().toLowerCase();
81+
// Defense-in-depth: spec-compliant browsers do NOT percent-decode the
82+
// data:-URL mediatype (WHATWG Fetch § data URL processor parses it raw;
83+
// Chromium's DataURL::Parse only unescapes the body), so `text%2Fhtml`
84+
// fails MIME parsing and falls back to `text/plain` — already safe. We
85+
// decode anyway to harden against legacy or non-conforming runtimes;
86+
// reject if decoding throws so malformed inputs can't slip through.
87+
let mime: string;
88+
try {
89+
mime = decodeURIComponent(rawMime);
90+
} catch {
91+
return false;
92+
}
93+
if (allowedDataMimes.has(mime)) return true;
94+
if (UNSAFE_DATA_MIMES.has(mime)) return false;
95+
}
96+
return true;
97+
};
98+
99+
const defaultConfig: IConfig = {
100+
header: {
101+
disableHeader: true,
102+
disableFileName: true,
103+
},
104+
pdfZoom: {
105+
defaultZoom: 0.8,
106+
zoomJump: 0.1,
107+
},
108+
pdfVerticalScrollByDefault: true,
109+
};
110+
111+
const docTheme: ITheme = {
112+
primary: "var(--klerosUIComponentsWhiteBackground)",
113+
secondary: "var(--klerosUIComponentsLightBackground)",
114+
tertiary: "var(--klerosUIComponentsLightBackground)",
115+
textPrimary: "var(--klerosUIComponentsPrimaryText)",
116+
textSecondary: "var(--klerosUIComponentsSecondaryText)",
117+
textTertiary: "var(--klerosUIComponentsSecondaryText)",
118+
};
119+
120+
const UnsupportedUrlMessage = ({ url }: { url: string }) => (
121+
<div
122+
className={cn(
123+
"text-klerosUIComponentsSecondaryText text-sm",
124+
"flex flex-col gap-2 p-6",
125+
)}
126+
>
127+
<p>Unable to display this file.</p>
128+
<p className="font-mono text-xs break-all">{url}</p>
129+
</div>
130+
);
131+
132+
const NoRendererFallback = ({
133+
document,
134+
fileName,
135+
}: {
136+
document: IDocument | undefined;
137+
fileName: string;
138+
}) => (
139+
<div
140+
className={cn(
141+
"text-klerosUIComponentsPrimaryText text-sm",
142+
"flex flex-col items-start gap-3 p-6",
143+
)}
144+
>
145+
<p>This file type can&apos;t be previewed.</p>
146+
<a
147+
className="text-klerosUIComponentsPrimaryBlue underline"
148+
href={document?.uri ?? ""}
149+
download={fileName}
150+
rel="noopener noreferrer"
151+
target="_blank"
152+
>
153+
Open in a new tab
154+
</a>
155+
</div>
156+
);
157+
158+
/**
159+
* Displays a file from a URL inside the application. Supports PDFs, images,
160+
* markdown, plaintext, and common document formats.
161+
*
162+
* Security: rejects `javascript:`, `vbscript:`, `file:`, and other unlisted
163+
* schemes up front so a hostile `url` can't deliver code execution through
164+
* the underlying viewer or its fallback download link.
165+
*/
166+
function FileViewer({
167+
url,
168+
fileName,
169+
config,
170+
className,
171+
allowedDataMimes,
172+
}: Readonly<FileViewerProps>) {
173+
const allowedDataMimesSet = useMemo(
174+
() => new Set((allowedDataMimes ?? []).map((m) => m.toLowerCase())),
175+
[allowedDataMimes],
176+
);
177+
const safe = isSafeUrl(url, allowedDataMimesSet);
178+
179+
const docs = useMemo(() => [{ uri: url, fileName }], [url, fileName]);
180+
181+
const pluginRenderers = useMemo(
182+
() => [...DocViewerRenderers, MarkdownDocRenderer, SvgDocRenderer],
183+
[],
184+
);
185+
186+
const mergedConfig = useMemo<IConfig>(
187+
() => ({
188+
...defaultConfig,
189+
...config,
190+
noRenderer: {
191+
...config?.noRenderer,
192+
overrideComponent:
193+
config?.noRenderer?.overrideComponent ?? NoRendererFallback,
194+
},
195+
}),
196+
[config],
197+
);
198+
199+
return (
200+
<div
201+
className={cn(
202+
"bg-klerosUIComponentsWhiteBackground shadow-default",
203+
"rounded-base max-h-[80vh] overflow-auto",
204+
className,
205+
)}
206+
>
207+
{safe ? (
208+
<DocViewer
209+
documents={docs}
210+
pluginRenderers={pluginRenderers}
211+
config={mergedConfig}
212+
theme={docTheme}
213+
className={cn(
214+
"!bg-klerosUIComponentsWhiteBackground",
215+
"[&_#pdf-controls]:!z-[3]",
216+
"[&_#pdf-controls]:!bg-klerosUIComponentsPrimaryPurple/15",
217+
"dark:[&_#pdf-controls]:!bg-klerosUIComponentsLightBackground/50",
218+
"[&_#pdf-controls]:!backdrop-saturate-150",
219+
"[&_#pdf-controls_svg_path]:!fill-klerosUIComponentsPrimaryText",
220+
"[&_#pdf-controls_svg_polygon]:!fill-klerosUIComponentsPrimaryText",
221+
"[&_#image-renderer]:!bg-klerosUIComponentsWhiteBackground",
222+
"[&_#image-renderer]:!h-auto",
223+
"[&_#image-renderer]:!flex-none",
224+
"[&_#image-renderer]:!px-6",
225+
// Transparency checkerboard. The gradient is inlined in the
226+
// arbitrary property (rather than a theme token) so the inner
227+
// `var(--klerosUIComponentsImageCheckerColor)` resolves at this
228+
// element's cascade — consumers can override that single variable
229+
// anywhere up the tree and the color propagates. A theme-token
230+
// wrapper would bake the inner var() at `:root` and the override
231+
// would silently no-op for descendants. Full arbitrary-property
232+
// syntax (not `bg-*` shortcut) so `tailwind-merge` recognizes this
233+
// as background-image and doesn't collide with the bg-color above.
234+
// eslint-disable-next-line max-len
235+
"[&_#image-renderer]:![background-image:linear-gradient(45deg,var(--klerosUIComponentsImageCheckerColor)_25%,transparent_25%),linear-gradient(-45deg,var(--klerosUIComponentsImageCheckerColor)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,var(--klerosUIComponentsImageCheckerColor)_75%),linear-gradient(-45deg,transparent_75%,var(--klerosUIComponentsImageCheckerColor)_75%)]",
236+
"[&_#image-renderer]:![background-size:20px_20px]",
237+
"[&_#image-renderer]:![background-position:0_0,0_10px,10px_-10px,-10px_0px]",
238+
"[&_#image-img]:!max-w-full",
239+
"[&_#image-img]:!max-h-[80vh]",
240+
"[&_[class*='--loading']]:text-klerosUIComponentsSecondaryText",
241+
)}
242+
/>
243+
) : (
244+
<UnsupportedUrlMessage url={url} />
245+
)}
246+
</div>
247+
);
248+
}
249+
250+
export default FileViewer;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from "react";
2+
import { type DocRenderer } from "@cyntler/react-doc-viewer";
3+
import ReactMarkdown from "react-markdown";
4+
5+
const decodeBase64Utf8 = (base64: string): string => {
6+
const binary = atob(base64);
7+
const bytes = Uint8Array.from(binary, (char) => char.codePointAt(0) ?? 0);
8+
return new TextDecoder("utf-8").decode(bytes);
9+
};
10+
11+
const MarkdownDocRenderer: DocRenderer = ({
12+
mainState: { currentDocument },
13+
}) => {
14+
if (!currentDocument?.fileData) return null;
15+
16+
const { fileData } = currentDocument;
17+
let text: string;
18+
19+
if (fileData instanceof ArrayBuffer) {
20+
text = new TextDecoder("utf-8").decode(fileData);
21+
} else if (fileData.startsWith("data:")) {
22+
// RFC 2397: `data:[<mediatype>][;base64],<payload>` — the payload is
23+
// base64 only when `;base64` is the last parameter before the comma;
24+
// otherwise it's percent-encoded. Dispatching on `atob` success would
25+
// mis-decode payloads that happen to be valid base64 by coincidence
26+
// (e.g. the literal string "test" atob's into garbage bytes).
27+
const commaIdx = fileData.indexOf(",");
28+
const header =
29+
commaIdx === -1 ? "" : fileData.slice("data:".length, commaIdx);
30+
const payload = commaIdx === -1 ? "" : fileData.slice(commaIdx + 1);
31+
const isBase64 = header.toLowerCase().endsWith(";base64");
32+
try {
33+
text = isBase64 ? decodeBase64Utf8(payload) : decodeURIComponent(payload);
34+
} catch {
35+
text = "";
36+
}
37+
} else {
38+
text = fileData;
39+
}
40+
41+
return (
42+
<div
43+
id="md-renderer"
44+
className="text-klerosUIComponentsPrimaryText [&_code]:text-klerosUIComponentsSecondaryText p-4 [&_a]:text-base"
45+
>
46+
<ReactMarkdown>{text}</ReactMarkdown>
47+
</div>
48+
);
49+
};
50+
51+
MarkdownDocRenderer.fileTypes = ["md", "text/plain"];
52+
MarkdownDocRenderer.weight = 1;
53+
54+
export default MarkdownDocRenderer;

src/lib/file-viewer/svg-viewer.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from "react";
2+
import { type DocRenderer } from "@cyntler/react-doc-viewer";
3+
4+
// Render SVG via `<img src>` rather than navigating to it or embedding it as a
5+
// document. `<img>`-loaded SVG runs in the W3C "secure static" mode: scripts,
6+
// event handlers, external `<image>`/`<use>` references, CSS `url()`, and
7+
// `<foreignObject>` are all disabled by the browser. This is the same sandbox
8+
// GitHub, Wikipedia, and Notion use for user-uploaded SVGs. Without this
9+
// renderer, DocViewer has no SVG match and falls back to the "open in new
10+
// tab" link, which top-frame-navigates to the URL and executes any scripts
11+
// in the SVG document.
12+
//
13+
// IDs match upstream image renderer ids (`#image-renderer`, `#image-img`) so
14+
// the existing arbitrary-variant styles in `index.tsx` apply uniformly.
15+
// `flex items-center justify-center` here mirrors what doc-viewer's PNG
16+
// styled-component provides for raster renderers — our element doesn't pick
17+
// up that class, so we set it inline to keep the image centered.
18+
const SvgDocRenderer: DocRenderer = ({ mainState: { currentDocument } }) => {
19+
if (!currentDocument?.uri) return null;
20+
return (
21+
<div id="image-renderer" className="flex items-center justify-center">
22+
<img
23+
id="image-img"
24+
src={currentDocument.uri}
25+
alt={currentDocument.fileName ?? ""}
26+
/>
27+
</div>
28+
);
29+
};
30+
31+
SvgDocRenderer.fileTypes = ["svg", "image/svg+xml"];
32+
SvgDocRenderer.weight = 1;
33+
34+
export default SvgDocRenderer;

src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ export { default as ScrollbarContainer } from "../lib/scrollbar";
5252
export { default as Copiable } from "../lib/copiable";
5353

5454
export { default as DraggableList } from "../lib/draggable-list";
55+
56+
export { default as FileViewer } from "../lib/file-viewer";

0 commit comments

Comments
 (0)