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:
Nicola Murino 2022-02-06 16:46:43 +01:00
parent 7e2a8e70c9
commit 9382db751c
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
18 changed files with 1138 additions and 75 deletions

View file

@ -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 {

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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),

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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"},
}
}

View file

@ -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)

View file

@ -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, "")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);

View 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>&nbsp;Home</a>&nbsp;{{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, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /"/g, '&quot;' );
};
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+'&#8230;';
}
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>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
}
if (row["size"] == "") {
return `<i class="fas fa-external-link-alt"></i>&nbsp;<a class="${cssClass}" href="${row['url']}" title="${title}">${shortened}</a>`;
}
var icon = getIconForFile(data);
return `<i class="${icon}"></i>&nbsp;<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}}

View file

@ -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();

View file

@ -2,7 +2,7 @@ package version
import "strings"
const version = "2.2.1-dev"
const version = "2.2.2-dev"
var (
commit = ""