No description
  • Go 80.4%
  • HTML 7.7%
  • PLpgSQL 4.1%
  • JavaScript 3%
  • CSS 2.1%
  • Other 2.7%
Find a file
Robert Kawecki da64348a81 Show a notice when viewing your own profile
Replaces the "Send message" link with "This is your profile." when
the viewer and the profile subject are the same person, in both
English and Polish locales.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:00:50 +02:00
cmd/forum Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
deploy Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
internal Show a notice when viewing your own profile 2026-08-11 13:00:50 +02:00
scripts Fix scripts/benchmark.sh for the TOML-config migration 2026-08-11 12:36:40 +02:00
.gitignore Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
Containerfile add source bundling 2026-07-12 21:51:05 +02:00
forum.example.toml Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
go.mod Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
go.sum Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
INSTALL.md Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
LICENSE release under CC0 2026-07-12 22:10:43 +02:00
Makefile Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
README.md Replace environment-variable config with a TOML file 2026-08-02 16:46:19 +02:00
sqlc.yaml Notifications, DMs, ops tooling; make content alerts opt-in per category 2026-07-12 11:47:47 +02:00

forum

Self-hostable discussion board for neighborhood communities — a replacement for Facebook groups. Server-rendered Go application with PostgreSQL as the only infrastructure dependency.

Design goals

  • One static binary + PostgreSQL (with the pgmq extension). Nothing else.
  • Works without JavaScript; JS only progressively enhances. Installable as a PWA.
  • Notifications first: web push and email digests are the core feature — members don't scroll the site all day, the site comes to them.
  • Private, invite-only communities with an invite-token economy (admins grant tokens; 1 invite = 1 token; revoked/expired invites refund).
  • Multi-tenant: one deployment hosts several communities, resolved by URL path (/osiedle/...) or by host. Accounts are global, profiles per community.
  • EU/GDPR by design: data export, account erasure, EXIF stripping, no tracking, one-click unsubscribe, Polish-first UI (pl/en).

Quick start

Requirements: Go 1.26+, PostgreSQL 18, and SMTP credentials (any EU transactional provider; Mailpit in development). Postgres needs two one-time additions — the pgmq extension and a Polish full-text search configuration. On a server, Bare-metal PostgreSQL (Debian) walks through both in five short steps; for development, the deploy/db/Containerfile image comes with both baked in.

Fast path — interactive wizard (recommended):

make build
./forum setup            # prompts for DB, community, admin invite in one go
./forum serve            # HTTP server
./forum worker           # notification worker (separate process)

forum setup walks through database configuration, applies migrations, generates VAPID keys, bootstraps the first community and category, and prints an admin invitation link. Start the two processes and you're done.

Manual path:

cp forum.example.toml forum.toml   # set database.url, smtp, etc.
make build
./forum migrate                 # apply schema migrations (run on every deploy)
./forum admin gen-vapid         # once; put the keys in forum.toml's [vapid] table
./forum serve                   # HTTP server
./forum worker                  # notification worker (separate process)

Bootstrap the first community and its admin:

./forum admin create-community -slug osiedle -name "Nasze Osiedle"
./forum admin create-category -community osiedle -slug ogolne -name "Ogólne"
./forum admin invite -community osiedle -email you@example.eu -role admin
# open the printed accept link, pick a display name — you're in

From there everything happens in the browser: admins grant invite tokens on the Members page, members invite neighbors, moderators handle the report queue.

