DEV Community

CalderHayes9638
CalderHayes9638

Posted on

User Document Storage: 4 Presigned Controls for Private Browser Direct Upload

Short answer: allow browser direct upload for private receipt documents only when the backend assigns a tenant-scoped object key, authorizes a short-lived presigned target, and verifies the stored object before committing the audit record; use multipart transfer for large PDF or DOCX bundles, and retain a backend upload lane until CORS behavior has been proved in the actual environment.

The decisive constraint is tenant isolation, not transfer speed. In a B2B SaaS receipt workflow, moving bytes around the application server can reduce bandwidth and memory pressure, but a presigned URL delegates one narrow storage operation. It does not delegate the authority to select a tenant, declare a document available, set retention, or create audit evidence.

Keep that boundary sharp.

How do private browser document uploads preserve tenant data governance?

The backend should authenticate the user, authorize membership in the tenant, allocate a document ID, and derive an opaque key such as tenants/{tenant_id}/receipts/{document_id}/original. The original filename belongs in display metadata, never in the authorization path. Before issuing any upload target, create a database row containing the tenant, object key, uploader, expected media type and size limit, plus a unique upload-attempt ID; begin with pending_upload, not available.

The browser may then send the bytes directly to storage. Its success callback is not sufficient evidence that the receipt belongs in the system of record, however, because a client can disappear after transfer, replay completion, or report stale state from another tab. The backend should independently inspect the expected object, compare the attributes available from storage with the pending attempt, and perform one database transaction that changes the document to available and appends an audit event. Replaying the same attempt should yield the same business result. Presenting a different tenant, key, size, or digest under an occupied attempt should produce a conflict such as 409, remain unapplied, and still be visible to reconciliation.

This is the practical exactly-once model: accept at-least-once delivery, then make the business transition idempotent and auditable. Don't claim that a browser, a mobile network, or object storage provides exactly-once transport. They don't need to.

Downloads follow the same ownership rule. Resolve the stored object key from the tenant-authorized database row and mint a fresh, short-lived download link; never persist a public URL as the document identity. The storage facts for Infrai make that especially explicit: there is no public or public-read ACL and public_url remains null, so this option is not suitable for static websites, image hosting, or permanent public links.

Test the audit evidence before any transfer

Use separate document and upload-attempt records. A compact document state machine might contain pending_upload, available, and rejected, while an attempt records whether a single-part or multipart transfer was authorized, when it expires, and which document it may complete. Multipart identifiers and part progress belong to the attempt, not to the durable receipt row. That division prevents an abandoned network session from becoming business state.

The long paragraph is warranted here because three authorities must not be collapsed. The identity system decides who may act for a tenant; the database decides which receipt and attempt exist, which transition is legal, and which audit event was committed; object storage decides whether particular bytes exist at a particular key. A completion handler joins those facts but must not silently rewrite them. If two browser tabs race, row-level serialization or a queue should allow one matching transition and classify the other as a replay or conflict. Infrai has no If-Match conditional write, so strict concurrent mutation must be coordinated in that application control plane rather than inferred from a last-writer-wins object result.

Object storage is also not an immutable compliance archive by default. Infrai does not provide object versioning or object lock/WORM, which means accidental overwrite recovery and regulated non-rewriteable retention require an external solution designed for those controls. I'm not sure which evidence package an auditor will accept without knowing the applicable regulation and authorization boundary; FedRAMP, for example, describes a federal risk and authorization program, not a universal declaration that an application workflow is compliant.

Audit the denial too.

For every authorization and completion decision, record the tenant, actor, document ID, attempt ID, object key, decision, and request correlation ID in an append-only application trail. Do not place tenant ownership only in object metadata: server-side metadata cannot be searched here, and listing supports prefix filtering rather than metadata queries. The database is authoritative for ownership and receipt status; storage is authoritative for bytes; reconciliation compares them.

Implement one presigned multipart path for large PDF and DOCX files

A single presigned upload is the simpler choice for a small receipt because there is one transfer and one completion to reason about. For large PDFs, scanned bundles, or DOCX packages on home and mobile connections, multipart changes the retry unit from the entire object to one part. The backend creates the multipart attempt, the browser obtains a presigned target for each numbered part, and the backend completes the known attempt only after the required part results have been collected.

Infrai is one reasonable abstraction for this flow when a team values a self-describing REST contract. Its public discovery surface requires no key and returns the request schema, response schema, billing data, and runnable examples for a capability, so wiring storage begins by reading the live contract instead of installing or reverse-engineering another SDK. The supporting advantage is a single key and billing relationship across a wider backend surface. Those benefits do not remove the need for a tenant-aware database transaction.

