mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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.
148 lines
6.0 KiB
TypeScript
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;
|
|
}
|