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:
commit
fc1f7259c5
52 changed files with 5459 additions and 0 deletions
193
internal/render/render.go
Normal file
193
internal/render/render.go
Normal 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 },
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue