Explorar o código

WebClient: refactor long-running tasks to improve browser compatibility

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino hai 1 ano
pai
achega
f38966c6ac

+ 30 - 28
go.mod

@@ -9,35 +9,35 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
 	github.com/alexedwards/argon2id v1.0.0
 	github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964
-	github.com/aws/aws-sdk-go-v2 v1.25.2
-	github.com/aws/aws-sdk-go-v2/config v1.27.6
-	github.com/aws/aws-sdk-go-v2/credentials v1.17.6
-	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2
-	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.8
-	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.1
-	github.com/aws/aws-sdk-go-v2/service/s3 v1.51.3
-	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.1
-	github.com/aws/aws-sdk-go-v2/service/sts v1.28.3
+	github.com/aws/aws-sdk-go-v2 v1.25.3
+	github.com/aws/aws-sdk-go-v2/config v1.27.7
+	github.com/aws/aws-sdk-go-v2/credentials v1.17.7
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.3
+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.9
+	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.2
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.51.4
+	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.2
+	github.com/aws/aws-sdk-go-v2/service/sts v1.28.4
 	github.com/bmatcuk/doublestar/v4 v4.6.1
 	github.com/cockroachdb/cockroach-go/v2 v2.3.7
 	github.com/coreos/go-oidc/v3 v3.9.0
 	github.com/drakkan/webdav v0.0.0-20240212101318-94e905cb9adb
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
-	github.com/fclairamb/ftpserverlib v0.22.0
-	github.com/fclairamb/go-log v0.4.1
-	github.com/go-acme/lego/v4 v4.15.0
+	github.com/fclairamb/ftpserverlib v0.24.0
+	github.com/fclairamb/go-log v0.5.0
+	github.com/go-acme/lego/v4 v4.16.1
 	github.com/go-chi/chi/v5 v5.0.12
 	github.com/go-chi/jwtauth/v5 v5.3.1
 	github.com/go-chi/render v1.0.3
-	github.com/go-sql-driver/mysql v1.7.1
+	github.com/go-sql-driver/mysql v1.8.0
 	github.com/golang/mock v1.6.0
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/google/uuid v1.6.0
 	github.com/hashicorp/go-hclog v1.6.2
 	github.com/hashicorp/go-plugin v1.6.0
 	github.com/hashicorp/go-retryablehttp v0.7.5
-	github.com/jackc/pgx/v5 v5.5.4
-	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
+	github.com/jackc/pgx/v5 v5.5.5
+	github.com/jlaffaye/ftp v0.2.0
 	github.com/klauspost/compress v1.17.7
 	github.com/lestrrat-go/jwx/v2 v2.0.21
 	github.com/lithammer/shortuuid/v3 v3.0.7
@@ -74,7 +74,7 @@ require (
 	golang.org/x/sys v0.18.0
 	golang.org/x/term v0.18.0
 	golang.org/x/time v0.5.0
-	google.golang.org/api v0.168.0
+	google.golang.org/api v0.169.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 )
 
