shares: allow to force an expiration date

this is a soft requirement, users can reactivate expired shares by
updating the expiration date

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-08-07 19:07:20 +02:00
parent ea96fe9a26
commit 830116bcf2
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
13 changed files with 233 additions and 9 deletions

View file

@ -16,7 +16,7 @@ The following settings are inherited from the primary group:
- home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username - home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username
- filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix", for any vfs, and the "username" for the SFTP filesystem config - filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix", for any vfs, and the "username" for the SFTP filesystem config
- max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security, default share expiration, password expiration, password strength: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`. The password strength defined at group level is only enforce when users change their password - max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security, default shares expiration, max shares expiration, password expiration, password strength: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`. The password strength defined at group level is only enforce when users change their password
- expires_in, if defined and the user does not have an expiration date set, defines the expiration of the account in number of days from the creation date - expires_in, if defined and the user does not have an expiration date set, defines the expiration of the account in number of days from the creation date
- TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication, anonymous user: if they are not set for the user they are replaced with the value set for the group - TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication, anonymous user: if they are not set for the user they are replaced with the value set for the group
- starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username - starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username

4
go.mod
View file

@ -53,7 +53,7 @@ require (
github.com/rs/cors v1.9.0 github.com/rs/cors v1.9.0
github.com/rs/xid v1.5.0 github.com/rs/xid v1.5.0
github.com/rs/zerolog v1.30.0 github.com/rs/zerolog v1.30.0
github.com/sftpgo/sdk v0.1.5 github.com/sftpgo/sdk v0.1.6-0.20230807170339-3178878ce745
github.com/shirou/gopsutil/v3 v3.23.7 github.com/shirou/gopsutil/v3 v3.23.7
github.com/spf13/afero v1.9.5 github.com/spf13/afero v1.9.5
github.com/spf13/cobra v1.7.0 github.com/spf13/cobra v1.7.0
@ -159,7 +159,7 @@ require (
golang.org/x/mod v0.12.0 // indirect golang.org/x/mod v0.12.0 // indirect
golang.org/x/sync v0.3.0 // indirect golang.org/x/sync v0.3.0 // indirect
golang.org/x/text v0.12.0 // indirect golang.org/x/text v0.12.0 // indirect
golang.org/x/tools v0.11.1 // indirect golang.org/x/tools v0.12.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect

8
go.sum
View file

@ -421,8 +421,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
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.5 h1:3vpE5wohtJvJKyPKB7smAMZiLjLyoJzbtIkYNyNh5iw= github.com/sftpgo/sdk v0.1.6-0.20230807170339-3178878ce745 h1:IRKfXQ0/P0ON9UzltTmgLKU0HWYSkuafARw3Pv3hDRU=
github.com/sftpgo/sdk v0.1.5/go.mod h1:TjeoMWS0JEXt9RukJveTnaiHj4+MVLtUiDC+mY++Odk= github.com/sftpgo/sdk v0.1.6-0.20230807170339-3178878ce745/go.mod h1:TjeoMWS0JEXt9RukJveTnaiHj4+MVLtUiDC+mY++Odk=
github.com/shirou/gopsutil/v3 v3.23.7 h1:C+fHO8hfIppoJ1WdsVm1RoI0RwXoNdfTK7yWXV0wVj4= github.com/shirou/gopsutil/v3 v3.23.7 h1:C+fHO8hfIppoJ1WdsVm1RoI0RwXoNdfTK7yWXV0wVj4=
github.com/shirou/gopsutil/v3 v3.23.7/go.mod h1:c4gnmoRC0hQuaLqvxnx1//VXQ0Ms/X9UnJF8pddY5z4= github.com/shirou/gopsutil/v3 v3.23.7/go.mod h1:c4gnmoRC0hQuaLqvxnx1//VXQ0Ms/X9UnJF8pddY5z4=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
@ -725,8 +725,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.11.1 h1:ojD5zOW8+7dOGzdnNgersm8aPfcDjhMp12UfG93NIMc= golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.11.1/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -2570,6 +2570,7 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth
filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
filters.DefaultSharesExpiration = in.DefaultSharesExpiration filters.DefaultSharesExpiration = in.DefaultSharesExpiration
filters.MaxSharesExpiration = in.MaxSharesExpiration
filters.PasswordExpiration = in.PasswordExpiration filters.PasswordExpiration = in.PasswordExpiration
filters.PasswordStrength = in.PasswordStrength filters.PasswordStrength = in.PasswordStrength
filters.WebClient = make([]string, len(in.WebClient)) filters.WebClient = make([]string, len(in.WebClient))
@ -2967,6 +2968,10 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
return util.NewValidationError(fmt.Sprintf("invalid web client options %q", opts)) return util.NewValidationError(fmt.Sprintf("invalid web client options %q", opts))
} }
} }
if filters.MaxSharesExpiration > 0 && filters.MaxSharesExpiration < filters.DefaultSharesExpiration {
return util.NewValidationError(fmt.Sprintf("default shares expiration: %d must be less than or equal to max shares expiration: %d",
filters.DefaultSharesExpiration, filters.MaxSharesExpiration))
}
updateFiltersValues(filters) updateFiltersValues(filters)
return validateFiltersPatternExtensions(filters) return validateFiltersPatternExtensions(filters)

