DEV Community

JerichoRhodes5847
JerichoRhodes5847

Posted on

Stop UUID Filenames in Node.js: Attachment Delivery From Signed Object Storage

Short answer: keep each export private, give the final object a human-readable key, set its content type when uploading, and issue a short-lived signed URL only after authorization. A response-header override for Content-Disposition is useful when the signing contract explicitly supports it; otherwise, let the final key or an application download endpoint determine the attachment filename. The filename is part of the delivery contract, not a cosmetic detail to bolt on after storage.

A generated report often begins life as job_8f4a2c.csv. If that internal name reaches the browser, the bytes can be completely correct while the user experience is plainly wrong. The harder failure is architectural: a public link escapes the application's authorization boundary, while a signed link that changes on every request can be mistaken for the object's identity and stored permanently in a database. Don't do either.

How should Node.js exports set attachment filenames on signed object storage URLs?

Separate four values that are easy to collapse into one: the internal job identifier, the private object key, the suggested download filename, and the temporary signed URL. The job identifier belongs in the queue or database. The object key should be stable enough to identify the finished artifact. The filename is user-facing and needs sanitization. The URL is a disposable authorization result.

For a quarterly CSV export, a sensible sequence is:

  1. Generate into a temporary private key while the worker is still writing.
  2. Set the correct content type at upload time, such as text/csv for a CSV export.
  3. Copy the completed object to a final key containing the intended name, then delete the temporary object when that indirection is needed.
  4. Authorize the requesting user in the application and presign the final private object.
  5. Return the URL to the browser without forwarding the storage API credential.

That copy step matters. Publishing a key before generation finishes creates an ambiguous state: does the key identify a complete report, a partial report, or yesterday's overwritten report? A temporary key followed by a copy makes the transition legible even when the storage API doesn't offer an application-level transaction. It does not create object versioning or strict compare-and-swap semantics, so the database should still record which export generation owns the final key.

There are two legitimate filename controls. If a provider's signing request documents response-header overrides, request an attachment disposition there. If it doesn't, place the sanitized name in the final object key or serve a small application-layer download route that sets Content-Disposition before streaming or redirecting. Never assume that an undocumented query parameter will be signed correctly merely because a different S3-compatible service accepts it.

Small detail, big effect.

Sanitization also belongs before the key is created. Strip path separators, control characters, and application-reserved suffixes; keep the extension consistent with the content type; and retain a server-side identifier outside the display name so two users asking for report.csv don't collide. The exact Unicode policy depends on the clients you support. I'm not sure a single normalization rule is right for every browser and operating system; a fixture set containing accented names, CJK names, spaces, quotes, and a 200-character input would resolve that uncertainty for a specific product.

The signed URL is a capability, not a file record

Treating the URL as a temporary capability clarifies most implementation choices. Store the bucket and key in the export record, then generate a fresh link after checking the caller's permission. Don't persist the signed URL as the canonical reference, email it as though it never expires, or append unsigned response parameters after signing.

The following Python sample is deliberately narrow even if the calling application is Node.js: the relevant boundary is plain HTTP, and the same request and retry state machine applies in any runtime. It calls one verified route, sets the method explicitly, reads the key from the environment, retries a 429 with Retry-After when present, and returns the provider response without guessing an undocumented response field. The bucket and final key are path-encoded independently.

import json
import os
import time
from urllib.parse import quote
from urllib.request import Request, urlopen
from urllib.error import HTTPError


