Compare commits

...

5 Commits

Author SHA1 Message Date
Naiyuan Qing
63706539d3 chore(desktop): enable Chromium PDF viewer for attachment preview
Adds webPreferences.plugins: true to the main BrowserWindow so the
bundled Chromium PDFium plugin activates inside iframes — required for
the attachment preview modal's PDF dispatch. Default is false in Electron;
without it <iframe src=*.pdf> renders blank.

Security trade-off, accepted intentionally and documented inline:
  1. This window already runs with webSecurity: false + sandbox: false,
     so plugins: true does NOT meaningfully widen the renderer's attack
     surface beyond what is already accepted.
  2. The only PDFs that reach an iframe here are signed CloudFront URLs
     we ourselves issued; user-supplied URLs are routed through
     setWindowOpenHandler → openExternalSafely and cannot land in this
     renderer.
  3. Chromium's PDFium plugin is itself sandboxed and only handles
     application/pdf — no Flash/Java/other historical plugin surfaces.

If we ever tighten webSecurity / sandbox, the follow-up is to host the
PDF viewer in a dedicated BrowserView with plugins scoped to that view,
keeping the main renderer plugin-free.

Old desktop builds ship without the preview modal, so the Eye button
never appears and PDF preview is gated by the same release — zero
regression risk for users on stale clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:25:39 +08:00
Naiyuan Qing
b80e952414 feat(views/editor): add AttachmentPreviewModal + Eye entry points
In-app preview for non-image attachments. An Eye icon now sits next to
the existing Download button on file cards / readonly file cards / the
standalone AttachmentList. Clicking it opens a full-screen modal that
dispatches by content_type:

  pdf:      <iframe src={download_url}>           — Chromium PDFium
  video/*:  <video controls src={download_url}>   — native controls
  audio/*:  <audio controls src={download_url}>   — native controls
  md:       <ReadonlyContent>                     — full markdown pipeline
  html:     <iframe srcdoc sandbox="">            — fully restricted
  text:     <code class="hljs">                   — lowlight highlight

Media types render directly from the signed CloudFront download_url
(server marks them inline-disposition). Text types fetch through the
new /api/attachments/{id}/content proxy via TanStack Query, wrapped
in useAttachmentPreview() so each entry point owns its own modal
state without depending on a global Provider mount.

Modal sizing: max-w-6xl × min(90vh, 100vh - 2rem) — slightly larger
than create-issue's max-w-4xl since PDF / video need room, but capped
to viewport on small screens. Sub-renderers use h-full to follow the
fixed modal height instead of viewport-relative units.

Images are intentionally NOT touched — the existing ImageLightbox
(extensions/image-view.tsx) already handles them correctly. The new
modal would be churn without user-visible benefit.

Adds i18n keys under attachment.* (en + zh-Hans) and registers
Preview/Download/Upload in the conventions glossary so future
translations stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:25:31 +08:00
Naiyuan Qing
c80c5f0b1b feat(core/api): add getAttachmentTextContent + preview error types
Adds an ApiClient method that fetches the text body of an attachment via
the new /api/attachments/{id}/content proxy. Two typed errors —
PreviewTooLargeError (413) and PreviewUnsupportedError (415) — let the
preview modal render specific fallbacks instead of a generic failure.

Refactors the private fetch() into a shared fetchRaw() helper so the
new method inherits the standard infra: auth headers, 401 →
handleUnauthorized recovery, X-Request-ID, error logging, and the
ApiError contract. The previous draft bypassed all of these by calling
window.fetch directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:25:19 +08:00
Naiyuan Qing
5704804e28 feat(server): add attachment preview proxy endpoint
GET /api/attachments/{id}/content streams the raw bytes of a
text-previewable attachment back to the client. Exists to (a) bypass
CloudFront CORS, which is not configured on the CDN, and (b) bypass
Content-Disposition: attachment which Chromium honors for iframe document
loads. Media types (image/video/audio/pdf) intentionally do NOT go through
this endpoint — clients render them directly from the signed CloudFront
download_url, which is already served with Content-Disposition: inline.

Hard cap: 2 MB. Larger files return 413. Anything outside the text
whitelist returns 415. The whitelist (isTextPreviewable) mirrors the
client-side dispatcher; the cross-reference comment in file.go flags
the manual sync until a JSON SSOT generator lands.

Response always uses Content-Type: text/plain; charset=utf-8 so a
hostile HTML payload can't be re-interpreted as a document. The
original MIME ships via X-Original-Content-Type for client dispatch.
Cache-Control: no-store so revoked attachment access takes effect
immediately on the next request.

Tests cover happy path (md), extension fallback when content_type is
generic, 415 (pdf), 413 (>2MB), foreign workspace (404 isolation), and
the isTextPreviewable table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:25:12 +08:00
Naiyuan Qing
3264ffa931 feat(storage): add GetReader to Storage interface
Adds a streaming read method to the Storage abstraction so callers can
pull object bytes without forcing a full in-memory load. S3Storage wraps
GetObject; LocalStorage opens the file with path-traversal and sidecar
guards. Tests cover happy path, traversal rejection, sidecar rejection,
and missing key.

Used in the next commit by the attachment-preview proxy endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:25:03 +08:00
25 changed files with 1763 additions and 31 deletions

View File

@@ -133,6 +133,27 @@ function createWindow(): void {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
// Required for the Chromium PDF viewer (PDFium) to activate inside
// iframes — used by the attachment preview modal for application/pdf
// files. Default is false in Electron; without it <iframe src=*.pdf>
// renders blank.
//
// Security trade-off, accepted intentionally:
// 1. This window already runs with `webSecurity: false` + `sandbox: false`,
// so `plugins: true` does NOT meaningfully widen the renderer's
// attack surface beyond what is already accepted.
// 2. The only PDFs that reach an iframe here are signed CloudFront URLs
// we ourselves issued (see useDownloadAttachment); user-supplied URLs
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
// cannot land in this renderer.
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
// and only handles the `application/pdf` MIME — it does not expose
// Flash, Java, or other historical plugin surfaces.
//
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
// to that view, keeping the main renderer plugin-free.
plugins: true,
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});

View File

@@ -160,6 +160,7 @@ Chinese term reference:
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |

View File

@@ -160,6 +160,7 @@ Multica 的产品名词分两类:
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |

View File

@@ -200,6 +200,60 @@ describe("ApiClient", () => {
});
});
describe("getAttachmentTextContent", () => {
it("returns body text and the original content type from the X-* header", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("# heading\n\nbody\n", {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Original-Content-Type": "text/markdown",
},
}),
),
);
const client = new ApiClient("https://api.example.test");
const { text, originalContentType } =
await client.getAttachmentTextContent("att-1");
expect(text).toBe("# heading\n\nbody\n");
expect(originalContentType).toBe("text/markdown");
});
it("throws PreviewTooLargeError on 413", async () => {
const { PreviewTooLargeError } = await import("./client");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("", { status: 413, statusText: "Payload Too Large" }),
),
);
const client = new ApiClient("https://api.example.test");
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
PreviewTooLargeError,
);
});
it("throws PreviewUnsupportedError on 415", async () => {
const { PreviewUnsupportedError } = await import("./client");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
),
);
const client = new ApiClient("https://api.example.test");
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
PreviewUnsupportedError,
);
});
});
describe("chat attachment wiring", () => {
it("uploadFile includes chat_session_id in the FormData body", async () => {
const fetchMock = vi.fn().mockResolvedValue(

View File

@@ -196,6 +196,27 @@ export class ApiError extends Error {
}
}
// Thrown by getAttachmentTextContent when the server refuses to inline a
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
// download" affordance with the Download CTA still available.
export class PreviewTooLargeError extends Error {
constructor() {
super("attachment too large for inline preview");
this.name = "PreviewTooLargeError";
}
}
// Thrown by getAttachmentTextContent when the server's text whitelist
// rejects the content type. Normally the client's isPreviewable() guard
// catches this earlier, but the two whitelists can drift — surfacing the
// 415 as a typed error makes the drift visible.
export class PreviewUnsupportedError extends Error {
constructor() {
super("attachment type not supported for inline preview");
this.name = "PreviewUnsupportedError";
}
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
@@ -270,15 +291,23 @@ export class ApiClient {
}
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
// Sends the request with the standard headers (auth, CSRF, request id,
// client identity) and runs the shared error path (401 → handleUnauthorized,
// structured ApiError, status-aware log level). Returns the raw Response so
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
// path, plain text for the attachment-preview proxy, etc.
private async fetchRaw(
path: string,
init?: RequestInit & { extraHeaders?: Record<string, string> },
): Promise<Response> {
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Request-ID": rid,
...this.authHeaders(),
...(init?.extraHeaders ?? {}),
...((init?.headers as Record<string, string>) ?? {}),
};
@@ -299,12 +328,18 @@ export class ApiClient {
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
return res;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await this.fetchRaw(path, {
...init,
extraHeaders: { "Content-Type": "application/json" },
});
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
@@ -1192,6 +1227,38 @@ export class ApiClient {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
// Fetches the raw bytes of a text-previewable attachment.
//
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
// bypasses Content-Disposition: attachment for the `text/*` family, both
// of which would otherwise prevent the renderer from getting the body.
// The server always replies with `text/plain; charset=utf-8` for safety;
// the original MIME ships back in the `X-Original-Content-Type` header so
// the preview dispatcher can choose between markdown / html / plain code.
//
// Routes through `fetchRaw` so it inherits the standard auth headers,
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
// the modal can render specific fallbacks instead of generic failure.
async getAttachmentTextContent(
id: string,
): Promise<{ text: string; originalContentType: string }> {
let res: Response;
try {
res = await this.fetchRaw(`/api/attachments/${id}/content`);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 413) throw new PreviewTooLargeError();
if (err.status === 415) throw new PreviewUnsupportedError();
}
throw err;
}
return {
text: await res.text(),
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
};
}
// Projects
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
const search = new URLSearchParams();

View File

@@ -1,4 +1,9 @@
export { ApiClient, ApiError } from "./client";
export {
ApiClient,
ApiError,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "./client";
export type {
ApiClientOptions,
ImportStarterContentPayload,

View File

@@ -9,6 +9,10 @@ interface ResolvedDownload {
// Returns the attachment id for a URL referenced in the markdown, or
// `undefined` if it's an external link we don't manage.
resolveAttachmentId: (url: string) => string | undefined;
// Returns the full Attachment record (content_type, filename, download_url,
// ...) for a URL referenced in the markdown. NodeView preview triggers use
// this to decide whether the type is previewable and to feed the modal.
resolveAttachment: (url: string) => Attachment | undefined;
// Called by NodeView click handlers. Re-signs through `getAttachment` when
// the URL maps to a known attachment; falls back to `openExternal` for
// external URLs so Electron still routes through the IPC bridge instead of
@@ -36,12 +40,16 @@ export function AttachmentDownloadProvider({ attachments, children }: ProviderPr
if (!url || !attachments?.length) return undefined;
return attachments.find((a) => a.url === url)?.id;
},
resolveAttachment: (url) => {
if (!url || !attachments?.length) return undefined;
return attachments.find((a) => a.url === url);
},
openByUrl: (url) => {
const id = url && attachments?.length
? attachments.find((a) => a.url === url)?.id
const att = url && attachments?.length
? attachments.find((a) => a.url === url)
: undefined;
if (id) {
download(id);
if (att) {
download(att.id);
return;
}
if (url) openExternal(url);
@@ -70,6 +78,7 @@ export function useAttachmentDownloadResolver(): ResolvedDownload {
if (ctx) return ctx;
return {
resolveAttachmentId: () => undefined,
resolveAttachment: () => undefined,
openByUrl: (url) => {
if (url) openExternal(url);
},

View File

@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import type { Attachment } from "@multica/core/types";
// vi.hoisted: factories run before module evaluation, letting us name mocks
// referenced from inside vi.mock factories below. The Error classes must be
// hoisted too because vi.mock is itself hoisted above the top-level `class`
// declarations.
const {
getAttachmentTextContentMock,
downloadMock,
FakePreviewTooLargeError,
FakePreviewUnsupportedError,
} = vi.hoisted(() => {
class FakePreviewTooLargeError extends Error {
constructor() {
super("too large");
this.name = "PreviewTooLargeError";
}
}
class FakePreviewUnsupportedError extends Error {
constructor() {
super("unsupported");
this.name = "PreviewUnsupportedError";
}
}
return {
getAttachmentTextContentMock: vi.fn(),
downloadMock: vi.fn(),
FakePreviewTooLargeError,
FakePreviewUnsupportedError,
};
});
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
PreviewTooLargeError: FakePreviewTooLargeError,
PreviewUnsupportedError: FakePreviewUnsupportedError,
}));
vi.mock("./use-download-attachment", () => ({
useDownloadAttachment: () => downloadMock,
}));
// ReadonlyContent has a heavy import surface (lowlight + KaTeX + Mermaid).
// Stub it so the markdown dispatch test only verifies wiring.
vi.mock("./readonly-content", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
}));
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: { download: "Download" },
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
preview_too_large: "File is too large to preview. Please download.",
preview_unsupported: "This file type can't be previewed.",
close: "Close",
download_failed: "",
},
}),
}),
}));
import { AttachmentPreviewModal } from "./attachment-preview-modal";
// Fresh QueryClient per render — no retries (preview errors are typed,
// not transient) and no caching across tests so each scenario is hermetic.
function render(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return rtlRender(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
return {
id: "att-1",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "u-1",
filename: "test.bin",
url: "https://cdn.example.test/att-1.bin",
download_url: "https://cdn.example.test/att-1.bin?Signature=s",
content_type: "application/octet-stream",
size_bytes: 0,
created_at: "2026-05-13T00:00:00Z",
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("AttachmentPreviewModal — dispatch", () => {
it("renders a PDF iframe pointing at the signed download URL", () => {
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
const iframe = document.querySelector("iframe");
expect(iframe).toBeTruthy();
expect(iframe?.getAttribute("src")).toBe(att.download_url);
});
it("renders a <video> for video/* content types", () => {
const att = makeAttachment({ filename: "clip.mp4", content_type: "video/mp4" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
const video = document.querySelector("video");
expect(video).toBeTruthy();
expect(video?.getAttribute("src")).toBe(att.download_url);
});
it("renders an <audio> for audio/* content types", () => {
const att = makeAttachment({ filename: "note.mp3", content_type: "audio/mpeg" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
const audio = document.querySelector("audio");
expect(audio).toBeTruthy();
});
it("fetches text and hands it to ReadonlyContent for Markdown", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "# heading\n\nbody\n",
originalContentType: "text/markdown",
});
const att = makeAttachment({ filename: "README.md", content_type: "text/markdown" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
expect(getAttachmentTextContentMock).toHaveBeenCalledWith("att-1");
await waitFor(() => {
expect(screen.getByTestId("readonly-content")).toBeTruthy();
});
expect(screen.getByTestId("readonly-content").textContent).toContain("# heading");
});
it("renders an iframe with srcdoc + sandbox='' for HTML", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({ filename: "page.html", content_type: "text/html" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
await waitFor(() => {
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("");
expect(frame?.getAttribute("srcdoc")).toBe("<p>hi</p>");
});
});
it("renders a code block with lowlight for source files", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "package main\n",
originalContentType: "text/plain",
});
const att = makeAttachment({ filename: "main.go", content_type: "text/plain" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
await waitFor(() => {
const code = document.querySelector("code.hljs");
expect(code).toBeTruthy();
expect(code?.className).toContain("language-go");
});
});
it("shows unsupported fallback when no PreviewKind matches", () => {
const att = makeAttachment({ filename: "blob.zip", content_type: "application/zip" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
});
});
describe("AttachmentPreviewModal — error states", () => {
it("shows the too-large fallback on PreviewTooLargeError", async () => {
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewTooLargeError());
const att = makeAttachment({ filename: "huge.txt", content_type: "text/plain" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText("File is too large to preview. Please download.")).toBeTruthy();
});
});
it("shows the unsupported fallback on PreviewUnsupportedError (server/client drift)", async () => {
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewUnsupportedError());
const att = makeAttachment({ filename: "weird.txt", content_type: "text/plain" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
});
});
it("shows the generic failed fallback on a transport error", async () => {
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("network down"));
const att = makeAttachment({ filename: "x.md", content_type: "text/markdown" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText("Couldn't load preview")).toBeTruthy();
});
});
});
describe("AttachmentPreviewModal — controls", () => {
it("ESC closes the modal", () => {
const onClose = vi.fn();
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
render(<AttachmentPreviewModal attachment={att} open onClose={onClose} />);
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onClose).toHaveBeenCalled();
});
it("Download button invokes useDownloadAttachment with the attachment id", () => {
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
// Two Download CTAs may exist (header + unsupported fallback). The header
// button is always present, look it up by aria-label/title.
const buttons = screen.getAllByTitle("Download");
expect(buttons.length).toBeGreaterThan(0);
fireEvent.click(buttons[0]!);
expect(downloadMock).toHaveBeenCalledWith("att-1");
});
it("clicking the backdrop closes the modal", () => {
const onClose = vi.fn();
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
render(<AttachmentPreviewModal attachment={att} open onClose={onClose} />);
const dialog = screen.getByRole("dialog");
fireEvent.click(dialog);
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,426 @@
"use client";
/**
* AttachmentPreviewModal — full-screen inline preview for an attachment.
*
* Sibling to the existing `ImageLightbox` (extensions/image-view.tsx) which
* keeps owning images. This modal handles 6 other PreviewKinds:
*
* - pdf : <iframe src={download_url}> — relies on Chromium's PDFium
* plugin. On desktop, requires webPreferences.plugins=true
* (see apps/desktop/src/main/index.ts).
* - video : <video controls src={download_url}>
* - audio : <audio controls src={download_url}>
*
* - markdown : fetch text via api.getAttachmentTextContent, render via
* the existing ReadonlyContent (full mention/mermaid/katex
* pipeline included).
* - html : fetch text, hand to <iframe srcdoc={text} sandbox="">.
* Empty sandbox attribute = max restriction (no scripts,
* no forms, no top-nav, no popups, no same-origin) — the
* recommended pattern for previewing untrusted HTML.
* - text : fetch text, highlight with lowlight if the extension
* maps to a known hljs language; otherwise plain <pre>.
*
* Media types load directly from the CloudFront signed `download_url`.
* Text types go through `/api/attachments/{id}/content` to sidestep
* CloudFront CORS (not configured) + Content-Disposition: attachment.
*/
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { Download, FileText, Loader2, X } from "lucide-react";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { cn } from "@multica/ui/lib/utils";
import {
api,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "@multica/core/api";
import type { Attachment } from "@multica/core/types";
import { useT } from "../i18n";
import { ReadonlyContent } from "./readonly-content";
import {
extensionToLanguage,
getPreviewKind,
type PreviewKind,
} from "./utils/preview";
import { useDownloadAttachment } from "./use-download-attachment";
// ---------------------------------------------------------------------------
// Public props
// ---------------------------------------------------------------------------
interface AttachmentPreviewModalProps {
attachment: Attachment;
open: boolean;
onClose: () => void;
}
// ---------------------------------------------------------------------------
// Hook — local state + ready-to-mount modal JSX
// ---------------------------------------------------------------------------
//
// Why no React context / provider: packages/views/ cannot mount a Context.Provider
// inside CoreProvider (in packages/core/), and threading a new provider through
// every app layout is more friction than it's worth for a feature with at most
// one open modal at a time. Instead each entry point gets its own local state
// and renders the returned `modal` node. Multiple entry points coexisting just
// means each carries its own (collapsed) state — they never collide because
// only one preview is open per user click.
export interface AttachmentPreviewHandle {
/** Try to open a preview for the attachment. Returns false when the file
* type isn't previewable so the caller can fall back to a download flow. */
tryOpen: (attachment: Attachment) => boolean;
/** Force-open a preview, skipping the isPreviewable() guard. Use for cases
* where the caller has already filtered. */
open: (attachment: Attachment) => void;
/** Modal node to render somewhere in the caller's tree. Resolves to `null`
* when no preview is active. Safe to render inside any container — the
* modal portals to document.body. */
modal: ReactNode;
}
export function useAttachmentPreview(): AttachmentPreviewHandle {
const [current, setCurrent] = useState<Attachment | null>(null);
const open = useCallback((att: Attachment) => setCurrent(att), []);
const tryOpen = useCallback((att: Attachment) => {
if (!getPreviewKind(att.content_type, att.filename)) return false;
setCurrent(att);
return true;
}, []);
const modal = current ? (
<AttachmentPreviewModal
attachment={current}
open
onClose={() => setCurrent(null)}
/>
) : null;
return useMemo(() => ({ open, tryOpen, modal }), [open, tryOpen, modal]);
}
// ---------------------------------------------------------------------------
// Modal — frame + dispatch
// ---------------------------------------------------------------------------
export function AttachmentPreviewModal({
attachment,
open,
onClose,
}: AttachmentPreviewModalProps) {
const { t } = useT("editor");
const download = useDownloadAttachment();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
const kind = getPreviewKind(attachment.content_type, attachment.filename);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={attachment.filename}
>
{/* Larger than the create-issue dialog (max-w-4xl, manualDialogContentClass)
because PDF / video previews want more room. Capped to viewport
minus the surrounding p-4 (1rem each side) so it never overflows
the screen on small displays / split panes. */}
<div
className="flex h-[min(90vh,calc(100vh-2rem))] w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-background shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-4 py-2">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-medium">{attachment.filename}</p>
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
{attachment.content_type || "—"}
</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={() => download(attachment.id)}
>
<Download className="size-4" />
</button>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.close)}
aria-label={t(($) => $.attachment.close)}
onClick={onClose}
>
<X className="size-4" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-background">
<PreviewContent
kind={kind}
attachment={attachment}
onDownload={() => download(attachment.id)}
/>
</div>
</div>
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
// Dispatch on PreviewKind. New cases go here; remember that the modal frame
// (header, close, Download CTA, ESC handling) is shared — sub-renderers only
// own the content area.
function PreviewContent({
kind,
attachment,
onDownload,
}: {
kind: PreviewKind | null;
attachment: Attachment;
onDownload: () => void;
}) {
const { t } = useT("editor");
if (kind === null) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
switch (kind) {
case "pdf":
return (
<iframe
src={attachment.download_url}
className="h-full w-full bg-background"
title={attachment.filename}
/>
);
case "video":
return (
<div className="flex h-full w-full items-center justify-center bg-black">
<video
src={attachment.download_url}
controls
className="max-h-full max-w-full"
/>
</div>
);
case "audio":
return (
<div className="flex h-full w-full items-center justify-center p-8">
<audio src={attachment.download_url} controls className="w-full max-w-xl" />
</div>
);
case "markdown":
return (
<TextBackedPreview
attachmentId={attachment.id}
onDownload={onDownload}
render={(text) => (
<ReadonlyContent
content={text}
className="px-6 py-4"
attachments={[attachment]}
/>
)}
/>
);
case "html":
return (
<TextBackedPreview
attachmentId={attachment.id}
onDownload={onDownload}
render={(text) => (
<iframe
srcDoc={text}
sandbox=""
className="h-full w-full bg-background"
title={attachment.filename}
/>
)}
/>
);
case "text":
return (
<TextBackedPreview
attachmentId={attachment.id}
onDownload={onDownload}
render={(text) => (
<CodeBlock language={extensionToLanguage(attachment.filename)} body={text} />
)}
/>
);
}
}
// ---------------------------------------------------------------------------
// Text-backed preview — fetches body once, then hands to the render prop
// ---------------------------------------------------------------------------
// React Query owns server state per the project convention; re-opening the
// same attachment hits the cache instead of re-fetching. Query is keyed on
// the attachment id alone — the 30 min TTL on the server-side signed URL
// is much longer than any plausible preview session.
function TextBackedPreview({
attachmentId,
onDownload,
render,
}: {
attachmentId: string;
onDownload: () => void;
render: (text: string) => ReactNode;
}) {
const { t } = useT("editor");
const query = useQuery({
queryKey: ["attachment-content", attachmentId] as const,
queryFn: () => api.getAttachmentTextContent(attachmentId),
// Errors are surfaced as typed fallbacks, not retried — 413 / 415 won't
// become 200 on a retry, and a transient failure is easier to recover
// from by closing and reopening the modal than waiting on background
// retries that have no UI affordance.
retry: false,
// 413 / 415 bodies are tiny; keep the result around for the session so
// the user can flip away and back without refetching.
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
if (query.isLoading) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t(($) => $.attachment.preview_loading)}
</div>
);
}
if (query.error) {
if (query.error instanceof PreviewTooLargeError) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_too_large)}
onDownload={onDownload}
/>
);
}
if (query.error instanceof PreviewUnsupportedError) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_failed)}
onDownload={onDownload}
/>
);
}
if (!query.data) return null;
return <>{render(query.data.text)}</>;
}
// ---------------------------------------------------------------------------
// Code block — lowlight, matches readonly-content's hljs CSS
// ---------------------------------------------------------------------------
const lowlight = createLowlight(common);
function CodeBlock({ language, body }: { language: string | undefined; body: string }) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = language
? lowlight.highlight(language, code)
: lowlight.highlightAuto(code);
return toHtml(tree) as string;
} catch {
// Fallthrough to a plain escaped <pre> when lowlight rejects the
// language tag. Avoids crashing the preview on an unknown extension.
return escapeHtml(code);
}
}, [body, language]);
return (
<pre className="rich-text-editor m-0 overflow-auto px-6 py-4 text-sm">
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// Fallback — used for 413 / 415 / unknown kinds
// ---------------------------------------------------------------------------
function UnsupportedFallback({
message,
onDownload,
}: {
message: string;
onDownload: () => void;
}) {
const { t } = useT("editor");
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<FileText className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{message}</p>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-sm transition-colors hover:bg-muted"
onClick={onDownload}
>
<Download className="size-4" />
{t(($) => $.image.download)}
</button>
</div>
);
}
// Re-export the predicate from the dispatch util so entry-point components
// only need a single import to gate the Eye button.
export { isPreviewable } from "./utils/preview";

