diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-29 07:50:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-29 07:50:31 +0300 |
| commit | aa95230758cd3487b5d4c55015c502c0f37e1760 (patch) | |
| tree | 1cc18359b7753554cbf43dcf422641a4cee90414 /internal/api | |
| parent | 2de97cd74935b5215d2266a4ae0e06b34aa31a98 (diff) | |
feat: implement bcrypt password hashing, session management, login/logout handlers, and bootstrap flow (m9)
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/api.go | 2 | ||||
| -rw-r--r-- | internal/api/handlers.go | 160 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 634 | ||||
| -rw-r--r-- | internal/api/middleware.go | 92 | ||||
| -rw-r--r-- | internal/api/server.go | 68 |
5 files changed, 954 insertions, 2 deletions
diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index e9a49ea..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package api implements HTTP handlers. -package api diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..a04fb29 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,160 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/paul/kiss-media-player/internal/model" +) + +type bootstrapRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req bootstrapRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Username == "" || req.Password == "" { + http.Error(w, "username and password required", http.StatusBadRequest) + return + } + + ctx := r.Context() + count, err := s.store.CountUsers(ctx) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if count > 0 { + http.Error(w, "bootstrap already complete", http.StatusForbidden) + return + } + + hash, err := s.hasher.Hash(req.Password) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + user := &model.User{ + Username: req.Username, + PasswordHash: hash, + IsAdmin: true, + CreatedAt: time.Now(), + } + id, err := s.store.CreateUser(ctx, user) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + user.ID = id + + sessID, err := s.sm.CreateSession(ctx, id) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + s.setSessionCookie(w, sessID) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": user.ID, + "username": user.Username, + "is_admin": user.IsAdmin, + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Username == "" || req.Password == "" { + http.Error(w, "username and password required", http.StatusBadRequest) + return + } + + ctx := r.Context() + user, err := s.store.GetUserByUsername(ctx, req.Username) + if err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + if user == nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + if err := s.hasher.Compare(user.PasswordHash, req.Password); err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + sessID, err := s.sm.CreateSession(ctx, user.ID) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + s.setSessionCookie(w, sessID) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": user.ID, + "username": user.Username, + "is_admin": user.IsAdmin, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err == nil && cookie.Value != "" { + _ = s.sm.DeleteSession(r.Context(), cookie.Value) + } + s.clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) setSessionCookie(w http.ResponseWriter, value string) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: value, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Expires: time.Now().Add(time.Duration(s.cfg.SessionTimeoutHours) * time.Hour), + }) +} + +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..afa472b --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,634 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/paul/kiss-media-player/internal" + "github.com/paul/kiss-media-player/internal/auth" + "github.com/paul/kiss-media-player/internal/clock" + "github.com/paul/kiss-media-player/internal/model" + "github.com/paul/kiss-media-player/internal/repository" +) + +func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm *auth.SessionManager, cfg *internal.Config) *Server { + t.Helper() + return NewServer(store, hasher, sm, cfg) +} + +func TestMiddleware_BootstrapRedirect(t *testing.T) { + tests := []struct { + name string + path string + userCount int + userErr error + wantCode int + wantLoc string + wantBody string + }{ + {"public bootstrap html", "/bootstrap.html", 0, nil, http.StatusOK, "", ""}, + {"public api bootstrap", "/api/bootstrap", 0, nil, http.StatusOK, "", ""}, + {"public login html", "/login.html", 0, nil, http.StatusOK, "", ""}, + {"public api login", "/api/login", 0, nil, http.StatusOK, "", ""}, + {"public healthz", "/healthz", 0, nil, http.StatusOK, "", ""}, + {"public readyz", "/readyz", 0, nil, http.StatusOK, "", ""}, + {"protected no users", "/", 0, nil, http.StatusTemporaryRedirect, "/bootstrap.html", ""}, + {"protected users exist", "/", 1, nil, http.StatusOK, "", ""}, + {"count error", "/", 0, errors.New("boom"), http.StatusInternalServerError, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { + return tt.userCount, tt.userErr + }, + }, + } + mw := NewMiddleware(store, nil) + handler := mw.BootstrapRedirect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantCode { + t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) + } + if tt.wantLoc != "" && rr.Header().Get("Location") != tt.wantLoc { + t.Fatalf("expected location %q, got %q", tt.wantLoc, rr.Header().Get("Location")) + } + }) + } +} + +func TestMiddleware_RequireSession(t *testing.T) { + now := time.Now() + tests := []struct { + name string + cookie *http.Cookie + session *model.Session + sessErr error + wantCode int + }{ + {"no cookie", nil, nil, nil, http.StatusUnauthorized}, + {"empty cookie", &http.Cookie{Name: "session", Value: ""}, nil, nil, http.StatusUnauthorized}, + {"valid session", &http.Cookie{Name: "session", Value: "abc"}, &model.Session{ID: "abc", UserID: 1, ExpiresAt: now.Add(time.Hour)}, nil, http.StatusOK}, + {"expired session", &http.Cookie{Name: "session", Value: "old"}, &model.Session{ID: "old", UserID: 1, ExpiresAt: now.Add(-time.Hour)}, nil, http.StatusUnauthorized}, + {"db error", &http.Cookie{Name: "session", Value: "abc"}, nil, errors.New("boom"), http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var deleted string + repo := repository.MockSessionRepo{ + GetSessionByIDFunc: func(ctx context.Context, id string) (*model.Session, error) { + if id == "abc" { + return tt.session, tt.sessErr + } + return tt.session, tt.sessErr + }, + DeleteSessionFunc: func(ctx context.Context, id string) error { + deleted = id + return nil + }, + } + sm := auth.NewSessionManager(&repo, &clock.MockClock{T: now}, time.Hour) + mw := NewMiddleware(nil, sm) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, ok := r.Context().Value(sessionCtxKey).(*model.Session) + if !ok && tt.wantCode == http.StatusOK { + t.Fatal("expected session in context") + } + if tt.wantCode == http.StatusOK && sess.ID != "abc" { + t.Fatalf("unexpected session id: %v", sess.ID) + } + w.WriteHeader(http.StatusOK) + }) + handler := mw.RequireSession(inner) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.cookie != nil { + req.AddCookie(tt.cookie) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != tt.wantCode { + t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) + } + if tt.name == "expired session" && deleted == "" { + t.Fatal("expected expired session deletion") + } + }) + } +} + +func TestMiddleware_RequireAdmin(t *testing.T) { + tests := []struct { + name string + ctxUser *model.User + userErr error + wantCode int + }{ + {"no session in context", nil, nil, http.StatusUnauthorized}, + {"session but nil user", nil, nil, http.StatusForbidden}, + {"non-admin", &model.User{ID: 1, IsAdmin: false}, nil, http.StatusForbidden}, + {"admin", &model.User{ID: 1, IsAdmin: true}, nil, http.StatusOK}, + {"db error", nil, errors.New("boom"), http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) { + if tt.ctxUser != nil { + return tt.ctxUser, tt.userErr + } + return nil, tt.userErr + }, + }, + } + mw := NewMiddleware(store, nil) + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := mw.RequireAdmin(inner) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.name != "no session in context" { + sess := &model.Session{UserID: 1} + // For "session but nil user" and "db error" we still want a valid session in context. + req = req.WithContext(context.WithValue(req.Context(), sessionCtxKey, sess)) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != tt.wantCode { + t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) + } + }) + } +} + +type staticHasher struct { + fixed string +} + +func (h *staticHasher) Hash(password string) (string, error) { return h.fixed, nil } +func (h *staticHasher) Compare(hash, password string) error { + if hash == h.fixed && password == "correct" { + return nil + } + return errors.New("mismatch") +} + +func TestServer_Bootstrap(t *testing.T) { + hasher := &staticHasher{fixed: "hashed"} + cfg := &internal.Config{SessionTimeoutHours: 24} + + t.Run("create first admin", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }, + CreateUserFunc: func(ctx context.Context, user *model.User) (int64, error) { return 7, nil }, + }, + } + repo := repository.MockSessionRepo{ + CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, + } + sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) + srv := newTestServer(t, store, hasher, sm, cfg) + + body := `{"username":"admin","password":"secret"}` + req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["username"] != "admin" { + t.Fatalf("unexpected username: %v", resp["username"]) + } + cookies := rr.Result().Cookies() + var sessCookie *http.Cookie + for _, c := range cookies { + if c.Name == "session" { + sessCookie = c + break + } + } + if sessCookie == nil { + t.Fatal("expected session cookie") + } + if !sessCookie.HttpOnly || !sessCookie.Secure || sessCookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("unexpected cookie attrs: HttpOnly=%v Secure=%v SameSite=%v", sessCookie.HttpOnly, sessCookie.Secure, sessCookie.SameSite) + } + }) + + t.Run("bootstrap already complete", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + }, + } + srv := newTestServer(t, store, hasher, nil, cfg) + body := `{"username":"admin","password":"secret"}` + req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected %d, got %d", http.StatusForbidden, rr.Code) + } + }) + + t.Run("missing fields", func(t *testing.T) { + store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}} + srv := newTestServer(t, store, hasher, nil, cfg) + req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(`{"username":""}`))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + }) + + t.Run("wrong method", func(t *testing.T) { + srv := newTestServer(t, nil, hasher, nil, cfg) + req := httptest.NewRequest(http.MethodGet, "/api/bootstrap", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected %d, got %d", http.StatusMethodNotAllowed, rr.Code) + } + }) +} + +func TestServer_Login(t *testing.T) { + hasher := &staticHasher{fixed: "hashed"} + cfg := &internal.Config{SessionTimeoutHours: 24} + + t.Run("valid credentials", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + GetUserByUsernameFunc: func(ctx context.Context, username string) (*model.User, error) { + return &model.User{ID: 1, Username: "alice", PasswordHash: "hashed"}, nil + }, + }, + } + repo := repository.MockSessionRepo{ + CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, + } + sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) + srv := newTestServer(t, store, hasher, sm, cfg) + body := `{"username":"alice","password":"correct"}` + req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + var resp map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &resp) + if resp["username"] != "alice" { + t.Fatalf("unexpected username") + } + cookies := rr.Result().Cookies() + var sessCookie *http.Cookie + for _, c := range cookies { + if c.Name == "session" { + sessCookie = c + break + } + } + if sessCookie == nil { + t.Fatal("expected session cookie after login") + } + }) + + t.Run("invalid credentials", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + GetUserByUsernameFunc: func(ctx context.Context, username string) (*model.User, error) { + return &model.User{ID: 1, Username: "alice", PasswordHash: "hashed"}, nil + }, + }, + } + srv := newTestServer(t, store, hasher, nil, cfg) + body := `{"username":"alice","password":"wrong"}` + req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) + + t.Run("user not found", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, + GetUserByUsernameFunc: func(ctx context.Context, username string) (*model.User, error) { + return nil, nil + }, + }, + } + srv := newTestServer(t, store, hasher, nil, cfg) + body := `{"username":"nobody","password":"pass"}` + req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) +} + +func TestServer_Logout(t *testing.T) { + cfg := &internal.Config{SessionTimeoutHours: 24} + + t.Run("valid session logout", func(t *testing.T) { + var deleted string + repo := repository.MockSessionRepo{ + GetSessionByIDFunc: func(ctx context.Context, id string) (*model.Session, error) { + if id == "abc" { + return &model.Session{ID: "abc", UserID: 1, ExpiresAt: time.Now().Add(time.Hour)}, nil + } + return nil, nil + }, + DeleteSessionFunc: func(ctx context.Context, id string) error { + deleted = id + return nil + }, + } + sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) + store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}} + srv := newTestServer(t, store, nil, sm, cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "abc"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected %d, got %d", http.StatusNoContent, rr.Code) + } + if deleted != "abc" { + t.Fatalf("expected session abc to be deleted, got %q", deleted) + } + cookies := rr.Result().Cookies() + var sessCookie *http.Cookie + for _, c := range cookies { + if c.Name == "session" { + sessCookie = c + break + } + } + if sessCookie == nil || sessCookie.MaxAge != -1 { + t.Fatal("expected cleared session cookie") + } + }) + + t.Run("no cookie logout", func(t *testing.T) { + store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}} + srv := newTestServer(t, store, nil, nil, cfg) + req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d", http.StatusUnauthorized, rr.Code) + } + }) +} + +func TestServer_Healthz(t *testing.T) { + cfg := &internal.Config{} + srv := newTestServer(t, &repository.MockStore{}, nil, nil, cfg) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } +} + +func TestServer_Readyz(t *testing.T) { + cfg := &internal.Config{} + t.Run("ping ok", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}, + } + store2 := &mockPingStore{store: store, err: nil} + srv := newTestServer(t, store2, nil, nil, cfg) + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("ping fail", func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}, + } + store2 := &mockPingStore{store: store, err: errors.New("down")} + srv := newTestServer(t, store2, nil, nil, cfg) + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected %d, got %d", http.StatusServiceUnavailable, rr.Code) + } + }) +} + +type mockPingStore struct { + store repository.Store + err error +} + +func (m *mockPingStore) CreateUser(ctx context.Context, user *model.User) (int64, error) { + return m.store.CreateUser(ctx, user) +} +func (m *mockPingStore) GetUserByID(ctx context.Context, id int64) (*model.User, error) { + return m.store.GetUserByID(ctx, id) +} +func (m *mockPingStore) GetUserByUsername(ctx context.Context, username string) (*model.User, error) { + return m.store.GetUserByUsername(ctx, username) +} +func (m *mockPingStore) ListUsers(ctx context.Context) ([]model.User, error) { + return m.store.ListUsers(ctx) +} +func (m *mockPingStore) DeleteUser(ctx context.Context, id int64) error { + return m.store.DeleteUser(ctx, id) +} +func (m *mockPingStore) CountUsers(ctx context.Context) (int, error) { + return m.store.CountUsers(ctx) +} +func (m *mockPingStore) CreateSet(ctx context.Context, set *model.Set) (int64, error) { + return m.store.CreateSet(ctx, set) +} +func (m *mockPingStore) GetSetByID(ctx context.Context, id int64) (*model.Set, error) { + return m.store.GetSetByID(ctx, id) +} +func (m *mockPingStore) ListSets(ctx context.Context) ([]model.Set, error) { + return m.store.ListSets(ctx) +} +func (m *mockPingStore) UpdateSet(ctx context.Context, set *model.Set) error { + return m.store.UpdateSet(ctx, set) +} +func (m *mockPingStore) DeleteSet(ctx context.Context, id int64) error { + return m.store.DeleteSet(ctx, id) +} +func (m *mockPingStore) GrantPermission(ctx context.Context, perm *model.SetPermission) error { + return m.store.GrantPermission(ctx, perm) +} +func (m *mockPingStore) RevokePermission(ctx context.Context, setID, userID int64) error { + return m.store.RevokePermission(ctx, setID, userID) +} +func (m *mockPingStore) GetPermission(ctx context.Context, setID, userID int64) (*model.SetPermission, error) { + return m.store.GetPermission(ctx, setID, userID) +} +func (m *mockPingStore) ListPermissionsBySet(ctx context.Context, setID int64) ([]model.SetPermission, error) { + return m.store.ListPermissionsBySet(ctx, setID) +} +func (m *mockPingStore) ListPermissionsByUser(ctx context.Context, userID int64) ([]model.SetPermission, error) { + return m.store.ListPermissionsByUser(ctx, userID) +} +func (m *mockPingStore) CreateMedia(ctx context.Context, media *model.Media) (int64, error) { + return m.store.CreateMedia(ctx, media) +} +func (m *mockPingStore) GetMediaByID(ctx context.Context, id int64) (*model.Media, error) { + return m.store.GetMediaByID(ctx, id) +} +func (m *mockPingStore) UpdateMedia(ctx context.Context, media *model.Media) error { + return m.store.UpdateMedia(ctx, media) +} +func (m *mockPingStore) SoftDeleteMedia(ctx context.Context, id int64) error { + return m.store.SoftDeleteMedia(ctx, id) +} +func (m *mockPingStore) RestoreMedia(ctx context.Context, id int64) error { + return m.store.RestoreMedia(ctx, id) +} +func (m *mockPingStore) HardDeleteMedia(ctx context.Context, id int64) error { + return m.store.HardDeleteMedia(ctx, id) +} +func (m *mockPingStore) ListMedia(ctx context.Context, filter repository.MediaFilter) ([]model.Media, error) { + return m.store.ListMedia(ctx, filter) +} +func (m *mockPingStore) ListDeletedMedia(ctx context.Context) ([]model.Media, error) { + return m.store.ListDeletedMedia(ctx) +} +func (m *mockPingStore) IncrementPlayCount(ctx context.Context, id int64) error { + return m.store.IncrementPlayCount(ctx, id) +} +func (m *mockPingStore) CreateTag(ctx context.Context, name string) (int64, error) { + return m.store.CreateTag(ctx, name) +} +func (m *mockPingStore) GetTagByID(ctx context.Context, id int64) (*model.Tag, error) { + return m.store.GetTagByID(ctx, id) +} +func (m *mockPingStore) GetTagByName(ctx context.Context, name string) (*model.Tag, error) { + return m.store.GetTagByName(ctx, name) +} +func (m *mockPingStore) ListTags(ctx context.Context) ([]model.Tag, error) { + return m.store.ListTags(ctx) +} +func (m *mockPingStore) DeleteTag(ctx context.Context, id int64) error { + return m.store.DeleteTag(ctx, id) +} +func (m *mockPingStore) AssignTag(ctx context.Context, mediaID, tagID int64) error { + return m.store.AssignTag(ctx, mediaID, tagID) +} +func (m *mockPingStore) RemoveTag(ctx context.Context, mediaID, tagID int64) error { + return m.store.RemoveTag(ctx, mediaID, tagID) +} +func (m *mockPingStore) ListTagsByMedia(ctx context.Context, mediaID int64) ([]model.Tag, error) { + return m.store.ListTagsByMedia(ctx, mediaID) +} +func (m *mockPingStore) ToggleFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + return m.store.ToggleFavorite(ctx, userID, mediaID) +} +func (m *mockPingStore) IsFavorite(ctx context.Context, userID, mediaID int64) (bool, error) { + return m.store.IsFavorite(ctx, userID, mediaID) +} +func (m *mockPingStore) ListFavoritesByUser(ctx context.Context, userID int64) ([]model.Favorite, error) { + return m.store.ListFavoritesByUser(ctx, userID) +} +func (m *mockPingStore) UpsertProgress(ctx context.Context, progress *model.PlaybackProgress) error { + return m.store.UpsertProgress(ctx, progress) +} +func (m *mockPingStore) GetProgress(ctx context.Context, userID, mediaID int64) (*model.PlaybackProgress, error) { + return m.store.GetProgress(ctx, userID, mediaID) +} +func (m *mockPingStore) ListProgressByUser(ctx context.Context, userID int64) ([]model.PlaybackProgress, error) { + return m.store.ListProgressByUser(ctx, userID) +} +func (m *mockPingStore) UpsertAccumulator(ctx context.Context, acc *model.PlaybackAccumulator) error { + return m.store.UpsertAccumulator(ctx, acc) +} +func (m *mockPingStore) GetAccumulator(ctx context.Context, sessionID string, mediaID int64) (*model.PlaybackAccumulator, error) { + return m.store.GetAccumulator(ctx, sessionID, mediaID) +} +func (m *mockPingStore) CreateSession(ctx context.Context, session *model.Session) error { + return m.store.CreateSession(ctx, session) +} +func (m *mockPingStore) GetSessionByID(ctx context.Context, id string) (*model.Session, error) { + return m.store.GetSessionByID(ctx, id) +} +func (m *mockPingStore) DeleteSession(ctx context.Context, id string) error { + return m.store.DeleteSession(ctx, id) +} +func (m *mockPingStore) DeleteExpiredSessions(ctx context.Context, now time.Time) error { + return m.store.DeleteExpiredSessions(ctx, now) +} +func (m *mockPingStore) CreateShare(ctx context.Context, share *model.Share) error { + return m.store.CreateShare(ctx, share) +} +func (m *mockPingStore) GetShareByToken(ctx context.Context, token string) (*model.Share, error) { + return m.store.GetShareByToken(ctx, token) +} +func (m *mockPingStore) ListSharesByMedia(ctx context.Context, mediaID int64) ([]model.Share, error) { + return m.store.ListSharesByMedia(ctx, mediaID) +} +func (m *mockPingStore) UseShare(ctx context.Context, token string) error { + return m.store.UseShare(ctx, token) +} +func (m *mockPingStore) DeleteShare(ctx context.Context, token string) error { + return m.store.DeleteShare(ctx, token) +} +func (m *mockPingStore) DeleteExpiredShares(ctx context.Context, now time.Time) error { + return m.store.DeleteExpiredShares(ctx, now) +} +func (m *mockPingStore) UpsertNote(ctx context.Context, note *model.Note) error { + return m.store.UpsertNote(ctx, note) +} +func (m *mockPingStore) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) { + return m.store.GetNote(ctx, mediaID, userID) +} +func (m *mockPingStore) DeleteNote(ctx context.Context, mediaID, userID int64) error { + return m.store.DeleteNote(ctx, mediaID, userID) +} +func (m *mockPingStore) Ping(ctx context.Context) error { + return m.err +} 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 +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..13415a5 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "net/http" + + "github.com/paul/kiss-media-player/internal" + "github.com/paul/kiss-media-player/internal/auth" + "github.com/paul/kiss-media-player/internal/repository" +) + +// Server holds HTTP handlers and dependencies. +type Server struct { + store repository.Store + hasher auth.Hasher + sm *auth.SessionManager + cfg *internal.Config + mux *http.ServeMux + mw *Middleware +} + +// NewServer creates a Server with routes. +func NewServer(store repository.Store, hasher auth.Hasher, sm *auth.SessionManager, cfg *internal.Config) *Server { + s := &Server{ + store: store, + hasher: hasher, + sm: sm, + cfg: cfg, + mux: http.NewServeMux(), + mw: NewMiddleware(store, sm), + } + s.routes() + return s +} + +func (s *Server) routes() { + s.mux.HandleFunc("POST /api/bootstrap", s.handleBootstrap) + s.mux.HandleFunc("POST /api/login", s.handleLogin) + + // logout requires a valid session + s.mux.Handle("POST /api/logout", s.mw.RequireSession(http.HandlerFunc(s.handleLogout))) + + s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + s.mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { + if err := s.pingStore(r.Context()); err != nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + }) +} + +func (s *Server) pingStore(ctx context.Context) error { + type pinger interface { + Ping(ctx context.Context) error + } + if p, ok := s.store.(pinger); ok { + return p.Ping(ctx) + } + return nil +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mw.BootstrapRedirect(s.mux).ServeHTTP(w, r) +} |
