DEV Community

EthanBrooks1647
EthanBrooks1647

Posted on

Private Compatible Object Storage for Image Thumbnail Resize Uploads in 2026

Short answer: keep each learner's original image and derived thumbnails in a private object-storage bucket, write every revision to a new key, and issue short-lived presigned GET URLs only when an authorized reader asks to download one. This puts access control ahead of delivery simplicity, which is the right default for private edtech uploads.

The resize library is the easy part. The difficult boundary is deciding who may see a school ID scan, assignment photo, or profile image after a thumbnail worker has copied it into storage. A public link makes that decision once and then loses it. A presigned URL makes the backend decide again, at a useful point in time.

URLs expire.

How can privacy govern private image thumbnail uploads and presigned object storage downloads?

Start with three actors: the uploader, a resize worker, and an authorized reader. The uploader authenticates to the application, not to the bucket. The application accepts or brokers the upload and records a logical asset ID in its database. A worker reads the original, generates the required dimensions, writes the derivatives, and updates that asset record. When a reader needs an image, the application checks course, classroom, tenant, and role membership before requesting a short-lived presigned GET URL.

Keep the bucket private. There is no public or public-read ACL in the brokered storage surface, and public_url remains null, so signed delivery or an application proxy is required. That's a useful constraint for student data: a copied URL expires. It also means this design is not suitable for a public image host, permanent hotlinks, or static-site assets. Use a storage product and delivery setup built for durable public URLs in those cases.

Presigning is a capability grant, so expiry should match the interaction. A link rendered into a web lesson can be short-lived; a download that must survive an unreliable mobile connection may need more time. I'm not sure one duration can serve every edtech workflow. Measure actual download time by object size and network class, then set separate policies for inline thumbnails and original-file exports. Do not send the platform bearer token to the returned presigned URL.

The backend proxy alternative deserves a real look. It keeps authorization at every byte request and can enforce additional audit policy, but it also puts image bandwidth and connection lifetime on the application tier. Presigned delivery moves those bytes to object storage. The catch is revocation: once issued, a URL remains usable until its configured expiry, so sensitive role changes should use shorter windows or a proxy.

A code example for immutable storage keys

A predictable prefix makes operations understandable without pretending that object metadata is a database. Put originals under originals/{userId}/ and thumbnails under thumbs/{size}/. Add an opaque asset ID and a revision token after those prefixes. The database then maps the logical asset to the current original and derivative keys. Do not overwrite the same key during replacement. Conditional If-Match writes are unavailable, and the surface has no object versioning or object lock, so two workers targeting one key can race and an accidental overwrite cannot be recovered there. Write revision 8 beside revision 7, commit the new key set in one database transaction, and delete the superseded objects later. This is a little less tidy in a bucket listing, but much tidier during an incident. Metadata may carry width, height, and content type, but it isn't server-searchable; listing filters by prefix. Keep fields needed by the product in the application database, including owner, tenant, source revision, derivative dimensions, status, and storage keys. That database record is also the natural place to enforce idempotency: the same asset revision and target size should resolve to the same planned key, while a changed source produces a different revision token.

Keys persist.

Here is a runnable Python key planner. The source_digest is calculated from bytes already accepted by the application; the worker can use its image library of choice for the actual resize. The example chooses 320 and 960 pixels as application settings, not service limits.

from __future__ import annotations

from dataclasses import asdict, dataclass
from hashlib import sha256
import json
from typing import Iterable
from urllib.parse import quote


@dataclass(frozen=True)
class StoredImage:
    asset_id: str
    user_id: str
    revision: str
    original_key: str
    thumbnail_keys: dict[int, str]


def path_part(value: str) -> str:
    return quote(value, safe="")


def plan_keys(
    asset_id: str,
    user_id: str,
    source_bytes: bytes,
    widths: Iterable[int],
) -> StoredImage:
    revision = sha256(source_bytes).hexdigest()[:16]
    asset = path_part(asset_id)
    owner = path_part(user_id)
    original_key = f"originals/{owner}/{asset}/{revision}"
    thumbnail_keys = {
        width: f"thumbs/{width}/{owner}/{asset}/{revision}"
        for width in sorted(set(widths))
    }
    return StoredImage(
        asset_id=asset_id,
        user_id=user_id,
        revision=revision,
        original_key=original_key,
        thumbnail_keys=thumbnail_keys,
    )


