Express, presigned URLs and user documents: uploads vs downloads

The upload leg and the download leg have different browser rules. A Node 22 Express design for PDF and DOCX that works today, multipart included.

Split the problem in two, because the browser treats the halves differently. Handing a user a signed download URL works from any page today — it’s a navigation, so no CORS rule is involved. Handing the browser a signed upload URL requires the bucket to answer a preflight, and on Infrai that’s the one piece you can’t configure yet, so the upload leg goes through a thin Express relay while the download leg goes straight from the browser to the bucket.

That asymmetry decides the architecture, so this page is organised around it rather than around a happy-path tutorial. Infrai signs both kinds of URL for free through one route, POST /v1/storage/object/presign/{bucket}/{key}, and the rest is Node.

Why the two legs differ

A cross-origin PUT from a page is never a “simple” request, so the browser fires an OPTIONS preflight first and refuses to send bytes unless the bucket replies with matching CORS headers. The Infrai API exposes a bucket’s cors_rules on GET /v1/storage/bucket/get/{bucket} but has no route to write them, and a fresh bucket comes back with an empty list. So: fetch(signedUrl, {method:"PUT"}) from a tab fails, while the identical URL works from curl, a mobile app, or your own server.

Downloads are different. When a user clicks a link and the browser navigates, there’s no preflight and no origin check — the file arrives, and Infrai’s signed GET responses carry Content-Disposition: attachment, so the browser saves rather than renders.

One consequence worth stating plainly: if a proxy-less browser upload is a hard requirement, you’d be better off on Amazon S3, Cloudflare R2 or a self-hosted MinIO, where CORS is yours to edit. Everything below assumes you’re fine relaying the upload.

A relay that doesn’t buffer

The naive Express handler reads the whole file into memory (or worse, /tmp) and then uploads it. A 60 MB DOCX times four concurrent users is how a 512 MB container dies. Pipe the request straight into the signed URL instead:

import express from "express";

const app = express();
const API = "https://api.infrai.cc";
const BUCKET = "user-documents";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");

const ALLOWED = new Set([
  "application/pdf",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]);

app.put("/documents/:filename", async (req, res) => {
  const contentType = req.headers["content-type"] ?? "";
  if (!ALLOWED.has(contentType)) return res.status(415).json({ error: "pdf or docx only" });

  const userId = req.header("x-user-id");
  if (!userId) return res.status(401).json({ error: "unauthenticated" });

  const key = `u/${userId}/${new Date().toISOString().slice(0, 7)}/${req.params.filename}`;
  try {
    const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ op: "put", expires_seconds: 900, content_type: contentType, max_bytes: 104_857_600 }),
    });
    const slot = await slotRes.json();
    if (!slotRes.ok || slot.ok === false) throw new Error(slot?.error?.code ?? `HTTP ${slotRes.status}`);

    const upstream = await fetch(slot.data.url, {
      method: slot.data.method,
      headers: slot.data.headers ?? {},
      body: req,
      duplex: "half",
    });
    if (!upstream.ok) throw new Error(`vendor rejected the upload: HTTP ${upstream.status}`);

    res.status(201).json({ key, etag: upstream.headers.get("etag") });
  } catch (err) {
    res.status(502).json({ error: String(err.message ?? err) });
  }
});

app.listen(3000);

body: req with duplex: "half" is the whole trick — bytes cross your process without ever landing in a buffer you own. Memory stays flat whether the document is 200 KB or 200 MB.

The signing call itself, if you want to see it on its own:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/user-documents/u/usr_2291/2026-07/handbook.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":900,"content_type":"application/pdf","max_bytes":104857600}'

max_bytes is enforced by the signature, not by your handler, which means a client that lies about Content-Length still can’t overrun it.

Files big enough to need parts

Above a couple of gigabytes a single PUT is a bad bet — one dropped connection and the whole transfer restarts. Multipart splits it:

