Fix attachment download for self-hosted deployments using private S3-compatible buckets without CloudFront. Closes#3721.
**Server**
- New unified `GET /api/attachments/{id}/download` endpoint that picks CloudFront / S3 presign / server proxy at request time.
- `ATTACHMENT_DOWNLOAD_MODE=auto|cloudfront|presign|proxy` and `ATTACHMENT_DOWNLOAD_URL_TTL` env knobs; `auto` routes Docker hostnames / localhost / private IPs through the proxy and public S3 endpoints through presign.
- `Storage.PresignGet` capability; S3 implementation generates presigned GET URLs.
- `attachmentToResponse` returns the unified relative endpoint instead of leaking raw unsigned S3 URLs when CloudFront is not configured. Proxy path streams via `io.Copy` with `Content-Disposition` / `Content-Length` / `Cache-Control: no-store` / `X-Content-Type-Options: nosniff`.
**Clients**
- CLI / Desktop / Mobile resolve relative `download_url` values against the configured API base. Desktop covers the Electron native download bridge and the media preview modal; Mobile covers `Linking.openURL`, the markdown image RN loader, and the composer's completed non-image file chip.
- Mobile gains a minimal Node-environment vitest lane wired into `mobile-verify.yml`.
**Docs**
- `.env.example`, `docker-compose.selfhost.yml`, `SELF_HOSTING_ADVANCED.md`, and the `environment-variables` doc set updated with the new env keys and the `ATTACHMENT_DOWNLOAD_MODE=proxy` recommendation for Docker / VPC-internal object stores.
**Tests**
- `internal/storage`, `internal/cli`, `internal/handler` (download endpoint, mode selection, proxy header, `/content` non-regression), `cmd/server` (trusted proxy parser).
- `packages/views/editor/use-download-attachment.test.tsx` and `attachment-preview-modal.test.tsx` exercise relative URL resolution + absolute pass-through.
- `apps/mobile/lib/attachment-url.test.ts` covers every helper branch plus the composer non-image chip case.
* 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>
- Add GET /api/config endpoint exposing cdn_domain from CLOUDFRONT_DOMAIN
- Create packages/core/config/ zustand store, fetched at app startup
- Extract file card preprocessing to packages/ui/markdown/file-cards.ts
with isCdnUrl(url, cdnDomain) using exact hostname match
- Add file card support to packages/ui/markdown/Markdown.tsx (was missing)
- Remove hardcoded .copilothub.ai hostname check from file-card.tsx
- Fix LocalStorage.CdnDomain() to return hostname not full URL
- Always run preprocessFileCards regardless of cdnDomain availability
(!file syntax works without CDN domain, only legacy matching needs it)
- Use useConfigStore hook in common/markdown.tsx for reactive updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(storage): add local file storage fallback
- Add local storage implementation for file uploads
- Update .env.example with LOCAL_UPLOAD_DIR and LOCAL_UPLOAD_BASE_URL
- Integrate local storage into server router and handlers
- Add storage abstraction layer with util functions
* ♻️ refactor(storage): improve path handling and file serving
switch from path to filepath for better cross-platform support and replace manual file serving logic with http.ServeFile to enhance security against path traversal. update unit tests to use t.Setenv for cleaner environment variable management.