diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f87b34c..c4efa0c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -24,7 +24,12 @@ jobs: run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env - name: Test - run: cd ${{ vars.DEPLOY_DIR }}/backend && go test ./... + run: | + docker run --rm \ + -v ${{ vars.DEPLOY_DIR }}/backend:/app \ + -w /app \ + golang:1.25-alpine \ + go test ./... - name: Build & Deploy run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9f40769..42f2ae5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -49,8 +49,9 @@ func main() { stopStore := db.NewStopStore(pool) suggStore := db.NewSuggestionStore(pool) journalStore := db.NewJournalStore(pool) + userStore := db.NewUserStore(pool) - router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, uploadDir) + router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, userStore, uploadDir) srv := &http.Server{ Addr: addr, diff --git a/backend/internal/api/journal.go b/backend/internal/api/journal.go index fbfd2d5..db9c19b 100644 --- a/backend/internal/api/journal.go +++ b/backend/internal/api/journal.go @@ -46,6 +46,20 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques entryTime := strings.TrimSpace(r.FormValue("time")) title := strings.TrimSpace(r.FormValue("title")) description := strings.TrimSpace(r.FormValue("description")) + visibility := r.FormValue("visibility") + if visibility != "public" && visibility != "private" { + visibility = "private" + } + var hashtags []string + if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" { + for _, tag := range strings.Split(raw, ",") { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(tag, "#") + if tag != "" { + hashtags = append(hashtags, tag) + } + } + } if date == "" || entryTime == "" { http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest) @@ -58,6 +72,8 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques EntryTime: entryTime, Title: title, Description: description, + Visibility: visibility, + Hashtags: hashtags, } if lat := r.FormValue("lat"); lat != "" { diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index d24ba6d..a7744ea 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -5,39 +5,72 @@ import ( "net/http" "github.com/jacek/pamietnik/backend/internal/auth" + "github.com/jacek/pamietnik/backend/internal/domain" ) type contextKey string const ctxUserID contextKey = "user_id" +const ctxUser contextKey = "user" const sessionCookieName = "session" -// RequireAuth is a middleware that validates the session cookie. +// RequireAuth validates the session cookie and stores user info in context. +// On failure it redirects to /login for browser requests (text/html) or returns JSON 401. func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(sessionCookieName) + user, err := userFromRequest(r, authStore) if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required") + redirectOrUnauthorized(w, r) return } - sess, err := authStore.GetSession(r.Context(), cookie.Value) - if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") - return - } - ctx := context.WithValue(r.Context(), ctxUserID, sess.UserID) + ctx := context.WithValue(r.Context(), ctxUserID, user.UserID) + ctx = context.WithValue(ctx, ctxUser, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } } +// requireAdmin checks that the authenticated user is an admin. +func requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := r.Context().Value(ctxUser).(domain.User) + if !ok || !u.IsAdmin { + http.Redirect(w, r, "/days", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func userFromRequest(r *http.Request, authStore *auth.Store) (domain.User, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return domain.User{}, auth.ErrSessionNotFound + } + return authStore.GetUserBySession(r.Context(), cookie.Value) +} + +func redirectOrUnauthorized(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("Accept") + if len(accept) > 0 && (accept == "application/json" || r.Header.Get("X-Requested-With") == "XMLHttpRequest") { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required") + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + func userIDFromContext(ctx context.Context) string { v, _ := ctx.Value(ctxUserID).(string) return v } +func userFromContext(ctx context.Context) domain.User { + v, _ := ctx.Value(ctxUser).(domain.User) + return v +} + func contextWithUserID(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, ctxUserID, userID) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 8487c5d..a3fb6c2 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -16,6 +16,7 @@ func NewRouter( stopStore *db.StopStore, suggStore *db.SuggestionStore, journalStore *db.JournalStore, + userStore *db.UserStore, uploadDir string, ) http.Handler { r := chi.NewRouter() @@ -23,10 +24,9 @@ func NewRouter( r.Use(middleware.Logger) r.Use(middleware.Recoverer) - webUI := NewWebUI(authStore, tpStore, stopStore, journalStore) + webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore) journalHandler := NewJournalHandler(journalStore, uploadDir) authMW := RequireAuth(authStore) - webAuthMW := requireWebAuth(authStore) // Health r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { @@ -55,19 +55,37 @@ func NewRouter( // Static assets (CSS etc.) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS())))) - // Web UI + // Public routes (no auth required) + r.Get("/", webUI.HandleFeed) + r.Get("/feed", webUI.HandleFeedFragment) + r.Get("/register", webUI.HandleGetRegister) + r.Post("/register", webUI.HandlePostRegister) r.Get("/login", webUI.HandleGetLogin) r.Post("/login", webUI.HandlePostLogin) r.Post("/logout", webUI.HandleLogout) + // Authenticated web routes r.Group(func(r chi.Router) { - r.Use(webAuthMW) + r.Use(authMW) r.Get("/days", webUI.HandleDaysList) r.Get("/days/redirect", webUI.HandleDaysRedirect) r.Get("/days/{date}", webUI.HandleDayDetail) r.Post("/entries", journalHandler.HandleCreateEntry) }) + // Admin routes + r.Group(func(r chi.Router) { + r.Use(authMW) + r.Use(requireAdmin) + r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin/entries", http.StatusSeeOther) + }) + r.Get("/admin/entries", webUI.HandleAdminEntries) + r.Get("/admin/users", webUI.HandleAdminUsers) + r.Post("/admin/users", webUI.HandleAdminCreateUser) + r.Delete("/admin/users/{id}", webUI.HandleAdminDeleteUser) + }) + // Serve uploaded images r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) @@ -76,31 +94,5 @@ func NewRouter( r.Handle(spaPrefix, http.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently)) r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix))) - // Redirect root to Go Web UI /days - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/days", http.StatusSeeOther) - }) - return r } - -// requireWebAuth redirects to /login for unauthenticated web users (HTML response). -func requireWebAuth(authStore *auth.Store) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(sessionCookieName) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - sess, err := authStore.GetSession(r.Context(), cookie.Value) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - ctx := r.Context() - ctx = contextWithUserID(ctx, sess.UserID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} diff --git a/backend/internal/api/static/style.css b/backend/internal/api/static/style.css index e6a337e..5fbe669 100644 --- a/backend/internal/api/static/style.css +++ b/backend/internal/api/static/style.css @@ -52,3 +52,17 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } /* Login */ .login-box { max-width: 360px; margin: 4rem auto; } + +/* Error message */ +.error { color: #c44; } + +/* Hashtags */ +.hashtags { margin-top: .3rem; } +.tag { font-size: .75rem; background: var(--pico-muted-background-color); padding: .1rem .4rem; border-radius: 999px; margin-right: .2rem; } + +/* Visibility badge */ +.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; } + +/* Delete button */ +.btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; } +.btn-delete:hover { background: #c44; color: #fff; } diff --git a/backend/internal/api/templates/admin/entries.html b/backend/internal/api/templates/admin/entries.html new file mode 100644 index 0000000..11d4235 --- /dev/null +++ b/backend/internal/api/templates/admin/entries.html @@ -0,0 +1,33 @@ +{{define "admin_title"}}Einträge verwalten — Admin{{end}} + +{{define "admin_content"}} +
→ Neuen Eintrag anlegen (Tagesansicht)
+ +{{if .Entries}} +| Datum | Zeit | Titel | Sichtbarkeit | Hashtags |
|---|---|---|---|---|
| {{.EntryDate}} | +{{.EntryTime}} | +{{if .Title}}{{.Title}}{{else}}—{{end}} | ++ {{if eq .Visibility "public"}} + öffentlich + {{else}} + privat + {{end}} + | +{{join .Hashtags ", "}} | +
// Noch keine Einträge
+{{end}} +{{end}} diff --git a/backend/internal/api/templates/admin/layout.html b/backend/internal/api/templates/admin/layout.html new file mode 100644 index 0000000..a65a668 --- /dev/null +++ b/backend/internal/api/templates/admin/layout.html @@ -0,0 +1,25 @@ +{{define "admin_base"}} + + + + +{{.Error}}
{{end}} + + + +| Benutzername | Admin | Erstellt | |
|---|---|---|---|
| {{.Username}} | +{{if .IsAdmin}}✓{{end}} | +{{.CreatedAt.Format "2006-01-02"}} | ++ {{if ne .UserID $.User.UserID}} + + {{else}} + (du) + {{end}} + | +
// Noch keine Einträge
{{end}} -| Zeit | Lat | Lon | Quelle | Notiz |
|---|---|---|---|---|
| {{.Timestamp.Format "15:04:05"}} | -{{printf "%.5f" .Lat}} | -{{printf "%.5f" .Lon}} | -{{.Source}} | -{{.Note}} | -
| // Keine Punkte | ||||
| Von | Bis | Dauer | Ort | {{divInt .DurationS 60}} min | {{if .PlaceLabel}}{{.PlaceLabel}}{{else}}—{{end}} | - {{else}} -
|---|---|---|---|
| // Keine Aufenthalte | |||
// Keine Aufenthalte
+ {{end}} + +| Zeit | Lat | Lon | Quelle |
|---|---|---|---|
| {{.Timestamp.Format "15:04:05"}} | +{{printf "%.5f" .Lat}} | +{{printf "%.5f" .Lon}} | +{{.Source}} | +
| // Keine Punkte | |||