Files
multica/apps/mobile/lib/markdown/markdown-image.tsx
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

95 lines
3.6 KiB
TypeScript

/**
* Block-level image with real aspect ratio + tap-to-lightbox.
*
* - Aspect ratio detection uses RN's `Image.getSize` (cross-platform,
* network-friendly). While dimensions resolve we lay out at 16:9 as
* a placeholder — same width-100% so the surrounding flow is stable
* and only the height shifts once the real ratio lands.
* - Rendering uses `expo-image` for on-disk caching + smooth fade-in
* transition.
* - Tap dispatches into the global LightboxProvider for fullscreen
* viewing with pinch-zoom + swipe-down-to-dismiss.
*
* URI resolution: markdown content authored in Multica stores image
* references using the internal `mc://file/<id>` scheme rather than
* baking signed HTTPS URLs into the content (signed URLs expire). iOS
* doesn't understand `mc://`, so we look the URI up in the supplied
* `attachments` list and swap it for the matching `download_url` before
* passing to any image API. Unmatched URIs fall through unchanged —
* external https links and well-known schemes load directly; an
* unknown reference fails the getSize callback and we fall back to a
* 16:9 placeholder slot.
*
* Cancellation: a content re-render that swaps the URI must not let the
* previous getSize callback overwrite state — guard with a `cancelled`
* flag in the cleanup path.
*/
import { useEffect, useMemo, useState } from "react";
import { Image as RNImage, Pressable, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
import type { Attachment } from "@multica/core/types";
import { resolveAttachmentUrl } from "@/lib/attachment-url";
import { useLightbox } from "./lightbox-provider";
interface Props {
uri: string;
alt?: string;
attachments?: Attachment[];
}
export function MarkdownImage({ uri, attachments }: Props) {
const { open } = useLightbox();
const [aspect, setAspect] = useState<number | null>(null);
const resolvedUri = useMemo(() => {
// mc://file/<id> → look up the matching attachment's download_url.
// No match (external link, html https URL, or unresolved mc://) falls
// through to the original uri.
let candidate: string | null | undefined = uri;
if (attachments && attachments.length > 0) {
const match = attachments.find((a) => a.url === uri);
if (match?.download_url) candidate = match.download_url;
}
// The backend may return a server-relative `download_url` (e.g.
// `/api/attachments/{id}/download`) when no CloudFront signer is
// configured — see MUL-2976. RN's image loader has no document
// origin to resolve against, so prepend `EXPO_PUBLIC_API_URL` for
// server-relative paths and let absolute URLs / external links pass
// through unchanged.
return resolveAttachmentUrl(candidate) ?? uri;
}, [uri, attachments]);
useEffect(() => {
let cancelled = false;
RNImage.getSize(
resolvedUri,
(w, h) => {
if (cancelled || !w || !h) return;
setAspect(w / h);
},
() => {
// Network failure / decode failure / 404 / unknown URI scheme
// (e.g. unresolved mc://) — keep the 16:9 fallback so the slot
// still shows the muted background instead of collapsing.
if (!cancelled) setAspect(16 / 9);
},
);
return () => {
cancelled = true;
};
}, [resolvedUri]);
return (
<Pressable onPress={() => open(resolvedUri)}>
<View className="rounded-lg overflow-hidden bg-muted">
<ExpoImage
source={{ uri: resolvedUri }}
style={{ width: "100%", aspectRatio: aspect ?? 16 / 9 }}
contentFit="contain"
transition={150}
/>
</View>
</Pressable>
);
}