View file

@ -359,6 +359,21 @@ func (u *User) hideConfidentialData() {
} }
} }
// CheckMaxShareExpiration returns an error if the share expiration exceed the
// maximum allowed date.
func (u *User) CheckMaxShareExpiration(expiresAt time.Time) error {
if u.Filters.MaxSharesExpiration == 0 {
return nil
}
maxAllowedExpiration := time.Now().Add(24 * time.Hour * time.Duration(u.Filters.MaxSharesExpiration+1))
maxAllowedExpiration = time.Date(maxAllowedExpiration.Year(), maxAllowedExpiration.Month(),
maxAllowedExpiration.Day(), 0, 0, 0, 0, maxAllowedExpiration.Location())
if util.GetTimeAsMsSinceEpoch(expiresAt) == 0 || expiresAt.After(maxAllowedExpiration) {
return util.NewValidationError(fmt.Sprintf("the share must expire before %s", maxAllowedExpiration.Format(time.DateOnly)))
}
return nil
}
// 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
@ -1738,7 +1753,7 @@ func (u *User) mergeWithPrimaryGroup(group *Group, replacer *strings.Replacer) {
u.mergeAdditiveProperties(group, sdk.GroupTypePrimary, replacer) u.mergeAdditiveProperties(group, sdk.GroupTypePrimary, replacer)
} }
func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *strings.Replacer) { func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *strings.Replacer) { //nolint:gocyclo
if u.Filters.MaxUploadFileSize == 0 { if u.Filters.MaxUploadFileSize == 0 {
u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
} }
@ -1775,6 +1790,9 @@ func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *
if u.Filters.DefaultSharesExpiration == 0 { if u.Filters.DefaultSharesExpiration == 0 {
u.Filters.DefaultSharesExpiration = filters.DefaultSharesExpiration u.Filters.DefaultSharesExpiration = filters.DefaultSharesExpiration
} }
if u.Filters.MaxSharesExpiration == 0 {
u.Filters.MaxSharesExpiration = filters.MaxSharesExpiration
}
if u.Filters.PasswordExpiration == 0 { if u.Filters.PasswordExpiration == 0 {
u.Filters.PasswordExpiration = filters.PasswordExpiration u.Filters.PasswordExpiration = filters.PasswordExpiration
} }

View file

@ -95,6 +95,10 @@ func addShare(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", http.StatusBadRequest) sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return return
} }
if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(share.ExpiresAt)); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
share.ID = 0 share.ID = 0
share.ShareID = util.GenerateUniqueID() share.ShareID = util.GenerateUniqueID()
share.LastUseAt = 0 share.LastUseAt = 0
@ -126,6 +130,11 @@ func updateShare(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return return
} }
user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "")
if err != nil {
sendAPIResponse(w, r, err, "Unable to retrieve your user", getRespStatus(err))
return
}
shareID := getURLParam(r, "id") shareID := getURLParam(r, "id")
share, err := dataprovider.ShareExists(shareID, claims.Username) share, err := dataprovider.ShareExists(shareID, claims.Username)
if err != nil { if err != nil {
@ -152,6 +161,10 @@ func updateShare(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(updatedShare.ExpiresAt)); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
err = dataprovider.UpdateShare(&updatedShare, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role) err = dataprovider.UpdateShare(&updatedShare, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role)
if err != nil { if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err)) sendAPIResponse(w, r, err, "", getRespStatus(err))

View file

@ -13502,6 +13502,117 @@ func TestShareUsage(t *testing.T) {
executeRequest(req) executeRequest(req)
} }
func TestShareMaxExpiration(t *testing.T) {
u := getTestUser()
u.Filters.MaxSharesExpiration = 5
u.Filters.DefaultSharesExpiration = 10
_, resp, err := httpdtest.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "must be less than or equal to max shares expiration")
u.Filters.DefaultSharesExpiration = 0
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
webClientToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
assert.NoError(t, err)
s := dataprovider.Share{
Name: "test share",
Scope: dataprovider.ShareScopeRead,
Password: defaultPassword,
Paths: []string{"/"},
ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(u.Filters.MaxSharesExpiration+2))),
}
asJSON, err := json.Marshal(s)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "share must expire before")
req, err = http.NewRequest(http.MethodPut, path.Join(userSharesPath, "shareID"), bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
// expiresAt is mandatory
s.ExpiresAt = 0
asJSON, err = json.Marshal(s)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "share must expire before")
s.ExpiresAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(2 * time.Hour))
asJSON, err = json.Marshal(s)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
shareID := rr.Header().Get("X-Object-ID")
assert.NotEmpty(t, shareID)
s.ExpiresAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(u.Filters.MaxSharesExpiration+2)))
asJSON, err = json.Marshal(s)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPut, path.Join(userSharesPath, shareID), bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "share must expire before")
form := make(url.Values)
form.Set("name", s.Name)
form.Set("scope", strconv.Itoa(int(s.Scope)))
form.Set("max_tokens", "0")
form.Set("paths", "/")
form.Set("expiration_date", time.Now().Add(24*time.Hour*time.Duration(u.Filters.MaxSharesExpiration+2)).Format("2006-01-02 15:04:05"))
form.Set(csrfFormToken, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webClientToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "share must expire before")
req, err = http.NewRequest(http.MethodPost, path.Join(webClientSharePath, shareID), bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webClientToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "share must expire before")
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webClientToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
}
func TestWebClientShareCredentials(t *testing.T) { func TestWebClientShareCredentials(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err) assert.NoError(t, err)
@ -19486,6 +19597,16 @@ func TestWebUserAddMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr) checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid default shares expiration") assert.Contains(t, rr.Body.String(), "invalid default shares expiration")
form.Set("default_shares_expiration", "10") form.Set("default_shares_expiration", "10")
// test invalid max shares expiration
form.Set("max_shares_expiration", "a")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, webToken)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid max shares expiration")
form.Set("max_shares_expiration", "30")
// test invalid password expiration // test invalid password expiration
form.Set("password_expiration", "a") form.Set("password_expiration", "a")
b, contentType, _ = getMultipartFormData(form, "", "") b, contentType, _ = getMultipartFormData(form, "", "")
@ -19613,6 +19734,7 @@ func TestWebUserAddMock(t *testing.T) {
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)
assert.Equal(t, 30, newUser.Filters.MaxSharesExpiration)
assert.Equal(t, 90, newUser.Filters.PasswordExpiration) assert.Equal(t, 90, newUser.Filters.PasswordExpiration)
assert.Equal(t, 60, newUser.Filters.PasswordStrength) assert.Equal(t, 60, newUser.Filters.PasswordStrength)
assert.Greater(t, newUser.LastPasswordChange, int64(0)) assert.Greater(t, newUser.LastPasswordChange, int64(0))
@ -19798,6 +19920,7 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("denied_protocols", common.ProtocolFTP) form.Set("denied_protocols", common.ProtocolFTP)
form.Set("max_upload_file_size", "100") form.Set("max_upload_file_size", "100")
form.Set("default_shares_expiration", "30") form.Set("default_shares_expiration", "30")
form.Set("max_shares_expiration", "60")
form.Set("password_expiration", "60") form.Set("password_expiration", "60")
form.Set("password_strength", "40") form.Set("password_strength", "40")
form.Set("disconnect", "1") form.Set("disconnect", "1")
@ -19879,6 +20002,7 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.Equal(t, int64(0), updateUser.UploadDataTransfer) assert.Equal(t, int64(0), updateUser.UploadDataTransfer)
assert.Equal(t, int64(0), updateUser.Filters.ExternalAuthCacheTime) assert.Equal(t, int64(0), updateUser.Filters.ExternalAuthCacheTime)
assert.Equal(t, 30, updateUser.Filters.DefaultSharesExpiration) assert.Equal(t, 30, updateUser.Filters.DefaultSharesExpiration)
assert.Equal(t, 60, updateUser.Filters.MaxSharesExpiration)
assert.Equal(t, 60, updateUser.Filters.PasswordExpiration) assert.Equal(t, 60, updateUser.Filters.PasswordExpiration)
assert.Equal(t, 40, updateUser.Filters.PasswordStrength) assert.Equal(t, 40, updateUser.Filters.PasswordStrength)
assert.True(t, updateUser.Filters.RequirePasswordChange) assert.True(t, updateUser.Filters.RequirePasswordChange)
@ -19993,6 +20117,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
form.Set("fs_provider", "0") form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
form.Set("ftp_security", "1") form.Set("ftp_security", "1")
@ -20089,6 +20214,7 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
form.Set("fs_provider", "0") form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
form.Set("external_auth_cache_time", "0") form.Set("external_auth_cache_time", "0")
@ -20180,6 +20306,7 @@ func TestUserTemplateMock(t *testing.T) {
form.Set("denied_extensions", "/dir2::.zip") form.Set("denied_extensions", "/dir2::.zip")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
form.Add("hooks", "external_auth_disabled") form.Add("hooks", "external_auth_disabled")
@ -20313,6 +20440,7 @@ func TestUserPlaceholders(t *testing.T) {
form.Set("external_auth_cache_time", "0") form.Set("external_auth_cache_time", "0")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
b, contentType, _ := getMultipartFormData(form, "", "") b, contentType, _ := getMultipartFormData(form, "", "")
@ -20659,6 +20787,7 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("pattern_policy1", "1") form.Set("pattern_policy1", "1")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
form.Set("ftp_security", "1") form.Set("ftp_security", "1")
@ -20876,6 +21005,7 @@ func TestWebUserGCSMock(t *testing.T) {
form.Set("pattern_type0", "allowed") form.Set("pattern_type0", "allowed")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
form.Set("ftp_security", "1") form.Set("ftp_security", "1")
@ -21005,6 +21135,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
form.Set("pattern_type1", "denied") form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
form.Set("http_equality_check_mode", "true") form.Set("http_equality_check_mode", "true")
@ -21132,6 +21263,7 @@ func TestWebUserAzureBlobMock(t *testing.T) {
form.Set("pattern_type1", "denied") form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
// test invalid az_upload_part_size // test invalid az_upload_part_size
@ -21316,6 +21448,7 @@ func TestWebUserCryptMock(t *testing.T) {
form.Set("pattern_type1", "denied") form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
// passphrase cannot be empty // passphrase cannot be empty
@ -21428,6 +21561,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
form.Set("pattern_type1", "denied") form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
// empty sftpconfig // empty sftpconfig
@ -21555,6 +21689,7 @@ func TestWebUserRole(t *testing.T) {
form.Set("total_data_transfer", strconv.FormatInt(user.TotalDataTransfer, 10)) form.Set("total_data_transfer", strconv.FormatInt(user.TotalDataTransfer, 10))
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "10") form.Set("default_shares_expiration", "10")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
b, contentType, _ := getMultipartFormData(form, "", "") b, contentType, _ := getMultipartFormData(form, "", "")
@ -22770,6 +22905,7 @@ func TestAddWebGroup(t *testing.T) {
assert.Contains(t, rr.Body.String(), "invalid max upload file size") assert.Contains(t, rr.Body.String(), "invalid max upload file size")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")
b, contentType, err = getMultipartFormData(form, "", "") b, contentType, err = getMultipartFormData(form, "", "")
@ -23208,6 +23344,7 @@ func TestUpdateWebGroupMock(t *testing.T) {
form.Set("total_data_transfer", "0") form.Set("total_data_transfer", "0")
form.Set("max_upload_file_size", "0") form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0") form.Set("default_shares_expiration", "0")
form.Set("max_shares_expiration", "0")
form.Set("expires_in", "0") form.Set("expires_in", "0")
form.Set("password_expiration", "0") form.Set("password_expiration", "0")
form.Set("password_strength", "0") form.Set("password_strength", "0")

View file

@ -1498,6 +1498,10 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
if err != nil { if err != nil {
return filters, fmt.Errorf("invalid default shares expiration: %w", err) return filters, fmt.Errorf("invalid default shares expiration: %w", err)
} }
maxSharesExpiration, err := strconv.Atoi(r.Form.Get("max_shares_expiration"))
if err != nil {
return filters, fmt.Errorf("invalid max shares expiration: %w", err)
}
passwordExpiration, err := strconv.Atoi(r.Form.Get("password_expiration")) passwordExpiration, err := strconv.Atoi(r.Form.Get("password_expiration"))
if err != nil { if err != nil {
return filters, fmt.Errorf("invalid password expiration: %w", err) return filters, fmt.Errorf("invalid password expiration: %w", err)
@ -1519,6 +1523,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
filters.TLSUsername = sdk.TLSUsername(strings.TrimSpace(r.Form.Get("tls_username"))) filters.TLSUsername = sdk.TLSUsername(strings.TrimSpace(r.Form.Get("tls_username")))
filters.WebClient = r.Form["web_client_options"] filters.WebClient = r.Form["web_client_options"]
filters.DefaultSharesExpiration = defaultSharesExpiration filters.DefaultSharesExpiration = defaultSharesExpiration
filters.MaxSharesExpiration = maxSharesExpiration
filters.PasswordExpiration = passwordExpiration filters.PasswordExpiration = passwordExpiration
filters.PasswordStrength = passwordStrength filters.PasswordStrength = passwordStrength
hooks := r.Form["hooks"] hooks := r.Form["hooks"]

View file

@ -1319,6 +1319,15 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
return return
} }
} }
user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "")
if err != nil {
s.renderAddUpdateSharePage(w, r, share, "Unable to retrieve your user", true)
return
}
if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(share.ExpiresAt)); err != nil {
s.renderAddUpdateSharePage(w, r, share, err.Error(), true)
return
}
err = dataprovider.AddShare(share, claims.Username, ipAddr, claims.Role) err = dataprovider.AddShare(share, claims.Username, ipAddr, claims.Role)
if err == nil { if err == nil {
http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther) http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
@ -1364,6 +1373,15 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
return return
} }
} }
user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "")
if err != nil {
s.renderAddUpdateSharePage(w, r, updatedShare, "Unable to retrieve your user", false)
return
}
if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(updatedShare.ExpiresAt)); err != nil {
s.renderAddUpdateSharePage(w, r, updatedShare, err.Error(), false)
return
}
err = dataprovider.UpdateShare(updatedShare, claims.Username, ipAddr, claims.Role) err = dataprovider.UpdateShare(updatedShare, claims.Username, ipAddr, claims.Role)
if err == nil { if err == nil {
http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther) http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)

