Browse Source

fs actions: add first upload/download

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 years ago
parent
commit
3e8254e398

+ 3 - 1
docs/custom-actions.md

@@ -10,8 +10,10 @@ The `hook` can be defined as the absolute path of your program or an HTTP URL.
 The following `actions` are supported:
 The following `actions` are supported:
 
 
 - `download`
 - `download`
+- `first-download`
 - `pre-download`
 - `pre-download`
 - `upload`
 - `upload`
+- `first-upload`
 - `pre-upload`
 - `pre-upload`
 - `delete`
 - `delete`
 - `pre-delete`
 - `pre-delete`
@@ -20,7 +22,7 @@ The following `actions` are supported:
 - `rmdir`
 - `rmdir`
 - `ssh_cmd`
 - `ssh_cmd`
 
 
-The `upload` condition includes both uploads to new files and overwrite of existing ones. If an upload is aborted for quota limits SFTPGo tries to remove the partial file, so if the notification reports a zero size file and a quota exceeded error the file has been deleted. The `ssh_cmd` condition will be triggered after a command is successfully executed via SSH. `scp` will trigger the `download` and `upload` conditions and not `ssh_cmd`.
+The `upload` condition includes both uploads to new files and overwrite of existing ones. If an upload is aborted for quota limits SFTPGo tries to remove the partial file, so if the notification reports a zero size file and a quota exceeded error the file has been deleted. The `ssh_cmd` condition will be triggered after a command is successfully executed via SSH. `scp` will trigger the `download` and `upload` conditions and not `ssh_cmd`. The `first-download` and `first-upload` action are executed only if no error occour and they don't exclude the `download` and `upload` notifications, so you will get both the `first-upload` and `upload` notification after the first successful upload and the same for the first successful download.
 For cloud backends directories are virtual, they are created implicitly when you upload a file and are implicitly removed when the last file within a directory is removed. The `mkdir` and `rmdir` notifications are sent only when a directory is explicitly created or removed.
 For cloud backends directories are virtual, they are created implicitly when you upload a file and are implicitly removed when the last file within a directory is removed. The `mkdir` and `rmdir` notifications are sent only when a directory is explicitly created or removed.
 
 
 The notification will indicate if an error is detected and so, for example, a partial file is uploaded.
 The notification will indicate if an error is detected and so, for example, a partial file is uploaded.

+ 2 - 2
go.mod

