Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s

- 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>
This commit is contained in:
Christoph K.
2026-04-07 20:53:31 +02:00
parent 034d16e059
commit 86627f94b1
20 changed files with 783 additions and 92 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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 != "" {

View File

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

View File

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

View File

@@ -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; }

View File

@@ -0,0 +1,33 @@
{{define "admin_title"}}Einträge verwalten — Admin{{end}}
{{define "admin_content"}}
<h1>Einträge</h1>
<p><a href="/days">→ Neuen Eintrag anlegen (Tagesansicht)</a></p>
{{if .Entries}}
<figure>
<table>
<thead><tr><th>Datum</th><th>Zeit</th><th>Titel</th><th>Sichtbarkeit</th><th>Hashtags</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td><a href="/days/{{.EntryDate}}">{{.EntryDate}}</a></td>
<td>{{.EntryTime}}</td>
<td>{{if .Title}}{{.Title}}{{else}}<small></small>{{end}}</td>
<td>
{{if eq .Visibility "public"}}
<span class="badge-public">öffentlich</span>
{{else}}
<small>privat</small>
{{end}}
</td>
<td><small>{{join .Hashtags ", "}}</small></td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{else}}
<p><small>// Noch keine Einträge</small></p>
{{end}}
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "admin_base"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "admin_title" .}}Admin{{end}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.slate.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<nav>
<strong>Admin</strong>
<span>
<a href="/admin/entries">Einträge</a> ·
<a href="/admin/users">Benutzer</a> ·
<a href="/days">← App</a>
</span>
</nav>
{{block "admin_content" .}}{{end}}
</main>
{{block "admin_scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "admin_title"}}Benutzer verwalten — Admin{{end}}
{{define "admin_content"}}
<h1>Benutzer</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/admin/users" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap">
<div>
<label>Benutzername</label>
<input type="text" name="username" required autocomplete="off">
</div>
<div>
<label>Passwort</label>
<input type="password" name="password" required autocomplete="new-password">
</div>
<button type="submit">Anlegen</button>
</form>
<figure>
<table>
<thead><tr><th>Benutzername</th><th>Admin</th><th>Erstellt</th><th></th></tr></thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Username}}</td>
<td>{{if .IsAdmin}}✓{{end}}</td>
<td><small>{{.CreatedAt.Format "2006-01-02"}}</small></td>
<td>
{{if ne .UserID $.User.UserID}}
<button class="btn-delete" data-url="/admin/users/{{.UserID}}" data-name="{{.Username}}">Löschen</button>
{{else}}
<small>(du)</small>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{end}}
{{define "admin_scripts"}}
<script>
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Benutzer "' + btn.dataset.name + '" löschen?')) return;
fetch(btn.dataset.url, {method: 'DELETE'})
.then(function() { window.location.reload(); });
});
});
</script>
{{end}}

View File

@@ -13,6 +13,15 @@
<label>Uhrzeit</label>
<input type="time" name="time" required id="entry-time">
</div>
<div class="form-col">
<label>Sichtbarkeit</label>
<select name="visibility">
<option value="private">Privat</option>
<option value="public">Öffentlich</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-col">
<label>GPS-Koordinaten <small>(optional)</small></label>
<div class="gps-row">
@@ -22,6 +31,10 @@
</div>
<small id="gps-status"></small>
</div>
<div class="form-col">
<label>Hashtags <small>(kommagetrennt, optional)</small></label>
<input type="text" name="hashtags" placeholder="reise, essen, natur">
</div>
</div>
<label>Überschrift</label>
<input type="text" name="title" placeholder="Titel des Eintrags">
@@ -38,10 +51,12 @@
<div class="entry-card">
<div class="entry-meta">
<strong>{{.EntryTime}}</strong>
{{if eq .Visibility "public"}}<span class="badge-public">öffentlich</span>{{end}}
{{if .Lat}}<small> · &#9675; {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
</div>
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
{{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
{{if .Images}}
<div class="entry-images">
{{range .Images}}
@@ -56,27 +71,8 @@
<p><small>// Noch keine Einträge</small></p>
{{end}}
<h2>Trackpunkte <small>({{len .Points}})</small></h2>
<figure>
<table>
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th><th>Notiz</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{.Timestamp.Format "15:04:05"}}</td>
<td>{{printf "%.5f" .Lat}}</td>
<td>{{printf "%.5f" .Lon}}</td>
<td class="source-{{.Source}}">{{.Source}}</td>
<td><small>{{.Note}}</small></td>
</tr>
{{else}}
<tr><td colspan="5"><small>// Keine Punkte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
<h2>Aufenthalte <small>({{len .Stops}})</small></h2>
{{if .Stops}}
<figure>
<table>
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead>
@@ -88,12 +84,34 @@
<td><small>{{divInt .DurationS 60}} min</small></td>
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small></small>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4"><small>// Keine Aufenthalte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
{{else}}
<p><small>// Keine Aufenthalte</small></p>
{{end}}
<details>
<summary><small>Trackpunkte ({{len .Points}})</small></summary>
<figure>
<table>
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{.Timestamp.Format "15:04:05"}}</td>
<td>{{printf "%.5f" .Lat}}</td>
<td>{{printf "%.5f" .Lon}}</td>
<td class="source-{{.Source}}">{{.Source}}</td>
</tr>
{{else}}
<tr><td colspan="4"><small>// Keine Punkte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
</details>
</main>
{{end}}

View File

@@ -4,7 +4,10 @@
<main class="container">
<div class="page-header">
<h1>REISEJOURNAL</h1>
<a href="/logout">[ Ausloggen ]</a>
<span>
{{if .IsAdmin}}<a href="/admin">[ Admin ]</a> · {{end}}
<a href="/logout">[ Ausloggen ]</a>
</span>
</div>
<form method="get" action="/days/redirect">
<fieldset role="group">

View File

@@ -0,0 +1,72 @@
{{define "title"}}Journal — Öffentliche Einträge{{end}}
{{define "content"}}
<main class="container">
<nav>
<strong>Journal</strong>
<a href="/login">Anmelden</a>
</nav>
<div id="feed">
{{template "feed_items" .}}
</div>
</main>
{{end}}
{{define "feed_items"}}
{{range .Entries}}
<article class="entry-card">
<header>
<small>{{.EntryDate}} · {{.EntryTime}}</small>
{{if .Title}}<strong> · {{.Title}}</strong>{{end}}
</header>
{{if .Description}}<p>{{.Description}}</p>{{end}}
{{if .Images}}
<div class="entry-images">
{{range .Images}}
<a href="/uploads/{{.Filename}}" target="_blank">
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
</a>
{{end}}
</div>
{{end}}
{{if .Hashtags}}
<footer class="hashtags">
{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}
</footer>
{{end}}
</article>
{{else}}
<p><small>// Noch keine öffentlichen Einträge</small></p>
{{end}}
{{if .HasMore}}
<div id="sentinel" data-offset="{{.Offset}}"></div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
(function() {
const sentinel = document.getElementById('sentinel');
if (!sentinel) return;
const obs = new IntersectionObserver(function(entries) {
if (!entries[0].isIntersecting) return;
obs.disconnect();
const offset = sentinel.dataset.offset;
fetch('/feed?offset=' + offset)
.then(r => r.text())
.then(html => {
sentinel.remove();
const div = document.createElement('div');
div.innerHTML = html;
document.getElementById('feed').append(...div.childNodes);
const next = document.getElementById('sentinel');
if (next) obs.observe(next);
});
});
obs.observe(sentinel);
})();
</script>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,20 @@
{{define "title"}}Registrieren — Journal{{end}}
{{define "content"}}
<main class="container" style="max-width:400px;margin-top:4rem">
<h1>Konto erstellen</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/register">
<label>Benutzername</label>
<input type="text" name="username" value="{{.Username}}" required autofocus autocomplete="username">
<label>Passwort</label>
<input type="password" name="password" required autocomplete="new-password">
<label>Passwort bestätigen</label>
<input type="password" name="confirm" required autocomplete="new-password">
<button type="submit">Registrieren</button>
</form>
<p><small>Bereits registriert? <a href="/login">Anmelden</a></small></p>
</main>
{{end}}
{{template "base" .}}

View File

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

View File

@@ -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 {

View File

@@ -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]
}

View File

@@ -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,

View File

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

View File

@@ -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"`
}

View File

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