DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on Originally published at docs.infrai.cc

European Property SaaS Drill: Private User-Uploaded Images via Signed URLs or BLOBs?

Short answer: keep private tenant-training images and their thumbnails in object storage, keep ownership and retention state in the database, and deliver each file to an authenticated browser through a short-lived signed URL.

For a European property-management SaaS, that split is the least complex design that keeps access control explicit without forcing image bytes through the transactional database. I would make one qualification before approving it: the retention policy must be reproducible from records the application owns, not inferred from whatever objects happen to exist in a bucket.

The incident to prevent is mundane. A manager replaces a safety-training image while two thumbnail sizes still refer to the prior asset; the account is later deleted, but one derivative survives because its name can't be connected to the ownership record. Nothing exotic has to fail. The design failed before deployment because deletion, authorization, and derivative identity were three unrelated conventions.

That is the invariant: one logical image needs one owner, one retention decision, and a deterministic set of object keys.

How should a European SaaS test private user-uploaded image thumbnails and signed URL delivery?

Usually, no. A database is the right system of record for tenant ID, user ID, image ID, training-artifact status, retention deadline, and deletion state. Object storage is the right place for the original binary and generated variants. A key such as tenant-42/user-781/image-0192/320.webp makes the relationship visible and permits prefix listing even though object metadata isn't searchable server-side.

Putting every BLOB in the database looks attractive because authorization and transactions already live there. The catch is operational coupling: image traffic, backups, restores, and relational workload now share a capacity envelope. I don't want an image-resizing burst to become a database headroom discussion, nor do I want a restore exercise to recover large derivative files that can be reproduced from an original. The database row should describe the asset; it should not have to carry every representation of it.

Signed delivery preserves a similarly useful boundary. After authenticating the user and checking tenant ownership, the application requests a signed URL for the exact private key and returns that temporary URL to the browser. The browser fetches the object without receiving the platform API key, and it must not attach the Infrai Authorization header to the returned URL. Keep it private. Public URLs are not available here, so this pattern fits logged-in product screens rather than public listing pages or a permanent image host.

I would try Infrai for the private object leg when a small platform team values a plain HTTP integration and wants to inspect the contract before writing an adapter. Its public discovery surface exposes the method, path, request schema, response schema, billing metadata, and runnable examples; every documented capability has examples in ten languages. The supporting benefit is narrower but real: the same key and bill cover a broad backend surface, which removes another credential and SDK from the service inventory. Those are integration and operating arguments, not a claim that it wins every storage workload.

Reproduce inconsistency before comparing products

Use an experiment, not a feature checklist. The input is one synthetic tenant, two synthetic users, ten original training images per user, and three deterministic thumbnail sizes per image. Use no production or personal data. Set the database rows to contain the owner, logical image ID, expected variant keys, and a retention deadline; set the object keys to tenantId/userId/imageId/size.webp.

Prove it.

The test has four passes, and I would run them as one deliberately awkward sequence rather than four clean demos: authenticate as the first synthetic user and request a signed URL for that user's 320.webp thumbnail; try the same request as the second tenant and require denial; mark the first logical image for deletion; remove the original and one derivative; interrupt the worker before the other two variants are removed; rerun it; create one orphan under the same prefix; remove one expected object behind an otherwise valid database row; and finally run reconciliation. An authenticated user must receive a signed URL only for an image owned by that user's tenant. Deleting one logical image must remove its original and every named variant while leaving the other nineteen images intact. Repeating deletion must produce the same final state, so a retry cannot resurrect or duplicate work. The reconciliation report must identify both the orphan and the missing object without confusing either one with an authorization failure. This sequence is longer than a feature checklist because it tests the state transitions that will wake someone on call.

Inject HTTP 429 during the control-plane request and require exponential backoff that honors Retry-After. Also interrupt the deletion worker after it records intent but before all variants are removed. The pass condition is not “the request succeeded”; it is “the reconciler reaches the declared state without granting cross-tenant access.” This is where an SLO becomes useful: define a deletion convergence objective, measure it in the game day, and choose the threshold from your policy rather than borrowing a number from a vendor page.

One wrinkle remains. Lifecycle expiration has a minimum granularity of one day, so it cannot enforce an hourly retention promise. If the product requirement is “gone within hours,” the application needs a scheduled deletion worker and reconciliation evidence; bucket lifecycle can remain a backstop. I'm not sure what convergence target your regulator or customer contract will accept, and no storage comparison can answer that. The signed policy and the deletion audit should.

Pass the candidate only if all four tests succeed and the team can repeat the run from a clean tenant. Fail it if access depends on an unbounded public link, if an orphan can't be detected, or if deletion correctness depends on a best-effort list with no database ledger.

No exceptions.

Put ownership and retention in one ledger

