220 lines
8.1 KiB
Markdown
220 lines
8.1 KiB
Markdown
|
|
# Favoritter
|
||
|
|
|
||
|
|
A self-hosted web application for collecting and sharing favorites. Movies, songs, cars, lego sets, flowers, pictures — anything you love.
|
||
|
|
|
||
|
|
Built as a single Go binary with SQLite, server-rendered HTML, and [HTMX](https://htmx.org/) for interactivity. Designed to be easy to deploy for technical users who can share access with friends and family.
|
||
|
|
|
||
|
|
## Features
|
||
|
|
|
||
|
|
- **Favorites** — Add favorites with a description, optional URL, optional image upload, and tags
|
||
|
|
- **Tags** — Autocomplete from existing tags, browse by tag
|
||
|
|
- **Privacy** — Each favorite can be public or private, with a configurable default
|
||
|
|
- **User profiles** — Public or limited visibility, with avatar, bio, and display name
|
||
|
|
- **Admin panel** — User management, tag management, signup request approval, site settings
|
||
|
|
- **Signup modes** — Open registration, approval-required, or closed
|
||
|
|
- **Atom feeds** — Global, per-user, and per-tag feeds
|
||
|
|
- **Import/Export** — JSON and CSV, for data portability
|
||
|
|
- **JSON API** — Full REST API under `/api/v1/` for third-party clients
|
||
|
|
- **OpenGraph** — Rich link previews when sharing favorites
|
||
|
|
- **Accessible** — Semantic HTML, keyboard navigation, screen reader support, `lang="nb"`, WCAG 2.2 AA target
|
||
|
|
- **Proxy-aware** — Supports deployment behind Caddy/nginx on a different machine (WireGuard, Tailscale)
|
||
|
|
|
||
|
|
## Quick Start
|
||
|
|
|
||
|
|
### Run directly
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Build
|
||
|
|
go build -o favoritter ./cmd/favoritter
|
||
|
|
|
||
|
|
# Run (creates admin user on first start)
|
||
|
|
FAVORITTER_ADMIN_USERNAME=admin \
|
||
|
|
FAVORITTER_ADMIN_PASSWORD=changeme \
|
||
|
|
./favoritter
|
||
|
|
```
|
||
|
|
|
||
|
|
Open http://localhost:8080 in your browser.
|
||
|
|
|
||
|
|
### Run with Podman/Docker
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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 .
|
||
|
|
|
||
|
|
podman run -d \
|
||
|
|
-p 8080:8080 \
|
||
|
|
-v favoritter-data:/data \
|
||
|
|
-e FAVORITTER_ADMIN_USERNAME=admin \
|
||
|
|
-e FAVORITTER_ADMIN_PASSWORD=changeme \
|
||
|
|
favoritter
|
||
|
|
```
|
||
|
|
|
||
|
|
## Configuration
|
||
|
|
|
||
|
|
All configuration is via environment variables. Sensible defaults are provided for everything except the initial admin credentials.
|
||
|
|
|
||
|
|
| Variable | Default | Description |
|
||
|
|
|----------|---------|-------------|
|
||
|
|
| `FAVORITTER_ADMIN_USERNAME` | *(none)* | Initial admin username (created on first run) |
|
||
|
|
| `FAVORITTER_ADMIN_PASSWORD` | *(none)* | Initial admin password |
|
||
|
|
| `FAVORITTER_DB_PATH` | `./data/favoritter.db` | SQLite database file path |
|
||
|
|
| `FAVORITTER_LISTEN` | `:8080` | Listen address (e.g. `:8080`, `10.0.0.5:8080`) |
|
||
|
|
| `FAVORITTER_BASE_PATH` | `/` | Base path for subpath deployment (e.g. `/faves`) |
|
||
|
|
| `FAVORITTER_EXTERNAL_URL` | *(auto)* | Public URL (e.g. `https://faves.example.com`). Used for feeds, cookies, OpenGraph. If unset, inferred from Host header. |
|
||
|
|
| `FAVORITTER_TRUSTED_PROXIES` | `127.0.0.1` | Comma-separated IPs/CIDRs to trust for `X-Forwarded-For` (e.g. `100.64.0.0/10` for Tailscale) |
|
||
|
|
| `FAVORITTER_UPLOAD_DIR` | `./data/uploads` | Directory for uploaded images |
|
||
|
|
| `FAVORITTER_MAX_UPLOAD_SIZE` | `10485760` | Maximum upload size in bytes (10 MB) |
|
||
|
|
| `FAVORITTER_SESSION_LIFETIME` | `720h` | Session cookie lifetime (30 days) |
|
||
|
|
| `FAVORITTER_ARGON2_MEMORY` | `65536` | Argon2id memory cost in KiB (64 MB) |
|
||
|
|
| `FAVORITTER_ARGON2_TIME` | `3` | Argon2id iterations |
|
||
|
|
| `FAVORITTER_ARGON2_PARALLELISM` | `2` | Argon2id parallelism |
|
||
|
|
| `FAVORITTER_RATE_LIMIT` | `60` | Auth endpoint rate limit (requests/minute/IP) |
|
||
|
|
| `FAVORITTER_SITE_NAME` | `Favoritter` | Site name shown in UI and feeds |
|
||
|
|
| `FAVORITTER_DEV_MODE` | `false` | Enable live template reload and debug logging |
|
||
|
|
|
||
|
|
## Deployment
|
||
|
|
|
||
|
|
### Behind Caddy (subdomain)
|
||
|
|
|
||
|
|
```
|
||
|
|
faves.example.com {
|
||
|
|
reverse_proxy 10.0.0.5:8080
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Set `FAVORITTER_EXTERNAL_URL=https://faves.example.com` and `FAVORITTER_TRUSTED_PROXIES=<caddy-ip>`.
|
||
|
|
|
||
|
|
### Behind Caddy (subpath)
|
||
|
|
|
||
|
|
```
|
||
|
|
example.com {
|
||
|
|
handle_path /faves/* {
|
||
|
|
reverse_proxy 10.0.0.5:8080
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Set `FAVORITTER_BASE_PATH=/faves` and `FAVORITTER_EXTERNAL_URL=https://example.com/faves`.
|
||
|
|
|
||
|
|
### Remote proxy (WireGuard/Tailscale)
|
||
|
|
|
||
|
|
Caddy and Favoritter can run on different machines. Set `FAVORITTER_TRUSTED_PROXIES` to the proxy's IP or CIDR (e.g. `100.64.0.0/10` for Tailscale, `10.0.0.0/24` for WireGuard).
|
||
|
|
|
||
|
|
## API
|
||
|
|
|
||
|
|
The JSON API lives under `/api/v1/`. Authenticate by posting to `/api/v1/auth/login` to get a session cookie, then use it for subsequent requests.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Login
|
||
|
|
curl -c cookies.txt -X POST http://localhost:8080/api/v1/auth/login \
|
||
|
|
-H 'Content-Type: application/json' \
|
||
|
|
-d '{"username":"admin","password":"changeme"}'
|
||
|
|
|
||
|
|
# List your faves
|
||
|
|
curl -b cookies.txt http://localhost:8080/api/v1/faves
|
||
|
|
|
||
|
|
# Create a fave
|
||
|
|
curl -b cookies.txt -X POST http://localhost:8080/api/v1/faves \
|
||
|
|
-H 'Content-Type: application/json' \
|
||
|
|
-d '{"description":"Blade Runner 2049","url":"https://example.com","privacy":"public","tags":["film","sci-fi"]}'
|
||
|
|
|
||
|
|
# Export
|
||
|
|
curl -b cookies.txt http://localhost:8080/api/v1/export/json
|
||
|
|
```
|
||
|
|
|
||
|
|
See the [route list](#routes) for all available endpoints.
|
||
|
|
|
||
|
|
## Routes
|
||
|
|
|
||
|
|
### Web
|
||
|
|
|
||
|
|
| Method | Path | Description |
|
||
|
|
|--------|------|-------------|
|
||
|
|
| GET | `/` | Home page / public feed |
|
||
|
|
| GET/POST | `/login` | Login |
|
||
|
|
| GET/POST | `/signup` | Signup (mode-dependent) |
|
||
|
|
| POST | `/logout` | Logout |
|
||
|
|
| GET/POST | `/reset-password` | Forced password reset |
|
||
|
|
| GET | `/faves` | Own faves list |
|
||
|
|
| GET | `/faves/new` | New fave form |
|
||
|
|
| POST | `/faves` | Create fave |
|
||
|
|
| GET | `/faves/{id}` | View fave |
|
||
|
|
| GET | `/faves/{id}/edit` | Edit fave form |
|
||
|
|
| POST | `/faves/{id}` | Update fave |
|
||
|
|
| DELETE | `/faves/{id}` | Delete fave |
|
||
|
|
| GET | `/tags/search?q=` | Tag autocomplete (HTMX) |
|
||
|
|
| GET | `/tags/{name}` | Browse by tag |
|
||
|
|
| GET | `/u/{username}` | Public profile |
|
||
|
|
| GET/POST | `/settings` | User settings |
|
||
|
|
| POST | `/settings/avatar` | Upload avatar |
|
||
|
|
| POST | `/settings/password` | Change password |
|
||
|
|
| GET | `/export` | Export page |
|
||
|
|
| GET | `/export/json` | Download JSON |
|
||
|
|
| GET | `/export/csv` | Download CSV |
|
||
|
|
| GET/POST | `/import` | Import page |
|
||
|
|
| GET | `/feed.xml` | Global Atom feed |
|
||
|
|
| GET | `/u/{username}/feed.xml` | User Atom feed |
|
||
|
|
| GET | `/tags/{name}/feed.xml` | Tag Atom feed |
|
||
|
|
| GET | `/admin` | Admin dashboard |
|
||
|
|
| GET/POST | `/admin/users` | User management |
|
||
|
|
| GET | `/admin/tags` | Tag management |
|
||
|
|
| GET/POST | `/admin/signup-requests` | Signup request management |
|
||
|
|
| GET/POST | `/admin/settings` | Site settings |
|
||
|
|
| GET | `/health` | Health check |
|
||
|
|
|
||
|
|
### API (`/api/v1/`)
|
||
|
|
|
||
|
|
| Method | Path | Description |
|
||
|
|
|--------|------|-------------|
|
||
|
|
| POST | `/api/v1/auth/login` | Login, returns session |
|
||
|
|
| POST | `/api/v1/auth/logout` | Logout |
|
||
|
|
| GET | `/api/v1/faves` | List own faves |
|
||
|
|
| POST | `/api/v1/faves` | Create fave |
|
||
|
|
| GET | `/api/v1/faves/{id}` | Get fave |
|
||
|
|
| PUT | `/api/v1/faves/{id}` | Update fave |
|
||
|
|
| DELETE | `/api/v1/faves/{id}` | Delete fave |
|
||
|
|
| GET | `/api/v1/tags?q=` | Search tags |
|
||
|
|
| GET | `/api/v1/users/{username}` | Public profile |
|
||
|
|
| GET | `/api/v1/users/{username}/faves` | User's public faves |
|
||
|
|
| GET | `/api/v1/export/json` | Export own faves |
|
||
|
|
| POST | `/api/v1/import` | Import faves |
|
||
|
|
|
||
|
|
## Tech Stack
|
||
|
|
|
||
|
|
- **Go** (1.22+ stdlib router, `html/template`, `embed`, `log/slog`)
|
||
|
|
- **SQLite** via [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) (pure Go, no CGO)
|
||
|
|
- **HTMX** for interactive UI without a JS framework
|
||
|
|
- **Pico CSS** for semantic HTML styling
|
||
|
|
- **Argon2id** for password hashing
|
||
|
|
- **gorilla/feeds** for Atom feed generation
|
||
|
|
|
||
|
|
Only 3 external Go dependencies.
|
||
|
|
|
||
|
|
## Security
|
||
|
|
|
||
|
|
- Argon2id password hashing with timing-attack prevention
|
||
|
|
- CSRF double-submit cookie pattern (auto-included by HTMX)
|
||
|
|
- Security headers: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
|
||
|
|
- Rate limiting on authentication endpoints
|
||
|
|
- Image uploads: EXIF metadata stripped, re-encoded, UUID filenames, path traversal protection
|
||
|
|
- Proxy-aware: trusts `X-Forwarded-For` only from configured proxy CIDRs
|
||
|
|
- Session cookies: HttpOnly, Secure (from `EXTERNAL_URL` or `X-Forwarded-Proto`), SameSite=Lax
|
||
|
|
|
||
|
|
## Development
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Run in dev mode (live template reload, debug logging)
|
||
|
|
FAVORITTER_DEV_MODE=true \
|
||
|
|
FAVORITTER_ADMIN_USERNAME=admin \
|
||
|
|
FAVORITTER_ADMIN_PASSWORD=dev \
|
||
|
|
go run ./cmd/favoritter
|
||
|
|
|
||
|
|
# Run tests
|
||
|
|
go test ./...
|
||
|
|
```
|
||
|
|
|
||
|
|
## License
|
||
|
|
|
||
|
|
[GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0-or-later)
|