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) }