diff --git a/.gitignore b/.gitignore index d10244a..7da7645 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d30b4c --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9519306 --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..cf5eb7e --- /dev/null +++ b/Caddyfile.example @@ -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 +# } diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..c216653 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a39b8a4 --- /dev/null +++ b/Makefile @@ -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) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..58038c7 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/dist/favoritter.env b/dist/favoritter.env new file mode 100644 index 0000000..89cf51d --- /dev/null +++ b/dist/favoritter.env @@ -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 diff --git a/dist/favoritter.service b/dist/favoritter.service new file mode 100644 index 0000000..6de8f51 --- /dev/null +++ b/dist/favoritter.service @@ -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 diff --git a/dist/postinstall.sh b/dist/postinstall.sh new file mode 100755 index 0000000..dd6c02d --- /dev/null +++ b/dist/postinstall.sh @@ -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" diff --git a/dist/preremove.sh b/dist/preremove.sh new file mode 100755 index 0000000..8e930f5 --- /dev/null +++ b/dist/preremove.sh @@ -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 diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d805e35..6fcea29 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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") +} diff --git a/internal/middleware/context.go b/internal/middleware/context.go index 2b45659..e53031b 100644 --- a/internal/middleware/context.go +++ b/internal/middleware/context.go @@ -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) diff --git a/internal/render/render.go b/internal/render/render.go index 7ecb6e8..b423f03 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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 diff --git a/nfpm.yaml b/nfpm.yaml new file mode 100644 index 0000000..ceb1fd9 --- /dev/null +++ b/nfpm.yaml @@ -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. " +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 diff --git a/web/templates/pages/error.html b/web/templates/pages/error.html new file mode 100644 index 0000000..78f7d3f --- /dev/null +++ b/web/templates/pages/error.html @@ -0,0 +1,18 @@ +{{define "content"}} +{{with .Data}} +
+
+

{{.Code}}

+

{{.Message}}

+
+ {{if eq .Code 404}} +

Siden du leter etter finnes ikke. Den kan ha blitt flyttet eller slettet.

+ {{else if eq .Code 403}} +

Du har ikke tilgang til denne siden.

+ {{else}} +

Noe gikk galt. Prøv igjen senere.

+ {{end}} +

Tilbake til forsiden

+
+{{end}} +{{end}}