mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +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>
186 lines
5.4 KiB
TypeScript
186 lines
5.4 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|