user: add additional emails

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-10-11 19:20:51 +02:00
parent bdd6de10a5
commit eba4c93efd
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
17 changed files with 267 additions and 28 deletions

2
go.mod
View file

@ -52,7 +52,7 @@ require (
github.com/rs/cors v1.11.1 github.com/rs/cors v1.11.1
github.com/rs/xid v1.6.0 github.com/rs/xid v1.6.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1 github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0 github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1

4
go.sum
View file

@ -371,8 +371,8 @@ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJ
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1 h1:UR1rI03lk+rLbt/FmUszQoY+hE3XxVCEGSumjbMZx/I= github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9 h1:wlXBnaNfJJJRZjHO2AerSS5gp0ckkYUgBzSXivUo0Wo=
github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1/go.mod h1:Isl0IEzS/Muvh8Fr4X+NWFsOS/fZQHRD4oPQpoY7C4g= github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9/go.mod h1:ehimvlTP+XTEiE3t1CPwWx9n7+6A6OGvMGlZ7ouvKFk=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=

View file

@ -2459,7 +2459,7 @@ func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovid
} }
subject := "SFTPGo password expiration notification" subject := "SFTPGo password expiration notification"
startTime := time.Now() startTime := time.Now()
if err := smtp.SendEmail([]string{user.Email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil { if err := smtp.SendEmail(user.GetEmailAddresses(), nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s", eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
user.Username, err, time.Since(startTime)) user.Username, err, time.Since(startTime))
return err return err
@ -2554,6 +2554,9 @@ func preserveUserProfile(user, newUser *dataprovider.User) {
if user.Email != "" { if user.Email != "" {
newUser.Email = user.Email newUser.Email = user.Email
} }
if len(user.Filters.AdditionalEmails) > 0 {
newUser.Filters.AdditionalEmails = user.Filters.AdditionalEmails
}
} }
if newUser.CanChangeAPIKeyAuth() { if newUser.CanChangeAPIKeyAuth() {
newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth

View file

@ -1418,6 +1418,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
// Update the profile attribute and make sure they are preserved // Update the profile attribute and make sure they are preserved
user.Password = "secret" user.Password = "secret"
user.Email = "example@example.com" user.Email = "example@example.com"
user.Filters.AdditionalEmails = []string{"alias@example.com"}
user.Description = "some desc" user.Description = "some desc"
user.Filters.TLSCerts = []string{serverCert} user.Filters.TLSCerts = []string{serverCert}
user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"} user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"}
@ -1432,6 +1433,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
assert.Len(t, user.PublicKeys, 1) assert.Len(t, user.PublicKeys, 1)
assert.Len(t, user.Filters.TLSCerts, 1) assert.Len(t, user.Filters.TLSCerts, 1)
assert.NotEmpty(t, user.Email) assert.NotEmpty(t, user.Email)
assert.Len(t, user.Filters.AdditionalEmails, 1)
assert.NotEmpty(t, user.Description) assert.NotEmpty(t, user.Description)
err = dataprovider.DeleteUser(username, "", "", "") err = dataprovider.DeleteUser(username, "", "", "")

View file

@ -7709,6 +7709,7 @@ func TestEventRulePasswordExpiration(t *testing.T) {
_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK) _, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
assert.NoError(t, err) assert.NoError(t, err)
user.Email = "user@example.net" user.Email = "user@example.net"
user.Filters.AdditionalEmails = []string{"additional@example.net"}
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err) assert.NoError(t, err)
conn, client, err = getSftpClient(user) conn, client, err = getSftpClient(user)
@ -7724,8 +7725,9 @@ func TestEventRulePasswordExpiration(t *testing.T) {
return lastReceivedEmail.get().From != "" return lastReceivedEmail.get().From != ""
}, 1500*time.Millisecond, 100*time.Millisecond) }, 1500*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get() email := lastReceivedEmail.get()
assert.Len(t, email.To, 1) assert.Len(t, email.To, 2)
assert.Contains(t, email.To, user.Email) assert.Contains(t, email.To, user.Email)
assert.Contains(t, email.To, user.Filters.AdditionalEmails[0])
assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days") assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
err = client.RemoveDirectory(dirName) err = client.RemoveDirectory(dirName)
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -3230,6 +3230,24 @@ func validateCombinedUserFilters(user *User) error {
return nil return nil
} }
func validateEmails(user *User) error {
if user.Email != "" && !util.IsEmailValid(user.Email) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
util.I18nErrorInvalidEmail,
)
}
for _, email := range user.Filters.AdditionalEmails {
if !util.IsEmailValid(email) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("email %q is not valid", email)),
util.I18nErrorInvalidEmail,
)
}
}
return nil
}
func validateBaseParams(user *User) error { func validateBaseParams(user *User) error {
if user.Username == "" { if user.Username == "" {
return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired) return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
@ -3237,11 +3255,8 @@ func validateBaseParams(user *User) error {
if err := checkReservedUsernames(user.Username); err != nil { if err := checkReservedUsernames(user.Username); err != nil {
return util.NewI18nError(err, util.I18nErrorReservedUsername) return util.NewI18nError(err, util.I18nErrorReservedUsername)
} }
if user.Email != "" && !util.IsEmailValid(user.Email) { if err := validateEmails(user); err != nil {
return util.NewI18nError( return err
util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
util.I18nErrorInvalidEmail,
)
} }
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) { if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
return util.NewI18nError( return util.NewI18nError(

View file

@ -124,6 +124,8 @@ type UserFilters struct {
sdk.BaseUserFilters sdk.BaseUserFilters
// User must change password from WebClient/REST API at next login. // User must change password from WebClient/REST API at next login.
RequirePasswordChange bool `json:"require_password_change,omitempty"` RequirePasswordChange bool `json:"require_password_change,omitempty"`
// AdditionalEmails defines additional email addresses
AdditionalEmails []string `json:"additional_emails,omitempty"`
// Time-based one time passwords configuration // Time-based one time passwords configuration
TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"` TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
// Recovery codes to use if the user loses access to their second factor auth device. // Recovery codes to use if the user loses access to their second factor auth device.
@ -404,6 +406,15 @@ func (u *User) CheckMaxShareExpiration(expiresAt time.Time) error {
return nil return nil
} }
// GetEmailAddresses returns all the email addresses.
func (u *User) GetEmailAddresses() []string {
var res []string
if u.Email != "" {
res = append(res, u.Email)
}
return slices.Concat(res, u.Filters.AdditionalEmails)
}
// GetSubDirPermissions returns permissions for sub directories // GetSubDirPermissions returns permissions for sub directories
func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions { func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
var result []sdk.DirectoryPermissions var result []sdk.DirectoryPermissions
@ -1784,6 +1795,8 @@ func (u *User) getACopy() User {
filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone() filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols)) filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols) copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
filters.AdditionalEmails = make([]string, len(u.Filters.AdditionalEmails))
copy(filters.AdditionalEmails, u.Filters.AdditionalEmails)
filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes)) filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
for _, code := range u.Filters.RecoveryCodes { for _, code := range u.Filters.RecoveryCodes {
if code.Secret == nil { if code.Secret == nil {

View file

@ -469,6 +469,7 @@ func getUserProfile(w http.ResponseWriter, r *http.Request) {
Description: user.Description, Description: user.Description,
AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth, AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
}, },
AdditionalEmails: user.Filters.AdditionalEmails,
PublicKeys: user.PublicKeys, PublicKeys: user.PublicKeys,
TLSCerts: user.Filters.TLSCerts, TLSCerts: user.Filters.TLSCerts,
} }
@ -508,6 +509,7 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
} }
if userMerged.CanChangeInfo() { if userMerged.CanChangeInfo() {
user.Email = req.Email user.Email = req.Email
user.Filters.AdditionalEmails = req.AdditionalEmails
user.Description = req.Description user.Description = req.Description
} }
if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil { if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil {

View file

@ -72,6 +72,7 @@ type adminProfile struct {
type userProfile struct { type userProfile struct {
baseProfile baseProfile
AdditionalEmails []string `json:"additional_emails,omitempty"`
PublicKeys []string `json:"public_keys,omitempty"` PublicKeys []string `json:"public_keys,omitempty"`
TLSCerts []string `json:"tls_certs,omitempty"` TLSCerts []string `json:"tls_certs,omitempty"`
} }
@ -786,7 +787,8 @@ func getActiveUser(username string, r *http.Request) (dataprovider.User, error)
} }
func handleForgotPassword(r *http.Request, username string, isAdmin bool) error { func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
var email, subject string var emails []string
var subject string
var err error var err error
var admin dataprovider.Admin var admin dataprovider.Admin
var user dataprovider.User var user dataprovider.User
@ -796,11 +798,13 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
} }
if isAdmin { if isAdmin {
admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr)) admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr))
email = admin.Email if admin.Email != "" {
emails = []string{admin.Email}
}
subject = fmt.Sprintf("Email Verification Code for admin %q", username) subject = fmt.Sprintf("Email Verification Code for admin %q", username)
} else { } else {
user, err = getActiveUser(username, r) user, err = getActiveUser(username, r)
email = user.Email emails = user.GetEmailAddresses()
subject = fmt.Sprintf("Email Verification Code for user %q", username) subject = fmt.Sprintf("Email Verification Code for user %q", username)
if err == nil { if err == nil {
if !isUserAllowedToResetPassword(r, &user) { if !isUserAllowedToResetPassword(r, &user) {
@ -821,7 +825,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
} }
return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser) return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser)
} }
if email == "" { if len(emails) == 0 {
return util.NewI18nError( return util.NewI18nError(
util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"), util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"),
util.I18nErrorPwdResetNoEmail, util.I18nErrorPwdResetNoEmail,
@ -836,7 +840,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
return util.NewGenericError("Unable to render password reset template") return util.NewGenericError("Unable to render password reset template")
} }
startTime := time.Now() startTime := time.Now()
if err := smtp.SendEmail([]string{email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil { if err := smtp.SendEmail(emails, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v", logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
err, time.Since(startTime)) err, time.Since(startTime))
return util.NewI18nError( return util.NewI18nError(
@ -844,8 +848,8 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
util.I18nErrorPwdResetSendEmail, util.I18nErrorPwdResetSendEmail,
) )
} }
logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, email: %q, is admin? %v, elapsed: %v", logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, emails: %+v, is admin? %v, elapsed: %v",
username, email, isAdmin, time.Since(startTime)) username, emails, isAdmin, time.Since(startTime))
return resetCodesMgr.Add(c) return resetCodesMgr.Add(c)
} }

