Shipping a 1 GB zip export: worker upload and a download link
Build the archive on disk, push it in bounded parts, store the key — then mint the signed link at click time behind your own auth. Node worker included.
An “export my data” button has two halves that people tend to design as one. The worker half builds an archive that might be 40 MB or might be 1.4 GB and pushes it into a bucket; the delivery half hands the user a URL that works for a few minutes and then doesn’t. On Infrai both halves are plain REST — a multipart session for the upload, POST /v1/storage/object/presign/{bucket}/{key} for the link — and the design decision that matters is keeping them apart.
Store the key in your jobs table. Mint the URL when someone clicks, never when the job finishes.
Why not just email the signed URL
| Approach | Link stays valid | Who checks the user is allowed | Failure mode |
|---|---|---|---|
| Signed URL emailed directly | Up to 7 days, then dead | Nobody | Forwarded mail is a working download |
Your /exports/:id/download route, redirecting to a fresh 5-minute URL | Forever, per session | Your session middleware | None worth mentioning |
| Public object, plain URL | Forever | Nobody | Search engines, eventually |
The middle row is barely more code, and it’s the only one where “revoke this user’s access” means anything. The signature is an expiry mechanism and a way to hand bytes to a browser — it is not an access control system, and the maximum TTL these buckets accept is 604800 seconds, so a “permanent” download link isn’t on the menu anyway.
The worker
Two things matter for a 1 GB archive: never hold it in memory, and never leave a half-finished upload behind. This worker writes the zip to a temp file with archiver, then walks the file with a fixed 16 MiB buffer, signing and pushing one part at a time. Part mechanics and abort semantics are covered in more depth in our multipart upload walkthrough.
import { createWriteStream } from "node:fs";
import { open, stat, unlink } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import archiver from "archiver";
const API = "https://api.infrai.cc";
const BUCKET = "kb-zip-export-0726";
const CHUNK = 16 * 1024 * 1024;
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
async function buildArchive(tmpPath, rows) {
const zip = archiver("zip", { zlib: { level: 6 } });
const sink = createWriteStream(tmpPath);
zip.pipe(sink);
for (const row of rows) zip.append(JSON.stringify(row), { name: `records/${row.id}.json` });
await zip.finalize();
await new Promise((done, fail) => sink.on("close", done).on("error", fail));
}
export async function runExport(tenantId, rows) {
const tmpPath = `/tmp/export-${tenantId}.zip`;
await buildArchive(tmpPath, rows);
const day = new Date().toISOString().slice(0, 10);
const key = `exports/${tenantId}/${day}/export-${randomBytes(3).toString("hex")}.zip`;
const opened = await fetch(`${API}/v1/storage/multipart/create/${BUCKET}`, {
method: "POST",
headers,
body: JSON.stringify({ key, content_type: "application/zip" }),
});
const session = (await opened.json()).data;
const file = await open(tmpPath, "r");
const parts = [];
try {
const { size } = await stat(tmpPath);
const buffer = Buffer.allocUnsafe(CHUNK);
for (let n = 1, offset = 0; offset < size; n++, offset += CHUNK) {
const { bytesRead } = await file.read(buffer, 0, CHUNK, offset);
const signed = await fetch(
`${API}/v1/storage/multipart/presign_part/${session.upload_id}/${n}`,
{ method: "POST", headers, body: "{}" },
);
const slot = (await signed.json()).data;
const sent = await fetch(slot.url, { method: "PUT", body: buffer.subarray(0, bytesRead) });
if (!sent.ok) throw new Error(`part ${n} -> HTTP ${sent.status}`);
parts.push({ part_number: n, etag: (sent.headers.get("etag") ?? "").replaceAll('"', "") });
}
const finished = await fetch(`${API}/v1/storage/multipart/complete/${session.upload_id}`, {
method: "POST",
headers,
body: JSON.stringify({ parts }),
});
const result = await finished.json();
if (result.ok === false) throw new Error(result.error.code);
return { key: result.data.key, size_bytes: result.data.size_bytes };
} catch (err) {
await fetch(`${API}/v1/storage/multipart/abort/${session.upload_id}`, { method: "DELETE", headers });
throw err;
} finally {
await file.close();
await unlink(tmpPath);
}
}
Note the random suffix on the key. Since anyone holding the object’s URL can read it, a guessable path like exports/tenant_88/2026-07-20.zip is a weaker secret than it looks — three random bytes cost nothing and take enumeration off the table. The OWASP upload guidance makes the same argument about never deriving storage paths from user-controlled input.
Minting the link at click time
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-zip-export-0726/exports/tenant_88/2026-07-20/export-a3f9c2.zip" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}'
{
"ok": true,
"data": {
"url": "https://<vendor-host>/<bucket>/exports/tenant_88/2026-07-20/export-a3f9c2.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=...",
"expires_at": "2026-07-26T01:14:37.834178Z"
}
}
Wire that behind a route that does the authorisation your bucket can’t:
import express from "express";
import { requireSession } from "./auth.mjs";
import { findExport } from "./exports-repo.mjs";
const API = "https://api.infrai.cc";
const BUCKET = "kb-zip-export-0726";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const app = express();
app.get("/exports/:id/download", requireSession, async (req, res) => {
const record = await findExport(req.params.id);
if (!record || record.tenant_id !== req.user.tenant_id) return res.sendStatus(404);
const signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${record.key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "get", expires_seconds: 300 }),
});
const json = await signed.json();
if (!signed.ok || json.ok === false) return res.status(502).json({ error: "could not sign" });
res.redirect(302, json.data.url);
});
app.listen(3000);
What expiry looks like from the client side
Five minutes later, the same URL answers with the vendor’s XML rather than your zip:
<?xml version='1.0' encoding='utf-8' ?>
<Error>
<Code>AccessDenied</Code>
<Message>Request has expired</Message>
<ServerTime>2026-07-26T01:09:44Z</ServerTime>
</Error>
Tampering with the signature gets the same 403. A download that starts before expiry keeps going, since the check happens once at request time — useful, because a 1 GB transfer on a hotel connection will outlive a 300-second window. A TTL outside 1..604800 fails at presign time with STORAGE_INVALID_TTL.
Here’s the part to be blunt about: strip the query string off that URL and the object still answers 200. These buckets serve objects without requiring a signature, so the signature buys expiry and convenience, not secrecy. If your exports contain data that must be unreadable to anyone without credentials, that’s a genuine limitation, and S3 with a bucket policy denying unsigned reads — or CloudFront signed URLs in front of it — is the stronger arrangement.
Don’t keep exports forever
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-zip-export-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"exports/","expire_days":7}]}'
Seven days matches the maximum link lifetime, and it means a forgotten 1.4 GB archive stops billing on its own.
One more sizing note, since exports are the one storage workload where egress dominates: if thousands of users pull multi-gigabyte archives every month, Cloudflare R2’s zero-egress pricing will beat almost any per-GB transfer rate, and it’s worth running the numbers before you commit.
Cost of one export
Verified 26 July 2026: opening the session, signing parts and presigning downloads are free; the commit bills $0.0002, and so does a read through object/get — which a signed download bypasses entirely. New accounts carry $2 in credit. Read the live numbers:
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')]"
Rates drift downward over time, so that reading beats this paragraph. The durable part is that the cron which schedules the export, the queue that runs it, the bucket that holds it and the email telling the user it’s ready are all one account and one bill — no second vendor to onboard because the feature grew a delivery step.