fs actions: add first upload/download

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-08-21 19:01:08 +02:00
parent 9ddd2d3588
commit 3e8254e398
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
23 changed files with 629 additions and 44 deletions

View file

@ -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:
- `download`
- `first-download`
- `pre-download`
- `upload`
- `first-upload`
- `pre-upload`
- `delete`
- `pre-delete`
@ -20,7 +22,7 @@ The following `actions` are supported:
- `rmdir`
- `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.
The notification will indicate if an error is detected and so, for example, a partial file is uploaded.

4
go.mod
View file

@ -20,7 +20,7 @@ require (
github.com/cockroachdb/cockroach-go/v2 v2.2.15
github.com/coreos/go-oidc/v3 v3.2.0
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/go-acme/lego/v4 v4.8.0
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/xid v1.4.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/spf13/afero v1.9.2
github.com/spf13/cobra v1.5.0

8
go.sum
View file

@ -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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
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/go.mod h1:sw1KvnkZ4wKCYkvy4SL3qVZcJSWFP8Ure4pM3z+KNn4=
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/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/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/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=

View file

@ -45,21 +45,23 @@ import (
// constants
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
OperationPreDownload = "pre-download"
// Pre-upload action name

View file

@ -1190,6 +1190,45 @@ func TestVfsSameResource(t *testing.T) {
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) {
bcryptPassword := "bcryptpassword"
for i := 0; i < b.N; i++ {

View file

@ -39,6 +39,8 @@ type BaseConnection struct {
// 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
lastActivity int64
uploadDone atomic.Bool
downloadDone atomic.Bool
// 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
transferID int64

View file

@ -3764,6 +3764,135 @@ func TestEventRuleFsActions(t *testing.T) {
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) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",

View file

@ -386,19 +386,20 @@ func (t *BaseTransfer) Close() error {
}
}
elapsed := time.Since(t.start).Nanoseconds() / 1000000
var uploadFileSize int64
if t.transferType == TransferDownload {
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)
ExecuteActionNotification(t.Connection, operationDownload, t.fsPath, t.requestPath, "", "", "", //nolint:errcheck
atomic.LoadInt64(&t.BytesSent), t.ErrTransfer)
} else {
fileSize := atomic.LoadInt64(&t.BytesReceived) + t.MinWriteOffset
uploadFileSize = atomic.LoadInt64(&t.BytesReceived) + t.MinWriteOffset
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()
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)
@ -409,9 +410,33 @@ func (t *BaseTransfer) Close() error {
err = t.ErrTransfer
}
}
t.updateTransferTimestamps(uploadFileSize)
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) {
err := ExecuteActionNotification(t.Connection, operationUpload, t.fsPath, t.requestPath, "", "", "",
fileSize, t.ErrTransfer)

View file

@ -35,7 +35,7 @@ import (
)
const (
boltDatabaseVersion = 20
boltDatabaseVersion = 21
)
var (
@ -570,6 +570,8 @@ func (p *BoltProvider) addUser(user *User) error {
user.UsedUploadDataTransfer = 0
user.UsedDownloadDataTransfer = 0
user.LastLogin = 0
user.FirstDownload = 0
user.FirstUpload = 0
user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
for idx := range user.VirtualFolders {
@ -621,6 +623,8 @@ func (p *BoltProvider) updateUser(user *User) error {
user.UsedUploadDataTransfer = oldUser.UsedUploadDataTransfer
user.UsedDownloadDataTransfer = oldUser.UsedDownloadDataTransfer
user.LastLogin = oldUser.LastLogin
user.FirstDownload = oldUser.FirstDownload
user.FirstUpload = oldUser.FirstUpload
user.CreatedAt = oldUser.CreatedAt
user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
buf, err := json.Marshal(user)
@ -2433,6 +2437,63 @@ func (p *BoltProvider) updateTaskTimestamp(name string) error {
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 {
return p.dbHandle.Close()
}
@ -2460,10 +2521,10 @@ func (p *BoltProvider) migrateDatabase() error {
providerLog(logger.LevelError, "%v", err)
logger.ErrorToConsole("%v", 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:
if version > boltDatabaseVersion {
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")
}
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 {
for _, bucketName := range [][]byte{actionsBucket, rulesBucket} {
err := tx.DeleteBucket(bucketName)

View file

@ -768,6 +768,8 @@ type Provider interface {
addTask(name string) error
updateTask(name string, version int64) error
updateTaskTimestamp(name string) error
setFirstDownloadTimestamp(username string) error
setFirstUploadTimestamp(username string) error
checkAvailability() error
close() error
reloadConfig() error
@ -1399,6 +1401,22 @@ func UpdateUserTransferQuota(user *User, uploadSize, downloadSize int64, reset b
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.
func GetUsedQuota(username string) (int, int64, int64, int64, error) {
if config.TrackQuota == 0 {
@ -3538,6 +3556,8 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
userUsedUploadTransfer := u.UsedUploadDataTransfer
userLastQuotaUpdate := u.LastQuotaUpdate
userLastLogin := u.LastLogin
userFirstDownload := u.FirstDownload
userFirstUpload := u.FirstUpload
userCreatedAt := u.CreatedAt
totpConfig := u.Filters.TOTPConfig
recoveryCodes := u.Filters.RecoveryCodes
@ -3552,6 +3572,8 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
u.UsedDownloadDataTransfer = userUsedDownloadTransfer
u.LastQuotaUpdate = userLastQuotaUpdate
u.LastLogin = userLastLogin
u.FirstDownload = userFirstDownload
u.FirstUpload = userFirstUpload
u.CreatedAt = userCreatedAt
if userID == 0 {
err = provider.addUser(&u)
@ -3787,6 +3809,8 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
user.LastQuotaUpdate = u.LastQuotaUpdate
user.LastLogin = u.LastLogin
user.FirstDownload = u.FirstDownload
user.FirstUpload = u.FirstUpload
user.CreatedAt = u.CreatedAt
user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
// 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.LastQuotaUpdate = u.LastQuotaUpdate
user.LastLogin = u.LastLogin
user.FirstDownload = u.FirstDownload
user.FirstUpload = u.FirstUpload
// preserve TOTP config and recovery codes
user.Filters.TOTPConfig = u.Filters.TOTPConfig
user.Filters.RecoveryCodes = u.Filters.RecoveryCodes

View file

@ -145,7 +145,8 @@ func getFsActionTypeAsString(value int) string {
// TODO: replace the copied strings with shared constants
var (
// 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 = []string{operationAdd, operationUpdate, operationDelete}
// SupportedRuleConditionProtocols defines the supported protcols for rule conditions

View file

@ -326,6 +326,8 @@ func (p *MemoryProvider) addUser(user *User) error {
user.UsedUploadDataTransfer = 0
user.UsedDownloadDataTransfer = 0
user.LastLogin = 0
user.FirstUpload = 0
user.FirstDownload = 0
user.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
user.VirtualFolders = p.joinUserVirtualFoldersFields(user)
@ -378,6 +380,8 @@ func (p *MemoryProvider) updateUser(user *User) error {
user.UsedUploadDataTransfer = u.UsedUploadDataTransfer
user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
user.LastLogin = u.LastLogin
user.FirstDownload = u.FirstDownload
user.FirstUpload = u.FirstUpload
user.CreatedAt = u.CreatedAt
user.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
user.ID = u.ID
@ -2203,6 +2207,44 @@ func (p *MemoryProvider) updateTaskTimestamp(name string) error {
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 {
nextID := int64(1)
for _, v := range p.dbHandle.users {

View file

@ -164,6 +164,12 @@ const (
"DROP TABLE `{{events_actions}}` CASCADE;" +
"DROP TABLE `{{tasks}}` CASCADE;" +
"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
@ -616,6 +622,14 @@ func (p *MySQLProvider) updateTaskTimestamp(name string) error {
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 {
return p.dbHandle.Close()
}
@ -657,6 +671,8 @@ func (p *MySQLProvider) migrateDatabase() error { //nolint:dupl
return err
case version == 19:
return updateMySQLDatabaseFromV19(p.dbHandle)
case version == 20:
return updateMySQLDatabaseFromV20(p.dbHandle)
default:
if version > sqlDatabaseVersion {
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 {
case 20:
return downgradeMySQLDatabaseFromV20(p.dbHandle)
case 21:
return downgradeMySQLDatabaseFromV21(p.dbHandle)
default:
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 {
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 {
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 {
logger.InfoToConsole("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)
}
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 {
logger.InfoToConsole("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)
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)
}

View file

@ -175,6 +175,14 @@ DROP TABLE "{{events_rules}}" CASCADE;
DROP TABLE "{{events_actions}}" CASCADE;
DROP TABLE "{{tasks}}" 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)
}
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 {
return p.dbHandle.Close()
}
@ -632,6 +648,8 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
return err
case version == 19:
return updatePgSQLDatabaseFromV19(p.dbHandle)
case version == 20:
return updatePgSQLDatabaseFromV20(p.dbHandle)
default:
if version > sqlDatabaseVersion {
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 {
case 20:
return downgradePgSQLDatabaseFromV20(p.dbHandle)
case 21:
return downgradePgSQLDatabaseFromV21(p.dbHandle)
default:
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 {
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 {
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 {
logger.InfoToConsole("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)
}
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 {
logger.InfoToConsole("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)
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)
}

View file

@ -34,7 +34,7 @@ import (
)
const (
sqlDatabaseVersion = 20
sqlDatabaseVersion = 21
defaultSQLQueryTimeout = 10 * 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 {
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
@ -1730,7 +1754,8 @@ func getUserFromDbRow(row sqlScanner) (User, error) {
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
&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 errors.Is(err, sql.ErrNoRows) {
return user, util.NewRecordNotFoundError(err.Error())

View file

@ -160,7 +160,13 @@ CREATE INDEX "{{prefix}}users_deleted_at_idx" ON "{{users}}" ("deleted_at");
DROP TABLE "{{events_rules}}";
DROP TABLE "{{events_actions}}";
DROP TABLE "{{tasks}}";
DROP INDEX IF EXISTS "{{prefix}}users_deleted_at_idx";
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)
}
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 {
return p.dbHandle.Close()
}
@ -604,6 +618,8 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
return err
case version == 19:
return updateSQLiteDatabaseFromV19(p.dbHandle)
case version == 20:
return updateSQLiteDatabaseFromV20(p.dbHandle)
default:
if version > sqlDatabaseVersion {
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 {
case 20:
return downgradeSQLiteDatabaseFromV20(p.dbHandle)
case 21:
return downgradeSQLiteDatabaseFromV21(p.dbHandle)
default:
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 {
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 {
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 {
logger.InfoToConsole("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)
}
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 {
logger.InfoToConsole("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, "{{users}}", sqlTableUsers)
sql = strings.ReplaceAll(sql, "{{tasks}}", sqlTableTasks)
sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
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 {
ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
defer cancel()

View file

@ -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," +
"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," +
"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"
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"
@ -457,6 +457,16 @@ func getSetUpdateAtQuery() string {
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 {
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,
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,
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],
sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14],

View file

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

View file

@ -533,8 +533,16 @@ func TestBasicFTPHandling(t *testing.T) {
assert.NoError(t, err)
err = ftpUploadFile(testFilePath, path.Join("/missing_dir", testFileName), testFileSize, client, 0)
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)
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
err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
assert.NoError(t, err)
@ -545,6 +553,8 @@ func TestBasicFTPHandling(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
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")
assert.NoError(t, err)
err = client.Delete(testFileName)

View file

@ -1995,6 +1995,8 @@ func TestUserTimestamps(t *testing.T) {
createdAt := user.CreatedAt
updatedAt := user.UpdatedAt
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, updatedAt, int64(0))
mappedPath := filepath.Join(os.TempDir(), "mapped_dir")
@ -2010,6 +2012,8 @@ func TestUserTimestamps(t *testing.T) {
user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err, string(resp))
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.Greater(t, user.UpdatedAt, updatedAt)
updatedAt = user.UpdatedAt
@ -2023,6 +2027,8 @@ func TestUserTimestamps(t *testing.T) {
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
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.Greater(t, user.UpdatedAt, updatedAt)
updatedAt = user.UpdatedAt
@ -2032,6 +2038,8 @@ func TestUserTimestamps(t *testing.T) {
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
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.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")
_, err = reader.Seek(0, io.SeekStart)
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
req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
assert.NoError(t, err)
@ -13273,6 +13285,10 @@ func TestWebFilesAPI(t *testing.T) {
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
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
req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
assert.NoError(t, err)
@ -13283,6 +13299,17 @@ func TestWebFilesAPI(t *testing.T) {
err = json.NewDecoder(rr.Body).Decode(&contents)
assert.NoError(t, err)
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
_, err = reader.Seek(0, io.SeekStart)
assert.NoError(t, err)

View file

@ -510,8 +510,16 @@ func TestBasicSFTPHandling(t *testing.T) {
assert.NoError(t, err)
err = sftpUploadFile(testFilePath, path.Join("/missing_dir", testFileName), testFileSize, client)
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)
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)
err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
assert.NoError(t, err)
@ -527,6 +535,8 @@ func TestBasicSFTPHandling(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, expectedQuotaFiles-1, user.UsedQuotaFiles)
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)
assert.NoError(t, err)
err = os.Remove(localDownloadPath)
@ -9550,10 +9560,22 @@ func TestSCPBasicHandling(t *testing.T) {
// test to download a missing file
err = scpDownload(localPath, remoteDownPath, false, false)
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)
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)
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)
if assert.NoError(t, err) {
assert.Equal(t, testFileSize, fi.Size())

View file

@ -526,9 +526,17 @@ func TestBasicHandling(t *testing.T) {
expectedQuotaFiles := 1
err = createTestFile(testFilePath, testFileSize)
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,
true, testFileSize, client)
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
err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
true, testFileSize, client)
@ -544,6 +552,8 @@ func TestBasicHandling(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
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)
assert.NoError(t, err)
_, err = client.Stat(testFileName)
@ -563,6 +573,7 @@ func TestBasicHandling(t *testing.T) {
assert.Equal(t, expectedQuotaSize-testFileSize, user.UsedQuotaSize)
err = downloadFile(testFileName, localDownloadPath, testFileSize, client)
assert.Error(t, err)
testDir := "testdir"
err = client.Mkdir(testDir, os.ModePerm)
assert.NoError(t, err)

View file

@ -5201,6 +5201,14 @@ components:
type: integer
format: int64
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:
$ref: '#/components/schemas/UserFilters'
filesystem: