No description
  • Go 77.1%
  • templ 19.3%
  • Shell 2.7%
  • CSS 0.7%
  • Dockerfile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tim Hårek Andreassen 8f52786dc7
All checks were successful
ci / check (push) Successful in 2m10s
release / check (push) Successful in 1m57s
release / ci (push) Successful in 0s
release / image (push) Successful in 1m25s
Render /docs with Swagger UI instead of Stoplight Elements
2026-09-13 00:30:54 +02:00
.forgejo/workflows Show the release version in the page footer 2026-09-11 18:09:02 +02:00
cmd Add planned lists (secret shared gift-idea pools) 2026-09-12 21:32:43 +02:00
docs Share received lists with co-managers 2026-09-12 21:13:35 +02:00
internal Render /docs with Swagger UI instead of Stoplight Elements 2026-09-13 00:30:54 +02:00
scripts Add planned lists (secret shared gift-idea pools) 2026-09-12 21:32:43 +02:00
.air.toml Give share-link pages the same container padding as Base, and stop air excluding generated files so embedded template/CSS changes actually rebuild 2026-08-29 18:50:56 +02:00
.dockerignore Add Docker self-hosting and Forgejo CI/release workflows 2026-09-11 13:56:42 +02:00
.gitattributes Initial commit 2026-08-28 23:01:01 +02:00
.gitbump.toml build: Add git-bump config 2026-09-11 14:06:37 +02:00
.gitignore build: Ignore db stuff 2026-08-29 08:31:29 +02:00
.golangci.toml Initial commit 2026-08-28 23:01:01 +02:00
.templui.json Initial commit 2026-08-28 23:01:01 +02:00
.tokeignore dev: Add tokeignore 2026-08-29 19:19:29 +02:00
compose.yaml Add Docker self-hosting and Forgejo CI/release workflows 2026-09-11 13:56:42 +02:00
Dockerfile Show the release version in the page footer 2026-09-11 18:09:02 +02:00
go.mod Add OIDC login (Pocket ID) behind OIDC_ISSUER 2026-09-08 21:10:43 +02:00
go.sum Add OIDC login (Pocket ID) behind OIDC_ISSUER 2026-09-08 21:10:43 +02:00
go.tool.mod Initial commit 2026-08-28 23:01:01 +02:00
go.tool.sum Initial commit 2026-08-28 23:01:01 +02:00
LICENSE Add MIT license 2026-09-11 14:22:53 +02:00
mise.toml Initial commit 2026-08-28 23:01:01 +02:00
PLAN.md Add planned lists (secret shared gift-idea pools) 2026-09-12 21:32:43 +02:00
README.md Add planned lists (secret shared gift-idea pools) 2026-09-12 21:32:43 +02:00

Gavebord

Self-hosted wishlist ("gift table" in Norwegian). Go + templ + htmx, single binary, SQLite storage, embedded assets.

Features

  • Wishlists for yourself or for others (children, grandparents — no account needed)
  • Shared management: several accounts can manage one list with equal rights, so both parents keep the same child's list — and neither sees what the other claimed
  • Share with an existing account: pick a name and they get the list under "Shared with you" from their own login — view + claim, or edit rights
  • Items with name, price, URL, photo, notes; URL preview auto-fills image + price
  • Hidden claims: "I'm buying this" — the claimer is never shown to anyone. The list's owner sees only that an item is claimed; a co-manager sees nothing.
  • Close + triage: after the occasion, close a wishlist (claims freeze) and sort its items into the gaveprotokoll or a private backlog of future wants
  • Received gifts (gaveprotokoll): a per-occasion record of what a person actually received, co-manageable like a wishlist — both parents keep the same ledger, CSV export for thank-you cards, a person rollup across occasions
  • Planned lists: a secret gift-idea pool for one person, kept by the people who give to them — every participant adds and claims ("taken", never by whom), items get archived as Given, and the person it is for has no possible view
  • Secret viewer links (/s/{token}): read-mostly access + claiming, no account — or an edit link that grants manager rights to the account that signs in through it
  • JSON API (huma) with per-user bearer tokens, OpenAPI 3.1 spec at /openapi.json, browsable docs at /docs
  • Admin panel + a debug claims API (the only place claim identity exists)
  • Light/dark theme toggle in the nav, followed from the OS until you pick one
  • shadcn-style components, no node in the toolchain

Quick start

mise run build          # CGO_ENABLED=0 go build -o build/gavebord ./cmd/gavebord
DB_PATH=gavebord.db ./build/gavebord
# open http://localhost:8080 — the first registered user becomes admin

Or with demo data:

DB_PATH=demo.db go run ./cmd/seed
ADDR=:8080 DB_PATH=seed.db go run ./cmd/gavebord

Self-hosting with Docker

BASE_URL=https://gavebord.example.com docker compose up -d