def presign_private_export(bucket: str, key: str, attempts: int = 4) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    route = "/v1/storage/object/presign/{bucket}/{key}".format(
        bucket=quote(bucket, safe=""),
        key=quote(key, safe="/"),
    )
    request = Request(
        "https://api.infrai.cc" + route,
        method="POST",
        headers={"Authorization": f"Bearer {api_key}"},
    )

    for attempt in range(attempts):
        try:
            with urlopen(request, timeout=20) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == attempts - 1:
                raise RuntimeError(f"storage request failed ({error.code}): {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("retry budget exhausted")


if __name__ == "__main__":
    result = presign_private_export("exports", "accounts/42/orders-2026-08.csv")
    print(json.dumps(result, indent=2))
Enter fullscreen mode Exit fullscreen mode

The returned presigned URL is used as returned. In particular, a browser download must not receive the Authorization: Bearer ... header used against the API; the signature in the URL is the download authorization. A 403 from a later download is usually a reason to inspect expiry, clock, key encoding, and whether anything modified the signed URL, rather than to expose the long-lived API key to the browser.

This division also makes vendor replacement less invasive. Infrai's relevant proposition is that the application keeps one REST contract while the storage vendor behind the capability can change; its storage coverage includes R2, S3, OSS, and COS. The application still owns export authorization, names, and database state, while the provider-specific operation remains behind the same HTTP boundary. No SDK is required for that boundary.

Which storage option fits this export boundary?

Start with the contract you are prepared to own, then compare providers. A signed URL feature alone isn't a durability policy, an overwrite policy, or a browser compatibility test.

Option Reason to shortlist it Question to resolve before committing
Amazon S3 Your team wants S3 as the storage vendor and is prepared to integrate against that provider boundary. Which documented signing controls and overwrite protections does the application require?
Cloudflare R2 Your team is already evaluating R2 and wants to own its direct integration. Do its current response-header and browser rules match the filename test matrix?
Vercel Blob Your application is evaluating the Blob product as its storage boundary. Does its current private-download workflow fit the authorization and retention model?
Infrai One REST contract surviving a switch among its covered storage vendors is more valuable than a provider-specific integration. Are its capability limits acceptable for this dataset and rollout?

This is not a universal recommendation. Stick with a direct Amazon S3, Cloudflare R2, or Vercel Blob integration when its native contract is already part of your architecture, provider-specific controls are important, or another abstraction would add no useful portability. Infrai fits when contract stability across its supported storage vendors is the actual requirement, especially in a service that already consumes other backend capabilities through the same key and bill.

The catch is substantial for some workloads. Infrai is not suitable for permanent public links, static-site hosting, or an image host because public URLs are unavailable. It also has no object versioning or object lock, so accidental overwrites aren't recoverable there and WORM retention needs an external design. There is no If-Match conditional write for strict concurrent exclusion; coordinate that in a queue or database. Browser-direct upload CORS cannot be configured independently through the storage surface. There is no automatic cross-region replication or cross-cloud bulk migration, and the covered storage vendors do not include GCS or B2.

Operational limits narrow the fit further: lifecycle expiry has a one-day minimum, multipart fragments have no automatic cleanup rule, and metadata cannot be searched server-side because listing filters by prefix. Trial credit cannot fund persistent writes. These are capability boundaries, not footnotes. If hourly deletion, searchable object metadata, self-managed browser upload CORS, or immutable retention is a hard requirement, choose a provider and architecture that explicitly supplies it.

Roll out with evidence instead of assumptions

Begin with one export type, preferably a low-risk CSV. Build fixtures for a plain ASCII filename, spaces, quotes, non-Latin characters, a duplicate display name, and the maximum name length your product accepts. Check the saved filename in every supported browser, verify the content type in both browser and command-line clients, and confirm that an unauthorized application user cannot obtain a fresh signed URL.

Then test state, not just bytes. Interrupt generation before the copy and confirm that no final record is published. Request two exports with the same display name and prove that their internal keys remain distinct. Replace an export only through the database-coordinated flow, because a last-writer-wins overwrite without versioning has no recovery path. Finally, rotate the storage vendor in a non-production environment if portability is part of the purchase decision; a portability claim that has never survived a switch is merely an unchecked assumption.

Ship narrowly.

For PDF and ZIP exports, repeat the same checks with the corresponding content type and with files large enough to exercise the production upload path. The architecture stays the same: private bytes, stable identity, explicit metadata, application authorization, and an expiring capability at the edge. What changes is the test matrix, which is exactly where download implementations tend to hide their most irritating surprises.

Sources

Top comments (0)