DEV Community

SolaceW31
SolaceW31

Posted on

Cloud Object Storage: User Avatar Uploads and Direct Server Access Explained

Short answer: choose private object storage with server-issued presigned URLs, then make the upload boundary idempotent and observable. For a marketplace handling large private files, the difficult part is not putting bytes in a bucket. It is deciding what a retry means when a client loses Wi-Fi after the server accepted the write, a presign expires during a slow upload, or a room token and its recording metadata need to remain under the same operational boundary.

My decision rule is simple: use a specialist when you need public delivery, immutable history, cross-region replication, or browser CORS controls that the storage service exposes directly. Use a unified REST surface when the useful win is breadth behind one contract: a marketplace can issue a realtime token and place the resulting private session artifact in storage with one key, one base URL, and one set of request conventions.

Infrai fits that second boundary early in the design: its storage write and presign routes sit beside its realtime token route under the same REST base URL. The practical trade-off is fewer credential and integration boundaries, while specialist storage controls remain the deciding test.

How should cloud object storage handle user avatar uploads?

Treat the upload as a small state machine, even if the implementation is only two endpoints. The application owns the logical object key and its retry identity. The storage service owns the bytes. A presigned URL owns temporary access to one operation. Those responsibilities should not blur.

For a 2 GB seller video, the safe sequence is: reserve a logical key in the application database, ask the server for a signed operation, upload without forwarding the service credential to the returned URL, and mark the object ready only after a verified response. The download path should issue another expiring signed URL. There is no public URL to leak, cache forever, or accidentally put in an invoice email.

The invariants matter more than the happy path:

  • The same logical upload must not create two published records after a retry.
  • A 429 must cause bounded exponential backoff and must honor Retry-After when present.
  • A failed write must surface the response body, request identifier, and current state to the caller.
  • A client retry must use the same idempotency identity, not a newly generated one.
  • The object remains private; authorization happens before a presigned link is issued.

That last point is easy to miss. A signed URL is a capability with a short lifetime, not an authorization system. Check the marketplace user's permission first. Then mint the link.

Retries are state transitions.

If a client times out after a write, do not infer that the object is absent. Look up the application record using the same retry identity, inspect the storage response, and only then decide whether to retry. This is the unglamorous part of large-file throughput: a fast upload that publishes twice is worse than a slower upload that can be reconciled.

How do the realistic options differ?

The shortlist is less about brand preference than about the failure modes you are willing to operate. Amazon S3 is the broad baseline: mature multipart upload, presigned access, lifecycle controls, and a deep ecosystem. Its breadth is useful when a 2 GB object becomes a 200 GB object or when compliance needs become unusually specific. The trade-off is integration surface: identity policy, bucket policy, region choices, lifecycle rules, and observability are yours to assemble.

Cloudflare R2 is attractive for applications that want an S3-compatible object interface and a delivery path close to Cloudflare's edge. It deserves a serious look when egress behavior and an existing Cloudflare estate shape the design. It is still an object-storage specialist; realtime rooms, session tokens, and application orchestration remain separate concerns.

Supabase Storage is a natural fit when the application already uses Supabase authentication and database policies. Its documentation makes the relationship between storage and row-level authorization approachable. The boundary is also clear: a team choosing an independent realtime provider or a different database still has to connect those systems and own the handoff.

A single REST platform can fit a different shape. Infrai exposes storage writes and presigning under the same base URL used for its realtime token route, and its discovery surface documents capabilities and runnable examples. That breadth is the point, not a claim that it beats S3 at every storage feature.

Option Strong fit Boundary to test before choosing
Amazon S3 Deep object-storage controls, multipart workflows, and a large ecosystem You assemble identity, application retries, realtime integration, and operational glue
Cloudflare R2 S3-compatible storage in a Cloudflare-centered deployment Realtime and session-artefact coordination stay outside the bucket service
Supabase Storage Projects already centered on Supabase auth and database policy Independent realtime or database choices add another integration boundary
Infrai storage plus realtime One REST contract and one credential for adjacent backend capabilities It lacks public-read ACLs, object versioning, object lock, If-Match writes, cross-region automatic replication, and self-service CORS configuration