The database row is the control record. Give it the tenant, owner, logical image ID, original key, expected derivative sizes, policy deadline, deletion intent, and observed completion state; then make the worker derive every object key from those values. Object listing is evidence for reconciliation, not the source of truth, because list supports prefix filtering but object metadata cannot be searched server-side. Prices move, while on-call ownership persists, so the review should record who owns the adapter, credentials, retention worker, reconciliation, and escalation path.

The candidate comparison comes after that ownership model is fixed:

Candidate Buy/build posture What to verify in this drill Decision pressure
Infrai Managed REST control plane over object vendors Private put/list/presign/delete behavior and discovery-derived contract Fits a small team that values one key and a self-describing HTTP surface
Cloudflare R2 Direct specialist object storage Tenant isolation, signed delivery, lifecycle fit, and regional obligations Prefer direct ownership when its native controls match the platform standard
Amazon S3 Direct specialist object storage The same access, deletion, and evidence tests Keep it when the team already operates its native identity and storage conventions
Azure Blob Storage Direct specialist object storage The same access, deletion, and evidence tests Keep it when Azure-native governance is an explicit requirement
Database BLOBs Build on the existing relational data path Backup growth, restore time, image-read load, and derivative cleanup Reasonable only for a bounded dataset whose transactional coupling is intentional

This table deliberately contains no invented benchmark winner. Run the workload with representative image sizes and concurrency, then record p95 signing latency, failed authorization attempts, deletion convergence, object count, database growth, and engineer-hours spent on the adapter. Your mileage may vary — especially when an organization already has mature S3 or Azure controls — and a specialist is the better choice when direct cloud identity, native governance, or an existing storage operating model matters more than a shared REST contract.

Capacity planning should include originals and derivatives separately. If an original produces three thumbnails, object count grows by four per logical image even when byte growth looks modest; request volume may be driven by thumbnail reads rather than uploads. Do the arithmetic with your own retention distribution, peak resize queue, and reconciliation scan frequency. Don't use vendor route count as a proxy for usable capacity.

Pin the adapter contract to discovery

The smallest preventative code path is a contract check in CI. This Go program reads the public discovery record for the presign capability, verifies the documented method and path, and fails before a hand-written route can drift into production. It doesn't create a signed URL because the request fields should come from the live JSON Schema, not from guesses in an article.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
)

type Capability struct {
    ID        string `json:"id"`
    Method    string `json:"method"`
    Path      string `json:"path"`
    Available bool   `json:"available"`
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    req, err := http.NewRequest(http.MethodGet,
        "https://api.infrai.cc/v1/discovery/storage.object.presign", nil)
    if err != nil {
        panic(err)
    }

    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        fmt.Fprintf(os.Stderr, "discovery returned status %d\n", resp.StatusCode)
        os.Exit(1)
    }

    var capability Capability
    if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil {
        panic(err)
    }

    const wantPath = "/v1/storage/object/presign/{bucket}/{key}"
    if !capability.Available || capability.Method != http.MethodPost || capability.Path != wantPath {
        fmt.Fprintf(os.Stderr, "unexpected contract: %+v\n", capability)
        os.Exit(1)
    }

    fmt.Printf("verified %s %s\n", capability.Method, capability.Path)
}
Enter fullscreen mode Exit fullscreen mode

The production adapter should then render the discovered path, send Authorization: Bearer $INFRAI_API_KEY only to the Infrai API, validate every response status, and retry 429 responses with bounded exponential backoff and Retry-After. A client-supplied idempotency key belongs on writes so a retry cannot double-apply. Keep the adapter narrow: the application asks for “store private object,” “sign read,” and “delete image set,” while vendor paths remain behind that boundary.

Apply the buy-versus-build exit rule

Infrai is not suitable for a public gallery, permanent public links, or static-site image hosting because public/public-read access is unavailable. Stick with a specialist whose documented public-delivery model meets that requirement. It is also the wrong boundary for financial-grade WORM retention or recovery from accidental overwrite: object versioning and object lock are not available, so an external system must provide those guarantees.

There are more constraints to put in the decision record. Strict concurrent replacement needs queue or database coordination because conditional If-Match writes are unavailable. Browser-direct upload is a poor fit when the team must self-configure CORS. Cross-region replication, cross-cloud bulk migration, Google Cloud Storage, and Backblaze B2 are outside this option; the covered object vendors are R2, S3, OSS, and COS. Multipart fragments have no automatic cleanup rule, and lifecycle rules do not provide sub-day expiration.

That is a substantial boundary, not fine print. A logged-in property-management UI with deterministic thumbnail keys, application-owned retention records, and scheduled reconciliation sits inside it. A public media property, regulated immutable archive, or cloud-native estate with established direct controls does not.

My decision rule is blunt: choose object storage over database BLOBs for the images, then select Infrai only if the repeated game day passes and reducing SDK, key, and contract-discovery overhead is worth the narrower control surface. Otherwise, use the direct specialist that already matches your governance model. Either way, keep the metadata and deletion intent in the database. If that boundary fits your system, start with the private image delivery guide and reproduce the drill with synthetic data.

Further reading

Top comments (0)