curl -sS -X POST https://api.infrai.cc/v1/storage/multipart/create/user-documents \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key":"u/usr_2291/2026-07/handbook.pdf","content_type":"application/pdf"}'
{
  "ok": true,
  "data": {
    "upload_id": "1785025689c35f92ffac286bf934062c0d9953b58dd188566de97fd6",
    "bucket_id": "bkt_5b3aa9688b214ae79b4c18",
    "key": "u/usr_2291/2026-07/handbook.pdf",
    "started_at": "2026-07-26T00:28:09.237830Z",
    "part_size_min": 5242880,
    "part_count_max": 10000
  }
}

part_size_min is 5 MiB and every part except the last must reach it — a 4 MB part in the middle is rejected at completion time, not at upload time, which is a nasty way to find out. Sign one URL per part, upload the chunk, keep the ETag:

import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";

const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const PART = 8 * 1024 * 1024;

export async function uploadInParts(uploadId, filePath) {
  const { size } = await stat(filePath);
  const parts = [];

  for (let n = 1, offset = 0; offset < size; n++, offset += PART) {
    const end = Math.min(offset + PART, size) - 1;
    const res = await fetch(`${API}/v1/storage/multipart/presign_part/${uploadId}/${n}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    const slot = await res.json();
    if (!res.ok || slot.ok === false) throw new Error(`presign_part ${n}: ${slot?.error?.code ?? res.status}`);

    const put = await fetch(slot.data.url, {
      method: slot.data.method,
      body: createReadStream(filePath, { start: offset, end }),
      duplex: "half",
    });
    if (!put.ok) throw new Error(`part ${n} failed: HTTP ${put.status}`);
    parts.push({ part_number: n, etag: (put.headers.get("etag") ?? "").replaceAll('"', "") });
  }

  const done = await fetch(`${API}/v1/storage/multipart/complete/${uploadId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ parts }),
  });
  const result = await done.json();
  if (!done.ok || result.ok === false) throw new Error(`complete: ${result?.error?.code ?? done.status}`);
  return result.data;
}

Strip the quotes off each ETag before you send it back — the vendor returns "8eee9ac5…" with them, and completion wants the bare hex. If the user closes the tab, call DELETE /v1/storage/multipart/abort/{upload_id}; abandoned parts otherwise sit there billed as storage.

The download leg, which is the easy half

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/user-documents/u/usr_2291/2026-07/handbook.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}'

Return the url from your own API and let the page navigate to it. Five minutes is usually plenty; longer links end up pasted into chat threads. Confirm the object first with the free head call:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/user-documents/u/usr_2291/2026-07/handbook.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

How the approaches compare

ApproachWorks in a browser todayServer memoryBest for
Browser → Infrai bucket, directNo — preflight has no CORS rule to matchNoneNothing yet; revisit when a CORS setter ships
Browser → Express relay → bucketYesFlat, if you streamThe default for documents up to a few hundred MB
Server-side multipartYes (server does the work)One part at a timeMulti-GB files, resumable jobs
Browser → S3/R2 directYesNoneTeams whose only requirement is proxy-less upload

What it costs

Signing, head, list and multipart setup are free and rate-limited. You pay per stored write and per server-side read: verified 26 July 2026, storage.object.put is $0.0001 per call, each storage.multipart.upload_part is $0.0001 and the completion is $0.0002, with bytes and egress metered separately. A 1 GB document in 8 MB parts is 128 part calls — about $0.013 in call fees.

curl -sS https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Prices here move down rather than up, so read the live figure before you build a spreadsheet on it. The argument that doesn’t move: the key that signed this upload also runs the queue that converts the DOCX, the cron that expires it and the error tracker that catches the failed part — one account, one invoice.

Limits worth knowing

Rejected content types come back as STORAGE_CONTENT_TYPE_BLOCKED, which is easy to misread as a signature problem. There’s no server-side virus scan or DOCX-to-PDF conversion in the storage layer — that’s your job or another capability’s. And the relay design costs you bandwidth on your own host, which is the price of the CORS gap; if that bandwidth is the dominant cost in your app, the specialist buckets win on that axis alone.

References

Browse more storage developer guides