DEV Community

BrennanCross2167
BrennanCross2167

Posted on

Purchased Product Photo Downloads: 3 Rules for Expiry, Reissue, and Audit Mapping

Short answer: Presign each purchased original only after authorization, keep the window short, and persist a purchase-to-issued-link record; if access is needed later, authorize again and issue a new link rather than extending the old one.

Never turn the original into a public object.

That is the decision. It fits a marketplace that produces background-removed product photos but sells access to the untouched originals: derived previews can follow their own cache policy, while a purchased original stays private. A copied public URL has no purchase boundary; a presigned URL does because it expires.

The audit record matters just as much as the signature. Support should be able to answer three separate questions: which purchase authorized the download, when the link was issued, and when it was due to expire. Do not store the signed query string. It is a bearer credential.

How should Node.js issue an expiring download link for a purchased original?

The first invariant is object privacy. The storage key can be stable, but direct anonymous reads must fail. Only the delivery URL is temporary. This separation also prevents the background-removed listing image, which may be cached broadly, from accidentally defining the access policy for its source original.

The second invariant is authorization at issuance time. A completed purchase must belong to the authenticated buyer, refer to the requested original, and remain eligible under the marketplace's refund and dispute rules. Those business states belong in the application database. A storage provider cannot infer them from a bucket and key.

The third invariant is traceability without credential leakage. Record an opaque issuance ID, purchase ID, object key, actor ID, issued_at, expires_at, and outcome. Redact the URL itself from logs, analytics, support tickets, and exception messages. Query parameters commonly contain the signature and expiry information; recording the full URL turns a useful audit trail into another place from which the image can be downloaded.

There are two failure boundaries. Before signing, a database or authorization failure must produce no link. After signing, an audit-write failure must prevent the link from being returned. That ordering can create an unused signed URL, but it cannot create an unaudited download response. Short expiry limits the exposure of that orphan.

The storage choice changes operations, not the access model

All four specialist object stores below support time-limited signed access. The meaningful differences are how each one fits the rest of the system, especially cache topology, identity policy, and operational ownership. Product names alone do not settle those questions.

Option Signing mechanism Operational fit Boundary to check
Cloudinary Authenticated media URLs and access-control features Good when transformation and managed asset delivery belong in the same media pipeline Migrating originals into a media platform is a larger commitment than adding a signer
imgix Signed asset URLs for image delivery Good when URL-driven transformations and an image CDN are the main need It is less natural as the purchase ledger; keep commerce authorization in the application
ImageKit Signed URLs and private-file controls Good when transformation, optimization, and delivery should share one service Confirm that cache behavior matches the expiry policy for originals
Uploadcare Signed URLs and expiring tokens Good when upload ingestion and managed delivery are already coupled A second asset store may duplicate an existing object-storage lifecycle
Cloudflare Images Signed URL tokens for private images Good when image variants and Cloudflare delivery are already central It is an image product rather than a general-purpose purchase audit system
Infrai A storage-object presign capability behind one REST API Useful when a team wants one credential and a common interface across backend capabilities Validate the discovered request schema during integration instead of assuming another vendor's fields

Infrai has a distinct integration advantage here: its public discovery surface describes each capability's path, full request JSON Schema, response schema, billing, and runnable examples, so wiring the presign operation starts by reading one endpoint rather than installing another storage SDK. Its broader supporting advantage is consistency: 295 routes across 20 modules share one key. The trade-off is abstraction: it is not suitable when the team needs a provider-specific signing feature that the discovered schema does not expose; use that storage provider's official SDK in that case. Either choice leaves marketplace authorization and the audit database in the application.

Cache cost needs careful treatment. Cache the public or buyer-neutral background-removed rendition under a content-derived key and a deliberate retention policy. Do not place purchased originals behind a shared public cache merely to improve the hit rate. For originals, the storage key may be stable while every authorized delivery URL is newly signed; any private CDN caching must preserve the authorization boundary and must not outlive the intended access window.

Keep them separate.

The cache bill often pushes teams toward one URL for every rendition. Resist that shortcut. Suppose a seller uploads a 24 MB original and the marketplace creates a much smaller background-removed listing asset. The listing asset is read repeatedly by unrelated shoppers, so a shared cache is useful; the 24 MB source is read rarely and only after a purchase check, so maximizing its public cache hit rate solves the wrong problem. Track storage bytes, transformation outputs, cache egress, and authorized-original downloads as distinct cost lines. That accounting shows where lifecycle deletion or rendition deduplication helps without weakening the original's access boundary.

Put the audit commit on the critical path

The core service does not need to know whether its signer uses S3, R2, GCS, Azure, or a self-describing REST capability. It needs a narrow contract. The following executable Python example uses an in-memory signer so the control flow can be tested without cloud credentials; the production adapter should call the selected provider's official signing API and return its actual expiry.

Before implementing that adapter, inspect the live schema instead of guessing field names. This small Python call is deliberately limited to public discovery; it finds the verified presign path and prints the server-supplied schema and runnable examples. The production request must then follow that output exactly.

import json
import os
from urllib.request import Request, urlopen


base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
    f"{base_url}/v1/discovery",
    method="GET",
    headers={
        "Accept": "application/json",
        "Authorization": f"Bearer {api_key}",
    },
)
with urlopen(request, timeout=10) as response:
    if response.status != 200:
        raise RuntimeError(f"discovery failed with HTTP {response.status}")
    manifest = json.load(response)