View file

@ -2431,7 +2431,7 @@ func compareUserFiltersEqualFields(expected sdk.BaseUserFilters, actual sdk.Base
return nil return nil
} }
func compareBaseUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error { func compareBaseUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error { //nolint:gocyclo
if len(expected.AllowedIP) != len(actual.AllowedIP) { if len(expected.AllowedIP) != len(actual.AllowedIP) {
return errors.New("allowed IP mismatch") return errors.New("allowed IP mismatch")
} }
@ -2468,6 +2468,9 @@ func compareBaseUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFil
if expected.DefaultSharesExpiration != actual.DefaultSharesExpiration { if expected.DefaultSharesExpiration != actual.DefaultSharesExpiration {
return errors.New("default_shares_expiration mismatch") return errors.New("default_shares_expiration mismatch")
} }
if expected.MaxSharesExpiration != actual.MaxSharesExpiration {
return errors.New("max_shares_expiration mismatch")
}
if expected.PasswordExpiration != actual.PasswordExpiration { if expected.PasswordExpiration != actual.PasswordExpiration {
return errors.New("password_expiration mismatch") return errors.New("password_expiration mismatch")
} }

View file

@ -5540,6 +5540,9 @@ components:
default_shares_expiration: default_shares_expiration:
type: integer type: integer
description: 'Defines the default expiration for newly created shares as number of days. 0 means no expiration' description: 'Defines the default expiration for newly created shares as number of days. 0 means no expiration'
max_shares_expiration:
type: integer
description: 'Defines the maximum allowed expiration, as a number of days, when a user creates or updates a share. 0 means no expiration'
password_expiration: password_expiration:
type: integer type: integer
description: 'The password expires after the defined number of days. 0 means no expiration' description: 'The password expires after the defined number of days. 0 means no expiration'

View file

@ -651,6 +651,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</div> </div>
<div class="form-group row">
<label for="idMaxSharesExpiration" class="col-sm-2 col-form-label">Max shares expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idMaxSharesExpiration" name="max_shares_expiration"
value="{{.Group.UserSettings.Filters.MaxSharesExpiration}}" min="0" aria-describedby="maxSharesExpirationHelpBlock">
<small id="maxSharesExpirationHelpBlock" class="form-text text-muted">
Maximum allowed expiration, as number of days, when a user creates or updates a share
</small>
</div>
</div>
<div class="form-group row"> <div class="form-group row">
<label for="idHooks" class="col-sm-2 col-form-label">Hooks</label> <label for="idHooks" class="col-sm-2 col-form-label">Hooks</label>
<div class="col-sm-10"> <div class="col-sm-10">

View file

@ -393,6 +393,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</div> </div>
<div class="form-group row">
<label for="idMaxSharesExpiration" class="col-sm-2 col-form-label">Max shares expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idMaxSharesExpiration" name="max_shares_expiration"
value="{{.User.Filters.MaxSharesExpiration}}" min="0" aria-describedby="maxSharesExpirationHelpBlock">
<small id="maxSharesExpirationHelpBlock" class="form-text text-muted">
Maximum allowed expiration, as number of days, when a user creates or updates a share
</small>
</div>
</div>
<div class="form-group row"> <div class="form-group row">
<label for="idDescription" class="col-sm-2 col-form-label">Description</label> <label for="idDescription" class="col-sm-2 col-form-label">Description</label>
<div class="col-sm-10"> <div class="col-sm-10">