Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Remove submodule tracking; backend is now a plain directory in the repo. Also update deploy workflow: remove --recurse-submodules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
5.0 KiB
Go
189 lines
5.0 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"errors"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"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
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore) *WebUI {
|
|
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j}
|
|
}
|
|
|
|
func render(w http.ResponseWriter, page string, data any) {
|
|
// Each page defines its own blocks; "base" assembles the full document.
|
|
// We must clone and re-associate per request because ParseFS loads all
|
|
// templates into one set — ExecuteTemplate("base") picks up the blocks
|
|
// defined by the last parsed file otherwise.
|
|
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
|
|
}
|
|
// Render into buffer first so we can still send a proper error status
|
|
// if execution fails — once we write to w the status code is committed.
|
|
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 (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: true,
|
|
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)
|
|
}
|
|
|
|
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) {
|
|
userID := userIDFromContext(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(), userID, from, to)
|
|
if err != nil {
|
|
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
render(w, "days.html", map[string]any{"Days": days})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|