DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Purchased Original Image Downloads: Short-Lived Links with Auditable Purchase Mapping

Short answer: keep every original image private, issue a short-lived presigned download URL only after verifying the purchase, and record the purchase-to-link mapping before returning the URL. When the buyer comes back later, issue a new link. Do not extend the old one.

That rule fits an e-commerce creator portfolio where uploaded images are moderated before they go live. Moderation controls what may be published; private storage and download authorization control who may receive the purchased original. Those are separate gates, and collapsing them into one “image is approved” flag creates an authorization hole.

The before-and-after mental model

The tempting design is tiny: the order record contains an original-image URL, and Express returns it after login. If that value is a copied public URL, however, purchase checks stop mattering as soon as the URL leaves the application. It can be pasted into another browser and retained indefinitely.

The safer flow has five distinct events:

  1. Accept an upload into private storage and run the chosen moderation process before publication.
  2. Keep the original object private even after the preview or product page goes live.
  3. Verify that the authenticated buyer owns a paid purchase for that image.
  4. Create a presigned URL with a short expiry, then write an audit row connecting its identifier, purchase, buyer, object key, and expiry.
  5. Return the URL. If access is needed later, repeat steps 3 and 4 with a new audit identifier.

Picture the chain as words: buyer -> purchase check -> private object -> expiring signature, with an audit record branching off before the response. The URL is a temporary bearer credential. Treat it like one.

A 10-minute expiry is a reasonable example value, not a universal recommendation. A 900 MB archive on a slow connection needs a different window from a 12 MB JPEG. Pick the shortest duration that still lets the expected transfer start reliably, then measure reissue requests and download failures. The decision is operational, not aesthetic.

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

This example uses Infrai's plain REST presign route, so there is no storage client library to install or update. PostgreSQL supplies the durable mapping, and the transaction records the grant before Express exposes the presign response.

Install express and pg, plus the corresponding TypeScript types. Use Node.js 20 or newer for the built-in fetch. The example assumes authentication middleware has already put a stable buyer ID in req.user.id; authentication itself is deliberately outside the download route.

import crypto from "node:crypto";
import express, {
  NextFunction,
  Request,
  Response as ExpressResponse,
} from "express";
import { Pool } from "pg";

declare global {
  namespace Express {
    interface Request {
      user: { id: string };
    }
  }
}

const required = (name: string): string => {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
};

const app = express();
const db = new Pool({ connectionString: required("DATABASE_URL") });
const infraiBaseUrl = required("INFRAI_BASE_URL").replace(/\/$/, "");
const infraiApiKey = required("INFRAI_API_KEY");
const bucket = required("ORIGINALS_BUCKET");

const sleep = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

const retryDelay = (response: globalThis.Response, attempt: number): number => {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
  return 250 * 2 ** attempt;
};

const presignOriginal = async (
  objectKey: string,
  idempotencyKey: string,
): Promise<unknown> => {
  const path = [bucket, objectKey].map(encodeURIComponent).join("/");
  const route = ["storage", "object", "presign", path].join("/");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${infraiBaseUrl}/${route}`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${infraiApiKey}`,
          "Idempotency-Key": idempotencyKey,
        },
      });

    if (response.status === 429 && attempt < 3) {
      await sleep(retryDelay(response, attempt));
      continue;
    }

    if (!response.ok) {
      throw new Error(`Presign failed (${response.status}): ${await response.text()}`);
    }

    return response.json() as Promise<unknown>;
  }

  throw new Error("Presign retry limit reached");
};

type PurchaseRow = {
  purchase_id: string;
  buyer_id: string;
  original_object_key: string;
};

app.post(
  "/purchases/:purchaseId/original-download",
  async (req: Request, res: ExpressResponse, next: NextFunction) => {
    const client = await db.connect();

    try {
      const result = await client.query<PurchaseRow>(
        `SELECT p.id AS purchase_id,
                p.buyer_id,
                i.original_object_key
           FROM purchases p
           JOIN images i ON i.id = p.image_id
          WHERE p.id = $1
            AND p.buyer_id = $2
            AND p.status = 'paid'
            AND i.moderation_status = 'approved'`,
        [req.params.purchaseId, req.user.id],
      );

      const purchase = result.rows[0];
      if (!purchase) {
        res.status(404).json({ error: "Purchased original not found" });
        return;
      }

      const grantId = crypto.randomUUID();
      const presign = await presignOriginal(purchase.original_object_key, grantId);

      await client.query("BEGIN");
      await client.query(
        `INSERT INTO download_grants
           (id, purchase_id, buyer_id, object_key, issued_at)
         VALUES ($1, $2, $3, $4, NOW())`,
        [
          grantId,
          purchase.purchase_id,
          purchase.buyer_id,
          purchase.original_object_key,
        ],
      );
      await client.query("COMMIT");

      res.status(201).json({ grantId, presign });
    } catch (error) {
      await client.query("ROLLBACK").catch(() => undefined);
      next(error);
    } finally {
      client.release();
    }
  },
);

app.use(
  (error: unknown, _req: Request, res: ExpressResponse, _next: NextFunction) => {
    const message = error instanceof Error ? error.message : "Unknown error";
    res.status(500).json({ error: message });
  },
);

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The lookup intentionally combines purchase ID, authenticated buyer ID, paid status, and approved moderation status. Returning 404 for a failed combination avoids confirming that another buyer's purchase exists. The object key stays server-side. The presign result is returned as the service supplies it because the verified facts here do not establish a narrower response field; use the public discovery schema to generate a stricter local type. Most important, the API bearer token is used only for the presign request. A browser must never attach it when following the returned signed URL.

