feat(web): Sessions, Rate-Limiting, Security-Header, Templates und eingebettete Assets

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:20:38 +02:00
parent e49882b8a8
commit bb2bc223b9
16 changed files with 1292 additions and 0 deletions

92
internal/web/render.go Normal file
View file

@ -0,0 +1,92 @@
package web
import (
"bytes"
"embed"
"fmt"
"html/template"
"net/http"
)
//go:embed templates/*.html
var templateFS embed.FS
// AssetsFS enthält CSS und JavaScript; wird vom Asset-Handler ausgeliefert.
//
//go:embed assets
var AssetsFS embed.FS
// Brand bündelt die White-Label-Angaben.
type Brand struct {
Title string
SupportContact string
Version string
HasLogo bool
}
// PageData ist das Wurzelobjekt jedes Templates.
type PageData struct {
Brand Brand
Flash string
FlashKind string // "error" oder "info"
CSRF string
User string
Data any
}
// errorPageData füllt error.html.
type errorPageData struct {
Title string
Message string
ShowLoginLink bool
ShowOverviewLink bool
}
// Renderer hält die beim Start geparsten Templates.
type Renderer struct {
brand Brand
pages map[string]*template.Template
}
// pageNames sind die Inhaltstemplates, die jeweils mit dem Layout kombiniert werden.
var pageNames = []string{"login", "overview", "guides", "error"}
// NewRenderer parst alle Templates beim Start; ein Fehler bricht den Start ab,
// damit ein Tippfehler im Template nicht erst im Betrieb auffällt.
func NewRenderer(brand Brand) (*Renderer, error) {
funcs := template.FuncMap{"t": T}
pages := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
tmpl, err := template.New("layout.html").Funcs(funcs).
ParseFS(templateFS, "templates/layout.html", "templates/"+name+".html")
if err != nil {
return nil, fmt.Errorf("Template %s: %w", name, err)
}
pages[name] = tmpl
}
return &Renderer{brand: brand, pages: pages}, nil
}
// Brand liefert die White-Label-Angaben für Handler.
func (rn *Renderer) Brand() Brand { return rn.brand }
// Render schreibt eine Seite. Es wird zuerst in einen Puffer gerendert, damit
// ein Template-Fehler nicht zu einer halb geschriebenen Antwort führt.
func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, status int, page string, data PageData) {
tmpl, ok := rn.pages[page]
if !ok {
http.Error(w, T("error_internal"), http.StatusInternalServerError)
return
}
if data.Brand.Title == "" {
data.Brand = rn.brand
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, T("error_internal"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.Write(buf.Bytes())
}