summaryrefslogtreecommitdiff
path: root/internal/api/middleware.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-29 07:50:31 +0300
committerPaul Buetow <paul@buetow.org>2026-04-29 07:50:31 +0300
commitaa95230758cd3487b5d4c55015c502c0f37e1760 (patch)
tree1cc18359b7753554cbf43dcf422641a4cee90414 /internal/api/middleware.go
parent2de97cd74935b5215d2266a4ae0e06b34aa31a98 (diff)
feat: implement bcrypt password hashing, session management, login/logout handlers, and bootstrap flow (m9)
Diffstat (limited to 'internal/api/middleware.go')
-rw-r--r--internal/api/middleware.go92
1 files changed, 92 insertions, 0 deletions
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
new file mode 100644
index 0000000..70da80b
--- /dev/null
+++ b/internal/api/middleware.go
@@ -0,0 +1,92 @@
+package api
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/paul/kiss-media-player/internal/auth"
+ "github.com/paul/kiss-media-player/internal/model"
+ "github.com/paul/kiss-media-player/internal/repository"
+)
+
+type ctxKey int
+
+const (
+ sessionCtxKey ctxKey = iota
+ userCtxKey
+)
+
+// Middleware holds dependencies for middleware constructors.
+type Middleware struct {
+ store repository.Store
+ sm *auth.SessionManager
+}
+
+// NewMiddleware creates middleware handlers.
+func NewMiddleware(store repository.Store, sm *auth.SessionManager) *Middleware {
+ return &Middleware{store: store, sm: sm}
+}
+
+// RequireSession validates the session cookie and injects the session into request context.
+func (mw *Middleware) RequireSession(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("session")
+ if err != nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ sess, err := mw.sm.ValidateSession(r.Context(), cookie.Value)
+ if err != nil || sess == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ ctx := context.WithValue(r.Context(), sessionCtxKey, sess)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// RequireAdmin ensures the authenticated user is an admin.
+func (mw *Middleware) RequireAdmin(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sess, ok := r.Context().Value(sessionCtxKey).(*model.Session)
+ if !ok || sess == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ user, err := mw.store.GetUserByID(r.Context(), sess.UserID)
+ if err != nil || user == nil || !user.IsAdmin {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ ctx := context.WithValue(r.Context(), userCtxKey, user)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// BootstrapRedirect redirects all requests to /bootstrap.html when no users exist.
+func (mw *Middleware) BootstrapRedirect(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if isBootstrapPublic(r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ count, err := mw.store.CountUsers(r.Context())
+ if err != nil {
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if count == 0 {
+ http.Redirect(w, r, "/bootstrap.html", http.StatusTemporaryRedirect)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func isBootstrapPublic(path string) bool {
+ switch path {
+ case "/bootstrap.html", "/api/bootstrap", "/login.html", "/api/login", "/healthz", "/readyz":
+ return true
+ }
+ return false
+}