- Go 77.1%
- templ 19.3%
- Shell 2.7%
- CSS 0.7%
- Dockerfile 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| cmd | ||
| docs | ||
| internal | ||
| scripts | ||
| .air.toml | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitbump.toml | ||
| .gitignore | ||
| .golangci.toml | ||
| .templui.json | ||
| .tokeignore | ||
| compose.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| go.tool.mod | ||
| go.tool.sum | ||
| LICENSE | ||
| mise.toml | ||
| PLAN.md | ||
| README.md | ||
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(addgroupsto 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:
- Create an application; note client ID + secret.
- Set the redirect URI to
https://<gavebord-host>/auth/oidc/callback(BASE_URL+ that path —BASE_URLmust be the public URL). - Set
OIDC_ISSUERtohttps://<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_OPENgates only the password form. - Add the
groupsscope and setOIDC_ADMIN_GROUPto map a Pocket ID group to theadminrole 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.