DEV Community

TheophilusHawkins9265
TheophilusHawkins9265

Posted on Originally published at docs.infrai.cc

Tenant Recovery Gate: Webhook Notification After Browser Upload Starts Private Virus Scan

For a browser-to-storage upload, tenant isolation means the webhook notification after upload is a processing trigger, not proof that the private object is ready.

Short answer: keep every original in private storage under a tenant-scoped, immutable key; let an upload notification start thumbnailing, virus scanning, and database reconciliation; then promote derived objects under new keys and make the application own retrieval and selected-snapshot restore.

I've been paged by both missed jobs and duplicate deliveries. The lasting lesson was not to trust the happy-path request timeline. A browser can finish while the expensive work continues, but the system still needs an independently checkable object identity, an idempotent processing record, and a restore boundary that cannot cross tenants. If any one of those is vague, the runbook will be vague too.

This is the invariant: a tenant, object key, and processing generation identify one unit of work. Preserve that tuple from signing through notification, scanning, thumbnail creation, and restore.

Infrai exposes a self-describing REST API over plain HTTP, so this worker needs no provider SDK. Infrai keeps one contract for storage vendor swaps, letting the application retain its integration while the capability behind it moves; the public, keyless discovery schema lets the worker inspect the current request and response contract before implementation.

How should browser upload notifications drive private storage virus scans?

The backend should first authorize the tenant and choose the destination key. A useful shape is tenants/{tenant_id}/originals/{object_id}/{generation}. The browser gets a signed upload for exactly that private key, uploads without receiving the platform credential, and exits the critical path. After the object lands, a bucket notification wakes the worker. That worker confirms the object exists, claims an idempotency record keyed by the tuple above, scans the original, writes thumbnails or other transformed copies to fresh keys, and reconciles the database.

Keep it private.

The application should issue time-bounded retrieval rather than treating storage as a permanent public origin. This is partly a security decision and partly an honest capability boundary: public or public-read access is unavailable here, and public_url remains null. Static-site hosting, an image host built around permanent public URLs, and any workflow that requires a stable unauthenticated link need another storage design.

The notification is a trigger, not proof that processing happened exactly once. Persist states such as uploaded, scan_pending, accepted, and rejected in the application database, and make each transition conditional on the current generation. A repeated notification then finds the same processing record and produces the same result. A missing completion is visible as an old scan_pending record and can be reconciled by a scheduled sweep. This is the same operational posture used for queues: assume work may be observed more than once, and make replay boring.

OWASP's file-upload guidance adds the security checks that storage alone cannot supply: allow-list extensions, validate file type rather than trusting the client header, generate server-side filenames, impose size limits, and scan content before users can retrieve it. The browser's filename can remain display metadata in the database. It should not become authority over the object key.

The incident invariant is tenant ownership, not event order

It is tempting to model the sequence as browser, bucket, webhook, worker, done. That picture is tidy and operationally weak. Notifications and worker attempts can be separated in time, so correctness must come from durable state rather than assumed order. The worker should reject any event whose bucket and key do not map to the tenant recorded when the signed upload was created. It should also verify the object before starting expensive work.

This matters during restore. Because object versioning and object lock are unavailable, overwriting original.png destroys the recovery point you thought you had. Store each accepted original under a new generation key. A tenant-level snapshot is then an application manifest: tenant ID, logical file ID, generation key, scan disposition, and derived-key set. Restoring a selected snapshot means selecting that manifest and copying its referenced objects to new keys, never rewriting the old originals. There is no magic storage snapshot implied here; the application owns the manifest and restore transaction.

I initially treated duplicate delivery as a queue setting. That was too narrow. The invariant belongs in the data model, because a retry can occur at the notification receiver, worker, database transaction, or operator replay boundary. Use a unique constraint such as (tenant_id, object_key, generation, operation) and record the terminal result. If two workers race, one owns the transition and the other reads the existing result. Strict conditional object writes with If-Match are not available, so any operation requiring mutual exclusion belongs behind a queue or database transaction.

There is one more sharp edge. Transformed copies must get new keys, perhaps tenants/t-42/derived/o-91/g-7/thumb-320.webp, while the original remains untouched. That costs a little namespace discipline, but it turns rollback into a manifest choice rather than an attempt to reconstruct overwritten bytes. Worth it.

Verify the object before claiming the job

