mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
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.
56 lines
1.7 KiB
Go
56 lines
1.7 KiB
Go
package storage
|
|
|
|
import "testing"
|
|
|
|
func TestIsInlineContentType(t *testing.T) {
|
|
cases := []struct {
|
|
ct string
|
|
want bool
|
|
}{
|
|
{"image/png", true},
|
|
{"image/jpeg", true},
|
|
{"image/gif", true},
|
|
{"image/webp", true},
|
|
{"video/mp4", true},
|
|
{"audio/mpeg", true},
|
|
{"application/pdf", true},
|
|
|
|
// SVG must NOT render inline — it can carry executable script.
|
|
{"image/svg+xml", false},
|
|
// MIME types are case-insensitive (RFC 2045 §5.1) and may carry
|
|
// parameters. The SVG carve-out is a security boundary, so any
|
|
// variant that resolves to image/svg+xml must also be blocked.
|
|
{"IMAGE/SVG+XML", false},
|
|
{"Image/Svg+Xml", false},
|
|
{"image/svg+xml; charset=utf-8", false},
|
|
{"image/svg+xml;charset=utf-8", false},
|
|
{" image/svg+xml ", false},
|
|
// Normalization must not break the positive cases either.
|
|
{"IMAGE/PNG", true},
|
|
{"image/png; foo=bar", true},
|
|
{" application/pdf", true},
|
|
|
|
{"text/html", false},
|
|
{"application/octet-stream", false},
|
|
{"text/plain", false},
|
|
{"", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := isInlineContentType(tc.ct); got != tc.want {
|
|
t.Errorf("isInlineContentType(%q) = %v, want %v", tc.ct, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContentDisposition(t *testing.T) {
|
|
if got := ContentDisposition("image/png", `nice"file;.png`); got != `inline; filename="nice_file_.png"` {
|
|
t.Fatalf("ContentDisposition image = %q", got)
|
|
}
|
|
if got := ContentDisposition("text/plain", "notes.txt"); got != `attachment; filename="notes.txt"` {
|
|
t.Fatalf("ContentDisposition text = %q", got)
|
|
}
|
|
if got := ContentDisposition("image/svg+xml", "logo.svg"); got != `attachment; filename="logo.svg"` {
|
|
t.Fatalf("ContentDisposition svg = %q", got)
|
|
}
|
|
}
|