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 <nicola.murino@gmail.com>
This commit is contained in:
parent
7e2a8e70c9
commit
9382db751c
18 changed files with 1138 additions and 75 deletions
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
14
go.mod
14
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
|
||||
|
|
29
go.sum
29
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=
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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, "")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
sftpgo (2.2.2-1ppa1) bionic; urgency=medium
|
||||
|
||||
* New upstream release
|
||||
|
||||
-- Nicola Murino <nicola.murino@gmail.com> Sun, 06 Feb 2022 15:48:39 +0100
|
||||
|
||||
sftpgo (2.2.1-1ppa1) bionic; urgency=medium
|
||||
|
||||
* New upstream release
|
||||
|
|
|
@ -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);
|
||||
|
|
296
templates/webclient/sharefiles.html
Normal file
296
templates/webclient/sharefiles.html
Normal file
|
@ -0,0 +1,296 @@
|
|||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "extra_css"}}
|
||||
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
|
||||
{{end}}
|
||||
|
||||
{{define "page_body"}}
|
||||
<div class="card shadow my-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold"><a href="{{.FilesURL}}?path=%2F"><i class="fas fa-home"></i> Home</a> {{range .Paths}}{{if eq .Href ""}}/{{.DirName}}{{else}}<a href="{{.Href}}">/{{.DirName}}</a>{{end}}{{end}}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .Error}}
|
||||
<div class="card mb-4 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.Error}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
|
||||
<div id="errorTxt" class="card-body text-form-error"></div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Last modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "extra_js"}}
|
||||
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
|
||||
var escapeHTML = function ( t ) {
|
||||
return t
|
||||
.replace( /&/g, '&' )
|
||||
.replace( /</g, '<' )
|
||||
.replace( />/g, '>' )
|
||||
.replace( /"/g, '"' );
|
||||
};
|
||||
|
||||
function shortenData(d, cutoff) {
|
||||
if ( typeof d !== 'string' ) {
|
||||
return d;
|
||||
}
|
||||
|
||||
if ( d.length <= cutoff ) {
|
||||
return d;
|
||||
}
|
||||
|
||||
var shortened = d.substr(0, cutoff-1);
|
||||
return shortened+'…';
|
||||
}
|
||||
|
||||
function getIconForFile(filename) {
|
||||
var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
|
||||
switch (extension) {
|
||||
case "doc":
|
||||
case "docx":
|
||||
case "odt":
|
||||
case "wps":
|
||||
return "far fa-file-word";
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
return "far fa-file-powerpoint";
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
case "ods":
|
||||
return "far fa-file-excel";
|
||||
case "pdf":
|
||||
return "far fa-file-pdf";
|
||||
case "webm":
|
||||
case "mkv":
|
||||
case "flv":
|
||||
case "vob":
|
||||
case "ogv":
|
||||
case "ogg":
|
||||
case "avi":
|
||||
case "ts":
|
||||
case "mov":
|
||||
case "wmv":
|
||||
case "asf":
|
||||
case "mpeg":
|
||||
case "mpv":
|
||||
case "3gp":
|
||||
return "far fa-file-video";
|
||||
case "jpeg":
|
||||
case "jpg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "webp":
|
||||
case "tiff":
|
||||
case "psd":
|
||||
case "bmp":
|
||||
case "svg":
|
||||
case "jp2":
|
||||
return "far fa-file-image";
|
||||
case "go":
|
||||
case "sh":
|
||||
case "bat":
|
||||
case "java":
|
||||
case "php":
|
||||
case "cs":
|
||||
case "asp":
|
||||
case "aspx":
|
||||
case "css":
|
||||
case "html":
|
||||
case "xhtml":
|
||||
case "htm":
|
||||
case "js":
|
||||
case "jsp":
|
||||
case "py":
|
||||
case "rb":
|
||||
case "cgi":
|
||||
case "c":
|
||||
case "cpp":
|
||||
case "h":
|
||||
case "hpp":
|
||||
case "kt":
|
||||
case "ktm":
|
||||
case "kts":
|
||||
case "swift":
|
||||
case "r":
|
||||
return "far fa-file-code";
|
||||
case "zip":
|
||||
case "zipx":
|
||||
case "rar":
|
||||
case "tar":
|
||||
case "gz":
|
||||
case "bz2":
|
||||
case "zstd":
|
||||
case "zst":
|
||||
case "sz":
|
||||
case "lz":
|
||||
case "lz4":
|
||||
case "xz":
|
||||
case "jar":
|
||||
return "far fa-file-archive";
|
||||
case "txt":
|
||||
case "rtf":
|
||||
case "json":
|
||||
case "xml":
|
||||
case "yaml":
|
||||
case "toml":
|
||||
case "log":
|
||||
case "csv":
|
||||
case "ini":
|
||||
case "cfg":
|
||||
return "far fa-file-alt";
|
||||
default:
|
||||
return "far fa-file";
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
$.fn.dataTable.ext.buttons.refresh = {
|
||||
text: '<i class="fas fa-sync-alt"></i>',
|
||||
name: 'refresh',
|
||||
titleAttr: "Refresh",
|
||||
action: function (e, dt, node, config) {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
$.fn.dataTable.ext.buttons.download = {
|
||||
text: '<i class="fas fa-download"></i>',
|
||||
name: 'download',
|
||||
titleAttr: "Download the whole share as zip",
|
||||
action: function (e, dt, node, config) {
|
||||
var downloadURL = '{{.DownloadURL}}';
|
||||
var ts = new Date().getTime().toString();
|
||||
window.location = `${downloadURL}?_=${ts}`;
|
||||
}
|
||||
};
|
||||
|
||||
var table = $('#dataTable').DataTable({
|
||||
"ajax": {
|
||||
"url": "{{.DirsURL}}?path={{.CurrentDir}}",
|
||||
"dataSrc": "",
|
||||
"error": function ($xhr, textStatus, errorThrown) {
|
||||
$(".dataTables_processing").hide();
|
||||
var txt = "Failed to get directory listing";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorTxt').text(txt);
|
||||
$('#errorMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorMsg').hide();
|
||||
}, 10000);
|
||||
}
|
||||
},
|
||||
"deferRender": true,
|
||||
"processing": true,
|
||||
"lengthMenu": [ 10, 25, 50, 100, 250, 500 ],
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"stateSaveParams": function (settings, data) {
|
||||
data.sftpgo_dir = '{{.CurrentDir}}';
|
||||
},
|
||||
"stateLoadParams": function (settings, data) {
|
||||
if (!data.sftpgo_dir || data.sftpgo_dir != '{{.CurrentDir}}'){
|
||||
data.start = 0;
|
||||
data.search.search = "";
|
||||
}
|
||||
},
|
||||
"columns": [
|
||||
{ "data": "type" },
|
||||
{
|
||||
"data": "name",
|
||||
"render": function (data, type, row) {
|
||||
if (type === 'display') {
|
||||
var title = "";
|
||||
var cssClass = "";
|
||||
var shortened = shortenData(data, 70);
|
||||
if (shortened != data){
|
||||
title = escapeHTML(data);
|
||||
cssClass = "ellipsis";
|
||||
}
|
||||
|
||||
if (row["type"] == "1") {
|
||||
return `<i class="fas fa-folder"></i> <a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
|
||||
}
|
||||
if (row["size"] == "") {
|
||||
return `<i class="fas fa-external-link-alt"></i> <a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
|
||||
}
|
||||
var icon = getIconForFile(data);
|
||||
return `<i class="${icon}"></i> <a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
{ "data": "size" },
|
||||
{ "data": "last_modified" }
|
||||
],
|
||||
"buttons": [],
|
||||
"lengthChange": false,
|
||||
"columnDefs": [
|
||||
{
|
||||
"targets": [0],
|
||||
"visible": false,
|
||||
"searchable": false
|
||||
},
|
||||
{
|
||||
"targets": [2, 3],
|
||||
"searchable": false
|
||||
}
|
||||
],
|
||||
"scrollX": false,
|
||||
"scrollY": false,
|
||||
"responsive": true,
|
||||
"language": {
|
||||
"processing": '<i class="fas fa-spinner fa-spin fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
|
||||
"loadingRecords": "",
|
||||
"emptyTable": "No files or folders"
|
||||
},
|
||||
"initComplete": function (settings, json) {
|
||||
table.button().add(0, 'refresh');
|
||||
table.button().add(0, 'pageLength');
|
||||
table.button().add(0, 'download');
|
||||
table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
|
||||
},
|
||||
"orderFixed": [0, 'asc'],
|
||||
"order": [1, 'asc']
|
||||
});
|
||||
|
||||
new $.fn.dataTable.FixedHeader(table);
|
||||
$.fn.dataTable.ext.errMode = 'none';
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
|
@ -93,7 +93,8 @@
|
|||
<div class="modal-body">
|
||||
<div id="readShare">
|
||||
<p>You can download the shared contents, as single zip file, using this <a id="readLink" href="#" target="_blank">link</a>.</p>
|
||||
<p>If the share consists of a single file you can download it uncompressed using this <a id="readUncompressedLink" href="#" target="_blank">link</a></p>
|
||||
<p>If the share consists of a single folder you can browse and download single files using this <a id="readBrowseLink" href="#" target="_blank">link</a>.</p>
|
||||
<p>If the share consists of a single file you can download it uncompressed using this <a id="readUncompressedLink" href="#" target="_blank">link</a>.</p>
|
||||
</div>
|
||||
<div id="writeShare">
|
||||
<p>You can upload one or more files to the shared directory by sending a multipart/form-data request to this <a id="writeLink" href="#" target="_blank">link</a>. The form field name for the file(s) is <b><code>filenames</code></b>.</p>
|
||||
|
@ -219,6 +220,8 @@
|
|||
$('#readLink').attr("title", shareURL);
|
||||
$('#readUncompressedLink').attr("href", shareURL+"?compress=false");
|
||||
$('#readUncompressedLink').attr("title", shareURL+"?compress=false");
|
||||
$('#readBrowseLink').attr("href", shareURL+"/browse");
|
||||
$('#readBrowseLink').attr("title", shareURL+"/browse");
|
||||
} else {
|
||||
$('#expiredShare').hide();
|
||||
$('#writeShare').show();
|
||||
|
|
|
@ -2,7 +2,7 @@ package version
|
|||
|
||||
import "strings"
|
||||
|
||||
const version = "2.2.1-dev"
|
||||
const version = "2.2.2-dev"
|
||||
|
||||
var (
|
||||
commit = ""
|
||||
|
|
Loading…
Reference in a new issue