DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

How to Validate Classified Ad Photos Through 4 Safe Lifecycle Boundaries

For classified photos, a safe upload is a lifecycle boundary, not a single endpoint. Treat every upload as untrusted bytes, quarantine it, validate a derived copy, and make each later state transition explicit. The result is a smaller cache, clearer deletion behavior, and fewer surprises for a healthtech marketplace that may retain sensitive images.

Short answer: accept bytes into quarantine, identify the actual media format, enforce pixel and size limits, strip metadata during derivation, and publish only after an independent lifecycle check says the asset is ready.

Think of the pipeline as a one-way hallway:

upload -> quarantine -> validated derivative -> searchable cache -> expiry/deletion

No search index should be able to reach into quarantine. That single boundary prevents a half-uploaded object from becoming a result.

How should classified ad photos move through a safe upload and lifecycle validation boundary?

Start with an upload record, not a URL. Store an opaque asset ID, the seller's ad ID, byte count, detected format, dimensions, validation decision, and an expiry timestamp. A client-provided filename is display text; it is not an identity and should never become a storage path.

The validator needs two views of the file. First, inspect the declared content type so the client gets a fast error. Then inspect the bytes (or a trusted media parser) and compare the detected format with the declaration. MDN's media-format guide is a useful reference for browser support and format terminology; it also makes clear why a file extension alone is not a format test.

Here is a deliberately small boundary. The parser is an injected dependency, so the policy can be tested without trusting a browser header.

type Detected = { format: "jpeg" | "png" | "webp"; width: number; height: number };

type MediaParser = (bytes: Uint8Array) => Promise<Detected>;

type Decision =
  | { state: "accepted"; detected: Detected; cacheKey: string }
  | { state: "rejected"; reason: string };

export async function validatePhoto(
  bytes: Uint8Array,
  declaredType: string,
  assetId: string,
  parse: MediaParser,
): Promise<Decision> {
  const maxBytes = 8 * 1024 * 1024;
  const maxPixels = 24_000_000;
  if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
    return { state: "rejected", reason: "byte_limit" };
  }

  const detected = await parse(bytes);
  const allowed = new Set(["jpeg", "png", "webp"]);
  if (!allowed.has(detected.format)) return { state: "rejected", reason: "format" };
  if (detected.width * detected.height > maxPixels) {
    return { state: "rejected", reason: "pixel_limit" };
  }

  const expected = `image/${detected.format}`;
  if (declaredType !== expected) return { state: "rejected", reason: "type_mismatch" };
  return { state: "accepted", detected, cacheKey: `photo-${assetId}-v1` };
}
Enter fullscreen mode Exit fullscreen mode

The limits are policy examples, not universal truths. Pick them from your largest legitimate listing, decoder memory, and request budget. Log the reason, not the image bytes.

The choice has a real trade-off:

Boundary choice Useful when Cost or risk
Synchronous validation The UI needs an immediate accept/reject result The request holds a worker while bytes are inspected
Asynchronous validation Upload traffic is bursty or files are large The UI needs a pending state and replayable jobs
Strict format allowlist Decoder exposure and cache variants must stay small Some older-device formats need a conversion step

Serving the original upload to every thumbnail request is an easy way to multiply storage and CPU costs. After validation, create a bounded derivative for listing cards and a second size for the detail view. Normalize orientation, remove location metadata, and record the transform version. A versioned internal key lets you replace a transform without silently mixing old and new pixels.

Cache headers should reflect the lifecycle. An immutable derivative can carry a long freshness window because its key changes when the transform changes. The listing record still needs a short, authoritative check: is this ad active, withdrawn, or expired? A fresh image with a stale ad is still a wrong search result.

This is where teams often overspend. They tune a CDN before measuring hit rate, then discover that every edit creates a new key and leaves abandoned derivatives behind. Track bytes written, bytes read, cache hit ratio, validation rejects, and deletion lag. A five-minute dashboard review can reveal more than a week of storage guesses.

A lifecycle check that can be replayed

Keep state transitions boring and idempotent. quarantine may be retried; published should be reached once; deleted must be terminal. A sweeper can replay the same decision after a worker restart.

type PhotoState = "quarantine" | "validated" | "published" | "expired" | "deleted";

type PhotoRecord = {
  state: PhotoState;
  expiresAt: number;
  derivativeKey?: string;
};

export function lifecycleDecision(record: PhotoRecord, now = Date.now()): PhotoState {
  if (record.state === "deleted") return "deleted";
  if (record.expiresAt <= now && record.state !== "quarantine") return "expired";
  if (record.state === "validated" && record.derivativeKey) return "published";
  return record.state;
}
Enter fullscreen mode Exit fullscreen mode

The worker that applies expired should remove the search document and mark derivatives for deletion. Keep a tombstone for the asset ID so a delayed upload event cannot resurrect it. Your mileage may vary on retention windows; legal retention requirements should settle that question before engineering picks a number.

What should you trade off when choosing the validation boundary?

There is no single “secure upload” switch. A synchronous validator gives immediate feedback but holds the request open. An asynchronous queue keeps the upload path responsive but requires a pending state in the UI and a retry policy. A strict format allowlist reduces decoder exposure, while a broad list helps sellers with older phones and increases test surface.

The catch is operational: this approach is not suitable when users need an image to appear before inspection completes. In that case, show a private, clearly labeled preview and keep it out of public search until validation finishes. Stick with a simpler direct upload only for low-risk, non-searchable media where the owner accepts the exposure; classified health-related listings should keep the boundary.

I initially treated cache eviction as a storage task. It isn't. It is a correctness task tied to ad status, deletion requests, and reprocessing. Once those events share one state machine, cost alerts and privacy checks can use the same audit trail.

References

Top comments (0)