View File

@@ -17,9 +17,11 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { FileText, Loader2, Download } from "lucide-react";
import { Eye, FileText, Loader2, Download } from "lucide-react";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
import { useAttachmentPreview } from "../attachment-preview-modal";
import { isPreviewable } from "../utils/preview";
// ---------------------------------------------------------------------------
@@ -35,12 +37,23 @@ function FileCardView({ node }: NodeViewProps) {
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const { openByUrl } = useAttachmentDownloadResolver();
const { openByUrl, resolveAttachment } = useAttachmentDownloadResolver();
const preview = useAttachmentPreview();
const openFile = () => {
openByUrl(href);
};
// The NodeView only holds href + filename. The full Attachment (with
// content_type / download_url) lives in the surrounding
// AttachmentDownloadProvider — resolve it lazily at click time so the
// eye button is only offered when we both know the record and the
// dispatcher recognizes the type.
const attachment = href ? resolveAttachment(href) : undefined;
const previewable = attachment
? isPreviewable(attachment.content_type, attachment.filename)
: false;
return (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
<div
@@ -56,10 +69,27 @@ function FileCardView({ node }: NodeViewProps) {
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{uploading ? t(($) => $.file_card.uploading, { filename }) : filename}</p>
</div>
{!uploading && href && previewable && attachment && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
preview.tryOpen(attachment);
}}
>
<Eye className="size-3.5" />
</button>
)}
{!uploading && href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -70,6 +100,7 @@ function FileCardView({ node }: NodeViewProps) {
</button>
)}
</div>
{preview.modal}
</NodeViewWrapper>
);
}

