web client: allow downloading of single shared files without compression
Fixes #629
This commit is contained in:
parent
5db31f0fb3
commit
4df0ae82ac
8 changed files with 166 additions and 21 deletions
|
@ -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
|
||||
|
|
|
@ -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
8
go.mod
|
@ -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
16
go.sum
|
@ -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=
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue