Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion frontend/src/components/data-table/renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,17 @@ export const DataTableBody = <TData,>({
);

const title = cell.getHoverTitle?.() ?? undefined;
const isCellSelected = cell.getIsSelected?.() || false;
return (
<TableCell
tabIndex={0}
{...getCellDomProps(cell.id)}
key={cell.id}
className={cn(
"whitespace-pre truncate max-w-[300px] outline-hidden border-r border-r-border/75",
"whitespace-pre truncate max-w-[300px] border-r border-r-border/75",
isCellSelected
? "outline outline-2 outline-(--blue-7) -outline-offset-2"
: "outline-hidden",
cell.column.getColumnWrapping &&
cell.column.getColumnWrapping?.() === "wrap" &&
COLUMN_WRAPPING_STYLES,
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/plugins/core/RenderHTML.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Tooltip } from "@/components/ui/tooltip";
import { DocHoverTarget } from "@/core/documentation/DocHoverTarget";
import { hasTrustedNotebookContext } from "@/core/static/export-context";
import { Logger } from "@/utils/Logger";
import { getRuntimeManager } from "@/core/runtime/config";
import { sanitizeHtml, useSanitizeHtml } from "./sanitize";

type ReplacementFn = NonNullable<HTMLReactParserOptions["replace"]>;
Expand All @@ -32,6 +33,39 @@ interface Options {
alwaysSanitizeHtml?: boolean;
additionalReplacements?: ReplacementFn[];
}
// Resolve a virtual file URL (./@file/... or @file/...) to an absolute URL
// using the runtime base, ensuring a trailing slash so the notebook-ID path
// segment is never dropped during relative resolution.
function resolveVirtualFileUrl(src: string): string {
const base = getRuntimeManager().httpURL;
if (!base.pathname.endsWith("/")) {
base.pathname += "/";
}
return new URL(src.replace(/^\.\//, ""), base).toString();
}

// Rewrite relative @file virtual-file URLs to absolute URLs so they resolve
// correctly even when the page URL has no trailing slash (e.g., molab edit mode).
// The virtual file URL is generated as "./@file/SIZE-filename" (relative). When
// the page URL has no trailing slash (e.g., /notebooks/nb_xxx), the browser
// resolves "./@file/..." to "/notebooks/@file/..." — dropping the notebook ID.
// We fix this by resolving against the runtime base URL with a guaranteed
// trailing slash, making the URL unambiguous.
const VIRTUAL_FILE_SRC_TAGS = new Set(["img", "source", "audio", "video"]);
const replaceVirtualFileSrc = (domNode: DOMNode): JSX.Element | undefined => {
if (
domNode instanceof Element &&
VIRTUAL_FILE_SRC_TAGS.has(domNode.name) &&
domNode.attribs?.src
) {
const src = domNode.attribs.src;
if (src.includes("/@file/") || src.startsWith("@file/")) {
const absoluteSrc = resolveVirtualFileUrl(src);
domNode.attribs.src = absoluteSrc;
return undefined;
}
}
};

const replaceValidTags = (domNode: DOMNode) => {
// Don't render invalid tags
Expand Down Expand Up @@ -87,6 +121,13 @@ const replaceValidIframes = (domNode: DOMNode) => {
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
// Rewrite relative @file URLs to absolute (same fix as replaceVirtualFileSrc)
if (
key === "src" &&
(value.includes("/@file/") || value.startsWith("@file/"))
) {
value = resolveVirtualFileUrl(value);
}
element.setAttribute(key, value);
});
return <div dangerouslySetInnerHTML={{ __html: element.outerHTML }} />;
Expand Down Expand Up @@ -275,6 +316,7 @@ function parseHtml({
additionalReplacements = [],
}: Pick<Options, "html" | "additionalReplacements">) {
const renderFunctions: ReplacementFn[] = [
replaceVirtualFileSrc,
replaceValidTags,
replaceValidIframes,
replaceSrcScripts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,44 @@ describe("renderHTML sanitization integration", () => {
);
});
});

describe("replaceVirtualFileSrc - virtual file URL rewriting", () => {
test("rewrites ./@file/ img src to absolute URL", () => {
const html = '<img src="./@file/12345-abc.png" alt="test">';
const { container } = render(
renderHTML({ html, alwaysSanitizeHtml: false }),
);
const img = container.querySelector("img");
expect(img).not.toBeNull();
// Should be absolute, not relative
expect(img?.getAttribute("src")).not.toMatch(/^\.\//);
expect(img?.getAttribute("src")).toContain("/@file/12345-abc.png");
});

test("rewrites @file/ img src (no leading ./) to absolute URL", () => {
const html = '<img src="@file/12345-abc.png" alt="test">';
const { container } = render(
renderHTML({ html, alwaysSanitizeHtml: false }),
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toContain("/@file/12345-abc.png");
});

test("does not rewrite non-@file img src", () => {
const html = '<img src="https://example.com/image.png" alt="test">';
const { container } = render(
renderHTML({ html, alwaysSanitizeHtml: false }),
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe("https://example.com/image.png");
});

test("does not rewrite data: URL img src", () => {
const html = '<img src="data:image/png;base64,abc==" alt="test">';
const { container } = render(
renderHTML({ html, alwaysSanitizeHtml: false }),
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe("data:image/png;base64,abc==");
});
});
Loading