Skip to content

Latest commit

 

History

History
242 lines (185 loc) · 9.8 KB

File metadata and controls

242 lines (185 loc) · 9.8 KB

Server

ASP.NET Core with a Blazor Server admin page and two API endpoints. Plain files on disk, no database, no login.

Storage layout

$CLICKWRAP_DATA/
  race-timer/
    stats.json         # download counters for this app
    3.3.0.1/
      app.zip          # zipped ClickOnce publish folder
      metadata.json
    3.4.0.0/
      app.zip
      metadata.json
  time-maker/
    1.6.0.0/
      ...

metadata.json:

{
  "version": "3.4.0.0",
  "releaseNotes": "Fixed lap import.",
  "uploadedUtc": "2026-08-28T09:14:03Z",
  "sizeBytes": 8412331,
  "sha256": "9f2c…"
}

Latest is the highest version folder name, parsed with System.Version — so 3.10.0.0 beats 3.9.0.0, which string ordering would get wrong. Folders that do not parse as a version, or that have no app.zip, are skipped. A version folder dropped in by hand works; without metadata.json the size and date come from the file itself and there are no release notes.

stats.json sits beside the version folders and is the only file the server writes outside an upload:

{
  "appId": "race-timer",
  "totalDownloads": 412,
  "lastDownloadUtc": "2026-09-02T11:20:44+00:00",
  "versions": {
    "3.4.0.0": { "downloads": 388, "lastDownloadUtc": "2026-09-02T11:20:44+00:00" }
  }
}

One counter is added per request to the download endpoint, before the file is streamed — so an aborted download still counts and a resumed range request counts twice. It is a popularity signal, not billing. totalDownloads keeps counting versions that have since been deleted, which is why it is stored rather than summed from versions.

Counting is best-effort: writes are serialised per app so two concurrent downloads cannot lose a count, go through a temporary file so a crash cannot leave a truncated one, and any failure is logged and swallowed rather than allowed to break the download. An unreadable stats.json starts again from zero. Delete the file to reset an app's counters; nothing else depends on it.

The counts are shown on both pages: total per app on /, and per version on /admin once an app is selected.

API

GET /api/apps/{appId}/latest

{
  "appId": "race-timer",
  "version": "3.4.0.0",
  "downloadUrl": "https://updates.example.com/api/apps/race-timer/versions/3.4.0.0/download",
  "releaseNotes": "Fixed lap import.",
  "uploadedUtc": "2026-08-28T09:14:03+00:00",
  "sizeBytes": 8412331,
  "sha256": "9f2c…"
}

404 when the app has no versions. 400 when the app id is not a valid segment.

GET /api/apps/{appId}/versions/{version}/download

Streams app.zip as application/zip with range requests enabled, so a large download can resume rather than restart.

GET /health

Plain text Healthy with 200, or Unhealthy with 503 — which is all a container healthcheck needs. It checks that the data folder is present, since an unmounted volume is the one failure the server cannot recover from on its own and cannot otherwise detect. The rest of the app being up is implied by the endpoint answering at all.

The image installs curl for this; wire it up from compose, for example:

healthcheck:
  test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
  interval: 30s
  timeout: 3s
  retries: 3

Path safety

appId and version become path segments, so they are whitelisted against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ rather than sanitised. Anything else is rejected with 400 before touching the disk.

/admin

One page, built with MudBlazor: pick an existing app or type a new id, enter a version, optional release notes, choose the zip, upload. Success shows a snackbar; validation failures show inline.

