diff --git a/internal/api/api.go b/internal/api/api.go index 54629bd1..a393018a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -56,6 +56,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/me/listenbrainz", h.handleGetListenBrainz) authed.Put("/me/listenbrainz", h.handlePutListenBrainz) authed.Get("/me/history", h.handleGetMyHistory) + authed.Put("/me/password", h.handleChangePassword) + authed.Put("/me/profile", h.handleUpdateMyProfile) + authed.Get("/me/api-token", h.handleGetMyAPIToken) + authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) authed.Get("/artists", h.handleListArtists) authed.Get("/artists/{id}", h.handleGetArtist) diff --git a/internal/api/me_password.go b/internal/api/me_password.go new file mode 100644 index 00000000..22942c43 --- /dev/null +++ b/internal/api/me_password.go @@ -0,0 +1,70 @@ +package api + +import ( + "encoding/json" + "net/http" + + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +type changePasswordReq struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// handleChangePassword implements PUT /api/me/password. Self-service: +// the caller proves they know their current password before they can +// set a new one. Distinct from the admin-driven reset path. +func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + var req changePasswordReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid_body", "") + return + } + if len(req.NewPassword) < minPasswordLength { + writeErr(w, http.StatusBadRequest, "password_too_short", "password must be at least 8 chars") + return + } + + q := dbq.New(h.pool) + current, err := q.GetUserByID(r.Context(), user.ID) + if err != nil { + h.logger.Error("change password: lookup failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if err := bcrypt.CompareHashAndPassword([]byte(current.PasswordHash), []byte(req.CurrentPassword)); err != nil { + writeErr(w, http.StatusUnauthorized, "wrong_password", "") + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + h.logger.Error("change password: hash failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if err := q.ChangeUserPassword(r.Context(), dbq.ChangeUserPasswordParams{ + ID: user.ID, + PasswordHash: string(hash), + }); err != nil { + h.logger.Error("change password: update failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil); err != nil { + h.logger.Warn("change password: audit failed", "err", err) + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/me_password_test.go b/internal/api/me_password_test.go new file mode 100644 index 00000000..2723b030 --- /dev/null +++ b/internal/api/me_password_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +func newMePasswordRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Put("/api/me/password", h.handleChangePassword) + return r +} + +func TestChangePassword_Happy(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "chpw1", "oldpassword1", false) + + body := `{"current_password":"oldpassword1","new_password":"newpassword1"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/password", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMePasswordRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + + // Verify DB row was updated — new password should match. + var hash string + if err := pool.QueryRow(context.Background(), + "SELECT password_hash FROM users WHERE id = $1", user.ID).Scan(&hash); err != nil { + t.Fatalf("read hash: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("newpassword1")); err != nil { + t.Errorf("new password does not match DB hash: %v", err) + } +} + +func TestChangePassword_WrongCurrent_401(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "chpw2", "correctpw1", false) + + body := `{"current_password":"wrongpassword","new_password":"newpassword1"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/password", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMePasswordRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 (wrong_password)", rec.Code) + } +} + +func TestChangePassword_TooShort_400(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "chpw3", "somepassword1", false) + + body := `{"current_password":"somepassword1","new_password":"short"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/password", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMePasswordRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (password_too_short)", rec.Code) + } +} diff --git a/internal/api/me_profile.go b/internal/api/me_profile.go new file mode 100644 index 00000000..cfc7a9c2 --- /dev/null +++ b/internal/api/me_profile.go @@ -0,0 +1,130 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "regexp" + "strings" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// emailRe is a simple format check. We don't try to validate +// deliverability — the SMTP send itself is the real check. +var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +type updateProfileReq struct { + // Pointer fields so the caller can omit-vs-clear distinctly. + // nil = leave unchanged; non-nil empty string = clear; non-nil + // non-empty = update to that value. Note: distinguishing + // omit-vs-clear at JSON-decode time requires custom unmarshaling + // or a sentinel; for v1 we use the simpler "empty string clears" + // semantics: any field present in the JSON body is applied. + DisplayName *string `json:"display_name"` + Email *string `json:"email"` +} + +type meProfileResp struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName *string `json:"display_name"` + Email *string `json:"email"` + IsAdmin bool `json:"is_admin"` +} + +func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + var req updateProfileReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid_body", "") + return + } + + // Normalize: trim spaces; treat empty string as "clear" (NULL). + var displayName, email *string + if req.DisplayName != nil { + s := strings.TrimSpace(*req.DisplayName) + if s == "" { + displayName = nil + } else { + displayName = &s + } + } + if req.Email != nil { + s := strings.ToLower(strings.TrimSpace(*req.Email)) + if s == "" { + email = nil + } else { + if !emailRe.MatchString(s) { + writeErr(w, http.StatusBadRequest, "email_invalid", "") + return + } + email = &s + } + } + + // If neither field was provided, no-op (just re-fetch and return). + q := dbq.New(h.pool) + if req.DisplayName == nil && req.Email == nil { + current, err := q.GetUserByID(r.Context(), user.ID) + if err != nil { + h.logger.Error("update profile: lookup failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + writeJSON(w, http.StatusOK, profileViewFromUser(current)) + return + } + + // To preserve existing values for fields not in the request, we need + // to fetch the current row and merge. + current, err := q.GetUserByID(r.Context(), user.ID) + if err != nil { + h.logger.Error("update profile: lookup failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if req.DisplayName == nil { + displayName = current.DisplayName + } + if req.Email == nil { + email = current.Email + } + + updated, err := q.UpdateUserProfile(r.Context(), dbq.UpdateUserProfileParams{ + ID: user.ID, + DisplayName: displayName, + Email: email, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + writeErr(w, http.StatusConflict, "email_taken", "") + return + } + h.logger.Error("update profile: update failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + writeJSON(w, http.StatusOK, profileViewFromUser(updated)) +} + +func profileViewFromUser(u dbq.User) meProfileResp { + return meProfileResp{ + ID: uuidToString(u.ID), + Username: u.Username, + DisplayName: u.DisplayName, + Email: u.Email, + IsAdmin: u.IsAdmin, + } +} diff --git a/internal/api/me_profile_test.go b/internal/api/me_profile_test.go new file mode 100644 index 00000000..b2f26330 --- /dev/null +++ b/internal/api/me_profile_test.go @@ -0,0 +1,204 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newMeProfileRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Put("/api/me/profile", h.handleUpdateMyProfile) + return r +} + +func TestUpdateProfile_DisplayNameOnly(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "prof1", "pw", false) + + body := `{"display_name":"My Name"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp meProfileResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.DisplayName == nil || *resp.DisplayName != "My Name" { + t.Errorf("display_name = %v, want 'My Name'", resp.DisplayName) + } +} + +func TestUpdateProfile_EmailOnly_Lowercased(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "prof2", "pw", false) + + body := `{"email":"User@Example.COM"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + // Verify DB row is lowercased. + var email *string + if err := pool.QueryRow(context.Background(), + "SELECT email FROM users WHERE id = $1", user.ID).Scan(&email); err != nil { + t.Fatalf("read email: %v", err) + } + if email == nil || *email != "user@example.com" { + t.Errorf("DB email = %v, want 'user@example.com'", email) + } +} + +func TestUpdateProfile_BothFields(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "prof3", "pw", false) + + body := `{"display_name":"Both Fields","email":"both@example.com"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp meProfileResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.DisplayName == nil || *resp.DisplayName != "Both Fields" { + t.Errorf("display_name = %v, want 'Both Fields'", resp.DisplayName) + } + if resp.Email == nil || *resp.Email != "both@example.com" { + t.Errorf("email = %v, want 'both@example.com'", resp.Email) + } +} + +func TestUpdateProfile_EmptyBody_NoOp(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "prof4", "pw", false) + + body := `{}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (no-op); body=%s", rec.Code, rec.Body.String()) + } + var resp meProfileResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + // Neither field should have been set (seeded without them). + if resp.DisplayName != nil { + t.Errorf("display_name = %v, want nil (no-op)", resp.DisplayName) + } +} + +func TestUpdateProfile_InvalidEmail_400(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "prof5", "pw", false) + + body := `{"email":"notanemail"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (email_invalid)", rec.Code) + } +} + +func TestUpdateProfile_EmailTaken_409(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + other := seedUser(t, pool, "prof6other", "pw", false) + user := seedUser(t, pool, "prof6user", "pw", false) + + // Give the other user an email directly. + if _, err := pool.Exec(context.Background(), + "UPDATE users SET email = 'taken@example.com' WHERE id = $1", other.ID); err != nil { + t.Fatalf("setup email: %v", err) + } + + body := `{"email":"taken@example.com"}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (email_taken)", rec.Code) + } +} + +func TestUpdateProfile_ClearDisplayName(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "prof7", "pw", false) + + // First, set a display name. + if _, err := pool.Exec(context.Background(), + "UPDATE users SET display_name = 'Will Be Cleared' WHERE id = $1", user.ID); err != nil { + t.Fatalf("setup display_name: %v", err) + } + + // Clear it by sending empty string. + body := `{"display_name":""}` + req := httptest.NewRequest(http.MethodPut, "/api/me/profile", bytes.NewReader([]byte(body))) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeProfileRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + // DB should now have NULL display_name. + var dn *string + if err := pool.QueryRow(context.Background(), + "SELECT display_name FROM users WHERE id = $1", user.ID).Scan(&dn); err != nil { + t.Fatalf("read display_name: %v", err) + } + if dn != nil { + t.Errorf("display_name = %v, want nil (cleared)", dn) + } +} diff --git a/internal/api/me_token.go b/internal/api/me_token.go new file mode 100644 index 00000000..e4ca4d2c --- /dev/null +++ b/internal/api/me_token.go @@ -0,0 +1,61 @@ +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +type apiTokenResp struct { + APIToken string `json:"api_token"` +} + +// handleGetMyAPIToken implements GET /api/me/api-token. +func (h *handlers) handleGetMyAPIToken(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + q := dbq.New(h.pool) + current, err := q.GetUserByID(r.Context(), user.ID) + if err != nil { + h.logger.Error("get api token: lookup failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + writeJSON(w, http.StatusOK, apiTokenResp{APIToken: current.ApiToken}) +} + +// handleRegenerateMyAPIToken implements POST /api/me/api-token. +func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + newToken, err := auth.MintSessionToken() + if err != nil { + h.logger.Error("regenerate api token: mint failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + q := dbq.New(h.pool) + updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{ + ID: user.ID, + ApiToken: newToken, + }) + if err != nil { + h.logger.Error("regenerate api token: update failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionTokenRegenerate, nil); err != nil { + h.logger.Warn("regenerate api token: audit failed", "err", err) + } + + writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken}) +} diff --git a/internal/api/me_token_test.go b/internal/api/me_token_test.go new file mode 100644 index 00000000..b3d26118 --- /dev/null +++ b/internal/api/me_token_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newMeTokenRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/me/api-token", h.handleGetMyAPIToken) + r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken) + return r +} + +func TestGetAPIToken_ReturnsCurrentToken(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tok1", "pw", false) + + req := httptest.NewRequest(http.MethodGet, "/api/me/api-token", nil) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTokenRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp apiTokenResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.APIToken != user.ApiToken { + t.Errorf("api_token = %q, want %q", resp.APIToken, user.ApiToken) + } +} + +func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tok2", "pw", false) + oldToken := user.ApiToken + + req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTokenRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp apiTokenResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.APIToken == "" { + t.Fatalf("api_token is empty") + } + if resp.APIToken == oldToken { + t.Errorf("api_token unchanged after regenerate") + } + + // Verify DB row has the new token and old token no longer matches. + var dbToken string + if err := pool.QueryRow(context.Background(), + "SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil { + t.Fatalf("read token: %v", err) + } + if dbToken != resp.APIToken { + t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken) + } + if dbToken == oldToken { + t.Errorf("old token still in DB after regenerate") + } +}