From 86627f94b1a5698b199023f0b9be0537f3876589 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Tue, 7 Apr 2026 20:53:31 +0200 Subject: [PATCH] Add public feed, admin area, self-registration, visibility & hashtags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/deploy.yml | 7 +- backend/cmd/server/main.go | 3 +- backend/internal/api/journal.go | 16 ++ backend/internal/api/middleware.go | 51 ++++- backend/internal/api/router.go | 52 ++--- backend/internal/api/static/style.css | 14 ++ .../internal/api/templates/admin/entries.html | 33 +++ .../internal/api/templates/admin/layout.html | 25 +++ .../internal/api/templates/admin/users.html | 53 +++++ backend/internal/api/templates/day.html | 62 ++++-- backend/internal/api/templates/days.html | 5 +- backend/internal/api/templates/public.html | 72 +++++++ backend/internal/api/templates/register.html | 20 ++ backend/internal/api/webui.go | 195 +++++++++++++++++- backend/internal/auth/auth.go | 35 ++++ backend/internal/db/journal.go | 76 +++++-- backend/internal/db/schema.sql | 4 + backend/internal/db/users.go | 43 ++++ backend/internal/domain/models.go | 3 + doc/architecture.md | 106 +++++++++- 20 files changed, 783 insertions(+), 92 deletions(-) create mode 100644 backend/internal/api/templates/admin/entries.html create mode 100644 backend/internal/api/templates/admin/layout.html create mode 100644 backend/internal/api/templates/admin/users.html create mode 100644 backend/internal/api/templates/public.html create mode 100644 backend/internal/api/templates/register.html create mode 100644 backend/internal/db/users.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f87b34c..c4efa0c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -24,7 +24,12 @@ jobs: run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env - name: Test - run: cd ${{ vars.DEPLOY_DIR }}/backend && go test ./... + run: | + docker run --rm \ + -v ${{ vars.DEPLOY_DIR }}/backend:/app \ + -w /app \ + golang:1.25-alpine \ + go test ./... - name: Build & Deploy run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9f40769..42f2ae5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -49,8 +49,9 @@ func main() { stopStore := db.NewStopStore(pool) suggStore := db.NewSuggestionStore(pool) journalStore := db.NewJournalStore(pool) + userStore := db.NewUserStore(pool) - router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, uploadDir) + router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, userStore, uploadDir) srv := &http.Server{ Addr: addr, diff --git a/backend/internal/api/journal.go b/backend/internal/api/journal.go index fbfd2d5..db9c19b 100644 --- a/backend/internal/api/journal.go +++ b/backend/internal/api/journal.go @@ -46,6 +46,20 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques entryTime := strings.TrimSpace(r.FormValue("time")) title := strings.TrimSpace(r.FormValue("title")) description := strings.TrimSpace(r.FormValue("description")) + visibility := r.FormValue("visibility") + if visibility != "public" && visibility != "private" { + visibility = "private" + } + var hashtags []string + if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" { + for _, tag := range strings.Split(raw, ",") { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(tag, "#") + if tag != "" { + hashtags = append(hashtags, tag) + } + } + } if date == "" || entryTime == "" { http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest) @@ -58,6 +72,8 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques EntryTime: entryTime, Title: title, Description: description, + Visibility: visibility, + Hashtags: hashtags, } if lat := r.FormValue("lat"); lat != "" { diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index d24ba6d..a7744ea 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -5,39 +5,72 @@ import ( "net/http" "github.com/jacek/pamietnik/backend/internal/auth" + "github.com/jacek/pamietnik/backend/internal/domain" ) type contextKey string const ctxUserID contextKey = "user_id" +const ctxUser contextKey = "user" const sessionCookieName = "session" -// RequireAuth is a middleware that validates the session cookie. +// RequireAuth validates the session cookie and stores user info in context. +// On failure it redirects to /login for browser requests (text/html) or returns JSON 401. func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(sessionCookieName) + user, err := userFromRequest(r, authStore) if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required") + redirectOrUnauthorized(w, r) return } - sess, err := authStore.GetSession(r.Context(), cookie.Value) - if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") - return - } - ctx := context.WithValue(r.Context(), ctxUserID, sess.UserID) + ctx := context.WithValue(r.Context(), ctxUserID, user.UserID) + ctx = context.WithValue(ctx, ctxUser, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } } +// requireAdmin checks that the authenticated user is an admin. +func requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := r.Context().Value(ctxUser).(domain.User) + if !ok || !u.IsAdmin { + http.Redirect(w, r, "/days", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func userFromRequest(r *http.Request, authStore *auth.Store) (domain.User, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return domain.User{}, auth.ErrSessionNotFound + } + return authStore.GetUserBySession(r.Context(), cookie.Value) +} + +func redirectOrUnauthorized(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + if len(accept) > 0 && (accept == "application/json" || r.Header.Get("X-Requested-With") == "XMLHttpRequest") { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required") + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + func userIDFromContext(ctx context.Context) string { v, _ := ctx.Value(ctxUserID).(string) return v } +func userFromContext(ctx context.Context) domain.User { + v, _ := ctx.Value(ctxUser).(domain.User) + return v +} + func contextWithUserID(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, ctxUserID, userID) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 8487c5d..a3fb6c2 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -16,6 +16,7 @@ func NewRouter( stopStore *db.StopStore, suggStore *db.SuggestionStore, journalStore *db.JournalStore, + userStore *db.UserStore, uploadDir string, ) http.Handler { r := chi.NewRouter() @@ -23,10 +24,9 @@ func NewRouter( r.Use(middleware.Logger) r.Use(middleware.Recoverer) - webUI := NewWebUI(authStore, tpStore, stopStore, journalStore) + webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore) journalHandler := NewJournalHandler(journalStore, uploadDir) authMW := RequireAuth(authStore) - webAuthMW := requireWebAuth(authStore) // Health r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { @@ -55,19 +55,37 @@ func NewRouter( // Static assets (CSS etc.) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS())))) - // Web UI + // Public routes (no auth required) + r.Get("/", webUI.HandleFeed) + r.Get("/feed", webUI.HandleFeedFragment) + r.Get("/register", webUI.HandleGetRegister) + r.Post("/register", webUI.HandlePostRegister) r.Get("/login", webUI.HandleGetLogin) r.Post("/login", webUI.HandlePostLogin) r.Post("/logout", webUI.HandleLogout) + // Authenticated web routes r.Group(func(r chi.Router) { - r.Use(webAuthMW) + r.Use(authMW) r.Get("/days", webUI.HandleDaysList) r.Get("/days/redirect", webUI.HandleDaysRedirect) r.Get("/days/{date}", webUI.HandleDayDetail) r.Post("/entries", journalHandler.HandleCreateEntry) }) + // Admin routes + r.Group(func(r chi.Router) { + r.Use(authMW) + r.Use(requireAdmin) + r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin/entries", http.StatusSeeOther) + }) + r.Get("/admin/entries", webUI.HandleAdminEntries) + r.Get("/admin/users", webUI.HandleAdminUsers) + r.Post("/admin/users", webUI.HandleAdminCreateUser) + r.Delete("/admin/users/{id}", webUI.HandleAdminDeleteUser) + }) + // Serve uploaded images r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) @@ -76,31 +94,5 @@ func NewRouter( r.Handle(spaPrefix, http.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently)) r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix))) - // Redirect root to Go Web UI /days - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/days", http.StatusSeeOther) - }) - return r } - -// requireWebAuth redirects to /login for unauthenticated web users (HTML response). -func requireWebAuth(authStore *auth.Store) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(sessionCookieName) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - sess, err := authStore.GetSession(r.Context(), cookie.Value) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - ctx := r.Context() - ctx = contextWithUserID(ctx, sess.UserID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} diff --git a/backend/internal/api/static/style.css b/backend/internal/api/static/style.css index e6a337e..5fbe669 100644 --- a/backend/internal/api/static/style.css +++ b/backend/internal/api/static/style.css @@ -52,3 +52,17 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } /* Login */ .login-box { max-width: 360px; margin: 4rem auto; } + +/* Error message */ +.error { color: #c44; } + +/* Hashtags */ +.hashtags { margin-top: .3rem; } +.tag { font-size: .75rem; background: var(--pico-muted-background-color); padding: .1rem .4rem; border-radius: 999px; margin-right: .2rem; } + +/* Visibility badge */ +.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; } + +/* Delete button */ +.btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; } +.btn-delete:hover { background: #c44; color: #fff; } diff --git a/backend/internal/api/templates/admin/entries.html b/backend/internal/api/templates/admin/entries.html new file mode 100644 index 0000000..11d4235 --- /dev/null +++ b/backend/internal/api/templates/admin/entries.html @@ -0,0 +1,33 @@ +{{define "admin_title"}}Einträge verwalten — Admin{{end}} + +{{define "admin_content"}} +

Einträge

+

→ Neuen Eintrag anlegen (Tagesansicht)

+ +{{if .Entries}} +
+ + + + {{range .Entries}} + + + + + + + + {{end}} + +
DatumZeitTitelSichtbarkeitHashtags
{{.EntryDate}}{{.EntryTime}}{{if .Title}}{{.Title}}{{else}}{{end}} + {{if eq .Visibility "public"}} + öffentlich + {{else}} + privat + {{end}} + {{join .Hashtags ", "}}
+
+{{else}} +

// Noch keine Einträge

+{{end}} +{{end}} diff --git a/backend/internal/api/templates/admin/layout.html b/backend/internal/api/templates/admin/layout.html new file mode 100644 index 0000000..a65a668 --- /dev/null +++ b/backend/internal/api/templates/admin/layout.html @@ -0,0 +1,25 @@ +{{define "admin_base"}} + + + + + {{block "admin_title" .}}Admin{{end}} + + + + +
+ + {{block "admin_content" .}}{{end}} +
+{{block "admin_scripts" .}}{{end}} + + +{{end}} diff --git a/backend/internal/api/templates/admin/users.html b/backend/internal/api/templates/admin/users.html new file mode 100644 index 0000000..e54934a --- /dev/null +++ b/backend/internal/api/templates/admin/users.html @@ -0,0 +1,53 @@ +{{define "admin_title"}}Benutzer verwalten — Admin{{end}} + +{{define "admin_content"}} +

Benutzer

+ +{{if .Error}}

{{.Error}}

{{end}} + +
+
+ + +
+
+ + +
+ +
+ +
+ + + + {{range .Users}} + + + + + + + {{end}} + +
BenutzernameAdminErstellt
{{.Username}}{{if .IsAdmin}}✓{{end}}{{.CreatedAt.Format "2006-01-02"}} + {{if ne .UserID $.User.UserID}} + + {{else}} + (du) + {{end}} +
+
+{{end}} + +{{define "admin_scripts"}} + +{{end}} diff --git a/backend/internal/api/templates/day.html b/backend/internal/api/templates/day.html index f2db36b..19bdcb5 100644 --- a/backend/internal/api/templates/day.html +++ b/backend/internal/api/templates/day.html @@ -13,6 +13,15 @@ +
+ + +
+ +
@@ -22,6 +31,10 @@
+
+ + +
@@ -38,10 +51,12 @@
{{if .Title}}
{{.Title}}
{{end}} {{if .Description}}
{{.Description}}
{{end}} + {{if .Hashtags}}
{{range .Hashtags}}#{{.}} {{end}}
{{end}} {{if .Images}}
{{range .Images}} @@ -56,27 +71,8 @@

// Noch keine Einträge

{{end}} -

Trackpunkte ({{len .Points}})

-
- - - - {{range .Points}} - - - - - - - - {{else}} - - {{end}} - -
ZeitLatLonQuelleNotiz
{{.Timestamp.Format "15:04:05"}}{{printf "%.5f" .Lat}}{{printf "%.5f" .Lon}}{{.Source}}{{.Note}}
// Keine Punkte
-
-

Aufenthalte ({{len .Stops}})

+ {{if .Stops}}
@@ -88,12 +84,34 @@ - {{else}} - {{end}}
VonBisDauerOrt
{{divInt .DurationS 60}} min {{if .PlaceLabel}}{{.PlaceLabel}}{{else}}{{end}}
// Keine Aufenthalte
+ {{else}} +

// Keine Aufenthalte

+ {{end}} + +
+ Trackpunkte ({{len .Points}}) +
+ + + + {{range .Points}} + + + + + + + {{else}} + + {{end}} + +
ZeitLatLonQuelle
{{.Timestamp.Format "15:04:05"}}{{printf "%.5f" .Lat}}{{printf "%.5f" .Lon}}{{.Source}}
// Keine Punkte
+
+
{{end}} diff --git a/backend/internal/api/templates/days.html b/backend/internal/api/templates/days.html index 6f517d5..888d931 100644 --- a/backend/internal/api/templates/days.html +++ b/backend/internal/api/templates/days.html @@ -4,7 +4,10 @@
diff --git a/backend/internal/api/templates/public.html b/backend/internal/api/templates/public.html new file mode 100644 index 0000000..5992cf1 --- /dev/null +++ b/backend/internal/api/templates/public.html @@ -0,0 +1,72 @@ +{{define "title"}}Journal — Öffentliche Einträge{{end}} + +{{define "content"}} +
+ + +
+ {{template "feed_items" .}} +
+
+{{end}} + +{{define "feed_items"}} +{{range .Entries}} +
+
+ {{.EntryDate}} · {{.EntryTime}} + {{if .Title}} · {{.Title}}{{end}} +
+ {{if .Description}}

{{.Description}}

{{end}} + {{if .Images}} +
+ {{range .Images}} + + {{.OriginalName}} + + {{end}} +
+ {{end}} + {{if .Hashtags}} +
+ {{range .Hashtags}}#{{.}} {{end}} +
+ {{end}} +
+{{else}} +

// Noch keine öffentlichen Einträge

+{{end}} +{{if .HasMore}} +
+{{end}} +{{end}} + +{{define "scripts"}} + +{{end}} + +{{template "base" .}} diff --git a/backend/internal/api/templates/register.html b/backend/internal/api/templates/register.html new file mode 100644 index 0000000..9e4b9e2 --- /dev/null +++ b/backend/internal/api/templates/register.html @@ -0,0 +1,20 @@ +{{define "title"}}Registrieren — Journal{{end}} + +{{define "content"}} +
+

Konto erstellen

+ {{if .Error}}

{{.Error}}

{{end}} + + + + + + + + + +

Bereits registriert? Anmelden

+
+{{end}} + +{{template "base" .}} diff --git a/backend/internal/api/webui.go b/backend/internal/api/webui.go index f0862d2..0cb877b 100644 --- a/backend/internal/api/webui.go +++ b/backend/internal/api/webui.go @@ -8,6 +8,7 @@ import ( "io/fs" "log/slog" "net/http" + "strconv" "strings" "time" @@ -27,6 +28,7 @@ var funcMap = template.FuncMap{ } return *p }, + "join": strings.Join, } var tmpls = template.Must( @@ -47,17 +49,14 @@ type WebUI struct { 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) *WebUI { - return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j} +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) { - // 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) @@ -67,8 +66,6 @@ func render(w http.ResponseWriter, page string, data any) { 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) @@ -79,6 +76,49 @@ func render(w http.ResponseWriter, page string, data any) { _, _ = 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": ""}) } @@ -128,6 +168,75 @@ func (ui *WebUI) HandleLogout(w http.ResponseWriter, r *http.Request) { 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 == "" { @@ -142,16 +251,16 @@ func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) { } func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) { - userID := userIDFromContext(r.Context()) + 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(), userID, from, to) + 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}) + render(w, "days.html", map[string]any{"Days": days, "IsAdmin": user.IsAdmin}) } func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) { @@ -186,3 +295,67 @@ func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) { "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) +} diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index e262459..1c5572e 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -21,6 +21,7 @@ const sessionDuration = 24 * time.Hour var ErrInvalidCredentials = errors.New("invalid username or password") var ErrSessionNotFound = errors.New("session not found or expired") +var ErrUsernameTaken = errors.New("username already taken") type Store struct { pool *pgxpool.Pool @@ -128,6 +129,40 @@ func (s *Store) Logout(ctx context.Context, sessionID string) error { return nil } +// Register creates a new user account. Returns ErrUsernameTaken if the username is already in use. +func (s *Store) Register(ctx context.Context, username, password string) error { + hash, err := HashPassword(password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + _, err = s.pool.Exec(ctx, + `INSERT INTO users (username, password_hash) VALUES ($1, $2)`, + username, hash, + ) + if err != nil && strings.Contains(err.Error(), "unique") { + return ErrUsernameTaken + } + return err +} + +// GetUserBySession returns the full user (including is_admin) for a session. +func (s *Store) GetUserBySession(ctx context.Context, sessionID string) (domain.User, error) { + var u domain.User + err := s.pool.QueryRow(ctx, + `SELECT u.user_id, u.username, u.is_admin, u.created_at + FROM sessions s JOIN users u ON s.user_id = u.user_id + WHERE s.session_id = $1 AND s.expires_at > NOW()`, + sessionID, + ).Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, ErrSessionNotFound + } + return domain.User{}, err + } + return u, nil +} + func newSessionID() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/backend/internal/db/journal.go b/backend/internal/db/journal.go index f400b00..8413129 100644 --- a/backend/internal/db/journal.go +++ b/backend/internal/db/journal.go @@ -3,6 +3,7 @@ package db import ( "context" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jacek/pamietnik/backend/internal/domain" @@ -18,11 +19,17 @@ func NewJournalStore(pool *pgxpool.Pool) *JournalStore { // InsertEntry creates a new journal entry and returns it with the generated entry_id. func (s *JournalStore) InsertEntry(ctx context.Context, e domain.JournalEntry) (domain.JournalEntry, error) { + if e.Visibility == "" { + e.Visibility = "private" + } + if e.Hashtags == nil { + e.Hashtags = []string{} + } err := s.pool.QueryRow(ctx, - `INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon, visibility, hashtags) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING entry_id, created_at`, - e.UserID, e.EntryDate, e.EntryTime, e.Title, e.Description, e.Lat, e.Lon, + e.UserID, e.EntryDate, e.EntryTime, e.Title, e.Description, e.Lat, e.Lon, e.Visibility, e.Hashtags, ).Scan(&e.EntryID, &e.CreatedAt) return e, err } @@ -41,7 +48,7 @@ func (s *JournalStore) InsertImage(ctx context.Context, img domain.JournalImage) // ListByDate returns all journal entries for a given date (YYYY-MM-DD), including their images. func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) { rows, err := s.pool.Query(ctx, - `SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, created_at + `SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at FROM journal_entries WHERE user_id = $1 AND entry_date = $2 ORDER BY entry_time`, @@ -51,32 +58,78 @@ func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]d return nil, err } defer rows.Close() + entries, err := collectEntries(rows) + if err != nil { + return nil, err + } + return s.attachImages(ctx, entries) +} +// ListPublic returns public journal entries ordered by created_at DESC, for infinite scroll. +func (s *JournalStore) ListPublic(ctx context.Context, limit, offset int) ([]domain.JournalEntry, error) { + rows, err := s.pool.Query(ctx, + `SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at + FROM journal_entries + WHERE visibility = 'public' + ORDER BY created_at DESC + LIMIT $1 OFFSET $2`, + limit, offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + entries, err := collectEntries(rows) + if err != nil { + return nil, err + } + return s.attachImages(ctx, entries) +} + +// ListByUser returns all entries for a user, ordered by entry_date DESC, entry_time DESC. +func (s *JournalStore) ListByUser(ctx context.Context, userID string) ([]domain.JournalEntry, error) { + rows, err := s.pool.Query(ctx, + `SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at + FROM journal_entries + WHERE user_id = $1 + ORDER BY entry_date DESC, entry_time DESC`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + entries, err := collectEntries(rows) + if err != nil { + return nil, err + } + return s.attachImages(ctx, entries) +} + +func collectEntries(rows pgx.Rows) ([]domain.JournalEntry, error) { var entries []domain.JournalEntry for rows.Next() { var e domain.JournalEntry if err := rows.Scan( &e.EntryID, &e.UserID, &e.EntryDate, &e.EntryTime, - &e.Title, &e.Description, &e.Lat, &e.Lon, &e.CreatedAt, + &e.Title, &e.Description, &e.Lat, &e.Lon, &e.Visibility, &e.Hashtags, &e.CreatedAt, ); err != nil { return nil, err } entries = append(entries, e) } - if err := rows.Err(); err != nil { - return nil, err - } + return entries, rows.Err() +} +// attachImages loads images for the given entries in a single query and populates .Images. +func (s *JournalStore) attachImages(ctx context.Context, entries []domain.JournalEntry) ([]domain.JournalEntry, error) { if len(entries) == 0 { return entries, nil } - - // Load all images in a single query to avoid N+1 entryIDs := make([]string, len(entries)) for i, e := range entries { entryIDs[i] = e.EntryID } - imgRows, err := s.pool.Query(ctx, `SELECT image_id, entry_id, filename, original_name, mime_type, size_bytes, created_at FROM journal_images WHERE entry_id = ANY($1) ORDER BY created_at`, @@ -101,7 +154,6 @@ func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]d if err := imgRows.Err(); err != nil { return nil, err } - for i, e := range entries { entries[i].Images = imgMap[e.EntryID] } diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index d42f4b0..29d9bfb 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -78,9 +79,12 @@ CREATE TABLE IF NOT EXISTS journal_entries ( description TEXT NOT NULL DEFAULT '', lat DOUBLE PRECISION, lon DOUBLE PRECISION, + visibility TEXT NOT NULL DEFAULT 'private', + hashtags TEXT[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS journal_entries_user_date_idx ON journal_entries(user_id, entry_date); +CREATE INDEX IF NOT EXISTS journal_entries_public_idx ON journal_entries(visibility, created_at DESC); CREATE TABLE IF NOT EXISTS journal_images ( image_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, diff --git a/backend/internal/db/users.go b/backend/internal/db/users.go new file mode 100644 index 0000000..3a19908 --- /dev/null +++ b/backend/internal/db/users.go @@ -0,0 +1,43 @@ +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/jacek/pamietnik/backend/internal/domain" +) + +type UserStore struct { + pool *pgxpool.Pool +} + +func NewUserStore(pool *pgxpool.Pool) *UserStore { + return &UserStore{pool: pool} +} + +// ListUsers returns all users ordered by created_at. +func (s *UserStore) ListUsers(ctx context.Context) ([]domain.User, error) { + rows, err := s.pool.Query(ctx, + `SELECT user_id, username, is_admin, created_at FROM users ORDER BY created_at`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var users []domain.User + for rows.Next() { + var u domain.User + if err := rows.Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt); err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +// DeleteUser removes a user by ID. +func (s *UserStore) DeleteUser(ctx context.Context, userID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM users WHERE user_id = $1`, userID) + return err +} diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index f2bcba5..cc76bf6 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -56,6 +56,8 @@ type JournalEntry struct { Description string `json:"description"` Lat *float64 `json:"lat,omitempty"` Lon *float64 `json:"lon,omitempty"` + Visibility string `json:"visibility"` // "public" | "private" + Hashtags []string `json:"hashtags"` CreatedAt time.Time `json:"created_at"` Images []JournalImage `json:"images,omitempty"` } @@ -74,6 +76,7 @@ type User struct { UserID string `json:"user_id"` Username string `json:"username"` PasswordHash string `json:"-"` + IsAdmin bool `json:"is_admin"` CreatedAt time.Time `json:"created_at"` } diff --git a/doc/architecture.md b/doc/architecture.md index 0ea1cf9..3921abd 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -178,16 +178,17 @@ backend/ │ ├── trackpoints.go UpsertBatch, ListByDate, ListDays, EnsureDevice │ ├── stops.go ListByDate │ ├── suggestions.go ListByDate - │ └── journal.go CRUD Journal Entries + Images + │ ├── journal.go CRUD Journal Entries + Images; ListPublic, ListByUser + │ └── users.go ListUsers, DeleteUser ├── auth/ - │ └── auth.go HashPassword, VerifyPassword, Login, GetSession, Logout + │ └── auth.go HashPassword, VerifyPassword, Login, Register, GetSession, Logout └── api/ ├── router.go chi Routing, Middleware-Gruppen - ├── middleware.go RequireAuth (Session Cookie → Context) + ├── middleware.go RequireAuth, requireAdmin (Session Cookie → Context) ├── ingest.go HandleSingleTrackpoint, HandleBatchTrackpoints ├── query.go HandleListDays, HandleListTrackpoints, Stops, Suggestions - ├── webui.go Server-side rendered Web UI (Go Templates) - ├── journal.go Journal Entry Endpoints + ├── webui.go Web UI: Feed, Register, Days, Admin-Handlers + ├── journal.go Journal Entry Endpoints (inkl. visibility + hashtags) └── response.go writeJSON, writeError helpers ``` @@ -365,6 +366,101 @@ Schema wird beim API-Start automatisch initialisiert (keine separate Migration n --- +## 7b. Datenbankschema + +```mermaid +erDiagram + users { + TEXT user_id PK + TEXT username UK + TEXT password_hash + BOOLEAN is_admin + TIMESTAMPTZ created_at + } + sessions { + TEXT session_id PK + TEXT user_id FK + TIMESTAMPTZ created_at + TIMESTAMPTZ expires_at + } + devices { + TEXT device_id PK + TEXT user_id FK + TIMESTAMPTZ created_at + } + trackpoints { + BIGSERIAL id PK + TEXT event_id + TEXT device_id FK + TEXT trip_id + TIMESTAMPTZ ts + DOUBLE lat + DOUBLE lon + TEXT source + TEXT note + } + stops { + TEXT stop_id PK + TEXT device_id FK + TEXT trip_id + TIMESTAMPTZ start_ts + TIMESTAMPTZ end_ts + DOUBLE center_lat + DOUBLE center_lon + INT duration_s + TEXT place_label + } + suggestions { + TEXT suggestion_id PK + TEXT stop_id FK + TEXT type + TEXT title + TEXT text + TIMESTAMPTZ created_at + TIMESTAMPTZ dismissed_at + } + journal_entries { + TEXT entry_id PK + TEXT user_id FK + DATE entry_date + TIME entry_time + TEXT title + TEXT description + DOUBLE lat + DOUBLE lon + TEXT visibility + TEXT[] hashtags + TIMESTAMPTZ created_at + } + journal_images { + TEXT image_id PK + TEXT entry_id FK + TEXT filename + TEXT original_name + TEXT mime_type + BIGINT size_bytes + TIMESTAMPTZ created_at + } + + users ||--o{ sessions : has + users ||--o{ devices : owns + users ||--o{ journal_entries : writes + devices ||--o{ trackpoints : records + stops ||--o{ suggestions : generates + journal_entries ||--o{ journal_images : contains +``` + +**Wichtige Felder:** + +| Tabelle | Feld | Bedeutung | +|---------|------|-----------| +| `users` | `is_admin` | Admin-Flag für Zugang zum Admin-Bereich | +| `journal_entries` | `visibility` | `public` = im öffentlichen Feed sichtbar; `private` = nur für Autor | +| `journal_entries` | `hashtags` | Kommagetrennte Tags als `TEXT[]`-Array | +| `trackpoints` | `(device_id, event_id)` | UNIQUE-Constraint für Idempotenz | + +--- + ## 8. Querschnittskonzepte ### Authentifizierung & Sessions (REQ-AUTH-01, REQ-AUTH-02, DEC-AUTH-01)