The MudBlazor theme lives in Components/ClickWrapTheme.cs and is built from the same palette as the installer and the store launcher (accent #2563EB, surface #FFFFFF, background #F3F4F6), so the admin UI reads as part of the same family of tools.

There is no app-level auth by design — this is expected to sit behind a Cloudflare Tunnel with Cloudflare Access in front of it. Do not expose it directly.

Protect the whole host, not just /admin

Put the Access application on your-host/* and add a bypass policy for /api/*, which is all the installers need. Do not scope Access to /admin alone.

The reason is Blazor, not Cloudflare. / and /admin are two routes of one interactive circuit: once a browser has loaded any page, the router switches between them client-side over the SignalR connection, with no further HTTP request. Access only sees HTTP requests, so it never gets a chance to challenge — the admin page renders, upload control included, from a session that only ever passed through the public page.

That is not merely a missed prompt on the nav button. Anyone able to load an unprotected page can reach the same UI directly:

history.pushState({}, '', '/admin');
window.dispatchEvent(new PopStateEvent('popstate'));

Protecting the whole host closes it: the circuit itself cannot be established without passing Access first. Nothing is lost by doing so — the pages are for you, and installers only ever call /api/....

Uploads stream to a staging folder and are hashed on the way through, so nothing is buffered whole in memory. The zip is then checked for setup.exe and a *.application at its root before anything moves into place. A rejected or interrupted upload leaves nothing behind, and a half-written version is never visible to /latest.

Rejections you will actually hit:

Cause Message
Version already exists Version 3.4.0.0 of 'race-timer' already exists. Tick overwrite to replace it.
Not a version number Enter a valid version number, for example 1.2.3.0.
Zipped the folder, not its contents This does not look like a ClickOnce publish folder: setup.exe and *.application missing from the root of the zip.

That last one is worth the check: the installer runs setup.exe from the root of what it extracts, so a wrongly-shaped zip would otherwise produce a broken install on every machine.

Configuration

All from environment variables, so it drops into a container with no appsettings.json. These are every variable the app itself reads — the names are declared once as constants on ServerOptions.

Variable Default Purpose
CLICKWRAP_DATA /data on Linux, <app>/data on Windows Root of the app/version tree. The only state; put it on a volume.
CLICKWRAP_PUBLIC_BASE_URL (forwarded headers) Public origin, e.g. https://updates.example.com. Used to build downloadUrl.
CLICKWRAP_MAX_UPLOAD_MB 512 Upload size cap, in megabytes. Values that are not a positive integer fall back to the default.
ASPNETCORE_URLS http://0.0.0.0:8080 Addresses to bind. All interfaces, since it is reached through the tunnel, not loopback.

Standard ASP.NET Core variables still work — ASPNETCORE_ENVIRONMENT, Logging__LogLevel__Default and the rest — but nothing in ClickWrap requires them.

Every one of these can also be passed as a command-line argument, which is handy in development:

dotnet run --project src/ClickWrap.Server -- --CLICKWRAP_DATA /tmp/clickwrap --urls http://0.0.0.0:8090

Startup output

The server prints what it resolved, so a misconfigured deployment is obvious in the logs:

--------------------- ClickWrap server starting v1.0.0 ---------------------
Current user: app
Working directory: /app
Listening on: http://0.0.0.0:8080
Public base URL: https://updates.example.com
Data path: /data
Max upload: 512 MB
Apps published: 2 (7 versions, 412 downloads served)

With CLICKWRAP_PUBLIC_BASE_URL unset, that line says so and names the variable, because it is the one setting that silently produces unreachable download URLs behind a tunnel.

Set CLICKWRAP_PUBLIC_BASE_URL in production. Without it the downloadUrl in /latest is built from the inbound request host, which behind a tunnel is the tunnel's host, not yours — installers would then be handed a URL they cannot reach.

TLS is terminated by Cloudflare, so there is no HTTPS redirection and no HSTS. X-Forwarded-* headers are trusted from any proxy because cloudflared connects from inside the container network and so is never in a known-proxy range.

Data Protection keys

Keys are persisted to a dataprotection-keys folder beside the binaries — /app/dataprotection-keys in the container, next to the build output on Windows. Deliberately not under CLICKWRAP_DATA: the key ring is not app content and has no business sharing a volume with the published packages.

You do not need to mount a volume for it. Nothing in this app outlives a request — no login, no cookies, no sessions — so a key ring regenerated on redeploy costs at most one reload for anyone who happened to have a page open at that moment, whose antiforgery token and Blazor circuit were signed with keys that no longer exist. Everything worth keeping is in CLICKWRAP_DATA.

Mount /app/dataprotection-keys only if you want redeploys to be seamless for open pages, or if you ever run a second replica: two instances with separate key rings cannot validate each other's tokens, so they would have to share this folder.

The remaining startup warning, "No XML encryptor configured", is expected on Linux: there is no DPAPI, and encrypting the key ring would mean managing a certificate. It is acceptable here because the keys protect nothing sensitive — authentication is Cloudflare Access's job at the edge, and this app has no login, cookies or sessions of its own. Silencing it would mean adding certificate management for no security gain.

Running it locally

dotnet run --project src/ClickWrap.Server

Then open http://localhost:8080/admin. Data lands in src/ClickWrap.Server/bin/Debug/net10.0/data unless CLICKWRAP_DATA says otherwise.