@@ -20,7 +20,7 @@ require (
 	github.com/cockroachdb/cockroach-go/v2 v2.2.15
 	github.com/cockroachdb/cockroach-go/v2 v2.2.15
 	github.com/coreos/go-oidc/v3 v3.2.0
 	github.com/coreos/go-oidc/v3 v3.2.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
-	github.com/fclairamb/ftpserverlib v0.19.0
+	github.com/fclairamb/ftpserverlib v0.19.1
 	github.com/fclairamb/go-log v0.4.1
 	github.com/fclairamb/go-log v0.4.1
 	github.com/go-acme/lego/v4 v4.8.0
 	github.com/go-acme/lego/v4 v4.8.0
 	github.com/go-chi/chi/v5 v5.0.8-0.20220512131524-9e71a0d4b3d6
 	github.com/go-chi/chi/v5 v5.0.8-0.20220512131524-9e71a0d4b3d6
@@ -51,7 +51,7 @@ require (
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.27.0
 	github.com/rs/zerolog v1.27.0
-	github.com/sftpgo/sdk v0.1.2-0.20220815185512-a4dc48b3993e
+	github.com/sftpgo/sdk v0.1.2-0.20220821164353-a9b95497604e
 	github.com/shirou/gopsutil/v3 v3.22.7
 	github.com/shirou/gopsutil/v3 v3.22.7
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/cobra v1.5.0

+ 4 - 4
go.sum

@@ -284,8 +284,8 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fclairamb/ftpserverlib v0.19.0 h1:5QcSQ0OIJBlezIqmGehiL/AVsRb6dIkMxbkuhyPkESM=
-github.com/fclairamb/ftpserverlib v0.19.0/go.mod h1:pmukdVOFKKUY9zjWRoxFW8JAljyulC/uK5FfusJzK2E=
+github.com/fclairamb/ftpserverlib v0.19.1 h1:OIqW+AdcsUEq4apudrluDD1c4iCRidLAoQzJRBUJnbg=
+github.com/fclairamb/ftpserverlib v0.19.1/go.mod h1:cVeFR3wvEjgtK99686UXJaTvqZk8jbjHFnhaC23LGpc=
 github.com/fclairamb/go-log v0.4.1 h1:rLtdSG9x2pK41AIAnE8WYpl05xBJfw1ZyYxZaXFcBsM=
 github.com/fclairamb/go-log v0.4.1 h1:rLtdSG9x2pK41AIAnE8WYpl05xBJfw1ZyYxZaXFcBsM=
 github.com/fclairamb/go-log v0.4.1/go.mod h1:sw1KvnkZ4wKCYkvy4SL3qVZcJSWFP8Ure4pM3z+KNn4=
 github.com/fclairamb/go-log v0.4.1/go.mod h1:sw1KvnkZ4wKCYkvy4SL3qVZcJSWFP8Ure4pM3z+KNn4=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@@ -714,8 +714,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 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/sftpgo/sdk v0.1.2-0.20220815185512-a4dc48b3993e h1:EJiTi+f2QCiDoGj1EBq6o1RX+JrtZnvTE6yKt3ks1B8=
-github.com/sftpgo/sdk v0.1.2-0.20220815185512-a4dc48b3993e/go.mod h1:RL4HeorXC6XgqtkLYnQUSogLdsdMfbsogIvdBVLuy4w=
+github.com/sftpgo/sdk v0.1.2-0.20220821164353-a9b95497604e h1:Up8iLVu+PPd5ejyG8fi8910IC4JO+A1/COJf+sJWHI8=
+github.com/sftpgo/sdk v0.1.2-0.20220821164353-a9b95497604e/go.mod h1:fxFs5FP9bhi3ObH+7qdxZF+2QOk8J/u4GAR5yuX5jMg=
 github.com/shirou/gopsutil/v3 v3.22.7 h1:flKnuCMfUUrO+oAvwAd6GKZgnPzr098VA/UJ14nhJd4=
 github.com/shirou/gopsutil/v3 v3.22.7 h1:flKnuCMfUUrO+oAvwAd6GKZgnPzr098VA/UJ14nhJd4=
 github.com/shirou/gopsutil/v3 v3.22.7/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
 github.com/shirou/gopsutil/v3 v3.22.7/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=

+ 17 - 15
internal/common/common.go

@@ -45,21 +45,23 @@ import (
 
 
 // constants
 // constants
 const (
 const (
-	logSender         = "common"
-	uploadLogSender   = "Upload"
-	downloadLogSender = "Download"
-	renameLogSender   = "Rename"
-	rmdirLogSender    = "Rmdir"
-	mkdirLogSender    = "Mkdir"
-	symlinkLogSender  = "Symlink"
-	removeLogSender   = "Remove"
-	chownLogSender    = "Chown"
-	chmodLogSender    = "Chmod"
-	chtimesLogSender  = "Chtimes"
-	truncateLogSender = "Truncate"
-	operationDownload = "download"
-	operationUpload   = "upload"
-	operationDelete   = "delete"
+	logSender              = "common"
+	uploadLogSender        = "Upload"
+	downloadLogSender      = "Download"
+	renameLogSender        = "Rename"
+	rmdirLogSender         = "Rmdir"
+	mkdirLogSender         = "Mkdir"
+	symlinkLogSender       = "Symlink"
+	removeLogSender        = "Remove"
+	chownLogSender         = "Chown"
+	chmodLogSender         = "Chmod"
+	chtimesLogSender       = "Chtimes"
+	truncateLogSender      = "Truncate"
+	operationDownload      = "download"
+	operationUpload        = "upload"
+	operationFirstDownload = "first-download"
+	operationFirstUpload   = "first-upload"
+	operationDelete        = "delete"
 	// Pre-download action name
 	// Pre-download action name
 	OperationPreDownload = "pre-download"
 	OperationPreDownload = "pre-download"
 	// Pre-upload action name
 	// Pre-upload action name

+ 39 - 0
internal/common/common_test.go

@@ -1190,6 +1190,45 @@ func TestVfsSameResource(t *testing.T) {
 	assert.False(t, res)
 	assert.False(t, res)
 }
 }
 
 
+func TestUpdateTransferTimestamps(t *testing.T) {
+	username := "user_test_timestamps"
+	user := &dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username: username,
+			HomeDir:  filepath.Join(os.TempDir(), username),
+			Status:   1,
+			Permissions: map[string][]string{
+				"/": {dataprovider.PermAny},
+			},
+		},
+	}
+	err := dataprovider.AddUser(user, "", "")
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), user.FirstUpload)
+	assert.Equal(t, int64(0), user.FirstDownload)
+
+	err = dataprovider.UpdateUserTransferTimestamps(username, true)
+	assert.NoError(t, err)
+	userGet, err := dataprovider.UserExists(username)
+	assert.NoError(t, err)
+	assert.Greater(t, userGet.FirstUpload, int64(0))
+	assert.Equal(t, int64(0), user.FirstDownload)
+	err = dataprovider.UpdateUserTransferTimestamps(username, false)
+	assert.NoError(t, err)
+	userGet, err = dataprovider.UserExists(username)
+	assert.NoError(t, err)
+	assert.Greater(t, userGet.FirstUpload, int64(0))
+	assert.Greater(t, userGet.FirstDownload, int64(0))
+	// updating again must fail
+	err = dataprovider.UpdateUserTransferTimestamps(username, true)
+	assert.Error(t, err)
+	err = dataprovider.UpdateUserTransferTimestamps(username, false)
+	assert.Error(t, err)
+	// cleanup
+	err = dataprovider.DeleteUser(username, "", "")
+	assert.NoError(t, err)
+}
+
 func BenchmarkBcryptHashing(b *testing.B) {
 func BenchmarkBcryptHashing(b *testing.B) {
 	bcryptPassword := "bcryptpassword"
 	bcryptPassword := "bcryptpassword"
 	for i := 0; i < b.N; i++ {
 	for i := 0; i < b.N; i++ {

+ 2 - 0
internal/common/connection.go

@@ -39,6 +39,8 @@ type BaseConnection struct {
 	// last activity for this connection.
 	// last activity for this connection.
 	// Since this field is accessed atomically we put it as first element of the struct to achieve 64 bit alignment
 	// Since this field is accessed atomically we put it as first element of the struct to achieve 64 bit alignment
 	lastActivity int64
 	lastActivity int64
+	uploadDone   atomic.Bool
+	downloadDone atomic.Bool
 	// unique ID for a transfer.
 	// unique ID for a transfer.
 	// This field is accessed atomically so we put it at the beginning of the struct to achieve 64 bit alignment
 	// This field is accessed atomically so we put it at the beginning of the struct to achieve 64 bit alignment
 	transferID int64
 	transferID int64

+ 129 - 0
internal/common/protocol_test.go

@@ -3764,6 +3764,135 @@ func TestEventRuleFsActions(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          2525,
+		From:          "notify@example.com",
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize(configDir)
+	require.NoError(t, err)
+	a1 := dataprovider.BaseEventAction{
+		Name: "action1",
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"test@example.com"},
+				Subject:    `"{{Event}}" from "{{Name}}"`,
+				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
+			},
+		},
+	}
+	action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err)
+	r1 := dataprovider.EventRule{
+		Name:    "test first upload rule",
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"first-upload"},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+			},
+		},
+	}
+	rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+	assert.NoError(t, err)
+	r2 := dataprovider.EventRule{
+		Name:    "test first download rule",
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"first-download"},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+			},
+		},
+	}
+	rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated)
+	assert.NoError(t, err)
+
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		testFileSize := int64(32768)
+		lastReceivedEmail.reset()
+		err = writeSFTPFileNoCheck(testFileName, testFileSize, client)
+		assert.NoError(t, err)
+		assert.Eventually(t, func() bool {
+			return lastReceivedEmail.get().From != ""
+		}, 1500*time.Millisecond, 100*time.Millisecond)
+		email := lastReceivedEmail.get()
+		assert.Len(t, email.To, 1)
+		assert.True(t, util.Contains(email.To, "test@example.com"))
+		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: "first-upload" from "%s"`, user.Username))
+		lastReceivedEmail.reset()
+		// a new upload will not produce a new notification
+		err = writeSFTPFileNoCheck(testFileName+"_1", 32768, client)
+		assert.NoError(t, err)
+		assert.Never(t, func() bool {
+			return lastReceivedEmail.get().From != ""
+		}, 1000*time.Millisecond, 100*time.Millisecond)
+		// the same for download
+		f, err := client.Open(testFileName)
+		assert.NoError(t, err)
+		contents := make([]byte, testFileSize)
+		n, err := io.ReadFull(f, contents)
+		assert.NoError(t, err)
+		assert.Equal(t, int(testFileSize), n)
+		err = f.Close()
+		assert.NoError(t, err)
+		assert.Eventually(t, func() bool {
+			return lastReceivedEmail.get().From != ""
+		}, 1500*time.Millisecond, 100*time.Millisecond)
+		email = lastReceivedEmail.get()
+		assert.Len(t, email.To, 1)
+		assert.True(t, util.Contains(email.To, "test@example.com"))
+		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: "first-download" from "%s"`, user.Username))
+		// download again
+		lastReceivedEmail.reset()
+		f, err = client.Open(testFileName)
+		assert.NoError(t, err)
+		contents = make([]byte, testFileSize)
+		n, err = io.ReadFull(f, contents)
+		assert.NoError(t, err)
+		assert.Equal(t, int(testFileSize), n)
+		err = f.Close()
+		assert.NoError(t, err)
+		assert.Never(t, func() bool {
+			return lastReceivedEmail.get().From != ""
+		}, 1000*time.Millisecond, 100*time.Millisecond)
+	}
+
+	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventRule(rule2, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize(configDir)
+	require.NoError(t, err)
+}
+
 func TestEventRuleCertificate(t *testing.T) {
 func TestEventRuleCertificate(t *testing.T) {
 	smtpCfg := smtp.Config{
 	smtpCfg := smtp.Config{
 		Host:          "127.0.0.1",
 		Host:          "127.0.0.1",

+ 30 - 5
internal/common/transfer.go

@@ -386,19 +386,20 @@ func (t *BaseTransfer) Close() error {
 		}
 		}
 	}
 	}
 	elapsed := time.Since(t.start).Nanoseconds() / 1000000
 	elapsed := time.Since(t.start).Nanoseconds() / 1000000
+	var uploadFileSize int64
 	if t.transferType == TransferDownload {
 	if t.transferType == TransferDownload {
 		logger.TransferLog(downloadLogSender, t.fsPath, elapsed, atomic.LoadInt64(&t.BytesSent), t.Connection.User.Username,
 		logger.TransferLog(downloadLogSender, t.fsPath, elapsed, atomic.LoadInt64(&t.BytesSent), t.Connection.User.Username,
 			t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode)
 			t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode)
 		ExecuteActionNotification(t.Connection, operationDownload, t.fsPath, t.requestPath, "", "", "", //nolint:errcheck
 		ExecuteActionNotification(t.Connection, operationDownload, t.fsPath, t.requestPath, "", "", "", //nolint:errcheck
 			atomic.LoadInt64(&t.BytesSent), t.ErrTransfer)
 			atomic.LoadInt64(&t.BytesSent), t.ErrTransfer)
 	} else {
 	} else {
-		fileSize := atomic.LoadInt64(&t.BytesReceived) + t.MinWriteOffset
+		uploadFileSize = atomic.LoadInt64(&t.BytesReceived) + t.MinWriteOffset
 		if statSize, errStat := t.getUploadFileSize(); errStat == nil {
 		if statSize, errStat := t.getUploadFileSize(); errStat == nil {
-			fileSize = statSize
+			uploadFileSize = statSize
 		}
 		}
-		t.Connection.Log(logger.LevelDebug, "uploaded file size %v", fileSize)
-		numFiles, fileSize = t.executeUploadHook(numFiles, fileSize)
-		t.updateQuota(numFiles, fileSize)
+		t.Connection.Log(logger.LevelDebug, "upload file size %v", uploadFileSize)
+		numFiles, uploadFileSize = t.executeUploadHook(numFiles, uploadFileSize)
+		t.updateQuota(numFiles, uploadFileSize)
 		t.updateTimes()
 		t.updateTimes()
 		logger.TransferLog(uploadLogSender, t.fsPath, elapsed, atomic.LoadInt64(&t.BytesReceived), t.Connection.User.Username,
 		logger.TransferLog(uploadLogSender, t.fsPath, elapsed, atomic.LoadInt64(&t.BytesReceived), t.Connection.User.Username,
 			t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode)
 			t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode)
@@ -409,9 +410,33 @@ func (t *BaseTransfer) Close() error {
 			err = t.ErrTransfer
 			err = t.ErrTransfer
 		}
 		}
 	}
 	}
