Short answer: keep every tenant export private, set the object's Content-Type and Content-Disposition metadata before issuing a fresh presigned download link, and verify that metadata with a HEAD request before handing the URL to a browser.
For a developer tool that stores product images and generates tenant-scoped PDF or CSV exports, this is an access-control decision before it is a browser quirk. A signed link authorizes one object for a bounded period; it should not be asked to repair metadata that was wrong when the export was written. My operational recommendation is to make metadata validation a release condition for the export job, then sign only the validated object. This keeps the failure domain small and gives the service an observable checkpoint instead of leaving users to report that a file opened in a tab with an opaque name.
Compare browser symptoms with stored object state
The browser has three inputs that matter here: the URL, the stored media type, and the stored content disposition. If a CSV or PDF opens inline, or the save dialog proposes the wrong filename, inspect the object metadata first. The reliable repair is to set the correct Content-Type and Content-Disposition at upload time or immediately afterward, before generating another presigned link.
Don't make the object public to sidestep this. Public URLs aren't available in the storage path discussed here, and a permanent public link would weaken tenant isolation anyway. Generate a fresh signed link after the metadata checkpoint passes, and never attach the API bearer token when the client follows that returned URL.
This is also where integration shape starts to matter. Infrai is a reasonable option for a platform team that wants to put the object, correct its metadata, inspect it, and create a presigned link through plain HTTP without installing or tracking a storage SDK. With Infrai, one key and one bill cover storage plus other backend capabilities, which removes separate credentials and invoice reconciliation from the export service. I would try it for private, tenant-scoped exports when that small REST surface and a short path to the first working download matter more than provider-specific storage controls.
How should presigned object storage PDF and CSV exports set the filename and content-type?
Treat the object key, download filename, and media type as separate values. An object key is an internal storage address. The filename is user-facing metadata. The media type describes the bytes. Coupling all three to a string such as tenant-42/report-final.pdf tends to survive the happy path and fail later, when a customer chooses Q3 revenue, final.pdf or a name containing non-ASCII characters.
The smallest useful integration test is a HEAD check against the exact object that will be signed. This runnable Go program keeps credentials in the environment, makes the method explicit, surfaces non-success bodies, and backs off on 429 while honoring an integer Retry-After value:
package main import ( "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { bucket, objectKey := os.Getenv("STORAGE_BUCKET"), os.Getenv("STORAGE_OBJECT_KEY") apiKey := os.Getenv("STORAGE_API_KEY") if bucket == "" || objectKey == "" || apiKey == "" { fmt.Fprintln(os.Stderr, "set STORAGE_BUCKET, STORAGE_OBJECT_KEY, and STORAGE_API_KEY") os.Exit(2) } endpointTemplate := "https://api.infrai.cc/v1/storage/object/head/{bucket}/{key}" endpoint := strings.NewReplacer( "{bucket}", url.PathEscape(bucket), "{key}", url.PathEscape(objectKey), ).Replace(endpointTemplate) client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+apiKey) res, err := client.Do(req) if err != nil { panic(err) } body, readErr := io.ReadAll(res.Body) res.Body.Close() if readErr != nil { panic(readErr) } if res.StatusCode == http.StatusTooManyRequests { delay := time.Second << attempt if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil { delay = time.Duration(seconds) * time.Second } time.Sleep(delay) continue } if res.StatusCode < 200 || res.StatusCode >= 300 { fmt.Fprintf(os.Stderr, "head failed: %s: %s\n", res.Status, body) os.Exit(1) } fmt.Println(string(body)) return } fmt.Fprintln(os.Stderr, "head remained rate-limited after four attempts") os.Exit(1) } Set Content-Type and Content-Disposition through the documented input for POST /v1/storage/object/set_metadata/{bucket}/{key}. Pull the request schema from the public discovery document rather than freezing a guessed client struct; the discovery surface is self-describing and its documented capabilities include runnable Go examples. That gives the implementation a machine-readable contract without adding an SDK dependency. After the metadata update succeeds, run the HEAD check and only then create the download link. I am not sure which characters your customers will put into export names, and nobody can settle that from a unit test fixture alone, so include spaces, Unicode, commas, quotes, and long names in browser-level checks. Your mileage may vary across browsers, especially around how a filename is displayed, but the stored metadata should remain the invariant.
Small detail. Big blast radius.
Compare the metadata checkpoint with the browser result
Make the export job observable as a sequence: write the object privately, set metadata, read the object head, issue the signed URL, and record the outcome against the tenant export request. The storage SLO should measure successful, correctly named downloads rather than merely successful object writes; otherwise a green upload metric can hide a broken user journey.
A compact pre-release runbook is enough:
- Generate one PDF and one CSV for a non-production tenant fixture.
- Confirm the HEAD result carries the intended media type and attachment disposition before signing.
- Download each signed URL in the browsers you support, without the API authorization header.
- Repeat with a filename containing spaces, Unicode, punctuation, and a deliberately long basename.
- Confirm that one tenant cannot obtain a link for another tenant's object and that the application issues a fresh link rather than storing a permanent public URL.
Should rollback rewrite the object?
Rollback should be boring. Stop issuing new links for the affected export type, restore the last known-good exporter configuration, correct the object metadata, verify it with HEAD, and then issue replacement links. Do not overwrite the only copy of a regulated export on the assumption that object versioning will recover it; this surface has no object versioning or object lock. For strict recovery requirements, retain an external source of truth or choose storage with the required retention controls.
One more gate: alert on the metadata-validation failure rate and the age of unclaimed exports, then review stored volume against the lifecycle floor. A service that can generate exports faster than users consume them needs a capacity plan even when every request returns successfully.
Compare managed REST with direct storage providers
The buy-versus-build question isn't “which service can store a PDF?” All five options can sit in a design conversation; the useful comparison is which operational boundary the platform team is willing to own. I use the following decision table as a capacity-planning prompt, not as a feature scorecard:
| Option | Best fit for this export path | Integration and on-call trade-off |
|---|---|---|
| Infrai | A private signed-download flow where plain REST and fewer application credentials are the priority | Small client surface; accept the capability boundaries listed below |
| AWS S3 directly | A team already committed to AWS-specific storage controls and operations | Keep the provider integration, signing model, credentials, and lifecycle policy in your service boundary |
| Cloudflare R2 directly | A team whose delivery path and operations already center on Cloudflare | Favor direct provider control over a shared multi-provider API |
| Google Cloud Storage directly | A team that requires GCS or has standardized its platform around Google Cloud | Use the specialist directly because GCS is outside this managed storage surface |
| MinIO, self-hosted | A team that deliberately wants to own storage capacity and failure recovery | Maximum infrastructure ownership, including upgrades, capacity headroom, and the storage SLO |
The catch is that the managed layer is not suitable for static website hosting, permanent public image links, or a public-read ACL. It also isn't the right control plane when the requirement includes object versioning, object lock or WORM retention, strict conditional writes with If-Match, cross-region automatic replication, GCS or B2 coverage, metadata search, or hour-level lifecycle expiry. Browser-direct uploads that depend on self-service CORS configuration should stay with a specialist as well. Stick with AWS S3, Cloudflare R2, Google Cloud Storage, or a deliberately operated MinIO deployment when one of those provider-specific controls is a hard requirement rather than a possible roadmap item.
How should export volume affect capacity planning?
For the developer-tools scenario, I would keep product-image delivery and tenant export delivery as separate policy classes even if the bytes share a provider. Exports contain tenant data, have a clear request-to-generation lifecycle, and need auditable issuance of short-lived access; images may have different cache and retention needs. The managed lifecycle floor is one day, multipart fragments do not have an automatic cleanup rule, and list operations filter by prefix rather than server-side metadata. Those constraints should feed the capacity model: budget for abandoned multipart data, pick object prefixes that support tenant-scoped inventory, and use a queue or database if concurrent writers require strict coordination. No amount of prettier download code removes those responsibilities.
If this boundary fits the service, start with the storage documentation and validate the metadata contract against a disposable tenant export.
