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.
126 lines
5.2 KiB
TypeScript
126 lines
5.2 KiB
TypeScript
/**
|
|
* Pure-function tests for the mobile attachment URL resolver. We exercise
|
|
* the with-base form because `resolveAttachmentUrl` itself is bound at
|
|
* module load to `process.env.EXPO_PUBLIC_API_URL`, which is what we
|
|
* intentionally don't want to mutate in tests — the with-base helper is
|
|
* the same code path with the API base passed in explicitly.
|
|
*
|
|
* Coverage target: every branch the call sites in the app rely on —
|
|
* - `comment-attachment-list.tsx` → file chip Linking.openURL
|
|
* - `markdown-image.tsx` → mc:// + RN image loader
|
|
* - `composer-attachment-row.tsx` → completed non-image chip
|
|
* tap → Linking.openURL
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
resolveAttachmentUrl,
|
|
resolveAttachmentUrlWithBase,
|
|
} from "./attachment-url";
|
|
|
|
describe("resolveAttachmentUrlWithBase", () => {
|
|
const BASE = "https://api.example.test";
|
|
|
|
it("prepends the API base for a server-relative path", () => {
|
|
expect(
|
|
resolveAttachmentUrlWithBase("/api/attachments/att-1/download", BASE),
|
|
).toBe("https://api.example.test/api/attachments/att-1/download");
|
|
});
|
|
|
|
it("trims a trailing slash on the API base before joining", () => {
|
|
expect(
|
|
resolveAttachmentUrlWithBase(
|
|
"/api/attachments/att-1/download",
|
|
"https://api.example.test/",
|
|
),
|
|
).toBe("https://api.example.test/api/attachments/att-1/download");
|
|
});
|
|
|
|
it("passes an absolute https URL through unchanged (CloudFront / presigned)", () => {
|
|
const signed =
|
|
"https://cdn.example.test/att-1.bin?Policy=p&Signature=s&Key-Pair-Id=k";
|
|
expect(resolveAttachmentUrlWithBase(signed, BASE)).toBe(signed);
|
|
});
|
|
|
|
it("passes an absolute http URL through unchanged (self-hosted dev)", () => {
|
|
expect(
|
|
resolveAttachmentUrlWithBase("http://localhost:8080/file.bin", BASE),
|
|
).toBe("http://localhost:8080/file.bin");
|
|
});
|
|
|
|
it("returns null for nullish or empty input", () => {
|
|
expect(resolveAttachmentUrlWithBase(null, BASE)).toBeNull();
|
|
expect(resolveAttachmentUrlWithBase(undefined, BASE)).toBeNull();
|
|
expect(resolveAttachmentUrlWithBase("", BASE)).toBeNull();
|
|
});
|
|
|
|
it("keeps a relative path unchanged when the base is empty (web same-origin convention)", () => {
|
|
// Mirrors `packages/core/workspace/avatar-url.ts` semantics for the
|
|
// empty-base case — the host platform resolves the path against its
|
|
// own document/page origin. RN doesn't have one, but exercising this
|
|
// branch keeps the contract explicit.
|
|
expect(
|
|
resolveAttachmentUrlWithBase("/api/attachments/att-1/download", ""),
|
|
).toBe("/api/attachments/att-1/download");
|
|
});
|
|
});
|
|
|
|
describe("composer file chip — completed non-image attachment", () => {
|
|
// MUL-2976 (PR #3747 follow-up): when `api.uploadFile(...)` finishes on
|
|
// a non-CloudFront deployment the returned `attachment.download_url` is
|
|
// a server-relative path. `composer-attachment-row.tsx` taps that value
|
|
// straight into `Linking.openURL` — and iOS rejects relative URLs with
|
|
// "Cannot open URL". The fix wraps the value with `resolveAttachmentUrl`
|
|
// before handing it to Linking; this test pins the behaviour we rely on.
|
|
const BASE = "https://api.example.test";
|
|
// Mirrors `ComposerAttachmentItem` after a successful non-image upload.
|
|
const completedFileChip = {
|
|
localId: "local-1",
|
|
localUri: "file:///private/var/.../IMG_0001.pdf",
|
|
filename: "report.pdf",
|
|
mimeType: "application/pdf",
|
|
status: "completed" as const,
|
|
id: "att-42",
|
|
url: "mc://file/att-42",
|
|
downloadUrl: "/api/attachments/att-42/download",
|
|
};
|
|
|
|
it("resolves a server-relative downloadUrl against the API base", () => {
|
|
expect(
|
|
resolveAttachmentUrlWithBase(completedFileChip.downloadUrl, BASE),
|
|
).toBe("https://api.example.test/api/attachments/att-42/download");
|
|
});
|
|
|
|
it("preserves an absolute downloadUrl returned by CloudFront / presign", () => {
|
|
const cloudFront = {
|
|
...completedFileChip,
|
|
downloadUrl:
|
|
"https://cdn.example.test/att-42.pdf?Signature=s&Key-Pair-Id=k",
|
|
};
|
|
expect(
|
|
resolveAttachmentUrlWithBase(cloudFront.downloadUrl, BASE),
|
|
).toBe(cloudFront.downloadUrl);
|
|
});
|
|
|
|
it("returns null when the upload hasn't populated downloadUrl yet (no Linking call)", () => {
|
|
// Mirrors a `completed` chip that arrived before the server response
|
|
// (defensive; in practice `completed` implies downloadUrl is set).
|
|
const partial = { ...completedFileChip, downloadUrl: undefined };
|
|
expect(resolveAttachmentUrlWithBase(partial.downloadUrl, BASE)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolveAttachmentUrl (env-bound)", () => {
|
|
it("matches the with-base form for an absolute URL regardless of EXPO_PUBLIC_API_URL", () => {
|
|
// The bound form is module-evaluation-time, but for absolute URLs the
|
|
// base is irrelevant — guarantees pass-through stays stable.
|
|
const absolute = "https://cdn.example.test/file.pdf?Signature=s";
|
|
expect(resolveAttachmentUrl(absolute)).toBe(absolute);
|
|
});
|
|
|
|
it("returns null for empty input", () => {
|
|
expect(resolveAttachmentUrl(undefined)).toBeNull();
|
|
expect(resolveAttachmentUrl(null)).toBeNull();
|
|
expect(resolveAttachmentUrl("")).toBeNull();
|
|
});
|
|
});
|