diff --git a/docs/groups.md b/docs/groups.md
index e45ea60c..6420f872 100644
--- a/docs/groups.md
+++ b/docs/groups.md
@@ -6,6 +6,7 @@ SFTPGo supports two types of groups:
- primary groups
- secondary groups
+- membership groups
A user can be a member of a primary group and many secondary and membership groups. Depending on the group type, the settings are inherited differently.
@@ -16,6 +17,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: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
+- 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 92edf5c0..31ff1889 100644
--- a/go.mod
+++ b/go.mod
@@ -45,14 +45,14 @@ require (
github.com/minio/sio v0.3.0
github.com/otiai10/copy v1.9.0
github.com/pires/go-proxyproto v0.6.2
- github.com/pkg/sftp v1.13.6-0.20230104082718-2489717da0f3
+ github.com/pkg/sftp v1.13.6-0.20230213180117-971c283182b6
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.14.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/cors v1.8.3
github.com/rs/xid v1.4.0
github.com/rs/zerolog v1.29.0
- github.com/sftpgo/sdk v0.1.3-0.20230213120720-de3129520736
+ github.com/sftpgo/sdk v0.1.3-0.20230213182959-2d89540f8810
github.com/shirou/gopsutil/v3 v3.23.1
github.com/spf13/afero v1.9.3
github.com/spf13/cobra v1.6.1
diff --git a/go.sum b/go.sum
index ddc7faa8..dc9bc0ab 100644
--- a/go.sum
+++ b/go.sum
@@ -1683,8 +1683,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
-github.com/pkg/sftp v1.13.6-0.20230104082718-2489717da0f3 h1:eKBJ919kpjpfHltsNthMO6ZQ/XQy76cHHbuz2bOmMSA=
-github.com/pkg/sftp v1.13.6-0.20230104082718-2489717da0f3/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
+github.com/pkg/sftp v1.13.6-0.20230213180117-971c283182b6 h1:5TvW1dv00Y13njmQ1AWkxSWtPkwE7ZEF6yDuv9q+Als=
+github.com/pkg/sftp v1.13.6-0.20230213180117-971c283182b6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -1800,12 +1800,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.3-0.20221217110036-383c1bb50fa0 h1:e1OQroqX8SWV06Z270CxG2/v//Wx1026iXKTDRn5J1E=
-github.com/sftpgo/sdk v0.1.3-0.20221217110036-383c1bb50fa0/go.mod h1:3GpW3Qy8IHH6kex0ny+Y6ayeYb9OJxz8Pxh3IZgAs2E=
-github.com/sftpgo/sdk v0.1.3-0.20230212154322-556375985d8c h1:SiWQZe99SZ/O4QSIsxzL91NgwFJNoo4IJ31cazUrYh4=
-github.com/sftpgo/sdk v0.1.3-0.20230212154322-556375985d8c/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
-github.com/sftpgo/sdk v0.1.3-0.20230213120720-de3129520736 h1:QFzoqYPIxuqDOe2NJfYI7J71bZrsfC0Aejc0ChblkcA=
-github.com/sftpgo/sdk v0.1.3-0.20230213120720-de3129520736/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
+github.com/sftpgo/sdk v0.1.3-0.20230213182959-2d89540f8810 h1:9K/1RGoZcWiv2ue1JvAnKwerOzJsCAUqCR2/BnibT8s=
+github.com/sftpgo/sdk v0.1.3-0.20230213182959-2d89540f8810/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
github.com/shirou/gopsutil/v3 v3.23.1 h1:a9KKO+kGLKEvcPIs4W62v0nu3sciVDOOOPUD0Hz7z/4=
github.com/shirou/gopsutil/v3 v3.23.1/go.mod h1:NN6mnm5/0k8jw4cBfCnJtr5L7ErOTg18tMNpgFkn0hA=
github.com/shoenig/test v0.4.3/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0=
diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go
index fda6cce0..3bcda315 100644
--- a/internal/common/eventmanager.go
+++ b/internal/common/eventmanager.go
@@ -1281,6 +1281,10 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
replacer := strings.NewReplacer(replacements...)
body := replaceWithReplacer(c.Body, replacer)
subject := replaceWithReplacer(c.Subject, replacer)
+ recipients := make([]string, 0, len(c.Recipients))
+ for _, recipient := range c.Recipients {
+ recipients = append(recipients, replaceWithReplacer(recipient, replacer))
+ }
startTime := time.Now()
var files []*mail.File
fileAttachments := make([]string, 0, len(c.Attachments))
@@ -1317,7 +1321,7 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
}
files = append(files, res...)
}
- err := smtp.SendEmail(c.Recipients, subject, body, smtp.EmailContentTypeTextPlain, files...)
+ err := smtp.SendEmail(recipients, subject, body, smtp.EmailContentTypeTextPlain, files...)
eventManagerLog(logger.LevelDebug, "executed email notification action, elapsed: %s, error: %v",
time.Since(startTime), err)
if err != nil {
diff --git a/internal/dataprovider/group.go b/internal/dataprovider/group.go
index a99cc6d5..9d7a4b3c 100644
--- a/internal/dataprovider/group.go
+++ b/internal/dataprovider/group.go
@@ -223,6 +223,7 @@ func (g *Group) getACopy() Group {
UploadDataTransfer: g.UserSettings.UploadDataTransfer,
DownloadDataTransfer: g.UserSettings.DownloadDataTransfer,
TotalDataTransfer: g.UserSettings.TotalDataTransfer,
+ ExpiresIn: g.UserSettings.ExpiresIn,
Filters: copyBaseUserFilters(g.UserSettings.Filters),
},
FsConfig: g.UserSettings.FsConfig.GetACopy(),
diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go
index 5566069a..c9e989e1 100644
--- a/internal/dataprovider/user.go
+++ b/internal/dataprovider/user.go
@@ -1737,6 +1737,9 @@ func (u *User) mergeWithPrimaryGroup(group Group, replacer *strings.Replacer) {
u.DownloadDataTransfer = group.UserSettings.DownloadDataTransfer
u.TotalDataTransfer = group.UserSettings.TotalDataTransfer
}
+ if u.ExpirationDate == 0 && group.UserSettings.ExpiresIn > 0 {
+ u.ExpirationDate = u.CreatedAt + int64(group.UserSettings.ExpiresIn)*86400000
+ }
u.mergePrimaryGroupFilters(group.UserSettings.Filters, replacer)
u.mergeAdditiveProperties(group, sdk.GroupTypePrimary, replacer)
}
diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go
index 293f07d1..789fc83f 100644
--- a/internal/httpd/auth_utils.go
+++ b/internal/httpd/auth_utils.go
@@ -409,7 +409,6 @@ func verifyCSRFToken(tokenString, ip string) error {
if tokenValidationMode != tokenValidationNoIPMatch {
if !util.Contains(token.Audience(), ip) {
- fmt.Printf("ip %v audience %+v\n\n", ip, token.Audience())
logger.Debug(logSender, "", "error validating CSRF token IP audience")
return errors.New("the form token is not valid")
}
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index 13ddce6a..386f4909 100644
--- a/internal/httpd/httpd_test.go
+++ b/internal/httpd/httpd_test.go
@@ -1184,6 +1184,8 @@ func TestGroupSettingsOverride(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, user1.VirtualFolders, 0)
assert.Len(t, user2.VirtualFolders, 3)
+ assert.Equal(t, int64(0), user1.ExpirationDate)
+ assert.Equal(t, int64(0), user2.ExpirationDate)
group2.UserSettings.FsConfig = vfs.Filesystem{
Provider: sdk.SFTPFilesystemProvider,
@@ -1230,6 +1232,7 @@ func TestGroupSettingsOverride(t *testing.T) {
group1.UserSettings.UploadBandwidth = 512
group1.UserSettings.DownloadBandwidth = 1024
group1.UserSettings.TotalDataTransfer = 2048
+ group1.UserSettings.ExpiresIn = 15
group1.UserSettings.Filters.MaxUploadFileSize = 1024 * 1024
group1.UserSettings.Filters.StartDirectory = "/startdir/%username%"
group1.UserSettings.Filters.WebClient = []string{sdk.WebClientInfoChangeDisabled}
@@ -1250,6 +1253,7 @@ func TestGroupSettingsOverride(t *testing.T) {
user, err = dataprovider.CheckUserAndPass(defaultUsername, defaultPassword, "", common.ProtocolHTTP)
assert.NoError(t, err)
assert.Len(t, user.VirtualFolders, 3)
+ assert.Equal(t, user.CreatedAt+int64(group1.UserSettings.ExpiresIn)*86400000, user.ExpirationDate)
assert.Equal(t, sdk.SFTPFilesystemProvider, user.FsConfig.Provider)
assert.Equal(t, altAdminUsername, user.FsConfig.SFTPConfig.Username)
assert.Equal(t, "/dirs/"+defaultUsername, user.FsConfig.SFTPConfig.Prefix)
@@ -21660,6 +21664,7 @@ func TestAddWebGroup(t *testing.T) {
QuotaFiles: 10,
UploadBandwidth: 128,
DownloadBandwidth: 256,
+ ExpiresIn: 10,
},
}
form := make(url.Values)
@@ -21727,6 +21732,16 @@ func TestAddWebGroup(t *testing.T) {
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), "invalid expires in")
+ form.Set("expires_in", strconv.Itoa(group.UserSettings.ExpiresIn))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webGroupPath, &b)
+ assert.NoError(t, err)
+ req.Header.Set("Content-Type", contentType)
+ setJWTCookieForReq(req, webToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid max upload file size")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
@@ -22163,6 +22178,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("expires_in", "0")
form.Set("password_expiration", "0")
form.Set("external_auth_cache_time", "0")
form.Set("fs_provider", strconv.FormatInt(int64(group.UserSettings.FsConfig.Provider), 10))
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index d9a1833c..988656f4 100644
--- a/internal/httpd/webadmin.go
+++ b/internal/httpd/webadmin.go
@@ -2074,6 +2074,10 @@ func getGroupFromPostFields(r *http.Request) (dataprovider.Group, error) {
if err != nil {
return group, err
}
+ expiresIn, err := strconv.ParseInt(r.Form.Get("expires_in"), 10, 64)
+ if err != nil {
+ return group, fmt.Errorf("invalid expires in: %w", err)
+ }
fsConfig, err := getFsConfigFromPostFields(r)
if err != nil {
return group, err
@@ -2099,6 +2103,7 @@ func getGroupFromPostFields(r *http.Request) (dataprovider.Group, error) {
UploadDataTransfer: dataTransferUL,
DownloadDataTransfer: dataTransferDL,
TotalDataTransfer: dataTransferTotal,
+ ExpiresIn: int(expiresIn),
Filters: filters,
},
FsConfig: fsConfig,
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index d79ad09b..56e9287b 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/internal/httpdtest/httpdtest.go
@@ -2212,7 +2212,6 @@ func compareGCSConfig(expected *vfs.Filesystem, actual *vfs.Filesystem) error {
return errors.New("GCS upload part size mismatch")
}
if expected.GCSConfig.UploadPartMaxTime != actual.GCSConfig.UploadPartMaxTime {
- fmt.Printf("aaaaaaaaaa %v, %v", expected.GCSConfig.UploadPartMaxTime, actual.GCSConfig.UploadPartMaxTime)
return errors.New("GCS upload part max time mismatch")
}
return nil
@@ -2782,6 +2781,9 @@ func compareEqualGroupSettingsFields(expected sdk.BaseGroupUserSettings, actual
if expected.TotalDataTransfer != actual.TotalDataTransfer {
return errors.New("total_data_transfer mismatch")
}
+ if expected.ExpiresIn != actual.ExpiresIn {
+ return errors.New("expires_in mismatch")
+ }
return compareUserPermissions(expected.Permissions, actual.Permissions)
}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 4efd598c..72a9759d 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -6443,6 +6443,9 @@ components:
total_data_transfer:
type: integer
description: 'Maximum total data transfer as MB'
+ expires_in:
+ type: integer
+ description: 'Account expiration in number of days from creation. 0 means no expiration'
filters:
$ref: '#/components/schemas/BaseUserFilters'
filesystem:
diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html
index d3d15034..6bc4ddc6 100644
--- a/templates/webadmin/eventaction.html
+++ b/templates/webadmin/eventaction.html
@@ -426,7 +426,7 @@ along with this program. If not, see