postcards: custom memorable URLs via Shlink (e.g. post.msbls.de/sardinien2026) #3

Closed
opened 2026-06-15 16:24:56 +00:00 by mAi · 2 comments
Collaborator

Context

m wants to assign a custom, memorable URL to a postcard instead of (or alongside) the unguessable /c/{token} link — e.g. post.msbls.de/sardinien2026 — and set it comfortably while composing. He explicitly named Shlink.

Architecture decision (otto-head)

Reuse the EXISTING Shlink instance on mlake (already multi-domain: s.flexsiebels.de, msbls.de, ts.msbls.de, dumusst.com; API at https://s.flexsiebels.de/rest/v3, key in env). Do not deploy a new Shlink. This mirrors how m/hihlc reuses it.

Routing model — the hihlc pattern (fallthrough on the same domain):

  • post.msbls.de/, /compose, /c/{token}, /static/*, /health → the postcards Go app (high Traefik priority for these specific paths).
  • post.msbls.de/{anything-else}falls through to Shlink (lower priority catch-all), which 302-redirects the custom slug to that card's https://post.msbls.de/c/{token}.
  • See m/hihlc README § “Domain-Settings (Shlink)” + its hihlc-shlink.yml Traefik dynamic config for the exact priority mechanism. post.msbls.de must be added to Shlink's domain list.

Compose-flow integration:

  • In the compose form, add an optional “custom URL” field (opt-in). Mobile-first, integrated into the existing create-a-card flow.
  • On submit with a slug: the app first creates the card (gets its /c/{token}), then calls the Shlink API (POST /rest/v3/short-urls, X-Api-Key) with customSlug=<slug>, domain=post.msbls.de, longUrl=https://post.msbls.de/c/{token}. Store the slug on the postcard so it can be shown/managed.
  • Surface the resulting pretty URL to the sender (that's the “komfortabel” part).

Constraints

  • Coexists with the unguessable-token model — custom slug is strictly opt-in; the privacy-default /c/{token} link always exists. A card with no slug behaves exactly like today.
  • Collision handling — Shlink rejects a taken slug (409); surface a friendly “schon vergeben” error and let the sender pick another.
  • Reserved paths — reject slugs that collide with the app's own routes (compose, c, static, health, empty/root) before calling Shlink, so a slug can never shadow the app.
  • German UI + Umlaute; renamable (read base from BASE_URL, don't hardcode post.msbls.de in app logic — the domain passed to Shlink should derive from BASE_URL's host).

DB

  • Add a nullable slug column (unique) to postcards.postcards via a new idempotent migration. Schema-ownership reminder: verify against msupabase as the postgres role (via pooler 100.99.98.203:6789), NOT supabase_admin — otherwise the prod app can't write and the deploy crash-loops (this hit v1).

Sequencing

Depends on #2 (v2 design/multi-image/flip-card) — both touch the compose flow + handlers + templates. This issue is filed now to capture the design but should be picked up AFTER #2 is merged, to avoid two workers editing the same files. otto-head will assign mAi once #2 lands.

Definition of done

  • Sender can optionally set a custom slug while composing; post.msbls.de/{slug} resolves (via Shlink) to the card.
  • Token link still works; no-slug cards unchanged. Collisions + reserved paths handled gracefully.
  • Traefik fallthrough config in place so app paths win and unknown paths hit Shlink.
  • go build/vet/test green; e2e verified against msupabase as postgres; test data + test slug cleaned up.
  • Commit link on this issue; otto-head merges, wires Shlink domain + Traefik routing, redeploys post.msbls.de, reports to mai/otto-wa.
## Context m wants to assign a **custom, memorable URL** to a postcard instead of (or alongside) the unguessable `/c/{token}` link — e.g. `post.msbls.de/sardinien2026` — and set it comfortably while composing. He explicitly named **Shlink**. ## Architecture decision (otto-head) **Reuse the EXISTING Shlink instance** on mlake (already multi-domain: s.flexsiebels.de, msbls.de, ts.msbls.de, dumusst.com; API at `https://s.flexsiebels.de/rest/v3`, key in env). Do **not** deploy a new Shlink. This mirrors how `m/hihlc` reuses it. **Routing model — the hihlc pattern (fallthrough on the same domain):** - `post.msbls.de/`, `/compose`, `/c/{token}`, `/static/*`, `/health` → the postcards Go app (high Traefik priority for these specific paths). - `post.msbls.de/{anything-else}` → **falls through to Shlink** (lower priority catch-all), which 302-redirects the custom slug to that card's `https://post.msbls.de/c/{token}`. - See `m/hihlc` README § “Domain-Settings (Shlink)” + its `hihlc-shlink.yml` Traefik dynamic config for the exact priority mechanism. post.msbls.de must be added to Shlink's domain list. **Compose-flow integration:** - In the compose form, add an optional “custom URL” field (opt-in). Mobile-first, integrated into the existing create-a-card flow. - On submit with a slug: the app first creates the card (gets its `/c/{token}`), then calls the Shlink API (`POST /rest/v3/short-urls`, `X-Api-Key`) with `customSlug=<slug>`, `domain=post.msbls.de`, `longUrl=https://post.msbls.de/c/{token}`. Store the slug on the postcard so it can be shown/managed. - Surface the resulting pretty URL to the sender (that's the “komfortabel” part). ## Constraints - **Coexists with the unguessable-token model** — custom slug is strictly opt-in; the privacy-default `/c/{token}` link always exists. A card with no slug behaves exactly like today. - **Collision handling** — Shlink rejects a taken slug (409); surface a friendly “schon vergeben” error and let the sender pick another. - **Reserved paths** — reject slugs that collide with the app's own routes (`compose`, `c`, `static`, `health`, empty/root) before calling Shlink, so a slug can never shadow the app. - German UI + Umlaute; renamable (read base from `BASE_URL`, don't hardcode post.msbls.de in app logic — the domain passed to Shlink should derive from `BASE_URL`'s host). ## DB - Add a nullable `slug` column (unique) to `postcards.postcards` via a new idempotent migration. **Schema-ownership reminder:** verify against msupabase as the **postgres** role (via pooler `100.99.98.203:6789`), NOT supabase_admin — otherwise the prod app can't write and the deploy crash-loops (this hit v1). ## Sequencing **Depends on #2 (v2 design/multi-image/flip-card)** — both touch the compose flow + handlers + templates. This issue is filed now to capture the design but should be picked up AFTER #2 is merged, to avoid two workers editing the same files. otto-head will assign mAi once #2 lands. ## Definition of done - Sender can optionally set a custom slug while composing; `post.msbls.de/{slug}` resolves (via Shlink) to the card. - Token link still works; no-slug cards unchanged. Collisions + reserved paths handled gracefully. - Traefik fallthrough config in place so app paths win and unknown paths hit Shlink. - `go build`/`vet`/`test` green; e2e verified against msupabase as postgres; test data + test slug cleaned up. - Commit link on this issue; otto-head merges, wires Shlink domain + Traefik routing, redeploys post.msbls.de, reports to mai/otto-wa.
mAi self-assigned this 2026-06-15 16:34:33 +00:00
Author
Collaborator

Optionaler, merkbarer Slug pro Karte: post.msbls.de/<slug> → Shlink → /c/{token} der Karte. Nutzt die bestehende, geteilte Shlink-Instanz (s.flexsiebels.de), kein neuer Shlink — analog m/hihlc.

App-Verhalten:

  • Opt-in im Compose-Flow: Feld „Wunsch-Adresse“ mit Domain-Prefix (post.msbls.de/), mobile-first, deutsch. Erscheint nur wenn SHLINK_API_KEY gesetzt ist — ohne Key ist das Feature aus und jede Karte funktioniert weiter über /c/{token}. Karten ohne Slug sind unverändert.
  • Reihenfolge: Der Slug wird vor dem Persistieren der Karte bei Shlink reserviert. Dadurch hinterlässt eine Kollision keine Waisen-Karte; schlägt danach etwas fehl, wird der Slug per DeleteShortURL zurückgerollt.
  • Kollision (Slug vergeben): freundliche Meldung „… ist leider schon vergeben“, Formular wird mit den eingegebenen Werten neu gerendert → Absender wählt einfach einen anderen Slug. Hinweis: das reale Shlink v3 antwortet hier mit HTTP 400 + non-unique-slug, nicht 409 — der Client erkennt beide.
  • Reservierte Pfade: compose, c, static, health, uploads werden (plus Format-Regeln: Kleinbuchstaben/Ziffern/Bindestriche, 2–64, kein führender/abschließender Bindestrich) vor jedem Seiteneffekt geprüft → ein Slug kann nie eine App-Route überschatten.
  • Renamebar: SHLINK_DOMAIN leitet sich aus dem Host von BASE_URL ab; nichts hardcoded post.msbls.de.
  • Pretty-URL wird dem Absender im Share-Banner angezeigt (das „komfortabel“).

DB: Idempotente Migration 003_slug_audience.sql — nullable, unique slug (Partial-Index nur über Non-NULL) + audience. ALTER behält den Table-Owner (postgres), additiv, bricht die laufende alte Prod-Instanz nicht (alte Binaries selektieren/inserten die neuen Spalten nicht).

Verifikation (e2e gegen msupabase als postgres, local storage → Prod-Bucket unangetastet):

  • Migration sauber angewandt: slug+audience Spalten + postcards_slug_key Index vorhanden.
  • Karte mit Slug → Pretty-URL angezeigt, Slug gespeichert, Shlink-Short-URL angelegt (post.msbls.de/<slug>).
  • Kollision → Status 400 + „schon vergeben“ + keine Waisen-Karte (genau 1 Zeile für den Slug).
  • Reservierter Slug compose → „reserviert“.
  • Karte ohne Slug → unverändert, slug IS NULL.
  • Test-Slug + Zeilen wieder gelöscht.
  • go build/vet/test/gofmt grün (inkl. Unit-Tests für Slug-Validierung, Shlink-Client 400/409, Domain-Ableitung; Template-Render-Tests).

Deploy-Artefakte für otto-head: deploy/traefik/postcards-app.yml (App-Pfade Priority 100) + postcards-shlink.yml (Catch-all Priority 1) + deploy/README.md (Env, Container-Name-Platzhalter, Shlink-Domain, „kein Dokploy-UI-Domain für post.msbls.de“-Hinweis).

Sequencing-Hinweis: Gemeinsam mit #4 gebaut (siehe dortigen Kommentar) und auf #2 aufbauend, plus enthält den separaten #5-Fix (Commit 39f048a).

Commit: https://mgit.msbls.de/m/postcards/commit/2f0c9b6
Branch: mai/hermes/issue-3-postcards-custom

Noch offen (otto-head): mergen, SHLINK_API_KEY in Dokploy setzen, Traefik-Dateien nach mlake kopieren (Container-Namen eintragen), post.msbls.de redeployen.

## #3 umgesetzt — Custom Memorable URLs via Shlink Optionaler, merkbarer Slug pro Karte: `post.msbls.de/<slug>` → Shlink → `/c/{token}` der Karte. Nutzt die **bestehende, geteilte** Shlink-Instanz (`s.flexsiebels.de`), kein neuer Shlink — analog `m/hihlc`. **App-Verhalten:** - Opt-in im Compose-Flow: Feld „Wunsch-Adresse“ mit Domain-Prefix (`post.msbls.de/`), mobile-first, deutsch. Erscheint nur wenn `SHLINK_API_KEY` gesetzt ist — ohne Key ist das Feature aus und jede Karte funktioniert weiter über `/c/{token}`. Karten ohne Slug sind unverändert. - **Reihenfolge:** Der Slug wird **vor** dem Persistieren der Karte bei Shlink reserviert. Dadurch hinterlässt eine Kollision **keine Waisen-Karte**; schlägt danach etwas fehl, wird der Slug per `DeleteShortURL` zurückgerollt. - **Kollision (Slug vergeben):** freundliche Meldung „… ist leider schon vergeben“, Formular wird mit den eingegebenen Werten neu gerendert → Absender wählt einfach einen anderen Slug. Hinweis: das reale Shlink v3 antwortet hier mit **HTTP 400 + `non-unique-slug`**, nicht 409 — der Client erkennt beide. - **Reservierte Pfade:** `compose`, `c`, `static`, `health`, `uploads` werden (plus Format-Regeln: Kleinbuchstaben/Ziffern/Bindestriche, 2–64, kein führender/abschließender Bindestrich) **vor** jedem Seiteneffekt geprüft → ein Slug kann nie eine App-Route überschatten. - **Renamebar:** `SHLINK_DOMAIN` leitet sich aus dem Host von `BASE_URL` ab; nichts hardcoded `post.msbls.de`. - Pretty-URL wird dem Absender im Share-Banner angezeigt (das „komfortabel“). **DB:** Idempotente Migration `003_slug_audience.sql` — nullable, unique `slug` (Partial-Index nur über Non-NULL) + `audience`. `ALTER` behält den Table-Owner (`postgres`), additiv, **bricht die laufende alte Prod-Instanz nicht** (alte Binaries selektieren/inserten die neuen Spalten nicht). **Verifikation (e2e gegen msupabase als `postgres`, local storage → Prod-Bucket unangetastet):** - Migration sauber angewandt: `slug`+`audience` Spalten + `postcards_slug_key` Index vorhanden. - Karte mit Slug → Pretty-URL angezeigt, Slug gespeichert, Shlink-Short-URL angelegt (`post.msbls.de/<slug>`). - Kollision → Status 400 + „schon vergeben“ + **keine** Waisen-Karte (genau 1 Zeile für den Slug). - Reservierter Slug `compose` → „reserviert“. - Karte ohne Slug → unverändert, `slug IS NULL`. - Test-Slug + Zeilen wieder gelöscht. - `go build`/`vet`/`test`/`gofmt` grün (inkl. Unit-Tests für Slug-Validierung, Shlink-Client 400/409, Domain-Ableitung; Template-Render-Tests). **Deploy-Artefakte für otto-head:** `deploy/traefik/postcards-app.yml` (App-Pfade Priority 100) + `postcards-shlink.yml` (Catch-all Priority 1) + `deploy/README.md` (Env, Container-Name-Platzhalter, Shlink-Domain, „kein Dokploy-UI-Domain für post.msbls.de“-Hinweis). **Sequencing-Hinweis:** Gemeinsam mit #4 gebaut (siehe dortigen Kommentar) und auf #2 aufbauend, plus enthält den separaten #5-Fix (Commit `39f048a`). Commit: https://mgit.msbls.de/m/postcards/commit/2f0c9b6 Branch: `mai/hermes/issue-3-postcards-custom` Noch offen (otto-head): mergen, `SHLINK_API_KEY` in Dokploy setzen, Traefik-Dateien nach mlake kopieren (Container-Namen eintragen), post.msbls.de redeployen.
Author
Collaborator

#3 live on https://post.msbls.de

Custom memorable URLs work end-to-end: e.g. post.msbls.de/sardinien2026 → Shlink → the card's /c/{token}. Verified live (created a slug card, hit the pretty URL, got 302 → 200 on the card), then cleaned up the test slug + card.

App code merged in 408b276 (was dormant until the key/routing landed). Infra wired by otto-head:

  • SHLINK_API_KEY (+ SHLINK_API_URL, SHLINK_DOMAIN=post.msbls.de) set on the Dokploy app; reuses the existing shared Shlink (s.flexsiebels.de), no new instance.
  • Removed the Dokploy-managed post.msbls.de domain (its Host-only router would swallow slugs) and replaced it with file-based Traefik routing: postcards-app.yml (app paths, priority 100) + postcards-shlink.yml (catch-all → shlink:8080).

Gotcha worth recording: the shlink catch-all initially 404'd because a global maintenance router (HostRegexp(\^.+$`)`, priority 1) tied with my catch-all (also priority 1) and won. Fix: bump the postcards-shlink router to priority 50 (above maintenance's 1, below the app's 100). Slug routing worked immediately after.

App paths (/, /compose, /c/, /static/, /health) still win via the priority-100 router. Not closing — m closes.

## ✅ #3 live on https://post.msbls.de Custom memorable URLs work end-to-end: e.g. `post.msbls.de/sardinien2026` → Shlink → the card's `/c/{token}`. Verified live (created a slug card, hit the pretty URL, got 302 → 200 on the card), then cleaned up the test slug + card. **App code** merged in `408b276` (was dormant until the key/routing landed). **Infra wired by otto-head:** - `SHLINK_API_KEY` (+ `SHLINK_API_URL`, `SHLINK_DOMAIN=post.msbls.de`) set on the Dokploy app; reuses the existing shared Shlink (`s.flexsiebels.de`), no new instance. - Removed the Dokploy-managed `post.msbls.de` domain (its Host-only router would swallow slugs) and replaced it with file-based Traefik routing: `postcards-app.yml` (app paths, priority 100) + `postcards-shlink.yml` (catch-all → `shlink:8080`). **Gotcha worth recording:** the shlink catch-all initially 404'd because a global maintenance router (`HostRegexp(\`^.+$\`)`, priority **1**) tied with my catch-all (also priority 1) and won. Fix: bump the postcards-shlink router to **priority 50** (above maintenance's 1, below the app's 100). Slug routing worked immediately after. App paths (`/`, `/compose`, `/c/`, `/static/`, `/health`) still win via the priority-100 router. Not closing — m closes.
mAi closed this issue 2026-06-23 15:48:01 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/postcards#3
No description provided.