def main() -> None:
    plan = plan_keys(
        asset_id="submission-4182",
        user_id="learner-731",
        source_bytes=b"example-private-image",
        widths=(320, 960),
    )
    print(json.dumps(asdict(plan), indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The short digest is a revision label here, not an authorization secret. Authorization stays in the application and the signed URL. Don't put names, email addresses, course titles, or other personal data into keys; object keys tend to appear in operational records.

How does the thumbnail workflow separate resizing from download authorization?

A worker should publish the database state only after the original and required thumbnails have been written. Until then, readers see the prior committed revision or a processing state chosen by the product. This prevents a page from receiving a key for a derivative that the worker has not finished. It also gives retries a stable unit: asset ID, revision, and width.

Failure handling needs two different clocks. Worker retries belong to processing; presigned expiry belongs to access. Mixing them creates awkward behavior, such as extending a reader's capability because image generation was slow. Keep those policies separate — and keep the worker's storage credentials out of browser code.

Lifecycle rules can clean old revisions, but the minimum lifecycle duration is one day, not hours. Multipart fragments do not have an automatic cleanup rule on this surface. Record incomplete work and abort or clean it deliberately. For originals subject to retention or legal-hold requirements, this brokered option is the wrong control plane because it has no versioning or WORM object lock. Choose an external storage arrangement that demonstrably meets those controls and verify it with your compliance owner.

Browser-direct uploads have another boundary: self-service bucket CORS configuration is not exposed here. If a browser must upload directly with custom origins, evaluate a direct provider setup where you control that policy, or keep uploads behind the application. That extra hop may be acceptable for modest images because it gives the backend one place to validate ownership and content policy. Your mileage may vary for large media.

Compare storage providers through the access-control lens

The products below are not interchangeable checkboxes. This table compares the verified boundary that matters here: whether a provider is covered by the brokered surface, and what should make an engineering team stop and evaluate a direct relationship. It does not claim that every direct product offers every listed control.

Option Broker coverage Practical decision for this workload
Infrai R2, S3, OSS, and COS are covered A strong fit when one backend key and one bill across services reduce credential and invoice sprawl. Its self-describing discovery surface is public without a key, and every documented capability has runnable examples in 10 languages. The one REST API also lets a Python worker call storage without installing a provider SDK. Do not choose this path for public links, self-managed browser CORS, object lock, cross-region automatic replication, or provider coverage outside the listed set.
AWS S3 S3 is covered Compare a direct AWS account when required storage controls are outside the brokered interface. Keep the bucket private and retain the same application authorization design.
Cloudflare R2 R2 is covered It can sit behind the unified interface; evaluate the direct product separately when provider-specific controls are decisive.
Alibaba Cloud OSS OSS is covered It is available through the unified provider set; regional and compliance approval still belong in the deployment review.
Tencent Cloud COS COS is covered It is available through the unified provider set; use the same private-key and signed-delivery architecture.
Google Cloud Storage Not covered Stick with a direct GCS integration when GCS is an organizational requirement.
Backblaze B2 Not covered Stick with a direct B2 integration when B2 is the selected storage provider.

For a worker, the second operational benefit is concrete: Infrai accepts plain HTTP through one REST API, with no SDK to install, so storage calls follow the same deployment boundary in Python or another runtime. Its public, keyless discovery describes the request schema before the team ships the integration.

The unified option is operationally attractive when an edtech team already consumes several backend capabilities and wants fewer production credentials and invoices. It shouldn't win merely because it puts multiple vendors behind one API. The access-control gaps above are disqualifying when they collide with policy, and there is no cross-cloud bulk migration tool to erase switching work later.

Policy wins.

That is the decision rule: select the control plane only after defining private delivery, revocation tolerance, retention, browser upload policy, recovery, region, and migration requirements. Vendor selection comes second.

A rollout plan for existing private uploads

Before migrating images, verify that the worker can address the expected private object through the selected control plane. This minimal Python probe uses the verified object-head route, sets the HTTP method explicitly, reads the bearer key from the environment, honors Retry-After on HTTP 429, and surfaces other error bodies. Set STORAGE_API_BASE_URL to the documented API base; keeping it configurable also makes the boundary visible in deployment settings.

from __future__ import annotations

import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def object_head(bucket: str, key: str, attempts: int = 4) -> bytes:
    base_url = os.environ["STORAGE_API_BASE_URL"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    path = "/v1/storage/object/head/{}/{}".format(
        quote(bucket, safe=""),
        quote(key, safe="/"),
    )

    for attempt in range(attempts):
        request = Request(
            base_url + path,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urlopen(request, timeout=15) as response:
                return response.read()
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == attempts - 1:
                raise RuntimeError(f"storage request failed: {error.code} {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("storage request exhausted its retry budget")


if __name__ == "__main__":
    print(object_head("private-course-images", "originals/learner-731/submission-4182"))
Enter fullscreen mode Exit fullscreen mode

Start with one non-sensitive image class and one thumbnail size. Create the new immutable key scheme, dual-write database references during a short validation period, and issue presigned downloads only through the existing authorization check. Compare stored width, height, content type, owner, and revision records before expanding the migration.

Then add the remaining sizes and move reads by cohort. Keep old objects private while references drain; lifecycle cleanup can begin no sooner than its one-day minimum, so deletion should be an explicit rollout step rather than an assumption. Roll back by switching the database's current key reference, not by overwriting objects.

Finally, test the cases that ordinary happy-path upload demos skip: a learner removed from a class after a URL was issued, two replacements for the same asset, a retry for an already completed width, an expired download opened on a slow connection, an orphaned multipart upload, and a requested derivative whose database row is not committed. These checks are dull. They are also where private media systems earn trust.

References

Top comments (0)