The image is published to src.timharek.no/tim/gavebord (latest + the tag) on every v* tag. compose.yaml pulls it; uncomment build: . to build the checkout instead. State lives in the gavebord-data volume as a single SQLite file — back up gavebord.db (plus -wal/-shm if present).

The container serves plain HTTP on :8080 with no TLS and no auth in front of it. Terminate TLS in a reverse proxy and set BASE_URL to the public URL: it is what OIDC redirects and share links are built from, and an https:// BASE_URL is also what marks the session and CSRF cookies Secure. Then register in the browser — the first user becomes admin.

OIDC

OIDC is optional and turns on only when OIDC_ISSUER is set. Register an application with your provider (Pocket ID works out of the box; anything with standard discovery does too):

  • redirect URI https://<gavebord-host>/auth/oidc/callback
  • scopes openid email profile (add groups to map a group to admin)
environment:
  BASE_URL: https://gavebord.example.com
  OIDC_ISSUER: https://idp.example.com
  OIDC_CLIENT_ID: gavebord
  OIDC_CLIENT_SECRET: ...
  OIDC_ADMIN_GROUP: admins
  OIDC_SCOPES: groups

Accounts are matched by verified email and linked, so existing password accounts keep working (hybrid mode is the default) — and REGISTRATION_OPEN gates only the password form, not provisioning through the provider. Set ADMIN_EMAIL to your address before the first login so that account is admin regardless of OIDC_ADMIN_GROUP. See docs/oidc.md for the full behavior, and mise-runner for the CI runner image the workflows use.

Configuration (env)

Variable Default Meaning
ADDR :8080 listen address
DB_PATH gavebord.db SQLite file
BASE_URL http://localhost:8080 used in share links and the OIDC redirect URI
REGISTRATION_OPEN true disable to close signups
ADMIN_EMAIL pre-seed an admin account
OIDC_ISSUER enables OIDC login when set (issuer URL)
OIDC_CLIENT_ID client ID from the OIDC application
OIDC_CLIENT_SECRET client secret from the OIDC application
OIDC_SCOPES extra scopes (e.g. groups for admin mapping)
OIDC_ADMIN_GROUP IdP group name → role admin on login
OIDC_PROVIDER_NAME Pocket ID button label on the login page

OIDC login (Pocket ID)

OIDC activates only when OIDC_ISSUER is set; without it, everything behaves exactly as before. It uses the authorization code flow with PKCE (S256) and nonce, via discovery — any standard-compliant provider works, and hybrid mode is the default (existing password accounts keep working).

Pocket ID setup:

  1. Create an application; note client ID + secret.
  2. Set the redirect URI to https://<gavebord-host>/auth/oidc/callback (BASE_URL + that path — BASE_URL must be the public URL).
  3. Set OIDC_ISSUER to https://<pocketid-host> (the issuer URL).
OIDC_ISSUER=https://idp.example.com \
OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... \
BASE_URL=https://gavebord.example.com ./gavebord

Behavior:

  • First login matches by verified email and links the identity to an existing account, else provisions one (role user, no password — those accounts sign in only via the provider). With OIDC on, signups via the provider are always allowed; REGISTRATION_OPEN gates only the password form.
  • Add the groups scope and set OIDC_ADMIN_GROUP to map a Pocket ID group to the admin role on login.
  • Invited viewers can sign in/up through the provider: the invite token rides along, and logout stays local.
  • Password login rejects OIDC-only accounts with a hint to use the provider.

Development

mise run dev       # templ + tailwind watch, air reload
mise run check     # vet + golangci-lint
mise run test
mise run smoke     # end-to-end against a throwaway DB
mise run seed      # demo data in seed.db

Tools live in go.tool.mod (go tool -modfile=go.tool.mod <name>) so the app module stays clean.

Backups

Copy the SQLite file (plus -wal/-shm if present, or use VACUUM INTO). Everything — users, sessions, claims — lives in that one file.

API

Session cookie or Authorization: Bearer <token> (generate on /settings; interactive reference at /docs, spec at /openapi.json):

GET  /api/v1/me
GET  /api/v1/wishlists          # lists the token's account may edit
GET  /api/v1/wishlists/{id}     # manager, or an account it is shared with;
                                # "taken" for the list's owner only
POST /api/v1/wishlists/{id}/items
GET  /api/v1/debug/claims   # admin + bearer token only

Privacy model

Claim identity never leaves the database for anyone: a list's owner sees that an item is claimed ("Claimed"), never who claimed it, and a manager who is neither the creator nor the person the list is for sees nothing at all — so one parent cannot see what the other claimed. Viewers (a link, or a share) see only their own claims; everyone else shows as "taken". Admins can open any list but get the same viewer row as anyone else; full claim data exists solely behind GET /api/v1/debug/claims (admin bearer token).

License

MIT — see LICENSE.