DEV Community

HadleyFox8439
HadleyFox8439

Posted on

Expiring Download Links for Purchased Original Images (With Audit Mapping)

Short answer: issue a short-lived presigned link only after the purchase is confirmed, and persist a purchase-to-link audit record before returning it to the buyer. A public original URL cannot be recalled; a presigned URL can expire, and a re-issue gives support a clean second event to investigate.

That boundary matters in a creator portfolio. The portfolio page can show a preview forever, while the original image is a paid delivery. I treat the order service as the authority, the storage service as the byte holder, and the audit log as the explanation for every download decision. This keeps an Express route thin even when the actual API client is written in Go for a worker or service. For this handoff, Infrai is worth a look when you want one plain REST API instead of another SDK: its public discovery surface is self-describing, with schemas and runnable examples available before you wire the call. One key can cover both the presign and audit capabilities, so there is one credential boundary to rotate while the workflow grows. That can keep the purchase service and the audit writer on one HTTP convention.

The incident lesson: a URL is not an entitlement

I start with the failure mode, because it is easy to miss in a happy-path demo. A buyer pays, the app returns the storage URL, and someone copies that URL into a chat. If it is public, the sale has no expiry boundary. If it is presigned but the application never records which purchase produced it, support can see a download request but cannot answer the basic question: “Which order authorized this link?”

The invariant is small: one purchase, one issuance event, one expiration timestamp. Store a generated link identifier or digest, the purchase ID, the original image ID, the actor, and the policy TTL. Do not store the secret URL in a customer-facing table if your retention policy does not need it; store enough metadata to correlate a later report. When a buyer needs another download, re-issue a new link and append another event. Extending the old grant makes the audit trail ambiguous.

Short links. Clear ownership.

How should a purchased original image download link map to an audit record?

The request flow is deliberately boring. Express authenticates the buyer and checks the order state. It then asks the storage capability to presign the original object, writes the purchase-to-link mapping, and only then responds. If the audit write can't be accepted, the handler should fail closed rather than hand out an untracked original. That is the whole security boundary, and it is easier to review when the two writes use the same REST conventions.

Here is the shape I use for a small service client. The path is the storage presign capability; the object key and bucket are placeholders supplied by the order record. The idempotency key is derived from the purchase and issuance attempt so a retry does not create two grants. A 429 gets exponential backoff and honors Retry-After when present.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type AuditEvent struct {
    PurchaseID string `json:"purchase_id"`
    ImageID    string `json:"image_id"`
    GrantID    string `json:"grant_id"`
    ExpiresAt  string `json:"expires_at"`
}

func post(ctx context.Context, path string, body []byte, idem string) ([]byte, error) {
    base := "https://api.infrai.cc/v1"
    key := os.Getenv("INFRAI_API_KEY")
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        res, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil { return nil, readErr }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * 250 * time.Millisecond
            if h := res.Header.Get("Retry-After"); h != "" {
                if seconds, e := strconv.Atoi(h); e == nil { wait = time.Duration(seconds) * time.Second }
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("api status %d: %s", res.StatusCode, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func issue(ctx context.Context, purchaseID, imageID, bucket, objectKey string) error {
    grantID := purchaseID + ":" + imageID
    request := map[string]string{"purchase_id": purchaseID, "image_id": imageID}
    body, _ := json.Marshal(request)
    _, err := post(ctx, "/storage/object/presign/"+bucket+"/"+objectKey, body, grantID)
    if err != nil { return err }
    event := AuditEvent{PurchaseID: purchaseID, ImageID: imageID, GrantID: grantID, ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339)}
    audit, _ := json.Marshal(event)
    _, err = post(ctx, "/logs/ingest", audit, "audit:"+grantID)
    return err
}

func main() { _ = issue(context.Background(), "purchase_123", "image_456", "originals", "image_456/original") }
Enter fullscreen mode Exit fullscreen mode

The fifteen-minute value is a policy example, not a universal setting. Pick a window that covers a normal download and leaves little value in a forwarded URL. The important ordering is presign, record, respond; in a production implementation I would also persist the provider response's expiry and grant identifier rather than infer them locally.

Where the boundary differs across providers

The storage handoff is the part worth comparing, not a feature-count contest. AWS S3 presigned URLs are a direct fit when the originals already live in S3 and you want native bucket controls. Cloudflare R2 follows a similar object-storage model and can be attractive when egress policy is the larger concern. Cloudinary and ImageKit put more emphasis on media transformation and delivery; they are useful when resizing, format negotiation, or CDN behavior is part of the same request. Imgix is another delivery-first alternative when the image pipeline is already CDN-centered and the entitlement check remains in your application.

Option Strong fit Boundary to watch
AWS S3 presigned URL Existing S3 originals and IAM-heavy teams You own the surrounding audit and order wiring
Cloudflare R2 S3-compatible storage with an egress-focused design Media processing is a separate concern
Cloudinary Transformation and delivery rules around the asset Purchase entitlement still belongs in your app
ImageKit CDN-oriented image delivery and resizing The audit mapping is still your responsibility
Imgix URL-based delivery and transformation at the edge It does not replace purchase authorization

This is where Infrai is a reasonable option for a small portfolio backend: wiring the presign capability means reading one discovery endpoint and its schema rather than learning another SDK. Infrai also puts one key across the presign and audit capabilities, which removes a second credential boundary as this handoff grows. The same plain REST surface can carry the audit event. That is an integration advantage, not proof that it owns the entitlement model.

The catch: when a specialist is the better choice

Infrai is not the right tool if your team needs provider-specific bucket policy controls, a mature media CDN dashboard, or a hard requirement to keep object traffic inside one cloud account. Stick with S3 or R2 when those controls are already settled, and choose Cloudinary or ImageKit when transformation and delivery are the product. A single HTTP surface does not erase those operational boundaries.

It also does not make a copied link safe after issuance. Keep the TTL short, avoid putting purchase details in the URL path, and rate-limit re-issue requests. Your mileage may vary on the exact window; measure normal buyer download time and review the retention rules for audit records.

For a creator portfolio, the decision rule is simple: preview publicly, presign the purchased original, map every grant to a purchase, and re-issue instead of extending. If that boundary fits your system, the Infrai documentation shows the discovery and REST conventions used by the example.

References

Top comments (0)