mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
fix(markdown): allow attachment download file-card hrefs
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -15,22 +15,36 @@
|
||||
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
|
||||
|
||||
// Keep in sync with UUID_RE in packages/core/types/attachment-url.ts.
|
||||
const ATTACHMENT_UUID_SOURCE =
|
||||
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
|
||||
const ATTACHMENT_DOWNLOAD_URL_SOURCE = `/api/attachments/${ATTACHMENT_UUID_SOURCE}/download`
|
||||
const ATTACHMENT_DOWNLOAD_URL_RE = new RegExp(
|
||||
`^${ATTACHMENT_DOWNLOAD_URL_SOURCE}$`,
|
||||
)
|
||||
|
||||
/**
|
||||
* URL alternation accepted inside `!file[name](url)` markdown.
|
||||
*
|
||||
* Restricted to:
|
||||
* - `/uploads/...` site-relative paths (LocalStorage backend with no LOCAL_UPLOAD_BASE_URL)
|
||||
* - `/api/attachments/<UUID>/download` site-relative attachment downloads
|
||||
* - `http(s)://...` absolute URLs (S3 / CloudFront / hosted)
|
||||
*
|
||||
* Anything else — `javascript:`, `data:`, protocol-relative `//host/x`, other
|
||||
* APIs `/api/…`, path-traversal `/../…` — is rejected so a stored file-card
|
||||
* cannot be turned into an out-of-band navigation.
|
||||
*/
|
||||
export const FILE_CARD_URL_PATTERN = /\/uploads\/[^)]*|https?:\/\/[^)]+/
|
||||
export const FILE_CARD_URL_PATTERN = new RegExp(
|
||||
`/uploads/[^)]*|https?:\\/\\/[^)]+|${ATTACHMENT_DOWNLOAD_URL_SOURCE}`,
|
||||
)
|
||||
|
||||
/** Prefix test applied by renderers to validate `data-href` before opening it. */
|
||||
export function isAllowedFileCardHref(href: string): boolean {
|
||||
return /^(https?:\/\/|\/uploads\/)/i.test(href)
|
||||
return (
|
||||
/^(https?:\/\/|\/uploads\/)/i.test(href) ||
|
||||
ATTACHMENT_DOWNLOAD_URL_RE.test(href)
|
||||
)
|
||||
}
|
||||
|
||||
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
|
||||
|
||||
@@ -564,6 +564,31 @@ describe("Attachment — file-card dispatch", () => {
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("url-only stable attachment download file-card resolves to record and downloads by id", () => {
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
const href = `/api/attachments/${id}/download`;
|
||||
resolverState.attachments = [
|
||||
makeRecord({
|
||||
id,
|
||||
filename: "manual.pdf",
|
||||
content_type: "application/pdf",
|
||||
url: "/uploads/manual.pdf",
|
||||
markdown_url: href,
|
||||
download_url: href,
|
||||
}),
|
||||
];
|
||||
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{ kind: "url", url: href, filename: "manual.pdf" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("manual.pdf")).toBeTruthy();
|
||||
fireEvent.mouseDown(screen.getByTitle("Download"));
|
||||
expect(downloadMock).toHaveBeenCalledWith(id);
|
||||
});
|
||||
|
||||
it("uploading file-card surfaces the uploading template, no Preview/Download", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
|
||||
@@ -6,30 +6,53 @@ import {
|
||||
preprocessFileCards,
|
||||
} from "@multica/ui/markdown";
|
||||
|
||||
const ATTACHMENT_ID = "11111111-2222-3333-4444-555555555555";
|
||||
const ATTACHMENT_DOWNLOAD = `/api/attachments/${ATTACHMENT_ID}/download`;
|
||||
|
||||
const allowedClickHrefs = [
|
||||
"/uploads/ok",
|
||||
"/uploads/workspaces/abc/file.png",
|
||||
"https://cdn.example.com/x",
|
||||
"http://localhost:8080/uploads/x.png",
|
||||
"HTTPS://CDN.EXAMPLE.COM/x",
|
||||
ATTACHMENT_DOWNLOAD,
|
||||
];
|
||||
|
||||
const parsedAllowedFileCardHrefs = [
|
||||
"/uploads/x.md",
|
||||
"/uploads/workspaces/abc/019e.md",
|
||||
"https://cdn.example.com/x.md",
|
||||
"http://localhost:8080/uploads/x.md",
|
||||
ATTACHMENT_DOWNLOAD,
|
||||
];
|
||||
|
||||
const rejectedFileCardHrefs = [
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
"data:text/html,xss",
|
||||
"//evil.com/x",
|
||||
"/../api/x",
|
||||
"/api/x",
|
||||
"/api/internal/x",
|
||||
`/api/attachments/${ATTACHMENT_ID}/content`,
|
||||
`/api/attachments/${ATTACHMENT_ID}`,
|
||||
"/api/attachments/not-a-uuid/download",
|
||||
"/api/attachments//download",
|
||||
`/api/attachments/${ATTACHMENT_ID}/download/../../x`,
|
||||
`/api/attachments/${ATTACHMENT_ID}/download?x=1`,
|
||||
`/api/attachments/${ATTACHMENT_ID}/download#fragment`,
|
||||
"",
|
||||
"ftp://example.com/x",
|
||||
"uploads/x",
|
||||
];
|
||||
|
||||
describe("isAllowedFileCardHref", () => {
|
||||
it.each([
|
||||
["/uploads/ok", true],
|
||||
["/uploads/workspaces/abc/file.png", true],
|
||||
["https://cdn.example.com/x", true],
|
||||
["http://localhost:8080/uploads/x.png", true],
|
||||
["HTTPS://CDN.EXAMPLE.COM/x", true],
|
||||
])("accepts %s", (href, expected) => {
|
||||
expect(isAllowedFileCardHref(href)).toBe(expected);
|
||||
it.each(allowedClickHrefs)("accepts %s", (href) => {
|
||||
expect(isAllowedFileCardHref(href)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["javascript:alert(1)", false],
|
||||
["JavaScript:alert(1)", false],
|
||||
["data:text/html,xss", false],
|
||||
["//evil.com/x", false],
|
||||
["/../api/x", false],
|
||||
["/api/x", false],
|
||||
["/api/internal/x", false],
|
||||
["", false],
|
||||
["ftp://example.com/x", false],
|
||||
["uploads/x", false],
|
||||
])("rejects %s", (href, expected) => {
|
||||
expect(isAllowedFileCardHref(href)).toBe(expected);
|
||||
it.each(rejectedFileCardHrefs)("rejects %s", (href) => {
|
||||
expect(isAllowedFileCardHref(href)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,25 +62,20 @@ describe("FILE_CARD_URL_PATTERN", () => {
|
||||
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
|
||||
);
|
||||
|
||||
it.each([
|
||||
"!file[doc.md](/uploads/x.md)",
|
||||
"!file[name](/uploads/workspaces/abc/019e.md)",
|
||||
"!file[doc.md](https://cdn.example.com/x.md)",
|
||||
"!file[doc.md](http://localhost:8080/uploads/x.md)",
|
||||
])("parses %s", (input) => {
|
||||
expect(parser.test(input)).toBe(true);
|
||||
it.each(parsedAllowedFileCardHrefs)("parses %s", (href) => {
|
||||
expect(parser.test(`!file[doc.md](${href})`)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(rejectedFileCardHrefs)("does not parse %s", (href) => {
|
||||
expect(parser.test(`!file[doc.md](${href})`)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"!file[evil.txt](javascript:alert(1))",
|
||||
"!file[evil.txt](data:text/html,xss)",
|
||||
"!file[evil.txt](//evil.com/x)",
|
||||
"!file[evil.txt](/../api/x)",
|
||||
"!file[evil.txt](/api/x)",
|
||||
"!file[doc.md](uploads/x.md)",
|
||||
"!file[doc.md](ftp://example.com/x)",
|
||||
])("does not parse %s", (input) => {
|
||||
expect(parser.test(input)).toBe(false);
|
||||
...parsedAllowedFileCardHrefs.map((href) => [href, true] as const),
|
||||
...rejectedFileCardHrefs.map((href) => [href, false] as const),
|
||||
])("matches the click gate for %s", (href, expected) => {
|
||||
expect(parser.test(`!file[doc.md](${href})`)).toBe(expected);
|
||||
expect(isAllowedFileCardHref(href)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +89,16 @@ describe("preprocessFileCards (integration)", () => {
|
||||
expect(out).toContain('data-filename="doc.md"');
|
||||
});
|
||||
|
||||
it("converts !file[…](attachment download URL) into a file-card div", () => {
|
||||
const out = preprocessFileCards(
|
||||
`!file[doc.md](${ATTACHMENT_DOWNLOAD})`,
|
||||
cdn,
|
||||
);
|
||||
expect(out).toContain('data-type="fileCard"');
|
||||
expect(out).toContain(`data-href="${ATTACHMENT_DOWNLOAD}"`);
|
||||
expect(out).toContain('data-filename="doc.md"');
|
||||
});
|
||||
|
||||
it("leaves a protocol-relative href untouched (not parsed as file-card)", () => {
|
||||
const out = preprocessFileCards("!file[evil.txt](//evil.com/x)", cdn);
|
||||
expect(out).not.toContain('data-type="fileCard"');
|
||||
|
||||
@@ -470,6 +470,31 @@ describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
|
||||
// <p class="truncate"> row. HtmlAttachmentPreview replaces it entirely.
|
||||
expect(queryByText("report.html")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a stable attachment download URL as file-card chrome", () => {
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
const href = `/api/attachments/${id}/download`;
|
||||
const attachment = {
|
||||
id,
|
||||
url: "/uploads/report.pdf",
|
||||
filename: "report.pdf",
|
||||
content_type: "application/pdf",
|
||||
size_bytes: 1024,
|
||||
markdown_url: href,
|
||||
download_url: href,
|
||||
} as any;
|
||||
|
||||
const { container, getByText } = renderWithQuery(
|
||||
<ReadonlyContent
|
||||
content={`!file[report.pdf](${href})`}
|
||||
attachments={[attachment]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText("report.pdf")).toBeTruthy();
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReadonlyContent slash command rendering", () => {
|
||||
|
||||
@@ -86,3 +86,27 @@ describe("AttachmentList — standalone HTML attachment routes through Attachmen
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentList — inline attachment filtering", () => {
|
||||
it("does not render a bottom attachment row when the body already has the stable file-card URL", () => {
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
const href = `/api/attachments/${id}/download`;
|
||||
const attachment = {
|
||||
id,
|
||||
url: "/uploads/report.pdf",
|
||||
filename: "report.pdf",
|
||||
content_type: "application/pdf",
|
||||
size_bytes: 1024,
|
||||
} as any;
|
||||
|
||||
const { container } = renderWithQuery(
|
||||
<AttachmentList
|
||||
attachments={[attachment]}
|
||||
content={`!file[report.pdf](${href})`}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("report.pdf")).toBeNull();
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user