Files
multica/apps/mobile/components/issue/composer-attachment-row.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

259 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unified chip row for the comment composer's pending pickables.
*
* Three chip kinds share the same capsule shape (icon + name + remove ×):
*
* - mention → `@<name>` (or `@all` for the workspace-wide pick). Tap is a
* no-op; only the × button removes. Drives the comment's
* mention markdown header on submit.
* - image → filename + image icon. Tap opens the lightbox using the
* LOCAL file:// uri so completed and uploading items both
* preview without waiting for the server URL.
* - file → filename + document icon. Tap opens the canonical
* download_url in Safari once the upload completed; before
* completion the tap is a no-op.
*
* Capsule (not thumbnail) by design: the previous version showed an actual
* image preview inside a 64x64 card. The user feedback was that the preview
* pulled the eye away from the input — the row should be a "what did I
* attach" summary, not a visual gallery. Click-to-zoom satisfies the
* "I want to verify it's the right image" need without competing with
* @ and file chips for visual weight.
*
* Lives above the TextInput (between the reply-target chip and the input
* itself). The composer measures whether the row is non-empty to decide
* whether to render this component at all — empty composer keeps the
* vertical footprint minimal.
*/
import { useMemo } from "react";
import { ActivityIndicator, Linking, Pressable, ScrollView, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { resolveAttachmentUrl } from "@/lib/attachment-url";
import { useLightbox } from "@/lib/markdown/lightbox-provider";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { Text } from "@/components/ui/text";
/** Mention chip data — composer-local state. No store, no cross-route
* sharing. The composer owns the array and passes it in. */
export type MentionChipType = "member" | "agent" | "squad" | "all" | "issue";
export interface MentionChip {
type: MentionChipType;
/** UUID for member/agent/squad/issue; literal "all" for @all. */
id: string;
/** Display name without leading `@`. For type "issue" this stores the
* human identifier (e.g. "MUL-123"), which is what the chip + the
* serialised markdown link both surface (matches web's
* packages/views/editor/extensions/mention-extension.ts:67-74 — issues
* drop the leading `@`). */
name: string;
}
export type ComposerAttachmentStatus = "uploading" | "completed" | "failed";
export interface ComposerAttachmentItem {
/** Stable local id assigned by the composer when the user picked. Used as
* the React key AND as the lookup id for status transitions. We don't use
* the server-returned `id` because it doesn't exist yet during upload. */
localId: string;
/** `file://...` from expo-image-picker / expo-document-picker. Source of
* truth for lightbox preview even post-upload — on-device cache. */
localUri: string;
filename: string;
mimeType: string;
status: ComposerAttachmentStatus;
/** Populated when status === "completed" — the server-side attachment id
* that the comment mutation will reference via `attachmentIds`. */
id?: string;
/** Populated when status === "completed" — canonical `mc://file/<id>`
* URL the server returns. The composer submits by id, not url; the
* field is kept for inline-insert affordances or debugging. */
url?: string;
/** Populated when status === "completed" — signed HTTPS link to open in
* Safari for file chips. Mirrors web's "download" path. */
downloadUrl?: string;
/** Populated when status === "failed" — short human-readable error. */
error?: string;
}
interface Props {
mentions: MentionChip[];
attachments: ComposerAttachmentItem[];
onRemoveMention: (type: MentionChipType, id: string) => void;
onRemoveAttachment: (localId: string) => void;
onRetryAttachment?: (localId: string) => void;
}
export function ComposerAttachmentRow({
mentions,
attachments,
onRemoveMention,
onRemoveAttachment,
onRetryAttachment,
}: Props) {
if (mentions.length === 0 && attachments.length === 0) return null;
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 6, paddingHorizontal: 2, paddingVertical: 2 }}
keyboardShouldPersistTaps="handled"
>
{mentions.map((m) => (
<MentionChipView
key={`m:${m.type}:${m.id}`}
mention={m}
onRemove={onRemoveMention}
/>
))}
{attachments.map((a) => (
<AttachmentChipView
key={a.localId}
item={a}
onRemove={onRemoveAttachment}
onRetry={onRetryAttachment}
/>
))}
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Mention chip — small capsule, no tap (only × removes).
// ---------------------------------------------------------------------------
function MentionChipView({
mention,
onRemove,
}: {
mention: MentionChip;
onRemove: (type: MentionChipType, id: string) => void;
}) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
// Icon picks: @all → people; issue → git-branch (matches web's status icon
// styling for issue mentions); else single-person glyph.
const iconName =
mention.type === "all"
? "people"
: mention.type === "issue"
? "git-branch-outline"
: "person";
// Issue chips show the bare identifier (e.g. "MUL-123") — no leading @.
// Mirrors how the serialized markdown link renders on web/desktop.
const label = mention.type === "issue" ? mention.name : `@${mention.name}`;
return (
<View className="flex-row items-center gap-1 h-7 px-2 rounded-full bg-primary/10">
<Ionicons name={iconName} size={12} color={theme.primary} />
<Text className="text-xs font-medium text-foreground">{label}</Text>
<Pressable
onPress={() => onRemove(mention.type, mention.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove mention ${mention.name}`}
className="h-4 w-4 items-center justify-center"
>
<Ionicons name="close" size={12} color={theme.mutedForeground} />
</Pressable>
</View>
);
}
// ---------------------------------------------------------------------------
// Attachment chip — image / file capsule with status overlay.
// ---------------------------------------------------------------------------
interface AttachmentChipProps {
item: ComposerAttachmentItem;
onRemove: (localId: string) => void;
onRetry?: (localId: string) => void;
}
function AttachmentChipView({ item, onRemove, onRetry }: AttachmentChipProps) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
const { open } = useLightbox();
const isImage = useMemo(
() => item.mimeType.startsWith("image/"),
[item.mimeType],
);
const onPress = () => {
if (item.status === "failed" && onRetry) {
onRetry(item.localId);
return;
}
if (item.status !== "completed") return;
if (isImage) {
// Prefer the local on-device file over the network URL — instant,
// no signed-URL round-trip, works the same pre/post upload.
open(item.localUri);
} else {
// Non-image file chip: open the canonical download URL in Safari.
// `downloadUrl` comes from `api.uploadFile(...).download_url`, which
// on non-CloudFront deployments is a server-relative path like
// `/api/attachments/{id}/download` (MUL-2976). RN's `Linking.openURL`
// requires an absolute http(s) URL — `Cannot open URL` otherwise — so
// resolve against `EXPO_PUBLIC_API_URL` first. Already-absolute
// CloudFront/presigned URLs pass through unchanged. `null` (no
// downloadUrl yet) falls through to a no-op.
const target = resolveAttachmentUrl(item.downloadUrl);
if (target) void Linking.openURL(target);
}
};
const iconName = item.status === "failed"
? "refresh"
: isImage
? "image-outline"
: "document-outline";
return (
<Pressable
onPress={onPress}
accessibilityRole={item.status === "failed" ? "button" : "image"}
accessibilityLabel={
item.status === "failed"
? `Retry upload of ${item.filename}`
: `Open ${item.filename}`
}
className="flex-row items-center gap-1 h-7 px-2 rounded-full bg-secondary active:opacity-80"
>
{item.status === "uploading" ? (
<ActivityIndicator size="small" color={theme.mutedForeground} />
) : (
<Ionicons
name={iconName}
size={12}
color={
item.status === "failed"
? theme.destructive
: theme.mutedForeground
}
/>
)}
<Text
className="text-xs text-foreground max-w-[120px]"
numberOfLines={1}
>
{item.filename}
</Text>
<Pressable
onPress={() => onRemove(item.localId)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove ${item.filename}`}
className="h-4 w-4 items-center justify-center"
>
<Ionicons name="close" size={12} color={theme.mutedForeground} />
</Pressable>
</Pressable>
);
}