Files
selfpost/internal/web/templates.go
T
mix d72a383904 Phase 2: SQLite persistence, admin setup-link, login/sessions
Implements the secure single-admin panel entry (spec 7.6).

- internal/store: modernc.org/sqlite (pure Go, static build), WAL +
  foreign keys, embedded PRAGMA user_version migrations; schema 0001
  covers admin/settings/domains/applications/send_log/rate_limits (spec 9).
- Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to
  log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP
  rate limit, subtle.ConstantTimeCompare, failures don't invalidate,
  one-time admin form, permanent invalidation once admin exists (/setup 404).
- bcrypt admin password; server-side username/password validation.
- Login + in-memory sessions, crypto-random token, cookie
  HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login
  rate limit, auth middleware.
- html/template base layout + setup/login/dashboard, vendored htmx 2.0.4.
- build/entrypoint.sh: fix bind-mounted /data ownership as root before
  supervisord drops to the unprivileged panel user (found via container test).

Verified on selfpost.example.com: go vet/build/test/gofmt clean; e2e curl
of setup+login flows; docker build + run with -v ./data:/data creates the
DB and 0600 token owned by panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:24:09 +03:00

55 lines
1.6 KiB
Go

package web
import (
"bytes"
"fmt"
"html/template"
"net/http"
)
// templates holds the parsed page templates. Each page is parsed together with
// the shared base layout so {{ template "base" . }} works. Rendering goes
// through html/template, which auto-escapes all interpolated data (spec 7.6.7).
type templates struct {
pages map[string]*template.Template
}
// pageFiles maps a logical page name to its template file. Every page composes
// with layout.html.
var pageFiles = map[string]string{
"setup": "templates/setup.html",
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
}
func loadTemplates() (*templates, error) {
t := &templates{pages: make(map[string]*template.Template)}
for name, file := range pageFiles {
tmpl, err := template.New("layout.html").ParseFS(assetsFS, "templates/layout.html", file)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err)
}
t.pages[name] = tmpl
}
return t, nil
}
// render writes a page using the base layout. Rendering to a buffer first means
// a template error yields a clean 500 instead of a half-written page.
func (s *Server) render(w http.ResponseWriter, status int, page string, data any) {
tmpl, ok := s.tmpl.pages[page]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
logf("panel: render %s: %v", page, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}