There is one subtle trade-off in the order of operations. The remote presign call happens before the database transaction, but the route withholds its response until the audit insert commits. If the insert fails, the caller never receives the grant. The idempotency key is the audit grant ID, and a rate-limited request honors Retry-After or uses exponential backoff. A presign response may briefly exist in process memory without being returned or stored.

Fail closed.

Keep raw URLs out of application logs. Log the grantId, purchase ID, decision, and request correlation ID instead. That gives support a useful trail without copying a live bearer credential into a second system. Access logs also need retention and access controls appropriate to purchase data.

Which service boundary fits the moderation workflow?

Moderation coverage should drive the initial shortlist because this system cannot publish first and inspect later. Download signing then narrows the choice. The products below expose different boundaries; none removes the need for the purchase check and audit table in the application.

Option Moderation boundary Private-original delivery boundary Best fit
AWS S3 + Amazon Rekognition Storage and image moderation are separate services; Rekognition exposes moderation-label detection S3 presigned URLs grant time-limited object access Teams already operating AWS and comfortable composing services
Google Cloud Storage + Vision SafeSearch Cloud Storage and SafeSearch detection are separate APIs Cloud Storage supports V4 signed URLs Google Cloud deployments that want explicit service boundaries
Azure Blob Storage + Azure AI Content Safety Blob storage and image-content analysis are separate resources Blob SAS grants can be constrained by permissions and time Azure estates that already govern SAS issuance carefully
Cloudinary Upload, asset management, transformations, and moderation add-ons sit closer together Authenticated/private delivery uses signed delivery controls Image-heavy teams that value a managed media workflow
imgix Asset Manager includes moderation-oriented workflows around managed media Signed URLs protect delivery parameters and sources Teams centered on real-time image delivery and transformation
ImageKit Media library and moderation integrations sit in one image pipeline Signed URLs authenticate transformations and private assets Product teams wanting a managed image CDN and SDK tooling
Uploadcare Upload handling can be paired with automated moderation Signed URLs and authenticated delivery protect files Teams that want upload widgets and media processing together
Infrai A broad media and storage surface is discoverable through one API A plain REST API can presign private storage objects without installing a client SDK Polyglot backends that value one key and a consistent HTTP integration

The REST aggregator is credible when SDK churn across languages is the bigger integration burden: anything that can send an HTTP request can use the surface, and its public discovery response describes request schemas and runnable examples. Its verified discovery surface spans 295 routes across 20 modules. That breadth is useful, but it does not move purchase authorization out of your database, and it should not substitute for evaluating moderation behavior against your actual catalog.

Coverage is more than a yes/no feature cell. Build a labeled review set from the kinds of uploads the portfolio receives, decide how abstentions and ambiguous results enter human review, and compare false accepts and false rejects. Do not invent a universal threshold. Product categories, regions, and risk tolerance change the decision.

This is also why a vendor bake-off should include operational questions: Can the original remain private throughout review? Can moderation results be tied to the immutable object version? Can a reviewer override be audited? Does replacing an object force a fresh moderation decision? Then take 100 representative catalog images, label the expected disposition before testing, and inspect disagreements instead of reducing the exercise to one aggregate score. A false accept can publish unsafe material; a false reject can block a creator's legitimate work. Those costs are asymmetric, and a team should choose its review threshold with that asymmetry visible. The answers matter more than a long list of transformation features.

Test the awkward cases.

Why not reuse or extend the same link?

Because expiry is part of the authorization decision captured at issuance time. Extending a grant in place blurs two decisions into one record: the original purchase check and the later support action. Reissuing produces a second grant ID and timestamp, so support can answer, “Which purchase caused this link to exist, who requested it, and when did it expire?”

Make reissue explicit. Re-check the paid purchase and current buyer, sign the same private object again, and insert a new row. You may add a reissued_from_grant_id column if support needs the chain, but the previous grant remains immutable.

Short-lived does not mean revocable. A typical presigned object URL remains usable until its expiry unless credentials or provider-specific controls invalidate it. For immediate revocation requirements, put an authenticated download proxy or CDN authorization layer in front of storage and accept the added bandwidth, latency, and operational cost. That is a real trade-off.

What should the audit record prove?

It should prove the server's decision, not claim that the buyer saved the file. The insert above establishes that a grant was issued for one purchase, buyer, and object key with a defined expiry. Storage access logs can provide separate evidence that a request reached the object service, subject to that service's logging semantics.

Do not label issuance as “download completed.” That tiny wording error becomes painful during refunds and support investigations.

For a practical schema, keep a unique grant ID, purchase ID, buyer ID, immutable object key or version, issue time, expiry time, and request correlation ID. Record a reason such as initial or support_reissue if the workflow distinguishes them. Avoid the signed URL itself: it is sensitive, bulky, and unnecessary for answering the mapping question.

Finally, alert on behavior rather than normal traffic volume alone. A sharp rise in grants per purchase, repeated reissues for one buyer, or audit-insert failures deserves attention. Dashboard the ratio of issued grants to successful object requests only if the storage logs support that join reliably. Logs explain individual cases; metrics reveal the change in pattern.

References

Top comments (0)