Files
pamietnik/backend/internal/api/webui.go
Christoph K. 86627f94b1
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
Add public feed, admin area, self-registration, visibility & hashtags
- Public feed (/) with infinite scroll via Intersection Observer
- Self-registration (/register)
- Admin area (/admin/entries, /admin/users) with user management
- journal_entries: visibility (public/private) + hashtags fields
- users: is_admin flag
- DB schema updated (recreate DB to apply)
- CI: run go test via docker run (golang:1.25-alpine) — fixes 'go not found'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:53:31 +02:00

362 lines
10 KiB
Go

package api
import (
"bytes"
"embed"
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/db"
)
//go:embed static templates
var assets embed.FS
var funcMap = template.FuncMap{
"divInt": func(a, b int) int { return a / b },
"deref": func(p *float64) float64 {
if p == nil {
return 0
}
return *p
},
"join": strings.Join,
}
var tmpls = template.Must(
template.New("").Funcs(funcMap).ParseFS(assets, "templates/*.html"),
)
func staticFS() fs.FS {
sub, err := fs.Sub(assets, "static")
if err != nil {
panic(err)
}
return sub
}
// WebUI groups all web UI handlers.
type WebUI struct {
authStore *auth.Store
tpStore *db.TrackpointStore
stopStore *db.StopStore
journalStore *db.JournalStore
userStore *db.UserStore
}
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore, u *db.UserStore) *WebUI {
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j, userStore: u}
}
func render(w http.ResponseWriter, page string, data any) {
t, err := tmpls.Clone()
if err == nil {
_, err = t.ParseFS(assets, "templates/"+page)
}
if err != nil {
slog.Error("template parse", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
slog.Error("template execute", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
func renderFragment(w http.ResponseWriter, page, block string, data any) {
t, err := tmpls.Clone()
if err == nil {
_, err = t.ParseFS(assets, "templates/"+page)
}
if err != nil {
slog.Error("template parse", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, block, data); err != nil {
slog.Error("template execute fragment", "page", page, "block", block, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
func renderAdmin(w http.ResponseWriter, page string, data any) {
// Parse layout (admin_base) + specific page fresh each request (no shared state between pages).
t, err := template.New("").Funcs(funcMap).ParseFS(assets,
"templates/admin/layout.html",
"templates/admin/"+page,
)
if err != nil {
slog.Error("template parse admin", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "admin_base", data); err != nil {
slog.Error("template execute admin", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
// --- Auth ---
func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
render(w, "login.html", map[string]any{"Error": "", "Username": ""})
}
func (ui *WebUI) HandlePostLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
sess, err := ui.authStore.Login(r.Context(), username, password)
if err != nil {
msg := "Interner Fehler."
if errors.Is(err, auth.ErrInvalidCredentials) {
msg = "Ungültige Zugangsdaten."
}
render(w, "login.html", map[string]any{"Error": msg, "Username": username})
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sess.SessionID,
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
http.Redirect(w, r, "/days", http.StatusSeeOther)
}
func (ui *WebUI) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err == nil {
ui.authStore.Logout(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(0, 0),
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- Register ---
func (ui *WebUI) HandleGetRegister(w http.ResponseWriter, r *http.Request) {
render(w, "register.html", map[string]any{"Error": "", "Username": ""})
}
func (ui *WebUI) HandlePostRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
confirm := r.FormValue("confirm")
if username == "" || password == "" {
render(w, "register.html", map[string]any{"Error": "Benutzername und Passwort sind Pflichtfelder.", "Username": username})
return
}
if password != confirm {
render(w, "register.html", map[string]any{"Error": "Passwörter stimmen nicht überein.", "Username": username})
return
}
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
msg := "Interner Fehler."
if errors.Is(err, auth.ErrUsernameTaken) {
msg = "Benutzername bereits vergeben."
}
render(w, "register.html", map[string]any{"Error": msg, "Username": username})
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- Public Feed ---
func (ui *WebUI) HandleFeed(w http.ResponseWriter, r *http.Request) {
entries, err := ui.journalStore.ListPublic(r.Context(), 20, 0)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
render(w, "public.html", map[string]any{
"Entries": entries,
"Offset": 20,
"HasMore": len(entries) == 20,
})
}
func (ui *WebUI) HandleFeedFragment(w http.ResponseWriter, r *http.Request) {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
entries, err := ui.journalStore.ListPublic(r.Context(), 20, offset)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
renderFragment(w, "public.html", "feed_items", map[string]any{
"Entries": entries,
"Offset": offset + 20,
"HasMore": len(entries) == 20,
})
}
// --- Days ---
func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
date := strings.TrimSpace(r.URL.Query().Get("date"))
if date == "" {
http.Redirect(w, r, "/days", http.StatusSeeOther)
return
}
if _, err := time.Parse("2006-01-02", date); err != nil {
http.Redirect(w, r, "/days", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
}
func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
now := time.Now().UTC()
from := now.AddDate(-20, 0, 0).Format("2006-01-02")
to := now.AddDate(0, 0, 1).Format("2006-01-02")
days, err := ui.tpStore.ListDays(r.Context(), user.UserID, from, to)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
render(w, "days.html", map[string]any{"Days": days, "IsAdmin": user.IsAdmin})
}
func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
date := chi.URLParam(r, "date")
if date == "" {
http.Error(w, "Datum fehlt", http.StatusBadRequest)
return
}
points, err := ui.tpStore.ListByDate(r.Context(), userID, date)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
stops, err := ui.stopStore.ListByDate(r.Context(), userID, date)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
entries, err := ui.journalStore.ListByDate(r.Context(), userID, date)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
render(w, "day.html", map[string]any{
"Date": date,
"Points": points,
"Stops": stops,
"Entries": entries,
})
}
// --- Admin ---
func (ui *WebUI) HandleAdminEntries(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
entries, err := ui.journalStore.ListByUser(r.Context(), user.UserID)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
renderAdmin(w, "entries.html", map[string]any{"Entries": entries, "User": user})
}
func (ui *WebUI) HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
users, err := ui.userStore.ListUsers(r.Context())
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": ""})
}
func (ui *WebUI) HandleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
user := userFromContext(r.Context())
if username == "" || password == "" {
users, _ := ui.userStore.ListUsers(r.Context())
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": "Benutzername und Passwort erforderlich."})
return
}
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
msg := "Interner Fehler."
if errors.Is(err, auth.ErrUsernameTaken) {
msg = "Benutzername bereits vergeben."
}
users, _ := ui.userStore.ListUsers(r.Context())
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": msg})
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (ui *WebUI) HandleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
targetID := chi.URLParam(r, "id")
currentUser := userFromContext(r.Context())
if targetID == currentUser.UserID {
http.Error(w, "Eigenen Account nicht löschbar", http.StatusBadRequest)
return
}
if err := ui.userStore.DeleteUser(r.Context(), targetID); err != nil {
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}