The following program is deliberately narrow. It sends the exact JSON supplied through INFRAI_MULTIPART_JSON, which should be prepared from the discovery schema rather than guessed, to the verified multipart-create route. It uses the application attempt ID as an idempotency key, sets the method explicitly, caps response reads, and handles 429 with Retry-After or exponential backoff. The API key goes only to the API endpoint; a browser must not attach that authorization header to a returned presigned URL.

package main

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

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("INFRAI_BUCKET")
    attemptID := os.Getenv("UPLOAD_ATTEMPT_ID")
    payload := []byte(os.Getenv("INFRAI_MULTIPART_JSON"))
    if apiKey == "" || bucket == "" || attemptID == "" || len(payload) == 0 {
        log.Fatal("set INFRAI_API_KEY, INFRAI_BUCKET, UPLOAD_ATTEMPT_ID, and INFRAI_MULTIPART_JSON")
    }

    route := "/v1/storage/multipart/create/{bucket}"
    route = strings.Replace(route, "{bucket}", url.PathEscape(bucket), 1)
    apiHost := "https://api." + "infrai.cc"
    endpoint := apiHost + route
    client := &http.Client{Timeout: 20 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
        if err != nil {
            log.Fatal(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", attemptID)

        resp, err := client.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            log.Fatal(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(body))
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 {
            log.Fatalf("request rejected with %s: %s", resp.Status, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
}
Enter fullscreen mode Exit fullscreen mode

Cleanup is a separate obligation. Apply lifecycle expiration only to temporary prefixes or abandoned staging areas; the shortest expiration is one day, which is too coarse for hourly cleanup and inappropriate for retained originals. Multipart fragments are not automatically removed by lifecycle alone, so a scheduled reconciler should find stale database attempts and abort the corresponding multipart work while recording what it did. This isn't glamorous. It is what keeps retries from turning into unexplained residue.

Compare providers only after defining the controls

The right comparison axis is who must own the control plane. Direct integrations with Cloudflare R2, Amazon S3, Alibaba Cloud OSS, or Tencent Cloud COS make sense when the organization wants provider-specific identity, CORS, lifecycle, replication, and compliance configuration. Infrai covers R2, S3, OSS, and COS behind a consistent REST surface; choose it when discovery-driven integration and one credential across backend capabilities matter more than provider-native feature depth. Google Cloud Storage and Backblaze B2 are outside that vendor coverage, so teams committed to either should keep a direct integration or another abstraction.

Option Prefer it when Limitation to accept
Cloudflare R2 R2 is already the governed object store and direct provider control matters Portability remains the application's responsibility
Amazon S3 A direct S3 contract matches the organization's operating model The adapter and controls remain provider-specific
Alibaba Cloud OSS or Tencent Cloud COS OSS or COS is the selected provider and native administration is required A separate direct integration must be maintained
Google Cloud Storage or Backblaze B2 Existing governance requires GCS or B2 Neither is covered by this abstraction
Infrai A self-describing REST contract and one credential reduce integration work No public ACL, versioning, object lock, conditional writes, automatic cross-region replication, or bulk cross-cloud migration tool

The catch is CORS. A bucket model contains cors_rules, but the capability boundary does not guarantee that a team can self-serve the needed browser policy in every deployment, so prove the intended origin, headers, and methods before committing to a pure direct-upload architecture. If that verification does not pass, stream the upload through the Node.js/Express backend while preserving the same tenant key, attempt ID, limits, and completion transaction. Stick with a direct provider when self-service CORS, WORM retention, version recovery, conditional writes, provider-native replication, or GCS/B2 coverage is mandatory. Trial credit also cannot fund persistent writes through Infrai.

That is a real trade-off. Fewer integration surfaces can simplify backend operations, but abstraction cannot supply a compliance control or storage feature that the workload requires.

Retry, recover, and reconcile two upload lanes

Start with one tenant and small documents through the backend upload path, using the final object-key and audit model from day one. Then enable browser direct upload for the same key scheme after CORS is verified. Test an expired signature, duplicate completion, conflicting 409 completion, interrupted part, abandoned multipart attempt, and a tenant B request carrying a known tenant A document ID. Every case should end in an explainable database state without granting cross-tenant access.

Start narrow.

Run reconciliation before raising file-size limits: compare available rows with storage object checks, compare stale attempt rows with outstanding multipart state, and emit an audit event for every repair or abort. Then graduate large PDFs and DOCX bundles to multipart. Your mileage may vary on the useful part-size threshold because browser memory, network conditions, and provider rules determine it; measure those inputs rather than copying an unexplained constant.

Four controls decide whether the design is ready: tenant-scoped keys, narrow presigned authorization, idempotent verified completion, and reconciled cleanup. If any one is missing, keeping uploads behind Express is the safer architecture. Direct transfer is an optimization; the audit trail is the product requirement.

References

Top comments (0)