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 run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env
- name: Test - 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 - name: Build & Deploy
run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d 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) stopStore := db.NewStopStore(pool)
suggStore := db.NewSuggestionStore(pool) suggStore := db.NewSuggestionStore(pool)
journalStore := db.NewJournalStore(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{ srv := &http.Server{
Addr: addr, Addr: addr,

View File

@@ -46,6 +46,20 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
entryTime := strings.TrimSpace(r.FormValue("time")) entryTime := strings.TrimSpace(r.FormValue("time"))
title := strings.TrimSpace(r.FormValue("title")) title := strings.TrimSpace(r.FormValue("title"))
description := strings.TrimSpace(r.FormValue("description")) 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 == "" { if date == "" || entryTime == "" {
http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest) 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, EntryTime: entryTime,
Title: title, Title: title,
Description: description, Description: description,
Visibility: visibility,
Hashtags: hashtags,
} }
if lat := r.FormValue("lat"); lat != "" { if lat := r.FormValue("lat"); lat != "" {

View File

@@ -5,39 +5,72 @@ import (
"net/http" "net/http"
"github.com/jacek/pamietnik/backend/internal/auth" "github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/domain"
) )
type contextKey string type contextKey string
const ctxUserID contextKey = "user_id" const ctxUserID contextKey = "user_id"
const ctxUser contextKey = "user"
const sessionCookieName = "session" 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 { func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName) user, err := userFromRequest(r, authStore)
if err != nil { if err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required") redirectOrUnauthorized(w, r)
return return
} }
sess, err := authStore.GetSession(r.Context(), cookie.Value) ctx := context.WithValue(r.Context(), ctxUserID, user.UserID)
if err != nil { ctx = context.WithValue(ctx, ctxUser, user)
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
return
}
ctx := context.WithValue(r.Context(), ctxUserID, sess.UserID)
next.ServeHTTP(w, r.WithContext(ctx)) 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 { func userIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(ctxUserID).(string) v, _ := ctx.Value(ctxUserID).(string)
return v 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 { func contextWithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, ctxUserID, userID) return context.WithValue(ctx, ctxUserID, userID)
} }

View File

@@ -16,6 +16,7 @@ func NewRouter(
stopStore *db.StopStore, stopStore *db.StopStore,
suggStore *db.SuggestionStore, suggStore *db.SuggestionStore,
journalStore *db.JournalStore, journalStore *db.JournalStore,
userStore *db.UserStore,
uploadDir string, uploadDir string,
) http.Handler { ) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
@@ -23,10 +24,9 @@ func NewRouter(
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore) webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore)
journalHandler := NewJournalHandler(journalStore, uploadDir) journalHandler := NewJournalHandler(journalStore, uploadDir)
authMW := RequireAuth(authStore) authMW := RequireAuth(authStore)
webAuthMW := requireWebAuth(authStore)
// Health // Health
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
@@ -55,19 +55,37 @@ func NewRouter(
// Static assets (CSS etc.) // Static assets (CSS etc.)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS())))) 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.Get("/login", webUI.HandleGetLogin)
r.Post("/login", webUI.HandlePostLogin) r.Post("/login", webUI.HandlePostLogin)
r.Post("/logout", webUI.HandleLogout) r.Post("/logout", webUI.HandleLogout)
// Authenticated web routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(webAuthMW) r.Use(authMW)
r.Get("/days", webUI.HandleDaysList) r.Get("/days", webUI.HandleDaysList)
r.Get("/days/redirect", webUI.HandleDaysRedirect) r.Get("/days/redirect", webUI.HandleDaysRedirect)
r.Get("/days/{date}", webUI.HandleDayDetail) r.Get("/days/{date}", webUI.HandleDayDetail)
r.Post("/entries", journalHandler.HandleCreateEntry) 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 // Serve uploaded images
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) 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.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently))
r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix))) 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 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 */
.login-box { max-width: 360px; margin: 4rem auto; } .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> <label>Uhrzeit</label>
<input type="time" name="time" required id="entry-time"> <input type="time" name="time" required id="entry-time">
</div> </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"> <div class="form-col">
<label>GPS-Koordinaten <small>(optional)</small></label> <label>GPS-Koordinaten <small>(optional)</small></label>
<div class="gps-row"> <div class="gps-row">
@@ -22,6 +31,10 @@
</div> </div>
<small id="gps-status"></small> <small id="gps-status"></small>
</div> </div>
<div class="form-col">
<label>Hashtags <small>(kommagetrennt, optional)</small></label>
<input type="text" name="hashtags" placeholder="reise, essen, natur">
</div>
</div> </div>
<label>Überschrift</label> <label>Überschrift</label>
<input type="text" name="title" placeholder="Titel des Eintrags"> <input type="text" name="title" placeholder="Titel des Eintrags">
@@ -38,10 +51,12 @@
<div class="entry-card"> <div class="entry-card">
<div class="entry-meta"> <div class="entry-meta">
<strong>{{.EntryTime}}</strong> <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}} {{if .Lat}}<small> · &#9675; {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
</div> </div>
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}} {{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
{{if .Description}}<div class="entry-desc">{{.Description}}</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}} {{if .Images}}
<div class="entry-images"> <div class="entry-images">
{{range .Images}} {{range .Images}}
@@ -56,27 +71,8 @@
<p><small>// Noch keine Einträge</small></p> <p><small>// Noch keine Einträge</small></p>
{{end}} {{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> <h2>Aufenthalte <small>({{len .Stops}})</small></h2>
{{if .Stops}}
<figure> <figure>
<table> <table>
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead> <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><small>{{divInt .DurationS 60}} min</small></td>
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small></small>{{end}}</td> <td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small></small>{{end}}</td>
</tr> </tr>
{{else}}
<tr><td colspan="4"><small>// Keine Aufenthalte</small></td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</figure> </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> </main>
{{end}} {{end}}

View File

@@ -4,7 +4,10 @@
<main class="container"> <main class="container">
<div class="page-header"> <div class="page-header">
<h1>REISEJOURNAL</h1> <h1>REISEJOURNAL</h1>
<a href="/logout">[ Ausloggen ]</a> <span>
{{if .IsAdmin}}<a href="/admin">[ Admin ]</a> · {{end}}
<a href="/logout">[ Ausloggen ]</a>
</span>
</div> </div>
<form method="get" action="/days/redirect"> <form method="get" action="/days/redirect">
<fieldset role="group"> <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" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -27,6 +28,7 @@ var funcMap = template.FuncMap{
} }
return *p return *p
}, },
"join": strings.Join,
} }
var tmpls = template.Must( var tmpls = template.Must(
@@ -47,17 +49,14 @@ type WebUI struct {
tpStore *db.TrackpointStore tpStore *db.TrackpointStore
stopStore *db.StopStore stopStore *db.StopStore
journalStore *db.JournalStore journalStore *db.JournalStore
userStore *db.UserStore
} }
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore) *WebUI { 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} return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j, userStore: u}
} }
func render(w http.ResponseWriter, page string, data any) { 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() t, err := tmpls.Clone()
if err == nil { if err == nil {
_, err = t.ParseFS(assets, "templates/"+page) _, 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) http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return 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 var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base", data); err != nil { if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
slog.Error("template execute", "page", page, "err", err) slog.Error("template execute", "page", page, "err", err)
@@ -79,6 +76,49 @@ func render(w http.ResponseWriter, page string, data any) {
_, _ = buf.WriteTo(w) _, _ = 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) { func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
render(w, "login.html", map[string]any{"Error": "", "Username": ""}) 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) 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) { func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
date := strings.TrimSpace(r.URL.Query().Get("date")) date := strings.TrimSpace(r.URL.Query().Get("date"))
if 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) { func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) user := userFromContext(r.Context())
now := time.Now().UTC() now := time.Now().UTC()
from := now.AddDate(-20, 0, 0).Format("2006-01-02") from := now.AddDate(-20, 0, 0).Format("2006-01-02")
to := now.AddDate(0, 0, 1).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 { if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError) http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return 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) { 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, "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 ErrInvalidCredentials = errors.New("invalid username or password")
var ErrSessionNotFound = errors.New("session not found or expired") var ErrSessionNotFound = errors.New("session not found or expired")
var ErrUsernameTaken = errors.New("username already taken")
type Store struct { type Store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
@@ -128,6 +129,40 @@ func (s *Store) Logout(ctx context.Context, sessionID string) error {
return nil 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) { func newSessionID() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {

View File

@@ -3,6 +3,7 @@ package db
import ( import (
"context" "context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/jacek/pamietnik/backend/internal/domain" "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. // 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) { 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, err := s.pool.QueryRow(ctx,
`INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon) `INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon, visibility, hashtags)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING entry_id, created_at`, 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) ).Scan(&e.EntryID, &e.CreatedAt)
return e, err 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. // 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) { func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) {
rows, err := s.pool.Query(ctx, 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 FROM journal_entries
WHERE user_id = $1 AND entry_date = $2 WHERE user_id = $1 AND entry_date = $2
ORDER BY entry_time`, ORDER BY entry_time`,
@@ -51,32 +58,78 @@ func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]d
return nil, err return nil, err
} }
defer rows.Close() 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 var entries []domain.JournalEntry
for rows.Next() { for rows.Next() {
var e domain.JournalEntry var e domain.JournalEntry
if err := rows.Scan( if err := rows.Scan(
&e.EntryID, &e.UserID, &e.EntryDate, &e.EntryTime, &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 { ); err != nil {
return nil, err return nil, err
} }
entries = append(entries, e) entries = append(entries, e)
} }
if err := rows.Err(); err != nil { return entries, rows.Err()
return nil, 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 { if len(entries) == 0 {
return entries, nil return entries, nil
} }
// Load all images in a single query to avoid N+1
entryIDs := make([]string, len(entries)) entryIDs := make([]string, len(entries))
for i, e := range entries { for i, e := range entries {
entryIDs[i] = e.EntryID entryIDs[i] = e.EntryID
} }
imgRows, err := s.pool.Query(ctx, imgRows, err := s.pool.Query(ctx,
`SELECT image_id, entry_id, filename, original_name, mime_type, size_bytes, created_at `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`, 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 { if err := imgRows.Err(); err != nil {
return nil, err return nil, err
} }
for i, e := range entries { for i, e := range entries {
entries[i].Images = imgMap[e.EntryID] 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, user_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -78,9 +79,12 @@ CREATE TABLE IF NOT EXISTS journal_entries (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION, lat DOUBLE PRECISION,
lon DOUBLE PRECISION, lon DOUBLE PRECISION,
visibility TEXT NOT NULL DEFAULT 'private',
hashtags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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_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 ( CREATE TABLE IF NOT EXISTS journal_images (
image_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, 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"` Description string `json:"description"`
Lat *float64 `json:"lat,omitempty"` Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"` Lon *float64 `json:"lon,omitempty"`
Visibility string `json:"visibility"` // "public" | "private"
Hashtags []string `json:"hashtags"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Images []JournalImage `json:"images,omitempty"` Images []JournalImage `json:"images,omitempty"`
} }
@@ -74,6 +76,7 @@ type User struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }

View File

@@ -178,16 +178,17 @@ backend/
│ ├── trackpoints.go UpsertBatch, ListByDate, ListDays, EnsureDevice │ ├── trackpoints.go UpsertBatch, ListByDate, ListDays, EnsureDevice
│ ├── stops.go ListByDate │ ├── stops.go ListByDate
│ ├── suggestions.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/
│ └── auth.go HashPassword, VerifyPassword, Login, GetSession, Logout │ └── auth.go HashPassword, VerifyPassword, Login, Register, GetSession, Logout
└── api/ └── api/
├── router.go chi Routing, Middleware-Gruppen ├── router.go chi Routing, Middleware-Gruppen
├── middleware.go RequireAuth (Session Cookie → Context) ├── middleware.go RequireAuth, requireAdmin (Session Cookie → Context)
├── ingest.go HandleSingleTrackpoint, HandleBatchTrackpoints ├── ingest.go HandleSingleTrackpoint, HandleBatchTrackpoints
├── query.go HandleListDays, HandleListTrackpoints, Stops, Suggestions ├── query.go HandleListDays, HandleListTrackpoints, Stops, Suggestions
├── webui.go Server-side rendered Web UI (Go Templates) ├── webui.go Web UI: Feed, Register, Days, Admin-Handlers
├── journal.go Journal Entry Endpoints ├── journal.go Journal Entry Endpoints (inkl. visibility + hashtags)
└── response.go writeJSON, writeError helpers └── 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 ## 8. Querschnittskonzepte
### Authentifizierung & Sessions (REQ-AUTH-01, REQ-AUTH-02, DEC-AUTH-01) ### Authentifizierung & Sessions (REQ-AUTH-01, REQ-AUTH-02, DEC-AUTH-01)