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 <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-11-19 19:39:28 +01:00
parent 6ebe7691db
commit 2290137868
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
6 changed files with 102 additions and 4 deletions

View file

@ -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. 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! If you find any other quirks or problems please let us know opening a GitHub issue, thank you!

2
go.mod
View file

@ -96,7 +96,7 @@ require (
github.com/aws/smithy-go v1.13.4 // indirect github.com/aws/smithy-go v1.13.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode 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/cespare/xxhash/v2 v2.1.2 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect

3
go.sum
View file

@ -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 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.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/cenkalti/backoff/v4 v4.1.2/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.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.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/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= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=

View file

@ -19,7 +19,9 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"strconv"
"strings" "strings"
"time"
"github.com/drakkan/webdav" "github.com/drakkan/webdav"
@ -36,6 +38,18 @@ type Connection struct {
request *http.Request 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. // GetClientVersion returns the connected client's version.
func (c *Connection) GetClientVersion() string { func (c *Connection) GetClientVersion() string {
if c.request != nil { if c.request != nil {
@ -85,7 +99,19 @@ func (c *Connection) Rename(ctx context.Context, oldName, newName string) error
oldName = util.CleanPath(oldName) oldName = util.CleanPath(oldName)
newName = util.CleanPath(newName) 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, // 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, baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, resolvedPath, filePath, requestPath,
common.TransferUpload, 0, 0, maxWriteSize, 0, true, fs, transferQuota) common.TransferUpload, 0, 0, maxWriteSize, 0, true, fs, transferQuota)
mtime := c.getModificationTime()
baseTransfer.SetTimes(resolvedPath, mtime, mtime)
return newWebDavFile(baseTransfer, w, nil), nil 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, baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, resolvedPath, filePath, requestPath,
common.TransferUpload, 0, initialSize, maxWriteSize, truncatedSize, false, fs, transferQuota) common.TransferUpload, 0, initialSize, maxWriteSize, truncatedSize, false, fs, transferQuota)
mtime := c.getModificationTime()
baseTransfer.SetTimes(resolvedPath, mtime, mtime)
return newWebDavFile(baseTransfer, w, nil), nil return newWebDavFile(baseTransfer, w, nil), nil
} }

View file

@ -470,6 +470,7 @@ func TestConnWithNilRequest(t *testing.T) {
assert.Empty(t, c.GetClientVersion()) assert.Empty(t, c.GetClientVersion())
assert.Empty(t, c.GetCommand()) assert.Empty(t, c.GetCommand())
assert.Empty(t, c.GetRemoteAddress()) assert.Empty(t, c.GetRemoteAddress())
assert.True(t, c.getModificationTime().IsZero())
} }
func TestResolvePathErrors(t *testing.T) { func TestResolvePathErrors(t *testing.T) {

View file

@ -245,6 +245,7 @@ XMf5HU3ThYqYn3bYypZZ8nQ7BXVh4LqGNqG29wR4v6l+dLO6odXnLzfApGD9e+d4
tlsClient1Username = "client1" tlsClient1Username = "client1"
tlsClient2Username = "client2" tlsClient2Username = "client2"
emptyPwdPlaceholder = "empty" emptyPwdPlaceholder = "empty"
ocMtimeHeader = "X-OC-Mtime"
) )
var ( var (
@ -820,6 +821,66 @@ func TestLockAfterDelete(t *testing.T) {
assert.NoError(t, err) 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) { func TestRenameWithLock(t *testing.T) {
u := getTestUser() u := getTestUser()
user, _, err := httpdtest.AddUser(u, http.StatusCreated) 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, 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 { ) error {
srcFile, err := os.Open(localSourcePath) srcFile, err := os.Open(localSourcePath)
if err != nil { if err != nil {
@ -3012,6 +3073,9 @@ func uploadFileWithRawClient(localSourcePath string, remoteDestPath string, user
return err return err
} }
req.SetBasicAuth(username, password) req.SetBasicAuth(username, password)
for _, kv := range headers {
req.Header.Set(kv.Key, kv.Value)
}
httpClient := &http.Client{Timeout: 10 * time.Second} httpClient := &http.Client{Timeout: 10 * time.Second}
if tlsConfig != nil { if tlsConfig != nil {
customTransport := http.DefaultTransport.(*http.Transport).Clone() customTransport := http.DefaultTransport.(*http.Transport).Clone()