postcards
Digital greeting-card web app (Grußkarte). A sender composes a card (text message + images + video) for any occasion — birthday, Christmas, an invitation, a thank-you, get-well, or just because — and gets a unique, unguessable shareable URL. Opening that URL renders the card. Mobile-first.
The home domain (sendmy.cards) is read from BASE_URL, so a rename is a
one-line change. No domain is hardcoded anywhere. (The internal schema, bucket
and module are still named postcards for continuity — only the user-facing
framing is the broader Grußkarte.)
Flow
landing (/) → compose (/compose) → persist + upload media
→ redirect to /c/{token} → rendered card
tokenis 128 bits ofcrypto/rand, base64url — unguessable, not sequential.- Recipients need no account; the secret link is the access control.
Occasion (card type)
- Occasion (chosen at compose): one of a preset set — Allgemein, Geburtstag, Weihnachten, Einladung, Dankeschön, Gute Besserung, Liebe, Postkarte — or Eigener Anlass, a free-text label the sender types themselves. The occasion drives the card's copy (title/badge) and pre-selects a sensible default colour world (the sender can still override the theme). It is persisted and reflected on the rendered card as a small badge. Allgemein is the neutral default, so cards composed before this feature (and any card left on the default) render with no badge, exactly as before.
Image source — own media or ready-made gallery
At compose the image source is a choice (a toggle above the media field):
- Eigene Medien — upload your own photos/video (the original behaviour, now one option among two).
- Aus Galerie wählen — pick a finished, ready-made image from a curated gallery, automatically filtered by the chosen occasion. An asset tagged Allgemein is the neutral catch-all shown under every occasion. The sender's message text is independent of the picture in both modes.
The two sources are exclusive per card (the toggle); the live preview (#15)
reflects a gallery pick the same way it reflects an upload. When a gallery image
is picked the server copies its bytes into a fresh per-card object
(<token>/<rand>.<ext>, the same unguessable scheme as an upload — #21), rather
than referencing the shared catalogue object. So a gallery pick is
indistinguishable from an upload downstream — layouts, the flip card, admin,
cleanup, and per-card deletion all work unchanged — and deleting a card never
touches the shared gallery asset (and deleting a gallery asset never breaks
cards already composed from it).
The gallery is source-agnostic: each asset records a provenance (imagen,
upload, or stock). The primary feed is the in-house ImaGen (FLUX)
generator, run offline (never in the compose request path) — see
Seeding the gallery below — and an admin can always hand-upload images too.
The whole gallery section only appears when at least one active asset exists;
with no assets (or no JavaScript) compose is exactly the plain upload form as
before.
Gallery admin
The password-gated admin (see Admin overview) gains /admin/gallery: a
manager listing every catalogue asset with its thumbnail, occasion tags, source,
and active flag. From there an admin can upload a new image (assigning
occasion tags + a title/credit), edit an asset's tags/title/credit, toggle
it active/hidden without deleting, and delete it (via a server-rendered
confirmation step, like the card delete). Deleting an asset removes only the
catalogue object — composed cards keep their copies.
Seeding the gallery
cmd/seed-gallery loads a small starter set of occasion-tagged ImaGen images
(embedded in the binary with a manifest) into the gallery_assets table and the
storage bucket, under the gallery/seed/ key prefix. It is idempotent
(upsert by storage key), reuses the app's config + migrations, and can run
before the app has ever started:
go run ./cmd/seed-gallery # uses the same env as the server (.env / Dokploy)
Ongoing curation then happens in /admin/gallery; re-running the seed only
refreshes the seeded set.
Audience & custom URLs
- Audience (chosen at compose): an eine Person (1:1, optional recipient name, unguessable-token privacy default) or zum Teilen (broadcast — no specific addressee; one link for many, no recipient line rendered).
- Custom memorable URL (optional, opt-in): in addition to
/c/{token}, a card can get a memorable slug, e.g.post.msbls.de/sardinien2026, served by the shared Shlink instance (302 → that card's/c/{token}). The token link always exists; a card without a slug behaves exactly as before. A broadcast card + a custom slug = one memorable link for everyone. Seedeploy/README.mdfor the Shlink + Traefik wiring. - Card expiry (optional, on any card — gültig bis a date): once the day
is over the card's own
/c/{token}stops serving content and shows a friendly „Diese Karte ist abgelaufen“ page (HTTP 410), enforced server-side before the password gate, so an expired card reads as expired regardless of any password. A card without an expiry behaves exactly as before. If the card also has a custom slug, the same moment is forwarded to Shlink asvalidUntil, so the pretty link and the card expire together. - Max-views cap (optional, on any card — max. N Aufrufe): once the card
has been opened N times its own
/c/{token}stops serving content and shows a friendly „Diese Karte ist nicht mehr verfügbar“ page (HTTP 410), enforced server-side before the password gate — like expiry, an exhausted card reads as exhausted regardless of any password. A per-cardvisit_countis incremented atomically on each counted view; the increment'sWHERE visit_count < max_visitsguard makes it race-free, so under concurrent hits exactly N views ever see content. What counts as a view: only a successful content render by a real visitor. Not counted: the password prompt / expired / exhausted pages (no content served),HEAD/prefetch, known bots and chat-app link-preview unfurlers (WhatsApp, Telegram, … — so a messenger preview doesn't burn the recipient's allotment), and the creator's own post-compose landing (?created=1). Ordinary reloads do count — max. N Aufrufe is taken literally. A card without a cap is never incremented and behaves exactly as before. If the card also has a custom slug, the same cap is forwarded to Shlink asmaxVisits, so the pretty link and the card exhaust together. (This supersedes the original slug-only cap, which was kept slug-level in #9/#14 because a card-level cap needs this stateful per-card counter rather than a stateless timestamp compare — see #18.) Duplicate slugs are first-come-first-served — Shlink rejects a taken slug and the sender is asked to pick another.
Password protection
- Optional password (chosen at compose, Passwort (optional)): if set, the
card is gated. Opening
/c/{token}then serves a password prompt instead of the card, and no card content (message, sender, recipient, occasion, or any media URL) is sent to the client until the password is verified server-side. A correct password sets a short-lived (12 h), HMAC-signed, per-card cookie (path-scoped to that card,HttpOnly,SameSite=Lax, andSecureover HTTPS) so a reload doesn't re-prompt; the signed payload binds the cookie to its own token, so it can never unlock a different card. The sender is auto-unlocked on creation (they just chose the password). A wrong password shows a friendly German error. Cards without a password behave exactly as before. The unguessable token stays the baseline; the password is an additional optional layer. - Storage: only a bcrypt hash (real KDF, per-hash random salt, embedded
cost) is stored in a nullable
password_hashcolumn — never the plaintext. bcrypt's 72-byte input limit is enforced with a friendly error rather than silent truncation. - Cookie signing key:
CARD_SESSION_SECRET(optional). When unset, a random per-process key is generated — secure, but unlock cookies don't survive a restart and wouldn't validate across multiple instances. Set a stable value for persistence or horizontal scaling. - Media objects — unguessable per-file names (#21). Uploaded images/videos
live in the public
postcardsSupabase bucket (and thelocalbackend serves them unauthenticated from/uploads/), so the object URL itself is the access control. Each media object therefore gets its own fresh ~128-bitcrypto/randfilename component — the key is<token>/<rand>.<ext>(videos:<token>/video/<rand>.<ext>), not the old sequential<token>/0.png. The<rand>is only revealed on the rendered card page, so for a password-gated card it is only handed out after the password is verified server-side. Knowing the card token alone no longer lets anyone reconstruct a media URL — page-gating plus an unguessable media URL together close the #11 media-leak gap pragmatically, with no private bucket and no proxy. The<token>/prefix is kept so admin-delete and per-card cleanup still match every object by token prefix.- Existing cards (composed before #21) keep their old, guessable
<token>/0.png-style keys:storage_keyis stored per row and drives the render URL, so old rows render unchanged, but their media URLs remain reconstructable from the token. A retroactive re-key would mean moving the storage objects and rewriting the rows; it is out of scope here (m's live cards are few/test). If full closure for old cards is wanted later, that migration is the follow-up. - This is unguessability, not authorization: the bucket is still public, so a viewer who has been given a media URL keeps it. The guarantee is that the card link alone no longer exposes the raw media.
- Existing cards (composed before #21) keep their old, guessable
Admin overview
A password-gated dashboard at /admin lists every card, newest first, for
oversight. Because the app has no user accounts (open compose, unguessable
per-card tokens), this all-cards view must be gated:
- Set
ADMIN_PASSWORDto enable it. It is verified server-side; on success a short-lived (12 h) HMAC-signed session cookie is set (HttpOnly,SameSite=Lax,Secureon https) using the same pattern and key as the per-card unlock cookie (internal/server/adminauth.go), domain-separated so an admin cookie can never unlock a card and vice versa. ADMIN_PASSWORDempty ⇒ the feature is disabled entirely: the/adminroutes are not even registered, so/adminis a404and the listing can never be exposed by accident in any environment.- The login gate ships no card data — nothing leaks before authentication.
- The list shows, per card: created date, occasion (+ custom label), recipient / sender, a message preview, layout, image/video counts, whether it is password-protected (never the hash), custom slug, expiry, visit cap (shown as views/cap, with an aufgebraucht flag once reached), audience, and a link to open the card. The list is paginated (50/page).
- Delete (row → confirmation page → POST) removes the card: its database row (image/video rows cascade), its stored media objects, and its custom Shlink slug. The confirmation is a server-rendered step, so the destructive action is never a single unconfirmed click and works without JavaScript.
In production, /admin must also be claimed by the app's Traefik router (a
PathPrefix(/admin) rule), otherwise the slug catch-all forwards it to Shlink —
see deploy/traefik/postcards-app.yml.
Stack
- Go (
net/http, stdlib router) — single static binary, templates + CSS embedded viaembed. - Postgres on the shared msupabase instance, dedicated
postcardsschema. The schema + tables are created automatically on startup (migrations/*.sql, idempotent). - Supabase Storage for uploaded images (bucket
postcards, public; created automatically if missing). Alocalfilesystem backend is available for development. - Dokploy on mlake (
docker-compose.yml).
Layout
cmd/server/ entrypoint (config load, graceful shutdown)
cmd/seed-gallery/ one-shot seeder for the ready-made gallery (embedded starter set)
internal/config/ env-driven configuration
internal/server/ HTTP handlers, routing, token generation
internal/postcard/ domain model + Postgres repository
internal/store/ storage abstraction: local + supabase backends
migrations/ embedded idempotent SQL + runner
web/ embedded templates (templates/) and assets (static/)
Run locally
Requires Go 1.25+ and a reachable Postgres.
cp .env.example .env
# edit .env — set DATABASE_URL, and either:
# STORAGE_BACKEND=local (no Supabase needed; images on disk)
# STORAGE_BACKEND=supabase + SUPABASE_URL + SUPABASE_SERVICE_KEY
go run ./cmd/server
# open http://localhost:8080
With STORAGE_BACKEND=local, images are written under LOCAL_UPLOAD_DIR
(default ./data/uploads) and served by the app at /uploads/....
Configuration
All configuration is environment-driven (see .env.example):
| Variable | Purpose | Default |
|---|---|---|
POSTCARDS_ADDR |
Listen address | :8080 |
BASE_URL |
Public origin for share links (no trailing slash) | http://localhost:8080 |
DATABASE_URL |
Postgres connection string | — (required) |
STORAGE_BACKEND |
supabase or local |
local |
SUPABASE_URL |
Supabase base URL (supabase backend) | — |
SUPABASE_SERVICE_KEY |
Service-role key (supabase backend) | — |
SUPABASE_BUCKET |
Storage bucket name | postcards |
LOCAL_UPLOAD_DIR |
Upload dir (local backend) | ./data/uploads |
MAX_UPLOAD_BYTES |
Max bytes per image | 10485760 (10 MiB) |
MAX_IMAGES |
Max images per postcard | 8 |
SHLINK_API_KEY |
Shlink API key; empty ⇒ custom-URL feature disabled | — |
SHLINK_API_URL |
Shlink REST base | https://s.flexsiebels.de/rest/v3 |
SHLINK_DOMAIN |
Short-URL domain | host of BASE_URL |
CARD_SESSION_SECRET |
HMAC key for unlock + admin session cookies; empty ⇒ random per-process | — |
ADMIN_PASSWORD |
Password gating /admin; empty ⇒ admin overview disabled (/admin is 404) |
— |
Deploy (Dokploy on mlake)
- Create a Dokploy Compose app pointing at this repo (
docker-compose.yml). - Set the environment (Dokploy → Environment), at minimum:
BASE_URL— the public URL (e.g.https://grues.se)DATABASE_URL— msupabase Postgres connection stringSTORAGE_BACKEND=supabaseSUPABASE_URL,SUPABASE_SERVICE_KEY
- Deploy. On first start the app creates the
postcardsschema, its tables, and the publicpostcardsstorage bucket. - Domain binding (when the name is chosen): set
POSTCARDS_DOMAINand uncomment the Traefiklabelsblock indocker-compose.yml, then updateBASE_URLto match. Nothing else changes.
Notes
- German UI copy uses proper Umlaute (ä/ö/ü/ß).
- The
composeand postcard pages arenoindex; secret links shouldn't be crawled. - The image public URL is derived at render time from the storage backend, so swapping backends or base URLs needs no data migration.