Architecture

  • cmd/forum — one binary, four modes: serve (HTTP), worker (notifications, digests, maintenance), migrate, admin (operator CLI).
  • internal/web — HTTP controllers, embedded templates and static files. Every mutation is a plain form POST with CSRF; JS (embedded, no build step) only enhances. Strict same-origin CSP.
  • internal/db — the single data-access layer: sqlc-generated queries over pgx, tern migrations (embedded), and hand-written pgmq helpers. Every app table lives in its own forum Postgres schema (not public), created and put on the database's default search_path by db.Migrate's ensureSchema before any migration runs, so migration files and queries never need to schema-qualify a table name. forum reset drops and recreates only that schema; the Polish full-text search baseline (tsearch schema, see Search) and the pgmq extension's own schema are untouched by either.
  • Use-case packages (accounts, invites, content, mod, gdpr, media, notify) coordinate DAL calls; operations run in one transaction per request.
  • Transactional outbox: creating a thread/post enqueues an event onto pgmq in the same transaction. The worker fans events out into per-recipient web-push jobs, delivers them (pruning dead subscriptions), and builds daily/weekly email digests directly from content, with per-profile hour/timezone and exactly-once claims. RFC 8058 one-click unsubscribe on every digest.
  • Invite-token economy correctness: conditional decrement (balance never negative), guarded status transitions (no double accept/revoke), and a partial unique index making refunds exactly-once — verified by concurrency tests.
  • Attachments: images are re-encoded (EXIF/GPS stripped), downscaled, thumbnailed, stored per community behind a BlobStore interface — local filesystem (FSStore) by default, or S3-compatible (S3Store) for multi-replica deployments.

Threads and posts are searchable (GET /search), ranked by relevance, using native Postgres full-text search — no separate search service. Matching is community-scoped and stemmed for Polish, so an inflected query like samochody also finds posts containing samochód.

