feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation

Go backend with server-rendered HTML/HTMX frontend, SQLite database,
and filesystem image storage. Self-hostable single-binary architecture.

Phase 1 — Authentication & project foundation:
- Argon2id password hashing with timing-attack prevention
- Session management with cookie-based auth and periodic cleanup
- Login, signup (open/requests/closed modes), logout, forced password reset
- CSRF double-submit cookie pattern with HTMX auto-inclusion
- Proxy-aware real IP extraction (WireGuard/Tailscale support)
- Configurable base path for subdomain and subpath deployment
- Rate limiting on auth endpoints with background cleanup
- Security headers (CSP, X-Frame-Options, Referrer-Policy)
- Structured logging with slog, graceful shutdown
- Pico CSS + HTMX vendored and embedded via go:embed

Phase 2 — Faves CRUD with tags and images:
- Full CRUD for favorites with ownership checks
- Image upload with EXIF stripping, resize to 1920px, UUID filenames
- Tag system with HTMX autocomplete (prefix search, popularity-sorted)
- Privacy controls (public/private per fave, user-configurable default)
- Tag browsing, pagination, batch tag loading (avoids N+1)
- OpenGraph meta tags on public fave detail pages

Includes code quality pass: extracted shared helpers, fixed signup
request persistence bug, plugged rate limiter memory leak, removed
dead code, and logged previously-swallowed errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-29 15:55:22 +02:00
commit fc1f7259c5
52 changed files with 5459 additions and 0 deletions

193
internal/render/render.go Normal file
View file

@ -0,0 +1,193 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package render provides HTML template rendering with layout support.
package render
import (
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"kode.naiv.no/olemd/favoritter/internal/config"
"kode.naiv.no/olemd/favoritter/internal/middleware"
"kode.naiv.no/olemd/favoritter/internal/model"
"kode.naiv.no/olemd/favoritter/internal/store"
"kode.naiv.no/olemd/favoritter/web"
)
// Renderer parses and executes HTML templates.
type Renderer struct {
templates map[string]*template.Template
cfg *config.Config
}
// PageData holds data passed to page templates.
type PageData struct {
Title string
User *model.User
CSRFToken string
BasePath string
SiteName string
Flash string
FlashType string // "success", "error", "info"
ExternalURL string
Data any
}
// New creates a Renderer, parsing all templates from the embedded filesystem.
func New(cfg *config.Config) (*Renderer, error) {
r := &Renderer{
cfg: cfg,
}
if err := r.parseTemplates(); err != nil {
return nil, err
}
return r, nil
}
func (r *Renderer) parseTemplates() error {
r.templates = make(map[string]*template.Template)
var templateFS fs.FS
if r.cfg.DevMode {
// In dev mode, read templates from disk for live reload.
templateFS = os.DirFS(filepath.Join("web", "templates"))
} else {
sub, err := fs.Sub(web.TemplatesFS, "templates")
if err != nil {
return fmt.Errorf("sub templates fs: %w", err)
}
templateFS = sub
}
funcMap := r.templateFuncs()
// Parse the base layout.
baseLayout, err := template.New("base.html").Funcs(funcMap).ParseFS(templateFS, "layouts/base.html")
if err != nil {
return fmt.Errorf("parse base layout: %w", err)
}
// Parse each page template with the base layout.
pages, err := fs.Glob(templateFS, "pages/*.html")
if err != nil {
return fmt.Errorf("glob pages: %w", err)
}
for _, page := range pages {
name := strings.TrimPrefix(page, "pages/")
name = strings.TrimSuffix(name, ".html")
t, err := baseLayout.Clone()
if err != nil {
return fmt.Errorf("clone base for %s: %w", name, err)
}
_, err = t.ParseFS(templateFS, page)
if err != nil {
return fmt.Errorf("parse page %s: %w", name, err)
}
r.templates[name] = t
}
// Parse partial templates (for HTMX responses).
partials, err := fs.Glob(templateFS, "partials/*.html")
if err != nil {
return fmt.Errorf("glob partials: %w", err)
}
for _, partial := range partials {
name := "partial:" + strings.TrimPrefix(partial, "partials/")
name = strings.TrimSuffix(name, ".html")
t, err := template.New(filepath.Base(partial)).Funcs(funcMap).ParseFS(templateFS, partial)
if err != nil {
return fmt.Errorf("parse partial %s: %w", name, err)
}
r.templates[name] = t
}
slog.Info("templates loaded", "pages", len(pages), "partials", len(partials))
return nil
}
// Page renders a full page with the base layout.
func (r *Renderer) Page(w http.ResponseWriter, req *http.Request, name string, data PageData) {
if r.cfg.DevMode {
// Reparse templates on every request in dev mode.
if err := r.parseTemplates(); err != nil {
slog.Error("template reparse failed", "error", err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
}
// Populate common data from context.
data.User = middleware.UserFromContext(req.Context())
data.CSRFToken = middleware.CSRFTokenFromContext(req.Context())
data.BasePath = r.cfg.BasePath
data.SiteName = r.cfg.SiteName
data.ExternalURL = r.cfg.ExternalURL
t, ok := r.templates[name]
if !ok {
slog.Error("template not found", "name", name)
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base.html", data); err != nil {
slog.Error("template execute failed", "name", name, "error", err)
}
}
// Partial renders a partial template (for HTMX responses).
func (r *Renderer) Partial(w io.Writer, name string, data any) error {
key := "partial:" + name
t, ok := r.templates[key]
if !ok {
return fmt.Errorf("partial template %q not found", name)
}
return t.Execute(w, data)
}
func (r *Renderer) templateFuncs() template.FuncMap {
return template.FuncMap{
// basePath returns the configured base path for URL construction.
"basePath": func() string {
return r.cfg.BasePath
},
// externalURL returns the configured external URL.
"externalURL": func() string {
return r.cfg.ExternalURL
},
// truncate truncates a string to n characters.
"truncate": func(n int, s string) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
},
// join joins strings with a separator.
"join": strings.Join,
// contains checks if a string contains a substring.
"contains": strings.Contains,
// add returns a + b.
"add": func(a, b int) int { return a + b },
// subtract returns a - b.
"subtract": func(a, b int) int { return a - b },
// maxTags returns the maximum number of tags per fave.
"maxTags": func() int { return store.MaxTagsPerFave },
}
}