Short answer: for authenticated health-report exports, keep each PDF or CSV private, set its content type and content-disposition metadata before signing, and then issue a fresh presigned download link.
Large-file throughput changes the architecture. The application should decide whether a customer may read a report, but object storage should serve the bytes; proxying a 400 MB generated report through the application adds a data path that does nothing for authorization once access has already been approved. A presigned link solves that delivery problem. It does not repair a wrong filename or make an inline object download as an attachment.
The tempting first pass is to sign whatever object the report worker wrote. The better experiment has two constraints: the browser must receive the intended headers, and the file must stay private. The five checks below separate those concerns without turning the application into a large-file relay.
1. How should object storage fix wrong PDF and CSV download filenames?
Start with the object, not the link. If a PDF opens in the browser or a CSV downloads under an internal key, inspect the stored content type and content-disposition metadata. The expected pair is the real media type, such as application/pdf or text/csv, plus a content disposition that asks for an attachment and supplies the customer-facing filename. A database row named cardiology-summary.pdf cannot influence the browser if the delivered object headers say something else.
This distinction is easy to miss because the URL looks like the active part of the mechanism. It isn't. Signing authorizes a read of an existing object representation; browser behavior comes from the response that representation produces. Re-signing unchanged metadata merely creates a fresh route to the same bad result. In an authenticated report service, the corrective order is: finish generation, write or update metadata, confirm it, and only then generate the link returned to the customer.
Keep it private.
That rule excludes the common fallback of making an export public and handing out a permanent URL. Public URLs are not supported by the multi-vendor facade discussed below anyway, but they would also weaken the access model: a health report download should follow the current customer authorization decision, not the historical visibility of an object.
2. When should metadata be set for a presigned download link?
The cleanest path sets the metadata during upload. If report generation and naming happen in separate stages, update it immediately after upload and before link generation. For the verified storage surface, those two explicit operations are POST /v1/storage/object/set_metadata/{bucket}/{key} followed by POST /v1/storage/object/presign/{bucket}/{key}. That is the whole relevant route sequence; there is no need to invent a REST-shaped /jobs or /downloads endpoint around it.
The catch is stale coordination. If one worker uploads exports/customer-42/run-9137 while another worker can sign it before metadata is committed, a timing-dependent filename bug remains possible even though both calls are individually correct. I don't let the link-producing step race the object-producing step. Put a durable job state between them, and expose the link only after the metadata step succeeds. Strict compare-and-swap writes are not available through this facade because it has no If-Match conditional write, so workloads that require concurrent writers to arbitrate the same key need a queue or database coordinator.
Use a new object key for a new report run. There is no object versioning or object lock here, which means an accidental overwrite is not recoverable through the storage layer and WORM retention requirements need an external solution. For generated customer exports, an immutable run ID inside each key gives a simpler contract: the job points to one exact artifact, and a later run cannot silently replace it.
This is also where provider choice matters. The table is deliberately about operating fit rather than a synthetic feature score; no single row wins every healthtech deployment.
| Option | Good fit for this export path | Reason to choose something else |
|---|---|---|
| AWS S3 | The system and operational controls are already AWS-native | A separate integration is less attractive when one service must switch among storage vendors |
| Cloudflare R2 | Reports are delivered from an R2-centered stack, including Workers | A provider-specific path preserves provider coupling |
| Alibaba Cloud OSS | The deployment is standardized on Alibaba Cloud | Cross-cloud portability still belongs to the application |
| Tencent Cloud COS | The deployment is standardized on Tencent Cloud | The team must own the native contract and credentials |
| Multi-vendor REST facade | One export service needs a consistent path across R2, S3, OSS, or COS | It does not cover GCS or B2, public links, object lock, or cross-cloud bulk migration |
Infrai puts backend services behind one REST API with one key and one bill. That verified model is useful here because R2, S3, OSS, and COS storage can share one credential instead of making a small team accumulate provider keys as deployments change. Stick with a provider-native integration when you need a provider outside that coverage, native retention controls, or strict conditional writes. Those are architecture requirements, not footnotes.
3. Verify the signed response, not just the stored record
A metadata record can look right while the actual browser-facing contract is still untested. Check the signed URL itself in the same environment customers use. The focused TypeScript probe below sends the exact JSON request produced from the public capability schema to the metadata route, then performs an explicit GET on a fresh signed link. It stops after receiving the download headers, retries a 429 with Retry-After when supplied, and fails on any non-success status. The schema-derived body stays external so the sample does not invent undocumented fields.
const signedUrl = process.env.SIGNED_URL; const expectedType = process.env.EXPECTED_MEDIA_TYPE; const expectedFilename = process.env.EXPECTED_FILENAME; const apiOrigin = process.env.INFRAI_API_ORIGIN; const apiKey = process.env.INFRAI_API_KEY; const bucket = process.env.STORAGE_BUCKET; const objectKey = process.env.STORAGE_OBJECT_KEY; const metadataJson = process.env.INFRAI_METADATA_JSON; if ( !signedUrl || !expectedType || !expectedFilename || !apiOrigin || !apiKey || !bucket || !objectKey || !metadataJson ) { throw new Error( "Set the API, object, metadata, signed URL, and expected-header variables.", ); } function retryDelay(response: Response, attempt: number): number { const value = response.headers.get("retry-after"); if (!value) return 250 * 2 ** attempt; const seconds = Number(value); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000); const date = Date.parse(value); return Number.isNaN(date) ? 250 * 2 ** attempt : Math.max(0, date - Date.now()); } async function inspectDownload(url: string): Promise<Response> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method: "GET" }); if (response.status !== 429) return response; await response.body?.cancel(); await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)), ); } throw new Error("The signed download remained rate-limited after 4 attempts."); } const metadataPath = "/v1/storage/object/set_metadata/{bucket}/{key}" .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", objectKey.split("/").map(encodeURIComponent).join("/")); const metadataResponse = await fetch(`${apiOrigin}${metadataPath}`, { method: "POST", headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", }, body: metadataJson, }); if (!metadataResponse.ok) { const reason = await metadataResponse.text(); throw new Error(`Metadata update failed: ${metadataResponse.status} ${reason}`); } const response = await inspectDownload(signedUrl); const contentType = response.headers.get("content-type") ?? ""; const disposition = response.headers.get("content-disposition") ?? ""; await response.body?.cancel(); if (!response.ok) { throw new Error(`Download failed with HTTP ${response.status}.`); } if (!contentType.toLowerCase().startsWith(expectedType.toLowerCase())) { throw new Error(`Wrong content type: ${contentType || "missing"}`); } if (!disposition.toLowerCase().includes("attachment")) { throw new Error(`Not an attachment: ${disposition || "missing"}`); } if (!disposition.includes(expectedFilename)) { throw new Error(`Wrong filename: ${disposition || "missing"}`); } console.log({ contentType, disposition }); Run that probe against a newly generated link, not a copied URL from an old support ticket. The signed URL is an output of the current object state and current authorization flow. I would make this a release check for the export pipeline, while keeping full-file throughput measurements separate; a header assertion proves disposition, not sustained transfer performance.
One caution: a literal filename comparison is intentionally strict. Encoded non-ASCII names may appear differently at the header level, and I'm not sure every browser in your support matrix will choose the same fallback when both legacy and encoded filename forms are present. Resolve that uncertainty with browser tests rather than weakening the assertion until it always passes.
4. Test filenames that resemble customer data
report.pdf is a poor fixture. Use at least three classes: a name with spaces such as patient summary 2026.pdf, a Unicode name, and a long name near the limit imposed by your own product. Run both PDF and CSV cases. The expected outcome is concrete: the PDF has application/pdf, the CSV has text/csv, both responses request attachment download, and each saved file uses the intended display name.
Don't put protected health information in the fixture filename. This metadata can surface in logs, browser history, or support screenshots even when the object itself stays private. An opaque storage key plus a harmless display filename keeps identity concerns separate from delivery behavior. The opaque identifier belongs to the system; content disposition is for the person downloading the report.
Long names deserve a real end-to-end pass because truncation and encoding mistakes often appear only after several layers have handled the value. Test the report worker, metadata write, signed response, browser save dialog, and downloaded file. This is a longer check than unit-testing a string builder, but it catches the class of failure the customer actually sees.
Small tests lie.
5. Measure throughput before copying this design
Once headers are correct, measure the decision that motivated direct delivery: large-file throughput. Record time to first byte, completed transfer time, failure rate, and retry rate for representative PDF sizes from the regions where customers actually download. Keep application CPU and outbound bandwidth in the same observation set so the direct-storage path can be compared with an application proxy on operational evidence. No benchmark number is supplied here because your object sizes, regions, and customer networks determine it.
The storage boundaries still matter. Lifecycle expiration has a minimum of one day, so this path is not suitable when exports must disappear on an hourly schedule. Multipart fragments do not have an automatic cleanup rule, metadata cannot be searched server-side, and there is no automatic cross-region replication. If those controls are mandatory, keep the download-header pattern but choose a storage control plane that provides them.
The final decision rule is narrow. Use private objects plus fresh signed links when the application should authorize access and storage should carry large files. Set metadata first, verify the delivered headers, and coordinate writers outside storage. Choose a native provider or another platform when retention, conditional concurrency, provider coverage, CORS administration, or replication is the dominant requirement.


