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>
2026-03-29 15:55:22 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-29 16:34:32 +02:00
|
|
|
// 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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-29 15:55:22 +02:00
|
|
|
// 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 },
|
|
|
|
|
}
|
|
|
|
|
}
|