Files
multica/packages/core/drafts/draft-upload.ts
Danila Katalshov 02e8e6c356 MUL-6023 feat(attachments): forced-attachment download URL across all storage modes (#6756)
* feat(attachments): forced-attachment download URL across all storage modes

Follow-up to #6713 (Refs #6712). The download button consumed `download_url`,
which is load-intent: the preview / inline-media paths need it to serve media
inline, so a cross-origin capability previews an image/PDF in-tab and a
presigned `download_url` 401s in token-mode. Add a separate, credential-free
`attachment_download_url` that forces Content-Disposition: attachment in every
storage mode, and point the web + desktop download buttons at it. `download_url`
is left untouched for the preview / inline-media paths.

Server (single-attachment endpoint only, never in list responses):
- proxy: a dl=1 variant of the #6092 capability, intent folded into the signed
  HMAC message so a load link cannot be flipped to a forced download (or the
  reverse); redemption forces AttachmentContentDisposition. Load-intent
  signatures stay byte-identical.
- presign: PresignGetWithContentDisposition(AttachmentContentDisposition).
- cloudfront: SignedURLWithContentDisposition (disposition folded into the
  signed resource, so a client cannot strip it).

Client: use-download-attachment.ts prefers the new field on web and desktop,
falling back to the #6092 capability (#6713) then the cookie-gated endpoint for
older servers. Optional zod/type field, parsed with the existing .loose()
fallback.

Follow-up to #6092 / #6713. Refs #6712.

* test(attachments): expect the second presign call for attachment_download_url

GetAttachmentByID now presigns the object twice in presign mode — once inline
for download_url, once with a forced attachment disposition for the new
attachment_download_url. Update TestGetAttachmentByID_AutoPublicEndpointReturnsPresignedDownloadURL
to expect both calls and to assert the forced-attachment presigned URL.

* fix(attachments): address review — omitempty, CloudFront test, mode-keyed switch

Addresses @Bohan-J's review on #6756:

- Add attachment_download_url `omitempty` so it stops shipping as an empty
  string in every list response, matching its own doc comment ("never in list
  responses") and the list-payload size tuned in #5999.
- Add TestGetAttachmentByID_CloudFrontModeSignsForcedAttachmentDownloadURL,
  covering the previously-unexercised CloudFront arm: attachment_download_url is
  CloudFront-signed and carries response-content-disposition=attachment folded
  into the signed Resource, and the load-intent download_url sibling does not
  force an attachment.
- Key the download-URL switch on the resolved mode instead of h.CFSigner != nil,
  so an explicit proxy/presign override takes effect even when a signer is
  configured; guard the CloudFront arm on a non-nil signer to keep an explicit
  cloudfront mode without a signer from panicking.
- Reframe the download-intent domain-separation comment as deliberate future
  headroom rather than a current threat mitigation (flipping today's two intents
  is not itself exploitable).
- Blank attachment_download_url in attachmentToDraftUpload alongside download_url,
  so a short-lived signed URL is not persisted into a draft.

Refs #6756 / #6712.
2026-08-12 15:02:03 +08:00

148 lines
6.0 KiB
TypeScript

import type { Attachment } from "../types";
/**
* Persistable upload placeholder + result (MUL-5181, L2).
*
* Before this, a composer draft stored only COMPLETED `Attachment` rows, so an
* upload that was still mid-flight when the composer closed had no persisted
* representation: reopening showed nothing, and a reload/app-restart left no
* trace that a file had ever been dropped. Modelled on Linear's local store
* (a first-class `uploads` record carrying `uploadId` + `uploadState`, linked
* to the draft), a `DraftUpload` lives in the persisted draft from the moment
* the file is picked, so the upload's lifecycle survives the component that
* started it.
*
* The `clientUploadId` is a client-minted id that ties the placeholder to the
* module-level {@link UploadCoordinator} running the actual request. It is NOT
* the server attachment id (which does not exist until the upload completes).
*/
export type UploadStatus = "uploading" | "uploaded" | "failed" | "interrupted";
interface DraftUploadBase {
/** Client-minted id, stable across the placeholder's whole lifecycle. */
clientUploadId: string;
filename: string;
/** Byte size, mirrored so a placeholder can render without the File. */
size: number;
contentType?: string;
}
/**
* A placeholder whose bytes are not (or no longer) resolvable to an attachment:
* - `uploading`: request in flight (owned by the coordinator).
* - `failed`: the request errored; keep it so the user sees the failure.
* - `interrupted`: LEGACY, no longer produced. Builds before MUL-5391 coerced
* a reload-surviving `uploading` record into this; that record is now
* dropped instead. Still accepted, rendered, and dismissable so blobs those
* builds persisted keep working.
*/
export interface PendingDraftUpload extends DraftUploadBase {
status: "uploading" | "failed" | "interrupted";
/** Present for `failed`; the surfaced error message. */
error?: string;
}
/** A completed upload carrying the full server attachment row. */
export interface UploadedDraftUpload extends DraftUploadBase {
status: "uploaded";
attachment: Attachment;
}
export type DraftUpload = PendingDraftUpload | UploadedDraftUpload;
/** True for a completed upload (narrows to {@link UploadedDraftUpload}). */
export function isUploaded(u: DraftUpload): u is UploadedDraftUpload {
return u.status === "uploaded";
}
/** The completed attachment rows, in order. The submit-bindable set. */
export function uploadedAttachments(uploads: readonly DraftUpload[]): Attachment[] {
const out: Attachment[] = [];
for (const u of uploads) {
if (u.status === "uploaded") out.push(u.attachment);
}
return out;
}
/** True while any upload is still in flight — the submit gate reads this. */
export function hasUploadingDraft(uploads: readonly DraftUpload[]): boolean {
return uploads.some((u) => u.status === "uploading");
}
/** Wrap a completed attachment as an uploaded placeholder. */
export function attachmentToDraftUpload(attachment: Attachment): UploadedDraftUpload {
return {
clientUploadId: attachment.id || attachment.url,
status: "uploaded",
filename: attachment.filename,
size: attachment.size_bytes,
contentType: attachment.content_type || undefined,
// `download_url` and `attachment_download_url` are minted for the current API
// response and may be short-lived signed URLs; draft uploads survive dialog
// closes and app restarts, so both are stripped here. `url`/`markdown_url`
// stay as the durable render/download paths, and content-editor's session
// merge backfills an empty download_url from the live upload result (the
// download flow re-fetches attachment_download_url fresh via getAttachment).
attachment: { ...attachment, download_url: "", attachment_download_url: "" },
};
}
function looksLikeAttachment(value: unknown): value is Attachment {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { filename?: unknown }).filename === "string" &&
typeof (value as { url?: unknown }).url === "string"
);
}
function isDraftUploadShape(value: unknown): value is DraftUpload {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { clientUploadId?: unknown }).clientUploadId === "string" &&
typeof (value as { status?: unknown }).status === "string"
);
}
/**
* Normalize a raw persisted array into `DraftUpload[]`:
* - already-`DraftUpload` entries are kept, EXCEPT any still in `uploading`,
* which are DROPPED — a reload/restart cannot resume the bytes, and no
* placeholder node survives in the body either (see the branch below).
* - bare `Attachment` rows (persisted by pre-L2 builds that stored only
* completed attachments) are wrapped as `uploaded`.
* - anything else is dropped.
*
* Runs on every load/rehydrate and on `set` writes, so the in-memory shape is
* always canonical regardless of which build wrote the persisted blob.
*/
export function normalizeStoredUploads(raw: unknown): DraftUpload[] {
if (!Array.isArray(raw)) return [];
const out: DraftUpload[] = [];
for (const item of raw) {
if (isDraftUploadShape(item)) {
if (item.status === "uploading") {
// Dropped, not coerced to `interrupted`. The bytes were never
// persisted, so this upload can neither resume nor be retried — and
// the document has no node for it either, because a placeholder is
// never serialised. Keeping a record no surface can act on only kept
// an otherwise-empty draft alive for the full TTL. The absence of the
// attachment in the body is what tells the user to attach it again.
continue;
} else if (item.status === "uploaded") {
// Trust the persisted attachment only if it still looks like one.
if (looksLikeAttachment((item as UploadedDraftUpload).attachment)) {
out.push(item);
}
} else {
out.push(item);
}
} else if (looksLikeAttachment(item)) {
out.push(attachmentToDraftUpload(item));
}
}
return out;
}