From f38966c6ac38649005c98566860b245586dc076f Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Mon, 11 Mar 2024 18:19:57 +0100 Subject: [PATCH] WebClient: refactor long-running tasks to improve browser compatibility Signed-off-by: Nicola Murino --- go.mod | 58 ++-- go.sum | 108 +++---- internal/dataprovider/session.go | 3 +- internal/dataprovider/sqlcommon.go | 3 + internal/httpd/httpd.go | 5 + internal/httpd/httpd_test.go | 264 +++++++++++++++++ internal/httpd/internal_test.go | 11 +- internal/httpd/server.go | 8 +- internal/httpd/webclient.go | 204 +++++++++++++ internal/httpd/webtask.go | 108 +++++++ internal/httpd/webtask_test.go | 133 +++++++++ templates/webclient/files.html | 456 +++++++++++++++++++---------- 12 files changed, 1118 insertions(+), 243 deletions(-) create mode 100644 internal/httpd/webtask.go create mode 100644 internal/httpd/webtask_test.go diff --git a/go.mod b/go.mod index 86a1d46e..35d45f24 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 0221362d..402d4e64 100644 --- a/go.sum +++ b/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= diff --git a/internal/dataprovider/session.go b/internal/dataprovider/session.go index 5065ec8c..a40c42d8 100644 --- a/internal/dataprovider/session.go +++ b/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 diff --git a/internal/dataprovider/sqlcommon.go b/internal/dataprovider/sqlcommon.go index f3977f45..8daf1efc 100644 --- a/internal/dataprovider/sqlcommon.go +++ b/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 diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index c9f3e0ac..13a69428 100644 --- a/internal/httpd/httpd.go +++ b/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() diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index cbfa4966..d803c58f 100644 --- a/internal/httpd/httpd_test.go +++ b/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" diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 1dfb8975..b47b6c91 100644 --- a/internal/httpd/internal_test.go +++ b/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) { diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 52a1d7be..80cf2747 100644 --- a/internal/httpd/server.go +++ b/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) diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index c407e2b2..4699ecf1 100644 --- a/internal/httpd/webclient.go +++ b/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) + } + } +} diff --git a/internal/httpd/webtask.go b/internal/httpd/webtask.go new file mode 100644 index 00000000..8375c9c0 --- /dev/null +++ b/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 . + +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 +} diff --git a/internal/httpd/webtask_test.go b/internal/httpd/webtask_test.go new file mode 100644 index 00000000..f4e2b949 --- /dev/null +++ b/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 . + +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 +} diff --git a/templates/webclient/files.html b/templates/webclient/files.html index 3fc71f24..6fa538aa 100644 --- a/templates/webclient/files.html +++ b/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; - } + status = error.response.status; } - if (!errorMessage){ - errorMessage = "fs.copy.err_generic"; - } - 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(); - }).catch(function (error) { - KTApp.hidePageLoading(); - let errorMessage; - 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; + taskStatusWaiter.wait({ + taskID: response.data.message, + delay: 500 + }).then((result) => { + if (result.status == 200){ + location.reload(); + } else { + showRenameItemError(result.status, oldName); } + }); + }).catch(function (error) { + let status = 0; + if (error && error.response) { + 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); }); }