mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +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.
95 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|