2026-06-15 15:02:55 +00:00

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
  • token is 128 bits of crypto/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.

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.

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.

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. See deploy/README.md for 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 as validUntil, 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-card visit_count is incremented atomically on each counted view; the increment's WHERE visit_count < max_visits guard 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 as maxVisits, 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, and Secure over 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_hash column — 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 postcards Supabase bucket (and the local backend serves them unauthenticated from /uploads/), so the object URL itself is the access control. Each media object therefore gets its own fresh ~128-bit crypto/rand filename 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_key is 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.

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_PASSWORD to enable it. It is verified server-side; on success a short-lived (12 h) HMAC-signed session cookie is set (HttpOnly, SameSite=Lax, Secure on 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_PASSWORD empty ⇒ the feature is disabled entirely: the /admin routes are not even registered, so /admin is a 404 and 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 via embed.
  • Postgres on the shared msupabase instance, dedicated postcards schema. The schema + tables are created automatically on startup (migrations/*.sql, idempotent).
  • Supabase Storage for uploaded images (bucket postcards, public; created automatically if missing). A local filesystem 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)

  1. Create a Dokploy Compose app pointing at this repo (docker-compose.yml).
  2. Set the environment (Dokploy → Environment), at minimum:
    • BASE_URL — the public URL (e.g. https://grues.se)
    • DATABASE_URL — msupabase Postgres connection string
    • STORAGE_BACKEND=supabase
    • SUPABASE_URL, SUPABASE_SERVICE_KEY
  3. Deploy. On first start the app creates the postcards schema, its tables, and the public postcards storage bucket.
  4. Domain binding (when the name is chosen): set POSTCARDS_DOMAIN and uncomment the Traefik labels block in docker-compose.yml, then update BASE_URL to match. Nothing else changes.

Notes

  • German UI copy uses proper Umlaute (ä/ö/ü/ß).
  • The compose and postcard pages are noindex; 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.
Description
Virtual postcard web app — create a shareable postcard (text + images) with a unique URL per recipient. Mobile-first. Provisional name; final domain TBD (grues.se / gruessdich.com / winkepost.de).
Readme MIT 713 KiB
Languages
Go 69.6%
HTML 21.6%
CSS 8.5%
Dockerfile 0.3%