mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 14:44:30 +02:00
Compare commits
5 Commits
quick-crea
...
feat/attac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63706539d3 | ||
|
|
b80e952414 | ||
|
|
c80c5f0b1b | ||
|
|
5704804e28 | ||
|
|
3264ffa931 |
@@ -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}`],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 | 主题 / 语言 |
|
||||
|
||||
@@ -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 | 主题 / 语言 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
249
packages/views/editor/attachment-preview-modal.test.tsx
Normal file
249
packages/views/editor/attachment-preview-modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
426
packages/views/editor/attachment-preview-modal.tsx
Normal file
426
packages/views/editor/attachment-preview-modal.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
90
packages/views/editor/utils/preview.test.ts
Normal file
90
packages/views/editor/utils/preview.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
185
packages/views/editor/utils/preview.ts
Normal file
185
packages/views/editor/utils/preview.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "复制链接",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user