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:
Nicola Murino
2026-08-27 15:27:27 +02:00
parent 2fdfb94f55
commit ffeec76674
5 changed files with 185 additions and 9 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+12
View File
@@ -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) {
+36 -6
View File
@@ -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)
}
+134
View File
@@ -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 {