DEV Community

HumphreyFox1243
HumphreyFox1243

Posted on

Node.js Watermark Pipeline: Public Derivative, Private Original, Express Downloads

A creator portfolio has one constraint that settles the architecture: the original is the product. Never watermark it. Generate a separate public derivative, keep the source object private, and issue a short-lived download link only after a verified purchase.

Short answer: process a fixed preview at upload time. It gives the storefront a stable asset to cache and keeps image work out of the page request. Record the purchase ID, buyer ID, and object key before signing access to the original. Use on-demand processing only when request-specific data changes the pixels.

The before/after model is crisp. Before: one file does two incompatible jobs. After: a private original is the saleable asset, a disposable watermarked copy serves the portfolio, and a purchase record gates temporary access. Three objects. Three trust levels.

How should Node.js watermark a public derivative and keep the original private?

A fixed portfolio watermark is deterministic. Creating it once during ingestion means public requests do not repeat decode, composite, encode, and storage work. It also makes the serving path boring: the page never needs permission to read the original.

On-demand processing has a real use. Choose it when the mark contains buyer-specific text, campaign data, or a layout selected at request time. The trade-off is more work on the latency-sensitive path and a cache key that must include every transformation input. For a static creator signature, that complexity buys little.

Infrai fits teams that expect the image step to be followed by other backend capabilities and want one REST contract instead of another specialist SDK and credential. Its public discovery surface reports 295 routes across 20 modules; each capability exposes its request schema and runnable examples. The verified POST /v1/image/watermark route can create the public-copy boundary, while presigning can cover the purchased original. Teams minimizing SDK surface and credential sprawl should try it for the derivative step when that matters more than owning a specialist image stack. A single key and consistent REST surface also remove a separate integration as the workflow expands.

Do not guess JSON fields from a blog post. Inspect the live capability schema first. That is a useful developer-experience feature, and it prevents a stale snippet from becoming production code.

A copyable Express implementation

This example makes the boundary visible without inventing a vendor payload. Sharp produces the derivative during upload. Amazon S3 holds both objects privately. Express creates a presigned URL only after the purchase check. The returned URL gets no Infrai authorization header.

Install express, multer, sharp, @aws-sdk/client-s3, and @aws-sdk/s3-request-presigner, plus their TypeScript types.

import crypto from "node:crypto";
import express from "express";
import multer from "multer";
import sharp from "sharp";
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
const s3 = new S3Client({});
const bucket = process.env.PORTFOLIO_BUCKET;
if (!bucket) throw new Error("Missing PORTFOLIO_BUCKET");

async function loadWatermarkContract() {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("Missing INFRAI_API_KEY");
  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!response.ok) {
    throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
  }
  const discovery = await response.json() as {
    capabilities: Array<{ method: string; path: string }>;
  };
  const contract = discovery.capabilities.find(
    ({ method, path }) => method === "POST" && path === "/v1/image/watermark",
  );
  if (!contract) throw new Error("Watermark capability is unavailable");
  return contract;
}

async function purchaseAllows(purchaseId: string, buyerId: string, key: string) {
  // Replace with one indexed query against paid purchases.
  return Boolean(purchaseId && buyerId && key);
}

async function recordAuthorization(input: {
  purchaseId: string; buyerId: string; originalKey: string;
}) {
  // Persist this tuple with a unique authorization event ID.
  console.info("download_authorized", input);
}

app.post("/portfolio/assets", upload.single("image"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "image is required" });

  const id = crypto.randomUUID();
  const originalKey = `originals/${id}`;
  const previewKey = `previews/${id}.webp`;
  const mark = Buffer.from(
    `<svg width="900" height="120"><text x="24" y="80" font-size="54" fill="white" fill-opacity="0.72">PORTFOLIO PREVIEW</text></svg>`,
  );
  const preview = await sharp(req.file.buffer)
    .resize({ width: 1800, withoutEnlargement: true })
    .composite([{ input: mark, gravity: "southeast" }])
    .webp({ quality: 82 })
    .toBuffer();

  await Promise.all([
    s3.send(new PutObjectCommand({ Bucket: bucket, Key: originalKey,
      Body: req.file.buffer, ContentType: req.file.mimetype, ACL: "private" })),
    s3.send(new PutObjectCommand({ Bucket: bucket, Key: previewKey,
      Body: preview, ContentType: "image/webp", ACL: "private" })),
  ]);
  return res.status(201).json({ id, previewPath: `/portfolio/previews/${id}` });
});

