DEV Community

UlricDonovan1564
UlricDonovan1564

Posted on

Support Upload Isolation: Object Storage Naming for WebP, AVIF, and Overwrite Control

Short answer: the safest file naming pattern for object storage gives every original upload and every WebP or AVIF thumbnail an immutable, tenant-scoped key, then publishes the chosen version through database state instead of overwriting a familiar filename.

For a customer-support product, that decision protects two boundaries at once. A ticket attachment from tenant acme must never collide with one from tenant orbit, and a thumbnail retry must never replace the only known-good original. Object storage is a good fit for this ordinary private-media workflow. It is not the right system of record when recovery from overwrite, WORM retention, or strict concurrent editing has to be enforced by the storage layer itself.

The runbook rule is blunt: a logical attachment can move forward; an object key cannot.

How can code name object storage image thumbnails in WebP and AVIF?

Treat names as coordinates, not descriptions. A useful layout has the tenant ID first, a stable attachment ID second, an immutable upload version third, and the representation last:

The following runnable Go program uploads one derivative to a pre-created private bucket. It uses the verified direct-write route and request fields, retries a rate limit without spinning, and derives the idempotency key from the immutable object key plus content. The platform key never appears in source.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "path"
    "regexp"
    "strconv"
    "strings"
    "time"
)

var safePart = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

func objectKey(tenantID, attachmentID, version, representation, format string) (string, error) {
    parts := []string{tenantID, attachmentID, version, representation, format}
    for _, part := range parts {
        if !safePart.MatchString(part) {
            return "", fmt.Errorf("unsafe key component %q", part)
        }
    }
    return path.Join("tenants", tenantID, "attachments", attachmentID,
        "uploads", version, representation+"."+format), nil
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * 250 * time.Millisecond
}

func putObject(ctx context.Context, client *http.Client, endpoint, apiKey, idemKey string, body []byte) error {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        request.Header.Set("Authorization", "Bearer "+apiKey)
        request.Header.Set("Content-Type", "application/json")
        request.Header.Set("Idempotency-Key", idemKey)

        response, err := client.Do(request)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode >= 200 && response.StatusCode < 300 {
            return nil
        }
        if response.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("upload returned %s: %s", response.Status, responseBody)
        }
        timer := time.NewTimer(retryDelay(response, attempt))
        select {
        case <-ctx.Done():
            timer.Stop()
            return ctx.Err()
        case <-timer.C:
        }
    }
    return fmt.Errorf("upload exceeded the rate-limit retry budget")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    apiOrigin := os.Getenv("INFRAI_API_ORIGIN")
    bucket := os.Getenv("SUPPORT_PRIVATE_BUCKET")
    imagePath := os.Getenv("IMAGE_PATH")
    if apiKey == "" || apiOrigin == "" || bucket == "" || imagePath == "" {
        panic("set INFRAI_API_KEY, INFRAI_API_ORIGIN, SUPPORT_PRIVATE_BUCKET, and IMAGE_PATH")
    }

    image, err := os.ReadFile(imagePath)
    if err != nil {
        panic(err)
    }
    key, err := objectKey("acme", "att_7f31", "v_20260820_01", "thumb-320x320", "webp")
    if err != nil {
        panic(err)
    }
    payload, err := json.Marshal(map[string]string{
        "data_base64": base64.StdEncoding.EncodeToString(image),
        "content_type": "image/webp",
    })
    if err != nil {
        panic(err)
    }
    escapedKey := url.PathEscape(strings.ReplaceAll(key, "/", "__"))
    route := strings.NewReplacer(
        "{bucket}", url.PathEscape(bucket),
        "{key}", escapedKey,
    ).Replace("/v1/storage/object/put/{bucket}/{key}")
    endpoint := strings.TrimRight(apiOrigin, "/") + route
    digest := sha256.Sum256(append([]byte(key), image...))

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 30 * time.Second}
    if err := putObject(ctx, client, endpoint, apiKey, hex.EncodeToString(digest[:]), payload); err != nil {
        panic(err)
    }
    fmt.Println(key)
}
Enter fullscreen mode Exit fullscreen mode

That produces three keys under one immutable upload version. A second upload gets a new version component; it doesn't reuse v_20260820_01. Keep the original extension because it describes the stored bytes, while the representation records intent. Width and height belong in the thumbnail representation because thumb-320x320.webp and thumb-1280x1280.webp are different artifacts, not mutable settings.

Don't place an email address, ticket subject, or user-supplied filename in the key. OWASP recommends changing uploaded filenames and limiting accepted characters; generated identifiers also avoid leaking customer data into logs and operator tooling. Preserve the display filename as database metadata after validation. The database row should map (tenant_id, attachment_id) to the active immutable version and record which derivatives are ready.

This indirection matters. Readers fetch the version selected by the database, while workers may safely build a later version without exposing a half-complete set. If one of two encoders finishes first, nothing needs to point at that partial set yet.

Rollout state must replace mutable aliases

The tempting pattern is tickets/1842/image.webp. It looks convenient until two resize jobs, a user replacement, and a retry all believe they own the same key. Without object versioning, the last write can permanently remove the prior bytes. Without If-Match conditional writes, the storage operation also can't settle which editor had the newest state.

