Files
multica/packages/views/editor/utils/preview.test.ts
Naiyuan Qing 454c8e3d1a feat: in-app preview for non-image attachments (#2528)
* 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>
2026-05-13 18:24:15 +08:00

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();
});
});