From 9382db751ca227cdd8e8ef7d01e62beb3642bba0 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 6 Feb 2022 16:46:43 +0100 Subject: [PATCH] make HTTP shares browsable if you share a single folder with read scope, you can now browse the share and download single files Fixes #674 See #677 Signed-off-by: Nicola Murino --- dataprovider/share.go | 21 ++ docker/README.md | 10 +- go.mod | 14 +- go.sum | 29 +-- httpd/api_http_user.go | 17 +- httpd/api_shares.go | 108 ++++++++- httpd/api_utils.go | 38 ++- httpd/httpd_test.go | 350 ++++++++++++++++++++++++++++ httpd/internal_test.go | 43 ++++ httpd/server.go | 4 + httpd/webclient.go | 169 ++++++++++++-- openapi/openapi.yaml | 97 +++++++- pkgs/build.sh | 2 +- pkgs/debian/changelog | 6 + templates/webclient/files.html | 2 +- templates/webclient/sharefiles.html | 296 +++++++++++++++++++++++ templates/webclient/shares.html | 5 +- version/version.go | 2 +- 18 files changed, 1138 insertions(+), 75 deletions(-) create mode 100644 templates/webclient/sharefiles.html diff --git a/dataprovider/share.go b/dataprovider/share.go index 5ad904fb..dc1b7bbc 100644 --- a/dataprovider/share.go +++ b/dataprovider/share.go @@ -197,6 +197,19 @@ func (s *Share) validatePaths() error { if s.Scope == ShareScopeWrite && len(s.Paths) != 1 { return util.NewValidationError("the write share scope requires exactly one path") } + // check nested paths + if len(s.Paths) > 1 { + for idx := range s.Paths { + for innerIdx := range s.Paths { + if idx == innerIdx { + continue + } + if isVirtualDirOverlapped(s.Paths[idx], s.Paths[innerIdx], true) { + return util.NewGenericError("shared paths cannot be nested") + } + } + } + } return nil } @@ -263,6 +276,14 @@ func (s *Share) CheckPassword(password string) (bool, error) { return match, err } +// GetRelativePath returns the specified absolute path as relative to the share base path +func (s *Share) GetRelativePath(name string) string { + if len(s.Paths) == 0 { + return "" + } + return util.CleanPath(strings.TrimPrefix(name, s.Paths[0])) +} + // IsUsable checks if the share is usable from the specified IP func (s *Share) IsUsable(ip string) (bool, error) { if s.MaxTokens > 0 && s.UsedTokens >= s.MaxTokens { diff --git a/docker/README.md b/docker/README.md index b57ce846..07794f9a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -4,11 +4,11 @@ SFTPGo provides an official Docker image, it is available on both [Docker Hub](h ## Supported tags and respective Dockerfile links -- [v2.2.1, v2.2, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile) -- [v2.2.1-alpine, v2.2-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile.alpine) -- [v2.2.1-slim, v2.2-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile) -- [v2.2.1-alpine-slim, v2.2-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile.alpine) -- [v2.2.1-distroless-slim, v2.2-distroless-slim, v2-distroless-slim, distroless-slim](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile.distroless) +- [v2.2.2, v2.2, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile) +- [v2.2.2-alpine, v2.2-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile.alpine) +- [v2.2.2-slim, v2.2-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile) +- [v2.2.2-alpine-slim, v2.2-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile.alpine) +- [v2.2.2-distroless-slim, v2.2-distroless-slim, v2-distroless-slim, distroless-slim](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile.distroless) - [edge](../Dockerfile) - [edge-alpine](../Dockerfile.alpine) - [edge-slim](../Dockerfile) diff --git a/go.mod b/go.mod index 192579f3..d5e91fb4 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/drakkan/sftpgo/v2 go 1.17 require ( - cloud.google.com/go/storage v1.19.0 + cloud.google.com/go/storage v1.20.0 github.com/Azure/azure-storage-blob-go v0.14.0 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 - github.com/aws/aws-sdk-go v1.42.45 + github.com/aws/aws-sdk-go v1.42.47 github.com/cockroachdb/cockroach-go/v2 v2.2.6 github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b github.com/fclairamb/ftpserverlib v0.17.0 @@ -41,7 +41,7 @@ require ( github.com/rs/zerolog v1.26.2-0.20220203140311-fc26014bd4e1 github.com/sftpgo/sdk v0.0.0-20220201111021-563c373f8012 github.com/shirou/gopsutil/v3 v3.22.1 - github.com/spf13/afero v1.8.0 + github.com/spf13/afero v1.8.1 github.com/spf13/cobra v1.3.0 github.com/spf13/viper v1.10.1 github.com/stretchr/testify v1.7.0 @@ -54,15 +54,15 @@ require ( gocloud.dev v0.24.0 golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd - golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 + golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 - google.golang.org/api v0.66.0 + google.golang.org/api v0.67.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) require ( cloud.google.com/go v0.100.2 // indirect - cloud.google.com/go/compute v1.1.0 // indirect + cloud.google.com/go/compute v1.2.0 // indirect cloud.google.com/go/iam v0.1.1 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -126,7 +126,7 @@ require ( golang.org/x/tools v0.1.9 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220202230416-2a053f022f0d // indirect + google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e // indirect google.golang.org/grpc v1.44.0 // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/ini.v1 v1.66.3 // indirect diff --git a/go.sum b/go.sum index bd64d654..d9161164 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= -cloud.google.com/go/compute v1.1.0 h1:pyPhehLfZ6pVzRgJmXGYvCY4K7WSWRhVw0AwhgVvS84= -cloud.google.com/go/compute v1.1.0/go.mod h1:2NIffxgWfORSI7EOYMFatGTfjMLnqrOKBEyYb6NoRgA= +cloud.google.com/go/compute v1.2.0 h1:EKki8sSdvDU0OO9mAXGwPXOTOgPz2l08R0/IutDH11I= +cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo= @@ -70,8 +70,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= cloud.google.com/go/storage v1.16.1/go.mod h1:LaNorbty3ehnU3rEjXSNV/NRgQA0O8Y+uh6bPe5UOk4= -cloud.google.com/go/storage v1.19.0 h1:XOQSnPJD8hRtZJ3VdCyK0mBZsGGImrzPAMbSWcHSe6Q= -cloud.google.com/go/storage v1.19.0/go.mod h1:6rgiTRjOqI/Zd9YKimub5TIB4d+p3LH33V3ZE1DMuUM= +cloud.google.com/go/storage v1.20.0 h1:kv3rQ3clEQdxqokkCCgQo+bxPqcuXiROjxvnKb8Oqdk= +cloud.google.com/go/storage v1.20.0/go.mod h1:TiC1o6FxNCG8y5gB7rqCsFZCIYPMPZCO81ppOoEPLGI= cloud.google.com/go/trace v0.1.0/go.mod h1:wxEwsoeRVPbeSkt7ZC9nWCgmoKQRAoySN7XHW2AmI7g= contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= contrib.go.opencensus.io/exporter/stackdriver v0.13.8/go.mod h1:huNtlWx75MwO7qMs0KrMxPZXzNNWebav1Sq/pm02JdQ= @@ -141,8 +141,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go v1.42.45 h1:rzYlmOX2EqdsYKvo0WBBffuff3BuckL1UB2KyzWhXyQ= -github.com/aws/aws-sdk-go v1.42.45/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc= +github.com/aws/aws-sdk-go v1.42.47 h1:Faabrbp+bOBiZjHje7Hbhvni212aQYQIXZMruwkgmmA= +github.com/aws/aws-sdk-go v1.42.47/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc= github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY= github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY= @@ -706,8 +706,9 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60= github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/afero v1.8.1 h1:izYHOT71f9iZ7iq37Uqjael60/vYC6vMtzedudZ0zEk= +github.com/spf13/afero v1.8.1/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0= @@ -947,8 +948,9 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a h1:ppl5mZgokTT8uPkmYOyEUmPTr3ypaKkg5eFOGrAmxxE= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1079,9 +1081,9 @@ google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3h google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM= -google.golang.org/api v0.65.0/go.mod h1:ArYhxgGadlWmqO1IqVujw6Cs8IdD33bTmzKo2Sh+cbg= -google.golang.org/api v0.66.0 h1:CbGy4LEiXCVCiNEDFgGpWOVwsDT7E2Qej1ZvN1P7KPg= google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M= +google.golang.org/api v0.67.0 h1:lYaaLa+x3VVUhtosaK9xihwQ9H9KRa557REHwwZ2orM= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= 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.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1164,13 +1166,12 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220202230416-2a053f022f0d h1:My3SknEgMxMbQeOp4Onz8T696iNcOYHJC/E7Dx+RDjc= -google.golang.org/genproto v0.0.0-20220202230416-2a053f022f0d/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e h1:hXl9hnyOkeznztYpYxVPAVZfPzcbO6Q0C+nLXodza8k= +google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go index e3b6ce3e..0c449a6a 100644 --- a/httpd/api_http_user.go +++ b/httpd/api_http_user.go @@ -10,7 +10,6 @@ import ( "os" "path" "strconv" - "time" "github.com/go-chi/render" "github.com/rs/xid" @@ -61,19 +60,7 @@ func readUserFolder(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err)) return } - results := make([]map[string]interface{}, 0, len(contents)) - for _, info := range contents { - res := make(map[string]interface{}) - res["name"] = info.Name() - if info.Mode().IsRegular() { - res["size"] = info.Size() - } - res["mode"] = info.Mode() - res["last_modified"] = info.ModTime().UTC().Format(time.RFC3339) - results = append(results, res) - } - - render.JSON(w, r, results) + renderAPIDirContents(w, r, contents, false) } func createUserDir(w http.ResponseWriter, r *http.Request) { @@ -163,7 +150,7 @@ func getUserFile(w http.ResponseWriter, r *http.Request) { } inline := r.URL.Query().Get("inline") != "" - if status, err := downloadFile(w, r, connection, name, info, inline); err != nil { + if status, err := downloadFile(w, r, connection, name, info, inline, nil); err != nil { resp := apiResponse{ Error: err.Error(), Message: http.StatusText(status), diff --git a/httpd/api_shares.go b/httpd/api_shares.go index 858811e6..4403b269 100644 --- a/httpd/api_shares.go +++ b/httpd/api_shares.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path" + "strings" "github.com/go-chi/render" "github.com/rs/xid" @@ -135,6 +136,79 @@ func deleteShare(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "Share deleted", http.StatusOK) } +func readBrowsableShareContents(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead) + if err != nil { + return + } + if err := validateBrowsableShare(share, connection); err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + name, err := getBrowsableSharedPath(share, r) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + contents, err := connection.ReadDir(name) + if err != nil { + sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err)) + return + } + renderAPIDirContents(w, r, contents, true) +} + +func downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead) + if err != nil { + return + } + if err := validateBrowsableShare(share, connection); err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + name, err := getBrowsableSharedPath(share, r) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + info, err := connection.Stat(name, 1) + if err != nil { + sendAPIResponse(w, r, err, "Unable to stat the requested file", getMappedStatusCode(err)) + return + } + if info.IsDir() { + sendAPIResponse(w, r, nil, fmt.Sprintf("Please set the path to a valid file, %#v is a directory", name), + http.StatusBadRequest) + return + } + + inline := r.URL.Query().Get("inline") != "" + dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck + if status, err := downloadFile(w, r, connection, name, info, inline, &share); err != nil { + dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck + resp := apiResponse{ + Error: err.Error(), + Message: http.StatusText(status), + } + ctx := r.Context() + if status != 0 { + ctx = context.WithValue(ctx, render.StatusCtxKey, status) + } + render.JSON(w, r.WithContext(ctx), resp) + } +} + func downloadFromShare(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead) @@ -147,13 +221,13 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) { compress := true var info os.FileInfo - if len(share.Paths) > 0 && r.URL.Query().Get("compress") == "false" { - info, err = connection.Stat(share.Paths[0], 0) + if len(share.Paths) == 1 && r.URL.Query().Get("compress") == "false" { + info, err = connection.Stat(share.Paths[0], 1) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } - if !info.IsDir() { + if info.Mode().IsRegular() { compress = false } } @@ -170,7 +244,7 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) { renderCompressedFiles(w, connection, "/", share.Paths, &share) return } - if status, err := downloadFile(w, r, connection, share.Paths[0], info, false); err != nil { + if status, err := downloadFile(w, r, connection, share.Paths[0], info, false, &share); err != nil { dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck resp := apiResponse{ Error: err.Error(), @@ -303,3 +377,29 @@ func checkPublicShare(w http.ResponseWriter, r *http.Request, shareShope datapro return share, connection, nil } + +func validateBrowsableShare(share dataprovider.Share, connection *Connection) error { + if len(share.Paths) != 1 { + return util.NewValidationError("A share with multiple paths is not browsable") + } + basePath := share.Paths[0] + info, err := connection.Stat(basePath, 0) + if err != nil { + return fmt.Errorf("unable to check the share directory: %w", err) + } + if !info.IsDir() { + return util.NewValidationError("The shared object is not a directory and so it is not browsable") + } + return nil +} + +func getBrowsableSharedPath(share dataprovider.Share, r *http.Request) (string, error) { + name := util.CleanPath(path.Join(share.Paths[0], r.URL.Query().Get("path"))) + if share.Paths[0] == "/" { + return name, nil + } + if name != share.Paths[0] && !strings.HasPrefix(name, share.Paths[0]+"/") { + return "", util.NewValidationError(fmt.Sprintf("Invalid path %#v", r.URL.Query().Get("path"))) + } + return name, nil +} diff --git a/httpd/api_utils.go b/httpd/api_utils.go index cdc75418..5e9fea13 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -82,7 +82,7 @@ func getRespStatus(err error) int { if os.IsNotExist(err) { return http.StatusBadRequest } - if os.IsPermission(err) { + if os.IsPermission(err) || errors.Is(err, dataprovider.ErrLoginNotAllowedFromIP) { return http.StatusForbidden } if errors.Is(err, plugin.ErrNoSearcher) || errors.Is(err, dataprovider.ErrNotImplemented) { @@ -187,6 +187,25 @@ func getSearchFilters(w http.ResponseWriter, r *http.Request) (int, int, string, return limit, offset, order, err } +func renderAPIDirContents(w http.ResponseWriter, r *http.Request, contents []os.FileInfo, omitNonRegularFiles bool) { + results := make([]map[string]interface{}, 0, len(contents)) + for _, info := range contents { + if omitNonRegularFiles && !info.Mode().IsDir() && !info.Mode().IsRegular() { + continue + } + res := make(map[string]interface{}) + res["name"] = info.Name() + if info.Mode().IsRegular() { + res["size"] = info.Size() + } + res["mode"] = info.Mode() + res["last_modified"] = info.ModTime().UTC().Format(time.RFC3339) + results = append(results, res) + } + + render.JSON(w, r, results) +} + func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir string, files []string, share *dataprovider.Share, ) { @@ -266,10 +285,20 @@ func getZipEntryName(entryPath, baseDir string) string { return strings.TrimPrefix(entryPath, "/") } +func checkDownloadFileFromShare(share *dataprovider.Share, info os.FileInfo) error { + if share != nil && !info.Mode().IsRegular() { + return util.NewValidationError("non regular files are not supported for shares") + } + return nil +} + func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, - info os.FileInfo, inline bool, + info os.FileInfo, inline bool, share *dataprovider.Share, ) (int, error) { - var err error + err := checkDownloadFileFromShare(share, info) + if err != nil { + return http.StatusBadRequest, err + } rangeHeader := r.Header.Get("Range") if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse { rangeHeader = "" @@ -314,6 +343,9 @@ func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection if r.Method != http.MethodHead { _, err = io.CopyN(w, reader, size) if err != nil { + if share != nil { + dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck + } connection.Log(logger.LevelDebug, "error reading file to download: %v", err) panic(http.ErrAbortHandler) } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index e90bcca1..9814f02a 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -9558,6 +9558,356 @@ func TestShareUncompressed(t *testing.T) { assert.NoError(t, err) } +func TestDownloadFromShareError(t *testing.T) { + u := getTestUser() + u.DownloadDataTransfer = 1 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + user.UsedDownloadDataTransfer = 1024*1024 - 32768 + _, err = httpdtest.UpdateTransferQuotaUsage(user, "add", http.StatusOK) + assert.NoError(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, int64(1024*1024-32768), user.UsedDownloadDataTransfer) + testFileName := "test_share_file.dat" + testFileSize := int64(524288) + testFilePath := filepath.Join(user.GetHomeDir(), testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + share := dataprovider.Share{ + Name: "test share root browse", + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + MaxTokens: 2, + } + asJSON, err := json.Marshal(share) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID := rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + defer func() { + rcv := recover() + assert.Equal(t, http.ErrAbortHandler, rcv) + + share, err = dataprovider.ShareExists(objectID, user.Username) + assert.NoError(t, err) + assert.Equal(t, 0, share.UsedTokens) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + }() + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileName), nil) + assert.NoError(t, err) + executeRequest(req) +} + +func TestBrowseShares(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + testFileName := "testsharefile.dat" + testFileNameLink := "testsharefile.link" + shareDir := "share" + subDir := "sub" + testFileSize := int64(65536) + testFilePath := filepath.Join(user.GetHomeDir(), shareDir, testFileName) + testLinkPath := filepath.Join(user.GetHomeDir(), shareDir, testFileNameLink) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(filepath.Join(user.GetHomeDir(), shareDir, subDir, testFileName), testFileSize) + assert.NoError(t, err) + err = os.Symlink(testFilePath, testLinkPath) + assert.NoError(t, err) + + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + share := dataprovider.Share{ + Name: "test share browse", + Scope: dataprovider.ShareScopeRead, + Paths: []string{shareDir}, + MaxTokens: 0, + } + asJSON, err := json.Marshal(share) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID := rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Please set the path to a valid file") + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contents := make([]map[string]interface{}, 0) + err = json.Unmarshal(rr.Body.Bytes(), &contents) + assert.NoError(t, err) + assert.Len(t, contents, 2) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contents = make([]map[string]interface{}, 0) + err = json.Unmarshal(rr.Body.Bytes(), &contents) + assert.NoError(t, err) + assert.Len(t, contents, 2) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"+subDir), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contents = make([]map[string]interface{}, 0) + err = json.Unmarshal(rr.Body.Bytes(), &contents) + assert.NoError(t, err) + assert.Len(t, contents, 1) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F.."), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Invalid share path") + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F.."), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F.."), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path=%2F..%2F"+testFileName), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileName), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contentDisposition := rr.Header().Get("Content-Disposition") + assert.NotEmpty(t, contentDisposition) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contentDisposition = rr.Header().Get("Content-Disposition") + assert.NotEmpty(t, contentDisposition) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+subDir+"%2F"+testFileName), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contentDisposition = rr.Header().Get("Content-Disposition") + assert.NotEmpty(t, contentDisposition) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=missing"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "file does not exist") + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path=missing"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=missing"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=missing"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileNameLink), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "non regular files are not supported for shares") + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileNameLink), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "non regular files are not supported for shares") + + // share a symlink + share = dataprovider.Share{ + Name: "test share browse", + Scope: dataprovider.ShareScopeRead, + Paths: []string{path.Join(shareDir, testFileNameLink)}, + MaxTokens: 0, + } + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID = rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + // uncompressed download should not work + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + // this share is not browsable, it does not contains a directory + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "The shared object is not a directory and so it is not browsable") + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "The shared object is not a directory and so it is not browsable") + + // now test a missing shareID + objectID = "123456" + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + // share a missing base path + share = dataprovider.Share{ + Name: "test share", + Scope: dataprovider.ShareScopeRead, + Paths: []string{path.Join(shareDir, "missingdir")}, + MaxTokens: 0, + } + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID = rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "unable to check the share directory") + // share multiple paths + share = dataprovider.Share{ + Name: "test share", + Scope: dataprovider.ShareScopeRead, + Paths: []string{shareDir, "/anotherdir"}, + MaxTokens: 0, + } + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID = rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "A share with multiple paths is not browsable") + // share the root path + share = dataprovider.Share{ + Name: "test share root", + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + MaxTokens: 0, + } + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID = rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contents = make([]map[string]interface{}, 0) + err = json.Unmarshal(rr.Body.Bytes(), &contents) + assert.NoError(t, err) + assert.Len(t, contents, 1) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestUserAPIShareErrors(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 263535d1..e46d7999 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -2187,3 +2187,46 @@ func TestMetadataAPI(t *testing.T) { err = doMetadataCheck(user) assert.Error(t, err) } + +func TestBrowsableSharePaths(t *testing.T) { + share := dataprovider.Share{ + Paths: []string{"/"}, + } + req, err := http.NewRequest(http.MethodGet, "/share", nil) + require.NoError(t, err) + name, err := getBrowsableSharedPath(share, req) + assert.NoError(t, err) + assert.Equal(t, "/", name) + req, err = http.NewRequest(http.MethodGet, "/share?path=abc", nil) + require.NoError(t, err) + name, err = getBrowsableSharedPath(share, req) + assert.NoError(t, err) + assert.Equal(t, "/abc", name) + + share.Paths = []string{"/a/b/c"} + req, err = http.NewRequest(http.MethodGet, "/share?path=abc", nil) + require.NoError(t, err) + name, err = getBrowsableSharedPath(share, req) + assert.NoError(t, err) + assert.Equal(t, "/a/b/c/abc", name) + req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc/d", nil) + require.NoError(t, err) + name, err = getBrowsableSharedPath(share, req) + assert.NoError(t, err) + assert.Equal(t, "/a/b/c/abc/d", name) + + req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc%2F..%2F..", nil) + require.NoError(t, err) + _, err = getBrowsableSharedPath(share, req) + assert.Error(t, err) + + req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc%2F..", nil) + require.NoError(t, err) + name, err = getBrowsableSharedPath(share, req) + assert.NoError(t, err) + assert.Equal(t, "/a/b/c", name) + + share = dataprovider.Share{ + Paths: []string{"/a", "/b"}, + } +} diff --git a/httpd/server.go b/httpd/server.go index e181bc2a..8be9bb8f 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -1007,6 +1007,8 @@ func (s *httpdServer) initializeRouter() { s.router.Get(sharesPath+"/{id}", downloadFromShare) s.router.Post(sharesPath+"/{id}", uploadFilesToShare) s.router.Post(sharesPath+"/{id}/{name}", uploadFileToShare) + s.router.Get(sharesPath+"/{id}/dirs", readBrowsableShareContents) + s.router.Get(sharesPath+"/{id}/files", downloadBrowsableSharedFile) s.router.Get(tokenPath, s.getToken) s.router.Post(adminPath+"/{username}/forgot-password", forgotAdminPassword) @@ -1226,6 +1228,8 @@ func (s *httpdServer) initializeRouter() { Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost) // share API exposed to external users s.router.Get(webClientPubSharesPath+"/{id}", downloadFromShare) + s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles) + s.router.Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents) s.router.Post(webClientPubSharesPath+"/{id}", uploadFilesToShare) s.router.Post(webClientPubSharesPath+"/{id}/{name}", uploadFileToShare) diff --git a/httpd/webclient.go b/httpd/webclient.go index 15594bab..edea61e5 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -45,6 +45,7 @@ const ( templateClientShare = "share.html" templateClientShares = "shares.html" templateClientViewPDF = "viewpdf.html" + templateShareFiles = "sharefiles.html" pageClientFilesTitle = "My Files" pageClientSharesTitle = "Shares" pageClientProfileTitle = "My Profile" @@ -53,6 +54,7 @@ const ( pageClientEditFileTitle = "Edit file" pageClientForgotPwdTitle = "SFTPGo WebClient - Forgot password" pageClientResetPwdTitle = "SFTPGo WebClient - Reset password" + pageExtShareTitle = "Shared files" ) // condResult is the result of an HTTP request precondition check. @@ -134,6 +136,16 @@ type filesPage struct { HasIntegrations bool } +type shareFilesPage struct { + baseClientPage + CurrentDir string + DirsURL string + FilesURL string + DownloadURL string + Error string + Paths []dirMapping +} + type clientMessagePage struct { baseClientPage Error string @@ -179,8 +191,8 @@ type clientSharePage struct { IsAdd bool } -func getFileObjectURL(baseDir, name string) string { - return fmt.Sprintf("%v?path=%v&_=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix()) +func getFileObjectURL(baseDir, name, baseWebPath string) string { + return fmt.Sprintf("%v?path=%v&_=%v", baseWebPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix()) } func getFileObjectModTime(t time.Time) string { @@ -244,6 +256,10 @@ func loadClientTemplates(templatesPath string) { viewPDFPaths := []string{ filepath.Join(templatesPath, templateClientDir, templateClientViewPDF), } + shareFilesPath := []string{ + filepath.Join(templatesPath, templateClientDir, templateClientBase), + filepath.Join(templatesPath, templateClientDir, templateShareFiles), + } filesTmpl := util.LoadTemplate(nil, filesPaths...) profileTmpl := util.LoadTemplate(nil, profilePaths...) @@ -259,6 +275,7 @@ func loadClientTemplates(templatesPath string) { forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...) resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...) viewPDFTmpl := util.LoadTemplate(nil, viewPDFPaths...) + shareFilesTmpl := util.LoadTemplate(nil, shareFilesPath...) clientTemplates[templateClientFiles] = filesTmpl clientTemplates[templateClientProfile] = profileTmpl @@ -274,6 +291,7 @@ func loadClientTemplates(templatesPath string) { clientTemplates[templateForgotPassword] = forgotPwdTmpl clientTemplates[templateResetPassword] = resetPwdTmpl clientTemplates[templateClientViewPDF] = viewPDFTmpl + clientTemplates[templateShareFiles] = shareFilesTmpl } func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage { @@ -440,6 +458,41 @@ func renderAddUpdateSharePage(w http.ResponseWriter, r *http.Request, share *dat renderClientTemplate(w, templateClientShare, data) } +func getDirMapping(dirName, baseWebPath string) []dirMapping { + paths := []dirMapping{} + if dirName != "/" { + paths = append(paths, dirMapping{ + DirName: path.Base(dirName), + Href: "", + }) + for { + dirName = path.Dir(dirName) + if dirName == "/" || dirName == "." { + break + } + paths = append([]dirMapping{{ + DirName: path.Base(dirName), + Href: getFileObjectURL("/", dirName, baseWebPath)}, + }, paths...) + } + } + return paths +} + +func renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, share dataprovider.Share) { + currentURL := path.Join(webClientPubSharesPath, share.ShareID, "browse") + data := shareFilesPage{ + baseClientPage: getBaseClientPageData(pageExtShareTitle, currentURL, r), + CurrentDir: url.QueryEscape(dirName), + DirsURL: path.Join(webClientPubSharesPath, share.ShareID, "dirs"), + FilesURL: currentURL, + DownloadURL: path.Join(webClientPubSharesPath, share.ShareID), + Error: error, + Paths: getDirMapping(dirName, currentURL), + } + renderClientTemplate(w, templateShareFiles, data) +} + func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User, hasIntegrations bool, ) { @@ -458,25 +511,8 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error stri CanDownload: user.HasPerm(dataprovider.PermDownload, dirName), CanShare: user.CanManageShares(), HasIntegrations: hasIntegrations, + Paths: getDirMapping(dirName, webClientFilesPath), } - paths := []dirMapping{} - if dirName != "/" { - paths = append(paths, dirMapping{ - DirName: path.Base(dirName), - Href: "", - }) - for { - dirName = path.Dir(dirName) - if dirName == "/" || dirName == "." { - break - } - paths = append([]dirMapping{{ - DirName: path.Base(dirName), - Href: getFileObjectURL("/", dirName)}, - }, paths...) - } - } - data.Paths = paths renderClientTemplate(w, templateClientFiles, data) } @@ -560,6 +596,95 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) { renderCompressedFiles(w, connection, name, filesList, nil) } +func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead) + if err != nil { + return + } + if err := validateBrowsableShare(share, connection); err != nil { + renderClientMessagePage(w, r, "Unable to validate share", "", getRespStatus(err), err, "") + return + } + name, err := getBrowsableSharedPath(share, r) + if err != nil { + renderClientMessagePage(w, r, "Invalid share path", "", getRespStatus(err), err, "") + return + } + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + contents, err := connection.ReadDir(name) + if err != nil { + sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err)) + return + } + results := make([]map[string]string, 0, len(contents)) + for _, info := range contents { + if !info.Mode().IsDir() && !info.Mode().IsRegular() { + continue + } + res := make(map[string]string) + if info.IsDir() { + res["type"] = "1" + res["size"] = "" + } else { + res["type"] = "2" + res["size"] = util.ByteCountIEC(info.Size()) + } + res["name"] = info.Name() + res["url"] = getFileObjectURL(share.GetRelativePath(name), info.Name(), + path.Join(webClientPubSharesPath, share.ShareID, "browse")) + res["last_modified"] = getFileObjectModTime(info.ModTime()) + results = append(results, res) + } + + render.JSON(w, r, results) +} + +func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead) + if err != nil { + return + } + if err := validateBrowsableShare(share, connection); err != nil { + renderClientMessagePage(w, r, "Unable to validate share", "", getRespStatus(err), err, "") + return + } + name, err := getBrowsableSharedPath(share, r) + if err != nil { + renderClientMessagePage(w, r, "Invalid share path", "", getRespStatus(err), err, "") + return + } + + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + var info os.FileInfo + if name == "/" { + info = vfs.NewFileInfo(name, true, 0, time.Now(), false) + } else { + info, err = connection.Stat(name, 1) + } + if err != nil { + renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), err.Error(), share) + return + } + if info.IsDir() { + renderSharedFilesPage(w, r, share.GetRelativePath(name), "", share) + return + } + inline := r.URL.Query().Get("inline") != "" + dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck + if status, err := downloadFile(w, r, connection, name, info, inline, &share); err != nil { + dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck + if status > 0 { + renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), err.Error(), share) + } + } +} + func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) @@ -602,7 +727,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http. results := make([]map[string]string, 0, len(contents)) for _, info := range contents { res := make(map[string]string) - res["url"] = getFileObjectURL(name, info.Name()) + res["url"] = getFileObjectURL(name, info.Name(), webClientFilesPath) if info.IsDir() { res["type"] = "1" res["size"] = "" @@ -685,7 +810,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques return } inline := r.URL.Query().Get("inline") != "" - if status, err := downloadFile(w, r, connection, name, info, inline); err != nil && status != 0 { + if status, err := downloadFile(w, r, connection, name, info, inline, nil); err != nil && status != 0 { if status > 0 { if status == http.StatusRequestedRangeNotSatisfiable { renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "") diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a1a47a4d..cca84714 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -23,7 +23,7 @@ info: SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one. Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user. SFTPGo allows to create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date. - version: 2.2.1-dev + version: 2.2.2-dev contact: name: API support url: 'https://github.com/drakkan/sftpgo' @@ -138,6 +138,101 @@ paths: $ref: '#/components/responses/InternalServerError' default: $ref: '#/components/responses/DefaultResponse' + /shares/{id}/files: + parameters: + - name: id + in: path + description: the share id + required: true + schema: + type: string + get: + security: + - BasicAuth: [] + tags: + - public shares + summary: Download a single file + description: Returns the file contents as response body. The share must have exactly one path defined and it must be a directory for this to work + operationId: download_share_file + parameters: + - in: query + name: path + required: true + description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt" + schema: + type: string + - in: query + name: inline + required: false + description: 'If set, the response will not have the Content-Disposition header set to `attachment`' + schema: + type: string + responses: + '200': + description: successful operation + content: + '*/*': + schema: + type: string + format: binary + '206': + description: successful operation + content: + '*/*': + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /shares/{id}/dirs: + parameters: + - name: id + in: path + description: the share id + required: true + schema: + type: string + get: + security: + - BasicAuth: [] + tags: + - public shares + summary: Read directory contents + description: Returns the contents of the specified directory for the specified share. The share must have exactly one path defined and it must be a directory for this to work + operationId: get_share_dir_contents + parameters: + - in: query + name: path + description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DirEntry' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' /shares/{id}/{fileName}: parameters: - name: id diff --git a/pkgs/build.sh b/pkgs/build.sh index 16c07d70..ccbe7b3b 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -NFPM_VERSION=2.11.3 +NFPM_VERSION=2.12.1 NFPM_ARCH=${NFPM_ARCH:-amd64} if [ -z ${SFTPGO_VERSION} ] then diff --git a/pkgs/debian/changelog b/pkgs/debian/changelog index 88c2b155..f6a506e4 100644 --- a/pkgs/debian/changelog +++ b/pkgs/debian/changelog @@ -1,3 +1,9 @@ +sftpgo (2.2.2-1ppa1) bionic; urgency=medium + + * New upstream release + + -- Nicola Murino Sun, 06 Feb 2022 15:48:39 +0100 + sftpgo (2.2.1-1ppa1) bionic; urgency=medium * New upstream release diff --git a/templates/webclient/files.html b/templates/webclient/files.html index 4807fac7..92eae453 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -1088,7 +1088,7 @@ table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container()); }, "orderFixed": [1, 'asc'], - "order": [[2, 'asc']] + "order": [2, 'asc'] }); new $.fn.dataTable.FixedHeader(table); diff --git a/templates/webclient/sharefiles.html b/templates/webclient/sharefiles.html new file mode 100644 index 00000000..5cb50b7c --- /dev/null +++ b/templates/webclient/sharefiles.html @@ -0,0 +1,296 @@ +{{template "base" .}} + +{{define "title"}}{{.Title}}{{end}} + +{{define "extra_css"}} + + + + +{{end}} + +{{define "page_body"}} +
+
+
 Home {{range .Paths}}{{if eq .Href ""}}/{{.DirName}}{{else}}/{{.DirName}}{{end}}{{end}}
+
+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} + +
+ + + + + + + + + +
TypeNameSizeLast modified
+
+
+
+{{end}} + +{{define "extra_js"}} + + + + + + + + +{{end}} \ No newline at end of file diff --git a/templates/webclient/shares.html b/templates/webclient/shares.html index 64020962..4c46b519 100644 --- a/templates/webclient/shares.html +++ b/templates/webclient/shares.html @@ -93,7 +93,8 @@