package api import ( "context" "errors" "net/http" "strings" "codeberg.org/snonux/player/internal/auth" "codeberg.org/snonux/player/internal/model" ) type ctxKey int var errUnauthorized = errors.New("unauthorized") const ( sessionCtxKey ctxKey = iota userCtxKey ) // Authenticator is the narrow auth interface needed by middleware. // // It exposes only the methods middleware actually calls, satisfying the // Interface Segregation Principle: callers (and test doubles) need not // implement the full service.AuthService surface (bootstrap, login, API // token CRUD, etc.) just to wire up request authentication. // // service.AuthService satisfies this interface structurally, so the live // server simply passes its concrete service in. type Authenticator interface { // AuthenticateBearer validates a Bearer token and returns a synthetic session. AuthenticateBearer(ctx context.Context, plaintext string) (*model.Session, error) // CountUsers reports the number of registered users (used by BootstrapRedirect). CountUsers(ctx context.Context) (int, error) // GetUserByID returns a user by database ID (used by RequireAdmin). GetUserByID(ctx context.Context, id int64) (*model.User, error) } // Middleware holds dependencies for middleware constructors. // // publicPaths and publicPrefixes form a route registry used by // BootstrapRedirect to decide which requests bypass the redirect when no // users exist. They are populated at route registration time (see // Server.routes()) so that adding a new public route automatically updates // the bypass set — no hidden hardcoded whitelist that silently 401s/redirects // new routes the developer forgot to add. type Middleware struct { authSvc Authenticator sm auth.SessionManager publicPaths map[string]bool publicPrefixes []string } // NewMiddleware creates middleware handlers. // // The auth dependency is the narrow Authenticator interface rather than the // full service.AuthService — middleware only needs three methods, and the // smaller surface keeps tests cheap and prevents accidental coupling to // unrelated auth-service operations. // // The public route registry starts empty; callers register public paths // via RegisterPublic / RegisterPublicPrefix as routes are wired up. func NewMiddleware(authSvc Authenticator, sm auth.SessionManager) *Middleware { return &Middleware{ authSvc: authSvc, sm: sm, publicPaths: make(map[string]bool), } } // RegisterPublic marks an exact path as public (bypasses BootstrapRedirect). // Call this at route registration time so the middleware's view of "public" // stays in sync with the actual route table. func (mw *Middleware) RegisterPublic(path string) { if mw.publicPaths == nil { mw.publicPaths = make(map[string]bool) } mw.publicPaths[path] = true } // RegisterPublicPrefix marks a path prefix as public. Used for routes whose // concrete paths contain wildcards (e.g. /s/{token}/...) or that serve a // directory tree (e.g. /css/, /js/, /images/). func (mw *Middleware) RegisterPublicPrefix(prefix string) { mw.publicPrefixes = append(mw.publicPrefixes, prefix) } // isPublic reports whether the given request path is registered as public. func (mw *Middleware) isPublic(path string) bool { if mw.publicPaths[path] { return true } for _, p := range mw.publicPrefixes { if strings.HasPrefix(path, p) { return true } } return false } // RequireSession validates the session cookie and injects the session into request context. // For HTML page requests (Accept: text/html), redirects to /login.html instead of returning 401. func (mw *Middleware) RequireSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, err := mw.authenticate(r) if err != nil || sess == nil { if wantsHTML(r) { http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect) return } http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), sessionCtxKey, sess) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (mw *Middleware) authenticate(r *http.Request) (*model.Session, error) { if token, ok := bearerToken(r); ok { if mw.authSvc == nil { return nil, errUnauthorized } return mw.authSvc.AuthenticateBearer(r.Context(), token) } cookie, err := r.Cookie("session") if err != nil { return nil, errUnauthorized } if mw.sm == nil { return nil, errUnauthorized } return mw.sm.ValidateSession(r.Context(), cookie.Value) } func bearerToken(r *http.Request) (string, bool) { fields := strings.Fields(r.Header.Get("Authorization")) if len(fields) == 0 || !strings.EqualFold(fields[0], "Bearer") { return "", false } if len(fields) != 2 { return "", true } return fields[1], true } // wantsHTML returns true if the request appears to be from a browser expecting an HTML page. func wantsHTML(r *http.Request) bool { accept := r.Header.Get("Accept") return strings.Contains(accept, "text/html") } // 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.authSvc.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. // // Public routes (the bootstrap page itself, login endpoints, health probes, // static assets, public share routes, etc.) bypass the redirect so the user // can complete the bootstrap flow. The set of public paths is consulted via // the route registry on Middleware (populated at route declaration time) // rather than a hardcoded list, so adding a new public route in server.go is // all that's required — there is no separate whitelist to keep in sync. func (mw *Middleware) BootstrapRedirect(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if mw.isPublic(r.URL.Path) { next.ServeHTTP(w, r) return } if mw.authSvc == nil { http.Error(w, "internal server error", http.StatusInternalServerError) return } count, err := mw.authSvc.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) }) }