Stock PostgreSQL (through 18) ships no Polish text-search configuration. PostgreSQL 19 includes one built in (config name polish) — but this repo currently targets PostgreSQL 18, so the equivalent is built from the Debian hunspell dictionary. Three pieces, wherever the database runs:

  • Dictionary files in the server's tsearch_data directory: the Debian hunspell-pl files transcoded from ISO-8859-2 to UTF-8 (Postgres's ispell reader does not transcode on its own — the classic gotcha, also called out in the article above), plus a small stopword list (deploy/db/tsearch_data/polish.stop).
  • deploy/db/init-polish-fts.sql — registers those files as the polish configuration, in a dedicated tsearch schema that survives forum reset (the script's header comments explain the details). Run by hand in production (Bare-metal PostgreSQL step 4); the dev image runs it automatically at first boot.
  • The migration (internal/db/migrations/001_init.sql, search section) then builds GIN indexes against that configuration — which is why the configuration must exist before the first forum migrate.

For development, build the image before starting a fresh dev database, then migrate as usual:

podman build -t localhost/forum-db:latest -f deploy/db/Containerfile deploy/db

Useful checks when working on search (from the article above):

\dF polish                                    -- confirm the configuration exists
select * from ts_debug('polish', 'Sprzedam stary samochód.');   -- inspect tokenization
select to_tsvector('polish', 'Sprzedam stary samochód.');       -- quick stemming smoke test

The hand-built configuration is deliberately named polish so PostgreSQL 19 makes all of this disappear: skip the dictionary files and the init script and its built-in configuration satisfies the same indexes and queries, which reference the configuration only by name.

Development

make test              # unit tests
TEST_DATABASE_URL=postgres://forum:forum@localhost:5432/forum_test make test
                       # + integration tests (needs pgmq-enabled Postgres)
make check             # vet + tests + govulncheck; run before pushing
make vulncheck         # just the vulnerability scan
make sqlc              # regenerate the DAL after editing queries/migrations
go run ./scripts/genicons   # regenerate PWA icons

There is no CI, so make check is the pre-push gate — export TEST_DATABASE_URL first or the integration tests skip themselves and it tells you far less than it appears to (it warns when they will).

Set DEV=1 for pretty console logs. Without SMTP configured, emails are logged instead of sent — magic-link URLs appear in the log.

Deployment

A production deployment is the static forum binary (under systemd or in a container) behind a TLS-terminating reverse proxy, talking to PostgreSQL — typically distro-packaged, prepared once as described in Bare-metal PostgreSQL (Debian) below. The repo also ships container tooling:

  • Containerfile — static binary in a distroless image.
  • deploy/db/Containerfile — Postgres with pgmq and Polish full-text search (see Search) for development and container-only setups. In production, admins typically run distro-packaged PostgreSQL instead — see Bare-metal PostgreSQL (Debian).
  • deploy/podman-pod.yaml — single-host Podman pod: the database image above + web + worker (podman kube play).
  • deploy/k8s.yaml — Kubernetes: web/worker Deployments, migrate Job, attachments PVC.

Run migrate on every deploy before starting serve/worker. Back up Postgres (pg_dump/PITR) and the attachments directory — recipes in Backup and restore.

Bare-metal PostgreSQL (Debian)

The expected production setup: PostgreSQL installed from distribution packages, managed like any other system service. The app needs exactly two things stock Postgres doesn't ship — the pgmq extension and a Polish text search configuration; everything else is plain Postgres. (The deploy/db container image exists to automate these same two steps for development, not as a deployment artifact.)

1. PostgreSQL 18. Debian 13 packages PostgreSQL 17; this project targets 18, so add the PGDG repository:

sudo apt install postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install postgresql-18

2. pgmq. Since 1.x pgmq is a pure-SQL extension — nothing to compile, make install just copies the control and SQL files into the server's share directory:

sudo apt install git make postgresql-server-dev-18   # pg_config + PGXS
git clone --depth 1 --branch v1.12.0 https://github.com/pgmq/pgmq.git
sudo make -C pgmq/pgmq-extension install PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config

(Pick the latest release tag; pgmq supports Postgres 1418.)

3. Polish dictionary files (the procedure from depesz's article; see Search for why the app needs them). Debian packages the hunspell dictionary, just in the wrong encoding for Postgres — so convert to UTF-8 on the way in and rewrite the affix file's SET declaration to match, and the head -1 check at the end confirms it worked:

sudo apt install hunspell-pl
SHAREDIR=$(/usr/lib/postgresql/18/bin/pg_config --sharedir)
sudo sh -c "iconv -f ISO8859-2 -t UTF-8 /usr/share/hunspell/pl_PL.dic \
    > $SHAREDIR/tsearch_data/polish.dict"
sudo sh -c "iconv -f ISO8859-2 -t UTF-8 /usr/share/hunspell/pl_PL.aff \
    | sed 's/^SET ISO8859-2/SET UTF-8/' > $SHAREDIR/tsearch_data/polish.affix"
sudo install -m 644 deploy/db/tsearch_data/polish.stop "$SHAREDIR/tsearch_data/"
head -1 "$SHAREDIR/tsearch_data/polish.affix"   # must print: SET UTF-8

4. Database, role, extension, FTS configuration. The SQL the container image runs at first boot (deploy/db/init-polish-fts.sql) is the same script to run by hand here. create extension is superuser-only, so do it now — migration 001's create extension if not exists pgmq then no-ops for the app user:

sudo -u postgres createuser forum -P
sudo -u postgres createdb -O forum forum
sudo -u postgres psql -d forum -c 'create extension pgmq'
sudo -u postgres psql -d forum -f deploy/db/init-polish-fts.sql

5. Verify, then hand over to the app — set database.url in forum.toml and run forum migrate as usual:

sudo -u postgres psql -d forum \
    -c "select to_tsvector('polish', 'Nowy samochód i stare samochody')"
# expect both inflected forms stemmed to one shared 'samochód' lexeme

That's the whole special part — done once, before the first forum migrate (migration 010 builds indexes against the polish configuration), and from here on it's ordinary Postgres administration. Minor apt upgrades are safe: dpkg leaves the hand-placed tsearch_data/polish.* and pgmq extension files alone. On a major version upgrade (18 → 19), redo step 2 against the new version's pg_config — and drop step 3 and the init script entirely, because PostgreSQL 19 ships its own polish configuration (see Search). One caveat: managed databases (RDS and friends) don't let you place files in tsearch_data, so they need PostgreSQL 19+.

Reverse proxy. The server speaks plain HTTP and expects TLS to be terminated by a fronting proxy (keep HTTPS=1 so cookies are Secure and HSTS is sent). Enable response compression at the proxy — the app deliberately does not gzip in-process: encode zstd gzip (Caddy) or gzip on; gzip_types text/html text/css application/javascript image/svg+xml; (nginx). Set PROXY_HEADER (e.g. X-Forwarded-For) only when the proxy always overwrites it.

Database pool. DB_MAX_CONNS (default 16) and DB_MIN_CONNS (default 2) size each process's pool — serve and worker each hold their own, so budget Postgres max_connections accordingly. DB_STATEMENT_TIMEOUT (default 30s, 0 disables) caps any single query server-side; migrate/reset ignore it so long backfills can run.

Custom snippets. Drop Markdown files into custom.d/ (next to the binary) to customize the site without touching code. Recognized files:

  • header.md — injected into the site header on every page (after the nav). Empty by default.
  • footer.md — injected into the site footer on every page (after the legal links). Empty by default.
  • privacy.md — replaces the built-in /privacy placeholder.
  • terms.md — replaces the built-in /terms placeholder.
  • flyer-intro.md — replaces the built-in flyer introduction for forum admin gen-flyers.

Files are rendered as Markdown (sanitized) once at startup; restart serve after editing. Missing files fall back to built-in defaults. Unknown files are ignored. The directory is gitignored.

Operational notes

  • iOS web push requires the PWA to be installed to the home screen — the email digest is the universal fallback channel, which is why it is first-class.
  • Multiple worker processes are safe (pgmq visibility timeouts, guarded digest claims); multiple serve processes are safe behind a load balancer (sessions live in Postgres). Note that rate limits are per-process, so N replicas multiply every abuse limit by N.
  • Memory. A serve process idles around 20-30 MiB. Upload decoding is the main pressure — MaxDecodePixels (24 MP) bounds the decompression buffer to ~72 MiB. Budget 128-256 Mi per serve replica to cover concurrent uploads. The worker is lighter and sits comfortably at 32-64 Mi.
  • /healthz (liveness) and /readyz (DB reachability) are wired for probes.
  • Every response carries an X-Request-Id header, and every request log line (including panic reports) carries the same request_id field — when a user reports an error page, ask for the ID and grep the logs.

Metrics

Set METRICS_TOKEN and forum serve exposes GET /metrics in Prometheus text format; without the token the endpoint does not exist (scrape output reveals activity levels of a private community, so it is never open). Scrapes authenticate with the token as a bearer credential:

scrape_configs:
  - job_name: forum
    scrape_interval: 30s
    authorization:
      credentials: <METRICS_TOKEN>
    static_configs:
      - targets: ["forum.example:8080"]

Exported:

  • forum_http_requests_total{class="2xx".."5xx"}, forum_http_request_duration_seconds (histogram) — this process's request traffic.
  • forum_queue_depth{queue=...}, forum_queue_oldest_message_age_seconds{queue=...} — pgmq health. A growing oldest-age means the worker is down or stuck; this is the first thing to alert on (e.g. > 600 for auth_email).
  • forum_queue_archived_messages{queue=...} — poison messages parked in the pgmq archive awaiting inspection. Alert on > 0.
  • forum_bus_live_peers — other serve/worker instances that answered a real bus ping (the same fan-out as forum admin ping). With N processes deployed, alert when it stays below N1. The ping waits 1s, so budget scrape timeouts for it.
  • forum_metrics_db_up — 0 when the scrape could not collect the queue gauges from Postgres.

Queue gauges are read from the shared database, so scraping one serve replica covers them; HTTP metrics are per-process, so scrape every replica.

Backup and restore

Postgres holds everything — content, sessions, invites, and the pgmq mail queues — so the database backup is the whole disaster story; the blob store (attachments) is the only other stateful piece.

# Logical dump (-Fc enables pg_restore -j and selective restore)
sudo -u postgres pg_dump -Fc forum > forum-$(date +%F).dump
# (container dev DB: podman exec pgmq-postgres pg_dump -U postgres -Fc forum)
# Attachments (fs mode; S3 mode: use the provider's bucket replication)
tar -C data -czf attachments-$(date +%F).tar.gz attachments

Verify once that the dump actually contains the queue tables (pg_restore -l forum-*.dump | grep q_auth_email): pgmq creates them via pgmq.create(), and on old pgmq versions they were members of the extension, which pg_dump silently skips.

Restore into an empty database on a server with the pgmq extension files and Polish dictionaries installed (steps 23 of Bare-metal PostgreSQL (Debian), or the deploy/db image), then reconcile:

createdb forum && pg_restore --no-owner -d forum forum-<date>.dump
forum migrate   # idempotent; fills any gap between dump and binary

Order matters for consistency: dump the database before snapshotting blobs. A blob store newer than the database leaves orphan files (harmless); the reverse leaves attachment rows whose downloads 404. Take backups from a cron job, keep at least one copy off the host, and restore into a scratch database periodically — a backup that has never been restored is a hope, not a backup. For point-in-time recovery put WAL archiving (e.g. wal-g, pgBackRest) in front of the same database; nothing in the app needs special handling for it.

Multi-host deployments

Attachments are served through the app process — never via presigned URLs or a public bucket — to keep private-community content membership-gated. When running more than one serve replica, every replica must see the same blob store.

Option 1 — Shared filesystem (simplest, no code change). Mount a single directory at ATTACHMENTS_DIR on every replica. Keep ATTACHMENT_STORE=fs (the default). Common approaches:

  • Kubernetes: provision a ReadWriteMany PersistentVolume. This requires a StorageClass whose CSI driver supports multi-node read-write, e.g. the NFS subdir provisioner (nfs-subdir-external- provisioner), AWS EFS CSI driver, Azure Files, or CephFS. The built-in k8s manifest (deploy/k8s.yaml) already declares accessModes: [ReadWriteMany] — only the StorageClass needs to match.
  • Bare metal / podman: point ATTACHMENTS_DIR at an NFS mount (or any network filesystem) shared between hosts. Podman kube play volumes also support NFS volumes directly.
  • S3-as-filesystem: tools like s3fs-fuse or rclone mount can expose an S3 bucket as a local directory, combining the cheap durability of S3 with simple POSIX access — but test atomic-rename behavior carefully; not all FUSE drivers support it.

The atomic-rename guarantee in FSStore.Save holds only within one real filesystem; NFSv4 satisfies this, most FUSE drivers do not. All replicas read and write the same directory; failure mode is shared-disk, so choose a reliable backing store.

Option 2 — S3-compatible backend (true horizontal scale). Set ATTACHMENT_STORE=s3 and the S3_* environment variables. Works with any S3-compatible service: AWS S3, Cloudflare R2, OVH Object Storage, RustFS, MinIO, etc. Buckets stay completely private — the server holds the only access credentials. Atomicity comes from single-PUT semantics; reads stream directly from the object store. No filesystem to share or mount; each replica is independently stateless in terms of file I/O. There is no /readyz blob ping, so a bucket outage only 404s specific downloads without taking a replica out of the LB rotation.

Privacy & access. In both modes the handler enforces community membership before opening any blob (internal/web/handlers_media.go:97). The app stays in the data path — bytes flow from the store through the server to the client. Cache-Control headers are private so proxies never share across users. Membership revocation takes effect immediately because there is no durable URL the user could hold independently.

Migrating between FS and S3

forum admin copy-blobs -from <fs|s3> -to <fs|s3> copies every attachment and avatar between backends using the configured ATTACHMENTS_DIR and S3_* values. Steps for an fss3 migration:

  1. Set S3_* in the environment alongside the existing ATTACHMENTS_DIR.
  2. Run forum admin copy-blobs -from fs -to s3 -confirm.
  3. Change ATTACHMENT_STORE=s3, restart serve.
  4. Verify uploads and downloads work before removing the old filesystem.

The reverse direction (s3fs) works the same way. The command copies every attachment row (full + thumbnail) and every profile avatar; it is idempotent — retrying after a partial failure skips objects already present in the destination.