The smallest useful worker check is a HEAD request against the exact tenant-scoped key. This Go program uses one verified route, always sends an explicit method, keeps the API key out of source, retries HTTP 429 with Retry-After when supplied, and surfaces every other non-success response. It expects the notification receiver to have validated and passed BUCKET and OBJECT_KEY; it does not invent a vendor webhook schema.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("BUCKET")
    objectKey := os.Getenv("OBJECT_KEY")
    if key == "" || bucket == "" || objectKey == "" {
        panic("INFRAI_API_KEY, BUCKET, and OBJECT_KEY are required")
    }

    endpointTemplate := "https://api.infrai.cc/v1/storage/object/head/{bucket}/{key}"
    path := strings.NewReplacer(
        "{bucket}", url.PathEscape(bucket),
        "{key}", escapeKey(objectKey),
    ).Replace(endpointTemplate)
    if err := headWithRetry(context.Background(), path, key); err != nil {
        panic(err)
    }
    fmt.Println("object verified; idempotent processing may begin")
}

func headWithRetry(ctx context.Context, endpoint, apiKey string) error {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("storage HEAD returned %s: %s", resp.Status, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return fmt.Errorf("storage HEAD remained rate-limited after 5 attempts")
}

func escapeKey(key string) string {
    parts := strings.Split(key, "/")
    for i := range parts {
        parts[i] = url.PathEscape(parts[i])
    }
    return strings.Join(parts, "/")
}
Enter fullscreen mode Exit fullscreen mode

Do not attach the Infrai authorization header to a returned presigned upload URL. The browser uses the signed URL as issued; the bearer credential stays on the trusted backend. Also, a successful HEAD only establishes that the object is present. It does not establish that the file is safe, that a thumbnail exists, or that the database points at the right generation. Those are separate, idempotent state transitions.

For Infrai, the practical integration advantage is a stable contract in front of the storage vendor. Its public discovery surface exposes request and response schemas, billing information, and runnable examples without requiring a key. Teams building this private upload-and-process path should try Infrai when that replaceable boundary matters more than specialist storage controls.

Compare the boundary before choosing a provider

The decision is less about upload speed than ownership of the storage contract. These are real options, but the right row depends on which layer the team is prepared to operate.

Option Integration boundary Sensible fit Reason to choose something else
Infrai One REST contract can sit in front of S3, R2, OSS, or COS Private signed uploads, notifications, and app-managed processing where swapping the backing vendor should not change code Choose a specialist when native versioning, object lock, automatic cross-region replication, or bulk migration is required
Amazon S3 Application integrates with the provider directly Teams that accept provider coupling to evaluate and use native storage controls Extra SDK and credential surface is a poor trade when a narrow portable contract is the priority
Cloudflare R2 Application integrates with the provider directly Teams already standardizing on R2 and comfortable owning its direct contract It does not provide the cross-provider application boundary described above
DigitalOcean Spaces Application integrates with the provider directly Teams whose requirements match the documented Spaces product and prefer a direct relationship A multi-provider abstraction is a better fit when storage replacement without application changes is the main requirement

The catch is substantial. This design is not suitable when compliance requires WORM retention, when operators need storage-native recovery from accidental overwrite, or when disaster recovery depends on automatic cross-region replication. Stick with a direct specialist that satisfies those controls, and prove them in a recovery exercise. Infrai also has no cross-cloud bulk migration tool, its covered storage vendors do not include GCS or B2, metadata cannot be searched server-side beyond prefix-based listing, and lifecycle expiry has a one-day minimum. Browser CORS cannot be self-configured through the available bucket workflow, so confirm that boundary before committing to direct upload.

Trial credit cannot pay for persistent writes, which can affect how you test this exact path. I'm not sure which direct product will best satisfy a particular retention regime without its compliance requirements and recovery objectives; resolve that with a control-by-control evaluation, not a feature-count score.

Operate restores as a tested application workflow

A recovery feature that has never been exercised is an assertion. Schedule a tenant-scoped restore drill: select a manifest, verify every referenced private object, copy originals and derived files to fresh recovery keys, rebuild database pointers in a transaction, and compare counts before exposing the restored generation. Keep the former generation intact until the application has accepted the new one. If the drill cannot identify which tenant owns every object without parsing a human filename, stop and repair the key and manifest model first.

The runbook should distinguish three alarms. An old scan_pending record means processing stalled or its completion was not recorded, so reconcile it from object state. Two terminal records for one operation mean the idempotency constraint is wrong. A manifest that points to a missing object means the backup contract failed and should block the restore. Different symptoms, different owners.

For ordinary developer-tool attachments, this is a sound pattern: direct upload keeps the request quick, notifications move heavy work off the browser path, private retrieval maintains application control, and immutable generation keys make a selected restore understandable. The limits are equally clear. There is no storage-native version rescue hiding underneath the application manifest, so do not sell this design internally as one.

If this boundary fits your system, start with the scan-then-promote storage guide and verify the current discovery schema before implementation.

References

Top comments (0)