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 . +
+ +
+ + + Maximum allowed expiration, as number of days, when a user creates or updates a share + +
+
+
diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index f7f4e990..30873e53 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -393,6 +393,17 @@ along with this program. If not, see .
+
+ +
+ + + Maximum allowed expiration, as number of days, when a user creates or updates a share + +
+
+