web client: allow downloading of single shared files without compression

Fixes #629
This commit is contained in:
Nicola Murino 2021-11-30 20:32:10 +01:00
parent 5db31f0fb3
commit 4df0ae82ac
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
8 changed files with 166 additions and 21 deletions

View file

@ -1088,7 +1088,7 @@ func (c *BaseConnection) GetPermissionDeniedError() error {
switch c.protocol {
case ProtocolSFTP:
return sftp.ErrSSHFxPermissionDenied
case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP:
case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention:
return os.ErrPermission
default:
return ErrPermissionDenied
@ -1100,7 +1100,7 @@ func (c *BaseConnection) GetNotExistError() error {
switch c.protocol {
case ProtocolSFTP:
return sftp.ErrSSHFxNoSuchFile
case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP:
case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention:
return os.ErrNotExist
default:
return ErrNotExist

View file

@ -256,7 +256,8 @@ func TestErrorsMapping(t *testing.T) {
err := conn.GetFsError(fs, os.ErrNotExist)
if protocol == ProtocolSFTP {
assert.ErrorIs(t, err, sftp.ErrSSHFxNoSuchFile)
} else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP {
} else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP ||
protocol == ProtocolHTTPShare || protocol == ProtocolDataRetention {
assert.EqualError(t, err, os.ErrNotExist.Error())
} else {
assert.EqualError(t, err, ErrNotExist.Error())

8
go.mod
View file

@ -6,8 +6,8 @@ require (
cloud.google.com/go/storage v1.18.2
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-20210511081203-7d35d68092b8
github.com/aws/aws-sdk-go v1.42.13
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/aws/aws-sdk-go v1.42.15
github.com/cockroachdb/cockroach-go/v2 v2.2.4
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
github.com/fclairamb/ftpserverlib v0.16.0
@ -128,8 +128,8 @@ require (
golang.org/x/text v0.3.7 // 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-20211118181313-81c1377c94b1 // indirect
gopkg.in/ini.v1 v1.65.0 // indirect
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12 // indirect
gopkg.in/ini.v1 v1.66.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

16
go.sum
View file

@ -126,8 +126,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8 h1:ZtMr6/tt7VU/Ijpyyedn7eUwwsNX1uskEcR+maLEF18=
github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8/go.mod h1:Kmn5t2Rb93Q4NTprN4+CCgARGvigKMJyxP0WckpTUp0=
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I=
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@ -137,8 +137,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.38.68/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.13 h1:+Nx87T+Bjiq2XybxK6vI98cTEBPLE/hILuZyEenlyEg=
github.com/aws/aws-sdk-go v1.42.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.15 h1:RcUChuF7KzrrTqx9LAzJbLBX00LkUY7cH9T1VdxNdqk=
github.com/aws/aws-sdk-go v1.42.15/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
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=
@ -1191,8 +1191,8 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 h1:b9mVrqYfq3P4bCdaLg1qtBnPzUYgglsIdjZkL/fQVOE=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12 h1:DN5b3HU13J4sMd/QjDx34U6afpaexKTDdop+26pdjdk=
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/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=
@ -1250,8 +1250,8 @@ gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWd
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.65.0 h1:B2//IEITFk89S+Nl2tozBeqUvFEpUAY6daarSlrx8jU=
gopkg.in/ini.v1 v1.65.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.0 h1:tYFFjdYXTsNBxJhYBABRbTuaKkX6UBzOvbYwhEcaZJQ=
gopkg.in/ini.v1 v1.66.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=

View file

@ -1,9 +1,11 @@
package httpd
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"github.com/go-chi/render"
"github.com/rs/xid"
@ -141,9 +143,37 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) {
common.Connections.Add(connection)
defer common.Connections.Remove(connection.GetID())
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 err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
if !info.IsDir() {
compress = false
}
}
dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.ShareID))
renderCompressedFiles(w, connection, "/", share.Paths, &share)
if compress {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.ShareID))
renderCompressedFiles(w, connection, "/", share.Paths, &share)
return
}
if status, err := downloadFile(w, r, connection, share.Paths[0], info, false); 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 uploadToShare(w http.ResponseWriter, r *http.Request) {

View file

@ -8867,7 +8867,7 @@ func TestShareUsage(t *testing.T) {
req.Header.Add("Content-Type", writer.FormDataContentType())
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "permission denied")
body = new(bytes.Buffer)
@ -8913,6 +8913,110 @@ func TestShareUsage(t *testing.T) {
executeRequest(req)
}
func TestShareUncompressed(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
testFileName := "testfile.dat"
testFileSize := int64(65536)
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",
Scope: dataprovider.ShareScopeRead,
Paths: []string{"/"},
Password: defaultPassword,
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, webClientPubSharesPath+"/"+objectID, nil)
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Equal(t, "application/zip", rr.Header().Get("Content-Type"))
req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil)
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Equal(t, "application/zip", rr.Header().Get("Content-Type"))
share = dataprovider.Share{
Name: "test share1",
Scope: dataprovider.ShareScopeRead,
Paths: []string{testFileName},
Password: defaultPassword,
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, webClientPubSharesPath+"/"+objectID, nil)
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Equal(t, "application/zip", rr.Header().Get("Content-Type"))
req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil)
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
user.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload}
user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil)
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
user.Permissions["/"] = []string{dataprovider.PermAny}
user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
err = os.Remove(testFilePath)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil)
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
_, 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

@ -22,7 +22,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.0
version: 2.2.0-dev
contact:
name: API support
url: 'https://github.com/drakkan/sftpgo'
@ -69,11 +69,18 @@ paths:
summary: Download shared files and folders as a single zip file
description: A zip file, containing the shared files and folders, will be generated on the fly and returned as response body. Only folders and regular files will be included in the zip. The share must be defined with the read scope and the associated user must have list and download permissions
operationId: get_share
parameters:
- in: query
name: compress
schema:
type: boolean
default: true
required: false
responses:
'200':
description: successful operation
content:
'application/zip':
'*/*':
schema:
type: string
format: binary

View file

@ -92,7 +92,8 @@
</div>
<div class="modal-body">
<div id="readShare">
You can download the shared contents, as single zip file, using this <a id="readLink" href="#" target="_blank">link</a>
<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>
</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>
@ -213,6 +214,8 @@
$('#readShare').show();
$('#readLink').attr("href", shareURL);
$('#readLink').attr("title", shareURL);
$('#readUncompressedLink').attr("href", shareURL+"?compress=false");
$('#readUncompressedLink').attr("title", shareURL+"?compress=false");
} else {
$('#expiredShare').hide();
$('#writeShare').show();