mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +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.
169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
/**
|
|
* Standalone attachment list for comment cards.
|
|
*
|
|
* Mirrors the design of web's `AttachmentList` in
|
|
* `packages/views/issues/components/comment-card.tsx:121-159` — renders
|
|
* any attachment whose URL the markdown content didn't already reference,
|
|
* with same-file dedup so a duplicate upload referenced inline doesn't
|
|
* also appear below.
|
|
*
|
|
* The data-contract parity goal: a comment authored on mobile (which has
|
|
* no inline-insert path — see `inline-comment-composer.tsx`) carries its
|
|
* attachments via the `attachments` field only, with no `` in
|
|
* `content`. Web reads it back and `AttachmentList` puts the attachments
|
|
* below the body. Mobile reads it back here and does the same. A comment
|
|
* authored on web with inline images already inside the markdown renders
|
|
* inline on both clients via `MarkdownImage`, and this list returns null
|
|
* because there's nothing "leftover" to show.
|
|
*
|
|
* For v1 we render images via the same `MarkdownImage` used by inline
|
|
* markdown rendering (consistent aspect-ratio + lightbox behavior). Non-
|
|
* image attachments render as a tappable file card showing 📎 + filename
|
|
* + size hint, opening the canonical download URL on tap.
|
|
*/
|
|
import { useMemo } from "react";
|
|
import { Linking, Pressable, View } from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import type { Attachment } from "@multica/core/types";
|
|
import { MarkdownImage } from "@/lib/markdown/markdown-image";
|
|
import { resolveAttachmentUrl } from "@/lib/attachment-url";
|
|
import { useColorScheme } from "@/lib/use-color-scheme";
|
|
import { THEME } from "@/lib/theme";
|
|
import { Text } from "@/components/ui/text";
|
|
|
|
interface Props {
|
|
attachments?: Attachment[];
|
|
/** The comment's markdown content. Attachments referenced inside it via
|
|
* `` or `[name](url)` are skipped so they aren't double-rendered.
|
|
* Pass `undefined` (not just an empty string) when the comment has no
|
|
* body — that disables the inline-reference filter and renders all
|
|
* supplied attachments. */
|
|
content?: string;
|
|
}
|
|
|
|
export function CommentAttachmentList({ attachments, content }: Props) {
|
|
const { colorScheme } = useColorScheme();
|
|
const theme = THEME[colorScheme];
|
|
|
|
const standalone = useMemo(() => {
|
|
if (!attachments || attachments.length === 0) return [];
|
|
if (!content) return attachments;
|
|
return attachments.filter((a) => {
|
|
// Skip attachments whose URL is already referenced inline in the
|
|
// markdown — they'll render via MarkdownImage (images) or a markdown
|
|
// link (files), and we'd otherwise show them twice.
|
|
if (content.includes(a.url)) return false;
|
|
// Dedup: if another attachment with the same file identity (name,
|
|
// type, size) is already inline in the content, this one is a
|
|
// duplicate upload — skip it. Mirrors web's
|
|
// `comment-card.tsx:132-140` defense.
|
|
const hasSiblingInContent = attachments.some(
|
|
(other) =>
|
|
other.id !== a.id &&
|
|
other.filename === a.filename &&
|
|
other.content_type === a.content_type &&
|
|
other.size_bytes === a.size_bytes &&
|
|
content.includes(other.url),
|
|
);
|
|
if (hasSiblingInContent) return false;
|
|
return true;
|
|
});
|
|
}, [attachments, content]);
|
|
|
|
if (standalone.length === 0) return null;
|
|
|
|
return (
|
|
<View className="gap-1.5">
|
|
{standalone.map((attachment) => {
|
|
const isImage = attachment.content_type.startsWith("image/");
|
|
if (isImage) {
|
|
return (
|
|
<MarkdownImage
|
|
key={attachment.id}
|
|
uri={attachment.url}
|
|
alt={attachment.filename}
|
|
attachments={attachments}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<FileCard
|
|
key={attachment.id}
|
|
attachment={attachment}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FileCard({
|
|
attachment,
|
|
theme,
|
|
}: {
|
|
attachment: Attachment;
|
|
theme: typeof THEME["light"];
|
|
}) {
|
|
const sizeLabel = formatBytes(attachment.size_bytes);
|
|
return (
|
|
<Pressable
|
|
onPress={() => {
|
|
// download_url is the canonical link — opening it hands off to
|
|
// Safari which handles auth-token-free download + previewing for
|
|
// common types (PDF, txt). Mirrors what the markdown link renderer
|
|
// does for `[name](url)`.
|
|
//
|
|
// The backend may return a server-relative URL like
|
|
// `/api/attachments/{id}/download` when no CloudFront signer is
|
|
// configured (MUL-2976). RN's `Linking.openURL` requires an
|
|
// absolute http(s) URL — it returns "Cannot open URL" otherwise —
|
|
// so resolve against `EXPO_PUBLIC_API_URL` first.
|
|
const target = resolveAttachmentUrl(attachment.download_url);
|
|
if (target) {
|
|
void Linking.openURL(target);
|
|
}
|
|
}}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Open ${attachment.filename}`}
|
|
className="flex-row items-center gap-2 px-3 py-2 rounded-md bg-secondary/60 active:opacity-80"
|
|
>
|
|
<Ionicons
|
|
name="document-outline"
|
|
size={20}
|
|
color={theme.mutedForeground}
|
|
/>
|
|
<View className="flex-1">
|
|
<Text
|
|
className="text-sm text-foreground"
|
|
numberOfLines={1}
|
|
>
|
|
{attachment.filename}
|
|
</Text>
|
|
{sizeLabel ? (
|
|
<Text className="text-xs text-muted-foreground">{sizeLabel}</Text>
|
|
) : null}
|
|
</View>
|
|
<Ionicons
|
|
name="download-outline"
|
|
size={18}
|
|
color={theme.mutedForeground}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string | null {
|
|
if (!bytes || bytes <= 0) return null;
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let value = bytes;
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex++;
|
|
}
|
|
const formatted =
|
|
value < 10 ? value.toFixed(1) : Math.round(value).toString();
|
|
return `${formatted} ${units[unitIndex]}`;
|
|
}
|