From 2290137868ee83ddf7a8cef133f51e25ededd968 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sat, 19 Nov 2022 19:39:28 +0100 Subject: [PATCH] WebDAV: add support for X-OC-Mtime header it is used by Nextcloud compatible clients to set the modification time Signed-off-by: Nicola Murino --- docs/webdav.md | 2 + go.mod | 2 +- go.sum | 3 +- internal/webdavd/handler.go | 32 ++++++++++++++- internal/webdavd/internal_test.go | 1 + internal/webdavd/webdavd_test.go | 66 ++++++++++++++++++++++++++++++- 6 files changed, 102 insertions(+), 4 deletions(-) diff --git a/docs/webdav.md b/docs/webdav.md index 37a82d3a..bf528977 100644 --- a/docs/webdav.md +++ b/docs/webdav.md @@ -32,4 +32,6 @@ SFTPGo has a minimal implementation for [Dead Properties](https://tools.ietf.org To properly support dead properties we need a design decision, probably the best solution is to write a plugin and store them inside a supported data provider. +SFTPGo also supports setting the modification time using the `X-OC-Mtime` header. Nextcloud compatible clients set this header. + If you find any other quirks or problems please let us know opening a GitHub issue, thank you! diff --git a/go.mod b/go.mod index 6a2f0a62..9ccd1c71 100644 --- a/go.mod +++ b/go.mod @@ -96,7 +96,7 @@ require ( github.com/aws/smithy-go v1.13.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/boombuler/barcode v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.1.3 // indirect + github.com/cenkalti/backoff/v4 v4.2.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect diff --git a/go.sum b/go.sum index 575c558c..eeed7de5 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,9 @@ github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= +github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= diff --git a/internal/webdavd/handler.go b/internal/webdavd/handler.go index 40b92db6..841353cf 100644 --- a/internal/webdavd/handler.go +++ b/internal/webdavd/handler.go @@ -19,7 +19,9 @@ import ( "net/http" "os" "path" + "strconv" "strings" + "time" "github.com/drakkan/webdav" @@ -36,6 +38,18 @@ type Connection struct { request *http.Request } +func (c *Connection) getModificationTime() time.Time { + if c.request == nil { + return time.Time{} + } + if val := c.request.Header.Get("X-OC-Mtime"); val != "" { + if unixTime, err := strconv.ParseInt(val, 10, 64); err == nil { + return time.Unix(unixTime, 0) + } + } + return time.Time{} +} + // GetClientVersion returns the connected client's version. func (c *Connection) GetClientVersion() string { if c.request != nil { @@ -85,7 +99,19 @@ func (c *Connection) Rename(ctx context.Context, oldName, newName string) error oldName = util.CleanPath(oldName) newName = util.CleanPath(newName) - return c.BaseConnection.Rename(oldName, newName) + err := c.BaseConnection.Rename(oldName, newName) + if err == nil { + if mtime := c.getModificationTime(); !mtime.IsZero() { + attrs := &common.StatAttributes{ + Flags: common.StatAttrTimes, + Atime: mtime, + Mtime: mtime, + } + setStatErr := c.SetStat(newName, attrs) + c.Log(logger.LevelDebug, "mtime header found for %q, value: %s, err: %v", newName, mtime, setStatErr) + } + } + return err } // Stat returns a FileInfo describing the named file/directory, or an error, @@ -202,6 +228,8 @@ func (c *Connection) handleUploadToNewFile(fs vfs.Fs, resolvedPath, filePath, re baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, resolvedPath, filePath, requestPath, common.TransferUpload, 0, 0, maxWriteSize, 0, true, fs, transferQuota) + mtime := c.getModificationTime() + baseTransfer.SetTimes(resolvedPath, mtime, mtime) return newWebDavFile(baseTransfer, w, nil), nil } @@ -260,6 +288,8 @@ func (c *Connection) handleUploadToExistingFile(fs vfs.Fs, resolvedPath, filePat baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, resolvedPath, filePath, requestPath, common.TransferUpload, 0, initialSize, maxWriteSize, truncatedSize, false, fs, transferQuota) + mtime := c.getModificationTime() + baseTransfer.SetTimes(resolvedPath, mtime, mtime) return newWebDavFile(baseTransfer, w, nil), nil } diff --git a/internal/webdavd/internal_test.go b/internal/webdavd/internal_test.go index 41ca5a03..335f8d1d 100644 --- a/internal/webdavd/internal_test.go +++ b/internal/webdavd/internal_test.go @@ -470,6 +470,7 @@ func TestConnWithNilRequest(t *testing.T) { assert.Empty(t, c.GetClientVersion()) assert.Empty(t, c.GetCommand()) assert.Empty(t, c.GetRemoteAddress()) + assert.True(t, c.getModificationTime().IsZero()) } func TestResolvePathErrors(t *testing.T) { diff --git a/internal/webdavd/webdavd_test.go b/internal/webdavd/webdavd_test.go index d8890d29..3b5d911d 100644 --- a/internal/webdavd/webdavd_test.go +++ b/internal/webdavd/webdavd_test.go @@ -245,6 +245,7 @@ XMf5HU3ThYqYn3bYypZZ8nQ7BXVh4LqGNqG29wR4v6l+dLO6odXnLzfApGD9e+d4 tlsClient1Username = "client1" tlsClient2Username = "client2" emptyPwdPlaceholder = "empty" + ocMtimeHeader = "X-OC-Mtime" ) var ( @@ -820,6 +821,66 @@ func TestLockAfterDelete(t *testing.T) { assert.NoError(t, err) } +func TestMtimeHeader(t *testing.T) { + u := getTestUser() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + client := getWebDavClient(user, false, nil) + assert.NoError(t, checkBasicFunc(client)) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFileSize := int64(65535) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword, + false, testFileSize, client, dataprovider.KeyValue{Key: ocMtimeHeader, Value: "1668879480"}) + assert.NoError(t, err) + // check the modification time + info, err := client.Stat(testFileName) + if assert.NoError(t, err) { + assert.Equal(t, time.Unix(1668879480, 0).UTC(), info.ModTime().UTC()) + } + // test on overwrite + err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword, + false, testFileSize, client, dataprovider.KeyValue{Key: ocMtimeHeader, Value: "1667879480"}) + assert.NoError(t, err) + info, err = client.Stat(testFileName) + if assert.NoError(t, err) { + assert.Equal(t, time.Unix(1667879480, 0).UTC(), info.ModTime().UTC()) + } + // invalid time will be silently ignored and the time set to now + err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword, + false, testFileSize, client, dataprovider.KeyValue{Key: ocMtimeHeader, Value: "not unix time"}) + assert.NoError(t, err) + info, err = client.Stat(testFileName) + if assert.NoError(t, err) { + assert.NotEqual(t, time.Unix(1667879480, 0).UTC(), info.ModTime().UTC()) + } + + req, err := http.NewRequest("MOVE", fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName), nil) + assert.NoError(t, err) + req.Header.Set("Overwrite", "T") + req.Header.Set("Destination", path.Join("/", testFileName+"rename")) + req.Header.Set(ocMtimeHeader, "1666779480") + req.SetBasicAuth(u.Username, u.Password) + httpClient := httpclient.GetHTTPClient() + resp, err := httpClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + // check the modification time + info, err = client.Stat(testFileName + "rename") + if assert.NoError(t, err) { + assert.Equal(t, time.Unix(1666779480, 0).UTC(), info.ModTime().UTC()) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestRenameWithLock(t *testing.T) { u := getTestUser() user, _, err := httpdtest.AddUser(u, http.StatusCreated) @@ -2989,7 +3050,7 @@ func checkFileSize(remoteDestPath string, expectedSize int64, client *gowebdav.C } func uploadFileWithRawClient(localSourcePath string, remoteDestPath string, username, password string, - useTLS bool, expectedSize int64, client *gowebdav.Client, + useTLS bool, expectedSize int64, client *gowebdav.Client, headers ...dataprovider.KeyValue, ) error { srcFile, err := os.Open(localSourcePath) if err != nil { @@ -3012,6 +3073,9 @@ func uploadFileWithRawClient(localSourcePath string, remoteDestPath string, user return err } req.SetBasicAuth(username, password) + for _, kv := range headers { + req.Header.Set(kv.Key, kv.Value) + } httpClient := &http.Client{Timeout: 10 * time.Second} if tlsConfig != nil { customTransport := http.DefaultTransport.(*http.Transport).Clone()