View file

@ -623,6 +623,7 @@ func TestInitialization(t *testing.T) {
func TestBasicUserHandling(t *testing.T) { func TestBasicUserHandling(t *testing.T) {
u := getTestUser() u := getTestUser()
u.Email = "user@user.com" u.Email = "user@user.com"
u.Filters.AdditionalEmails = []string{"email1@user.com", "email2@user.com"}
user, resp, err := httpdtest.AddUser(u, http.StatusCreated) user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err, string(resp)) assert.NoError(t, err, string(resp))
_, resp, err = httpdtest.AddUser(u, http.StatusConflict) _, resp, err = httpdtest.AddUser(u, http.StatusConflict)
@ -663,6 +664,12 @@ func TestBasicUserHandling(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, string(body), "Validation error: email") assert.Contains(t, string(body), "Validation error: email")
user.Email = ""
user.Filters.AdditionalEmails = []string{"invalid@email"}
_, body, err = httpdtest.UpdateUser(user, http.StatusBadRequest, "")
assert.NoError(t, err)
assert.Contains(t, string(body), "Validation error: email")
_, err = httpdtest.RemoveUser(user, http.StatusOK) _, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -11298,6 +11305,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr) checkResponseCode(t, http.StatusBadRequest, rr)
email := "userapi@example.com" email := "userapi@example.com"
additionalEmails := []string{"userapi1@example.com"}
description := "user API description" description := "user API description"
profileReq := make(map[string]any) profileReq := make(map[string]any)
profileReq["allow_api_key_auth"] = true profileReq["allow_api_key_auth"] = true
@ -11305,6 +11313,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
profileReq["description"] = description profileReq["description"] = description
profileReq["public_keys"] = []string{testPubKey, testPubKey1} profileReq["public_keys"] = []string{testPubKey, testPubKey1}
profileReq["tls_certs"] = []string{httpsCert} profileReq["tls_certs"] = []string{httpsCert}
profileReq["additional_emails"] = additionalEmails
asJSON, err := json.Marshal(profileReq) asJSON, err := json.Marshal(profileReq)
assert.NoError(t, err) assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON)) req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
@ -11322,6 +11331,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
err = json.Unmarshal(rr.Body.Bytes(), &profileReq) err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, email, profileReq["email"].(string)) assert.Equal(t, email, profileReq["email"].(string))
assert.Len(t, profileReq["additional_emails"].([]interface{}), 1)
assert.Equal(t, description, profileReq["description"].(string)) assert.Equal(t, description, profileReq["description"].(string))
assert.True(t, profileReq["allow_api_key_auth"].(bool)) assert.True(t, profileReq["allow_api_key_auth"].(bool))
val, ok := profileReq["public_keys"].([]any) val, ok := profileReq["public_keys"].([]any)
@ -11343,6 +11353,17 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
rr = executeRequest(req) rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr) checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "Validation error: email") assert.Contains(t, rr.Body.String(), "Validation error: email")
// set an invalid additional email
profileReq = make(map[string]any)
profileReq["additional_emails"] = []string{"not an email"}
asJSON, err = json.Marshal(profileReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "Validation error: email")
// set an invalid public key // set an invalid public key
profileReq = make(map[string]any) profileReq = make(map[string]any)
profileReq["public_keys"] = []string{"not a public key"} profileReq["public_keys"] = []string{"not a public key"}
@ -19859,6 +19880,7 @@ func TestWebUserProfile(t *testing.T) {
form.Set("public_keys[0][public_key]", testPubKey) form.Set("public_keys[0][public_key]", testPubKey)
form.Set("public_keys[1][public_key]", testPubKey1) form.Set("public_keys[1][public_key]", testPubKey1)
form.Set("tls_certs[0][tls_cert]", httpsCert) form.Set("tls_certs[0][tls_cert]", httpsCert)
form.Set("additional_emails[0][additional_email]", "email1@user.com")
// no csrf token // no csrf token
req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode()))) req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err) assert.NoError(t, err)
@ -19885,6 +19907,9 @@ func TestWebUserProfile(t *testing.T) {
assert.Len(t, user.Filters.TLSCerts, 1) assert.Len(t, user.Filters.TLSCerts, 1)
assert.Equal(t, email, user.Email) assert.Equal(t, email, user.Email)
assert.Equal(t, description, user.Description) assert.Equal(t, description, user.Description)
if assert.Len(t, user.Filters.AdditionalEmails, 1) {
assert.Equal(t, "email1@user.com", user.Filters.AdditionalEmails[0])
}
// set an invalid email // set an invalid email
form.Set("email", "not an email") form.Set("email", "not an email")
@ -21268,6 +21293,7 @@ func TestWebUserAddMock(t *testing.T) {
user.AdditionalInfo = "info" user.AdditionalInfo = "info"
user.Description = "user dsc" user.Description = "user dsc"
user.Email = "test@test.com" user.Email = "test@test.com"
user.Filters.AdditionalEmails = []string{"example1@test.com", "example2@test.com"}
mappedDir := filepath.Join(os.TempDir(), "mapped") mappedDir := filepath.Join(os.TempDir(), "mapped")
folderName := filepath.Base(mappedDir) folderName := filepath.Base(mappedDir)
f := vfs.BaseVirtualFolder{ f := vfs.BaseVirtualFolder{
@ -21285,6 +21311,8 @@ func TestWebUserAddMock(t *testing.T) {
form.Set(csrfFormToken, csrfToken) form.Set(csrfFormToken, csrfToken)
form.Set("username", user.Username) form.Set("username", user.Username)
form.Set("email", user.Email) form.Set("email", user.Email)
form.Set("additional_emails[0][additional_email]", user.Filters.AdditionalEmails[0])
form.Set("additional_emails[1][additional_email]", user.Filters.AdditionalEmails[1])
form.Set("home_dir", user.HomeDir) form.Set("home_dir", user.HomeDir)
form.Set("osfs_read_buffer_size", "2") form.Set("osfs_read_buffer_size", "2")
form.Set("osfs_write_buffer_size", "3") form.Set("osfs_write_buffer_size", "3")
@ -21611,6 +21639,7 @@ func TestWebUserAddMock(t *testing.T) {
assert.True(t, newUser.Filters.DisableFsChecks) assert.True(t, newUser.Filters.DisableFsChecks)
assert.False(t, newUser.Filters.AllowAPIKeyAuth) assert.False(t, newUser.Filters.AllowAPIKeyAuth)
assert.Equal(t, user.Email, newUser.Email) assert.Equal(t, user.Email, newUser.Email)
assert.Equal(t, len(user.Filters.AdditionalEmails), len(newUser.Filters.AdditionalEmails))
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory) assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
assert.Equal(t, 0, newUser.Filters.FTPSecurity) assert.Equal(t, 0, newUser.Filters.FTPSecurity)
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration) assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)

View file

@ -1981,6 +1981,13 @@ func updateRepeaterFormFields(r *http.Request) {
} }
continue continue
} }
if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
email := strings.TrimSpace(r.Form.Get(k))
if email != "" {
r.Form.Add("additional_emails", email)
}
continue
}
if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") { if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
base, _ := strings.CutSuffix(k, "[vfolder_path]") base, _ := strings.CutSuffix(k, "[vfolder_path]")
r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k))) r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
@ -2114,6 +2121,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
Filters: dataprovider.UserFilters{ Filters: dataprovider.UserFilters{
BaseUserFilters: filters, BaseUserFilters: filters,
RequirePasswordChange: r.Form.Get("require_password_change") != "", RequirePasswordChange: r.Form.Get("require_password_change") != "",
AdditionalEmails: r.Form["additional_emails"],
}, },
VirtualFolders: getVirtualFoldersFromPostFields(r), VirtualFolders: getVirtualFoldersFromPostFields(r),
FsConfig: fsConfig, FsConfig: fsConfig,
@ -3317,6 +3325,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
user.SetEmptySecrets() user.SetEmptySecrets()
user.PublicKeys = nil user.PublicKeys = nil
user.Email = "" user.Email = ""
user.Filters.AdditionalEmails = nil
user.Description = "" user.Description = ""
if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 { if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration))) user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))