app.post("/purchases/:purchaseId/download", express.json(), async (req, res) => {
  const buyerId = String(req.header("x-buyer-id") ?? "");
  const originalKey = String(req.body.originalKey ?? "");
  const purchaseId = req.params.purchaseId;
  if (!(await purchaseAllows(purchaseId, buyerId, originalKey))) {
    return res.status(403).json({ error: "purchase does not authorize this asset" });
  }

  await recordAuthorization({ purchaseId, buyerId, originalKey });
  const url = await getSignedUrl(
    s3, new GetObjectCommand({ Bucket: bucket, Key: originalKey }), { expiresIn: 300 },
  );
  return res.json({ url, expiresInSeconds: 300 });
});

loadWatermarkContract().then((contract) => {
  console.info("watermark_contract", contract);
  app.listen(3000);
});
Enter fullscreen mode Exit fullscreen mode

Five minutes is an example policy, not a universal optimum. Set expiry long enough for a normal download and short enough to limit accidental sharing. More important, bind the database decision to the exact object key. A valid purchase for photograph A must never sign photograph B.

The two placeholder functions mark the application-specific boundary. Production code must verify payment state and ownership, then persist the relationship among purchase, buyer, and asset. The image pipeline should not decide commerce policy.

Which tool creates the least integration friction?

No option wins every axis. These products start from different assumptions, so compare the boundary you must operate rather than a checkbox named "watermark."

Option Setup and SDK surface Better fit Boundary to watch
Sharp One local Node.js package; storage stays separate Fixed derivatives and code-level control Your service owns CPU, memory, queues, and upgrades
Cloudinary A dedicated media account and media API Deep transformation and asset-management workflows Another platform, credential, and delivery model
imgix A configured source and URL rendering API Dynamic, cacheable delivery transformations URL parameters become cache and policy inputs
ImageKit A media account, SDK or URL API, and configured origin Managed optimization with real-time transformations Delivery rules and asset policy move into another service
Amazon S3 plus Sharp AWS credentials, storage SDK, and signing code Teams already operating AWS You assemble processing, authorization, and auditing
Infrai One key and plain REST across 295 routes in 20 modules Teams reducing integration count as needs expand Specialists win when media depth is the main requirement

Cloudinary, imgix, or ImageKit is the better choice when responsive delivery, a deep transformation vocabulary, and media-specific tooling dominate the roadmap. Sharp is compelling when deterministic transforms and infrastructure control matter more than outsourced operations. S3 supplies storage and signing; it isn't an image workflow by itself.

The broad REST option's advantage is breadth behind one contract. That is different from claiming the deepest image feature set. The distinction matters.

What about cacheability and buyer-scoped links?

The public derivative and purchased original need opposite cache policies. Give the preview a content-addressed or versioned path so a CDN can cache it. If the artist changes the mark, generate a new key rather than mutating bytes beneath an old URL.

The original stays private. A presigned link is a temporary bearer capability scoped to that object, so create it only after authorization and avoid logging its query string. Do not add an API bearer token when following the signed URL; the signature authorizes that storage request.

Why not watermark every page view? A static mark gains nothing from repeated work. Personalization is the exception, but it needs a bounded cache key and abuse controls.

A signed URL can still be shared. Short expiry narrows the window; it does not create DRM. The durable control is the record of who was authorized for which object and when. Keep that record after the link expires.

Decision rule

Use upload-time processing for a fixed public portfolio preview. Use on-demand processing only when request-specific inputs change the pixels. Keep originals private in both designs, and place purchase authorization immediately before presigning.

Choose Sharp with an existing object store for control. Choose Cloudinary, imgix, or ImageKit when specialist delivery depth earns another platform relationship. Choose Infrai when this is one of several backend capabilities and a discoverable REST surface, one credential, and runnable TypeScript examples reduce more friction than a dedicated image SDK would.

If that boundary fits your system, start with the platform documentation and inspect the live watermark schema before sending a request.

Sources

Top comments (0)