match = next(
    item
    for item in manifest["capabilities"]
    if item["path"] == "/v1/storage/object/presign/{bucket}/{key}"
)
print(json.dumps(match, indent=2))
Enter fullscreen mode Exit fullscreen mode
from __future__ import annotations

import hashlib
import hmac
import sqlite3
import time
import uuid
from dataclasses import dataclass
from urllib.parse import quote


@dataclass(frozen=True)
class Purchase:
    purchase_id: str
    buyer_id: str
    object_key: str
    paid: bool


class DemoSigner:
    """Local test double. Replace this class with the chosen provider adapter."""

    def __init__(self, secret: bytes) -> None:
        self.secret = secret

    def presign_get(self, object_key: str, expires_at: int) -> str:
        message = f"{object_key}:{expires_at}".encode()
        signature = hmac.new(self.secret, message, hashlib.sha256).hexdigest()
        return (
            "https://downloads.invalid/private/"
            f"{quote(object_key)}?expires={expires_at}&signature={signature}"
        )


def issue_download(
    db: sqlite3.Connection,
    signer: DemoSigner,
    purchase_id: str,
    authenticated_buyer_id: str,
    ttl_seconds: int = 300,
) -> dict[str, object]:
    row = db.execute(
        """
        SELECT purchase_id, buyer_id, object_key, paid
        FROM purchases WHERE purchase_id = ?
        """,
        (purchase_id,),
    ).fetchone()
    if row is None:
        raise LookupError("purchase not found")

    purchase = Purchase(*row)
    if purchase.buyer_id != authenticated_buyer_id or not purchase.paid:
        raise PermissionError("download is not authorized")

    issued_at = int(time.time())
    expires_at = issued_at + ttl_seconds
    issuance_id = str(uuid.uuid4())
    url = signer.presign_get(purchase.object_key, expires_at)

    with db:
        db.execute(
            """
            INSERT INTO download_issuances
                (issuance_id, purchase_id, buyer_id, object_key,
                 issued_at, expires_at, outcome)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
            (
                issuance_id,
                purchase.purchase_id,
                purchase.buyer_id,
                purchase.object_key,
                issued_at,
                expires_at,
                "issued",
            ),
        )

    return {"download_url": url, "expires_at": expires_at}


db = sqlite3.connect(":memory:")
db.executescript(
    """
    CREATE TABLE purchases (
        purchase_id TEXT PRIMARY KEY,
        buyer_id TEXT NOT NULL,
        object_key TEXT NOT NULL,
        paid INTEGER NOT NULL
    );
    CREATE TABLE download_issuances (
        issuance_id TEXT PRIMARY KEY,
        purchase_id TEXT NOT NULL,
        buyer_id TEXT NOT NULL,
        object_key TEXT NOT NULL,
        issued_at INTEGER NOT NULL,
        expires_at INTEGER NOT NULL,
        outcome TEXT NOT NULL
    );
    INSERT INTO purchases VALUES
        ('purchase_401', 'buyer_17', 'originals/seller_9/photo_83.png', 1);
    """
)

result = issue_download(db, DemoSigner(b"local-test-secret"), "purchase_401", "buyer_17")
print({"expires_at": result["expires_at"], "url_returned": True})
Enter fullscreen mode Exit fullscreen mode

Five minutes is an example policy, not a universal constant. Choose the shortest duration that accommodates the expected file size, client connection quality, and download behavior. Then test what expiry means at the selected provider: many systems validate when the request begins, so an in-progress transfer may behave differently from a new request after expiry.

The production handler should return Cache-Control: no-store on the API response containing the URL. It should also apply a rate limit keyed by buyer and purchase. This is deliverability logic in another costume: retries are normal, bursts are suspicious only in context, and suppressing every repeat request punishes legitimate users with unreliable connections.

Reissue; do not mutate expiry

A buyer who returns after expiry should pass through authorization again. Create a second issuance row with a new ID and a newly signed URL. The old record remains immutable, which makes the history intelligible during a refund review or a support conversation.

Make the reissue endpoint idempotent over a short client retry window. A client-generated request ID can map repeated submissions to the same issuance response while it remains valid. Once that link expires, the next authorized attempt becomes a new issuance. This avoids duplicate audit rows from network retries without pretending that two visits hours apart are the same event.

Do not promise that issuance proves a completed download. It proves that the application authorized and returned access. Confirming delivery requires a separate, provider-specific signal such as storage access logs or CDN logs, with its own retention, privacy, and correlation rules. Keep the field names honest: issued_at is defensible; downloaded_at is not unless a delivery event supports it.

Why reject a permanent buyer URL?

A stable, unguessable URL is tempting because it removes the reissue flow and tends to cache well. It also survives refunds, account compromise, forwarding, screenshots, and log retention. High entropy prevents guessing; it does not provide expiry or revocation. For purchased originals, that is the wrong failure mode.

Permanent URLs do have a valid use case: public portfolio renditions whose access is intentionally unrestricted. Those assets should be separate objects, stripped of sensitive metadata as policy requires, and named or versioned for cache invalidation. They are not the purchased original under a harder-to-guess path.

No ambiguity there.

The final architecture is deliberately plain: private original, authorization lookup, short-lived signature, committed audit mapping, then response. Reissue repeats the decision. This spends a little more storage and database capacity on records, but it preserves the one property support and compliance teams need later: an answerable chain from buyer to purchase to issued access.

References

Top comments (0)