Files
multica/apps/mobile/lib/attachment-url.ts
Multica Eve ae27058b0a fix(attachments): unified download endpoint with mode + presign + proxy (MUL-2976) (#3747)
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.
2026-06-04 14:52:57 +08:00

45 lines
1.9 KiB
TypeScript

/**
* Resolve a server-relative attachment URL against the configured API base.
*
* Background: when the backend has no CloudFront signer configured (e.g.
* the self-hosted RustFS / private-S3 case in MUL-2976), `attachment.url`
* and `attachment.download_url` come back as server-relative paths like
* `/api/attachments/{id}/download`. Web is happy with that — same-origin
* `<img src="/api/...">` resolves against the document base — but RN
* needs an absolute http(s) URL for both `Linking.openURL` (`Cannot open
* URL` otherwise) and `<Image source={{ uri }}>` (no document origin to
* resolve against; the request is silently dropped).
*
* Mirrors `packages/core/workspace/avatar-url.ts:resolvePublicFileUrl`
* exactly. We don't import the core helper because its `getBaseUrl()`
* pulls from a singleton ApiClient that lives in `@multica/core/api` —
* not on the mobile sharing whitelist (apps/mobile/CLAUDE.md "mirror,
* don't import"). Mobile reads its own `EXPO_PUBLIC_API_URL` from the
* Expo env, the same value the rest of `data/api.ts` uses.
*
* Contract:
* - null / undefined / "" → null (caller should treat as "no URL").
* - already-absolute URL → returned unchanged.
* - server-relative path → API base + path, with a single boundary
* slash (we trim trailing slashes from the
* base before joining).
*/
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "";
export function resolveAttachmentUrlWithBase(
rawUrl: string | null | undefined,
baseUrl: string,
): string | null {
if (!rawUrl) return null;
if (!rawUrl.startsWith("/")) return rawUrl;
const trimmedBaseUrl = baseUrl.replace(/\/+$/, "");
return `${trimmedBaseUrl}${rawUrl}`;
}
export function resolveAttachmentUrl(
rawUrl: string | null | undefined,
): string | null {
return resolveAttachmentUrlWithBase(rawUrl, API_URL);
}