Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ yarn-debug.log*
yarn-error.log*

*storybook.log

# "yarn pack" output
*.tgz
Comment thread
kemuru marked this conversation as resolved.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kleros/ui-components-library",
"version": "3.7.0",
"version": "3.8.0",
"description": "UI components library which implements the Kleros design system.",
"source": "./src/lib/index.ts",
"main": "./dist/index.js",
Expand Down Expand Up @@ -91,6 +91,7 @@
"tailwindcss": "^4.0.11"
},
"dependencies": {
"@cyntler/react-doc-viewer": "^1.17.0",
"@internationalized/date": "^3.7.0",
"bignumber.js": "^9.1.2",
"clsx": "^2.1.1",
Expand All @@ -99,6 +100,7 @@
"react-aria-components": "^1.7.1",
"react-dom": "^18.0.0",
"react-is": "^18.0.0",
"react-markdown": "^9.0.1",
"simplebar": "^5.3.6",
"simplebar-react": "^2.3.6",
"tailwind-merge": "^3.0.2",
Expand Down
32 changes: 32 additions & 0 deletions src/lib/file-viewer/file-viewer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* PNG transparency checkerboard color.
Upstream `@cyntler/react-doc-viewer` hardcodes `#e0e0e0` on `#image-renderer`
for PNGs. That's fine on white (subtle, industry-standard) but visually loud
on a dark surface, and impossible to retune without overriding the property.

We replace the hardcoded color with `--klerosUIComponentsImageCheckerColor`
so consumers (scout, etc.) can swap a single token to match their palette.
Defaults in theme.css: light = #e0e0e0 (matches upstream), dark = #2c0d63
(slight lift over the dark surface). Selector specificity (2 ids = 0,2,0)
beats the styled-component without !important. */
#react-doc-viewer #image-renderer {
Comment thread
kemuru marked this conversation as resolved.
Outdated
background-image: linear-gradient(
45deg,
var(--klerosUIComponentsImageCheckerColor, #e0e0e0) 25%,
transparent 25%
),
linear-gradient(
-45deg,
var(--klerosUIComponentsImageCheckerColor, #e0e0e0) 25%,
transparent 25%
),
linear-gradient(
45deg,
transparent 75%,
var(--klerosUIComponentsImageCheckerColor, #e0e0e0) 75%
),
linear-gradient(
-45deg,
transparent 75%,
var(--klerosUIComponentsImageCheckerColor, #e0e0e0) 75%
);
}
254 changes: 254 additions & 0 deletions src/lib/file-viewer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import React, { useMemo } from "react";
import DocViewer, {
DocViewerRenderers,
type IConfig,
type ITheme,
type IDocument,
} from "@cyntler/react-doc-viewer";

import { cn } from "../../utils";
import MarkdownDocRenderer from "./markdown-viewer";
import "@cyntler/react-doc-viewer/dist/index.css";
import "./file-viewer.css";

interface FileViewerProps {
/** URL of the file to display. Supports https:, http:, blob:, and data: URIs. */
url: string;
/** Optional file name override (used for download). Useful when the URL path lacks a meaningful filename. */
fileName?: string;
/** Override the DocViewer config. Merged shallowly over the defaults. */
config?: IConfig;
/** Class applied to the outer wrapper. */
className?: string;
}

const SAFE_URL_SCHEMES = new Set(["http:", "https:", "blob:", "data:"]);

// Data-URL MIMEs that execute script on top-frame navigation. Modern Firefox
// and Chrome already block most of these via their data:-navigation
// restrictions, but coverage varies by version and type (XHTML and XML have
// historically slipped through), so re-enforcing at the gate keeps the threat
// model trivially auditable. SVG is included because `<svg onload>` runs
// script when navigated to (it does not when loaded via `<img src>`). XML and
// XHTML can execute script via xml-stylesheet processing instructions or
// inline `<script>` once parsed as a document.
const UNSAFE_DATA_MIMES = new Set([
Comment thread
kemuru marked this conversation as resolved.
"text/html",
"application/xhtml+xml",
"application/xml",
"text/xml",
"image/svg+xml",
]);

/**
* Returns true if `raw` is a relative URL or uses an allowlisted scheme.
* Blocks `javascript:`, `vbscript:`, `file:`, `about:`, and anything else
* that could execute code or escape sandboxing when clicked.
*/
const isSafeUrl = (raw: string): boolean => {
if (typeof raw !== "string" || raw.length === 0) return false;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
// Relative URLs (e.g. "/foo", "./bar.pdf") throw — they resolve against
// the page origin and inherit its safety, so they're allowed.
return true;
}
const protocol = parsed.protocol.toLowerCase();
if (!SAFE_URL_SCHEMES.has(protocol)) return false;
if (protocol === "data:") {
const rawMime = parsed.pathname.split(/[,;]/)[0].trim().toLowerCase();
// Defense-in-depth: spec-compliant browsers do NOT percent-decode the
// data:-URL mediatype (WHATWG Fetch § data URL processor parses it raw;
// Chromium's DataURL::Parse only unescapes the body), so `text%2Fhtml`
// fails MIME parsing and falls back to `text/plain` — already safe. We
// decode anyway to harden against legacy or non-conforming runtimes;
// reject if decoding throws so malformed inputs can't slip through.
let mime: string;
try {
mime = decodeURIComponent(rawMime);
} catch {
return false;
}
if (UNSAFE_DATA_MIMES.has(mime)) return false;
}
return true;
};

type PdfjsLib = {
version?: string;
GlobalWorkerOptions?: { workerSrc?: string };
};

const getPdfjs = (): PdfjsLib | undefined =>
typeof globalThis === "undefined"
? undefined
: (globalThis as unknown as { pdfjsLib?: PdfjsLib }).pdfjsLib;

const buildDefaultPdfWorkerSrc = (): string => {
// doc-viewer bundles its own pdfjs and assigns it to globalThis.pdfjsLib at
// module init; reading the version from there guarantees the worker we load
// matches the runtime pdfjs (mismatched versions throw "incompatible worker").
// Fallback constant is the version doc-viewer@1.17.x ships with.
const version = getPdfjs()?.version ?? "4.3.136";
return `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
};

const setPdfWorkerSrc = (src: string): void => {
const lib = getPdfjs();
if (lib?.GlobalWorkerOptions && lib.GlobalWorkerOptions.workerSrc !== src) {
lib.GlobalWorkerOptions.workerSrc = src;
}
};

// Module init: doc-viewer's module top-level code points workerSrc at
// unpkg.com. Override it immediately so no unpkg request ever fires (the
// worker is only fetched when a PDF is rendered, which is always after this).
setPdfWorkerSrc(buildDefaultPdfWorkerSrc());

/**
* Override the PDF.js worker URL globally. Call once at app startup if you
* want to self-host the worker (e.g. under strict CSP) instead of using the
* default version-pinned jsDelivr URL. The worker is a process-wide singleton
* on `globalThis.pdfjsLib.GlobalWorkerOptions` — there is no per-instance
* configuration.
*/
export const configurePdfWorker = (src: string): void => {
Comment thread
kemuru marked this conversation as resolved.
Outdated
setPdfWorkerSrc(src);
};

const defaultConfig: IConfig = {
header: {
disableHeader: true,
disableFileName: true,
},
pdfZoom: {
defaultZoom: 0.8,
zoomJump: 0.1,
},
pdfVerticalScrollByDefault: true,
};

const docTheme: ITheme = {
primary: "var(--klerosUIComponentsWhiteBackground)",
secondary: "var(--klerosUIComponentsLightBackground)",
tertiary: "var(--klerosUIComponentsLightBackground)",
textPrimary: "var(--klerosUIComponentsPrimaryText)",
textSecondary: "var(--klerosUIComponentsSecondaryText)",
textTertiary: "var(--klerosUIComponentsSecondaryText)",
};

const UnsupportedUrlMessage = ({ url }: { url: string }) => (
<div
className={cn(
"text-klerosUIComponentsSecondaryText",
"flex flex-col gap-2 p-6 text-sm",
)}
>
<p>Unable to display this file.</p>
<p className="font-mono text-xs break-all">{url}</p>
</div>
);

const NoRendererFallback = ({
document,
fileName,
}: {
document: IDocument | undefined;
fileName: string;
}) => (
<div
className={cn(
"text-klerosUIComponentsPrimaryText",
"flex flex-col items-start gap-3 p-6 text-sm",
Comment thread
kemuru marked this conversation as resolved.
Outdated
)}
>
<p>This file type can&apos;t be previewed.</p>
<a
className="text-klerosUIComponentsPrimaryBlue underline"
href={document?.uri ?? ""}
download={fileName}
rel="noopener noreferrer"
target="_blank"
>
Open in a new tab
</a>
</div>
);

/**
* Displays a file from a URL inside the application. Supports PDFs, images,
* markdown, plaintext, and common document formats.
*
* Security: rejects `javascript:`, `vbscript:`, `file:`, and other unlisted
* schemes up front so a hostile `url` can't deliver code execution through
* the underlying viewer or its fallback download link.
*/
function FileViewer({
url,
fileName,
config,
className,
}: Readonly<FileViewerProps>) {
const safe = isSafeUrl(url);

const docs = useMemo(() => [{ uri: url, fileName }], [url, fileName]);

const pluginRenderers = useMemo(
() => [...DocViewerRenderers, MarkdownDocRenderer],
[],
);

const mergedConfig = useMemo<IConfig>(
() => ({
...defaultConfig,
...config,
noRenderer: {
...config?.noRenderer,
overrideComponent:
config?.noRenderer?.overrideComponent ?? NoRendererFallback,
},
}),
[config],
);

return (
<div
className={cn(
"bg-klerosUIComponentsWhiteBackground shadow-default",
"rounded-base max-h-[80vh] overflow-auto",
className,
)}
>
{safe ? (
<DocViewer
documents={docs}
pluginRenderers={pluginRenderers}
config={mergedConfig}
theme={docTheme}
className={cn(
"!bg-klerosUIComponentsWhiteBackground",
"[&_#pdf-controls]:!z-[3]",
"[&_#pdf-controls]:!bg-klerosUIComponentsPrimaryPurple/15",
"dark:[&_#pdf-controls]:!bg-klerosUIComponentsLightBackground/50",
"[&_#pdf-controls]:!backdrop-saturate-150",
"[&_#pdf-controls_svg_path]:!fill-klerosUIComponentsPrimaryText",
"[&_#pdf-controls_svg_polygon]:!fill-klerosUIComponentsPrimaryText",
"[&_#image-renderer]:!bg-klerosUIComponentsWhiteBackground",
"[&_#image-renderer]:!h-auto",
"[&_#image-renderer]:!flex-none",
"[&_#image-renderer]:!px-6",
"[&_#image-img]:!max-w-full",
"[&_#image-img]:!max-h-[80vh]",
"[&_[class*='--loading']]:text-klerosUIComponentsSecondaryText",
)}
/>
) : (
<UnsupportedUrlMessage url={url} />
)}
</div>
);
}

export default FileViewer;
54 changes: 54 additions & 0 deletions src/lib/file-viewer/markdown-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";
import { type DocRenderer } from "@cyntler/react-doc-viewer";
import ReactMarkdown from "react-markdown";

const decodeBase64Utf8 = (base64: string): string => {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));

Check warning on line 7 in src/lib/file-viewer/markdown-viewer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#codePointAt()` over `String#charCodeAt()`.

See more on https://sonarcloud.io/project/issues?id=kleros_ui-components-library&issues=AZ4b_9NpREQgRgn7HA1N&open=AZ4b_9NpREQgRgn7HA1N&pullRequest=87
Comment thread
kemuru marked this conversation as resolved.
Outdated
return new TextDecoder("utf-8").decode(bytes);
};

const MarkdownDocRenderer: DocRenderer = ({
mainState: { currentDocument },
}) => {
if (!currentDocument?.fileData) return null;

const { fileData } = currentDocument;
let text: string;

if (fileData instanceof ArrayBuffer) {
text = new TextDecoder("utf-8").decode(fileData);
} else if (fileData.startsWith("data:")) {
// RFC 2397: `data:[<mediatype>][;base64],<payload>` — the payload is
// base64 only when `;base64` is the last parameter before the comma;
// otherwise it's percent-encoded. Dispatching on `atob` success would
// mis-decode payloads that happen to be valid base64 by coincidence
// (e.g. the literal string "test" atob's into garbage bytes).
const commaIdx = fileData.indexOf(",");
const header =
commaIdx === -1 ? "" : fileData.slice("data:".length, commaIdx);
const payload = commaIdx === -1 ? "" : fileData.slice(commaIdx + 1);
const isBase64 = header.toLowerCase().endsWith(";base64");
try {
text = isBase64 ? decodeBase64Utf8(payload) : decodeURIComponent(payload);
} catch {
text = "";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
kemuru marked this conversation as resolved.
} else {
text = fileData;
}

return (
<div
id="md-renderer"
className="text-klerosUIComponentsPrimaryText [&_code]:text-klerosUIComponentsSecondaryText p-4 [&_a]:text-base"
>
<ReactMarkdown>{text}</ReactMarkdown>
</div>
);
};

MarkdownDocRenderer.fileTypes = ["md", "text/plain"];
MarkdownDocRenderer.weight = 1;

export default MarkdownDocRenderer;
2 changes: 2 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ export { default as ScrollbarContainer } from "../lib/scrollbar";
export { default as Copiable } from "../lib/copiable";

export { default as DraggableList } from "../lib/draggable-list";

export { default as FileViewer, configurePdfWorker } from "../lib/file-viewer";
Loading
Loading