Stock Kutt (bare-metal LXC, Postgres on void-db), blackflame via custom CSS (no fork), private-first via CF Access with a public-later toggle. Hybrid Void integration: embedded themed Kutt + a native card (update-tracker + quick-add). Repos: Hynes/URLShortener-void-kutt + void-v2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
8.3 KiB
Markdown
140 lines
8.3 KiB
Markdown
# Design: Kutt URL shortener as a Void app
|
|
|
|
**Date:** 2026-06-08
|
|
**Status:** Approved (brainstorm), pending implementation plan
|
|
**Repos:** new **`Hynes/URLShortener-void-kutt`** (theme + deploy) + **`Hynes/Void-Homelab`** (void-v2 integration)
|
|
|
|
## Summary
|
|
|
|
Self-host **Kutt** (a modern Node URL shortener) **unmodified** in its own bare-metal
|
|
LXC behind `link.hynesy.com`, blackflame-themed via Kutt's **custom-CSS** support (no
|
|
fork → stays 100% upstream-clean). Surface it in the Void as a **Hybrid Apps item**:
|
|
an embedded themed Kutt UI for management, plus a small **Void-native card** for an
|
|
**update-tracker** and a **quick-add** box. Start **fully private** (CF Access over the
|
|
whole host) with a clean, no-rebuild path to **public-later** (and per-link
|
|
public/private via Kutt's multi-domain support).
|
|
|
|
## Background / constraints
|
|
|
|
- **Why Kutt** (vs Shlink): liked the UI, MIT, actively released (v3.2.5, 2026-05),
|
|
Node/Postgres bare-metal install, and **themeable via custom CSS without forking** —
|
|
so we ride upstream `npm` updates with zero merge conflicts.
|
|
- **User preferences honoured:** bare-metal-in-LXC (no Docker for long-term personal
|
|
apps); blackflame styling; static IP + router MAC reservation per guest; HA-tag +
|
|
Z→Z3 replication; backup before changes; document everything to the wiki + git.
|
|
- **The redirect-vs-auth split:** a shortener's redirect endpoint normally must be
|
|
public, but the admin must be protected. We resolve this with a **CF Access toggle**
|
|
(private now) rather than baking a split in.
|
|
|
|
## Decisions
|
|
|
|
| Decision | Choice | Rationale |
|
|
|---|---|---|
|
|
| App | **Kutt, stock** (pinned release) | Liked UI; themeable via CSS so no fork; upstream-clean. |
|
|
| Deploy form | **Bare-metal LXC** (Node + systemd) | No-Docker-for-keepers preference. |
|
|
| Database | **Shared `void-db`** (CT 310, PG 16) — dedicated `kutt` DB + least-priv role | One Postgres to back up / HA; no new DB service. |
|
|
| Redis | **Skipped** | Optional cache; unnecessary single-user. |
|
|
| Domain | `link.hynesy.com` → Traefik → Kutt | Clear, readable. |
|
|
| Access (now) | **Private** — CF Access over the whole host | Locked down first. |
|
|
| Access (later) | Relax CF Access on redirects; add a public 2nd domain for per-link public/private | No rebuild — policy flip + Kutt multi-domain. |
|
|
| UI | **Hybrid** — embed themed Kutt + a Void-native card | Keep the UI you liked + Void extras. |
|
|
| Feature parity w/ Shlink | Stock + QR Void-side; richer gaps via **upstream MRs** (separate effort) | Never fork Kutt. |
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Browser ──(CF Access, Phase 1)── link.hynesy.com ── Traefik(mediastack) ── CT 113 kutt (Node)
|
|
│ DATABASE_URL
|
|
void-db CT 310 (PG 16, db=kutt)
|
|
Void (#/links) ── iframe ── themed Kutt UI
|
|
└── Void-native card ── /api/links proxy (void-app, holds Kutt API key) ── CT 113 Kutt REST API
|
|
└── update-tracker ── GitHub releases API + running version
|
|
```
|
|
|
|
## Components
|
|
|
|
### 1. Kutt LXC (CT 113 `kutt`)
|
|
- New unprivileged LXC on **Z**, static IP + router MAC reservation, **HA-tagged**,
|
|
replicated Z→Z3, AppArmor `unconfined` (host requirement). 2 GB / 2 cores / small disk.
|
|
- **Node 20+**; Kutt checked out at a **pinned release tag** (e.g. `v3.2.5`) under
|
|
`/opt/kutt`, user `kutt`. Install: `npm ci` → (build step if the release needs one)
|
|
→ `npm run migrate` → `npm start`, managed by **`kutt.service`** (systemd).
|
|
- Config via `/opt/kutt/.env` (mode 600): `DEFAULT_DOMAIN=link.hynesy.com`,
|
|
`DB_*`/`DATABASE_URL` → void-db, `DISABLE_REGISTRATION=true`, trust-proxy / forwarded
|
|
settings so Kutt knows its public origin, **no SMTP** (registration off, admin
|
|
pre-seeded), one admin account + an API key.
|
|
|
|
### 2. Database (shared void-db)
|
|
- On CT 310 (`192.168.1.215:5432`, PG 16): create database **`kutt`** owned by a new
|
|
**`kutt`** role (NOSUPERUSER, owns its DB + `public` schema so Kutt migrations run).
|
|
Kutt manages its own schema via `npm run migrate`. Rides void-db's existing HA +
|
|
backups; no impact on the `void` database.
|
|
|
|
### 3. Domain + CF Access (private → public)
|
|
- Traefik (mediastack `/docker/proxy/dynamic.yml`): router `link.hynesy.com` → CT 113
|
|
Kutt port. Wildcard `*.hynesy.com` DNS already targets the tunnel.
|
|
- **Phase 1 (private):** CF Access app over **all of** `link.hynesy.com` (Google IdP,
|
|
email allowlist) — admin *and* redirects private. (Embed then works via
|
|
`void.hynesy.com`, not the raw LAN IP — same CF-cookie rule as Timelapse/AI-Usage.)
|
|
- **Phase 2 (public, later, separate effort):** remove CF Access from redirects; for
|
|
per-link public/private add a public 2nd domain (e.g. `go.hynesy.com`, no CF Access)
|
|
and use Kutt's multi-domain to choose a link's domain.
|
|
|
|
### 4. Theming (blackflame, no fork)
|
|
- Kutt's **custom CSS** hook: a blackflame stylesheet (palette `--accent #ff4f2e` etc.,
|
|
Cinzel/Cormorant/JetBrains fonts, surfaces) applied to stock Kutt. Lives in
|
|
`Hynes/URLShortener-void-kutt` and is dropped into Kutt's custom/override dir at deploy. **No Kutt
|
|
source changes** → `npm`/release updates never conflict.
|
|
|
|
### 5. Void integration (the Hybrid)
|
|
- **Apps rail "Links"** (`#/links`, `public/views/links.js`): a Void header bar + a
|
|
Void-native card (below) + an `<iframe src="https://link.hynesy.com">` (themed Kutt).
|
|
Mirrors the Timelapse/AI-Usage embed views; added to the **Apps** sidebar section.
|
|
- **Void server proxy** (`lib/api/routes/links.js`, mount `/api/links`, owner-gated):
|
|
forwards to Kutt's REST API over the LAN (CT 113 IP:port) with the **Kutt API key**
|
|
held in void-app's `.env` (key never reaches the browser; LAN call works regardless
|
|
of CF Access). Endpoints needed: create link (quick-add) + recent links + version.
|
|
- **Void-native card:**
|
|
- **Update-tracker** — the proxy fetches `api.github.com/repos/thedevs-network/kutt/
|
|
releases/latest` (cached ~6 h) and the running Kutt version; the card shows the
|
|
version, an **"update available"** badge when they differ, and a changelog link.
|
|
(Bare-metal = manual updates, so this is the "what's new / time to update" signal.)
|
|
- **Quick-add** — paste a URL → `POST /api/links` → Kutt creates the short link → show
|
|
+ copy. Optional **QR** rendered Void-side (client lib) for any link.
|
|
|
|
### 6. Feature parity with Shlink
|
|
Kutt stays **stock**. Two non-forking lanes: (a) **now**, presentation-only wins like
|
|
**QR** in the Void card; (b) **later**, richer gaps (geo analytics, tags) as **merge
|
|
requests to Kutt upstream** — tracked as a *separate* project, **out of scope here**.
|
|
|
|
## Data flow
|
|
Create: Void quick-add → `/api/links` proxy (+API key) → Kutt → row in `kutt` DB → short
|
|
URL returned. Resolve: `link.hynesy.com/<slug>` → Kutt → 302 (CF-gated in Phase 1).
|
|
Update check: card → proxy → GitHub releases + running version → badge.
|
|
|
|
## Error handling
|
|
- Kutt down / proxy error → the card shows "Kutt unreachable" + the `↗ Open` fallback;
|
|
the embed shows Kutt's own error. Void itself unaffected.
|
|
- GitHub API rate-limited/unreachable → update-tracker shows "version check unavailable"
|
|
(cached last-known if any); never blocks the card.
|
|
- Missing/invalid Kutt API key → proxy returns a clear 502; quick-add disabled with a hint.
|
|
|
|
## Testing
|
|
- **vitest:** the `/api/links` proxy (mock Kutt API — create/list) and the update-tracker
|
|
comparison logic (mock GitHub `releases/latest` + running version → badge true/false);
|
|
the `links.js` view renders the card + iframe (jsdom).
|
|
- **Deploy smoke:** create a link via Kutt's API → `curl` the slug → 302 to target;
|
|
confirm `link.hynesy.com` is CF-gated (302 to cloudflareaccess) in Phase 1.
|
|
|
|
## Out of scope (separate efforts)
|
|
- Phase-2 public access + per-link public/private second domain.
|
|
- Upstream MRs for geo/tags parity.
|
|
- Redis cache; SMTP/registration; multi-user.
|
|
|
|
## Repos & docs (standing rule)
|
|
- **`Hynes/URLShortener-void-kutt`** (created, `gitea@192.168.1.223:Hynes/URLShortener-void-kutt.git`): blackflame theme CSS, the
|
|
LXC create/bootstrap + `kutt.service`, `.env.example`, deploy notes.
|
|
- **`Hynes/Void-Homelab`** (void-v2): the `links.js` view + `/api/links` proxy + Apps
|
|
rail entry + CHANGELOG.
|
|
- **Wiki:** new "Kutt / Link Shortener LXC (113)" page under Hosts & Services.
|