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, once minted | Forwarded mail is a working download until it expires |
Your /exports/:id/download route, redirecting to a fresh 5-minute URL | Forever, per session | Your session middleware | None worth mentioning |
| A permanently public object | — | — | Not on the menu: set_acl accepts private and signed-only and answers 400 STORAGE_ACL_INVALID to public-read |
The middle row is barely more code, and it’s the only one where “revoke this user’s access” means anything. The signature is the access boundary here — an unsigned request for the same path gets a 403, not the bytes — but the signed URL is still a bearer token: whoever holds it before expires_at is authorised, and the object can’t tell a customer apart from a forwarded mailing list. Ceiling on the TTL is 604800 seconds, so a “permanent” download link isn’t available even if you wanted one.
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. A guessable path like exports/tenant_88/2026-07-20.zip isn’t readable by a stranger — the signature check stops that — but it is guessable by anyone who can reach your download route, and a key derived from a tenant id plus a date is exactly the shape an IDOR probe walks. 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.
Worth testing yourself rather than believing a paragraph: strip the query string off that URL and request the bare object path. It comes back 403. The signature is what authorises the read, so a link that leaks into a referrer header or a support ticket stops working at expires_at and nothing else about the object is exposed in the meantime.
What that still doesn’t give you is per-recipient revocation. Once a URL is minted it is valid until it expires, and there’s no route to kill an individual one early — a caveat that argues for 300-second TTLs minted at click time rather than hour-long ones mailed out. If you need a link you can revoke on demand, or per-viewer download auditing, CloudFront signed cookies in front of S3 is the arrangement built for it; buy that if legal wants a revocation button, not because you distrust the signature.
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, presigning downloads and aborting are all free and rate-limited. Each upload_part is $0.0001, and the commit is $0.0002.
Pulling bytes back through the API is the one that isn’t a per-call fee at all. GET /v1/storage/object/get/{bucket}/{key} is metered at $0.104 per GB, which is the number to plan around on an export workload, because a 1.4 GB archive is a 1.4 GB read every time someone clicks. That’s also the strongest argument for the redirect pattern above: a presigned download hands the bytes over from the storage host instead of round-tripping them through your process. New accounts carry $2 in credit. Read the live numbers, and read the unit next to them:
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'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage')]"
A price that moves is a nuisance; a unit that moves rewrites the model, and only the second column tells you which happened.
The durable part is the shape, not the rate. POST /v1/cron/create schedules the export, POST /v1/queue/publish runs it, this bucket holds it and POST /v1/email/send tells the user it’s ready — all on the same key, one account and one bill, with no second vendor to onboard because the feature grew a delivery step.