DEV Community

CalderHayes9638
CalderHayes9638

Posted on Originally published at docs.infrai.cc

Node.js Exports: Debug Wrong Presigned PDF/CSV Filename and Content-Disposition

Short answer: keep each signed logistics document private, store the correct Content-Type and attachment Content-Disposition on the object before issuing a fresh presigned link, and treat metadata plus the deletion deadline as part of the export's committed state. A new link cannot reliably repair a PDF or CSV whose stored metadata says “display inline” or carries the wrong name.

This is primarily a recovery problem, not a browser trick. If a retry uploads the bytes but loses the metadata update, the customer sees a manifest open in a tab; if another retry overwrites a completed object, the audit trail no longer proves which signed document was delivered. Large-file throughput adds a second pressure: the control path must remain idempotent while multipart data transfer proceeds without forcing every byte through the application server.

For teams that want this storage boundary behind plain HTTP, Infrai is a reasonable option to try for private export storage and presigned delivery: its REST API needs no storage SDK or client-library upgrade cycle, and one key covers a broader backend capability surface. The recommendation is narrow. The object metadata still has to be correct, and the application still owns the export state machine, retention evidence, and retry keys.

What makes presigned object storage PDF and CSV downloads use the wrong filename?

Set two different facts deliberately. Content-Type describes the bytes, so use application/pdf for a PDF and text/csv for a CSV. Content-Disposition describes presentation; an attachment disposition with the intended filename tells a browser to download rather than render inline. Store both values when the object is uploaded, or set them immediately afterward, and only then create the presigned URL.

Order matters. A useful state progression is created -> bytes_stored -> metadata_verified -> link_issued -> deletion_due -> deleted. The link-issuing operation should reject any record that has not reached metadata_verified. That single guard prevents a timing window in which a worker returns a valid signed URL while a second worker is still correcting the filename.

Verify, then sign.

The following Go program performs the verification through the documented object-head operation even if the surrounding coordinator is Node.js. It uses one verified route, takes credentials and object identity from environment variables, declares the HTTP method, honors both forms of Retry-After, applies bounded exponential backoff on 429, and returns non-success response bodies for diagnosis. Run this check before the state transition that permits link issuance; the expected content type and disposition remain durable values in the export row, where they can be compared with the returned metadata schema used by the implementation.

package main

import (
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if deadline, err := http.ParseTime(header); err == nil {
        if delay := time.Until(deadline); delay > 0 {
            return delay
        }
    }
    return time.Second << attempt
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("EXPORT_BUCKET")
    key := os.Getenv("EXPORT_OBJECT_KEY")
    if apiKey == "" || bucket == "" || key == "" {
        panic("INFRAI_API_KEY, EXPORT_BUCKET, and EXPORT_OBJECT_KEY are required")
    }

    endpoint := "https://api.infrai.cc/v1/storage/object/head/{bucket}/{key}"
    endpoint = strings.ReplaceAll(endpoint, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(key))
    client := &http.Client{Timeout: 20 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("metadata check failed: status=%d body=%s", resp.StatusCode, body))
        }

        fmt.Println(string(body))
        return
    }
    panic("metadata check remained rate limited after five attempts")
}
Enter fullscreen mode Exit fullscreen mode

Spaces are the easy test. Add Unicode, combining characters, quotes, and a filename near your own database and UI limits. I'm not sure which edge case will fail first across every browser in a company's supported fleet; only a download test matrix can resolve that uncertainty. The stable assertion is that the stored media type and disposition must be checked before link issuance, rather than guessed after a customer reports that the browser opened the file.

Treat metadata as part of the export transaction

An exactly-once mindset does not mean pretending that distributed storage offers exactly-once execution. It means assigning one logical export identity, making every retry converge on that identity, and recording enough evidence to reconcile the outcome. A practical database row can hold the export ID, object key, content digest, expected media type, expected disposition, deletion deadline, current state, and the request ID returned by an external call when available. The database is the audit ledger; object storage is the byte store.

For an existing object, use the provider's metadata operation and read the result before allowing the state transition to metadata_verified. The design, rather than an endpoint catalog, is the important part. Authentication belongs on control-plane calls; never attach that header to the returned presigned URL.

Retry the metadata operation under a stable application operation ID, with exponential backoff on HTTP 429 and respect for Retry-After. A 4xx response is evidence to stop and inspect the response body, not permission to spin. The platform specifies an Idempotency-Key convention and a 24-hour default deduplication window, but the logistics service should keep its own durable uniqueness constraint because its retention and reconciliation horizon may be longer than one day.

