mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* 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> * 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> * 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> * 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> * 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|