+	t.updateTransferTimestamps(uploadFileSize)
 	return err
 	return err
 }
 }
 
 
+func (t *BaseTransfer) updateTransferTimestamps(uploadFileSize int64) {
+	if t.ErrTransfer != nil {
+		return
+	}
+	if t.transferType == TransferUpload {
+		if t.Connection.User.FirstUpload == 0 && !t.Connection.uploadDone.Load() {
+			if err := dataprovider.UpdateUserTransferTimestamps(t.Connection.User.Username, true); err == nil {
+				t.Connection.uploadDone.Store(true)
+				ExecuteActionNotification(t.Connection, operationFirstUpload, t.fsPath, t.requestPath, "", //nolint:errcheck
+					"", "", uploadFileSize, t.ErrTransfer)
+			}
+		}
+		return
+	}
+	if t.Connection.User.FirstDownload == 0 && !t.Connection.downloadDone.Load() && atomic.LoadInt64(&t.BytesSent) > 0 {
+		if err := dataprovider.UpdateUserTransferTimestamps(t.Connection.User.Username, false); err == nil {
+			t.Connection.downloadDone.Store(true)
+			ExecuteActionNotification(t.Connection, operationFirstDownload, t.fsPath, t.requestPath, "", //nolint:errcheck
+				"", "", atomic.LoadInt64(&t.BytesSent), t.ErrTransfer)
+		}
+	}
+}
+
 func (t *BaseTransfer) executeUploadHook(numFiles int, fileSize int64) (int, int64) {
 func (t *BaseTransfer) executeUploadHook(numFiles int, fileSize int64) (int, int64) {
 	err := ExecuteActionNotification(t.Connection, operationUpload, t.fsPath, t.requestPath, "", "", "",
 	err := ExecuteActionNotification(t.Connection, operationUpload, t.fsPath, t.requestPath, "", "", "",
 		fileSize, t.ErrTransfer)
 		fileSize, t.ErrTransfer)

+ 69 - 8
internal/dataprovider/bolt.go

@@ -35,7 +35,7 @@ import (
 )
 )
 
 
 const (
 const (
-	boltDatabaseVersion = 20
+	boltDatabaseVersion = 21
 )
 )
 
 
 var (
 var (
@@ -570,6 +570,8 @@ func (p *BoltProvider) addUser(user *User) error {
 		user.UsedUploadDataTransfer = 0
 		user.UsedUploadDataTransfer = 0
 		user.UsedDownloadDataTransfer = 0
 		user.UsedDownloadDataTransfer = 0
 		user.LastLogin = 0
 		user.LastLogin = 0
+		user.FirstDownload = 0
+		user.FirstUpload = 0
 		user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		for idx := range user.VirtualFolders {
 		for idx := range user.VirtualFolders {
@@ -621,6 +623,8 @@ func (p *BoltProvider) updateUser(user *User) error {
 		user.UsedUploadDataTransfer = oldUser.UsedUploadDataTransfer
 		user.UsedUploadDataTransfer = oldUser.UsedUploadDataTransfer
 		user.UsedDownloadDataTransfer = oldUser.UsedDownloadDataTransfer
 		user.UsedDownloadDataTransfer = oldUser.UsedDownloadDataTransfer
 		user.LastLogin = oldUser.LastLogin
 		user.LastLogin = oldUser.LastLogin
+		user.FirstDownload = oldUser.FirstDownload
+		user.FirstUpload = oldUser.FirstUpload
 		user.CreatedAt = oldUser.CreatedAt
 		user.CreatedAt = oldUser.CreatedAt
 		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		buf, err := json.Marshal(user)
 		buf, err := json.Marshal(user)
@@ -2433,6 +2437,63 @@ func (p *BoltProvider) updateTaskTimestamp(name string) error {
 	return ErrNotImplemented
 	return ErrNotImplemented
 }
 }
 
 
+func (p *BoltProvider) setFirstDownloadTimestamp(username string) error {
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := p.getUsersBucket(tx)
+		if err != nil {
+			return err
+		}
+		var u []byte
+		if u = bucket.Get([]byte(username)); u == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist, unable to set download timestamp",
+				username))
+		}
+		var user User
+		err = json.Unmarshal(u, &user)
+		if err != nil {
+			return err
+		}
+		if user.FirstDownload > 0 {
+			return util.NewGenericError(fmt.Sprintf("first download already set to %v",
+				util.GetTimeFromMsecSinceEpoch(user.FirstDownload)))
+		}
+		user.FirstDownload = util.GetTimeAsMsSinceEpoch(time.Now())
+		buf, err := json.Marshal(user)
+		if err != nil {
+			return err
+		}
+		return bucket.Put([]byte(username), buf)
+	})
+}
+
+func (p *BoltProvider) setFirstUploadTimestamp(username string) error {
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := p.getUsersBucket(tx)
+		if err != nil {
+			return err
+		}
+		var u []byte
+		if u = bucket.Get([]byte(username)); u == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist, unable to set upload timestamp",
+				username))
+		}
+		var user User
+		if err = json.Unmarshal(u, &user); err != nil {
+			return err
+		}
+		if user.FirstUpload > 0 {
+			return util.NewGenericError(fmt.Sprintf("first upload already set to %v",
+				util.GetTimeFromMsecSinceEpoch(user.FirstUpload)))
+		}
+		user.FirstUpload = util.GetTimeAsMsSinceEpoch(time.Now())
+		buf, err := json.Marshal(user)
+		if err != nil {
+			return err
+		}
+		return bucket.Put([]byte(username), buf)
+	})
+}
+
 func (p *BoltProvider) close() error {
 func (p *BoltProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -2460,10 +2521,10 @@ func (p *BoltProvider) migrateDatabase() error {
 		providerLog(logger.LevelError, "%v", err)
 		providerLog(logger.LevelError, "%v", err)
 		logger.ErrorToConsole("%v", err)
 		logger.ErrorToConsole("%v", err)
 		return err
 		return err
-	case version == 19:
-		logger.InfoToConsole(fmt.Sprintf("updating database version: %d -> 20", version))
-		providerLog(logger.LevelInfo, "updating database version: %d -> 20", version)
-		return updateBoltDatabaseVersion(p.dbHandle, 20)
+	case version == 19, version == 20:
+		logger.InfoToConsole(fmt.Sprintf("updating database version: %d -> 21", version))
+		providerLog(logger.LevelInfo, "updating database version: %d -> 21", version)
+		return updateBoltDatabaseVersion(p.dbHandle, 21)
 	default:
 	default:
 		if version > boltDatabaseVersion {
 		if version > boltDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -2485,9 +2546,9 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error {
 		return errors.New("current version match target version, nothing to do")
 		return errors.New("current version match target version, nothing to do")
 	}
 	}
 	switch dbVersion.Version {
 	switch dbVersion.Version {
-	case 20:
-		logger.InfoToConsole("downgrading database version: 20 -> 19")
-		providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
+	case 20, 21:
+		logger.InfoToConsole("downgrading database version: %d -> 19", dbVersion.Version)
+		providerLog(logger.LevelInfo, "downgrading database version: %d -> 19", dbVersion.Version)
 		err := p.dbHandle.Update(func(tx *bolt.Tx) error {
 		err := p.dbHandle.Update(func(tx *bolt.Tx) error {
 			for _, bucketName := range [][]byte{actionsBucket, rulesBucket} {
 			for _, bucketName := range [][]byte{actionsBucket, rulesBucket} {
 				err := tx.DeleteBucket(bucketName)
 				err := tx.DeleteBucket(bucketName)

+ 26 - 0
internal/dataprovider/dataprovider.go

@@ -768,6 +768,8 @@ type Provider interface {
 	addTask(name string) error
 	addTask(name string) error
 	updateTask(name string, version int64) error
 	updateTask(name string, version int64) error
 	updateTaskTimestamp(name string) error
 	updateTaskTimestamp(name string) error
+	setFirstDownloadTimestamp(username string) error
+	setFirstUploadTimestamp(username string) error
 	checkAvailability() error
 	checkAvailability() error
 	close() error
 	close() error
 	reloadConfig() error
 	reloadConfig() error
@@ -1399,6 +1401,22 @@ func UpdateUserTransferQuota(user *User, uploadSize, downloadSize int64, reset b
 	return nil
 	return nil
 }
 }
 
 
+// UpdateUserTransferTimestamps updates the first download/upload fields if unset
+func UpdateUserTransferTimestamps(username string, isUpload bool) error {
+	if isUpload {
+		err := provider.setFirstUploadTimestamp(username)
+		if err != nil {
+			providerLog(logger.LevelWarn, "unable to set first upload: %v", err)
+		}
+		return err
+	}
+	err := provider.setFirstDownloadTimestamp(username)
+	if err != nil {
+		providerLog(logger.LevelWarn, "unable to set first download: %v", err)
+	}
+	return err
+}
+
 // GetUsedQuota returns the used quota for the given SFTPGo user.
 // GetUsedQuota returns the used quota for the given SFTPGo user.
 func GetUsedQuota(username string) (int, int64, int64, int64, error) {
 func GetUsedQuota(username string) (int, int64, int64, int64, error) {
 	if config.TrackQuota == 0 {
 	if config.TrackQuota == 0 {
@@ -3538,6 +3556,8 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
 	userUsedUploadTransfer := u.UsedUploadDataTransfer
 	userUsedUploadTransfer := u.UsedUploadDataTransfer
 	userLastQuotaUpdate := u.LastQuotaUpdate
 	userLastQuotaUpdate := u.LastQuotaUpdate
 	userLastLogin := u.LastLogin
 	userLastLogin := u.LastLogin
+	userFirstDownload := u.FirstDownload
+	userFirstUpload := u.FirstUpload
 	userCreatedAt := u.CreatedAt
 	userCreatedAt := u.CreatedAt
 	totpConfig := u.Filters.TOTPConfig
 	totpConfig := u.Filters.TOTPConfig
 	recoveryCodes := u.Filters.RecoveryCodes
 	recoveryCodes := u.Filters.RecoveryCodes
@@ -3552,6 +3572,8 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
 	u.UsedDownloadDataTransfer = userUsedDownloadTransfer
 	u.UsedDownloadDataTransfer = userUsedDownloadTransfer
 	u.LastQuotaUpdate = userLastQuotaUpdate
 	u.LastQuotaUpdate = userLastQuotaUpdate
 	u.LastLogin = userLastLogin
 	u.LastLogin = userLastLogin
+	u.FirstDownload = userFirstDownload
+	u.FirstUpload = userFirstUpload
 	u.CreatedAt = userCreatedAt
 	u.CreatedAt = userCreatedAt
 	if userID == 0 {
 	if userID == 0 {
 		err = provider.addUser(&u)
 		err = provider.addUser(&u)
@@ -3787,6 +3809,8 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 		user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
 		user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastLogin = u.LastLogin
 		user.LastLogin = u.LastLogin
+		user.FirstDownload = u.FirstDownload
+		user.FirstUpload = u.FirstUpload
 		user.CreatedAt = u.CreatedAt
 		user.CreatedAt = u.CreatedAt
 		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		// preserve TOTP config and recovery codes
 		// preserve TOTP config and recovery codes
@@ -3859,6 +3883,8 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
 		user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
 		user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastQuotaUpdate = u.LastQuotaUpdate
 		user.LastLogin = u.LastLogin
 		user.LastLogin = u.LastLogin
+		user.FirstDownload = u.FirstDownload
+		user.FirstUpload = u.FirstUpload
 		// preserve TOTP config and recovery codes
 		// preserve TOTP config and recovery codes
 		user.Filters.TOTPConfig = u.Filters.TOTPConfig
 		user.Filters.TOTPConfig = u.Filters.TOTPConfig
 		user.Filters.RecoveryCodes = u.Filters.RecoveryCodes
 		user.Filters.RecoveryCodes = u.Filters.RecoveryCodes

+ 2 - 1
internal/dataprovider/eventrule.go

@@ -145,7 +145,8 @@ func getFsActionTypeAsString(value int) string {
 // TODO: replace the copied strings with shared constants
 // TODO: replace the copied strings with shared constants
 var (
 var (
 	// SupportedFsEvents defines the supported filesystem events
 	// SupportedFsEvents defines the supported filesystem events
-	SupportedFsEvents = []string{"upload", "download", "delete", "rename", "mkdir", "rmdir", "ssh_cmd"}
+	SupportedFsEvents = []string{"upload", "first-upload", "download", "first-download", "delete", "rename",
+		"mkdir", "rmdir", "ssh_cmd"}
 	// SupportedProviderEvents defines the supported provider events
 	// SupportedProviderEvents defines the supported provider events
 	SupportedProviderEvents = []string{operationAdd, operationUpdate, operationDelete}
 	SupportedProviderEvents = []string{operationAdd, operationUpdate, operationDelete}
 	// SupportedRuleConditionProtocols defines the supported protcols for rule conditions
 	// SupportedRuleConditionProtocols defines the supported protcols for rule conditions

+ 42 - 0
internal/dataprovider/memory.go

@@ -326,6 +326,8 @@ func (p *MemoryProvider) addUser(user *User) error {
 	user.UsedUploadDataTransfer = 0
 	user.UsedUploadDataTransfer = 0
 	user.UsedDownloadDataTransfer = 0
 	user.UsedDownloadDataTransfer = 0
 	user.LastLogin = 0
 	user.LastLogin = 0
+	user.FirstUpload = 0
+	user.FirstDownload = 0
 	user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.VirtualFolders = p.joinUserVirtualFoldersFields(user)
 	user.VirtualFolders = p.joinUserVirtualFoldersFields(user)
@@ -378,6 +380,8 @@ func (p *MemoryProvider) updateUser(user *User) error {
 	user.UsedUploadDataTransfer = u.UsedUploadDataTransfer
 	user.UsedUploadDataTransfer = u.UsedUploadDataTransfer
 	user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
 	user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
 	user.LastLogin = u.LastLogin
 	user.LastLogin = u.LastLogin
+	user.FirstDownload = u.FirstDownload
+	user.FirstUpload = u.FirstUpload
 	user.CreatedAt = u.CreatedAt
 	user.CreatedAt = u.CreatedAt
 	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	user.ID = u.ID
 	user.ID = u.ID
@@ -2203,6 +2207,44 @@ func (p *MemoryProvider) updateTaskTimestamp(name string) error {
 	return ErrNotImplemented
 	return ErrNotImplemented
 }
 }
 
 
+func (p *MemoryProvider) setFirstDownloadTimestamp(username string) error {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	user, err := p.userExistsInternal(username)
+	if err != nil {
+		return err
+	}
+	if user.FirstDownload > 0 {
+		return util.NewGenericError(fmt.Sprintf("first download already set to %v",
+			util.GetTimeFromMsecSinceEpoch(user.FirstDownload)))
+	}
+	user.FirstDownload = util.GetTimeAsMsSinceEpoch(time.Now())
+	p.dbHandle.users[user.Username] = user
+	return nil
+}
+
+func (p *MemoryProvider) setFirstUploadTimestamp(username string) error {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	user, err := p.userExistsInternal(username)
+	if err != nil {
+		return err
+	}
+	if user.FirstUpload > 0 {
+		return util.NewGenericError(fmt.Sprintf("first upload already set to %v",
+			util.GetTimeFromMsecSinceEpoch(user.FirstUpload)))
+	}
+	user.FirstUpload = util.GetTimeAsMsSinceEpoch(time.Now())
+	p.dbHandle.users[user.Username] = user
+	return nil
+}
+
 func (p *MemoryProvider) getNextID() int64 {
 func (p *MemoryProvider) getNextID() int64 {
 	nextID := int64(1)
 	nextID := int64(1)
 	for _, v := range p.dbHandle.users {
 	for _, v := range p.dbHandle.users {

+ 47 - 1
internal/dataprovider/mysql.go

@@ -164,6 +164,12 @@ const (
 		"DROP TABLE `{{events_actions}}` CASCADE;" +
 		"DROP TABLE `{{events_actions}}` CASCADE;" +
 		"DROP TABLE `{{tasks}}` CASCADE;" +
 		"DROP TABLE `{{tasks}}` CASCADE;" +
 		"ALTER TABLE `{{users}}` DROP COLUMN `deleted_at`;"
 		"ALTER TABLE `{{users}}` DROP COLUMN `deleted_at`;"
+	mysqlV21SQL = "ALTER TABLE `{{users}}` ADD COLUMN `first_download` bigint DEFAULT 0 NOT NULL; " +
+		"ALTER TABLE `{{users}}` ALTER COLUMN `first_download` DROP DEFAULT; " +
+		"ALTER TABLE `{{users}}` ADD COLUMN `first_upload` bigint DEFAULT 0 NOT NULL; " +
+		"ALTER TABLE `{{users}}` ALTER COLUMN `first_upload` DROP DEFAULT;"
+	mysqlV21DownSQL = "ALTER TABLE `{{users}}` DROP COLUMN `first_upload`; " +
+		"ALTER TABLE `{{users}}` DROP COLUMN `first_download`;"
 )
 )
 
 
 // MySQLProvider defines the auth provider for MySQL/MariaDB database
 // MySQLProvider defines the auth provider for MySQL/MariaDB database
@@ -616,6 +622,14 @@ func (p *MySQLProvider) updateTaskTimestamp(name string) error {
 	return sqlCommonUpdateTaskTimestamp(name, p.dbHandle)
 	return sqlCommonUpdateTaskTimestamp(name, p.dbHandle)
 }
 }
 
 
+func (p *MySQLProvider) setFirstDownloadTimestamp(username string) error {
+	return sqlCommonSetFirstDownloadTimestamp(username, p.dbHandle)
+}
+
+func (p *MySQLProvider) setFirstUploadTimestamp(username string) error {
+	return sqlCommonSetFirstUploadTimestamp(username, p.dbHandle)
+}
+
 func (p *MySQLProvider) close() error {
 func (p *MySQLProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -657,6 +671,8 @@ func (p *MySQLProvider) migrateDatabase() error { //nolint:dupl
 		return err
 		return err
 	case version == 19:
 	case version == 19:
 		return updateMySQLDatabaseFromV19(p.dbHandle)
 		return updateMySQLDatabaseFromV19(p.dbHandle)
+	case version == 20:
+		return updateMySQLDatabaseFromV20(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -681,6 +697,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 	switch dbVersion.Version {
 	switch dbVersion.Version {
 	case 20:
 	case 20:
 		return downgradeMySQLDatabaseFromV20(p.dbHandle)
 		return downgradeMySQLDatabaseFromV20(p.dbHandle)
+	case 21:
+		return downgradeMySQLDatabaseFromV21(p.dbHandle)
 	default:
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 	}
@@ -692,13 +710,27 @@ func (p *MySQLProvider) resetDatabase() error {
 }
 }
 
 
 func updateMySQLDatabaseFromV19(dbHandle *sql.DB) error {
 func updateMySQLDatabaseFromV19(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom19To20(dbHandle)
+	if err := updateMySQLDatabaseFrom19To20(dbHandle); err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV20(dbHandle)
+}
+
+func updateMySQLDatabaseFromV20(dbHandle *sql.DB) error {
+	return updateMySQLDatabaseFrom20To21(dbHandle)
 }
 }
 
 
 func downgradeMySQLDatabaseFromV20(dbHandle *sql.DB) error {
 func downgradeMySQLDatabaseFromV20(dbHandle *sql.DB) error {
 	return downgradeMySQLDatabaseFrom20To19(dbHandle)
 	return downgradeMySQLDatabaseFrom20To19(dbHandle)
 }
 }
 
 
+func downgradeMySQLDatabaseFromV21(dbHandle *sql.DB) error {
+	if err := downgradeMySQLDatabaseFrom21To20(dbHandle); err != nil {
+		return err
+	}
+	return downgradeMySQLDatabaseFromV20(dbHandle)
+}
+
 func updateMySQLDatabaseFrom19To20(dbHandle *sql.DB) error {
 func updateMySQLDatabaseFrom19To20(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 19 -> 20")
 	logger.InfoToConsole("updating database version: 19 -> 20")
 	providerLog(logger.LevelInfo, "updating database version: 19 -> 20")
 	providerLog(logger.LevelInfo, "updating database version: 19 -> 20")
@@ -711,6 +743,13 @@ func updateMySQLDatabaseFrom19To20(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 20, true)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 20, true)
 }
 }
 
 
+func updateMySQLDatabaseFrom20To21(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 20 -> 21")
+	providerLog(logger.LevelInfo, "updating database version: 20 -> 21")
+	sql := strings.ReplaceAll(mysqlV21SQL, "{{users}}", sqlTableUsers)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 21, true)
+}
+
 func downgradeMySQLDatabaseFrom20To19(dbHandle *sql.DB) error {
 func downgradeMySQLDatabaseFrom20To19(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database version: 20 -> 19")
 	logger.InfoToConsole("downgrading database version: 20 -> 19")
 	providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
 	providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
@@ -721,3 +760,10 @@ func downgradeMySQLDatabaseFrom20To19(dbHandle *sql.DB) error {
 	sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
 	sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 19, false)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 19, false)
 }
 }
+
+func downgradeMySQLDatabaseFrom21To20(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 21 -> 20")
+	providerLog(logger.LevelInfo, "downgrading database version: 21 -> 20")
+	sql := strings.ReplaceAll(mysqlV21DownSQL, "{{users}}", sqlTableUsers)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 20, false)
+}

+ 49 - 1
internal/dataprovider/pgsql.go

@@ -175,6 +175,14 @@ DROP TABLE "{{events_rules}}" CASCADE;
 DROP TABLE "{{events_actions}}" CASCADE;
 DROP TABLE "{{events_actions}}" CASCADE;
 DROP TABLE "{{tasks}}" CASCADE;
 DROP TABLE "{{tasks}}" CASCADE;
 ALTER TABLE "{{users}}" DROP COLUMN "deleted_at" CASCADE;
 ALTER TABLE "{{users}}" DROP COLUMN "deleted_at" CASCADE;
+`
+	pgsqlV21SQL = `ALTER TABLE "{{users}}" ADD COLUMN "first_download" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ALTER COLUMN "first_download" DROP DEFAULT;
+ALTER TABLE "{{users}}" ADD COLUMN "first_upload" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ALTER COLUMN "first_upload" DROP DEFAULT;
+`
+	pgsqlV21DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "first_upload" CASCADE;
+ALTER TABLE "{{users}}" DROP COLUMN "first_download" CASCADE;
 `
 `
 )
 )
 
 
@@ -591,6 +599,14 @@ func (p *PGSQLProvider) updateTaskTimestamp(name string) error {
 	return sqlCommonUpdateTaskTimestamp(name, p.dbHandle)
 	return sqlCommonUpdateTaskTimestamp(name, p.dbHandle)
 }
 }
 
 
+func (p *PGSQLProvider) setFirstDownloadTimestamp(username string) error {
+	return sqlCommonSetFirstDownloadTimestamp(username, p.dbHandle)
+}
+
+func (p *PGSQLProvider) setFirstUploadTimestamp(username string) error {
+	return sqlCommonSetFirstUploadTimestamp(username, p.dbHandle)
+}
+
 func (p *PGSQLProvider) close() error {
 func (p *PGSQLProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -632,6 +648,8 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
 		return err
 		return err
 	case version == 19:
 	case version == 19:
 		return updatePgSQLDatabaseFromV19(p.dbHandle)
 		return updatePgSQLDatabaseFromV19(p.dbHandle)
+	case version == 20:
+		return updatePgSQLDatabaseFromV20(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -656,6 +674,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 	switch dbVersion.Version {
 	switch dbVersion.Version {
 	case 20:
 	case 20:
 		return downgradePgSQLDatabaseFromV20(p.dbHandle)
 		return downgradePgSQLDatabaseFromV20(p.dbHandle)
+	case 21:
+		return downgradePgSQLDatabaseFromV21(p.dbHandle)
 	default:
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 	}
@@ -667,13 +687,27 @@ func (p *PGSQLProvider) resetDatabase() error {
 }
 }
 
 
 func updatePgSQLDatabaseFromV19(dbHandle *sql.DB) error {
 func updatePgSQLDatabaseFromV19(dbHandle *sql.DB) error {
-	return updatePgSQLDatabaseFrom19To20(dbHandle)
+	if err := updatePgSQLDatabaseFrom19To20(dbHandle); err != nil {
+		return err
+	}
+	return updatePgSQLDatabaseFromV20(dbHandle)
+}
+
+func updatePgSQLDatabaseFromV20(dbHandle *sql.DB) error {
+	return updatePgSQLDatabaseFrom20To21(dbHandle)
 }
 }
 
 
 func downgradePgSQLDatabaseFromV20(dbHandle *sql.DB) error {
 func downgradePgSQLDatabaseFromV20(dbHandle *sql.DB) error {
 	return downgradePgSQLDatabaseFrom20To19(dbHandle)
 	return downgradePgSQLDatabaseFrom20To19(dbHandle)
 }
 }
 
 
+func downgradePgSQLDatabaseFromV21(dbHandle *sql.DB) error {
+	if err := downgradePgSQLDatabaseFrom21To20(dbHandle); err != nil {
+		return err
+	}
+	return downgradePgSQLDatabaseFromV20(dbHandle)
+}
+
 func updatePgSQLDatabaseFrom19To20(dbHandle *sql.DB) error {
 func updatePgSQLDatabaseFrom19To20(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 19 -> 20")
 	logger.InfoToConsole("updating database version: 19 -> 20")
 	providerLog(logger.LevelInfo, "updating database version: 19 -> 20")
 	providerLog(logger.LevelInfo, "updating database version: 19 -> 20")
@@ -686,6 +720,13 @@ func updatePgSQLDatabaseFrom19To20(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, true)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, true)
 }
 }
 
 
+func updatePgSQLDatabaseFrom20To21(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 20 -> 21")
+	providerLog(logger.LevelInfo, "updating database version: 20 -> 21")
+	sql := strings.ReplaceAll(pgsqlV21SQL, "{{users}}", sqlTableUsers)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 21, true)
+}
+
 func downgradePgSQLDatabaseFrom20To19(dbHandle *sql.DB) error {
 func downgradePgSQLDatabaseFrom20To19(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database version: 20 -> 19")
 	logger.InfoToConsole("downgrading database version: 20 -> 19")
 	providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
 	providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
@@ -696,3 +737,10 @@ func downgradePgSQLDatabaseFrom20To19(dbHandle *sql.DB) error {
 	sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
 	sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 19, false)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 19, false)
 }
 }
+
+func downgradePgSQLDatabaseFrom21To20(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 21 -> 20")
+	providerLog(logger.LevelInfo, "downgrading database version: 21 -> 20")
+	sql := strings.ReplaceAll(pgsqlV21DownSQL, "{{users}}", sqlTableUsers)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, false)
+}

+ 27 - 2
internal/dataprovider/sqlcommon.go

@@ -34,7 +34,7 @@ import (
 )
 )
 
 
 const (
 const (
-	sqlDatabaseVersion     = 20
+	sqlDatabaseVersion     = 21
 	defaultSQLQueryTimeout = 10 * time.Second
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 )
 )
@@ -893,6 +893,30 @@ func sqlCommonSetUpdatedAt(username string, dbHandle *sql.DB) {
 	}
 	}
 }
 }
 
 
+func sqlCommonSetFirstDownloadTimestamp(username string, dbHandle *sql.DB) error {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+
+	q := getSetFirstDownloadQuery()
+	res, err := dbHandle.ExecContext(ctx, q, util.GetTimeAsMsSinceEpoch(time.Now()), username)
+	if err != nil {
+		return err
+	}
+	return sqlCommonRequireRowAffected(res)
+}
+
+func sqlCommonSetFirstUploadTimestamp(username string, dbHandle *sql.DB) error {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+
+	q := getSetFirstUploadQuery()
+	res, err := dbHandle.ExecContext(ctx, q, util.GetTimeAsMsSinceEpoch(time.Now()), username)
+	if err != nil {
+		return err
+	}
+	return sqlCommonRequireRowAffected(res)
+}
+
 func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
 func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	defer cancel()
 	defer cancel()
@@ -1730,7 +1754,8 @@ func getUserFromDbRow(row sqlScanner) (User, error) {
 		&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
 		&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
 		&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
 		&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
 		&additionalInfo, &description, &email, &user.CreatedAt, &user.UpdatedAt, &user.UploadDataTransfer, &user.DownloadDataTransfer,
 		&additionalInfo, &description, &email, &user.CreatedAt, &user.UpdatedAt, &user.UploadDataTransfer, &user.DownloadDataTransfer,
-		&user.TotalDataTransfer, &user.UsedUploadDataTransfer, &user.UsedDownloadDataTransfer, &user.DeletedAt)
+		&user.TotalDataTransfer, &user.UsedUploadDataTransfer, &user.UsedDownloadDataTransfer, &user.DeletedAt, &user.FirstDownload,
+		&user.FirstUpload)
 	if err != nil {
 	if err != nil {
 		if errors.Is(err, sql.ErrNoRows) {
 		if errors.Is(err, sql.ErrNoRows) {
 			return user, util.NewRecordNotFoundError(err.Error())
 			return user, util.NewRecordNotFoundError(err.Error())

+ 48 - 1
internal/dataprovider/sqlite.go

@@ -160,7 +160,13 @@ CREATE INDEX "{{prefix}}users_deleted_at_idx" ON "{{users}}" ("deleted_at");
 DROP TABLE "{{events_rules}}";
 DROP TABLE "{{events_rules}}";
 DROP TABLE "{{events_actions}}";
 DROP TABLE "{{events_actions}}";
 DROP TABLE "{{tasks}}";
 DROP TABLE "{{tasks}}";
+DROP INDEX IF EXISTS "{{prefix}}users_deleted_at_idx";
 ALTER TABLE "{{users}}" DROP COLUMN "deleted_at";
 ALTER TABLE "{{users}}" DROP COLUMN "deleted_at";
+`
+	sqliteV21SQL = `ALTER TABLE "{{users}}" ADD COLUMN "first_download" bigint DEFAULT 0 NOT NULL;
+ALTER TABLE "{{users}}" ADD COLUMN "first_upload" bigint DEFAULT 0 NOT NULL;`
+	sqliteV21DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "first_upload";
+ALTER TABLE "{{users}}" DROP COLUMN "first_download";
 `
 `
 )
 )
 
 
@@ -563,6 +569,14 @@ func (p *SQLiteProvider) updateTaskTimestamp(name string) error {
 	return sqlCommonUpdateTaskTimestamp(name, p.dbHandle)
 	return sqlCommonUpdateTaskTimestamp(name, p.dbHandle)
 }
 }
 
 
+func (p *SQLiteProvider) setFirstDownloadTimestamp(username string) error {
+	return sqlCommonSetFirstDownloadTimestamp(username, p.dbHandle)
+}
+
+func (p *SQLiteProvider) setFirstUploadTimestamp(username string) error {
+	return sqlCommonSetFirstUploadTimestamp(username, p.dbHandle)
+}
+
 func (p *SQLiteProvider) close() error {
 func (p *SQLiteProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -604,6 +618,8 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
 		return err
 		return err
 	case version == 19:
 	case version == 19:
 		return updateSQLiteDatabaseFromV19(p.dbHandle)
 		return updateSQLiteDatabaseFromV19(p.dbHandle)
+	case version == 20:
+		return updateSQLiteDatabaseFromV20(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelError, "database version %v is newer than the supported one: %v", version,
@@ -628,6 +644,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 	switch dbVersion.Version {
 	switch dbVersion.Version {
 	case 20:
 	case 20:
 		return downgradeSQLiteDatabaseFromV20(p.dbHandle)
 		return downgradeSQLiteDatabaseFromV20(p.dbHandle)
+	case 21:
+		return downgradeSQLiteDatabaseFromV21(p.dbHandle)
 	default:
 	default:
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 		return fmt.Errorf("database version not handled: %v", dbVersion.Version)
 	}
 	}
@@ -639,13 +657,27 @@ func (p *SQLiteProvider) resetDatabase() error {
 }
 }
 
 
 func updateSQLiteDatabaseFromV19(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFromV19(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom19To20(dbHandle)
+	if err := updateSQLiteDatabaseFrom19To20(dbHandle); err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV20(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV20(dbHandle *sql.DB) error {
+	return updateSQLiteDatabaseFrom20To21(dbHandle)
 }
 }
 
 
 func downgradeSQLiteDatabaseFromV20(dbHandle *sql.DB) error {
 func downgradeSQLiteDatabaseFromV20(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFrom20To19(dbHandle)
 	return downgradeSQLiteDatabaseFrom20To19(dbHandle)
 }
 }
 
 
+func downgradeSQLiteDatabaseFromV21(dbHandle *sql.DB) error {
+	if err := downgradeSQLiteDatabaseFrom21To20(dbHandle); err != nil {
+		return err
+	}
+	return downgradeSQLiteDatabaseFromV20(dbHandle)
+}
+
 func updateSQLiteDatabaseFrom19To20(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFrom19To20(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 19 -> 20")
 	logger.InfoToConsole("updating database version: 19 -> 20")
 	providerLog(logger.LevelInfo, "updating database version: 19 -> 20")
 	providerLog(logger.LevelInfo, "updating database version: 19 -> 20")
@@ -658,6 +690,13 @@ func updateSQLiteDatabaseFrom19To20(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, true)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, true)
 }
 }
 
 
+func updateSQLiteDatabaseFrom20To21(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 20 -> 21")
+	providerLog(logger.LevelInfo, "updating database version: 20 -> 21")
+	sql := strings.ReplaceAll(sqliteV21SQL, "{{users}}", sqlTableUsers)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 21, true)
+}
+
 func downgradeSQLiteDatabaseFrom20To19(dbHandle *sql.DB) error {
 func downgradeSQLiteDatabaseFrom20To19(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database version: 20 -> 19")
 	logger.InfoToConsole("downgrading database version: 20 -> 19")
 	providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
 	providerLog(logger.LevelInfo, "downgrading database version: 20 -> 19")
@@ -666,9 +705,17 @@ func downgradeSQLiteDatabaseFrom20To19(dbHandle *sql.DB) error {
 	sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping)
 	sql = strings.ReplaceAll(sql, "{{rules_actions_mapping}}", sqlTableRulesActionsMapping)
 	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
 	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
 	sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
 	sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 19, false)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 19, false)
 }
 }
 
 
+func downgradeSQLiteDatabaseFrom21To20(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 21 -> 20")
+	providerLog(logger.LevelInfo, "downgrading database version: 21 -> 20")
+	sql := strings.ReplaceAll(sqliteV21DownSQL, "{{users}}", sqlTableUsers)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 20, false)
+}
+
 /*func setPragmaFK(dbHandle *sql.DB, value string) error {
 /*func setPragmaFK(dbHandle *sql.DB, value string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
 	defer cancel()
 	defer cancel()

+ 13 - 3
internal/dataprovider/sqlqueries.go

@@ -26,7 +26,7 @@ const (
 	selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
 	selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
 		"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," +
 		"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," +
 		"additional_info,description,email,created_at,updated_at,upload_data_transfer,download_data_transfer,total_data_transfer," +
 		"additional_info,description,email,created_at,updated_at,upload_data_transfer,download_data_transfer,total_data_transfer," +
-		"used_upload_data_transfer,used_download_data_transfer,deleted_at"
+		"used_upload_data_transfer,used_download_data_transfer,deleted_at,first_download,first_upload"
 	selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem"
 	selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem"
 	selectAdminFields  = "id,username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login"
 	selectAdminFields  = "id,username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login"
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
@@ -457,6 +457,16 @@ func getSetUpdateAtQuery() string {
 	return fmt.Sprintf(`UPDATE %s SET updated_at = %s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
 	return fmt.Sprintf(`UPDATE %s SET updated_at = %s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
 }
 }
 
 
+func getSetFirstUploadQuery() string {
+	return fmt.Sprintf(`UPDATE %s SET first_upload = %s WHERE username = %s AND first_upload = 0`,
+		sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
+}
+
+func getSetFirstDownloadQuery() string {
+	return fmt.Sprintf(`UPDATE %s SET first_download = %s WHERE username = %s AND first_download = 0`,
+		sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
+}
+
 func getUpdateLastLoginQuery() string {
 func getUpdateLastLoginQuery() string {
 	return fmt.Sprintf(`UPDATE %s SET last_login = %s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
 	return fmt.Sprintf(`UPDATE %s SET last_login = %s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
 }
 }
@@ -484,8 +494,8 @@ func getAddUserQuery() string {
 	return fmt.Sprintf(`INSERT INTO %s (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
 	return fmt.Sprintf(`INSERT INTO %s (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
 		used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
 		used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
 		filesystem,additional_info,description,email,created_at,updated_at,upload_data_transfer,download_data_transfer,total_data_transfer,
 		filesystem,additional_info,description,email,created_at,updated_at,upload_data_transfer,download_data_transfer,total_data_transfer,
-		used_upload_data_transfer,used_download_data_transfer,deleted_at)
-		VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0)`,
+		used_upload_data_transfer,used_download_data_transfer,deleted_at,first_download,first_upload)
+		VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,0,0)`,
 		sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
 		sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
 		sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
 		sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
 		sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14],
 		sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14],

+ 2 - 0
internal/dataprovider/user.go

@@ -1878,6 +1878,8 @@ func (u *User) getACopy() User {
 			Status:                   u.Status,
 			Status:                   u.Status,
 			ExpirationDate:           u.ExpirationDate,
 			ExpirationDate:           u.ExpirationDate,
 			LastLogin:                u.LastLogin,
 			LastLogin:                u.LastLogin,
+			FirstDownload:            u.FirstDownload,
+			FirstUpload:              u.FirstUpload,
 			AdditionalInfo:           u.AdditionalInfo,
 			AdditionalInfo:           u.AdditionalInfo,
 			Description:              u.Description,
 			Description:              u.Description,
 			CreatedAt:                u.CreatedAt,
 			CreatedAt:                u.CreatedAt,

+ 10 - 0
internal/ftpd/ftpd_test.go

@@ -533,8 +533,16 @@ func TestBasicFTPHandling(t *testing.T) {
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 			err = ftpUploadFile(testFilePath, path.Join("/missing_dir", testFileName), testFileSize, client, 0)
 			err = ftpUploadFile(testFilePath, path.Join("/missing_dir", testFileName), testFileSize, client, 0)
 			assert.Error(t, err)
 			assert.Error(t, err)
+			user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+			assert.NoError(t, err)
+			assert.Equal(t, int64(0), user.FirstUpload)
+			assert.Equal(t, int64(0), user.FirstDownload)
 			err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
 			err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
+			user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+			assert.NoError(t, err)
+			assert.Greater(t, user.FirstUpload, int64(0))
+			assert.Equal(t, int64(0), user.FirstDownload)
 			// overwrite an existing file
 			// overwrite an existing file
 			err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
 			err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
@@ -545,6 +553,8 @@ func TestBasicFTPHandling(t *testing.T) {
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 			assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
 			assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
 			assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
 			assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
+			assert.Greater(t, user.FirstUpload, int64(0))
+			assert.Greater(t, user.FirstDownload, int64(0))
 			err = client.Rename(testFileName, testFileName+"1")
 			err = client.Rename(testFileName, testFileName+"1")
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 			err = client.Delete(testFileName)
 			err = client.Delete(testFileName)

+ 27 - 0
internal/httpd/httpd_test.go

@@ -1995,6 +1995,8 @@ func TestUserTimestamps(t *testing.T) {
 	createdAt := user.CreatedAt
 	createdAt := user.CreatedAt
 	updatedAt := user.UpdatedAt
 	updatedAt := user.UpdatedAt
 	assert.Equal(t, int64(0), user.LastLogin)
 	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, int64(0), user.FirstDownload)
+	assert.Equal(t, int64(0), user.FirstUpload)
 	assert.Greater(t, createdAt, int64(0))
 	assert.Greater(t, createdAt, int64(0))
 	assert.Greater(t, updatedAt, int64(0))
 	assert.Greater(t, updatedAt, int64(0))
 	mappedPath := filepath.Join(os.TempDir(), "mapped_dir")
 	mappedPath := filepath.Join(os.TempDir(), "mapped_dir")
@@ -2010,6 +2012,8 @@ func TestUserTimestamps(t *testing.T) {
 	user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err, string(resp))
 	assert.NoError(t, err, string(resp))
 	assert.Equal(t, int64(0), user.LastLogin)
 	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, int64(0), user.FirstDownload)
+	assert.Equal(t, int64(0), user.FirstUpload)
 	assert.Equal(t, createdAt, user.CreatedAt)
 	assert.Equal(t, createdAt, user.CreatedAt)
 	assert.Greater(t, user.UpdatedAt, updatedAt)
 	assert.Greater(t, user.UpdatedAt, updatedAt)
 	updatedAt = user.UpdatedAt
 	updatedAt = user.UpdatedAt
@@ -2023,6 +2027,8 @@ func TestUserTimestamps(t *testing.T) {
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, int64(0), user.LastLogin)
 	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, int64(0), user.FirstDownload)
+	assert.Equal(t, int64(0), user.FirstUpload)
 	assert.Equal(t, createdAt, user.CreatedAt)
 	assert.Equal(t, createdAt, user.CreatedAt)
 	assert.Greater(t, user.UpdatedAt, updatedAt)
 	assert.Greater(t, user.UpdatedAt, updatedAt)
 	updatedAt = user.UpdatedAt
 	updatedAt = user.UpdatedAt
@@ -2032,6 +2038,8 @@ func TestUserTimestamps(t *testing.T) {
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, int64(0), user.LastLogin)
 	assert.Equal(t, int64(0), user.LastLogin)
+	assert.Equal(t, int64(0), user.FirstDownload)
+	assert.Equal(t, int64(0), user.FirstUpload)
 	assert.Equal(t, createdAt, user.CreatedAt)
 	assert.Equal(t, createdAt, user.CreatedAt)
 	assert.Greater(t, user.UpdatedAt, updatedAt)
 	assert.Greater(t, user.UpdatedAt, updatedAt)
 
 
@@ -13266,6 +13274,10 @@ func TestWebFilesAPI(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "Unable to parse multipart form")
 	assert.Contains(t, rr.Body.String(), "Unable to parse multipart form")
 	_, err = reader.Seek(0, io.SeekStart)
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), user.FirstUpload)
+	assert.Equal(t, int64(0), user.FirstDownload)
 	// set the proper content type
 	// set the proper content type
 	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -13273,6 +13285,10 @@ func TestWebFilesAPI(t *testing.T) {
 	setBearerForReq(req, webAPIToken)
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
 	checkResponseCode(t, http.StatusCreated, rr)
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Greater(t, user.FirstUpload, int64(0))
+	assert.Equal(t, int64(0), user.FirstDownload)
 	// check we have 2 files
 	// check we have 2 files
 	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -13283,6 +13299,17 @@ func TestWebFilesAPI(t *testing.T) {
 	err = json.NewDecoder(rr.Body).Decode(&contents)
 	err = json.NewDecoder(rr.Body).Decode(&contents)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Len(t, contents, 2)
 	assert.Len(t, contents, 2)
+	// download a file
+	req, err = http.NewRequest(http.MethodGet, userFilesPath+"?path=file1.txt", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Equal(t, "file1 content", rr.Body.String())
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Greater(t, user.FirstUpload, int64(0))
+	assert.Greater(t, user.FirstDownload, int64(0))
 	// overwrite the existing files
 	// overwrite the existing files
 	_, err = reader.Seek(0, io.SeekStart)
 	_, err = reader.Seek(0, io.SeekStart)
 	assert.NoError(t, err)
 	assert.NoError(t, err)

+ 22 - 0
internal/sftpd/sftpd_test.go

@@ -510,8 +510,16 @@ func TestBasicSFTPHandling(t *testing.T) {
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = sftpUploadFile(testFilePath, path.Join("/missing_dir", testFileName), testFileSize, client)
 		err = sftpUploadFile(testFilePath, path.Join("/missing_dir", testFileName), testFileSize, client)
 		assert.Error(t, err)
 		assert.Error(t, err)
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Equal(t, int64(0), user.FirstUpload)
+		assert.Equal(t, int64(0), user.FirstDownload)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Greater(t, user.FirstUpload, int64(0))
+		assert.Equal(t, int64(0), user.FirstDownload)
 		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
 		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
 		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
 		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
@@ -527,6 +535,8 @@ func TestBasicSFTPHandling(t *testing.T) {
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		assert.Equal(t, expectedQuotaFiles-1, user.UsedQuotaFiles)
 		assert.Equal(t, expectedQuotaFiles-1, user.UsedQuotaFiles)
 		assert.Equal(t, expectedQuotaSize-testFileSize, user.UsedQuotaSize)
 		assert.Equal(t, expectedQuotaSize-testFileSize, user.UsedQuotaSize)
+		assert.Greater(t, user.FirstUpload, int64(0))
+		assert.Greater(t, user.FirstDownload, int64(0))
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(localDownloadPath)
 		err = os.Remove(localDownloadPath)
@@ -9550,10 +9560,22 @@ func TestSCPBasicHandling(t *testing.T) {
 		// test to download a missing file
 		// test to download a missing file
 		err = scpDownload(localPath, remoteDownPath, false, false)
 		err = scpDownload(localPath, remoteDownPath, false, false)
 		assert.Error(t, err, "downloading a missing file via scp must fail")
 		assert.Error(t, err, "downloading a missing file via scp must fail")
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Equal(t, int64(0), user.FirstUpload)
+		assert.Equal(t, int64(0), user.FirstDownload)
 		err = scpUpload(testFilePath, remoteUpPath, false, false)
 		err = scpUpload(testFilePath, remoteUpPath, false, false)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Greater(t, user.FirstUpload, int64(0))
+		assert.Equal(t, int64(0), user.FirstDownload)
 		err = scpDownload(localPath, remoteDownPath, false, false)
 		err = scpDownload(localPath, remoteDownPath, false, false)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Greater(t, user.FirstUpload, int64(0))
+		assert.Greater(t, user.FirstDownload, int64(0))
 		fi, err := os.Stat(localPath)
 		fi, err := os.Stat(localPath)
 		if assert.NoError(t, err) {
 		if assert.NoError(t, err) {
 			assert.Equal(t, testFileSize, fi.Size())
 			assert.Equal(t, testFileSize, fi.Size())

+ 11 - 0
internal/webdavd/webdavd_test.go

@@ -526,9 +526,17 @@ func TestBasicHandling(t *testing.T) {
 		expectedQuotaFiles := 1
 		expectedQuotaFiles := 1
 		err = createTestFile(testFilePath, testFileSize)
 		err = createTestFile(testFilePath, testFileSize)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Equal(t, int64(0), user.FirstUpload)
+		assert.Equal(t, int64(0), user.FirstDownload)
 		err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
 		err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
 			true, testFileSize, client)
 			true, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+		user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+		assert.NoError(t, err)
+		assert.Greater(t, user.FirstUpload, int64(0))
+		assert.Greater(t, user.FirstDownload, int64(0)) // webdav read the mime type
 		// overwrite an existing file
 		// overwrite an existing file
 		err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
 		err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
 			true, testFileSize, client)
 			true, testFileSize, client)
@@ -544,6 +552,8 @@ func TestBasicHandling(t *testing.T) {
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
 		assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
 		assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
 		assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
+		assert.Greater(t, user.FirstUpload, int64(0))
+		assert.Greater(t, user.FirstDownload, int64(0))
 		err = client.Rename(testFileName, testFileName+"1", false)
 		err = client.Rename(testFileName, testFileName+"1", false)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		_, err = client.Stat(testFileName)
 		_, err = client.Stat(testFileName)
@@ -563,6 +573,7 @@ func TestBasicHandling(t *testing.T) {
 		assert.Equal(t, expectedQuotaSize-testFileSize, user.UsedQuotaSize)
 		assert.Equal(t, expectedQuotaSize-testFileSize, user.UsedQuotaSize)
 		err = downloadFile(testFileName, localDownloadPath, testFileSize, client)
 		err = downloadFile(testFileName, localDownloadPath, testFileSize, client)
 		assert.Error(t, err)
 		assert.Error(t, err)
+
 		testDir := "testdir"
 		testDir := "testdir"
 		err = client.Mkdir(testDir, os.ModePerm)
 		err = client.Mkdir(testDir, os.ModePerm)
 		assert.NoError(t, err)
 		assert.NoError(t, err)

+ 8 - 0
openapi/openapi.yaml

@@ -5201,6 +5201,14 @@ components:
           type: integer
           type: integer
           format: int64
           format: int64
           description: Last user login as unix timestamp in milliseconds. It is saved at most once every 10 minutes
           description: Last user login as unix timestamp in milliseconds. It is saved at most once every 10 minutes
+        first_download:
+          type: integer
+          format: int64
+          description: first download time as unix timestamp in milliseconds
+        first_upload:
+          type: integer
+          format: int64
+          description: first upload time as unix timestamp in milliseconds
         filters:
         filters:
           $ref: '#/components/schemas/UserFilters'
           $ref: '#/components/schemas/UserFilters'
         filesystem:
         filesystem: