mirror of
https://github.com/drakkan/sftpgo.git
synced 2026-10-19 16:15:39 +00:00
webdav: return the correct ETag after upload, or no ETag if unavailable
Fixes #2282 Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
@@ -18,7 +18,7 @@ require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.4.3
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/drakkan/webdav v0.0.0-20260709145212-0af7fe6f3bb2
|
||||
github.com/drakkan/webdav v0.0.0-20260827102817-e59fe5de336d
|
||||
github.com/eikenb/pipeat v0.0.0-20260709234154-608894928e53
|
||||
github.com/fclairamb/ftpserverlib v0.32.1
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
|
||||
@@ -124,8 +124,8 @@ github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0 h1:EW9gIJRmt9lzk66
|
||||
github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f h1:S9JUlrOzjK58UKoLqqb40YLyVlt0bcIFtYrvnanV3zc=
|
||||
github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f/go.mod h1:4p8lUl4vQ80L598CygL+3IFtm+3nggvvW/palOlViwE=
|
||||
github.com/drakkan/webdav v0.0.0-20260709145212-0af7fe6f3bb2 h1:McnQpczddjoCZKoyb7l1RMAq4a8RyQ7Hfupe15/hfFw=
|
||||
github.com/drakkan/webdav v0.0.0-20260709145212-0af7fe6f3bb2/go.mod h1:zOVb1QDhwwqWn2L2qZ0U3swMSO4GTSNyIwXCGO/UGWE=
|
||||
github.com/drakkan/webdav v0.0.0-20260827102817-e59fe5de336d h1:orHahHnkVSs6BIA8TViUYnpAQ58Gdxlqz6r9Q0A35wA=
|
||||
github.com/drakkan/webdav v0.0.0-20260827102817-e59fe5de336d/go.mod h1:zOVb1QDhwwqWn2L2qZ0U3swMSO4GTSNyIwXCGO/UGWE=
|
||||
github.com/eikenb/pipeat v0.0.0-20260709234154-608894928e53 h1:tp32ZrpCDwPqZSHXm2w/DaofsilxI9n+5SOe3+14qeU=
|
||||
github.com/eikenb/pipeat v0.0.0-20260709234154-608894928e53/go.mod h1:XccPiThW83W5pzeOCsJAylEUtWeH+3zQVwiO402FXXc=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
|
||||
@@ -59,6 +59,7 @@ type BaseTransfer struct {
|
||||
mTime time.Time
|
||||
transferQuota dataprovider.TransferQuota
|
||||
metadata map[string]string
|
||||
finalInfo fs.FileInfo
|
||||
sync.Mutex
|
||||
errAbort error
|
||||
ErrTransfer error
|
||||
@@ -346,6 +347,7 @@ func (t *BaseTransfer) getUploadFileSize() (int64, int, error) {
|
||||
info, err := t.Fs.Stat(t.fsPath)
|
||||
if err == nil {
|
||||
fileSize = info.Size()
|
||||
t.finalInfo = info
|
||||
}
|
||||
if t.ErrTransfer != nil && vfs.IsCryptOsFs(t.Fs) {
|
||||
errDelete := t.Fs.Remove(t.fsPath, false)
|
||||
@@ -434,6 +436,7 @@ func (t *BaseTransfer) Close() error {
|
||||
t.ErrTransfer)
|
||||
}
|
||||
if t.ErrTransfer != nil {
|
||||
t.finalInfo = nil
|
||||
t.Connection.Log(logger.LevelError, "transfer error: %v, path: %q", t.ErrTransfer, t.fsPath)
|
||||
if err == nil {
|
||||
err = t.ErrTransfer
|
||||
@@ -504,9 +507,18 @@ func (t *BaseTransfer) updateTimes() {
|
||||
err := t.Fs.Chtimes(t.fsPath, t.aTime, t.mTime, false)
|
||||
t.Connection.Log(logger.LevelDebug, "set times for file %q, atime: %v, mtime: %v, err: %v",
|
||||
t.fsPath, t.aTime, t.mTime, err)
|
||||
if err == nil && t.finalInfo != nil {
|
||||
t.finalInfo = vfs.NewFileInfo(t.finalInfo.Name(), false, t.finalInfo.Size(), t.mTime, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetFinalInfo returns information about the uploaded file, or nil if
|
||||
// unavailable
|
||||
func (t *BaseTransfer) GetFinalInfo() fs.FileInfo {
|
||||
return t.finalInfo
|
||||
}
|
||||
|
||||
func (t *BaseTransfer) updateQuota(numFiles int, fileSize int64) bool {
|
||||
// Uploads on some filesystem (S3 and similar) are atomic, if there is an error nothing is uploaded
|
||||
if t.File == nil && t.ErrTransfer != nil && vfs.HasImplicitAtomicUploads(t.Fs) {
|
||||
|
||||
@@ -44,12 +44,13 @@ var _ webdav.TransferErrorHandler = (*webDavFile)(nil)
|
||||
|
||||
type webDavFile struct {
|
||||
*common.BaseTransfer
|
||||
writer io.WriteCloser
|
||||
reader io.ReadCloser
|
||||
info os.FileInfo
|
||||
startOffset int64
|
||||
isFinished bool
|
||||
readTried atomic.Bool
|
||||
writer io.WriteCloser
|
||||
reader io.ReadCloser
|
||||
info os.FileInfo
|
||||
startOffset int64
|
||||
isFinished bool
|
||||
readTried atomic.Bool
|
||||
uploadFinalized atomic.Bool
|
||||
}
|
||||
|
||||
func newWebDavFile(baseTransfer *common.BaseTransfer, pipeWriter vfs.PipeWriter, pipeReader vfs.PipeReader) *webDavFile {
|
||||
@@ -134,6 +135,9 @@ func (f *webDavFile) Stat() (os.FileInfo, error) {
|
||||
if f.GetType() == common.TransferDownload && !f.Connection.User.HasPerm(dataprovider.PermListItems, path.Dir(f.GetVirtualPath())) {
|
||||
return nil, f.Connection.GetPermissionDeniedError()
|
||||
}
|
||||
if f.uploadFinalized.Load() {
|
||||
return f.statUploadedFile()
|
||||
}
|
||||
f.Lock()
|
||||
errUpload := f.ErrTransfer
|
||||
f.Unlock()
|
||||
@@ -162,6 +166,29 @@ func (f *webDavFile) Stat() (os.FileInfo, error) {
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
func (f *webDavFile) statUploadedFile() (os.FileInfo, error) {
|
||||
info := f.GetFinalInfo()
|
||||
if info == nil {
|
||||
if !vfs.IsLocalOrCryptoFs(f.Fs) {
|
||||
return nil, errors.New("no info available for the uploaded file")
|
||||
}
|
||||
var err error
|
||||
info, err = f.Fs.Stat(f.GetFsPath())
|
||||
if err != nil {
|
||||
return nil, f.Connection.GetFsError(f.Fs, err)
|
||||
}
|
||||
}
|
||||
if vfs.IsCryptOsFs(f.Fs) {
|
||||
info = f.Fs.(*vfs.CryptFs).ConvertFileInfo(info)
|
||||
}
|
||||
return &webDavFileInfo{
|
||||
FileInfo: info,
|
||||
Fs: f.Fs,
|
||||
virtualPath: f.GetVirtualPath(),
|
||||
fsPath: f.GetFsPath(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *webDavFile) checkFirstRead() error {
|
||||
if !f.Connection.User.HasPerm(dataprovider.PermDownload, path.Dir(f.GetVirtualPath())) {
|
||||
return f.Connection.GetPermissionDeniedError()
|
||||
@@ -387,6 +414,9 @@ func (f *webDavFile) Close() error {
|
||||
} else {
|
||||
f.Connection.RemoveTransfer(f.BaseTransfer)
|
||||
}
|
||||
if err == nil && f.GetType() == common.TransferUpload {
|
||||
f.uploadFinalized.Store(true)
|
||||
}
|
||||
return f.Connection.GetFsError(f.Fs, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -971,6 +971,74 @@ func TestMtimeHeader(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUploadETag(t *testing.T) {
|
||||
u := getTestUser()
|
||||
u.Username += "1"
|
||||
localUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
sftpUser := getTestSFTPUser()
|
||||
sftpUser.HomeDir = filepath.Join(homeBasePath, sftpUser.Username)
|
||||
sftpUser.FsConfig.SFTPConfig.Username = localUser.Username
|
||||
sftpUserWithQuota := getTestSFTPUser()
|
||||
sftpUserWithQuota.Username += "_quota"
|
||||
sftpUserWithQuota.HomeDir = filepath.Join(homeBasePath, sftpUserWithQuota.Username)
|
||||
sftpUserWithQuota.FsConfig.SFTPConfig.Username = localUser.Username
|
||||
sftpUserWithQuota.QuotaSize = 6553600
|
||||
|
||||
cryptUserWithQuota := getTestUserWithCryptFs()
|
||||
cryptUserWithQuota.Username += "_quota"
|
||||
cryptUserWithQuota.QuotaSize = 6553600
|
||||
|
||||
testFilePath := filepath.Join(homeBasePath, testFileName)
|
||||
testFileSize := int64(65535)
|
||||
err = createTestFile(testFilePath, testFileSize)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
user dataprovider.User
|
||||
hasETag bool
|
||||
}{
|
||||
{getTestUser(), true},
|
||||
{getTestUserWithCryptFs(), true},
|
||||
{cryptUserWithQuota, true},
|
||||
{sftpUser, false},
|
||||
{sftpUserWithQuota, true},
|
||||
} {
|
||||
user, _, err := httpdtest.AddUser(tc.user, http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
etag, err := uploadFileGetETag(testFilePath, testFileName, user.Username, defaultPassword)
|
||||
assert.NoError(t, err, user.Username)
|
||||
if tc.hasETag {
|
||||
assert.NotEmpty(t, etag, user.Username)
|
||||
headETag, err := getETagWithHead(testFileName, user.Username, defaultPassword)
|
||||
assert.NoError(t, err, user.Username)
|
||||
assert.Equal(t, etag, headETag, user.Username)
|
||||
body, err := getPropfindResponse(testFileName, user.Username, defaultPassword)
|
||||
assert.NoError(t, err, user.Username)
|
||||
assert.Contains(t, body, strings.Trim(etag, `"`), user.Username)
|
||||
etag, err = uploadFileGetETag(testFilePath, testFileName, user.Username, defaultPassword,
|
||||
dataprovider.KeyValue{Key: ocMtimeHeader, Value: "1668879480"})
|
||||
assert.NoError(t, err, user.Username)
|
||||
headETag, err = getETagWithHead(testFileName, user.Username, defaultPassword)
|
||||
assert.NoError(t, err, user.Username)
|
||||
assert.Equal(t, etag, headETag, user.Username)
|
||||
} else {
|
||||
assert.Empty(t, etag, user.Username)
|
||||
}
|
||||
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
err = os.RemoveAll(user.GetHomeDir())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = httpdtest.RemoveUser(localUser, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
err = os.RemoveAll(localUser.GetHomeDir())
|
||||
assert.NoError(t, err)
|
||||
err = os.Remove(testFilePath)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRenameWithLock(t *testing.T) {
|
||||
u := getTestUser()
|
||||
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
|
||||
@@ -3510,6 +3578,72 @@ func uploadFileWithRawClient(localSourcePath string, remoteDestPath string, user
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadFileGetETag(localSourcePath, remoteDestPath, username, password string,
|
||||
headers ...dataprovider.KeyValue,
|
||||
) (string, error) {
|
||||
data, err := os.ReadFile(localSourcePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("http://%v/%v", webDavServerAddr, remoteDestPath),
|
||||
struct{ io.Reader }{bytes.NewReader(data)})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
for _, kv := range headers {
|
||||
req.Header.Set(kv.Key, kv.Value)
|
||||
}
|
||||
resp, err := httpclient.GetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("unexpected status code: %v", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
func getETagWithHead(remotePath, username, password string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodHead, fmt.Sprintf("http://%v/%v", webDavServerAddr, remotePath), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
resp, err := httpclient.GetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status code: %v", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
func getPropfindResponse(remotePath, username, password string) (string, error) {
|
||||
req, err := http.NewRequest("PROPFIND", fmt.Sprintf("http://%v/%v", webDavServerAddr, remotePath), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Depth", "0")
|
||||
req.SetBasicAuth(username, password)
|
||||
resp, err := httpclient.GetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
return "", fmt.Errorf("unexpected status code: %v", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
return string(body), err
|
||||
}
|
||||
|
||||
// This method is buggy. I have to find time to better investigate and eventually report the issue upstream.
|
||||
// For now we upload using the uploadFileWithRawClient method
|
||||
/*func uploadFile(localSourcePath string, remoteDestPath string, expectedSize int64, client *gowebdav.Client) error {
|
||||
|
||||
Reference in New Issue
Block a user