View File

@@ -14,3 +14,9 @@ export { useFileDropZone } from "./use-file-drop-zone";
export { FileDropOverlay } from "./file-drop-overlay";
export { useDownloadAttachment } from "./use-download-attachment";
export { AttachmentDownloadProvider } from "./attachment-download-context";
export {
AttachmentPreviewModal,
useAttachmentPreview,
isPreviewable,
} from "./attachment-preview-modal";
export type { AttachmentPreviewHandle } from "./attachment-preview-modal";

View File

@@ -30,7 +30,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
import { Maximize2, Download, Eye, Link as LinkIcon, FileText } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
@@ -45,6 +45,8 @@ import { openLink, isMentionHref } from "./utils/link-handler";
import { preprocessMarkdown } from "./utils/preprocess";
import { MermaidDiagram } from "./mermaid-diagram";
import { useDownloadAttachment } from "./use-download-attachment";
import { useAttachmentPreview } from "./attachment-preview-modal";
import { isPreviewable } from "./utils/preview";
import "katex/dist/katex.min.css";
import "./content-editor.css";
@@ -239,18 +241,24 @@ function ReadonlyImage({
function ReadonlyFileCard({
href,
filename,
resolveAttachmentId,
resolveAttachment,
onDownload,
onPreview,
}: {
href: string;
filename: string;
resolveAttachmentId: (url: string) => string | undefined;
resolveAttachment: (url: string) => Attachment | undefined;
onDownload: (attachmentId: string) => void;
onPreview: (att: Attachment) => boolean;
}) {
const handleClick = () => {
const id = resolveAttachmentId(href);
if (id) {
onDownload(id);
const { t } = useT("editor");
const attachment = href ? resolveAttachment(href) : undefined;
const previewable = attachment
? isPreviewable(attachment.content_type, attachment.filename)
: false;
const handleDownloadClick = () => {
if (attachment) {
onDownload(attachment.id);
return;
}
openExternal(href);
@@ -261,11 +269,24 @@ function ReadonlyFileCard({
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{href && previewable && attachment && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onClick={() => onPreview(attachment)}
>
<Eye className="size-3.5" />
</button>
)}
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={handleClick}
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={handleDownloadClick}
>
<Download className="size-3.5" />
</button>
@@ -276,7 +297,9 @@ function ReadonlyFileCard({
function buildComponents(
resolveAttachmentId: (url: string) => string | undefined,
resolveAttachment: (url: string) => Attachment | undefined,
onDownload: (attachmentId: string) => void,
onPreview: (att: Attachment) => boolean,
): Partial<Components> {
return {
// Links — route mention:// to mention components, others show preview card
@@ -304,8 +327,9 @@ function buildComponents(
<ReadonlyFileCard
href={href}
filename={filename}
resolveAttachmentId={resolveAttachmentId}
resolveAttachment={resolveAttachment}
onDownload={onDownload}
onPreview={onPreview}
/>
);
}
@@ -410,9 +434,19 @@ export const ReadonlyContent = memo(function ReadonlyContent({
[attachments],
);
const resolveAttachment = useCallback(
(url: string): Attachment | undefined => {
if (!url || !attachments?.length) return undefined;
return attachments.find((a) => a.url === url);
},
[attachments],
);
const preview = useAttachmentPreview();
const components = useMemo(
() => buildComponents(resolveAttachmentId, download),
[resolveAttachmentId, download],
() => buildComponents(resolveAttachmentId, resolveAttachment, download, preview.tryOpen),
[resolveAttachmentId, resolveAttachment, download, preview.tryOpen],
);
return (
@@ -426,6 +460,7 @@ export const ReadonlyContent = memo(function ReadonlyContent({
{processed}
</ReactMarkdown>
<LinkHoverCard {...hover} />
{preview.modal}
</div>
);
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
extensionToLanguage,
getPreviewKind,
isPreviewable,
type PreviewKind,
} from "./preview";
describe("getPreviewKind", () => {
const cases: Array<[string, string, PreviewKind | null]> = [
// Media types — typed correctly server-side
["application/pdf", "manual.pdf", "pdf"],
["video/mp4", "clip.mp4", "video"],
["audio/mpeg", "note.mp3", "audio"],
// Markdown — both well-typed and sniffer-fallback paths
["text/markdown", "README", "markdown"],
["text/plain", "README.md", "markdown"],
["application/octet-stream", "notes.markdown", "markdown"],
// HTML — both content-type and extension paths
["text/html", "page", "html"],
["application/octet-stream", "page.html", "html"],
// Code / config — fallback to text after sniffer guesses "text/plain"
["text/plain", "main.go", "text"],
["application/octet-stream", "main.go", "text"],
["text/plain", "config.yml", "text"],
["application/javascript", "bundle.js", "text"],
["application/json", "data.json", "text"],
// Plain text
["text/plain", "log.txt", "text"],
// Build files without extension
["application/octet-stream", "Dockerfile", "text"],
["application/octet-stream", "Makefile", "text"],
// Out of scope
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "report.docx", null],
["application/octet-stream", "blob.bin", null],
["application/zip", "archive.zip", null],
];
for (const [ct, filename, want] of cases) {
it(`(${ct}, ${filename}) → ${want}`, () => {
expect(getPreviewKind(ct, filename)).toBe(want);
});
}
// PDF should dispatch from extension alone when content_type is wrong.
it("falls through to extension when content_type is mislabeled", () => {
expect(getPreviewKind("application/octet-stream", "manual.pdf")).toBe("pdf");
});
});
describe("isPreviewable", () => {
it("is true for any non-null PreviewKind", () => {
expect(isPreviewable("application/pdf", "x.pdf")).toBe(true);
expect(isPreviewable("text/plain", "x.txt")).toBe(true);
});
it("is false for unsupported types", () => {
expect(isPreviewable("application/zip", "x.zip")).toBe(false);
expect(isPreviewable("application/octet-stream", "x.bin")).toBe(false);
});
});
describe("extensionToLanguage", () => {
it("maps common code extensions to hljs language tokens", () => {
expect(extensionToLanguage("index.ts")).toBe("typescript");
expect(extensionToLanguage("main.go")).toBe("go");
expect(extensionToLanguage("script.py")).toBe("python");
expect(extensionToLanguage("style.scss")).toBe("scss");
});
it("falls back to plaintext for non-code text files", () => {
expect(extensionToLanguage("log.txt")).toBe("plaintext");
});
it("recognizes extension-less build files", () => {
expect(extensionToLanguage("Dockerfile")).toBe("dockerfile");
expect(extensionToLanguage("Makefile")).toBe("makefile");
});
it("returns undefined for unknown extensions", () => {
expect(extensionToLanguage("blob.bin")).toBeUndefined();
expect(extensionToLanguage("noextension")).toBeUndefined();
});
});

View File

@@ -0,0 +1,185 @@
/**
* Preview dispatch table for the AttachmentPreviewModal.
*
* Add new previewable kinds here. To add a type:
* 1. Add a new branch returning a new PreviewKind literal.
* 2. Add the corresponding renderer in attachment-preview-modal.tsx's dispatch.
* 3. If the renderer needs the file body as text, also extend isTextPreviewable
* in server/internal/handler/file.go so the proxy endpoint accepts it.
* 4. If the renderer fetches a binary, decide whether to use download_url
* (CloudFront, no auth on the client side) or a new authenticated proxy.
*/
export type PreviewKind =
| "pdf"
| "video"
| "audio"
| "markdown"
| "html"
| "text";
const EXT_LANGUAGE_MAP: Record<string, string> = {
// Markdown
md: "markdown",
markdown: "markdown",
// Plain text — left undefined intentionally; lowlight renders the body
// unhighlighted when no language is supplied.
txt: "plaintext",
log: "plaintext",
// Web
html: "xml",
htm: "xml",
xml: "xml",
svg: "xml",
css: "css",
scss: "scss",
sass: "scss",
less: "less",
// Config / data
json: "json",
yml: "yaml",
yaml: "yaml",
toml: "ini",
ini: "ini",
conf: "ini",
// Shell
sh: "bash",
bash: "bash",
zsh: "bash",
// Languages
py: "python",
rb: "ruby",
go: "go",
rs: "rust",
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
cc: "cpp",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
php: "php",
lua: "lua",
vim: "vim",
sql: "sql",
csv: "plaintext",
tsv: "plaintext",
};
// Build files that are commonly extension-less.
const BASENAME_LANGUAGE_MAP: Record<string, string> = {
dockerfile: "dockerfile",
makefile: "makefile",
};
// IMPORTANT — KEEP IN SYNC with isTextPreviewable() in
// server/internal/handler/file.go. If an extension lands here but the proxy
// rejects it, the user sees a 415 fallback in the modal. If the proxy accepts
// but this set doesn't, the Eye button doesn't appear at all.
//
// TODO(follow-up): extract to a JSON single-source-of-truth + generator
// (mirror reserved-slugs pattern in server/internal/handler/reserved_slugs.json).
const TEXT_EXTENSIONS = new Set<string>([
"md", "markdown", "txt", "log", "csv", "tsv",
"html", "htm", "json", "xml",
"yml", "yaml", "toml", "ini", "conf",
"sh", "bash", "zsh",
"py", "rb", "go", "rs",
"ts", "tsx", "js", "jsx", "mjs", "cjs",
"css", "scss", "sass", "less",
"sql",
"java", "kt", "swift",
"c", "cc", "cpp", "h", "hpp",
"cs", "php", "lua", "vim",
]);
const TEXT_CONTENT_TYPES = new Set<string>([
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/x-httpd-php",
]);
const TEXT_BASENAMES = new Set<string>(["dockerfile", "makefile"]);
function extOf(filename: string): string {
const base = filename.toLowerCase().split(/[\\/]/).pop() ?? "";
const dot = base.lastIndexOf(".");
if (dot <= 0) return "";
return base.slice(dot + 1);
}
function baseOf(filename: string): string {
return (filename.toLowerCase().split(/[\\/]/).pop() ?? "").trim();
}
function normalizeContentType(contentType: string): string {
const ct = (contentType ?? "").toLowerCase().trim();
const semi = ct.indexOf(";");
return (semi >= 0 ? ct.slice(0, semi) : ct).trim();
}
function isTextLike(contentType: string, filename: string): boolean {
const ct = normalizeContentType(contentType);
if (ct.startsWith("text/")) return true;
if (TEXT_CONTENT_TYPES.has(ct)) return true;
const ext = extOf(filename);
if (ext && TEXT_EXTENSIONS.has(ext)) return true;
return TEXT_BASENAMES.has(baseOf(filename));
}
// Dispatch on PreviewKind. New cases go in attachment-preview-modal.tsx;
// remember that the modal frame (header, close, Download CTA, ESC handling)
// is shared — sub-renderers only own the content area.
export function getPreviewKind(
contentType: string,
filename: string,
): PreviewKind | null {
const ct = normalizeContentType(contentType);
if (ct === "application/pdf" || extOf(filename) === "pdf") return "pdf";
if (ct.startsWith("video/")) return "video";
if (ct.startsWith("audio/")) return "audio";
// Markdown — covers both the well-typed case and the common
// server-side sniffer fallback (text/plain for .md).
const ext = extOf(filename);
if (ct === "text/markdown" || ext === "md" || ext === "markdown") {
return "markdown";
}
if (ct === "text/html" || ext === "html" || ext === "htm") {
return "html";
}
if (isTextLike(contentType, filename)) return "text";
return null;
}
export function isPreviewable(contentType: string, filename: string): boolean {
return getPreviewKind(contentType, filename) !== null;
}
// Pick the hljs language token for a file. Returns undefined when the file
// doesn't have a recognizable extension — callers can fall back to a plain
// `<pre>` render. Kept tiny and ext-driven on purpose: lowlight's `common`
// pack covers the ~50 languages people upload in practice, anything else
// rendered as plain text is preferable to importing the full pack.
export function extensionToLanguage(filename: string): string | undefined {
const ext = extOf(filename);
if (ext && EXT_LANGUAGE_MAP[ext]) return EXT_LANGUAGE_MAP[ext];
const base = baseOf(filename);
if (BASENAME_LANGUAGE_MAP[base]) return BASENAME_LANGUAGE_MAP[base];
return undefined;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { CheckCircle2, ChevronRight, Copy, Download, Eye, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
import { cn } from "@multica/ui/lib/utils";
import { useActorName } from "@multica/core/workspace/hooks";
import { timeAgo } from "@multica/core/utils";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment } from "../../editor";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, isPreviewable } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -122,7 +122,9 @@ function DeleteCommentDialog({
// ---------------------------------------------------------------------------
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
const { t } = useT("editor");
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
@@ -156,15 +158,29 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{a.filename}</p>
</div>
{isPreviewable(a.content_type, a.filename) && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onClick={() => preview.tryOpen(a)}
>
<Eye className="size-3.5" />
</button>
)}
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={() => download(a.id)}
>
<Download className="size-3.5" />
</button>
</div>
))}
{preview.modal}
</div>
);
}

View File

@@ -113,6 +113,14 @@ vi.mock("../../editor", () => ({
// real API singleton; tests that care about download wiring should write
// dedicated specs against `use-download-attachment.test.tsx`.
useDownloadAttachment: () => vi.fn(),
// Inert preview hook — comment-card's AttachmentList uses it to gate the
// Eye button. Dedicated coverage lives in attachment-preview-modal.test.tsx.
useAttachmentPreview: () => ({
open: vi.fn(),
tryOpen: () => false,
modal: null,
}),
isPreviewable: () => false,
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),

View File

@@ -34,7 +34,13 @@
"copy_link_failed": "Failed to copy link"
},
"attachment": {
"download_failed": "Couldn't fetch a download link. Try again in a moment."
"download_failed": "Couldn't fetch a download link. Try again in a moment.",
"preview": "Preview",
"preview_loading": "Loading preview…",
"preview_failed": "Couldn't load preview",
"preview_too_large": "File is too large to preview. Please download.",
"preview_unsupported": "This file type can't be previewed.",
"close": "Close"
},
"link_hover": {
"copy_link": "Copy link",

View File

@@ -34,7 +34,13 @@
"copy_link_failed": "复制链接失败"
},
"attachment": {
"download_failed": "下载链接获取失败,请稍后重试。"
"download_failed": "下载链接获取失败,请稍后重试。",
"preview": "预览",
"preview_loading": "加载预览中…",
"preview_failed": "预览加载失败",
"preview_too_large": "文件太大,无法在线预览,请下载查看。",
"preview_unsupported": "该文件类型暂不支持预览。",
"close": "关闭"
},
"link_hover": {
"copy_link": "复制链接",

View File

@@ -420,6 +420,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// Attachments
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
r.Get("/api/attachments/{id}/content", h.GetAttachmentContent)
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
// Comments

View File

@@ -29,6 +29,12 @@ var extContentTypes = map[string]string{
const maxUploadSize = 100 << 20 // 100 MB
// maxPreviewTextSize caps the body the preview proxy will load into memory
// for text-based types. Anything larger returns 413 and the UI falls back
// to "please download". Sized so a typical README/source-file fits but a
// 100 MB log dump can't blow up the renderer.
const maxPreviewTextSize = 2 << 20 // 2 MB
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
@@ -360,6 +366,163 @@ func (h *Handler) GetAttachmentByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
}
// ---------------------------------------------------------------------------
// GetAttachmentContent — GET /api/attachments/{id}/content
//
// Streams the raw bytes of a text-previewable attachment back to the client.
// Exists to (a) bypass CloudFront CORS (not configured) and (b) bypass
// Content-Disposition: attachment which Chromium honors for iframe document
// loads. Media types (image/video/audio/pdf) intentionally do NOT go through
// this endpoint — clients render them directly from the CloudFront signed
// download_url, which already serves them with Content-Disposition: inline
// (see storage/util.go isInlineContentType).
//
// Hard cap: 2 MB. Larger files return 413. Anything outside the text
// whitelist returns 415.
// ---------------------------------------------------------------------------
func (h *Handler) GetAttachmentContent(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
ID: attUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
return
}
if !isTextPreviewable(att.ContentType, att.Filename) {
writeError(w, http.StatusUnsupportedMediaType, "preview not supported for this file type")
return
}
if h.Storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage not configured")
return
}
key := h.Storage.KeyFromURL(att.Url)
reader, err := h.Storage.GetReader(r.Context(), key)
if err != nil {
slog.Error("failed to open attachment for preview", "id", attachmentID, "key", key, "error", err)
writeError(w, http.StatusNotFound, "attachment object not found")
return
}
defer reader.Close()
// LimitReader to maxPreviewTextSize+1 so we can detect "exactly at the
// limit" vs "exceeds the limit" by checking the returned length.
body, err := io.ReadAll(io.LimitReader(reader, maxPreviewTextSize+1))
if err != nil {
slog.Error("failed to read attachment body for preview", "id", attachmentID, "error", err)
writeError(w, http.StatusBadGateway, "failed to read attachment body")
return
}
if len(body) > maxPreviewTextSize {
writeError(w, http.StatusRequestEntityTooLarge, "file too large for inline preview")
return
}
// Always reply as text/plain so a hostile HTML payload can't be
// re-interpreted as a document by the browser. The original MIME is
// surfaced via X-Original-Content-Type for the client-side dispatcher.
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Original-Content-Type", att.ContentType)
// No-store: workspace membership / attachment ACL can change between
// requests (member removed, attachment deleted). A cached body would
// stay readable past the revocation window. The redundant request is
// fine here — bodies are capped at 2 MB and the endpoint is only hit
// when a user explicitly opens a preview.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
if _, err := w.Write(body); err != nil {
slog.Error("failed to write attachment preview body", "id", attachmentID, "error", err)
}
}
// isTextPreviewable is the whitelist for the text preview proxy.
//
// IMPORTANT — KEEP IN SYNC with the client-side mirror in
// packages/views/editor/utils/preview.ts (TEXT_EXTENSIONS / TEXT_CONTENT_TYPES
// / TEXT_BASENAMES + extensionToLanguage). If a type is allowed here but not
// mapped client-side the user sees raw unhighlighted text; if mapped client-side
// but rejected here the user sees a 415 fallback.
//
// TODO(follow-up): extract this list to a JSON single-source-of-truth and
// generate the TS side, mirroring the reserved-slugs pattern (see
// server/internal/handler/reserved_slugs.json + scripts/generate-reserved-slugs.mjs).
// Drift severity here is low (worst case: Eye button visible but proxy 415s,
// modal shows the unsupported fallback — still functional, just confusing),
// so it ships as manual hand-sync for v1.
//
// We check both content_type and extension because http.DetectContentType
// regularly returns "text/plain" for Markdown / source code, so a pure
// content-type check would 415 those.
func isTextPreviewable(contentType, filename string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
// Strip params (e.g. "text/plain; charset=utf-8")
if idx := strings.Index(ct, ";"); idx >= 0 {
ct = strings.TrimSpace(ct[:idx])
}
if strings.HasPrefix(ct, "text/") {
return true
}
switch ct {
case "application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/x-httpd-php":
return true
}
ext := strings.ToLower(path.Ext(filename))
switch ext {
case ".md", ".markdown",
".txt", ".log",
".csv", ".tsv",
".html", ".htm",
".json", ".xml",
".yml", ".yaml", ".toml", ".ini", ".conf",
".sh", ".bash", ".zsh",
".py", ".rb", ".go", ".rs",
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".css", ".scss", ".sass", ".less",
".sql",
".java", ".kt", ".swift",
".c", ".cc", ".cpp", ".h", ".hpp",
".cs", ".php", ".lua", ".vim",
".dockerfile", ".makefile", ".gitignore":
return true
}
// Filenames without extension that match well-known build files.
base := strings.ToLower(path.Base(filename))
switch base {
case "dockerfile", "makefile", ".env":
return true
}
return false
}
// ---------------------------------------------------------------------------
// DeleteAttachment — DELETE /api/attachments/{id}
// ---------------------------------------------------------------------------

View File

@@ -5,10 +5,15 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
)
// createHandlerTestChatSession seeds a chat_session row owned by testUserID
@@ -31,16 +36,47 @@ func createHandlerTestChatSession(t *testing.T, agentID string) string {
return sessionID
}
type mockStorage struct{}
// mockStorage is a tiny in-memory Storage stand-in. Upload records the bytes
// keyed by the storage key so GetReader can round-trip them in tests; KeyFromURL
// strips the synthetic CDN host so consumers can pass either the URL or the
// raw key.
type mockStorage struct {
mu sync.Mutex
files map[string][]byte
}
func (m *mockStorage) Upload(_ context.Context, key string, _ []byte, _ string, _ string) (string, error) {
func (m *mockStorage) Upload(_ context.Context, key string, data []byte, _ string, _ string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.files == nil {
m.files = map[string][]byte{}
}
m.files[key] = append([]byte(nil), data...)
return fmt.Sprintf("https://cdn.example.com/%s", key), nil
}
func (m *mockStorage) Delete(_ context.Context, _ string) {}
func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {}
func (m *mockStorage) KeyFromURL(rawURL string) string { return rawURL }
func (m *mockStorage) CdnDomain() string { return "cdn.example.com" }
func (m *mockStorage) Delete(_ context.Context, key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.files, key)
}
func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {}
func (m *mockStorage) KeyFromURL(rawURL string) string {
const prefix = "https://cdn.example.com/"
if strings.HasPrefix(rawURL, prefix) {
return strings.TrimPrefix(rawURL, prefix)
}
return rawURL
}
func (m *mockStorage) CdnDomain() string { return "cdn.example.com" }
func (m *mockStorage) GetReader(_ context.Context, key string) (io.ReadCloser, error) {
m.mu.Lock()
defer m.mu.Unlock()
if data, ok := m.files[key]; ok {
return io.NopCloser(bytes.NewReader(data)), nil
}
return nil, fmt.Errorf("mockStorage GetReader: key not found: %q", key)
}
func TestUploadFileForeignWorkspace(t *testing.T) {
origStorage := testHandler.Storage
@@ -281,3 +317,189 @@ func TestUploadFile_RejectsForeignChatSession(t *testing.T) {
t.Fatalf("UploadFile with unknown chat_session_id: expected 4xx, got %d: %s", w.Code, w.Body.String())
}
}
// ---------------------------------------------------------------------------
// GetAttachmentContent tests (preview proxy)
// ---------------------------------------------------------------------------
// seedPreviewAttachment inserts an attachment row + writes the bytes into the
// active mockStorage. Returns the new attachment id. Caller is responsible for
// installing the mockStorage on testHandler before calling.
func seedPreviewAttachment(t *testing.T, store *mockStorage, key, filename, contentType string, body []byte) string {
t.Helper()
// Register the body so GetReader can find it via KeyFromURL → key.
url, err := store.Upload(context.Background(), key, body, contentType, filename)
if err != nil {
t.Fatalf("seed Upload: %v", err)
}
var id string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO attachment (workspace_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, 'member', $2, $3, $4, $5, $6)
RETURNING id::text
`, testWorkspaceID, testUserID, filename, url, contentType, len(body)).Scan(&id); err != nil {
t.Fatalf("seed attachment row: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, id)
})
return id
}
func newPreviewRequest(t *testing.T, attachmentID, workspaceID string) (*http.Request, *httptest.ResponseRecorder) {
t.Helper()
req := httptest.NewRequest("GET", "/api/attachments/"+attachmentID+"/content", nil)
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", workspaceID)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", attachmentID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
return req, httptest.NewRecorder()
}
func TestGetAttachmentContent_HappyPath_Markdown(t *testing.T) {
store := &mockStorage{}
origStorage := testHandler.Storage
testHandler.Storage = store
defer func() { testHandler.Storage = origStorage }()
body := []byte("# heading\n\nbody text\n")
id := seedPreviewAttachment(t, store, "preview-md-key.md", "preview.md", "text/markdown", body)
req, w := newPreviewRequest(t, id, testWorkspaceID)
testHandler.GetAttachmentContent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); got != string(body) {
t.Errorf("body = %q, want %q", got, body)
}
if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/plain; charset=utf-8", got)
}
if got := w.Header().Get("X-Original-Content-Type"); got != "text/markdown" {
t.Errorf("X-Original-Content-Type = %q, want text/markdown", got)
}
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
}
// Even when http.DetectContentType returned "text/plain" instead of "text/markdown"
// (a known sniffer quirk), the extension whitelist still grants access.
func TestGetAttachmentContent_AcceptsByExtensionWhenContentTypeIsGeneric(t *testing.T) {
store := &mockStorage{}
origStorage := testHandler.Storage
testHandler.Storage = store
defer func() { testHandler.Storage = origStorage }()
body := []byte("package main\n")
id := seedPreviewAttachment(t, store, "main-go-key.go", "main.go", "application/octet-stream", body)
req, w := newPreviewRequest(t, id, testWorkspaceID)
testHandler.GetAttachmentContent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
}
func TestGetAttachmentContent_Unsupported_PDF(t *testing.T) {
store := &mockStorage{}
origStorage := testHandler.Storage
testHandler.Storage = store
defer func() { testHandler.Storage = origStorage }()
id := seedPreviewAttachment(t, store, "pdf-key.pdf", "manual.pdf", "application/pdf", []byte("%PDF-1.4\n"))
req, w := newPreviewRequest(t, id, testWorkspaceID)
testHandler.GetAttachmentContent(w, req)
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want 415; body=%s", w.Code, w.Body.String())
}
}
func TestGetAttachmentContent_TooLarge(t *testing.T) {
store := &mockStorage{}
origStorage := testHandler.Storage
testHandler.Storage = store
defer func() { testHandler.Storage = origStorage }()
// One byte over the limit. Allocate ASCII so io.ReadAll has work to do.
big := bytes.Repeat([]byte("a"), maxPreviewTextSize+1)
id := seedPreviewAttachment(t, store, "huge-key.txt", "huge.txt", "text/plain", big)
req, w := newPreviewRequest(t, id, testWorkspaceID)
testHandler.GetAttachmentContent(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
}
}
func TestGetAttachmentContent_ForeignWorkspace(t *testing.T) {
store := &mockStorage{}
origStorage := testHandler.Storage
testHandler.Storage = store
defer func() { testHandler.Storage = origStorage }()
id := seedPreviewAttachment(t, store, "ws-mismatch.md", "note.md", "text/markdown", []byte("# secret\n"))
// Same attachment id, but request comes in scoped to a different workspace.
foreign := "00000000-0000-0000-0000-000000000099"
req, w := newPreviewRequest(t, id, foreign)
testHandler.GetAttachmentContent(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String())
}
}
func TestGetAttachmentContent_NotFound(t *testing.T) {
store := &mockStorage{}
origStorage := testHandler.Storage
testHandler.Storage = store
defer func() { testHandler.Storage = origStorage }()
req, w := newPreviewRequest(t, "00000000-0000-0000-0000-000000000abc", testWorkspaceID)
testHandler.GetAttachmentContent(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String())
}
}
// isTextPreviewable is the whitelist linkpin between the proxy and the
// client-side dispatcher. Regress against the most common content types so
// drifting one of the lists alone fails loud.
func TestIsTextPreviewable(t *testing.T) {
t.Helper()
cases := []struct {
name string
contentType string
filename string
want bool
}{
{"markdown by ext", "application/octet-stream", "README.md", true},
{"markdown by mime", "text/markdown", "README", true},
{"plain text", "text/plain", "log.txt", true},
{"json by mime", "application/json", "data.json", true},
{"yaml by ext", "application/octet-stream", "config.yml", true},
{"go source", "text/plain", "main.go", true},
{"typescript", "application/octet-stream", "index.ts", true},
{"html", "text/html", "page.html", true},
{"dockerfile no ext", "application/octet-stream", "Dockerfile", true},
{"pdf rejected", "application/pdf", "doc.pdf", false},
{"png rejected", "image/png", "shot.png", false},
{"video rejected", "video/mp4", "clip.mp4", false},
{"binary fallthrough", "application/octet-stream", "blob.bin", false},
{"docx rejected", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "report.docx", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isTextPreviewable(tc.contentType, tc.filename); got != tc.want {
t.Errorf("isTextPreviewable(%q, %q) = %v, want %v", tc.contentType, tc.filename, got, tc.want)
}
})
}
}

View File

@@ -82,6 +82,28 @@ func (s *LocalStorage) KeyFromURL(rawURL string) string {
return rawURL
}
// GetReader opens the underlying file for streaming. Refuses keys that
// resolve outside uploadDir (defense against a stored key with traversal
// components) and refuses the sidecar suffix so /content can't be coaxed
// into leaking the .meta.json blob.
func (s *LocalStorage) GetReader(ctx context.Context, key string) (io.ReadCloser, error) {
if key == "" {
return nil, fmt.Errorf("local GetReader: empty key")
}
if strings.HasSuffix(key, metaSuffix) {
return nil, fmt.Errorf("local GetReader: refusing to serve sidecar key %q", key)
}
filePath := filepath.Join(s.uploadDir, key)
if !isUnder(s.uploadDir, filePath) {
return nil, fmt.Errorf("local GetReader: key escapes upload dir: %q", key)
}
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("local GetReader: %w", err)
}
return f, nil
}
func (s *LocalStorage) Delete(ctx context.Context, key string) {
if key == "" {
return

View File

@@ -2,6 +2,7 @@ package storage
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -428,3 +429,84 @@ func TestLocalStorage_Delete_RemovesSidecar(t *testing.T) {
t.Errorf("sidecar should be removed after Delete, got err=%v", err)
}
}
// GetReader returns the uploaded bytes verbatim — used by the preview proxy.
func TestLocalStorage_GetReader_RoundTrip(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
key := "preview.md"
body := []byte("# hello\nworld\n")
if _, err := store.Upload(ctx, key, body, "text/markdown", "preview.md"); err != nil {
t.Fatalf("Upload failed: %v", err)
}
rc, err := store.GetReader(ctx, key)
if err != nil {
t.Fatalf("GetReader: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("io.ReadAll: %v", err)
}
if string(got) != string(body) {
t.Errorf("body = %q, want %q", got, body)
}
}
// Refuses path traversal at storage layer so callers don't need to defend it.
func TestLocalStorage_GetReader_RejectsTraversal(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if rc, err := store.GetReader(context.Background(), "../../../etc/passwd"); err == nil {
rc.Close()
t.Fatal("GetReader should refuse traversal keys")
}
}
// The sidecar JSON is an internal detail. Allowing /content to read it via a
// crafted key would expose the original filename + content-type stored next
// to every upload.
func TestLocalStorage_GetReader_RejectsSidecarSuffix(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if rc, err := store.GetReader(context.Background(), "some-key.txt"+metaSuffix); err == nil {
rc.Close()
t.Fatal("GetReader should refuse sidecar keys")
}
}
// Missing key surfaces as a plain error — the handler maps it to 404.
func TestLocalStorage_GetReader_MissingKey(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if rc, err := store.GetReader(context.Background(), "nonexistent.txt"); err == nil {
rc.Close()
t.Fatal("GetReader should error on missing key")
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"os"
"strings"
@@ -152,6 +153,25 @@ func (s *S3Storage) KeyFromURL(rawURL string) string {
return rawURL
}
// GetReader streams the object body back to the caller. The returned
// ReadCloser must be closed; closing it terminates the underlying HTTP
// connection to S3. A missing key surfaces as an *types.NoSuchKey error
// wrapped in the SDK's smithy wrapper — callers can use errors.As to
// distinguish "not found" from a transport failure.
func (s *S3Storage) GetReader(ctx context.Context, key string) (io.ReadCloser, error) {
if key == "" {
return nil, fmt.Errorf("s3 GetReader: empty key")
}
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("s3 GetObject: %w", err)
}
return out.Body, nil
}
// Delete removes an object from S3. Errors are logged but not fatal.
func (s *S3Storage) Delete(ctx context.Context, key string) {
if key == "" {

View File

@@ -2,6 +2,7 @@ package storage
import (
"context"
"io"
)
type Storage interface {
@@ -10,4 +11,9 @@ type Storage interface {
DeleteKeys(ctx context.Context, keys []string)
KeyFromURL(rawURL string) string
CdnDomain() string
// GetReader streams an object back to the caller. Used by the attachment
// preview proxy (GET /api/attachments/{id}/content) to bypass CloudFront
// CORS and the inline/attachment Content-Disposition decision. Caller
// must Close the returned reader.
GetReader(ctx context.Context, key string) (io.ReadCloser, error)
}