| Links |
Create, edit, archive, soft-delete with a 30-day window. Custom or generated aliases, tags, titles, expiry (410 past it). Full-text and substring search, cursor pagination. |
| Redirects |
In-process cache → Redis → Postgres, with negative caching for the unknown aliases a public shortener is mostly asked for. Redis is optional: lose it and redirects get slower, not wrong. Per link and off by default, a visitor's query string and the path segments after the alias can be forwarded onto the destination — so one short link can stand in for a whole documentation tree. With path forwarding off, anything under an alias is the same 404 an unknown alias gets. |
| Routing rules |
Send different visitors to different destinations from one link. Rules are checked lowest priority number first and the first match wins; anyone matching none falls through to the link's split test, its fallback, or the link's own destination. Twelve conditions — country, region, city, language, browser, OS, device, date and time, referrer host, query parameters, UTM parameters, and whether somebody was seen on that link earlier today — combined with AND, any listed value matching. Time windows are evaluated when the visitor arrives, in a real IANA timezone, so a window opens and closes on time even on a hot link. Every rule destination goes through the same tier checks a link's own does. Region and city are resolved for the redirect and never stored. There is no cookies condition, deliberately, and asking for one is refused by name. |
| Split testing |
Divide one link's traffic between several destinations. Weighted arms take a share each — weights are relative, so 60/40 and 600/400 are the same test — or sequential arms are visited strictly in turn, in an order kept in the database so it holds across every replica and every restart. A fallback destination catches whoever no rule and no arm claimed, standing in for the link's own without changing it. Switching an arm off is one click and the rest re-share its traffic, which is what feature-flagging a destination looks like here. Every click records which destination served it, and the link's page shows clicks, visitors and share per arm beside its configured weight — a split with no attribution is a coin flip with extra steps. |
| Folders |
File links into a tree, up to eight levels deep. Create, rename, move and delete from a page of its own; filter the link list by a folder, or by No folder for everything never filed. Moving is two clicks — Move, then Move here on a destination — and only destinations that would be accepted offer the button, because a folder can never be moved into itself or into anything inside it. Every control here is a plain form or link — keyboard-operable, and working with scripting off — which is what rules out drag-and-drop rather than a preference. That is a property of this page, not a fallback the dashboard offers: the dashboard needs JavaScript (below). Deleting a folder deletes the folders inside it and no links at all: everything filed anywhere in the branch survives as unfiled. Two folders in the same place cannot share a name, ignoring case. |
| QR codes |
Several codes per link, each with its own label, its own style and its own logo, drawn as SVG or downloaded as PNG. A pure-Go encoder draws the picture with explicit pixel width and height over a viewBox of the same extent, so it prints at any size; qr.svg and qr.png are the link's default code and qr/codes/{slug}/image.svg/.png are the named ones. The form asks how big you want it, in pixels — one number, and it is exactly what the picture measures, because only the symbol needs whole modules and the quiet zone carries the remainder in pixels; it lands inside three to five modules wherever the grid admits one and errs wide rather than narrow where it does not — and the PNG is bounded at 2048px a side rather than rasterising whatever you ask for. The preview beside the form keeps a fixed footprint and scales the code down into it, so setting a large size changes the file and not the page. A logo goes in the middle: one image file, capped at 1MiB and 1024px a side, covering a centred square three tenths of the symbol's width, which forces error correction to level H so the code still scans. Restyle foreground, background, error correction and size; the style is stored per code and changes the drawing, never what the code says. Every code takes the strongest error-correction level that does not make the symbol any bigger — usually Q for a short URL, in exactly the picture the old default M produced — so correction is taken wherever it costs nothing; a level set over the API is a floor that raises it and never lowers it, and removing a logo returns a code to the rule rather than leaving it at H. The picture carries its own white background and stays black on white in both themes, because an inverted QR code is refused by a large share of scanners. Scans are counted as ordinary clicks and the codes are told apart: the payload carries ?src=qr plus the code's own qrc parameter, and each code appears in the Referrers breakdown as qr:<slug>, and the bare qr is what a picture carrying no code at all records — counted against whichever code is the link's default. |
| Accounts |
A forgotten password stops being permanent. A single-use token, mailed, one hour, superseding whatever was outstanding — and the response is byte-identical whether or not the address has an account, so the form is not a way to learn who has one. Completing a reset ends every session on the account and spends every sibling token; API keys are deliberately untouched, so recovery is not an outage. An account can be deleted, and what it leaves behind is erased: an hourly pass scrubs the identifying fields and keeps the row, so foreign keys and audit records go on pointing at something, and the actor's name in the audit log becomes a constant tombstone rather than a hole. Both need a mailer for the recovery half — with no SMTP_HOST a reset request refuses out loud and writes nothing, rather than pretending to send. |
| Two-factor |
TOTP (RFC 6238), with ten single-use recovery codes that make it survivable. Off by default and unavailable at all until the operator sets LINKCTRL_MFA_SECRET_KEY — an instance without one offers no second factor, and an instance that loses one falls back to the recovery codes. The check sits inside the login flow between the password and the session, so a right password alone mints nothing, and failed second-factor attempts count against the same lockout as failed passwords rather than getting a budget of their own. An enrolment started and abandoned leaves the account exactly as it was. No WebAuthn, no passkeys, no SMS, no push, and nobody can be required to have one — there is no policy that enforces it across an organization. |
| Updates |
This instance can tell you when a newer LinkCtrl is released, and it asks first. A daily GET to GitHub's releases API carrying this server's address and the running version and nothing else — no instance identifier, no counts, no configuration. It is off until somebody is asked and says yes: on the setup form for a fresh instance, on the dashboard at the first sign-in by an account holding instance.admin for one that was upgraded. LINKCTRL_UPDATE_CHECK=false refuses it outright, and so does never answering. The destination is a compile-time constant, not a setting. |
| High availability |
Several replicas behind a load balancer, with a failover contract you can configure against. /readyz answers 503 to mean take me out of rotation and 200 to mean keep me — degraded is a 200, because the word is diagnostic and the code is the instruction. Measured rather than claimed: three replicas destroyed and rebuilt under 2,000 requests a second cost zero failed requests and zero retries, with the whole replacement taking 35 seconds; the same replacement with no drain delay cost 905 retried requests. A single container is still a tested configuration — the release image is driven over HTTP on a network carrying nothing but Postgres, in CI on every push, so nothing here is required to run one box. |
| Webhooks |
Register a URL and this instance POSTs a workspace's events to it, signed with HMAC-SHA256 and a per-webhook secret shown once. Seven events — a link's five lifecycle changes, a refused destination, and an automation rule that fired — and the vocabulary is closed, so a subscription that would never fire is not something you can create by typo. Delivery is a Postgres queue, not a call on the request path: a link write queues a row and returns, the scheduler drains it under a leader lock, and a failure is retried with a doubling backoff for seven attempts across an hour before it is abandoned. Every attempt is recorded — status, count, response code — and the log is on the page behind each registration. The URL goes through the same destination checks a link does, and the address it resolves to is checked again at connect time, because a webhook makes this server fetch something rather than a visitor's browser. No redirect is followed. |
| Automation |
Standing instructions: when this happens in this workspace, do these things. Three triggers — a link expiring, a click budget running out, a destination somebody was refused — and three actions: notify the owners, emit an automation.fired webhook, or archive the matched links. Evaluation runs on the scheduler under a leader lock and never on a request; there is no run-now button and no endpoint that could be one. A rule is armed when you create it and re-armed when you unpause it, so it acts on what happens next rather than on your back catalogue — and last_fired_at is a watermark, not a note: a rule sees each subject exactly once, so nothing loops. One run reads at most 100 rules and 25 subjects a rule, and a rule that matches more picks up the rest next time rather than dropping it. The hundred are the hundred the scheduler has looked at least recently — not the ones that fired least recently, which would leave every idle rule at the front — so an instance holding more rules than that evaluates all of them, in rotation. Twenty rules fit in a workspace; every change is audited, and so is every firing. |
| Campaigns |
Label links with the body of work they belong to, and filter the list by it. Create, edit and delete from a page of its own; a slug is derived from the name and is unique per workspace, because it is what a filter URL names. Start and end dates describe the campaign and enforce nothing — a link in a finished campaign still redirects, because expiry belongs to the link. Deleting a campaign keeps every link it held, unlabelled. A link can carry a folder and a campaign at once: one is where it lives, the other is what it is for. There is no per-campaign analytics — that is a later phase. |
| Domains |
A workspace registers a hostname of its own, proves it controls it, and serves its links there: register, verify, rename, remove, from a page and from the API, behind domains.write. A hostname belongs to exactly one workspace — it is one alias namespace, so it cannot be shared and a second registration of the same name is refused instance-wide. An admin manages their own workspace's hostnames and gets 403 on anybody else's; the instance's default domain stays the operator's. Every administrative change is audited. |
| Custom domain verification |
A DNS TXT record is what turns a registration into a served hostname, and nothing else does. Until the check passes, a Host header naming the hostname gets the operational 404 whatever DNS points at this instance — which is what stops a stranger registering your name and serving links on it. Verified hostnames are re-checked hourly; a hostname that stops passing keeps working for 24 hours while its owner is notified, and then stops being served on every replica at once. Both numbers are yours to set. Renaming a hostname un-verifies it, because the record you published proves control of the old name. Links can then be created on your own hostname, with short_url built from it, and its bare domain can redirect wherever you like. TLS stays your proxy's: LinkCtrl never speaks ACME, and answers Caddy's on-demand ask for verified hostnames only. |
| Analytics |
Clicks, estimated unique visitors, bots, device, browser, OS, language, referrer host, and country with an optional GeoIP database. Daily rollups, server-rendered charts — including a world choropleth and a share ring per breakdown, both computed in Go and drawn as inline SVG, no JavaScript and no CDN — a bounded recent-activity feed, retention enforced by dropping whole months. Totals are recomputed every minute and the per-dimension breakdowns every fifteen, so a breakdown can lag the click count above it by up to that; linkctrl_rollup_staleness_seconds says by how much. |
| Auth |
Email/password with argon2id, server-side sessions in __Host- cookies, per-account lockout and per-address rate limiting, real RBAC with four built-in roles and a working permission evaluator. An optional second factor sits inside the same flow (above), and a forgotten password is recoverable by the person who forgot it. |
| Abuse limits |
Per-address limits on credential endpoints, the API, and 404 probing. The last charges misses only, so a working link is never throttled by anyone's scanning. |
| Bot blocking |
Refuse automated clients on a link, or on the whole link domain, with 403 and a body naming nothing — identical whether the link is live, expired or archived, so being blocked reveals no more than a 404 would. Off by default and inherited from the domain; an operator with domains.write may enforce it so no link can opt out. Detection is the same user-agent heuristic the click statistics use, and there is no challenge or appeal: a person it misjudges cannot get through. Refusals are counted as bot clicks and on linkctrl_redirects_total{outcome="blocked_bot"}, never written to the audit log — a crawler would fill it. |
| API keys |
lk_live_… bearer tokens, scoped to permissions you hold, intersected with your current role on every request — and dead the moment their owner stops holding a membership that covers them, so removing somebody stops the credentials they leave behind. Revocable by their owner, or by anybody holding apikeys.write across the organization, which is the answer to a key that has to be stopped and an owner who will not stop it. Usage timestamps. A key belongs to your account, not to one tenant — leave it unpinned and it reaches every organization you are a member of, which is what makes one credential usable by somebody who works in several; pin it to a workspace or an organization and it reaches that and nothing else. An administrator of an organization can cut their own organization out of somebody's account-wide key without destroying a credential that is not theirs, and the key's owner is told which ones. A key can replace itself — one call with its own token returns a successor with the same reach or less, and the old secret keeps working for a bounded window before it stops. Nobody has to be signed in, which is the point; the cost is that a leaked key can rotate itself too, so every generation is listed and audited and SECURITY.md says what to do about a key you did not create. |
| Audit log |
Events recorded with the actor snapshotted at write time and a network prefix rather than an address, readable at GET /api/v1/audit behind a non-delegable permission. Retention is its own setting and defaults to keeping everything, so growth is reported rather than trimmed silently. Thirty-nine actions are recorded, which is every administrative change this product makes: the root redirect and bot policy of a domain, the invitation lifecycle, member and workspace changes, the organization lifecycle, refused destinations and the disputes that follow them, domain registration and verification, API key rotation, automation firings, and the instance-level acts that belong to no tenant. The count is audit.AllActions, held to the declared set by TestAllActionsIsExhaustive, so it cannot drift without a failing build — and it has drifted twice from this page anyway, which is why it is now stated as a number this sentence had to be recounted against: it said seven categories until 0.2.0 and thirty-two until 0.3.0, while Phase 3 added the account lifecycle's own. A bot being refused is not among them — that is traffic, and it is counted rather than logged. An organization's records outlive the organization, so a teardown does not erase its own trail. |
| Notifications |
An in-app inbox for things the instance wanted you to know about — the audit log outgrowing its threshold is the first — with mark-read. A bell in the header carries the count and previews the newest few, so answering "what is it" costs nothing; the full page is one click on. Emailed as well when a mailer is configured. |
| Mail |
Optional SMTP, off unless SMTP_HOST is set. Queued in an outbox and delivered by the scheduler, so a message survives a restart; plain text only, and every consumer works unchanged with no mailer at all. |
| Invitations |
Bring somebody into your organization with a single-use, revocable, expiring link. It is tied to the address you send it to, so forwarding it cannot add a stranger, and the role it carries is capped at your own — at editor when an API key issued it, because redeeming one produces an account that outlives the key. Emailed when a mailer is configured, copyable either way. While sign-ups are closed an invitation may only add an account that already exists. |
| Sign-ups |
A public /signup form and POST /api/v1/auth/register, admitting people according to LINKCTRL_SIGNUP_MODE — closed, invitation-only or open. The operator sets it and nothing in the running instance changes it; the shipped default is closed. Open registration confirms the address by email before the account exists, so it needs a mailer — with none the instance stays invitation-only and says so at boot. A self-registered account gets an organization and workspace of its own; an invited one gets membership and nothing else. |
| Members |
A member list with role changes and removal, behind members.read and members.write. You manage only roles below your own — an admin manages editors and viewers, an owner manages everyone including other owners — and the last owner of an organization cannot be removed or demoted. A role assigned with an API key is capped at editor, for the reason an invitation issued with one is: the account it produces outlives the key. Giving somebody a role in one workspace adds it there and takes nothing away anywhere — and reaches that workspace only: organization-wide memberships, invitations and the organization itself need a membership that covers the organization. |
| Workspaces |
Every request resolves to exactly one, and a switcher moves the browser you are in without moving the others. Which workspace a new session starts in follows the one you used last, or a pin you set. Create, rename and delete them within an organization; deleting one is refused while it still holds any link, archived ones included, because everything in it cascades and there is no trash. |
| Organizations |
Create one of your own, provisioned with a workspace and an owner membership in a single transaction, behind a new orgs.create permission held by the owner role. On a default instance that means the account from the setup form and nobody else, until an owner grants it. Delete one behind org.delete, which owners alone hold and no API key may: it removes every workspace, membership, invitation and key in one transaction, is refused while any link remains or while it is the instance's last, and leaves the audit trail behind. Somebody left belonging to nothing keeps their account and is offered an organization of their own. |
| Dashboard |
Server-rendered HTML with htmx, and it needs JavaScript — htmx swaps a fragment instead of reloading the page for search, filtering and several of the writes, and there is no <noscript> fallback: the stance is written down rather than defended in markup nobody reads. Individual pages do differ — the folder tree above is plain forms throughout, and so are the link filters — but that is what those controls happen to be and not a promise the product keeps, so read it as this page works rather than the dashboard degrades. The redirect path needs none, which is the part a visitor touches. (This sentence read "works without JavaScript" until now. It stopped being true when the requirement was settled deliberately, before 0.3.0 shipped, and the row was not recounted against that.) No build step at runtime — the header's menus are popovers, so the browser opens them, closes them on Escape and needs no script to do it. Needs a browser from mid-2023 (Chrome 114, Safari 17, Firefox 125) for that. Light and dark, following the operating system unless overridden per browser — the server renders the theme into the page, so there is no flash of the wrong one. Rebuilt in 0.3.0 from a walkthrough of somebody using it: two top-level destinations rather than nine, the shell naming the workspace and organization you are in at every membership count, a link page that opens on the link rather than on its analytics, and the buried high-traffic controls — the QR code, the dispute reviewers — moved onto routes of their own that render as ordinary pages and open as popups from where you were standing. No page scrolls sideways at 360px, held by a test that renders every one of them. |
| API |
REST with RFC 9457 problem responses, an OpenAPI 3 document, and Swagger UI at /docs. |
| Operations |
/healthz, /readyz, Prometheus metrics on a separate unpublished port, structured JSON logs, graceful shutdown that flushes buffered clicks. |
| CLI |
lctl for config validation, migrations, partitions and API keys — including the first key on a headless box. |