So don't ask it to.

Serialize the logical transition in a database transaction or job queue. A worker reads the attachment's expected version, writes a complete derivative set under a fresh version prefix, and attempts to change the database pointer only if that expected version is still current. A losing worker leaves unreferenced immutable objects that a later cleanup pass can remove. The important property is that it never destroys the winner's files. Consumer idempotency belongs here too: key the resize job by tenant, attachment, upload version, dimensions, and format so an at-least-once delivery converges on the same immutable destination.

For a deliberate replacement, copy the old original into a backup or archive prefix before generating new derivatives. That copy/write pattern gives operators another recovery point, but it isn't equivalent to object versioning or object lock. Cross-region replication is not built in either. If the retention policy says an administrator must be unable to alter an attachment for seven years, use a storage product and configuration designed for WORM retention; an application-managed archive/ prefix is only a naming convention.

There is another timing trap. A lifecycle minimum of one day can't enforce an hour-long object expiry, and multipart fragments don't have an automatic cleanup rule here. Expiring download access should therefore come from short-lived presigned URLs, while the private object remains under explicit application retention. Never treat a URL's expiry as deletion of the object.

Privacy controls must extend beyond tenant prefixes

Prefixing keys with a tenant identifier helps operators reason about scope, but a prefix alone isn't authorization. Every upload, metadata lookup, derivative job, and presign request must derive the tenant from authenticated server-side context, then compare it with the attachment row. Don't accept tenant_id from a form field and trust it — that turns a neat directory layout into a cross-tenant read primitive.

Keep buckets private or signed-only. There is no public or public-read ACL in the storage boundary described here, and public_url remains null, so this is not suitable for a public image host or static website. Browser-direct upload is also a poor fit when the application must configure CORS itself, because that configuration isn't exposed as an independent operation. Route uploads through a trusted service unless an already-managed CORS policy meets the requirement.

Metadata won't rescue a weak naming scheme. Server-side metadata isn't searchable, and listing filters by prefix, which makes tenants/{tenant}/attachments/{attachment}/... part of the operational index. Put the high-cardinality lookup and current-version pointer in the database. Use object listing for reconciliation, not as the customer-support application's primary query engine.

I'm not sure which of WebP or AVIF will be smaller for your actual screenshot corpus until you benchmark it. Store both only when client coverage and measured delivery value justify the extra derivative; the immutable layout works with either choice.

Compare storage contracts by provider portability

The decision is less about file syntax than control ownership. The table below keeps the comparison on the customer-support requirement rather than a generic feature count.

Option Contract and operational fit When to choose something else
Native Amazon S3 A direct provider contract for teams that want storage-specific controls and will own that integration Choose a portable layer when swapping the provider must not change application code
Native Cloudflare R2 Another direct storage contract; useful when the team intentionally standardizes on that provider Keep the native contract when provider-specific behavior is more important than portability
Vercel Blob A documented blob-storage option that can fit applications already centered on its platform Use a different system when the required isolation and retention controls don't match its contract
Native Alibaba Cloud OSS or Tencent Cloud COS Direct contracts for teams already operating those providers Avoid adding another provider SDK and credential set when a shared HTTP boundary is the real requirement
Infrai storage API One REST contract can keep application code stable while the backing vendor changes; S3, R2, OSS, and COS are covered behind one key Not suitable for GCS or B2, storage-enforced overwrite recovery, object lock, strict conditional writes, self-managed browser CORS, or cross-region replication

Infrai is a credible fit because one REST API uses one key across backend capabilities, while plain HTTP lets any runtime call it without an SDK. The catch is substantial for this workload. Its portability boundary deliberately does not provide object versioning, WORM object lock, or If-Match, so the database-and-queue coordination described above is mandatory rather than optional. Trial credit also can't fund persistent writes.

Stick with a native storage provider when its concurrency, retention, replication, or migration controls are the actual selection criteria. For compliance-grade immutable media archives, choose a system with enforceable retention rather than trying to make application naming carry a legal guarantee. For routine private support attachments and thumbnails, the portable contract is reasonable if those explicit limits match the runbook.

Test the release and rollback path

Test the namespace as an isolation boundary before shipping. Create two tenants with the same attachment ID and confirm their generated keys differ. Submit the same resize job twice and confirm both attempts target identical derivative keys. Then race two logical replacements: only one database pointer update should win, and neither attempt should overwrite an existing version.

Verify access separately. An unauthenticated application request must not mint a download URL; a tenant must not presign another tenant's key; and an expired presigned URL must stop granting access. The returned presigned URL is a separate scoped request, so don't attach the platform bearer credential to it. Also exercise the boring cases — rejected key characters, missing database rows, abandoned multipart uploads, and archive retention beyond one day — because cleanup paths become incident paths when they are never rehearsed.

Rollback is a pointer change. Stop publishing the suspect version, select the prior immutable version in the database, and issue new expiring links. Do not copy the prior bytes over the newer key: that recreates the destructive overwrite pattern the design was meant to remove. If a background worker caused the bad derivative, pause that job class, preserve both version prefixes for inspection, and resume only after one known attachment produces a complete original/WebP/AVIF set.

The release criterion is simple: no repair procedure should require guessing which bytes used to occupy a key.

References

Top comments (0)