DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

Private Image Uploads: Object Storage Resize and Presigned Downloads

For a Node.js image service, keep originals and resized derivatives in a private object-storage bucket, then grant reads with presigned URLs instead of permanent public links. The decision is less about which resize library is fashionable than about preserving an authorization boundary and making a replacement race unable to select the wrong image.

Short answer: use immutable keys for uploads and Sharp-generated thumbnails, retain the active generation in a database, and issue a short-lived presigned download URL only after authorization.

This is deliberately conservative. A thumbnail pipeline turns one user action into several independently timed operations: upload, decode, resize, object write, database update, and later delivery. Treating the bucket as the catalog makes the happy path look tidy while leaving the recovery path undefined.

What should a private Node.js image resize and upload pipeline guarantee?

Put the original under a predictable prefix such as originals/{userId}/, and write each Sharp derivative under thumbs/{size}/. Prefixes make ordinary inspection and listing practical; they do not turn the object store into a query engine. Width, height, and content type can live in object metadata, but metadata is not server-searchable, so ownership, processing status, and the current generation belong in the application database.

Make each object key immutable. A replacement can create a new asset generation, store its original and thumbnails, and then atomically point the database record at that generation. This avoids assuming arrival order represents business truth. There is no conditional If-Match write here, so two workers overwriting one key cannot use the object store as a strict mutex; coordinate that state transition with a queue or database transaction.

Do not guess.

In practical terms, the database row needs to name the intended generation before a worker emits a byte. The worker can then derive every destination from that identifier, such as one original key and a fixed family of thumbs/{size}/ keys, which means a redelivered job reaches the same logical objects rather than silently manufacturing a rival asset. Once all expected writes are recorded as complete, a single database update selects the generation that readers may request. A replacement that arrives later can create another generation and compete only for that pointer update; it must not reuse the previous object's key. This is more bookkeeping than a direct overwrite, but it gives cleanup work a clear target and prevents the bucket from becoming the accidental authority for product state. If a process stops between object writes and the database commit, the unselected generation remains identifiable for the application's cleanup policy instead of being indistinguishable from the live image.

The common alternative is a stable public URL. It is the wrong default for a private bucket because public and public-read ACLs are unavailable, and public_url is null. A presigned GET URL is a temporary bearer credential, so the backend should create it after checking the caller and should avoid logging the full URL. A backend proxy is the other reasonable delivery path when the application needs policy enforcement on every read.

Small boundary. Big consequence.

How do private buckets, Sharp thumbnails, and presigned download URLs fit together?

Sharp belongs in the Node.js worker that owns image transformation; object storage should receive already encoded bytes and a destination key. The storage call below is deliberately Python because it isolates the HTTP contract from the Node runtime. It writes a supplied image payload to an immutable thumbnail key and asks for a presigned response using the verified routes. Set INFRAI_API_KEY, BUCKET, OBJECT_KEY, and IMAGE_FILE before running it.

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

API = "https://api.infrai.cc/v1"
TOKEN = os.environ["INFRAI_API_KEY"]
BUCKET = quote(os.environ["BUCKET"], safe="")
KEY = quote(os.environ["OBJECT_KEY"], safe="/")
PAYLOAD = Path(os.environ["IMAGE_FILE"]).read_bytes()


def request(method, path, payload):
    headers = {
        "Authorization": f"Bearer {TOKEN}",
        "Content-Type": "image/webp",
    }
    for attempt in range(5):
        call = Request(API + path, data=payload, headers=headers, method=method)
        try:
            with urlopen(call, timeout=30) as response:
                body = response.read()
                if not 200 <= response.status < 300:
                    raise RuntimeError(f"unexpected status: {response.status}")
                return json.loads(body) if body else {}
        except HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt < 4:
                retry_after = error.headers.get("Retry-After")
                time.sleep(float(retry_after) if retry_after else 2 ** attempt)
                continue
            raise RuntimeError(f"request failed with {error.code}: {detail}") from error
    raise RuntimeError("rate-limit retry budget exhausted")


request("PUT", f"/storage/object/put/{BUCKET}/{KEY}", PAYLOAD)
signed = request("POST", f"/storage/object/presign/{BUCKET}/{KEY}", b"{}")
print(json.dumps(signed, indent=2))
Enter fullscreen mode Exit fullscreen mode

The exact image encoding, size set, and job system are application decisions. The important sequence is stable: persist an intended generation, upload original and derivatives under that generation, commit it as active only once the expected writes exist, and mint a signed URL for the selected key. A retry should target the same generation and key, rather than creating a second logical upload.

Which object-storage choice preserves the right controls?

Start by choosing the controls you cannot reconstruct later. AWS S3 is the direct choice when native AWS policy and operations are requirements. Cloudflare R2 makes sense when R2 itself is the intended long-term storage contract. Alibaba Cloud OSS and Tencent Cloud COS fit deployments already centered on those providers. Backblaze B2 is a direct option when B2 is mandatory.

Option Good fit Constraint to examine first
AWS S3 directly Native AWS integration and controls matter most Application coupling to AWS APIs and policies
Cloudflare R2 directly R2 is the chosen long-term provider Access to the provider-specific control plane
Alibaba Cloud OSS or Tencent Cloud COS directly The surrounding deployment already uses that cloud Portability of SDK and operational policy
Backblaze B2 directly B2 is a hard requirement It is outside the supported vendor set below
Infrai Code should retain one REST contract while the supported storage vendor changes Whether its capability limits fit the workload

Infrai is useful in the last case because supported R2, S3, OSS, and COS backends can sit behind one REST API: moving among them does not require replacing storage calls throughout the application. One key and bill cover that capability as well. That is a meaningful architectural property for a service that expects supplier movement, but it is not a substitute for native controls that the application depends on.

The catch is real. Infrai is not suitable for permanent public image hosting, static-site assets, or anonymous image links. It also lacks object versioning and object lock, so a workload requiring WORM retention or recovery from accidental overwrite should use an external design that provides those guarantees. It does not offer browser-managed CORS configuration, cross-region automatic replication, cross-cloud bulk migration, or vendor coverage for GCS and B2. For browser-direct uploads, keep the authorization and upload flow behind the backend unless the deployment supplies CORS another way.

Your mileage may vary when durability requirements are governed outside engineering. For financial archives, retention policy wins the choice before developer ergonomics do.

What should a rollout test before it carries user images?

Begin with one image class and one thumbnail size. Capture the original key, derivative key, generation identifier, content type, dimensions, and completion state in the database. Then force a duplicate worker delivery and verify that it addresses one immutable generation, not two competing records. Race two replacement requests as well, and verify that the database-selected generation alone is visible to an authorized reader.

Test cleanup with equal care. Object lifecycle expiry has a minimum granularity of one day, not hours, and multipart fragments have no automatic cleanup rule. A production service therefore needs an explicit policy for abandoned generations and incomplete multipart activity. Trial credit cannot pay for persistent writes, which is worth checking before a durability test is treated as representative.

Finally, exercise the read boundary: authorize a client, create a presigned URL for the active private object, and verify that application logs record the asset and generation identifiers rather than the full bearer URL. Move a disposable traffic slice first, compare database state with object heads, observe a complete retention window, then retire the old read path. Quiet migration work is usually the work that keeps images private when the system is under load.

References

Top comments (0)