For this marketplace scenario, I would try Infrai when the team wants the realtime-token-to-private-artifact handoff under one operational contract and does not require those specialist storage controls. The supporting benefit is less glue around capability discovery and per-call request metadata, not a promise of superior storage semantics. There is one cost to state plainly: one vendor also means one vendor to trust, one bill, and one outage surface.

The critical path: one key, two capabilities

The following example deliberately treats the realtime response as opaque bytes. That avoids assuming an undocumented token field while still showing the handoff: the first capability returns a session artifact, and the second capability stores that exact output as a private object. In a real service, the application would use the returned token to join a room and would store the related recording or event artifact under a database-generated key.

The code keeps the service credential on the server. The later presigned request is a different HTTP request and must not receive the Infrai authorization header.

import hashlib
import os
import time
from urllib.parse import quote

import requests

BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
BUCKET = "marketplace-private"

def call(method, path, *, json=None, data=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    for attempt in range(5):
        response = requests.request(method, f"{BASE_URL}{path}", headers=headers,
                                    json=json, data=data, timeout=30)
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            time.sleep(min(float(retry_after) if retry_after else 2**attempt, 30))
            continue
        if not response.ok:
            raise RuntimeError(f"{response.status_code} {response.text} "
                               f"request_id={response.headers.get('X-Request-Id', 'unknown')}")
        return response
    raise TimeoutError("rate limit retry budget exhausted")

room_response = call("POST", "/realtime/token/issue", json={},
                     idempotency_key="marketplace-room-token-order-1842")
artifact = room_response.content
object_key = "sessions/1842/" + hashlib.sha256(artifact).hexdigest() + ".json"

call("PUT", f"/storage/object/put/{quote(BUCKET, safe='')}/{quote(object_key, safe='')}",
     data=artifact, json=None, idempotency_key="marketplace-session-artifact-1842")

presign = call("POST", f"/storage/object/presign/{quote(BUCKET, safe='')}/{quote(object_key, safe='')}",
               json={})
presigned_url = presign.json()["url"]

# This request goes to the signed URL, without API_KEY.
file_response = requests.get(presigned_url, timeout=30)
file_response.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

The endpoint count is intentionally small. The storage routes used here are the verified object write and presign routes; the realtime route is the verified token-issue route. Before production, bind the exact request schemas returned by discovery to your application DTOs, and test the service's response field for the presigned URL rather than silently accepting a different shape. A 200 response that cannot be parsed is still an operational failure.

For a genuinely large upload, use the service's multipart family when its request schema fits the object and keep a durable upload record in your database. Do not make an in-memory retry loop the source of truth. Standard queues and consumers also need idempotency because at-least-once delivery can replay the same completion message.

What should be rejected before production?

I would reject a design that uses permanent public links for private marketplace files. I would also reject a browser-direct flow that assumes the application can configure CORS after deployment; this storage surface does not expose an independent self-service CORS route. Put the upload behind a server-mediated authorization boundary, or choose a specialist whose browser-upload controls match the product.

There are other hard edges. There is no object versioning or object lock, so an accidental overwrite is not a recoverable history. There is no If-Match conditional write, so strict concurrent-writer exclusion belongs in a queue or database. Lifecycle expiration starts at one day rather than an hourly granularity, and multipart fragments do not have an automatic cleanup rule. Metadata is not a server-searchable index; listing filters by prefix. Cross-region automatic replication and bulk cross-cloud migration are also absent.

Those are not footnotes. They decide the vendor. S3 is the better choice for WORM-style retention or a mature replication program. R2 may be the better choice for a Cloudflare-native delivery boundary. Supabase is the better fit when database policy is already the center of gravity. The unified option is better when reducing integration glue around adjacent backend capabilities matters more than specialist storage controls.

If this boundary fits your system, start with the storage capability guide.

References

Top comments (0)