diff --git a/.golangci.yml b/.golangci.yml index 6f1a759f2..552e13cb7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,6 +145,10 @@ linters: - revive path: pkg/utils/* text: 'var-naming: avoid meaningless package names' + - linters: + - revive + path: pkg/routes/api/shared/* + text: 'var-naming: avoid meaningless package names' - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go new file mode 100644 index 000000000..cba87cdb5 --- /dev/null +++ b/pkg/models/user_settings.go @@ -0,0 +1,128 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/modules/avatar" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// UserGeneralSettings is the single user-settings wire struct shared by v1 and +// v2 — both the update request body and the nested settings on GET /user. A +// dedicated struct (not user.User) is required: user.User's settings fields are +// json:"-" so they don't leak when it is embedded in other responses +// (assignees, created_by, members …). +type UserGeneralSettings struct { + Name string `json:"name" doc:"The full name of the user."` + EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."` + DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."` + DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."` + OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."` + OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."` + DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."` + WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."` + Language string `json:"language" doc:"The user's language."` + Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."` + FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."` + // Server/OpenID-provided; populated on read, ignored on write. + ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."` +} + +// NewUserGeneralSettings projects a user's stored settings into the shared wire +// struct for GET /user. Used by both the v1 and v2 user-show handlers. +func NewUserGeneralSettings(u *user.User) *UserGeneralSettings { + return &UserGeneralSettings{ + Name: u.Name, + EmailRemindersEnabled: u.EmailRemindersEnabled, + DiscoverableByName: u.DiscoverableByName, + DiscoverableByEmail: u.DiscoverableByEmail, + OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, + OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, + DefaultProjectID: u.DefaultProjectID, + WeekStart: u.WeekStart, + Language: u.Language, + Timezone: u.Timezone, + FrontendSettings: u.FrontendSettings, + ExtraSettingsLinks: u.ExtraSettingsLinks, + } +} + +// ChangeUserPassword verifies the old password, sets the new one, and +// invalidates all of the user's sessions. Lives here (not in pkg/user) because +// it needs DeleteAllUserSessions, which pkg/user cannot import. +func ChangeUserPassword(s *xorm.Session, u *user.User, oldPassword, newPassword string) error { + if oldPassword == "" { + return user.ErrEmptyOldPassword{} + } + + if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { + return err + } + + if err := user.UpdateUserPassword(s, u, newPassword); err != nil { + return err + } + + return DeleteAllUserSessions(s, u.ID) +} + +// UpdateUserGeneralSettings copies the general settings onto the user, persists +// them, and flushes the avatar cache when an initials avatar's name changed. +// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar, +// which pkg/user cannot import. +func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error { + invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name + + u.Name = settings.Name + u.EmailRemindersEnabled = settings.EmailRemindersEnabled + u.DiscoverableByEmail = settings.DiscoverableByEmail + u.DiscoverableByName = settings.DiscoverableByName + u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled + u.DefaultProjectID = settings.DefaultProjectID + u.WeekStart = settings.WeekStart + u.Language = settings.Language + u.Timezone = settings.Timezone + u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime + u.FrontendSettings = settings.FrontendSettings + + if _, err := user.UpdateUser(s, u, true); err != nil { + return err + } + + if invalidateAvatar { + avatar.FlushAllCaches(u) + } + return nil +} + +// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and +// flushes the avatar cache when the provider changes (or is set to initials). +func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error { + oldProvider := u.AvatarProvider + u.AvatarProvider = provider + + if _, err := user.UpdateUser(s, u, false); err != nil { + return err + } + + if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider { + avatar.FlushAllCaches(u) + } + return nil +} diff --git a/pkg/routes/api/shared/auth_provider.go b/pkg/routes/api/shared/auth_provider.go new file mode 100644 index 000000000..042a5567d --- /dev/null +++ b/pkg/routes/api/shared/auth_provider.go @@ -0,0 +1,54 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package shared holds helpers used by both the v1 and v2 route packages. It +// sits above the auth/user modules in the import graph, so it can combine them +// without creating a cycle. +package shared + +import ( + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/user" +) + +// GetAuthProviderName resolves the human-readable name of the source a user +// authenticated with: "local"/"ldap" for those issuers, otherwise the +// configured OpenID provider whose issuer URL matches the user's. Returns "" +// when no provider matches. +func GetAuthProviderName(u *user.User) (string, error) { + switch u.Issuer { + case user.IssuerLocal: + return "local", nil + case user.IssuerLDAP: + return "ldap", nil + } + + providers, err := openid.GetAllProviders() + if err != nil { + return "", err + } + for _, provider := range providers { + issuerURL, err := provider.Issuer() + if err != nil { + return "", err + } + if issuerURL == u.Issuer { + return provider.Name, nil + } + } + + return "", nil +} diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 2efa9c0f0..049330411 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -26,7 +26,6 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/avatar" user2 "code.vikunja.io/api/pkg/user" ) @@ -36,35 +35,6 @@ type UserAvatarProvider struct { AvatarProvider string `json:"avatar_provider"` } -// UserSettings holds all user settings -type UserSettings struct { - // The new name of the current user. - Name string `json:"name"` - // If enabled, sends email reminders of tasks to the user. - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - // If true, this user can be found by their name or parts of it when searching for it. - DiscoverableByName bool `json:"discoverable_by_name"` - // If true, the user can be found when searching for their exact email. - DiscoverableByEmail bool `json:"discoverable_by_email"` - // If enabled, the user will get an email for their overdue tasks each morning. - OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` - // The time when the daily summary of overdue tasks will be sent via email. - OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"` - // If a task is created without a specified project this value should be used. Applies - // to tasks made directly in API and from clients. - DefaultProjectID int64 `json:"default_project_id"` - // The day when the week starts for this user. 0 = sunday, 1 = monday, etc. - WeekStart int `json:"week_start" valid:"range(0|6)"` - // The user's language - Language string `json:"language"` - // The user's time zone. Used to send task reminders in the time zone of the user. - Timezone string `json:"timezone"` - // Additional settings only used by the frontend - FrontendSettings interface{} `json:"frontend_settings"` - // Additional settings links as provided by openid - ExtraSettingsLinks map[string]any `json:"extra_settings_links"` -} - // GetUserAvatarProvider returns the currently set user avatar // @Summary Return user avatar setting // @Description Returns the current user's avatar setting. @@ -135,29 +105,16 @@ func ChangeUserAvatarProvider(c *echo.Context) error { return err } - oldProvider := user.AvatarProvider - - user.AvatarProvider = uap.AvatarProvider - - _, err = user2.UpdateUser(s, user, false) - if err != nil { + if err := models.UpdateUserAvatarProvider(s, user, uap.AvatarProvider); err != nil { _ = s.Rollback() return err } - if user.AvatarProvider == "initials" { - avatar.FlushAllCaches(user) - } - if err := s.Commit(); err != nil { _ = s.Rollback() return err } - if oldProvider != user.AvatarProvider { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."}) } @@ -167,13 +124,13 @@ func ChangeUserAvatarProvider(c *echo.Context) error { // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param avatar body UserSettings true "The updated user settings" +// @Param avatar body models.UserGeneralSettings true "The updated user settings" // @Success 200 {object} models.Message // @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 500 {object} models.Message "Internal server error." // @Router /user/settings/general [post] func UpdateGeneralUserSettings(c *echo.Context) error { - us := &UserSettings{} + us := &models.UserGeneralSettings{} err := c.Bind(us) if err != nil { var he *echo.HTTPError @@ -202,22 +159,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - invalidateAvatar := user.AvatarProvider == "initials" && user.Name != us.Name - - user.Name = us.Name - user.EmailRemindersEnabled = us.EmailRemindersEnabled - user.DiscoverableByEmail = us.DiscoverableByEmail - user.DiscoverableByName = us.DiscoverableByName - user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled - user.DefaultProjectID = us.DefaultProjectID - user.WeekStart = us.WeekStart - user.Language = us.Language - user.Timezone = us.Timezone - user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime - user.FrontendSettings = us.FrontendSettings - - _, err = user2.UpdateUser(s, user, true) - if err != nil { + if err := models.UpdateUserGeneralSettings(s, user, us); err != nil { _ = s.Rollback() return err } @@ -227,10 +169,6 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - if invalidateAvatar { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."}) } diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go index d5a391267..655b0fb5c 100644 --- a/pkg/routes/api/v1/user_show.go +++ b/pkg/routes/api/v1/user_show.go @@ -20,7 +20,7 @@ import ( "net/http" "time" - "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" @@ -34,11 +34,11 @@ import ( type UserWithSettings struct { user.User - Settings *UserSettings `json:"settings"` - DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` - IsLocalUser bool `json:"is_local_user"` - AuthProvider string `json:"auth_provider"` - IsAdmin bool `json:"is_admin"` + Settings *models.UserGeneralSettings `json:"settings"` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` + IsLocalUser bool `json:"is_local_user"` + AuthProvider string `json:"auth_provider"` + IsAdmin bool `json:"is_admin"` } // UserShow gets all information about the current user @@ -67,57 +67,17 @@ func UserShow(c *echo.Context) error { } us := &UserWithSettings{ - User: *u, - Settings: &UserSettings{ - Name: u.Name, - EmailRemindersEnabled: u.EmailRemindersEnabled, - DiscoverableByName: u.DiscoverableByName, - DiscoverableByEmail: u.DiscoverableByEmail, - OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, - DefaultProjectID: u.DefaultProjectID, - WeekStart: u.WeekStart, - Language: u.Language, - Timezone: u.Timezone, - OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, - FrontendSettings: u.FrontendSettings, - ExtraSettingsLinks: u.ExtraSettingsLinks, - }, + User: *u, + Settings: models.NewUserGeneralSettings(u), DeletionScheduledAt: u.DeletionScheduledAt, IsLocalUser: u.Issuer == user.IssuerLocal, IsAdmin: u.IsAdmin, } - us.AuthProvider, err = getAuthProviderName(u) + us.AuthProvider, err = shared.GetAuthProviderName(u) if err != nil { return err } return c.JSON(http.StatusOK, us) } - -func getAuthProviderName(u *user.User) (name string, err error) { - if u.Issuer == user.IssuerLocal { - return "local", nil - } - - if u.Issuer == user.IssuerLDAP { - return "ldap", nil - } - - providers, err := openid.GetAllProviders() - if err != nil { - return "", err - } - - for _, provider := range providers { - issuerURL, err := provider.Issuer() - if err != nil { - return "", err - } - if issuerURL == u.Issuer { - return provider.Name, nil - } - } - - return -} diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go index ea1077075..e73ba6f89 100644 --- a/pkg/routes/api/v1/user_update_email.go +++ b/pkg/routes/api/v1/user_update_email.go @@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() - emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{ - Username: emailUpdate.User.Username, - Password: emailUpdate.Password, - }) - if err != nil { - _ = s.Rollback() - return err - } - - err = user.UpdateEmail(s, emailUpdate) - if err != nil { + if err := user.ChangeUserEmail(s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go index 0172a21ec..52941a48a 100644 --- a/pkg/routes/api/v1/user_update_password.go +++ b/pkg/routes/api/v1/user_update_password.go @@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error { return err } - if newPW.OldPassword == "" { - return user.ErrEmptyOldPassword{} - } - s := db.NewSession() defer s.Close() - // Check the current password - if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil { - _ = s.Rollback() - return err - } - - // Update the password - if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, doer.ID); err != nil { + if err := models.ChangeUserPassword(s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { _ = s.Rollback() return err } diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 73e104682..b721ba518 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -31,6 +31,17 @@ type EmailUpdate struct { Password string `json:"password"` } +// ChangeUserEmail verifies the user's password, then sets a new email address +// (kicking off confirmation when the mailer is enabled). Shared by the v1 and +// v2 email-update handlers; only HTTP input binding stays in the handlers. +func ChangeUserEmail(s *xorm.Session, u *User, password, newEmail string) error { + verified, err := CheckUserCredentials(s, &Login{Username: u.Username, Password: password}) + if err != nil { + return err + } + return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail}) +} + // UpdateEmail lets a user update their email address func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) {