DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three download buttons, no user agent sniffing, and a link that stops working 60 seconds after you click it

Notifio is a desktop app, so the website's real job is to hand over an installer. Those installers are large: around 600MB for each macOS DMG and 300MB for the Windows setup, because the Chromium the app drives is inside the bundle rather than something the user is asked to install.

Handing over a 600MB file involves more decisions than I expected when I wrote the page. Here is the download page as it stands, and what each part of it is refusing to do.

Three buttons, because the browser cannot tell me the architecture

The page shows two cards. The macOS card has two buttons, Apple Silicon as the primary and Intel Mac as the secondary. The Windows card has one.

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`;
Enter fullscreen mode Exit fullscreen mode

No user agent sniffing, no "we detected your platform" headline, no single button that changes meaning depending on who is looking at it. The reason is specific rather than philosophical: a browser will tell you it is on a Mac, and it will not reliably tell you which Mac.

navigator.platform still reports MacIntel on Apple Silicon, deliberately, for compatibility with a decade of code that checked it. The modern answer is navigator.userAgentData.getHighEntropyValues(["architecture"]), which is a Chromium feature, so on Safari, the browser a Mac user is most likely to be reading this page in, there is no answer at all.

Then look at the cost of being wrong in each direction. An x64 build on Apple Silicon starts, under Rosetta, which means the user gets a working app that translates a bundled browser it did not need to translate. An arm64 build on an Intel Mac does not start at all. Those are not symmetric outcomes, and neither of them is worth guessing about after a 600MB download. Two labelled buttons, one of which says "M1 / M2 / M3", are better than a guess with a 600MB penalty.

It helps that each DMG only carries the Chromium it will actually run, which is what keeps a split like this to two files rather than one universal binary at double the size.

Each button is a route, not a file

Those three hrefs are API routes, not links to the installers. The installers live in a private Cloudflare R2 bucket, and the whole of the route is this:

const FILES: Record<string, string> = {
  "mac-arm64": "notifio-arm64.dmg",
  "mac-x64": "notifio-x64.dmg",
  win: "notifio-setup.exe",
};

export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ platform: string }> }
) {
  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 }
    );
  }
  // ... credentials check ...
  const url = await getSignedUrl(
    client,
    new GetObjectCommand({ Bucket: bucket, Key: fileName }),
    { expiresIn: 60 }
  );

  return NextResponse.redirect(url, { status: 302 });
}
Enter fullscreen mode Exit fullscreen mode

Two things are doing work here.

The first is that FILES is a lookup table rather than string interpolation, so the dynamic segment in the URL never reaches the bucket. There is no key to traverse to and no object in that bucket a stranger can name. Three inputs are valid and everything else is a 400 that says what the three are.

The second is the 302. The function does not stream the file. It signs a URL and gets out of the way, so the 600MB travels from R2 to the user and never through the serverless function, which is both the cheap option and the only one with no size or duration ceiling to worry about.

Sixty seconds is not a security boundary

The signed URL is valid for one minute, and the comment next to it says what that is for:

// Presigned URL valid for 60 seconds — enough for the browser to start the
// download without permanently exposing the file.
Enter fullscreen mode Exit fullscreen mode

Worth being precise about what the expiry does and does not buy, because it is easy to feel like the installer is protected here. The expiry governs when the request may start, not how long it may take, so a download that begins in time is free to run for an hour. And anybody who wants a fresh URL can get one by clicking the button again, as there is no licence check in front of it: the installer is free to download, and the licence is what activates it.

What the expiry actually prevents is a durable hotlink. Without it, the first signed URL anybody generated would be pasteable into a forum post, a tweet or a scraper forever, pointing straight at bucket bandwidth I pay for, with no page of mine in the path. With it, the shareable artifact is the download page, which is the thing I would like people to share anyway.

The keys have no version in them

notifio-arm64.dmg, notifio-x64.dmg, notifio-setup.exe. Fixed names, which means a release overwrites the previous one, and the release script's whole verification step exists because of that: it compares the uploaded size against the local artifact and reads back the version it stamped into the object metadata, because an overwrite has no second chance.

The cost is that there is no URL for an older build. You cannot pin a version, and if a release is bad the fix is forward only. For an app with no auto-updater, where reinstalling is the update mechanism, I decided a stable download URL is worth more than an archive of old installers. The script does have an --archive flag that uploads a version-stamped copy alongside the fixed key, which is the escape hatch for the day I regret this.

503 rather than 500 when the credentials are gone

if (!accountId || !accessKeyId || !secretAccessKey) {
  console.error("[download] Missing R2 environment variables");
  return NextResponse.json(
    { error: "Download is temporarily unavailable." },
    { status: 503 }
  );
}
Enter fullscreen mode Exit fullscreen mode

A missing environment variable is a deployment problem, not a client problem, and 503 is the status that says "come back later" to both a person and a crawler. The log line is there because the page itself cannot tell you which of the three variables is missing.

This path is also one of the reasons the site has a usable maintenance mode: the download route touches no database at all. When the database is offline, checkout and licence validation have to answer 503, while the marketing pages, the contact form and the installers keep working. A customer who already paid can still install the app on a new laptop during a database outage, which is the one thing I would hate to take away.

The one thing on the page that costs a render

The page is force-dynamic, and not because of anything above. It is because of one sentence at the bottom:

Don&apos;t have a license yet?{" "}
<Link href="/pricing">Get one for <Price pence={LICENSE_PRICE_PENCE} /></Link>
Enter fullscreen mode Exit fullscreen mode

That price is localised from a header on the request, so a static render would show every visitor in the world a figure in pounds. One footer link is enough to take the whole page out of the static set, which is a trade I would make again for a page whose content is three buttons, and one I would think much harder about on a page with real content.

The general shape I would take from all of this: a download page is a routing problem wearing a design problem's clothes. Decide what the URL promises, how long it promises it for, and what happens when the thing behind it is missing, and the buttons more or less draw themselves.

You can click all three for yourself on the download page, the licence that activates what you install is on the pricing page, the install and activation steps are written out on the help page, and the per-site setup guides live at notifio.app/alerts.

Top comments (0)