瀏覽代碼

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>
Nicola Murino 3 年之前
父節點
當前提交
9382db751c

+ 21 - 0
dataprovider/share.go

@@ -197,6 +197,19 @@ func (s *Share) validatePaths() error {
 	if s.Scope == ShareScopeWrite && len(s.Paths) != 1 {
 		return util.NewValidationError("the write share scope requires exactly one path")
 	}
+	// check nested paths
+	if len(s.Paths) > 1 {
+		for idx := range s.Paths {
+			for innerIdx := range s.Paths {
+				if idx == innerIdx {
+					continue
+				}
+				if isVirtualDirOverlapped(s.Paths[idx], s.Paths[innerIdx], true) {
+					return util.NewGenericError("shared paths cannot be nested")
+				}
+			}
+		}
+	}
 	return nil
 }
 
@@ -263,6 +276,14 @@ func (s *Share) CheckPassword(password string) (bool, error) {
 	return match, err
 }
 
+// GetRelativePath returns the specified absolute path as relative to the share base path
+func (s *Share) GetRelativePath(name string) string {
+	if len(s.Paths) == 0 {
+		return ""
+	}
+	return util.CleanPath(strings.TrimPrefix(name, s.Paths[0]))
+}
+
 // IsUsable checks if the share is usable from the specified IP
 func (s *Share) IsUsable(ip string) (bool, error) {
 	if s.MaxTokens > 0 && s.UsedTokens >= s.MaxTokens {

+ 5 - 5
docker/README.md

@@ -4,11 +4,11 @@ SFTPGo provides an official Docker image, it is available on both [Docker Hub](h
 
 ## Supported tags and respective Dockerfile links
 
-- [v2.2.1, v2.2, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile)
-- [v2.2.1-alpine, v2.2-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile.alpine)
-- [v2.2.1-slim, v2.2-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile)
-- [v2.2.1-alpine-slim, v2.2-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile.alpine)
-- [v2.2.1-distroless-slim, v2.2-distroless-slim, v2-distroless-slim, distroless-slim](https://github.com/drakkan/sftpgo/blob/v2.2.1/Dockerfile.distroless)
+- [v2.2.2, v2.2, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile)
+- [v2.2.2-alpine, v2.2-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile.alpine)
+- [v2.2.2-slim, v2.2-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile)
+- [v2.2.2-alpine-slim, v2.2-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile.alpine)
+- [v2.2.2-distroless-slim, v2.2-distroless-slim, v2-distroless-slim, distroless-slim](https://github.com/drakkan/sftpgo/blob/v2.2.2/Dockerfile.distroless)
 - [edge](../Dockerfile)
 - [edge-alpine](../Dockerfile.alpine)
 - [edge-slim](../Dockerfile)

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

+ 15 - 14
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=

+ 2 - 15
httpd/api_http_user.go

@@ -10,7 +10,6 @@ import (
 	"os"
 	"path"
 	"strconv"
-	"time"
 
 	"github.com/go-chi/render"
 	"github.com/rs/xid"
@@ -61,19 +60,7 @@ func readUserFolder(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
 		return
 	}
-	results := make([]map[string]interface{}, 0, len(contents))
-	for _, info := range contents {
-		res := make(map[string]interface{})
-		res["name"] = info.Name()
-		if info.Mode().IsRegular() {
-			res["size"] = info.Size()
-		}
-		res["mode"] = info.Mode()
-		res["last_modified"] = info.ModTime().UTC().Format(time.RFC3339)
-		results = append(results, res)
-	}
-
-	render.JSON(w, r, results)
+	renderAPIDirContents(w, r, contents, false)
 }
 
 func createUserDir(w http.ResponseWriter, r *http.Request) {
@@ -163,7 +150,7 @@ func getUserFile(w http.ResponseWriter, r *http.Request) {
 	}
 
 	inline := r.URL.Query().Get("inline") != ""
-	if status, err := downloadFile(w, r, connection, name, info, inline); err != nil {
+	if status, err := downloadFile(w, r, connection, name, info, inline, nil); err != nil {
 		resp := apiResponse{
 			Error:   err.Error(),
 			Message: http.StatusText(status),

+ 104 - 4
httpd/api_shares.go

@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"os"
 	"path"
+	"strings"
 
 	"github.com/go-chi/render"
 	"github.com/rs/xid"
@@ -135,6 +136,79 @@ func deleteShare(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, err, "Share deleted", http.StatusOK)
 }
 
+func readBrowsableShareContents(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	if err != nil {
+		return
+	}
+	if err := validateBrowsableShare(share, connection); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	name, err := getBrowsableSharedPath(share, r)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	contents, err := connection.ReadDir(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
+		return
+	}
+	renderAPIDirContents(w, r, contents, true)
+}
+
+func downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	if err != nil {
+		return
+	}
+	if err := validateBrowsableShare(share, connection); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	name, err := getBrowsableSharedPath(share, r)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	info, err := connection.Stat(name, 1)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to stat the requested file", getMappedStatusCode(err))
+		return
+	}
+	if info.IsDir() {
+		sendAPIResponse(w, r, nil, fmt.Sprintf("Please set the path to a valid file, %#v is a directory", name),
+			http.StatusBadRequest)
+		return
+	}
+
+	inline := r.URL.Query().Get("inline") != ""
+	dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
+	if status, err := downloadFile(w, r, connection, name, info, inline, &share); err != nil {
+		dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck
+		resp := apiResponse{
+			Error:   err.Error(),
+			Message: http.StatusText(status),
+		}
+		ctx := r.Context()
+		if status != 0 {
+			ctx = context.WithValue(ctx, render.StatusCtxKey, status)
+		}
+		render.JSON(w, r.WithContext(ctx), resp)
+	}
+}
+
 func downloadFromShare(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
@@ -147,13 +221,13 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) {
 
 	compress := true
 	var info os.FileInfo
-	if len(share.Paths) > 0 && r.URL.Query().Get("compress") == "false" {
-		info, err = connection.Stat(share.Paths[0], 0)
+	if len(share.Paths) == 1 && r.URL.Query().Get("compress") == "false" {
+		info, err = connection.Stat(share.Paths[0], 1)
 		if err != nil {
 			sendAPIResponse(w, r, err, "", getRespStatus(err))
 			return
 		}
-		if !info.IsDir() {
+		if info.Mode().IsRegular() {
 			compress = false
 		}
 	}
@@ -170,7 +244,7 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) {
 		renderCompressedFiles(w, connection, "/", share.Paths, &share)
 		return
 	}
-	if status, err := downloadFile(w, r, connection, share.Paths[0], info, false); err != nil {
+	if status, err := downloadFile(w, r, connection, share.Paths[0], info, false, &share); err != nil {
 		dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck
 		resp := apiResponse{
 			Error:   err.Error(),
@@ -303,3 +377,29 @@ func checkPublicShare(w http.ResponseWriter, r *http.Request, shareShope datapro
 
 	return share, connection, nil
 }
+
+func validateBrowsableShare(share dataprovider.Share, connection *Connection) error {
+	if len(share.Paths) != 1 {
+		return util.NewValidationError("A share with multiple paths is not browsable")
+	}
+	basePath := share.Paths[0]
+	info, err := connection.Stat(basePath, 0)
+	if err != nil {
+		return fmt.Errorf("unable to check the share directory: %w", err)
+	}
+	if !info.IsDir() {
+		return util.NewValidationError("The shared object is not a directory and so it is not browsable")
+	}
+	return nil
+}
+
+func getBrowsableSharedPath(share dataprovider.Share, r *http.Request) (string, error) {
+	name := util.CleanPath(path.Join(share.Paths[0], r.URL.Query().Get("path")))
+	if share.Paths[0] == "/" {
+		return name, nil
+	}
+	if name != share.Paths[0] && !strings.HasPrefix(name, share.Paths[0]+"/") {
+		return "", util.NewValidationError(fmt.Sprintf("Invalid path %#v", r.URL.Query().Get("path")))
+	}
+	return name, nil
+}

+ 35 - 3
httpd/api_utils.go

@@ -82,7 +82,7 @@ func getRespStatus(err error) int {
 	if os.IsNotExist(err) {
 		return http.StatusBadRequest
 	}
-	if os.IsPermission(err) {
+	if os.IsPermission(err) || errors.Is(err, dataprovider.ErrLoginNotAllowedFromIP) {
 		return http.StatusForbidden
 	}
 	if errors.Is(err, plugin.ErrNoSearcher) || errors.Is(err, dataprovider.ErrNotImplemented) {
@@ -187,6 +187,25 @@ func getSearchFilters(w http.ResponseWriter, r *http.Request) (int, int, string,
 	return limit, offset, order, err
 }
 
+func renderAPIDirContents(w http.ResponseWriter, r *http.Request, contents []os.FileInfo, omitNonRegularFiles bool) {
+	results := make([]map[string]interface{}, 0, len(contents))
+	for _, info := range contents {
+		if omitNonRegularFiles && !info.Mode().IsDir() && !info.Mode().IsRegular() {
+			continue
+		}
+		res := make(map[string]interface{})
+		res["name"] = info.Name()
+		if info.Mode().IsRegular() {
+			res["size"] = info.Size()
+		}
+		res["mode"] = info.Mode()
+		res["last_modified"] = info.ModTime().UTC().Format(time.RFC3339)
+		results = append(results, res)
+	}
+
+	render.JSON(w, r, results)
+}
+
 func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir string, files []string,
 	share *dataprovider.Share,
 ) {
@@ -266,10 +285,20 @@ func getZipEntryName(entryPath, baseDir string) string {
 	return strings.TrimPrefix(entryPath, "/")
 }
 
+func checkDownloadFileFromShare(share *dataprovider.Share, info os.FileInfo) error {
+	if share != nil && !info.Mode().IsRegular() {
+		return util.NewValidationError("non regular files are not supported for shares")
+	}
+	return nil
+}
+
 func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string,
-	info os.FileInfo, inline bool,
+	info os.FileInfo, inline bool, share *dataprovider.Share,
 ) (int, error) {
-	var err error
+	err := checkDownloadFileFromShare(share, info)
+	if err != nil {
+		return http.StatusBadRequest, err
+	}
 	rangeHeader := r.Header.Get("Range")
 	if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse {
 		rangeHeader = ""
@@ -314,6 +343,9 @@ func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection
 	if r.Method != http.MethodHead {
 		_, err = io.CopyN(w, reader, size)
 		if err != nil {
+			if share != nil {
+				dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
+			}
 			connection.Log(logger.LevelDebug, "error reading file to download: %v", err)
 			panic(http.ErrAbortHandler)
 		}

+ 350 - 0
httpd/httpd_test.go

@@ -9558,6 +9558,356 @@ func TestShareUncompressed(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestDownloadFromShareError(t *testing.T) {
+	u := getTestUser()
+	u.DownloadDataTransfer = 1
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	user.UsedDownloadDataTransfer = 1024*1024 - 32768
+	_, err = httpdtest.UpdateTransferQuotaUsage(user, "add", http.StatusOK)
+	assert.NoError(t, err)
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, int64(1024*1024-32768), user.UsedDownloadDataTransfer)
+	testFileName := "test_share_file.dat"
+	testFileSize := int64(524288)
+	testFilePath := filepath.Join(user.GetHomeDir(), testFileName)
+	err = createTestFile(testFilePath, testFileSize)
+	assert.NoError(t, err)
+
+	token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	share := dataprovider.Share{
+		Name:      "test share root browse",
+		Scope:     dataprovider.ShareScopeRead,
+		Paths:     []string{"/"},
+		MaxTokens: 2,
+	}
+	asJSON, err := json.Marshal(share)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID := rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+
+	defer func() {
+		rcv := recover()
+		assert.Equal(t, http.ErrAbortHandler, rcv)
+
+		share, err = dataprovider.ShareExists(objectID, user.Username)
+		assert.NoError(t, err)
+		assert.Equal(t, 0, share.UsedTokens)
+
+		_, err = httpdtest.RemoveUser(user, http.StatusOK)
+		assert.NoError(t, err)
+		err = os.RemoveAll(user.GetHomeDir())
+		assert.NoError(t, err)
+	}()
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileName), nil)
+	assert.NoError(t, err)
+	executeRequest(req)
+}
+
+func TestBrowseShares(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	testFileName := "testsharefile.dat"
+	testFileNameLink := "testsharefile.link"
+	shareDir := "share"
+	subDir := "sub"
+	testFileSize := int64(65536)
+	testFilePath := filepath.Join(user.GetHomeDir(), shareDir, testFileName)
+	testLinkPath := filepath.Join(user.GetHomeDir(), shareDir, testFileNameLink)
+	err = createTestFile(testFilePath, testFileSize)
+	assert.NoError(t, err)
+	err = createTestFile(filepath.Join(user.GetHomeDir(), shareDir, subDir, testFileName), testFileSize)
+	assert.NoError(t, err)
+	err = os.Symlink(testFilePath, testLinkPath)
+	assert.NoError(t, err)
+
+	token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	share := dataprovider.Share{
+		Name:      "test share browse",
+		Scope:     dataprovider.ShareScopeRead,
+		Paths:     []string{shareDir},
+		MaxTokens: 0,
+	}
+	asJSON, err := json.Marshal(share)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID := rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Please set the path to a valid file")
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents := make([]map[string]interface{}, 0)
+	err = json.Unmarshal(rr.Body.Bytes(), &contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 2)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = make([]map[string]interface{}, 0)
+	err = json.Unmarshal(rr.Body.Bytes(), &contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 2)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"+subDir), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = make([]map[string]interface{}, 0)
+	err = json.Unmarshal(rr.Body.Bytes(), &contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 1)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F.."), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Invalid share path")
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F.."), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F.."), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path=%2F..%2F"+testFileName), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileName), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contentDisposition := rr.Header().Get("Content-Disposition")
+	assert.NotEmpty(t, contentDisposition)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contentDisposition = rr.Header().Get("Content-Disposition")
+	assert.NotEmpty(t, contentDisposition)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+subDir+"%2F"+testFileName), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contentDisposition = rr.Header().Get("Content-Disposition")
+	assert.NotEmpty(t, contentDisposition)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=missing"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "file does not exist")
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path=missing"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=missing"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=missing"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileNameLink), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "non regular files are not supported for shares")
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileNameLink), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "non regular files are not supported for shares")
+
+	// share a symlink
+	share = dataprovider.Share{
+		Name:      "test share browse",
+		Scope:     dataprovider.ShareScopeRead,
+		Paths:     []string{path.Join(shareDir, testFileNameLink)},
+		MaxTokens: 0,
+	}
+	asJSON, err = json.Marshal(share)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID = rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+	// uncompressed download should not work
+	req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Equal(t, "application/zip", rr.Header().Get("Content-Type"))
+	// this share is not browsable, it does not contains a directory
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "The shared object is not a directory and so it is not browsable")
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "The shared object is not a directory and so it is not browsable")
+
+	// now test a missing shareID
+	objectID = "123456"
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	// share a missing base path
+	share = dataprovider.Share{
+		Name:      "test share",
+		Scope:     dataprovider.ShareScopeRead,
+		Paths:     []string{path.Join(shareDir, "missingdir")},
+		MaxTokens: 0,
+	}
+	asJSON, err = json.Marshal(share)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID = rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+	assert.Contains(t, rr.Body.String(), "unable to check the share directory")
+	// share multiple paths
+	share = dataprovider.Share{
+		Name:      "test share",
+		Scope:     dataprovider.ShareScopeRead,
+		Paths:     []string{shareDir, "/anotherdir"},
+		MaxTokens: 0,
+	}
+	asJSON, err = json.Marshal(share)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID = rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "A share with multiple paths is not browsable")
+	// share the root path
+	share = dataprovider.Share{
+		Name:      "test share root",
+		Scope:     dataprovider.ShareScopeRead,
+		Paths:     []string{"/"},
+		MaxTokens: 0,
+	}
+	asJSON, err = json.Marshal(share)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID = rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contents = make([]map[string]interface{}, 0)
+	err = json.Unmarshal(rr.Body.Bytes(), &contents)
+	assert.NoError(t, err)
+	assert.Len(t, contents, 1)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestUserAPIShareErrors(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)

+ 43 - 0
httpd/internal_test.go

@@ -2187,3 +2187,46 @@ func TestMetadataAPI(t *testing.T) {
 	err = doMetadataCheck(user)
 	assert.Error(t, err)
 }
+
+func TestBrowsableSharePaths(t *testing.T) {
+	share := dataprovider.Share{
+		Paths: []string{"/"},
+	}
+	req, err := http.NewRequest(http.MethodGet, "/share", nil)
+	require.NoError(t, err)
+	name, err := getBrowsableSharedPath(share, req)
+	assert.NoError(t, err)
+	assert.Equal(t, "/", name)
+	req, err = http.NewRequest(http.MethodGet, "/share?path=abc", nil)
+	require.NoError(t, err)
+	name, err = getBrowsableSharedPath(share, req)
+	assert.NoError(t, err)
+	assert.Equal(t, "/abc", name)
+
+	share.Paths = []string{"/a/b/c"}
+	req, err = http.NewRequest(http.MethodGet, "/share?path=abc", nil)
+	require.NoError(t, err)
+	name, err = getBrowsableSharedPath(share, req)
+	assert.NoError(t, err)
+	assert.Equal(t, "/a/b/c/abc", name)
+	req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc/d", nil)
+	require.NoError(t, err)
+	name, err = getBrowsableSharedPath(share, req)
+	assert.NoError(t, err)
+	assert.Equal(t, "/a/b/c/abc/d", name)
+
+	req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc%2F..%2F..", nil)
+	require.NoError(t, err)
+	_, err = getBrowsableSharedPath(share, req)
+	assert.Error(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc%2F..", nil)
+	require.NoError(t, err)
+	name, err = getBrowsableSharedPath(share, req)
+	assert.NoError(t, err)
+	assert.Equal(t, "/a/b/c", name)
+
+	share = dataprovider.Share{
+		Paths: []string{"/a", "/b"},
+	}
+}

+ 4 - 0
httpd/server.go

@@ -1007,6 +1007,8 @@ func (s *httpdServer) initializeRouter() {
 	s.router.Get(sharesPath+"/{id}", downloadFromShare)
 	s.router.Post(sharesPath+"/{id}", uploadFilesToShare)
 	s.router.Post(sharesPath+"/{id}/{name}", uploadFileToShare)
+	s.router.Get(sharesPath+"/{id}/dirs", readBrowsableShareContents)
+	s.router.Get(sharesPath+"/{id}/files", downloadBrowsableSharedFile)
 
 	s.router.Get(tokenPath, s.getToken)
 	s.router.Post(adminPath+"/{username}/forgot-password", forgotAdminPassword)
@@ -1226,6 +1228,8 @@ func (s *httpdServer) initializeRouter() {
 			Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
 		// share API exposed to external users
 		s.router.Get(webClientPubSharesPath+"/{id}", downloadFromShare)
+		s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)
+		s.router.Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents)
 		s.router.Post(webClientPubSharesPath+"/{id}", uploadFilesToShare)
 		s.router.Post(webClientPubSharesPath+"/{id}/{name}", uploadFileToShare)
 

+ 147 - 22
httpd/webclient.go

@@ -45,6 +45,7 @@ const (
 	templateClientShare             = "share.html"
 	templateClientShares            = "shares.html"
 	templateClientViewPDF           = "viewpdf.html"
+	templateShareFiles              = "sharefiles.html"
 	pageClientFilesTitle            = "My Files"
 	pageClientSharesTitle           = "Shares"
 	pageClientProfileTitle          = "My Profile"
@@ -53,6 +54,7 @@ const (
 	pageClientEditFileTitle         = "Edit file"
 	pageClientForgotPwdTitle        = "SFTPGo WebClient - Forgot password"
 	pageClientResetPwdTitle         = "SFTPGo WebClient - Reset password"
+	pageExtShareTitle               = "Shared files"
 )
 
 // condResult is the result of an HTTP request precondition check.
@@ -134,6 +136,16 @@ type filesPage struct {
 	HasIntegrations bool
 }
 
+type shareFilesPage struct {
+	baseClientPage
+	CurrentDir  string
+	DirsURL     string
+	FilesURL    string
+	DownloadURL string
+	Error       string
+	Paths       []dirMapping
+}
+
 type clientMessagePage struct {
 	baseClientPage
 	Error   string
@@ -179,8 +191,8 @@ type clientSharePage struct {
 	IsAdd bool
 }
 
-func getFileObjectURL(baseDir, name string) string {
-	return fmt.Sprintf("%v?path=%v&_=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix())
+func getFileObjectURL(baseDir, name, baseWebPath string) string {
+	return fmt.Sprintf("%v?path=%v&_=%v", baseWebPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix())
 }
 
 func getFileObjectModTime(t time.Time) string {
@@ -244,6 +256,10 @@ func loadClientTemplates(templatesPath string) {
 	viewPDFPaths := []string{
 		filepath.Join(templatesPath, templateClientDir, templateClientViewPDF),
 	}
+	shareFilesPath := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBase),
+		filepath.Join(templatesPath, templateClientDir, templateShareFiles),
+	}
 
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	profileTmpl := util.LoadTemplate(nil, profilePaths...)
@@ -259,6 +275,7 @@ func loadClientTemplates(templatesPath string) {
 	forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...)
 	resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...)
 	viewPDFTmpl := util.LoadTemplate(nil, viewPDFPaths...)
+	shareFilesTmpl := util.LoadTemplate(nil, shareFilesPath...)
 
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
@@ -274,6 +291,7 @@ func loadClientTemplates(templatesPath string) {
 	clientTemplates[templateForgotPassword] = forgotPwdTmpl
 	clientTemplates[templateResetPassword] = resetPwdTmpl
 	clientTemplates[templateClientViewPDF] = viewPDFTmpl
+	clientTemplates[templateShareFiles] = shareFilesTmpl
 }
 
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
@@ -440,6 +458,41 @@ func renderAddUpdateSharePage(w http.ResponseWriter, r *http.Request, share *dat
 	renderClientTemplate(w, templateClientShare, data)
 }
 
+func getDirMapping(dirName, baseWebPath string) []dirMapping {
+	paths := []dirMapping{}
+	if dirName != "/" {
+		paths = append(paths, dirMapping{
+			DirName: path.Base(dirName),
+			Href:    "",
+		})
+		for {
+			dirName = path.Dir(dirName)
+			if dirName == "/" || dirName == "." {
+				break
+			}
+			paths = append([]dirMapping{{
+				DirName: path.Base(dirName),
+				Href:    getFileObjectURL("/", dirName, baseWebPath)},
+			}, paths...)
+		}
+	}
+	return paths
+}
+
+func renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, share dataprovider.Share) {
+	currentURL := path.Join(webClientPubSharesPath, share.ShareID, "browse")
+	data := shareFilesPage{
+		baseClientPage: getBaseClientPageData(pageExtShareTitle, currentURL, r),
+		CurrentDir:     url.QueryEscape(dirName),
+		DirsURL:        path.Join(webClientPubSharesPath, share.ShareID, "dirs"),
+		FilesURL:       currentURL,
+		DownloadURL:    path.Join(webClientPubSharesPath, share.ShareID),
+		Error:          error,
+		Paths:          getDirMapping(dirName, currentURL),
+	}
+	renderClientTemplate(w, templateShareFiles, data)
+}
+
 func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User,
 	hasIntegrations bool,
 ) {
@@ -458,25 +511,8 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error stri
 		CanDownload:     user.HasPerm(dataprovider.PermDownload, dirName),
 		CanShare:        user.CanManageShares(),
 		HasIntegrations: hasIntegrations,
+		Paths:           getDirMapping(dirName, webClientFilesPath),
 	}
-	paths := []dirMapping{}
-	if dirName != "/" {
-		paths = append(paths, dirMapping{
-			DirName: path.Base(dirName),
-			Href:    "",
-		})
-		for {
-			dirName = path.Dir(dirName)
-			if dirName == "/" || dirName == "." {
-				break
-			}
-			paths = append([]dirMapping{{
-				DirName: path.Base(dirName),
-				Href:    getFileObjectURL("/", dirName)},
-			}, paths...)
-		}
-	}
-	data.Paths = paths
 	renderClientTemplate(w, templateClientFiles, data)
 }
 
@@ -560,6 +596,95 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
 	renderCompressedFiles(w, connection, name, filesList, nil)
 }
 
+func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	if err != nil {
+		return
+	}
+	if err := validateBrowsableShare(share, connection); err != nil {
+		renderClientMessagePage(w, r, "Unable to validate share", "", getRespStatus(err), err, "")
+		return
+	}
+	name, err := getBrowsableSharedPath(share, r)
+	if err != nil {
+		renderClientMessagePage(w, r, "Invalid share path", "", getRespStatus(err), err, "")
+		return
+	}
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	contents, err := connection.ReadDir(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
+		return
+	}
+	results := make([]map[string]string, 0, len(contents))
+	for _, info := range contents {
+		if !info.Mode().IsDir() && !info.Mode().IsRegular() {
+			continue
+		}
+		res := make(map[string]string)
+		if info.IsDir() {
+			res["type"] = "1"
+			res["size"] = ""
+		} else {
+			res["type"] = "2"
+			res["size"] = util.ByteCountIEC(info.Size())
+		}
+		res["name"] = info.Name()
+		res["url"] = getFileObjectURL(share.GetRelativePath(name), info.Name(),
+			path.Join(webClientPubSharesPath, share.ShareID, "browse"))
+		res["last_modified"] = getFileObjectModTime(info.ModTime())
+		results = append(results, res)
+	}
+
+	render.JSON(w, r, results)
+}
+
+func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	if err != nil {
+		return
+	}
+	if err := validateBrowsableShare(share, connection); err != nil {
+		renderClientMessagePage(w, r, "Unable to validate share", "", getRespStatus(err), err, "")
+		return
+	}
+	name, err := getBrowsableSharedPath(share, r)
+	if err != nil {
+		renderClientMessagePage(w, r, "Invalid share path", "", getRespStatus(err), err, "")
+		return
+	}
+
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	var info os.FileInfo
+	if name == "/" {
+		info = vfs.NewFileInfo(name, true, 0, time.Now(), false)
+	} else {
+		info, err = connection.Stat(name, 1)
+	}
+	if err != nil {
+		renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), err.Error(), share)
+		return
+	}
+	if info.IsDir() {
+		renderSharedFilesPage(w, r, share.GetRelativePath(name), "", share)
+		return
+	}
+	inline := r.URL.Query().Get("inline") != ""
+	dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
+	if status, err := downloadFile(w, r, connection, name, info, inline, &share); err != nil {
+		dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck
+		if status > 0 {
+			renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), err.Error(), share)
+		}
+	}
+}
+
 func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	claims, err := getTokenClaims(r)
@@ -602,7 +727,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
 	results := make([]map[string]string, 0, len(contents))
 	for _, info := range contents {
 		res := make(map[string]string)
-		res["url"] = getFileObjectURL(name, info.Name())
+		res["url"] = getFileObjectURL(name, info.Name(), webClientFilesPath)
 		if info.IsDir() {
 			res["type"] = "1"
 			res["size"] = ""
@@ -685,7 +810,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 		return
 	}
 	inline := r.URL.Query().Get("inline") != ""
-	if status, err := downloadFile(w, r, connection, name, info, inline); err != nil && status != 0 {
+	if status, err := downloadFile(w, r, connection, name, info, inline, nil); err != nil && status != 0 {
 		if status > 0 {
 			if status == http.StatusRequestedRangeNotSatisfiable {
 				renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "")

+ 96 - 1
openapi/openapi.yaml

@@ -23,7 +23,7 @@ info:
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
     SFTPGo allows to create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
-  version: 2.2.1-dev
+  version: 2.2.2-dev
   contact:
     name: API support
     url: 'https://github.com/drakkan/sftpgo'
@@ -138,6 +138,101 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+  /shares/{id}/files:
+    parameters:
+      - name: id
+        in: path
+        description: the share id
+        required: true
+        schema:
+          type: string
+    get:
+      security:
+        - BasicAuth: []
+      tags:
+        - public shares
+      summary: Download a single file
+      description: Returns the file contents as response body. The share must have exactly one path defined and it must be a directory for this to work
+      operationId: download_share_file
+      parameters:
+        - in: query
+          name: path
+          required: true
+          description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt"
+          schema:
+            type: string
+        - in: query
+          name: inline
+          required: false
+          description: 'If set, the response will not have the Content-Disposition header set to `attachment`'
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            '*/*':
+              schema:
+                type: string
+                format: binary
+        '206':
+          description: successful operation
+          content:
+            '*/*':
+              schema:
+                type: string
+                format: binary
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /shares/{id}/dirs:
+    parameters:
+      - name: id
+        in: path
+        description: the share id
+        required: true
+        schema:
+          type: string
+    get:
+      security:
+        - BasicAuth: []
+      tags:
+        - public shares
+      summary: Read directory contents
+      description: Returns the contents of the specified directory for the specified share. The share must have exactly one path defined and it must be a directory for this to work
+      operationId: get_share_dir_contents
+      parameters:
+        - in: query
+          name: path
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/DirEntry'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /shares/{id}/{fileName}:
     parameters:
       - name: id

+ 1 - 1
pkgs/build.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 
-NFPM_VERSION=2.11.3
+NFPM_VERSION=2.12.1
 NFPM_ARCH=${NFPM_ARCH:-amd64}
 if [ -z ${SFTPGO_VERSION} ]
 then

+ 6 - 0
pkgs/debian/changelog

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

+ 1 - 1
templates/webclient/files.html

@@ -1088,7 +1088,7 @@
                 table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
             },
             "orderFixed": [1, 'asc'],
-            "order": [[2, 'asc']]
+            "order": [2, 'asc']
         });
 
         new $.fn.dataTable.FixedHeader(table);

+ 296 - 0
templates/webclient/sharefiles.html

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

+ 4 - 1
templates/webclient/shares.html

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

+ 1 - 1
version/version.go

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