View file

@ -179,6 +179,8 @@ type clientProfilePage struct {
CanSubmit bool CanSubmit bool
AllowAPIKeyAuth bool AllowAPIKeyAuth bool
Email string Email string
AdditionalEmails []string
AdditionalEmailsString string
Description string Description string
Error *util.I18nError Error *util.I18nError
} }
@ -841,6 +843,8 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
data.TLSCerts = user.Filters.TLSCerts data.TLSCerts = user.Filters.TLSCerts
data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
data.Email = user.Email data.Email = user.Email
data.AdditionalEmails = user.Filters.AdditionalEmails
data.AdditionalEmailsString = strings.Join(data.AdditionalEmails, ", ")
data.Description = user.Description data.Description = user.Description
data.CanSubmit = userMerged.CanUpdateProfile() data.CanSubmit = userMerged.CanUpdateProfile()
renderClientTemplate(w, templateClientProfile, data) renderClientTemplate(w, templateClientProfile, data)
@ -1661,6 +1665,15 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
if userMerged.CanChangeInfo() { if userMerged.CanChangeInfo() {
user.Email = strings.TrimSpace(r.Form.Get("email")) user.Email = strings.TrimSpace(r.Form.Get("email"))
user.Description = r.Form.Get("description") user.Description = r.Form.Get("description")
for k := range r.Form {
if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
email := strings.TrimSpace(r.Form.Get(k))
if email != "" {
r.Form.Add("additional_emails", email)
}
}
}
user.Filters.AdditionalEmails = r.Form["additional_emails"]
} }
err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role) err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
if err != nil { if err != nil {

View file

@ -2037,6 +2037,9 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
if expected.Email != actual.Email { if expected.Email != actual.Email {
return errors.New("email mismatch") return errors.New("email mismatch")
} }
if !slices.Equal(expected.Filters.AdditionalEmails, actual.Filters.AdditionalEmails) {
return errors.New("additional emails mismatch")
}
if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange { if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
return errors.New("require_password_change mismatch") return errors.New("require_password_change mismatch")
} }

View file

@ -540,6 +540,7 @@
"invalid_quota_size": "Invalid quota size", "invalid_quota_size": "Invalid quota size",
"expires_in": "Expires in", "expires_in": "Expires in",
"expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration", "expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration",
"additional_emails": "Additional emails",
"tls_certs": "TLS certificates", "tls_certs": "TLS certificates",
"tls_certs_help": "TLS certificates can be used for FTP and/or WebDAV authentication", "tls_certs_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
"tls_cert_help": "Paste a PEM encoded TLS certificate here", "tls_cert_help": "Paste a PEM encoded TLS certificate here",

View file

@ -540,6 +540,7 @@
"invalid_quota_size": "Quota (dimensione) non valida", "invalid_quota_size": "Quota (dimensione) non valida",
"expires_in": "Scadenza", "expires_in": "Scadenza",
"expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza", "expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza",
"additional_emails": "Email aggiuntive",
"tls_certs": "Certificati TLS", "tls_certs": "Certificati TLS",
"tls_certs_help": "I certificati TLS possono essere utilizzati per l'autenticazione FTP e/o WebDAV", "tls_certs_help": "I certificati TLS possono essere utilizzati per l'autenticazione FTP e/o WebDAV",
"tls_cert_help": "Incolla qui un tuo certificato TLS codificato PEM", "tls_cert_help": "Incolla qui un tuo certificato TLS codificato PEM",

View file

@ -461,6 +461,70 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</div> </div>
</div> </div>
<div class="card mt-10">
<div class="card-header bg-light">
<h3 data-i18n="user.additional_emails" class="card-title section-title-inner">Additional emails</h3>
</div>
<div class="card-body">
<div id="additional_emails">
<div class="form-group">
<div data-repeater-list="additional_emails">
{{- range $idx, $val := .User.Filters.AdditionalEmails}}
<div data-repeater-item>
<div class="form-group row">
<div class="col-md-10 mt-3 mt-md-8">
<input type="email" class="form-control" placeholder="" name="additional_email" value="{{$val}}" maxlength="255" autocomplete="off" spellcheck="false" />
</div>
<div class="col-md-2 mt-3 mt-md-8">
<a href="#" data-repeater-delete
class="btn btn-light-danger">
<i class="ki-duotone ki-trash fs-5">
<span class="path1"></span>
<span class="path2"></span>
<span class="path3"></span>
<span class="path4"></span>
<span class="path5"></span>
</i>
<span data-i18n="general.delete">Delete</span>
</a>
</div>
</div>
</div>
{{- else}}
<div data-repeater-item>
<div class="form-group row">
<div class="col-md-10 mt-3 mt-md-8">
<input type="email" class="form-control" placeholder="" name="additional_email" value="" maxlength="255" autocomplete="off" spellcheck="false" />
</div>
<div class="col-md-2 mt-3 mt-md-8">
<a href="#" data-repeater-delete
class="btn btn-light-danger">
<i class="ki-duotone ki-trash fs-5">
<span class="path1"></span>
<span class="path2"></span>
<span class="path3"></span>
<span class="path4"></span>
<span class="path5"></span>
</i>
<span data-i18n="general.delete">Delete</span>
</a>
</div>
</div>
</div>
{{- end}}
</div>
</div>
<div class="form-group mt-5">
<a href="#" data-repeater-create class="btn btn-light-primary">
<i class="ki-duotone ki-plus fs-3"></i>
<span data-i18n="general.add">Add</span>
</a>
</div>
</div>
</div>
</div>
{{- template "user_group_profile" .User.Filters}} {{- template "user_group_profile" .User.Filters}}
<div class="form-group row mt-10"> <div class="form-group row mt-10">
@ -789,6 +853,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
initRepeater('#src_bandwidth_limits'); initRepeater('#src_bandwidth_limits');
initRepeater('#tls_certs'); initRepeater('#tls_certs');
initRepeater('#access_time_restrictions'); initRepeater('#access_time_restrictions');
initRepeater('#additional_emails');
initRepeaterItems(); initRepeaterItems();
//{{- if .Error}} //{{- if .Error}}
//{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}} //{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}

View file

@ -31,6 +31,80 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</div> </div>
</div> </div>
{{- if .LoggedUser.CanChangeInfo}}
<div class="card mt-10">
<div class="card-header bg-light">
<h3 data-i18n="user.additional_emails" class="card-title section-title-inner">Additional emails</h3>
</div>
<div class="card-body">
<div id="additional_emails">
<div class="form-group">
<div data-repeater-list="additional_emails">
{{- range $idx, $val := .AdditionalEmails}}
<div data-repeater-item>
<div class="form-group row">
<div class="col-md-10 mt-3 mt-md-8">
<input type="email" class="form-control" placeholder="" name="additional_email" value="{{$val}}" maxlength="255" autocomplete="off" spellcheck="false" />
</div>
<div class="col-md-2 mt-3 mt-md-8">
<a href="#" data-repeater-delete
class="btn btn-light-danger">
<i class="ki-duotone ki-trash fs-5">
<span class="path1"></span>
<span class="path2"></span>
<span class="path3"></span>
<span class="path4"></span>
<span class="path5"></span>
</i>
<span data-i18n="general.delete">Delete</span>
</a>
</div>
</div>
</div>
{{- else}}
<div data-repeater-item>
<div class="form-group row">
<div class="col-md-10 mt-3 mt-md-8">
<input type="email" class="form-control" placeholder="" name="additional_email" value="" maxlength="255" autocomplete="off" spellcheck="false" />
</div>
<div class="col-md-2 mt-3 mt-md-8">
<a href="#" data-repeater-delete
class="btn btn-light-danger">
<i class="ki-duotone ki-trash fs-5">
<span class="path1"></span>
<span class="path2"></span>
<span class="path3"></span>
<span class="path4"></span>
<span class="path5"></span>
</i>
<span data-i18n="general.delete">Delete</span>
</a>
</div>
</div>
</div>
{{- end}}
</div>
</div>
<div class="form-group mt-5">
<a href="#" data-repeater-create class="btn btn-light-primary">
<i class="ki-duotone ki-plus fs-3"></i>
<span data-i18n="general.add">Add</span>
</a>
</div>
</div>
</div>
</div>
{{- else}}
<div class="form-group row mt-10">
<label for="idAdditionalEmails" data-i18n="user.additional_emails" class="col-md-3 col-form-label">Additional emails</label>
<div class="col-md-9">
<input type="text" id="idAdditionalEmails" name="description" placeholder="" value="{{.AdditionalEmailsString}}"
class="form-control-plaintext readonly-input" readonly>
</div>
</div>
{{- end}}
<div class="form-group row mt-10"> <div class="form-group row mt-10">
<label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label> <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
<div class="col-md-9"> <div class="col-md-9">
@ -217,6 +291,9 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
//{{- if .LoggedUser.CanManageTLSCerts}} //{{- if .LoggedUser.CanManageTLSCerts}}
initRepeater('#tls_certs'); initRepeater('#tls_certs');
//{{- end}} //{{- end}}
//{{- if .LoggedUser.CanChangeInfo}}
initRepeater('#additional_emails');
//{{- end}}
initRepeaterItems(); initRepeaterItems();
//{{- end}} //{{- end}}