Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
39 changes: 39 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);
const props = { ...domNode.attribs, src: absoluteSrc };
return React.createElement(domNode.name, props);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
}
};

const replaceValidTags = (domNode: DOMNode) => {
// Don't render invalid tags
Expand Down Expand Up @@ -87,6 +121,10 @@ 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 +313,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