feat: add packaging, deployment, error pages, and project docs

Phase 7 — Polish:
- Error page template with styled 404/403/500 pages
- Error rendering helper on Renderer

Phase 8 — Packaging & Deployment:
- Containerfile: multi-stage build, non-root user, health check,
  OCI labels with build date and git revision
- Makefile: build, test, cross-compile, deb, rpm, container,
  tarballs, checksums targets
- nfpm.yaml: .deb and .rpm package config
- systemd service: hardened with NoNewPrivileges, ProtectSystem,
  ProtectHome, PrivateTmp, RestrictSUIDSGID
- Default environment file with commented examples
- postinstall/preremove scripts (shellcheck validated)
- compose.yaml: example Podman/Docker Compose
- Caddyfile.example: subdomain, subpath, and remote proxy configs
- CHANGELOG.md for release notes
- CLAUDE.md with architecture, conventions, and quick reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-29 16:34:32 +02:00
commit 1fc42bf1b2
16 changed files with 435 additions and 2 deletions

6
.gitignore vendored
View file

@ -1,6 +1,10 @@
# Build artifacts
/favoritter
/dist/
/dist/*.tar.gz
/dist/*.deb
/dist/*.rpm
/dist/checksums.txt
/dist/favoritter_*
# Data (database and uploads)
/data/

23
CHANGELOG.md Normal file
View file

@ -0,0 +1,23 @@
# Changelog
All notable changes to Favoritter are documented here.
## Unreleased
### Added
- User registration with configurable signup modes (open, approval-required, closed)
- Favorites with description, optional URL, image upload, tags, and privacy controls
- Tag system with autocomplete, browsing, and admin management
- User profiles (public or limited visibility) with avatars
- Admin panel: user management, tag management, signup requests, site settings
- Atom feeds: global, per-user, per-tag
- JSON and CSV import/export for data portability
- JSON REST API under `/api/v1/`
- OpenGraph meta tags for link previews
- Argon2id password hashing
- CSRF protection, rate limiting, security headers
- Proxy-aware deployment (WireGuard/Tailscale support)
- Configurable base path for subdomain and subpath deployment
- Systemd service file with security hardening
- Containerfile for Podman/Docker deployment
- Makefile with cross-compilation, .deb/.rpm packaging targets

64
CLAUDE.md Normal file
View file

@ -0,0 +1,64 @@
# Favoritter — Project Guide
## Quick Reference
```bash
go build ./cmd/favoritter # Build
go test ./... # Test
FAVORITTER_DEV_MODE=true \
FAVORITTER_ADMIN_USERNAME=admin \
FAVORITTER_ADMIN_PASSWORD=dev \
go run ./cmd/favoritter # Run (dev mode)
make build # Build with version info
make test # Run tests
make container # Build container image
```
## Architecture
Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. SQLite for storage, filesystem for uploaded images. All templates and static assets are embedded via `go:embed`.
### Directory Layout
- `cmd/favoritter/` — Entry point, wiring, graceful shutdown
- `internal/config/` — Environment variable configuration
- `internal/database/` — SQLite connection, PRAGMAs, migration runner
- `internal/model/` — Domain types (no logic, no DB)
- `internal/store/` — Data access layer (one file per entity, plain SQL)
- `internal/handler/` — HTTP handlers for web UI
- `internal/handler/api/` — JSON REST API handlers
- `internal/middleware/` — HTTP middleware (auth, CSRF, rate limit, etc.)
- `internal/render/` — Template rendering with layout support
- `internal/image/` — Image upload processing (validate, resize, strip EXIF)
- `web/templates/` — HTML templates (layouts, pages, partials)
- `web/static/` — CSS, JS, vendored Pico CSS + HTMX
- `dist/` — Packaging artifacts (systemd, env file, install scripts)
### Key Design Decisions
- **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing
- **3 external dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds
- **`SetMaxOpenConns(1)`** — SQLite works best with a single writer; PRAGMAs are set once on the single connection
- **Templates embedded in binary**`//go:embed` for single-binary deployment; dev mode reads from disk for live reload
- **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard
### Database
SQLite with WAL mode. Migrations are embedded SQL files in `internal/database/migrations/`, applied sequentially on startup. Forward-only — no down migrations.
## Conventions
- **Norwegian Bokmål** for all user-facing text (templates, flash messages, error text)
- **SPDX license headers** on all source files
- **Argon2id** for password hashing (never bcrypt)
- **UUID filenames** for all uploads — never use user-provided filenames
- **Errors**: log with `slog.Error` at the handler level, return generic messages to users — never leak internal errors
- **Tests**: use real in-memory SQLite (`:memory:`), set fast Argon2 params (`Memory=1024, Time=1`) in tests
- **Session cookie name**: use `middleware.SessionCookieName` constant, never hardcode `"session"`
- **CSRF**: auto-included in HTMX requests via `htmx:configRequest` JS hook
## Hosting
- Hosted on Forgejo at `kode.naiv.no` — use `fj` CLI, not `gh`
- Supports deployment as: standalone binary, .deb package, .rpm package, or container
- Reverse proxy (Caddy) may be on a different machine — always use `EXTERNAL_URL` and `TRUSTED_PROXIES`

32
Caddyfile.example Normal file
View file

@ -0,0 +1,32 @@
# Example Caddy configurations for Favoritter.
# Copy the relevant section to your Caddyfile.
# --- Option 1: Subdomain deployment ---
# Set FAVORITTER_EXTERNAL_URL=https://faves.example.com
# Set FAVORITTER_TRUSTED_PROXIES to Caddy's IP
faves.example.com {
reverse_proxy localhost:8080
}
# --- Option 2: Subpath deployment ---
# Set FAVORITTER_BASE_PATH=/faves
# Set FAVORITTER_EXTERNAL_URL=https://example.com/faves
# Set FAVORITTER_TRUSTED_PROXIES to Caddy's IP
# example.com {
# handle_path /faves/* {
# reverse_proxy localhost:8080
# }
# # Redirect /faves to /faves/
# redir /faves /faves/ permanent
# }
# --- Option 3: Remote proxy (WireGuard/Tailscale) ---
# Caddy runs on a different machine than Favoritter.
# Set FAVORITTER_TRUSTED_PROXIES=100.64.0.0/10 (Tailscale)
# or the specific WireGuard IP of the Caddy machine.
# faves.example.com {
# reverse_proxy 100.64.1.2:8080
# }

42
Containerfile Normal file
View file

@ -0,0 +1,42 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Build: BUILDAH_FORMAT=docker podman build \
# --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
# --build-arg GIT_REVISION="$(git describe --always --dirty)" \
# -t favoritter .
FROM docker.io/library/golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.version=${VERSION} -X main.buildDate=${BUILD_DATE}" \
-o /favoritter ./cmd/favoritter
FROM docker.io/library/debian:bookworm-slim
ARG BUILD_DATE
ARG GIT_REVISION
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${GIT_REVISION}" \
org.opencontainers.image.source="https://kode.naiv.no/olemd/favoritter" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
org.opencontainers.image.title="Favoritter" \
org.opencontainers.image.description="Self-hosted favorites web app"
RUN printf 'build_date=%s\ngit_revision=%s\n' "${BUILD_DATE}" "${GIT_REVISION}" > /etc/build-info
RUN useradd -r -s /usr/sbin/nologin favoritter \
&& mkdir -p /data/uploads \
&& chown -R favoritter:favoritter /data
USER favoritter
COPY --from=builder /favoritter /usr/local/bin/favoritter
ENV FAVORITTER_DB_PATH=/data/favoritter.db \
FAVORITTER_UPLOAD_DIR=/data/uploads \
FAVORITTER_LISTEN=:8080
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s CMD ["/usr/local/bin/favoritter", "-healthcheck"]
ENTRYPOINT ["/usr/local/bin/favoritter"]

85
Makefile Normal file
View file

@ -0,0 +1,85 @@
# Favoritter build system
# SPDX-License-Identifier: AGPL-3.0-or-later
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.buildDate=$(BUILD_DATE)
PLATFORMS := linux/amd64 linux/arm64
DIST := dist
.PHONY: build build-all deb rpm container artifacts checksums clean test lint
## Build for current platform
build:
go build -ldflags="$(LDFLAGS)" -o favoritter ./cmd/favoritter
## Run tests
test:
go test ./...
## Cross-compile for all platforms
build-all: $(DIST)
@for platform in $(PLATFORMS); do \
os=$${platform%%/*}; \
arch=$${platform##*/}; \
echo "Building $$os/$$arch..."; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
go build -ldflags="$(LDFLAGS)" \
-o $(DIST)/favoritter_$(VERSION)_$${os}_$${arch} \
./cmd/favoritter; \
done
## Build .deb packages (requires nfpm)
deb: build-all
@for platform in $(PLATFORMS); do \
arch=$${platform##*/}; \
echo "Packaging deb for $$arch..."; \
ARCH=$$arch VERSION=$(VERSION) nfpm package \
--packager deb \
--target $(DIST)/favoritter_$(VERSION)_$${arch}.deb; \
done
## Build .rpm packages (requires nfpm)
rpm: build-all
@for platform in $(PLATFORMS); do \
arch=$${platform##*/}; \
echo "Packaging rpm for $$arch..."; \
ARCH=$$arch VERSION=$(VERSION) nfpm package \
--packager rpm \
--target $(DIST)/favoritter_$(VERSION)_$${arch}.rpm; \
done
## Build container image
container:
BUILDAH_FORMAT=docker podman build \
--build-arg BUILD_DATE="$(BUILD_DATE)" \
--build-arg GIT_REVISION="$(VERSION)" \
--build-arg VERSION="$(VERSION)" \
-t favoritter:$(VERSION) \
-t favoritter:latest .
## Package binaries into tarballs
tarballs: build-all
@for platform in $(PLATFORMS); do \
os=$${platform%%/*}; \
arch=$${platform##*/}; \
name=favoritter_$(VERSION)_$${os}_$${arch}; \
echo "Creating tarball $$name.tar.gz..."; \
tar -czf $(DIST)/$$name.tar.gz \
-C $(DIST) $${name} \
-C $(CURDIR) README.md LICENSE; \
done
## Generate SHA256 checksums
checksums:
cd $(DIST) && sha256sum *.tar.gz *.deb *.rpm 2>/dev/null > checksums.txt || true
## Build all release artifacts
artifacts: tarballs deb rpm checksums
## Clean build artifacts
clean:
rm -rf $(DIST) favoritter
$(DIST):
mkdir -p $(DIST)

24
compose.yaml Normal file
View file

@ -0,0 +1,24 @@
# Example Podman/Docker Compose configuration.
# Usage: podman-compose up -d
services:
favoritter:
build:
context: .
dockerfile: Containerfile
args:
BUILD_DATE: "${BUILD_DATE:-unknown}"
GIT_REVISION: "${GIT_REVISION:-unknown}"
ports:
- "8080:8080"
volumes:
- favoritter-data:/data
environment:
FAVORITTER_ADMIN_USERNAME: "${FAVORITTER_ADMIN_USERNAME:-admin}"
FAVORITTER_ADMIN_PASSWORD: "${FAVORITTER_ADMIN_PASSWORD:?Set FAVORITTER_ADMIN_PASSWORD}"
FAVORITTER_EXTERNAL_URL: "${FAVORITTER_EXTERNAL_URL:-}"
FAVORITTER_SITE_NAME: "${FAVORITTER_SITE_NAME:-Favoritter}"
restart: unless-stopped
volumes:
favoritter-data:

16
dist/favoritter.env vendored Normal file
View file

@ -0,0 +1,16 @@
# Favoritter configuration
# See README.md for all available environment variables.
FAVORITTER_DB_PATH=/var/lib/favoritter/favoritter.db
FAVORITTER_UPLOAD_DIR=/var/lib/favoritter/uploads
FAVORITTER_LISTEN=127.0.0.1:8080
# Uncomment and set on first run to create the admin user:
# FAVORITTER_ADMIN_USERNAME=admin
# FAVORITTER_ADMIN_PASSWORD=changeme
# Set this to your public URL for correct feeds, cookies, and OpenGraph:
# FAVORITTER_EXTERNAL_URL=https://faves.example.com
# If your reverse proxy is on another machine (WireGuard/Tailscale):
# FAVORITTER_TRUSTED_PROXIES=100.64.0.0/10

25
dist/favoritter.service vendored Normal file
View file

@ -0,0 +1,25 @@
[Unit]
Description=Favoritter - Self-hosted favorites web app
After=network.target
[Service]
Type=simple
User=favoritter
Group=favoritter
EnvironmentFile=/etc/favoritter/favoritter.env
ExecStart=/usr/bin/favoritter
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/favoritter
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
[Install]
WantedBy=multi-user.target

21
dist/postinstall.sh vendored Executable file
View file

@ -0,0 +1,21 @@
#!/bin/sh
# Post-install script for Favoritter .deb/.rpm package.
# Creates the system user and sets directory permissions.
set -e
# Create system user if it doesn't exist.
if ! getent passwd favoritter >/dev/null 2>&1; then
useradd -r -s /usr/sbin/nologin -d /var/lib/favoritter favoritter
fi
# Ensure data directories exist with correct ownership.
install -d -o favoritter -g favoritter -m 0750 /var/lib/favoritter
install -d -o favoritter -g favoritter -m 0750 /var/lib/favoritter/uploads
# Reload systemd to pick up the service file.
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload
fi
echo "Favoritter installed. Configure /etc/favoritter/favoritter.env then run:"
echo " sudo systemctl enable --now favoritter"

9
dist/preremove.sh vendored Executable file
View file

@ -0,0 +1,9 @@
#!/bin/sh
# Pre-remove script for Favoritter .deb/.rpm package.
# Stops the service before package removal.
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl stop favoritter 2>/dev/null || true
systemctl disable favoritter 2>/dev/null || true
fi

View file

@ -143,3 +143,8 @@ func (h *Handler) Routes() *http.ServeMux {
return mux
}
// handleNotFound renders a styled 404 page.
func (h *Handler) handleNotFound(w http.ResponseWriter, r *http.Request) {
h.deps.Renderer.Error(w, r, http.StatusNotFound, "Ikke funnet")
}

View file

@ -53,7 +53,8 @@ func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || !user.IsAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Forbidden"))
return
}
next.ServeHTTP(w, r)

View file

@ -152,6 +152,18 @@ func (r *Renderer) Page(w http.ResponseWriter, req *http.Request, name string, d
}
}
// Error renders an error page with the given HTTP status code.
func (r *Renderer) Error(w http.ResponseWriter, req *http.Request, code int, message string) {
w.WriteHeader(code)
r.Page(w, req, "error", PageData{
Title: message,
Data: map[string]any{
"Code": code,
"Message": message,
},
})
}
// Partial renders a partial template (for HTMX responses).
func (r *Renderer) Partial(w io.Writer, name string, data any) error {
key := "partial:" + name

52
nfpm.yaml Normal file
View file

@ -0,0 +1,52 @@
# nfpm configuration for building .deb and .rpm packages.
# https://nfpm.goreleaser.com/
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Usage:
# ARCH=amd64 VERSION=1.0.0 nfpm package --packager deb --target dist/
# ARCH=arm64 VERSION=1.0.0 nfpm package --packager rpm --target dist/
name: favoritter
arch: "${ARCH}"
platform: linux
version: "${VERSION}"
maintainer: "Ole M. <olemd@kode.naiv.no>"
description: "Self-hosted favorites web app"
vendor: ""
homepage: "https://kode.naiv.no/olemd/favoritter"
license: AGPL-3.0-or-later
contents:
- src: ./dist/favoritter_${VERSION}_linux_${ARCH}
dst: /usr/bin/favoritter
file_info:
mode: 0755
- src: ./dist/favoritter.service
dst: /lib/systemd/system/favoritter.service
- src: ./dist/favoritter.env
dst: /etc/favoritter/favoritter.env
type: config|noreplace
- dst: /var/lib/favoritter
type: dir
file_info:
mode: 0750
owner: favoritter
group: favoritter
- dst: /var/lib/favoritter/uploads
type: dir
file_info:
mode: 0750
owner: favoritter
group: favoritter
scripts:
postinstall: ./dist/postinstall.sh
preremove: ./dist/preremove.sh
overrides:
deb:
depends:
- libc6
rpm:
depends:
- glibc

View file

@ -0,0 +1,18 @@
{{define "content"}}
{{with .Data}}
<article>
<hgroup>
<h1>{{.Code}}</h1>
<p>{{.Message}}</p>
</hgroup>
{{if eq .Code 404}}
<p>Siden du leter etter finnes ikke. Den kan ha blitt flyttet eller slettet.</p>
{{else if eq .Code 403}}
<p>Du har ikke tilgang til denne siden.</p>
{{else}}
<p>Noe gikk galt. Prøv igjen senere.</p>
{{end}}
<p><a href="{{basePath}}/">Tilbake til forsiden</a></p>
</article>
{{end}}
{{end}}