diff --git a/docs/groups.md b/docs/groups.md
index be66e8ef..6b66b258 100644
--- a/docs/groups.md
+++ b/docs/groups.md
@@ -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
- 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
- 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
diff --git a/go.mod b/go.mod
index 0a9ce914..71b95618 100644
--- a/go.mod
+++ b/go.mod
@@ -53,7 +53,7 @@ require (
github.com/rs/cors v1.9.0
github.com/rs/xid v1.5.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/spf13/afero v1.9.5
github.com/spf13/cobra v1.7.0
@@ -159,7 +159,7 @@ require (
golang.org/x/mod v0.12.0 // indirect
golang.org/x/sync v0.3.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
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect
diff --git a/go.sum b/go.sum
index ea184971..d0c938c0 100644
--- a/go.sum
+++ b/go.sum
@@ -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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
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.5/go.mod h1:TjeoMWS0JEXt9RukJveTnaiHj4+MVLtUiDC+mY++Odk=
+github.com/sftpgo/sdk v0.1.6-0.20230807170339-3178878ce745 h1:IRKfXQ0/P0ON9UzltTmgLKU0HWYSkuafARw3Pv3hDRU=
+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/go.mod h1:c4gnmoRC0hQuaLqvxnx1//VXQ0Ms/X9UnJF8pddY5z4=
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.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.11.1 h1:ojD5zOW8+7dOGzdnNgersm8aPfcDjhMp12UfG93NIMc=
-golang.org/x/tools v0.11.1/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
+golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
+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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go
index 5bdf6725..aef250f2 100644
--- a/internal/dataprovider/dataprovider.go
+++ b/internal/dataprovider/dataprovider.go
@@ -2570,6 +2570,7 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth
filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
filters.DefaultSharesExpiration = in.DefaultSharesExpiration
+ filters.MaxSharesExpiration = in.MaxSharesExpiration
filters.PasswordExpiration = in.PasswordExpiration
filters.PasswordStrength = in.PasswordStrength
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))
}
}
+ 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)
return validateFiltersPatternExtensions(filters)
diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go
index a8381d4b..baab62fc 100644
--- a/internal/dataprovider/user.go
+++ b/internal/dataprovider/user.go
@@ -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
func (u *User) GetSubDirPermissions() []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)
}
-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 {
u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
}
@@ -1775,6 +1790,9 @@ func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *
if u.Filters.DefaultSharesExpiration == 0 {
u.Filters.DefaultSharesExpiration = filters.DefaultSharesExpiration
}
+ if u.Filters.MaxSharesExpiration == 0 {
+ u.Filters.MaxSharesExpiration = filters.MaxSharesExpiration
+ }
if u.Filters.PasswordExpiration == 0 {
u.Filters.PasswordExpiration = filters.PasswordExpiration
}
diff --git a/internal/httpd/api_shares.go b/internal/httpd/api_shares.go
index a2b574cf..7bdd09ab 100644
--- a/internal/httpd/api_shares.go
+++ b/internal/httpd/api_shares.go
@@ -95,6 +95,10 @@ func addShare(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
+ if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(share.ExpiresAt)); err != nil {
+ sendAPIResponse(w, r, err, "", getRespStatus(err))
+ return
+ }
share.ID = 0
share.ShareID = util.GenerateUniqueID()
share.LastUseAt = 0
@@ -126,6 +130,11 @@ func updateShare(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
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")
share, err := dataprovider.ShareExists(shareID, claims.Username)
if err != nil {
@@ -152,6 +161,10 @@ func updateShare(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index e76129ee..97d0669f 100644
--- a/internal/httpd/httpd_test.go
+++ b/internal/httpd/httpd_test.go
@@ -13502,6 +13502,117 @@ func TestShareUsage(t *testing.T) {
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) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
@@ -19486,6 +19597,16 @@ func TestWebUserAddMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid default shares expiration")
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
form.Set("password_expiration", "a")
b, contentType, _ = getMultipartFormData(form, "", "")
@@ -19613,6 +19734,7 @@ func TestWebUserAddMock(t *testing.T) {
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
assert.Equal(t, 0, newUser.Filters.FTPSecurity)
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
+ assert.Equal(t, 30, newUser.Filters.MaxSharesExpiration)
assert.Equal(t, 90, newUser.Filters.PasswordExpiration)
assert.Equal(t, 60, newUser.Filters.PasswordStrength)
assert.Greater(t, newUser.LastPasswordChange, int64(0))
@@ -19798,6 +19920,7 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("denied_protocols", common.ProtocolFTP)
form.Set("max_upload_file_size", "100")
form.Set("default_shares_expiration", "30")
+ form.Set("max_shares_expiration", "60")
form.Set("password_expiration", "60")
form.Set("password_strength", "40")
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.Filters.ExternalAuthCacheTime)
assert.Equal(t, 30, updateUser.Filters.DefaultSharesExpiration)
+ assert.Equal(t, 60, updateUser.Filters.MaxSharesExpiration)
assert.Equal(t, 60, updateUser.Filters.PasswordExpiration)
assert.Equal(t, 40, updateUser.Filters.PasswordStrength)
assert.True(t, updateUser.Filters.RequirePasswordChange)
@@ -19993,6 +20117,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("ftp_security", "1")
@@ -20089,6 +20214,7 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "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("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Add("hooks", "external_auth_disabled")
@@ -20313,6 +20440,7 @@ func TestUserPlaceholders(t *testing.T) {
form.Set("external_auth_cache_time", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
@@ -20659,6 +20787,7 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("pattern_policy1", "1")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("ftp_security", "1")
@@ -20876,6 +21005,7 @@ func TestWebUserGCSMock(t *testing.T) {
form.Set("pattern_type0", "allowed")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("ftp_security", "1")
@@ -21005,6 +21135,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("http_equality_check_mode", "true")
@@ -21132,6 +21263,7 @@ func TestWebUserAzureBlobMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
// test invalid az_upload_part_size
@@ -21316,6 +21448,7 @@ func TestWebUserCryptMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
// passphrase cannot be empty
@@ -21428,6 +21561,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
// empty sftpconfig
@@ -21555,6 +21689,7 @@ func TestWebUserRole(t *testing.T) {
form.Set("total_data_transfer", strconv.FormatInt(user.TotalDataTransfer, 10))
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "10")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
@@ -22770,6 +22905,7 @@ func TestAddWebGroup(t *testing.T) {
assert.Contains(t, rr.Body.String(), "invalid max upload file size")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
b, contentType, err = getMultipartFormData(form, "", "")
@@ -23208,6 +23344,7 @@ func TestUpdateWebGroupMock(t *testing.T) {
form.Set("total_data_transfer", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
form.Set("expires_in", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index a6f7b5ca..57aa370f 100644
--- a/internal/httpd/webadmin.go
+++ b/internal/httpd/webadmin.go
@@ -1498,6 +1498,10 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
if err != nil {
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"))
if err != nil {
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.WebClient = r.Form["web_client_options"]
filters.DefaultSharesExpiration = defaultSharesExpiration
+ filters.MaxSharesExpiration = maxSharesExpiration
filters.PasswordExpiration = passwordExpiration
filters.PasswordStrength = passwordStrength
hooks := r.Form["hooks"]
diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go
index ff653582..1ee08b1a 100644
--- a/internal/httpd/webclient.go
+++ b/internal/httpd/webclient.go
@@ -1319,6 +1319,15 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
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)
if err == nil {
http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
@@ -1364,6 +1373,15 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
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)
if err == nil {
http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index eee8d87b..c559ebf2 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/internal/httpdtest/httpdtest.go
@@ -2431,7 +2431,7 @@ func compareUserFiltersEqualFields(expected sdk.BaseUserFilters, actual sdk.Base
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) {
return errors.New("allowed IP mismatch")
}
@@ -2468,6 +2468,9 @@ func compareBaseUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFil
if expected.DefaultSharesExpiration != actual.DefaultSharesExpiration {
return errors.New("default_shares_expiration mismatch")
}
+ if expected.MaxSharesExpiration != actual.MaxSharesExpiration {
+ return errors.New("max_shares_expiration mismatch")
+ }
if expected.PasswordExpiration != actual.PasswordExpiration {
return errors.New("password_expiration mismatch")
}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 0140a97c..7cccfd44 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -5540,6 +5540,9 @@ components:
default_shares_expiration:
type: integer
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:
type: integer
description: 'The password expires after the defined number of days. 0 means no expiration'
diff --git a/templates/webadmin/group.html b/templates/webadmin/group.html
index 08d7b812..5ac70d42 100644
--- a/templates/webadmin/group.html
+++ b/templates/webadmin/group.html
@@ -651,6 +651,17 @@ along with this program. If not, see