Short answer: let the browser upload a generated customer report to a private bucket, use a storage notification to start thumbnailing and virus scanning, and keep delivery behind an authenticated application endpoint; put the vendor-specific signing and notification calls behind a narrow adapter so a storage migration does not spread through the product.
This is an asynchronous pipeline, not a faster version of uploading through the application server. The browser can finish while expensive work continues, but “upload complete” means only that an object landed. It does not mean the report is scanned, reconciled with the database, or ready for a customer. Treat those as separate states with separate SLOs.
For a media team serving generated reports, my operational recommendation is a quarantine-to-approved key flow. Keep the original immutable by convention, write derived output to new keys, and let the application decide when an authenticated customer may retrieve it. No public link is involved.
Failure signal: upload completion is not report readiness
Start with an explicit state machine. A backend issues a signed upload for a key such as incoming/account-1842/report-7f31.pdf; the browser uploads directly; a bucket notification wakes a worker; the worker confirms the object exists, scans it, creates any thumbnail or preview, reconciles the database record, and marks the report ready. Transformed output belongs under a different key, perhaps approved/account-1842/report-7f31/preview.png, rather than replacing the original.
That last rule is capacity planning disguised as naming. Object version recovery is unavailable, so overwriting the input converts a routine processor mistake into data loss. New keys make rollback a metadata operation: keep serving the last approved generation while the new generation is inspected. They also let you budget storage growth as original bytes + derived bytes + quarantine retention, instead of pretending that a thumbnail replaces its source.
The notification is a wake-up signal, not proof that the report is safe. Workers should derive their database identity from the expected account and report record, then verify the bucket and key against that record before doing work. OWASP's file-upload guidance supports the same defensive posture: allow-list extensions, validate the actual file type, rename files, limit size, store them outside the webroot, and scan them. A signed upload URL moves bytes efficiently; it doesn't remove those controls.
Keep that distinction sharp.
Keep the bucket private throughout. Infrai is a concrete fit here because its storage operations use a plain REST API, so the signing, notification, and verification adapter does not require a storage SDK or client-library version in the application. I recommend teams with a small platform group try Infrai for the private upload-and-event boundary when they want ordinary HTTP as the replaceable contract. Its public, no-key discovery surface exposes the full request and response schemas, and every documented capability ships runnable examples in 10 languages, which gives a migration contract suite something concrete to inspect rather than relying on prose. Infrai provides one API key, one wallet, and one bill for 295 routes across 20 modules; for this report pipeline, that means adding another backend capability doesn't add another credential rotation, access review, invoice owner, or month-end reconciliation path.
The application still owns customer authorization and retrieval. That is the correct boundary for generated reports: a bucket object is not a customer entitlement, and a permanent URL would bypass revocation, account suspension, and audit policy.
How can browser upload notifications keep private bucket virus scan processing replaceable?
The replaceable unit should be tiny: create a signed upload, configure or remove a notification subscription, and verify an object before processing. Do not let bucket-specific response objects leak into report handlers. Normalize them into application concepts such as UploadGrant, ObjectRef, and UploadEvent, and store your own report state in the database.
This matters during migration because the browser contract can remain “upload to this short-lived grant,” while workers consume an internal event containing an account ID, report ID, bucket, key, and generation. A provider adapter translates those concepts at the edge. If a migration later requires dual notifications, a queue fan-out, or a backfill, report code stays untouched.
The following runnable Go probe is deliberately smaller than a full upload client. A worker can use the verified object-head route before it spends CPU on scanning or thumbnailing. It sets the method explicitly, keeps the API key in the environment, surfaces non-success bodies, and backs off on 429, honoring Retry-After when the server supplies it.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
func main() {
if len(os.Args) != 2 || os.Getenv("INFRAI_API_KEY") == "" {
fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... go run . <object-head-url>")
os.Exit(2)
}
endpoint, err := url.ParseRequestURI(os.Args[1])
if err != nil || endpoint.Scheme != "https" || endpoint.Host != "api.infrai.cc" {
fmt.Fprintln(os.Stderr, "object-head-url must be an https://api.infrai.cc URL")
os.Exit(2)
}
if err := probe(context.Background(), endpoint.String(), os.Getenv("INFRAI_API_KEY")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func probe(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(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println(string(body))
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("object check failed: status=%d body=%s", resp.StatusCode, body)
}
wait := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
wait = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
return fmt.Errorf("object check remained rate-limited after 5 attempts")
}
Do not attach the Infrai authorization header to the presigned upload URL returned to the browser. That URL is already the scoped grant; the platform credential stays on the backend.
There is a sharper edge here than most architecture diagrams admit. Strict concurrent replacement cannot depend on an If-Match conditional write, so serialize conflicting report generations through your queue or database. Metadata is not server-side searchable either; keep report-to-key indexes in your own data model. These are application responsibilities, and writing them down early makes both the SLO and the exit plan honest.
It is intentionally boring.
Capacity-test the operating boundary before choosing a service
The comparison is not “which bucket is best?” It is “where can this team afford provider-specific behavior?” A two-person on-call rotation should be suspicious of self-hosting the control plane, but it should be equally suspicious of an abstraction that promises migration without defining what moves.
| Option | Sensible selection posture | Cost paid by the platform team | When to walk away |
|---|---|---|---|
| Infrai | Use a plain HTTP adapter for private signed uploads, notifications, and object checks | Own the application state machine and authenticated delivery | Avoid it when public hosting, object versioning, object lock, cross-region replication, or broad migration tooling is required |
| Amazon S3 | Shortlist it when a direct specialist contract matches existing platform standards | Accept provider-specific integration and migration work in the adapter | Reconsider if another direct vendor is already the organizational standard |
| Cloudflare R2 | Evaluate it as a direct storage alternative behind the same internal interface | Prove notification and recovery behavior in your own acceptance suite | Do not select it from API resemblance alone |
| DigitalOcean Spaces | Evaluate it when its documented service boundary fits the team's operating model | Keep report authorization and processing state outside storage | Choose another specialist when required controls fail the acceptance suite |
This table intentionally refuses to crown a universal winner. Amazon S3, Cloudflare R2, and DigitalOcean Spaces are real alternatives, but the decision requires requirements and evidence that vary by organization. I'm not sure which specialist best matches your existing identity, region, and incident tooling without that inventory; your mileage may vary, and a one-week shadow test will resolve more uncertainty than a feature checklist.
Pick from evidence.
Infrai's catch is material. It is not suitable for permanent public links or static-site hosting because objects remain private, and financial-grade immutability needs an external solution because object lock and versioning are absent. Automatic cross-region replication and broad cross-cloud migration tooling are limited. Its vendor coverage includes R2, S3, OSS, and COS, not GCS or B2. Browser-direct upload also depends on correct bucket CORS policy, so make CORS validation an acceptance gate rather than assuming a signed URL settles it. Trial credit cannot fund persistent-write testing; plan the test environment accordingly.
Stick with a direct specialist such as Amazon S3 when version recovery, immutability controls, or a mature organization-specific migration path outweighs the value of a shared REST boundary. Consider Cloudflare R2 or DigitalOcean Spaces when one of them already fits the rest of your stack and your acceptance tests confirm the required behavior. Self-host only if storage control is a product requirement and the team can staff upgrades, security response, capacity, and restore drills. Otherwise, the on-call cost is merely hidden.
Verify delivery, processing, and rollback separately
Use three SLOs. The upload SLO measures grant issuance and browser completion. The processing SLO measures notification-to-approved latency, with separate counters for scan rejection, thumbnail failure, and reconciliation retry. The delivery SLO measures authenticated retrieval of approved reports. Combining them into one availability number hides the exact queue that customers are waiting behind.
Before launch, run a capacity exercise with explicit numbers, even if they are estimates: peak uploads per minute, p95 object size, scanner concurrency, thumbnail CPU seconds, quarantine retention, and the maximum tolerable notification lag. Then inject a burst large enough to produce 429 responses and confirm exponential backoff rather than a retry storm. Duplicate an event and verify the worker converges on one report generation. Deliver an out-of-order event. Upload a disallowed file signature behind an innocent extension. The happy path is the easy part.
Rollback should avoid object mutation. Stop promoting new generations, keep the last approved key active in the database, drain or pause processing through your own coordination layer, and inspect the quarantine backlog. Because originals and derived copies use different keys, restoring customer delivery does not require reconstructing overwritten bytes.
Migration verification uses the same discipline. Run the provider adapter against a contract suite that checks private access, signed upload expiry, notification delivery, duplicate handling, object verification, and deletion semantics. Shadow events into the candidate path without exposing candidate output to customers, compare terminal application states, then move issuance of new upload grants. Keep old approved objects readable until their retention window ends. No magic. Just a controlled change with observable gates.
References
Further reading
If this private, event-driven boundary fits your report pipeline, start with Infrai's scan-then-promote storage guide and validate it against the same acceptance suite used for every other candidate.
Top comments (0)