@@ -83,19 +83,20 @@ require (
 	cloud.google.com/go/compute v1.25.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	cloud.google.com/go/iam v1.1.6 // indirect
+	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.3 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.3 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.2 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.3 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.4 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.4 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.2 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.20.1 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.5 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.5 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.3 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.20.2 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.2 // indirect
 	github.com/aws/smithy-go v1.20.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
@@ -108,7 +109,8 @@ require (
 	github.com/fatih/color v1.16.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.7.0 // indirect
-	github.com/go-jose/go-jose/v3 v3.0.2 // indirect
+	github.com/go-jose/go-jose/v3 v3.0.3 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.1 // indirect
 	github.com/go-logr/logr v1.4.1 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
@@ -173,9 +175,9 @@ require (
 	golang.org/x/tools v0.19.0 // indirect
 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect
+	google.golang.org/genproto v0.0.0-20240311132316-a219d84964c2 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 // indirect
 	google.golang.org/grpc v1.62.1 // indirect
 	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 56 - 52
go.sum

@@ -11,6 +11,8 @@ cloud.google.com/go/kms v1.15.7 h1:7caV9K3yIxvlQPAcaFffhlT7d1qpxjB1wHBtjWa13SM=
 cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI=
 cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA=
 cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0 h1:n1DH8TPV4qqPTje2RcUBYwtrTWlabVp4n46+74X2pn4=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0/go.mod h1:HDcZnuGbiyppErN6lB+idp4CKhjbc8gwjto6OPpyggM=
@@ -33,46 +35,46 @@ github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHc
 github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
 github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 h1:I9YN9WMo3SUh7p/4wKeNvD/IQla3U3SUa61U7ul+xM4=
 github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964/go.mod h1:eFiR01PwTcpbzXtdMces7zxg6utvFM5puiWHpWB8D/k=
-github.com/aws/aws-sdk-go-v2 v1.25.2 h1:/uiG1avJRgLGiQM9X3qJM8+Qa6KRGK5rRPuXE0HUM+w=
-github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo=
+github.com/aws/aws-sdk-go-v2 v1.25.3 h1:xYiLpZTQs1mzvz5PaI6uR0Wh57ippuEthxS4iK5v0n0=
+github.com/aws/aws-sdk-go-v2 v1.25.3/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 h1:gTK2uhtAPtFcdRRJilZPx8uJLL2J85xK11nKtWL0wfU=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1/go.mod h1:sxpLb+nZk7tIfCWChfd+h4QwHNUR57d8hA1cleTkjJo=
-github.com/aws/aws-sdk-go-v2/config v1.27.6 h1:WmoH1aPrxwcqAZTTnETjKr+fuvqzKd4hRrKxQUiuKP4=
-github.com/aws/aws-sdk-go-v2/config v1.27.6/go.mod h1:W9RZFF2pL+OhnUSZsQS/eDMWD8v+R+yWgjj3nSlrXVU=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.6 h1:akhj/nSC6SEx3OmiYGG/7mAyXMem9ZNVVf+DXkikcTk=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.6/go.mod h1:chJZuJ7TkW4kiMwmldOJOEueBoSkUb4ynZS1d9dhygo=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2 h1:AK0J8iYBFeUk2Ax7O8YpLtFsfhdOByh2QIkHmigpRYk=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2/go.mod h1:iRlGzMix0SExQEviAyptRWRGdYNo3+ufW/lCzvKVTUc=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.8 h1:fjsaZ2EUoOaosuYMLbQAVJsPIAOV4Xn52AQmk5JbhAs=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.8/go.mod h1:WPJcs0Mze3WntafH9Df2NdJ1oSQkEQVL6piZxoS0ecY=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2 h1:bNo4LagzUKbjdxE0tIcR9pMzLR2U/Tgie1Hq1HQ3iH8=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2/go.mod h1:wRQv0nN6v9wDXuWThpovGQjqF1HFdcgWjporw14lS8k=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2 h1:EtOU5jsPdIQNP+6Q2C5e3d65NKT1PeCiQk+9OdzO12Q=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2/go.mod h1:tyF5sKccmDz0Bv4NrstEr+/9YkSPJHrcO7UsUKf7pWM=
+github.com/aws/aws-sdk-go-v2/config v1.27.7 h1:JSfb5nOQF01iOgxFI5OIKWwDiEXWTyTgg1Mm1mHi0A4=
+github.com/aws/aws-sdk-go-v2/config v1.27.7/go.mod h1:PH0/cNpoMO+B04qET699o5W92Ca79fVtbUnvMIZro4I=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.7 h1:WJd+ubWKoBeRh7A5iNMnxEOs982SyVKOJD+K8HIezu4=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.7/go.mod h1:UQi7LMR0Vhvs+44w5ec8Q+VS+cd10cjwgHwiVkE0YGU=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.3 h1:p+y7FvkK2dxS+FEwRIDHDe//ZX+jDhP8HHE50ppj4iI=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.3/go.mod h1:/fYB+FZbDlwlAiynK9KDXlzZl3ANI9JkD0Uhz5FjNT4=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.9 h1:vXY/Hq1XdxHBIYgBUmug/AbMyIe1AKulPYS2/VE1X70=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.9/go.mod h1:GyJJTZoHVuENM4TeJEl5Ffs4W9m19u+4wKJcDi/GZ4A=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.3 h1:ifbIbHZyGl1alsAhPIYsHOg5MuApgqOvVeI8wIugXfs=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.3/go.mod h1:oQZXg3c6SNeY6OZrDY+xHcF4VGIEoNotX2B4PrDeoJI=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.3 h1:Qvodo9gHG9F3E8SfYOspPeBt0bjSbsevK8WhRAUHcoY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.3/go.mod h1:vCKrdLXtybdf/uQd/YfVR2r5pcbNuEYKzMQpcxmeSJw=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.2 h1:en92G0Z7xlksoOylkUhuBSfJgijC7rHVLRdnIlHEs0E=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.2/go.mod h1:HgtQ/wN5G+8QSlK62lbOtNwQ3wTSByJ4wH2rCkPt+AE=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.3 h1:mDnFOE2sVkyphMWtTH+stv0eW3k0OTx94K63xpxHty4=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.3/go.mod h1:V8MuRVcCRt5h1S+Fwu8KbC7l/gBGo3yBAyUbJM2IJOk=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 h1:EyBZibRTVAs6ECHZOw5/wlylS9OcTzwyjeQMudmREjE=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.4 h1:J3Q6N2sTChfYLZSTey3Qeo7n3JSm6RTJDcKev+7Sbus=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.4/go.mod h1:ZopsdDMVg1H03X7BdzpGaufOkuz27RjtKDzioP2U0Hg=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.4 h1:jRiWxyuVO8PlkN72wDMVn/haVH4SDCBkUt0Lf/dxd7s=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.4/go.mod h1:Ru7vg1iQ7cR4i7SZ/JTLYN9kaXtbL69UdgG0OQWQxW0=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.2 h1:1oY1AVEisRI4HNuFoLdRUB0hC63ylDAN6Me3MrfclEg=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.2/go.mod h1:KZ03VgvZwSjkT7fOetQ/wF3MZUvYFirlI1H5NklUNsY=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.1 h1:cy+x/R8zd4Zluf+6ZWzbPPdLh+l4MeYPlYxdqK+Qr0M=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.1/go.mod h1:ITcPsa7HwiY6ddwbwmtuf+/q7sfr4MjH5HzUvn5FHBQ=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.51.3 h1:7cR4xxS480TI0R6Bd75g9Npdw89VriquvQPlMNmuds4=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.51.3/go.mod h1:zb72GZ2MvfCX5ynVJ+Mc/NCx7hncbsko4NZm5E+p6J4=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.1 h1:DtKw4TxZT3VrzYupXQJPBqT9ImyobZZE+JIQPPAVxqs=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.1/go.mod h1:bit9G2ORpSjUTr4PA4usvbBfbOyvMj0LbE1dXF14Sug=
-github.com/aws/aws-sdk-go-v2/service/sso v1.20.1 h1:utEGkfdQ4L6YW/ietH7111ZYglLJvS+sLriHJ1NBJEQ=
-github.com/aws/aws-sdk-go-v2/service/sso v1.20.1/go.mod h1:RsYqzYr2F2oPDdpy+PdhephuZxTfjHQe7SOBcZGoAU8=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1 h1:9/GylMS45hGGFCcMrUZDVayQE1jYSIN6da9jo7RAYIw=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1/go.mod h1:YjAPFn4kGFqKC54VsHs5fn5B6d+PCY2tziEa3U/GB5Y=
-github.com/aws/aws-sdk-go-v2/service/sts v1.28.3 h1:TkiFkSVX990ryWIMBCT4kPqZEgThQe1xPU/AQXavtvU=
-github.com/aws/aws-sdk-go-v2/service/sts v1.28.3/go.mod h1:xYNauIUqSuvzlPVb3VB5no/n48YGhmlInD3Uh0Co8Zc=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.5 h1:mbWNpfRUTT6bnacmvOTKXZjR/HycibdWzNpfbrbLDIs=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.5/go.mod h1:FCOPWGjsshkkICJIn9hq9xr6dLKtyaWpuUojiN3W1/8=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.5 h1:K/NXvIftOlX+oGgWGIa3jDyYLDNsdVhsjHmsBH2GLAQ=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.5/go.mod h1:cl9HGLV66EnCmMNzq4sYOti+/xo8w34CsgzVtm2GgsY=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.3 h1:4t+QEX7BsXz98W8W1lNvMAG+NX8qHz2CjLBxQKku40g=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.3/go.mod h1:oFcjjUq5Hm09N9rpxTdeMeLeQcxS7mIkBkL8qUKng+A=
+github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.2 h1:SxqH8OxVZ804yweHp2bNERqys7om9cmjxowlI/XOnS8=
+github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.2/go.mod h1:Djusz31OLPJV5vma0ujfKxgsl8gBezwLZh5LA5044fk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.51.4 h1:lW5xUzOPGAMY7HPuNF4FdyBwRc3UJ/e8KsapbesVeNU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.51.4/go.mod h1:MGTaf3x/+z7ZGugCGvepnx2DS6+caCYYqKhzVoLNYPk=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.2 h1:WrqqLhD5St2cbXsvR0yuY43pdhXsUL0yjQepBJIpTvI=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.2/go.mod h1:GvNHKQAAOSKjmlccE/+Ww2gDbwYP9EewIuvWiQSquQs=
+github.com/aws/aws-sdk-go-v2/service/sso v1.20.2 h1:XOPfar83RIRPEzfihnp+U6udOveKZJvPQ76SKWrLRHc=
+github.com/aws/aws-sdk-go-v2/service/sso v1.20.2/go.mod h1:Vv9Xyk1KMHXrR3vNQe8W5LMFdTjSeWk0gBZBzvf3Qa0=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.2 h1:pi0Skl6mNl2w8qWZXcdOyg197Zsf4G97U7Sso9JXGZE=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.2/go.mod h1:JYzLoEVeLXk+L4tn1+rrkfhkxl6mLDEVaDSvGq9og90=
+github.com/aws/aws-sdk-go-v2/service/sts v1.28.4 h1:Ppup1nVNAOWbBOrcoOxaxPeEnSFB2RnnQdguhXpmeQk=
+github.com/aws/aws-sdk-go-v2/service/sts v1.28.4/go.mod h1:+K1rNPVyGxkRuv9NNiaZ4YhBFuyw2MMA9SlIJ1Zlpz8=
 github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw=
 github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -128,24 +130,26 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
-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.5.0 h1:Gz9wSamEaA6lta4IU2cjJc2xSq5sV5VYSB5w/SUHhVc=
+github.com/fclairamb/go-log v0.5.0/go.mod h1:XoRO1dYezpsGmLLkZE9I+sHqpqY65p8JA+Vqblb7k40=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/go-acme/lego/v4 v4.15.0 h1:A7MHEU3b+TDFqhC/HmzMJnzPbyeaYvMZQBbqgvbThhU=
-github.com/go-acme/lego/v4 v4.15.0/go.mod h1:eeGhjW4zWT7Ccqa3sY7ayEqFLCAICx+mXgkMHKIkLxg=
+github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ=
+github.com/go-acme/lego/v4 v4.16.1/go.mod h1:AVvwdPned/IWpD/ihHhMsKnveF7HHYAz/CmtXi7OZoE=
 github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
 github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
 github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
 github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
 github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
-github.com/go-jose/go-jose/v3 v3.0.2 h1:2Edjn8Nrb44UvTdp84KU0bBPs1cO7noRCybtS3eJEUQ=
-github.com/go-jose/go-jose/v3 v3.0.2/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
+github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
+github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
+github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
+github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
 github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
 github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
 github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
@@ -158,8 +162,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
 github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
 github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
-github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
+github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -238,8 +242,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
 github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
-github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
+github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
 github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
 github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
@@ -522,8 +526,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY=
-google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
+google.golang.org/api v0.169.0 h1:QwWPy71FgMWqJN/l6jVlFHUa29a7dcUy02I8o799nPY=
+google.golang.org/api v0.169.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
@@ -531,12 +535,12 @@ google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJ
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ=
-google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
-google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ=
-google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
+google.golang.org/genproto v0.0.0-20240311132316-a219d84964c2 h1:rrOOzm+NteCjTNqCnDAdYhvKL1G/9N/Lj1GRxJtQEL0=
+google.golang.org/genproto v0.0.0-20240311132316-a219d84964c2/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
+google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ=
+google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 h1:9IZDv+/GcI6u+a4jRFRLxQs0RUCfavGfoOgEW6jpkI0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=

+ 2 - 1
internal/dataprovider/session.go

@@ -29,6 +29,7 @@ const (
 	SessionTypeResetCode
 	SessionTypeOAuth2Auth
 	SessionTypeInvalidToken
+	SessionTypeWebTask
 )
 
 // Session defines a shared session persisted in the data provider
@@ -43,7 +44,7 @@ func (s *Session) validate() error {
 	if s.Key == "" {
 		return errors.New("unable to save a session with an empty key")
 	}
-	if s.Type < SessionTypeOIDCAuth || s.Type > SessionTypeInvalidToken {
+	if s.Type < SessionTypeOIDCAuth || s.Type > SessionTypeWebTask {
 		return fmt.Errorf("invalid session type: %v", s.Type)
 	}
 	return nil

+ 3 - 0
internal/dataprovider/sqlcommon.go

@@ -3247,6 +3247,9 @@ func sqlCommonGetSession(key string, dbHandle sqlQuerier) (Session, error) {
 	var data []byte // type hint, some driver will use string instead of []byte if the type is any
 	err := dbHandle.QueryRowContext(ctx, q, key).Scan(&session.Key, &data, &session.Type, &session.Timestamp)
 	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return session, util.NewRecordNotFoundError(err.Error())
+		}
 		return session, err
 	}
 	session.Data = data

+ 5 - 0
internal/httpd/httpd.go

@@ -177,6 +177,7 @@ const (
 	webClientViewPDFPathDefault           = "/web/client/viewpdf"
 	webClientGetPDFPathDefault            = "/web/client/getpdf"
 	webClientExistPathDefault             = "/web/client/exist"
+	webClientTasksPathDefault             = "/web/client/tasks"
 	webStaticFilesPathDefault             = "/static"
 	webOpenAPIPathDefault                 = "/openapi"
 	// MaxRestoreSize defines the max size for the loaddata input file
@@ -278,6 +279,7 @@ var (
 	webClientViewPDFPath           string
 	webClientGetPDFPath            string
 	webClientExistPath             string
+	webClientTasksPath             string
 	webStaticFilesPath             string
 	webOpenAPIPath                 string
 	// max upload size for http clients, 1GB by default
@@ -936,6 +938,7 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
 	resetCodesMgr = newResetCodeManager(isShared)
 	oidcMgr = newOIDCManager(isShared)
 	oauth2Mgr = newOAuth2Manager(isShared)
+	webTaskMgr = newWebTaskManager(isShared)
 	staticFilesPath := util.FindSharedDataPath(c.StaticFilesPath, configDir)
 	templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
 	openAPIPath := util.FindSharedDataPath(c.OpenAPIPath, configDir)
@@ -1108,6 +1111,7 @@ func updateWebClientURLs(baseURL string) {
 	webClientViewPDFPath = path.Join(baseURL, webClientViewPDFPathDefault)
 	webClientGetPDFPath = path.Join(baseURL, webClientGetPDFPathDefault)
 	webClientExistPath = path.Join(baseURL, webClientExistPathDefault)
+	webClientTasksPath = path.Join(baseURL, webClientTasksPathDefault)
 	webStaticFilesPath = path.Join(baseURL, webStaticFilesPathDefault)
 	webOpenAPIPath = path.Join(baseURL, webOpenAPIPathDefault)
 }
@@ -1196,6 +1200,7 @@ func startCleanupTicker(duration time.Duration) {
 				counter++
 				invalidatedJWTTokens.Cleanup()
 				resetCodesMgr.Cleanup()
+				webTaskMgr.Cleanup()
 				if counter%2 == 0 {
 					oidcMgr.cleanup()
 					oauth2Mgr.cleanup()

+ 264 - 0
internal/httpd/httpd_test.go

@@ -193,6 +193,9 @@ const (
 	webClientViewPDFPath           = "/web/client/viewpdf"
 	webClientGetPDFPath            = "/web/client/getpdf"
 	webClientExistPath             = "/web/client/exist"
+	webClientTasksPath             = "/web/client/tasks"
+	webClientFileMovePath          = "/web/client/file-actions/move"
+	webClientFileCopyPath          = "/web/client/file-actions/copy"
 	jsonAPISuffix                  = "/json"
 	httpBaseURL                    = "http://127.0.0.1:8081"
 	defaultRemoteAddr              = "127.0.0.1:1234"
@@ -16505,6 +16508,28 @@ func TestRenameDifferentResource(t *testing.T) {
 	testFileName := "file.txt"
 
 	webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+
+	getStatusResponse := func(taskID string) int {
+		req, _ := http.NewRequest(http.MethodGet, webClientTasksPath+"/"+url.PathEscape(taskID), nil)
+		req.RemoteAddr = defaultRemoteAddr
+		req.Header.Set("X-CSRF-TOKEN", csrfToken)
+		setJWTCookieForReq(req, webToken)
+		rr := executeRequest(req)
+		if rr.Code != http.StatusOK {
+			return -1
+		}
+		resp := make(map[string]any)
+		err = json.Unmarshal(rr.Body.Bytes(), &resp)
+		if err != nil {
+			return -1
+		}
+		return int(resp["status"].(float64))
+	}
+
 	assert.NoError(t, err)
 
 	req, err := http.NewRequest(http.MethodPost, userFileActionsPath+"/move?path="+testFileName+"&target="+url.QueryEscape(path.Join("/", "folderPath", testFileName)), nil) //nolint:goconst
@@ -16514,6 +16539,24 @@ func TestRenameDifferentResource(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Cannot perform copy step")
 
+	req, err = http.NewRequest(http.MethodPost, webClientFileMovePath+"?path="+testFileName+"&target="+url.QueryEscape(path.Join("/", "folderPath", testFileName)), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	taskResp := make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &taskResp)
+	assert.NoError(t, err)
+	taskID := taskResp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusNotFound
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
 	err = os.WriteFile(filepath.Join(user.GetHomeDir(), testFileName), []byte("just a test"), os.ModePerm)
 	assert.NoError(t, err)
 
@@ -16557,6 +16600,24 @@ func TestRenameDifferentResource(t *testing.T) {
 	checkResponseCode(t, http.StatusForbidden, rr)
 	assert.Contains(t, rr.Body.String(), "Cannot perform remove step")
 
+	req, err = http.NewRequest(http.MethodPost, webClientFileMovePath+"?path="+testFileName+"&target="+url.QueryEscape(path.Join("/", "folderPath", testFileName)), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	taskResp = make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &taskResp)
+	assert.NoError(t, err)
+	taskID = taskResp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusForbidden
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
@@ -17185,6 +17246,209 @@ func TestBufferedWebFilesAPI(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestWebClientTasksAPI(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	u1 := getTestUser()
+	u1.Username = xid.New().String()
+	user1, _, err := httpdtest.AddUser(u1, http.StatusCreated)
+	assert.NoError(t, err)
+
+	testDir := "subdir"
+	testFileData := []byte("data")
+	testFilePath := filepath.Join(user.GetHomeDir(), testDir, "file.txt")
+	testFileName := filepath.Base(testFilePath)
+	err = os.MkdirAll(filepath.Dir(testFilePath), os.ModePerm)
+	assert.NoError(t, err)
+	err = os.WriteFile(testFilePath, testFileData, 0666)
+	assert.NoError(t, err)
+
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+	assert.NoError(t, err)
+	webToken1, err := getJWTWebClientTokenFromTestServer(user1.Username, defaultPassword)
+	assert.NoError(t, err)
+
+	getStatusResponse := func(taskID string) int {
+		req, _ := http.NewRequest(http.MethodGet, webClientTasksPath+"/"+url.PathEscape(taskID), nil)
+		req.RemoteAddr = defaultRemoteAddr
+		req.Header.Set("X-CSRF-TOKEN", csrfToken)
+		setJWTCookieForReq(req, webToken)
+		rr := executeRequest(req)
+		if rr.Code != http.StatusOK {
+			return -1
+		}
+		resp := make(map[string]any)
+		err = json.Unmarshal(rr.Body.Bytes(), &resp)
+		if err != nil {
+			return -1
+		}
+		return int(resp["status"].(float64))
+	}
+	// missing task
+	assert.Equal(t, -1, getStatusResponse("missing"))
+
+	req, err := http.NewRequest(http.MethodPost, webClientFileCopyPath+"?path="+
+		url.QueryEscape(path.Join(testDir, testFileName))+"&target="+url.QueryEscape(testFileName), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	resp := make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &resp)
+	assert.NoError(t, err)
+	taskID := resp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusOK
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
+	// cannot get the task with a different user
+	req, err = http.NewRequest(http.MethodGet, webClientTasksPath+"/"+url.PathEscape(taskID), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken1)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	req, err = http.NewRequest(http.MethodPost, webClientFileMovePath+"?path="+
+		url.QueryEscape(path.Join(testDir, testFileName))+"&target="+url.QueryEscape(testFileName), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	resp = make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &resp)
+	assert.NoError(t, err)
+	taskID = resp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusOK
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
+	req, err = http.NewRequest(http.MethodDelete, webClientDirsPath+"?path="+
+		url.QueryEscape(testDir), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	resp = make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &resp)
+	assert.NoError(t, err)
+	taskID = resp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusOK
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
+	req, err = http.NewRequest(http.MethodDelete, webClientDirsPath+"?path="+
+		url.QueryEscape(testDir), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	resp = make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &resp)
+	assert.NoError(t, err)
+	taskID = resp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusNotFound
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
+	req, err = http.NewRequest(http.MethodPost, webClientFileMovePath+"?path="+
+		url.QueryEscape(path.Join(testDir, testFileName))+"&target="+url.QueryEscape(testFileName), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	resp = make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &resp)
+	assert.NoError(t, err)
+	taskID = resp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusNotFound
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
+	req, err = http.NewRequest(http.MethodPost, webClientFileCopyPath+"?path="+
+		url.QueryEscape(path.Join(testDir, testFileName)+"/")+"&target="+url.QueryEscape(testFileName+"/"), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusAccepted, rr)
+	resp = make(map[string]any)
+	err = json.Unmarshal(rr.Body.Bytes(), &resp)
+	assert.NoError(t, err)
+	taskID = resp["message"].(string)
+	assert.NotEmpty(t, taskID)
+
+	assert.Eventually(t, func() bool {
+		status := getStatusResponse(taskID)
+		return status == http.StatusNotFound
+	}, 1000*time.Millisecond, 100*time.Millisecond)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user1, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user1.GetHomeDir())
+	assert.NoError(t, err)
+	// user deleted
+	req, err = http.NewRequest(http.MethodDelete, webClientDirsPath+"?path="+
+		url.QueryEscape(testDir), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodPost, webClientFileMovePath+"?path="+
+		url.QueryEscape(path.Join(testDir, testFileName))+"&target="+url.QueryEscape(testFileName), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodPost, webClientFileCopyPath+"?path="+
+		url.QueryEscape(path.Join(testDir, testFileName))+"&target="+url.QueryEscape(testFileName), nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+}
+
 func TestStartDirectory(t *testing.T) {
 	u := getTestUser()
 	u.Filters.StartDirectory = "/start/dir"

+ 8 - 3
internal/httpd/internal_test.go

@@ -19,7 +19,6 @@ import (
 	"context"
 	"crypto/tls"
 	"crypto/x509"
-	"database/sql"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -286,6 +285,7 @@ RKjnkiEZeG4+G91Xu7+HmcBLwV86k5I+tXK9O1Okomr6Zry8oqVcxU5TB6VRS+rA
 ubwF00Drdvk2+kDZfxIM137nBiy7wgCJi2Ksm5ihN3dUF6Q0oNPl
 -----END RSA PRIVATE KEY-----`
 	defaultAdminUsername = "admin"
+	defeaultUsername     = "test_user"
 )
 
 var (
@@ -521,6 +521,11 @@ func TestInvalidToken(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
+	rr = httptest.NewRecorder()
+	getWebTask(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
 	rr = httptest.NewRecorder()
 	getAdminProfile(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -3167,7 +3172,7 @@ func TestDbResetCodeManager(t *testing.T) {
 		assert.ErrorIs(t, err, util.ErrNotFound)
 	}
 	_, err = mgr.Get(resetCode.Code)
-	assert.ErrorIs(t, err, sql.ErrNoRows)
+	assert.ErrorIs(t, err, util.ErrNotFound)
 	// add an expired reset code
 	resetCode = newResetCode("user", false)
 	resetCode.ExpiresAt = time.Now().Add(-24 * time.Hour)
@@ -3179,7 +3184,7 @@ func TestDbResetCodeManager(t *testing.T) {
 	}
 	mgr.Cleanup()
 	_, err = mgr.Get(resetCode.Code)
-	assert.ErrorIs(t, err, sql.ErrNoRows)
+	assert.ErrorIs(t, err, util.ErrNotFound)
 
 	dbMgr, ok := mgr.(*dbResetCodeManager)
 	if assert.True(t, ok) {

+ 5 - 3
internal/httpd/server.go

@@ -1583,6 +1583,8 @@ func (s *httpdServer) setupWebClientRoutes() {
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientViewPDFPath, s.handleClientViewPDF)
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientGetPDFPath, s.handleClientGetPDF)
 			router.With(s.checkAuthRequirements, s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
+			router.With(s.checkAuthRequirements, s.refreshCookie, verifyCSRFHeader).Get(webClientTasksPath+"/{id}",
+				getWebTask)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientFilePath, uploadUserFile)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
@@ -1595,11 +1597,11 @@ func (s *httpdServer) setupWebClientRoutes() {
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientDirsPath, createUserDir)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
-				Delete(webClientDirsPath, deleteUserDir)
+				Delete(webClientDirsPath, taskDeleteDir)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
-				Post(webClientFileActionsPath+"/move", renameUserFsEntry)
+				Post(webClientFileActionsPath+"/move", taskRenameFsEntry)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
-				Post(webClientFileActionsPath+"/copy", copyUserFsEntry)
+				Post(webClientFileActionsPath+"/copy", taskCopyFsEntry)
 			router.With(s.checkAuthRequirements, s.refreshCookie).
 				Post(webClientDownloadZipPath, s.handleWebClientDownloadZip)
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientPingPath, handlePingRequest)

+ 204 - 0
internal/httpd/webclient.go

@@ -129,6 +129,7 @@ type filesPage struct {
 	DownloadURL        string
 	ViewPDFURL         string
 	FileURL            string
+	TasksURL           string
 	CanAddFiles        bool
 	CanCreateDirs      bool
 	CanRename          bool
@@ -750,6 +751,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
 		FileURL:            "",
 		FileActionsURL:     "",
 		CheckExistURL:      path.Join(baseSharePath, "browse", "exist"),
+		TasksURL:           "",
 		CanAddFiles:        share.Scope == dataprovider.ShareScopeReadWrite,
 		CanCreateDirs:      false,
 		CanRename:          false,
@@ -793,6 +795,7 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di
 		FileURL:            webClientFilePath,
 		FileActionsURL:     webClientFileActionsPath,
 		CheckExistURL:      webClientExistPath,
+		TasksURL:           webClientTasksPath,
 		CanAddFiles:        user.CanAddFilesFromWeb(dirName),
 		CanCreateDirs:      user.CanAddDirsFromWeb(dirName),
 		CanRename:          user.CanRenameFromWeb(dirName, dirName),
@@ -2015,3 +2018,204 @@ func checkShareRedirectURL(next, base string) (bool, string) {
 	}
 	return true, next
 }
+
+func getWebTask(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	taskID := getURLParam(r, "id")
+
+	task, err := webTaskMgr.Get(taskID)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to get task", getMappedStatusCode(err))
+		return
+	}
+	if task.User != claims.Username {
+		sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+	render.JSON(w, r, task)
+}
+
+func taskDeleteDir(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	task := webTaskData{
+		ID:        connection.GetID(),
+		User:      connection.GetUsername(),
+		Path:      name,
+		Timestamp: util.GetTimeAsMsSinceEpoch(time.Now()),
+		Status:    0,
+	}
+	if err := webTaskMgr.Add(task); err != nil {
+		common.Connections.Remove(connection.GetID())
+		sendAPIResponse(w, r, nil, "Unable to create task", http.StatusInternalServerError)
+		return
+	}
+	go executeDeleteTask(connection, task)
+	sendAPIResponse(w, r, nil, task.ID, http.StatusAccepted)
+}
+
+func taskRenameFsEntry(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+	oldName := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+	newName := connection.User.GetCleanedPath(r.URL.Query().Get("target"))
+	task := webTaskData{
+		ID:        connection.GetID(),
+		User:      connection.GetUsername(),
+		Path:      oldName,
+		Target:    newName,
+		Timestamp: util.GetTimeAsMsSinceEpoch(time.Now()),
+		Status:    0,
+	}
+	if err := webTaskMgr.Add(task); err != nil {
+		common.Connections.Remove(connection.GetID())
+		sendAPIResponse(w, r, nil, "Unable to create task", http.StatusInternalServerError)
+		return
+	}
+	go executeRenameTask(connection, task)
+	sendAPIResponse(w, r, nil, task.ID, http.StatusAccepted)
+}
+
+func taskCopyFsEntry(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+	source := r.URL.Query().Get("path")
+	target := r.URL.Query().Get("target")
+	copyFromSource := strings.HasSuffix(source, "/")
+	copyInTarget := strings.HasSuffix(target, "/")
+	source = connection.User.GetCleanedPath(source)
+	target = connection.User.GetCleanedPath(target)
+	if copyFromSource {
+		source += "/"
+	}
+	if copyInTarget {
+		target += "/"
+	}
+	task := webTaskData{
+		ID:        connection.GetID(),
+		User:      connection.GetUsername(),
+		Path:      source,
+		Target:    target,
+		Timestamp: util.GetTimeAsMsSinceEpoch(time.Now()),
+		Status:    0,
+	}
+	if err := webTaskMgr.Add(task); err != nil {
+		common.Connections.Remove(connection.GetID())
+		sendAPIResponse(w, r, nil, "Unable to create task", http.StatusInternalServerError)
+		return
+	}
+	go executeCopyTask(connection, task)
+	sendAPIResponse(w, r, nil, task.ID, http.StatusAccepted)
+}
+
+func executeDeleteTask(conn *Connection, task webTaskData) {
+	done := make(chan bool)
+
+	defer func() {
+		close(done)
+		common.Connections.Remove(conn.GetID())
+	}()
+
+	go keepAliveTask(task, done, 2*time.Minute)
+
+	status := http.StatusOK
+	if err := conn.RemoveAll(task.Path); err != nil {
+		status = getMappedStatusCode(err)
+	}
+
+	task.Timestamp = util.GetTimeAsMsSinceEpoch(time.Now())
+	task.Status = status
+	err := webTaskMgr.Add(task)
+	conn.Log(logger.LevelDebug, "delete task finished, status: %d, update task err: %v", status, err)
+}
+
+func executeRenameTask(conn *Connection, task webTaskData) {
+	done := make(chan bool)
+
+	defer func() {
+		close(done)
+		common.Connections.Remove(conn.GetID())
+	}()
+
+	go keepAliveTask(task, done, 2*time.Minute)
+
+	status := http.StatusOK
+
+	if !conn.IsSameResource(task.Path, task.Target) {
+		if err := conn.Copy(task.Path, task.Target); err != nil {
+			status = getMappedStatusCode(err)
+			task.Timestamp = util.GetTimeAsMsSinceEpoch(time.Now())
+			task.Status = status
+			err = webTaskMgr.Add(task)
+			conn.Log(logger.LevelDebug, "copy step for rename task finished, status: %d, update task err: %v", status, err)
+			return
+		}
+		if err := conn.RemoveAll(task.Path); err != nil {
+			status = getMappedStatusCode(err)
+		}
+	} else {
+		if err := conn.Rename(task.Path, task.Target); err != nil {
+			status = getMappedStatusCode(err)
+		}
+	}
+
+	task.Timestamp = util.GetTimeAsMsSinceEpoch(time.Now())
+	task.Status = status
+	err := webTaskMgr.Add(task)
+	conn.Log(logger.LevelDebug, "rename task finished, status: %d, update task err: %v", status, err)
+}
+
+func executeCopyTask(conn *Connection, task webTaskData) {
+	done := make(chan bool)
+
+	defer func() {
+		close(done)
+		common.Connections.Remove(conn.GetID())
+	}()
+
+	go keepAliveTask(task, done, 2*time.Minute)
+
+	status := http.StatusOK
+	if err := conn.Copy(task.Path, task.Target); err != nil {
+		status = getMappedStatusCode(err)
+	}
+
+	task.Timestamp = util.GetTimeAsMsSinceEpoch(time.Now())
+	task.Status = status
+	err := webTaskMgr.Add(task)
+	conn.Log(logger.LevelDebug, "copy task finished, status: %d, update task err: %v", status, err)
+}
+
+func keepAliveTask(task webTaskData, done chan bool, interval time.Duration) {
+	ticker := time.NewTicker(interval)
+	defer func() {
+		ticker.Stop()
+	}()
+
+	for {
+		select {
+		case <-done:
+			return
+		case <-ticker.C:
+			task.Timestamp = util.GetTimeAsMsSinceEpoch(time.Now())
+			err := webTaskMgr.Add(task)
+			logger.Debug(logSender, task.ID, "task timestamp updated, err: %v", err)
+		}
+	}
+}

+ 108 - 0
internal/httpd/webtask.go

@@ -0,0 +1,108 @@
+// Copyright (C) 2024 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package httpd
+
+import (
+	"encoding/json"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+	"github.com/drakkan/sftpgo/v2/internal/logger"
+	"github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+var (
+	webTaskMgr webTaskManager
+)
+
+func newWebTaskManager(isShared int) webTaskManager {
+	if isShared == 1 {
+		logger.Info(logSender, "", "using provider task manager")
+		return &dbTaskManager{}
+	}
+	logger.Info(logSender, "", "using memory task manager")
+	return &memoryTaskManager{}
+}
+
+type webTaskManager interface {
+	Add(data webTaskData) error
+	Get(ID string) (webTaskData, error)
+	Cleanup()
+}
+
+type webTaskData struct {
+	ID        string `json:"id"`
+	User      string `json:"user"`
+	Path      string `json:"path"`
+	Target    string `json:"target"`
+	Timestamp int64  `json:"ts"`
+	Status    int    `json:"status"` // 0 in progress or http status code (200 ok, 403 and so on)
+}
+
+type memoryTaskManager struct {
+	tasks sync.Map
+}
+
+func (m *memoryTaskManager) Add(data webTaskData) error {
+	m.tasks.Store(data.ID, &data)
+	return nil
+}
+
+func (m *memoryTaskManager) Get(ID string) (webTaskData, error) {
+	data, ok := m.tasks.Load(ID)
+	if !ok {
+		return webTaskData{}, util.NewRecordNotFoundError(fmt.Sprintf("task for ID %q not found", ID))
+	}
+	return *data.(*webTaskData), nil
+}
+
+func (m *memoryTaskManager) Cleanup() {
+	m.tasks.Range(func(key, value any) bool {
+		data := value.(*webTaskData)
+		if data.Timestamp < util.GetTimeAsMsSinceEpoch(time.Now().Add(-5*time.Minute)) {
+			m.tasks.Delete(key)
+		}
+		return true
+	})
+}
+
+type dbTaskManager struct{}
+
+func (m *dbTaskManager) Add(data webTaskData) error {
+	session := dataprovider.Session{
+		Key:       data.ID,
+		Data:      data,
+		Type:      dataprovider.SessionTypeWebTask,
+		Timestamp: data.Timestamp,
+	}
+	return dataprovider.AddSharedSession(session)
+}
+
+func (m *dbTaskManager) Get(ID string) (webTaskData, error) {
+	sess, err := dataprovider.GetSharedSession(ID)
+	if err != nil {
+		return webTaskData{}, err
+	}
+	d := sess.Data.([]byte)
+	var data webTaskData
+	err = json.Unmarshal(d, &data)
+	return data, err
+}
+
+func (m *dbTaskManager) Cleanup() {
+	dataprovider.CleanupSharedSessions(dataprovider.SessionTypeWebTask, time.Now().Add(-5*time.Minute)) //nolint:errcheck
+}

+ 133 - 0
internal/httpd/webtask_test.go

@@ -0,0 +1,133 @@
+// Copyright (C) 2024 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package httpd
+
+import (
+	"testing"
+	"time"
+
+	"github.com/rs/xid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+func TestMemoryWebTaskManager(t *testing.T) {
+	mgr := newWebTaskManager(0)
+	m, ok := mgr.(*memoryTaskManager)
+	require.True(t, ok)
+	task := webTaskData{
+		ID:        xid.New().String(),
+		User:      defeaultUsername,
+		Timestamp: time.Now().Add(-1 * time.Hour).UnixMilli(),
+		Status:    0,
+	}
+	task1 := webTaskData{
+		ID:        xid.New().String(),
+		User:      defeaultUsername,
+		Timestamp: time.Now().UnixMilli(),
+		Status:    0,
+	}
+	err := m.Add(task)
+	require.NoError(t, err)
+	err = m.Add(task1)
+	require.NoError(t, err)
+	taskGet, err := m.Get(task.ID)
+	require.NoError(t, err)
+	require.Equal(t, task, taskGet)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.ErrorIs(t, err, util.ErrNotFound)
+	taskGet, err = m.Get(task1.ID)
+	require.NoError(t, err)
+	require.Equal(t, task1, taskGet)
+	task1.Timestamp = time.Now().Add(-1 * time.Hour).UnixMilli()
+	err = m.Add(task1)
+	require.NoError(t, err)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.ErrorIs(t, err, util.ErrNotFound)
+	// test keep alive task
+	oldMgr := webTaskMgr
+	webTaskMgr = mgr
+
+	done := make(chan bool)
+	go keepAliveTask(task, done, 50*time.Millisecond)
+
+	time.Sleep(120 * time.Millisecond)
+	close(done)
+	taskGet, err = m.Get(task.ID)
+	require.NoError(t, err)
+	assert.Greater(t, taskGet.Timestamp, task.Timestamp)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.NoError(t, err)
+	err = m.Add(task)
+	require.NoError(t, err)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.ErrorIs(t, err, util.ErrNotFound)
+
+	webTaskMgr = oldMgr
+}
+
+func TestDbWebTaskManager(t *testing.T) {
+	if !isSharedProviderSupported() {
+		t.Skip("this test it is not available with this provider")
+	}
+	mgr := newWebTaskManager(1)
+	m, ok := mgr.(*dbTaskManager)
+	require.True(t, ok)
+
+	task := webTaskData{
+		ID:        xid.New().String(),
+		User:      defeaultUsername,
+		Timestamp: time.Now().Add(-1 * time.Hour).UnixMilli(),
+		Status:    0,
+	}
+	err := m.Add(task)
+	require.NoError(t, err)
+	taskGet, err := m.Get(task.ID)
+	require.NoError(t, err)
+	require.Equal(t, task, taskGet)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.ErrorIs(t, err, util.ErrNotFound)
+	err = m.Add(task)
+	require.NoError(t, err)
+	// test keep alive task
+	oldMgr := webTaskMgr
+	webTaskMgr = mgr
+
+	done := make(chan bool)
+	go keepAliveTask(task, done, 50*time.Millisecond)
+
+	time.Sleep(120 * time.Millisecond)
+	close(done)
+	taskGet, err = m.Get(task.ID)
+	require.NoError(t, err)
+	assert.Greater(t, taskGet.Timestamp, task.Timestamp)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.NoError(t, err)
+	err = m.Add(task)
+	require.NoError(t, err)
+	m.Cleanup()
+	_, err = m.Get(task.ID)
+	require.ErrorIs(t, err, util.ErrNotFound)
+
+	webTaskMgr = oldMgr
+}

+ 299 - 155
templates/webclient/files.html

@@ -264,6 +264,62 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
         //{{- end}}
     }
 
+    var taskStatusWaiter = function () {
+        var promiseResolve;
+
+        function getTaskStatus(taskID, numErrors) {
+            axios.get('{{.TasksURL}}'+"/"+encodeURIComponent(taskID),{
+                timeout: 15000,
+                headers: {
+                    'X-CSRF-TOKEN': '{{.CSRFToken}}'
+                },
+                validateStatus: function (status) {
+                    return status == 200;
+                }
+            }).then(function(response){
+                let status = response.data.status;
+                if (status == 0){
+                    setTimeout(function() {
+                        getTaskStatus(taskID, numErrors);
+                    }, 2500);
+                } else {
+                    promiseResolve({
+                        status: status
+                    });
+                }
+            }).catch(function(error){
+                numErrors++;
+                if (numErrors >= 3){
+                    promiseResolve({
+                        status: 0
+                    });
+                    return;
+                }
+                if (error && error.response && error.response.status == 404){
+                    promiseResolve({
+                        status: 404
+                    });
+                    return;
+                }
+                setTimeout(function() {
+                    getTaskStatus(taskID, numErrors);
+                }, 2500);
+            });
+        }
+
+        return {
+            wait: function(params){
+                setTimeout(function() {
+                    getTaskStatus(params.taskID, 0);
+                }, params.delay);
+
+                return new Promise(function(resolve, reject) {
+                    promiseResolve = resolve;
+                });
+            }
+        }
+    }();
+
     //{{- if not .ShareUploadBaseURL}}
     var KTDatatablesFoldersExplorer = function () {
         var dt;
@@ -1048,6 +1104,35 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 
             const deleteButton = document.querySelector('[data-kt-filemanager-table-select="delete_selected"]');
             if (deleteButton) {
+
+                function getMultiDeleteErrorMessage(status, deleted) {
+                    let errorMessage;
+                    switch (status) {
+                        case 403:
+                            if (deleted > 0){
+                                errorMessage = "fs.delete_multi.err_403_partial";
+                            } else {
+                                errorMessage = "fs.delete_multi.err_403";
+                            }
+                            break;
+                        case 429:
+                        if (deleted > 0){
+                                errorMessage = "fs.delete_multi.err_429_partial";
+                            } else {
+                                errorMessage = "fs.delete_multi.err_429";
+                            }
+                            break;
+                    }
+                    if (!errorMessage){
+                        if (deleted > 0){
+                            errorMessage = "fs.delete_multi.err_generic_partial";
+                        } else {
+                            errorMessage = "fs.delete_multi.err_generic";
+                        }
+                    }
+                    return errorMessage;
+                }
+
                 let el = $(deleteButton);
                 el.off("click");
                 el.on('click', function(e){
@@ -1075,14 +1160,11 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                             if (selectedRowsIdx.length == 0){
                                 return;
                             }
-                            keepAlive();
-                            let keepAliveTimer = setInterval(keepAlive, 300000);
                             $('#loading_message').text("");
                             KTApp.showPageLoading();
 
                             function deleteSelected() {
                                 if (index >= selectedRowsIdx.length || hasError){
-                                    clearInterval(keepAliveTimer);
                                     KTApp.hidePageLoading();
                                     if (!hasError){
                                         location.reload();
@@ -1090,57 +1172,66 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                                     return;
                                 }
                                 let meta = dt.row(selectedRowsIdx[index]).data()['meta'];
-                                let attrs = getDeleteReqAttrs(meta);
+                                let itemName = getNameFromMeta(meta);
+                                let isDir = (getTypeFromMeta(meta) == "1");
+                                let path;
+                                if (isDir){
+                                    path = '{{.DirsURL}}';
+                                } else {
+                                    path = '{{.FilesURL}}';
+                                }
+                                path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName);
+
                                 let deleteTxt = "";
                                 if (selectedRowsIdx.length > 1){
                                     deleteTxt = $.t('fs.deleting', {
                                         idx : index + 1,
                                         total: selectedRowsIdx.length,
-                                        name: getNameFromMeta(meta)
+                                        name: itemName
                                     });
                                 }
                                 $('#loading_message').text(deleteTxt);
-                                axios.delete(attrs.path,{
-                                    timeout: attrs.reqTimeout,
+                                axios.delete(path,{
+                                    timeout: 15000,
                                     headers: {
                                         'X-CSRF-TOKEN': '{{.CSRFToken}}'
                                     },
                                     validateStatus: function (status) {
+                                        if (isDir){
+                                            return status == 202;
+                                        }
                                         return status == 200;
                                     }
                                 }).then(function(response){
-                                    index++;
-                                    deleted++;
-                                    deleteSelected();
+                                    if (isDir){
+                                        taskStatusWaiter.wait({
+                                            taskID: response.data.message,
+                                            delay: 500
+                                        }).then((result) => {
+                                            index++;
+                                            if (result.status == 200){
+                                                deleted++;
+                                            } else {
+                                                hasError = true;
+                                                let errorMessage = getMultiDeleteErrorMessage(result.status, deleted);
+                                                setI18NData(errTxtEl, errorMessage);
+                                                errDivEl.removeClass("d-none");
+                                            }
+                                            deleteSelected();
+                                        });
+                                    } else {
+                                        index++;
+                                        deleted++;
+                                        deleteSelected();
+                                    }
                                 }).catch(function(error){
                                     index++;
                                     hasError = true;
-                                    let errorMessage;
+                                    let status = 0;
                                     if (error && error.response) {
-                                        switch (error.response.status) {
-                                            case 403:
-                                                if (deleted > 0){
-                                                    errorMessage = "fs.delete_multi.err_403_partial";
-                                                } else {
-                                                    errorMessage = "fs.delete_multi.err_403";
-                                                }
-                                                break;
-                                            case 429:
-                                            if (deleted > 0){
-                                                    errorMessage = "fs.delete_multi.err_429_partial";
-                                                } else {
-                                                    errorMessage = "fs.delete_multi.err_429";
-                                                }
-                                                break;
-                                        }
-                                    }
-                                    if (!errorMessage){
-                                        if (deleted > 0){
-                                            errorMessage = "fs.delete_multi.err_generic_partial";
-                                        } else {
-                                            errorMessage = "fs.delete_multi.err_generic";
-                                        }
+                                        status = error.response.status;
                                     }
+                                    let errorMessage = getMultiDeleteErrorMessage(status, deleted);
                                     setI18NData(errTxtEl, errorMessage);
                                     errDivEl.removeClass("d-none");
                                     deleteSelected();
@@ -1274,8 +1365,30 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             });
             return;
         }
-        keepAlive();
-        let keepAliveTimer = setInterval(keepAlive, 300000);
+
+        function showCopyError(status) {
+            KTApp.hidePageLoading();
+            let errorMessage = "";
+            switch (status) {
+                case 403:
+                    errorMessage = "fs.copy.err_403";
+                    break;
+                case 429:
+                    errorMessage = "fs.copy.err_429";
+                    break;
+                default:
+                    errorMessage = "fs.copy.err_generic";
+            }
+            ModalAlert.fire({
+                text: $.t(errorMessage),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
+        }
+
         let hasError = false;
         let index = 0;
 
@@ -1284,7 +1397,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 
         function copyItem() {
             if (index >= items.length || hasError){
-                clearInterval(keepAliveTimer);
                 KTApp.hidePageLoading();
                 if (!hasError){
                     location.reload();
@@ -1316,41 +1428,33 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+item.sourceName)+'&target='+item.targetDir+encodeURIComponent("/"+item.targetName);
 
             axios.post(path, null, {
-                timeout: 180000,
+                timeout: 15000,
                 headers: {
                     'X-CSRF-TOKEN': '{{.CSRFToken}}'
                 },
                 validateStatus: function (status) {
-                    return status == 200;
+                    return status == 202;
                 }
             }).then(function (response) {
-                index++;
-                copyItem();
+                taskStatusWaiter.wait({
+                    taskID: response.data.message,
+                    delay: 500
+                }).then((result) => {
+                    index++;
+                    if (result.status != 200){
+                        hasError = true;
+                        showCopyError(result.status);
+                    }
+                    copyItem();
+                });
             }).catch(function (error) {
                 index++;
                 hasError = true;
-                let errorMessage = "";
+                let status = 0;
                 if (error && error.response) {
-                    switch (error.response.status) {
-                        case 403:
-                            errorMessage = "fs.copy.err_403";
-                            break;
-                        case 429:
-                            errorMessage = "fs.copy.err_429";
-                            break;
-                    }
-                }
-                if (!errorMessage){
-                    errorMessage = "fs.copy.err_generic";
+                    status = error.response.status;
                 }
-                ModalAlert.fire({
-                    text: $.t(errorMessage),
-                    icon: "warning",
-                    confirmButtonText: $.t('general.ok'),
-                    customClass: {
-                        confirmButton: "btn btn-primary"
-                    }
-                });
+                showCopyError(status);
                 copyItem();
             });
         }
@@ -1410,8 +1514,33 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             });
             return;
         }
-        keepAlive();
-        let keepAliveTimer = setInterval(keepAlive, 300000);
+
+        function showMoveError(status) {
+            KTApp.hidePageLoading();
+            let errorMessage = "";
+            switch (status) {
+                case 400:
+                    errorMessage = "fs.move.err_unsupported";
+                    break;
+                case 403:
+                    errorMessage = "fs.move.err_403";
+                    break;
+                case 429:
+                    errorMessage = "fs.move.err_429";
+                    break;
+                default:
+                    errorMessage = "fs.move.err_generic";
+            }
+            ModalAlert.fire({
+                text: $.t(errorMessage),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
+        }
+
         let hasError = false;
         let index = 0;
 
@@ -1420,7 +1549,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 
         function moveItem() {
             if (index >= items.length || hasError){
-                clearInterval(keepAliveTimer);
                 KTApp.hidePageLoading();
                 if (!hasError){
                     location.reload();
@@ -1452,44 +1580,33 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+item.sourceName)+'&target='+item.targetDir+encodeURIComponent("/"+item.targetName);
 
             axios.post(path, null, {
-                timeout: 180000,
+                timeout: 15000,
                 headers: {
                     'X-CSRF-TOKEN': '{{.CSRFToken}}'
                 },
                 validateStatus: function (status) {
-                    return status == 200;
+                    return status == 202;
                 }
             }).then(function (response) {
-                index++;
-                moveItem();
+                taskStatusWaiter.wait({
+                    taskID: response.data.message,
+                    delay: 500
+                }).then((result) => {
+                    index++;
+                    if (result.status != 200){
+                        hasError = true;
+                        showMoveError(result.status);
+                    }
+                    moveItem();
+                });
             }).catch(function (error) {
                 index++;
                 hasError = true;
-                let errorMessage = "";
+                let status = 0;
                 if (error && error.response) {
-                    switch (error.response.status) {
-                        case 400:
-                            errorMessage = "fs.move.err_unsupported";
-                            break;
-                        case 403:
-                            errorMessage = "fs.move.err_403";
-                            break;
-                        case 429:
-                            errorMessage = "fs.move.err_429";
-                            break;
-                    }
+                    status = error.response.status;
                 }
-                if (!errorMessage){
-                    errorMessage = "fs.move.err_generic";
-                }
-                ModalAlert.fire({
-                    text: $.t(errorMessage),
-                    icon: "warning",
-                    confirmButtonText: $.t('general.ok'),
-                    customClass: {
-                        confirmButton: "btn btn-primary"
-                    }
-                });
+                showMoveError(status);
                 moveItem();
             });
         }
@@ -1531,20 +1648,27 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
         });
     }
 
-    function getDeleteReqAttrs(meta) {
-        let path;
-        let reqTimeout = 15000;
-        let itemType = getTypeFromMeta(meta);
-        let itemName = getNameFromMeta(meta);
-        if (itemType == "1"){
-            path = '{{.DirsURL}}';
-            reqTimeout = 120000
-        } else {
-            path = '{{.FilesURL}}';
+    function showDeleteItemError(status, itemName) {
+        KTApp.hidePageLoading();
+        let errorMessage;
+        switch (status) {
+            case 403:
+                errorMessage = "fs.delete.err_403";
+                break;
+            case 429:
+                errorMessage = "fs.delete.err_429";
+                break;
+            default:
+                errorMessage = "fs.delete.err_generic";
         }
-        path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName);
-
-        return { path, reqTimeout}
+        ModalAlert.fire({
+            text: $.t(errorMessage, {name: itemName}),
+            icon: "warning",
+            confirmButtonText: $.t('general.ok'),
+            customClass: {
+                confirmButton: "btn btn-primary"
+            }
+        });
     }
 
     function deleteItem(meta) {
@@ -1564,42 +1688,47 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             if (result.isConfirmed){
                 $('#loading_message').text("");
                 KTApp.showPageLoading();
-                let attrs = getDeleteReqAttrs(meta);
+                let isDir = (getTypeFromMeta(meta) == "1");
+                let path;
+                if (isDir){
+                    path = '{{.DirsURL}}';
+                } else {
+                    path = '{{.FilesURL}}';
+                }
+                path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName);
 
-                axios.delete(attrs.path, {
-                    timeout: attrs.reqTimeout,
+                axios.delete(path, {
+                    timeout: 15000,
                     headers: {
                         'X-CSRF-TOKEN': '{{.CSRFToken}}'
                     },
                     validateStatus: function (status) {
+                        if (isDir){
+                            return status == 202;
+                        }
                         return status == 200;
                     }
                 }).then(function(response){
-                    location.reload();
+                    if (isDir){
+                        taskStatusWaiter.wait({
+                            taskID: response.data.message,
+                            delay: 500
+                        }).then((result) => {
+                            if (result.status == 200){
+                                location.reload();
+                            } else {
+                                showDeleteItemError(result.status, itemName);
+                            }
+                        });
+                    } else {
+                        location.reload();
+                    }
                 }).catch(function(error){
-                    KTApp.hidePageLoading();
-                    let errorMessage;
+                    let status = 0;
                     if (error && error.response) {
-                        switch (error.response.status) {
-                            case 403:
-                                errorMessage = "fs.delete.err_403";
-                                break;
-                            case 429:
-                                errorMessage = "fs.delete.err_429";
-                                break;
-                        }
+                        status = error.response.status;
                     }
-                    if (!errorMessage){
-                        errorMessage = "fs.delete.err_generic";
-                    }
-                    ModalAlert.fire({
-                        text: $.t(errorMessage, {name: itemName}),
-                        icon: "warning",
-                        confirmButtonText: $.t('general.ok'),
-                        customClass: {
-                            confirmButton: "btn btn-primary"
-                        }
-                    });
+                    showDeleteItemError(status, itemName);
                 });
             }
         });
@@ -1624,6 +1753,33 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
         $('#modal_rename').modal('show');
     }
 
+    function showRenameItemError(status, oldName) {
+        KTApp.hidePageLoading();
+        let errorMessage;
+        switch (status) {
+            case 400:
+                errorMessage = "fs.rename.err_unsupported";
+                break;
+            case 403:
+                errorMessage = "fs.rename.err_403";
+                break;
+            case 429:
+                errorMessage = "fs.rename.err_429";
+                break;
+            default:
+                errorMessage = "fs.rename.err_generic";
+        }
+
+        ModalAlert.fire({
+            text: $.t(errorMessage, {name: oldName}),
+            icon: "warning",
+            confirmButtonText: $.t('general.ok'),
+            customClass: {
+                confirmButton: "btn btn-primary"
+            }
+        });
+    }
+
     function doRename() {
         let meta = $('#rename_old_name').val();
         let oldName = getNameFromMeta(meta);
@@ -1674,37 +1830,25 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                     'X-CSRF-TOKEN': '{{.CSRFToken}}'
                 },
                 validateStatus: function (status) {
-                    return status == 200;
+                    return status == 202;
                 }
             }).then(function (response) {
-                location.reload();
+                taskStatusWaiter.wait({
+                    taskID: response.data.message,
+                    delay: 500
+                }).then((result) => {
+                    if (result.status == 200){
+                        location.reload();
+                    } else {
+                        showRenameItemError(result.status, oldName);
+                    }
+                });
             }).catch(function (error) {
-                KTApp.hidePageLoading();
-                let errorMessage;
+                let status = 0;
                 if (error && error.response) {
-                    switch (error.response.status) {
-                        case 400:
-                            errorMessage = "fs.rename.err_unsupported";
-                            break;
-                        case 403:
-                            errorMessage = "fs.rename.err_403";
-                            break;
-                        case 429:
-                            errorMessage = "fs.rename.err_429";
-                            break;
-                    }
+                    status = error.response.status;
                 }
-                if (!errorMessage) {
-                    errorMessage = "fs.rename.err_generic";
-                }
-                ModalAlert.fire({
-                        text: $.t(errorMessage, {name: oldName}),
-                        icon: "warning",
-                        confirmButtonText: $.t('general.ok'),
-                        customClass: {
-                            confirmButton: "btn btn-primary"
-                        }
-                    });
+                showRenameItemError(status, oldName);
             });
         }