The download page for Notifio has three buttons on it: Apple Silicon, Intel Mac, Windows. Each one points at a route on our own domain:
const MAC_ARM64_URL = `${APP_URL}/api/download/mac-arm64`;
const MAC_X64_URL = `${APP_URL}/api/download/mac-x64`;
const WIN_URL = `${APP_URL}/api/download/win`;
None of those routes serves a file. Each one issues a 302 to a Cloudflare R2 URL that is signed, and that stops working 60 seconds after it was created.
The whole route is about fifty lines, and almost every line is a decision I would defend in a review.
Why the installers are not in the repo and not on the host
Notifio bundles its own Chromium, because the monitoring has to run a real browser and asking users to install one separately is not a product. That makes the installers large, and it is also why macOS gets two separate DMGs instead of a universal build: a universal binary would carry two Chromiums and nobody needs the one for the other architecture.
Large release artefacts do not belong in git, and they do not belong in the deployment bundle of a Next.js app either, where every file you add is a file every deploy has to carry. They live in an R2 bucket, and the bucket is private.
The platform is a lookup, never an interpolation
const FILES: Record<string, string> = {
"mac-arm64": "notifio-arm64.dmg",
"mac-x64": "notifio-x64.dmg",
win: "notifio-setup.exe",
};
const { platform } = await params;
const fileName = FILES[platform];
if (!fileName) {
return NextResponse.json(
{ error: `Unknown platform "${platform}". Use "mac-arm64", "mac-x64", or "win".` },
{ status: 400 }
);
}
/api/download/[platform] is a dynamic segment, which means the value is whatever anybody puts in the URL bar. The object key handed to R2 comes out of a hardcoded map, so there is no string in the universe you can put in that segment that produces an object key we did not write ourselves.
The version I did not write looks like Key: \notifio-${platform}.dmg``, works perfectly for the three real cases, and turns a path segment into partial control of a bucket key. The map is not more defensive code, it is less code with a smaller output space.
The 400 also names the three valid values. This endpoint has no secrets in it, every one of those names is on the public download page, and an error that tells you what to do instead is just better than "Bad Request".
Sixty seconds is the length of a redirect, not the length of a download
`ts
// Presigned URL valid for 60 seconds, enough for the browser to start the
// download without permanently exposing the file.
const url = await getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: fileName }),
{ expiresIn: 60 }
);
return NextResponse.redirect(url, { status: 302 });
`
The thing people get wrong here is assuming the expiry has to cover the download. It does not. The signature is checked when the request is made, so a 200 MB file on a slow connection keeps streaming long after the minute has passed. What the lifetime actually bounds is how long the signed URL is useful to somebody who copies it out of their network tab.
Sixty seconds is roughly "the browser follows the redirect right now, or this is gone". There is no scenario where a longer window helps a real user, and a longer window is a URL that can be pasted into a forum.
That is the other half of why the bucket is private. There is exactly one way to get a build of Notifio, and it goes through a route we control, which means we can rename objects, add an architecture, or move the bucket entirely without invalidating a single link anybody has shared.
A redirect, not a proxy
Returning the bytes through the route would have been easy: fetch from R2, stream the response. It would also mean every megabyte of every download passes through a serverless function with an execution time limit, a memory limit, and a per-invocation cost.
A 302 means the file transfer happens directly between the user's browser and R2's edge. Our function does a signature computation and returns a header.
The trade you accept is that the route cannot tell you whether a download completed. It knows a signed URL was issued and nothing else. That is the correct amount of knowledge for this product, where the thing we actually want to count is activations, and activations are a separate event entirely.
Missing configuration is a 503 with nothing in it
`ts
if (!accountId || !accessKeyId || !secretAccessKey) {
console.error("[download] Missing R2 environment variables");
return NextResponse.json(
{ error: "Download is temporarily unavailable." },
{ status: 503 }
);
}
`
Two things on purpose.
503 rather than 500, because this is not an error in handling the request, it is the service being unable to do its job right now. A visitor cannot fix it and cannot work around it, and "temporarily unavailable" is the honest description of a missing environment variable in a deploy that someone is about to fix.
And the response names nothing. The log line says the category, the response does not say which variable, how many, or that R2 is involved at all. Same reasoning as our health endpoint, which reports how many environment variables are missing and never which ones: confirming the shape of somebody's infrastructure is a free gift to whoever is poking at it, and it does not help anybody who is allowed to know.
The S3 client is also built inside the handler rather than at module scope, after that check. A module-level client constructed from undefined credentials is a module that throws at import time, which takes out the whole route file instead of producing a readable 503.
`ts
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
`
region: "auto" is the R2 convention. It is S3-compatible enough that the AWS v3 SDK and its presigner work unmodified, which is most of why it was chosen.
The rule worth taking away
A download endpoint should be an allowlist plus a redirect. The allowlist means user input never reaches your storage keys. The redirect means your compute never touches the bytes. And a signed URL whose lifetime matches the redirect rather than the transfer gives you a private bucket without a single downside your users can feel.
See it working
Click any of the three buttons on notifio.app/download and watch the network tab: the request to /api/download/mac-arm64 is a 302, and the URL it lands on has a signature and an expiry on it. Then try notifio.app/api/download/linux to see the 400 and the list of platforms we do publish.
What you get after installing is on notifio.app/help, the licence side is on notifio.app/pricing, and the per-portal pages under notifio.app/alerts cover what the app does once it is running.
Top comments (0)