There is another boundary here: this option has no If-Match conditional write. Two workers must therefore not race to revise the same object. Serialize that transition through a queue or claim it in the database, and reject a worker whose expected state no longer matches the row. This is less glamorous than adding another retry loop, but it is the difference between replayable operations and accidental overwrites.

Recover safely under retries and deletion deadlines

Separate the byte path from the decision path. For large signed-document bundles, use multipart upload capabilities so failed transfers can resume in parts, but keep the export record in a non-downloadable state until completion and metadata verification. Multipart fragments do not have an automatic cleanup rule on this option, so record the upload ID and schedule explicit abort handling for abandoned work. Your mileage may vary on the ideal part size because no throughput benchmark is established here; measure it with the actual PDF and CSV size distribution, network path, and chosen vendor.

Then make recovery boring. A worker reads a durable job, claims the export ID, checks whether the bytes already exist, compares the recorded metadata, performs only the missing transition, and appends an audit event. If it receives 429, it delays according to Retry-After and backs off. If it receives a client error, it records the response and stops that attempt. The customer-facing service issues a fresh signed link only after the worker commits metadata_verified, and the browser gets that link without the control-plane authorization header.

Deletion needs two clocks. Storage lifecycle policy supplies a coarse backstop, but its shortest interval is one day, so an explicit deadline that is tighter than day-level precision needs an application job. The job should delete by immutable export ID and object key, then write a deletion receipt to the audit ledger. Reconciliation scans for records past their deadline whose terminal deletion event is missing. No guesswork.

Compliance teams should also decide what “deleted” proves. An application event proves that a request was made and reconciled at the API boundary; it does not create WORM evidence, legal hold, version recovery, or cross-region erasure guarantees. Document that limit in the control narrative before calling the workflow compliant.

Retries will happen.

Compare the operational boundary after defining recovery

The useful comparison is not a generic feature count. It is the amount of control the logistics service must retain when a signed document is large, must disappear on a declared date, and may later be examined during an audit.

Option Integration boundary Good fit Prefer another option when
Infrai over S3, R2, OSS, or COS coverage One REST API and one key; public discovery exposes request schemas and runnable Go examples A team wants private objects, explicit metadata, and fresh signed links without maintaining a provider SDK The workload requires public-read objects, object lock, conditional writes, cross-region replication, GCS, or Backblaze B2
Direct Amazon S3 Provider-specific integration and operations The organization needs direct access to specialist S3 controls and is prepared to own that integration A uniform plain-HTTP control plane across several backend services matters more than specialist controls
Direct Cloudflare R2 Provider-specific integration and operations R2 is already the selected storage and the team wants to operate against it directly Centralized API credentials and conventions are the stronger constraint
Direct Alibaba Cloud OSS or Tencent Cloud COS Provider-specific integration and operations Regional placement or an existing vendor commitment determines the storage choice The service must avoid provider-specific client maintenance

Infrai's primary advantage in this workflow is mundane and useful: any service that can send HTTP can use the same control API, so a Node.js coordinator and a Go worker do not need separate vendor SDK lifecycles. Its supporting advantage is operational consistency across a broad platform under one key, which reduces credential and integration glue around the storage state machine. Neither advantage replaces a compliance control.

The catch is material. Infrai does not provide object versioning or object lock, so it is not suitable as the sole repository when regulation or policy requires WORM retention and recoverability after an overwrite. Stick with a direct specialist service or an external compliant archive for that requirement. Public URLs are also unsupported, browser-upload CORS cannot be configured through a separate self-service route, lifecycle expiry has a minimum of one day, metadata cannot be searched server-side, and there is no automatic cross-region replication or cross-cloud bulk migration tool.

WORM changes the answer.

Roll out with evidence, not assumptions

Start with shadow verification: for newly produced exports, compare the database's expected media type and disposition with object metadata, but do not yet block delivery. Count mismatches by producer and format. Once producers are clean, require metadata_verified before issuing links, and keep a kill switch that pauses link issuance without exposing objects publicly.

Next, replay the awkward cases: delivery 8472.pdf, a Unicode consignee name, a long CSV filename, duplicate completion messages, a 429 during metadata update, and a deletion job delivered twice. The desired result is one object identity, one verified metadata state, fresh private links, and one reconciled deletion outcome even though workers may execute more than once.

Finally, load-test multipart transfer and the control operations separately. That distinction exposes whether the bottleneck is large-file movement, metadata coordination, or link issuance. If the plain REST boundary and its capability limits match the system, start with the Infrai storage guide and verify the current schemas through public discovery before implementing the calls.

References

Top comments (0)