Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
33
backend/internal/api/templates/admin/entries.html
Normal file
33
backend/internal/api/templates/admin/entries.html
Normal 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}}
|
||||
25
backend/internal/api/templates/admin/layout.html
Normal file
25
backend/internal/api/templates/admin/layout.html
Normal 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}}
|
||||
53
backend/internal/api/templates/admin/users.html
Normal file
53
backend/internal/api/templates/admin/users.html
Normal 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}}
|
||||
@@ -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> · ○ {{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}}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
72
backend/internal/api/templates/public.html
Normal file
72
backend/internal/api/templates/public.html
Normal 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" .}}
|
||||
20
backend/internal/api/templates/register.html
Normal file
20
backend/internal/api/templates/register.html
Normal 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" .}}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
backend/internal/db/users.go
Normal file
43
backend/internal/db/users.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user