Skip to content

rubickanov/brevis

Repository files navigation

Brevis

A small, self-hostable URL shortener: a .NET 10 Native-AOT backend that also serves a React admin dashboard — one binary, login, create links, watch their analytics.

Stack

  • .NET 10, ASP.NET Core minimal API
  • SQLite + Dapper.AOT
  • Native AOT (single self-contained binary, no JIT)
  • Per-click analytics with an in-memory batched write pipeline
  • React + TypeScript + Vite dashboard (Recharts), built into the API's wwwroot
  • Single-admin auth: password login → opaque session token in an httpOnly cookie (no JWT)

Run

Set an admin password once (stored hashed in a gitignored file, loaded by just run):

just set-password 'your-password'
just run                   # API + dashboard on http://localhost:5038

Open http://localhost:5038 and log in. (The dashboard is served from wwwroot, which is populated by just web-build or just publish.)

Dashboard dev (hot reload)

For frontend work, run the Vite dev server alongside the API — two terminals:

just run        # terminal 1: backend on :5038
just web-dev    # terminal 2: Vite on :5173, proxies /api -> :5038

Edit files under web/ and the browser updates instantly. Vite proxies API calls to the backend, so it behaves like one origin (no CORS).

Dev tasks

Common tasks are wrapped in a justfile (requires just, curl, jq, and — for the dashboard — Node 18+):

just                       # list recipes
just run                   # run the API (+ serves the built dashboard)
just set-password <pw>     # set the local admin password
just test                  # run the tests
just web-install           # install dashboard deps (web/)
just web-dev               # Vite dev server with hot reload
just web-build             # build the dashboard into wwwroot
just shorten https://...   # create a short link, print the code (run `just login <pw>` first)
just stats <code>          # pretty-print a link's analytics
just bench <code> [N] [C]  # benchmark the redirect (N requests, C concurrent)

The base URL is read from appsettings.json (BaseUrl) — one source of truth, no extra config files. Override a single call with just base=http://host:port <recipe>, or check the resolved value with just show-base.

API

Public:

GET /{code}        # 302 to the original URL (404 if unknown); records a click

Auth: POST /api/login with { "password": "..." } sets an httpOnly brevis_session cookie (204), or 401 on a bad password. POST /api/logout revokes it. All /api/* routes below require that cookie (the browser sends it automatically); without it they return 401.

Admin — link management (these are what the dashboard calls):

POST   /api/links            { "longUrl": "https://..." }  -> 201 { code, shortUrl }
GET    /api/links            -> 200 [{ code, longUrl, shortUrl, createdAt, clicks }]  (newest first)
GET    /api/links/{code}     -> 200 { code, longUrl, shortUrl, createdAt, stats }  (404 if unknown)
DELETE /api/links/{code}     -> 204  (404 if unknown)

The stats object on the detail endpoint:

{
  "code": "aB3kF9x",
  "totalClicks": 1234,
  "clicksByDay":  [{ "date": "2026-06-01", "count": 90 }],
  "topReferrers": [{ "referrer": "news.ycombinator.com", "count": 40 }],
  "byCountry":    [{ "country": "GB", "count": 600 }],
  "byBrowser":    [{ "browser": "Chrome", "count": 700 }]
}

Analytics pipeline

Every redirect records a click event (timestamp, referrer, user-agent, IP, and geo country/city). The challenge is doing this without slowing the redirect, which is the hot path. So recording is decoupled from persistence:

  1. The redirect captures the raw click and drops it into a bounded in-memory queue (System.Threading.Channels), then returns the 302 immediately. It never blocks on the database, and never does the geo lookup inline. If the queue is full (extreme load), the event is dropped rather than adding latency.
  2. A background BackgroundService (ClickWriter) drains the queue, enriches each event with geo data, and bulk-inserts in batches — flushing when the batch reaches BatchSize or FlushIntervalMs elapses, whichever comes first. One transaction per batch means one fsync instead of one per click, and a single writer means zero write-lock contention. On graceful shutdown the queue is drained so a clean stop loses nothing (a hard crash drops in-flight events — an accepted trade for approximate analytics).

Measured effect on the redirect path (3,000 requests, 24 concurrent, Native AOT, SQLite):

metric synchronous insert batched pipeline
mean 624 ms 7.6 ms
p50 20.7 ms 0.24 ms
p99 7,827 ms 152 ms
max 16,686 ms 301 ms

Reproduce with just bench <code> (see Dev tasks).

Geo lookups (optional)

Geo enrichment uses a MaxMind GeoLite2 City database, which requires a free MaxMind account + license key to download. Point Geo:DatabasePath at the .mmdb file. The app runs fine without it — geo lookups simply no-op and country/city come back as unknown.

Note: MaxMind.Db needs <TrimmerRootAssembly Include="MaxMind.Db" /> (already set in the csproj) to survive Native AOT trimming — without it, opening a database throws at runtime even though the publish reports no warnings.

Configuration

src/Brevis.API/appsettings.json:

  • ConnectionStrings:Sqlite — SQLite connection string
  • BaseUrl — base URL prepended to generated short links
  • Admin:PasswordHash — PBKDF2 hash of the admin password (empty = login disabled). Generate with just hash-password <pw>, or set it for local dev with just set-password <pw>. In production pass it as the Admin__PasswordHash env var rather than committing it.
  • Geo:DatabasePath — path to a GeoLite2 .mmdb file, resolved relative to the app's content root (so geodata/GeoLite2-City.mmdb works in both dotnet run and the published app). Empty = geo disabled.
  • Analytics:BatchSize — flush once this many events are buffered (default 500)
  • Analytics:FlushIntervalMs — max time before a partial batch is flushed (default 1000)
  • Analytics:QueueCapacity — bounded queue size; events are dropped when full (default 10000)

Publish (AOT)

dotnet publish src/Brevis.API -c Release -r linux-x64

Use just publish, which builds the dashboard into wwwroot first (needs Node 18+), then runs the AOT publish. Output (native binary + wwwroot) lands in bin/Release/net10.0/linux-x64/publish/. The Dockerfile does the same sequence inside the build container.

Deploy

Brevis ships as a single self-contained binary serving both the API and the dashboard. Put a TLS-terminating reverse proxy in front of it — the session cookie is Secure over HTTPS.

docker build -t brevis .
docker run -d --name brevis -p 8080:8080 \
  -e BaseUrl='https://brev.example.com/' \
  -e Admin__PasswordHash="$(just hash-password 'your-strong-password')" \
  -v brevis-data:/app \
  brevis
  • BaseUrl — public origin used to build short URLs (include the trailing slash).
  • Admin__PasswordHash — the password hash (__ maps to the Admin:PasswordHash config key).
  • -v brevis-data:/app — persists the SQLite database across restarts.

Mount a GeoLite2 database to enable country analytics (see Geo lookups):

  -v /path/to/GeoLite2-City.mmdb:/app/geodata/GeoLite2-City.mmdb:ro

Front it with a reverse proxy that terminates TLS, e.g. Caddy:

brev.example.com {
    reverse_proxy localhost:8080
}

The app reads X-Forwarded-For for the client IP, so geo/analytics see the real address.

Without Docker, just publish produces the same artifact (binary + wwwroot) under src/Brevis.API/bin/Release/net10.0/linux-x64/publish/ — copy it to your host, set BaseUrl and Admin__PasswordHash, and run ./Brevis.API.

Test

dotnet test

License

MIT

About

A small URL shortener built with ASP.NET Core minimal API on .NET 10, compiled to Native AOT.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Contributors