diff --git a/README.md b/README.md index 3090c0c..b2fd88d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,19 @@ the log. --name webdav \ ``` +### Partial updates + +This server supports partial file updates compatible with SabreDAV's `PATCH` extension. This is not an official WebDAV specification. Requests must use the `application/x-sabredav-partialupdate` content type, include `Content-Length`, and provide the target range in `X-Update-Range`. + +Supported `X-Update-Range` values are: + +- `bytes=start-end` +- `bytes=start-` +- `bytes=-N` +- `append` + +For clients that use it, the server also supports partial `PUT` requests with `Content-Range`, for example `Content-Range: bytes 6-8/*`. This is an extra compatibility path and should be treated as a client/server agreement. + ## Configuration The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with @@ -136,6 +149,7 @@ cors: allowed_headers: - Authorization - Content-Type + - Content-Range - Depth - Destination - If @@ -143,6 +157,7 @@ cors: - Overwrite - TimeOut - Translate + - X-Update-Range allowed_methods: - COPY - DELETE @@ -153,6 +168,7 @@ cors: - MKCOL - MOVE - OPTIONS + - PATCH - POST - PROPFIND - PROPPATCH diff --git a/lib/config.go b/lib/config.go index 7526c7b..f3b63fe 100644 --- a/lib/config.go +++ b/lib/config.go @@ -87,8 +87,8 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { v.SetDefault("Log.Outputs", []string{"stderr"}) v.SetDefault("Log.Colors", true) v.SetDefault("CORS.Allowed_Hosts", []string{"*"}) - v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Depth", "Destination", "If", "Lock-Token", "Overwrite"}) - v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}) + v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}) + v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}) // Read and unmarshal configuration err := v.ReadInConfig() diff --git a/lib/config_test.go b/lib/config_test.go index 6108e0f..312e0b9 100644 --- a/lib/config_test.go +++ b/lib/config_test.go @@ -52,8 +52,8 @@ func TestConfigDefaults(t *testing.T) { require.Equal(t, dir, cfg.Directory) require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts) - require.EqualValues(t, []string{"Authorization", "Content-Type", "Depth", "Destination", "If", "Lock-Token", "Overwrite"}, cfg.CORS.AllowedHeaders) - require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods) + require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders) + require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods) } func TestConfigCascade(t *testing.T) { diff --git a/lib/handler.go b/lib/handler.go index 0deb220..b3839c2 100644 --- a/lib/handler.go +++ b/lib/handler.go @@ -176,6 +176,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + if r.Method == "OPTIONS" { + user.handleOptions(w, r, req.path) + return + } + + if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") { + user.handlePartialUpdate(w, r, req.path) + return + } + // Runs the WebDAV. user.ServeHTTP(w, r) } diff --git a/lib/handler_test.go b/lib/handler_test.go index 7ae87a0..0b60e67 100644 --- a/lib/handler_test.go +++ b/lib/handler_test.go @@ -2,10 +2,13 @@ package lib import ( "fmt" + "io" + "net/http" "net/http/httptest" "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/require" @@ -82,6 +85,404 @@ func TestServerDefaults(t *testing.T) { require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403") } +func TestServerPartialUpdateOptions(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, map[string][]byte{ + "foo.txt": []byte("hello world"), + }) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + req, err := http.NewRequest(http.MethodOptions, srv.URL+"/foo.txt", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Contains(t, resp.Header.Get("DAV"), "sabredav-partialupdate") + require.Contains(t, resp.Header.Get("Allow"), "PATCH") + require.Equal(t, partialUpdateContentType, resp.Header.Get("Accept-Patch")) +} + +func TestServerPatchPartialUpdate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + initialData string + body string + updateRange string + wantData string + }{{ + name: "start", + initialData: "hello world", + body: "DAV", + updateRange: "bytes=6-", + wantData: "hello DAVld", + }, { + name: "suffix", + initialData: "hello world", + body: "DAV", + updateRange: "bytes=-5", + wantData: "hello DAVld", + }, { + name: "append", + initialData: "hello", + body: " world", + updateRange: "append", + wantData: "hello world", + }, { + name: "suffix_zero", + initialData: "hello", + body: " world", + updateRange: "bytes=-0", + wantData: "hello world", + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, map[string][]byte{ + "foo.txt": []byte(tc.initialData), + }) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + req, err := http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader(tc.body)) + require.NoError(t, err) + req.Header.Set("Content-Type", partialUpdateContentType) + req.Header.Set("X-Update-Range", tc.updateRange) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusNoContent, resp.StatusCode) + data, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + require.NoError(t, err) + require.Equal(t, tc.wantData, string(data)) + }) + } +} + +func TestServerPatchPartialUpdateCreatesSparseFile(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, nil) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + req, err := http.NewRequest("PATCH", srv.URL+"/new.bin", strings.NewReader("x")) + require.NoError(t, err) + req.Header.Set("Content-Type", partialUpdateContentType) + req.Header.Set("X-Update-Range", "bytes=3-") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + data, err := os.ReadFile(filepath.Join(dir, "new.bin")) + require.NoError(t, err) + require.Equal(t, []byte{0, 0, 0, 'x'}, data) +} + +func TestServerPutContentRangePartialUpdate(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, map[string][]byte{ + "foo.txt": []byte("hello world"), + }) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + req, err := http.NewRequest(http.MethodPut, srv.URL+"/foo.txt", strings.NewReader("DAV")) + require.NoError(t, err) + req.Header.Set("Content-Range", "bytes 6-8/*") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + require.Equal(t, http.StatusNoContent, resp.StatusCode) + data, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + require.NoError(t, err) + require.Equal(t, "hello DAVld", string(data)) +} + +func TestServerPartialUpdateErrors(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + method string + body string + contentLength int64 + path string + headerName string + updateRange string + contentRange string + wantStatus int + }{{ + name: "patch_missing_content_length", + method: "PATCH", + body: "DAV", + contentLength: -1, + updateRange: "bytes=6-8", + wantStatus: http.StatusLengthRequired, + }, { + name: "patch_invalid_range", + method: "PATCH", + body: "DAV", + updateRange: "bytes=8-6", + wantStatus: http.StatusRequestedRangeNotSatisfiable, + }, { + name: "patch_length_mismatch", + method: "PATCH", + body: "TOOLONG", + updateRange: "bytes=6-8", + wantStatus: http.StatusRequestedRangeNotSatisfiable, + }, { + name: "put_content_range_length_mismatch", + method: http.MethodPut, + body: "TOOLONG", + contentLength: -1, + contentRange: "bytes 6-8/*", + wantStatus: http.StatusRequestedRangeNotSatisfiable, + }, { + name: "if_none_match", + method: "PATCH", + body: "DAV", + headerName: "If-None-Match", + updateRange: "bytes=0-2", + wantStatus: http.StatusPreconditionFailed, + }, { + name: "if_match", + method: "PATCH", + path: "/missing.txt", + body: "DAV", + headerName: "If-Match", + updateRange: "bytes=0-2", + wantStatus: http.StatusPreconditionFailed, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, map[string][]byte{ + "foo.txt": []byte("hello world"), + }) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + var body io.Reader = strings.NewReader(tc.body) + if tc.contentLength < 0 { + body = io.NopCloser(strings.NewReader(tc.body)) + } + path := tc.path + if path == "" { + path = "/foo.txt" + } + req, err := http.NewRequest(tc.method, srv.URL+path, body) + require.NoError(t, err) + if tc.contentLength < 0 { + req.ContentLength = tc.contentLength + } + if tc.method == "PATCH" { + req.Header.Set("Content-Type", partialUpdateContentType) + req.Header.Set("X-Update-Range", tc.updateRange) + } + if tc.contentRange != "" { + req.Header.Set("Content-Range", tc.contentRange) + } + if tc.headerName != "" { + req.Header.Set(tc.headerName, "*") + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, tc.wantStatus, resp.StatusCode) + data, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + require.NoError(t, err) + require.Equal(t, "hello world", string(data)) + }) + } +} + +func TestServerPartialUpdateETagPreconditions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + method string + headerName string + headerValue func(string) string + contentRange string + wantStatus int + wantData string + }{{ + name: "if_match_matches", + method: "PATCH", + headerName: "If-Match", + headerValue: func(etag string) string { return etag }, + wantStatus: http.StatusNoContent, + wantData: "hello DAVld", + }, { + name: "if_match_mismatch", + method: "PATCH", + headerName: "If-Match", + headerValue: func(string) string { return `"definitely-wrong"` }, + wantStatus: http.StatusPreconditionFailed, + wantData: "hello world", + }, { + name: "if_match_list_matches", + method: "PATCH", + headerName: "If-Match", + headerValue: func(etag string) string { return `"definitely-wrong", ` + etag }, + wantStatus: http.StatusNoContent, + wantData: "hello DAVld", + }, { + name: "if_none_match_matches", + method: "PATCH", + headerName: "If-None-Match", + headerValue: func(etag string) string { return etag }, + wantStatus: http.StatusPreconditionFailed, + wantData: "hello world", + }, { + name: "if_none_match_mismatch", + method: "PATCH", + headerName: "If-None-Match", + headerValue: func(string) string { return `"definitely-wrong"` }, + wantStatus: http.StatusNoContent, + wantData: "hello DAVld", + }, { + name: "put_content_range_if_match_mismatch", + method: http.MethodPut, + headerName: "If-Match", + headerValue: func(string) string { return `"definitely-wrong"` }, + contentRange: "bytes 6-8/*", + wantStatus: http.StatusPreconditionFailed, + wantData: "hello world", + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, map[string][]byte{ + "foo.txt": []byte("hello world"), + }) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + req, err := http.NewRequest(http.MethodHead, srv.URL+"/foo.txt", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + etag := resp.Header.Get("ETag") + require.NotEmpty(t, etag) + + req, err = http.NewRequest(tc.method, srv.URL+"/foo.txt", strings.NewReader("DAV")) + require.NoError(t, err) + if tc.method == "PATCH" { + req.Header.Set("Content-Type", partialUpdateContentType) + req.Header.Set("X-Update-Range", "bytes=6-8") + } + if tc.contentRange != "" { + req.Header.Set("Content-Range", tc.contentRange) + } + req.Header.Set(tc.headerName, tc.headerValue(etag)) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, tc.wantStatus, resp.StatusCode) + data, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + require.NoError(t, err) + require.Equal(t, tc.wantData, string(data)) + }) + } +} + +func TestServerPartialUpdateHonorsLocks(t *testing.T) { + t.Parallel() + + const createLockBody = ` + + + + test + ` + + testCases := []struct { + name string + lockPath string + depth string + ifPath string + }{{ + name: "file", + lockPath: "/foo.txt", + depth: "0", + ifPath: "/foo.txt", + }, { + name: "root", + lockPath: "/", + depth: "infinity", + ifPath: "/", + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dir := makeTestDirectory(t, map[string][]byte{ + "foo.txt": []byte("hello world"), + }) + srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD") + defer srv.Close() + + req, err := http.NewRequest("LOCK", srv.URL+tc.lockPath, strings.NewReader(createLockBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Depth", tc.depth) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + lockToken := resp.Header.Get("Lock-Token") + + req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV")) + require.NoError(t, err) + req.Header.Set("Content-Type", partialUpdateContentType) + req.Header.Set("X-Update-Range", "bytes=6-8") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, 423, resp.StatusCode) + + req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV")) + require.NoError(t, err) + req.Header.Set("Content-Type", partialUpdateContentType) + req.Header.Set("X-Update-Range", "bytes=6-8") + req.Header.Set("If", fmt.Sprintf("<%s%s> (%s)", srv.URL, tc.ifPath, lockToken)) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + data, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + require.NoError(t, err) + require.Equal(t, "hello DAVld", string(data)) + }) + } +} + func TestServerListingCharacters(t *testing.T) { t.Parallel() diff --git a/lib/partial_update.go b/lib/partial_update.go new file mode 100644 index 0000000..a796c86 --- /dev/null +++ b/lib/partial_update.go @@ -0,0 +1,526 @@ +package lib + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/net/webdav" +) + +const partialUpdateContentType = "application/x-sabredav-partialupdate" + +type updateRange struct { + offset int64 + end int64 + hasEnd bool + append bool +} + +type partialUpdateError struct { + status int + err error +} + +func (e partialUpdateError) Error() string { + return e.err.Error() +} + +func newPartialUpdateError(status int, message string) error { + return partialUpdateError{status: status, err: errors.New(message)} +} + +func writePartialUpdateError(w http.ResponseWriter, err error, fallbackStatus int) { + var httpErr partialUpdateError + if errors.As(err, &httpErr) { + fallbackStatus = httpErr.status + } + http.Error(w, err.Error(), fallbackStatus) +} + +func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) { + allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH" + if fi, err := u.FileSystem.Stat(r.Context(), reqPath); err == nil { + if fi.IsDir() { + allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND" + } else { + allow = "OPTIONS, LOCK, GET, HEAD, POST, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND, PUT, PATCH" + } + } + + w.Header().Set("Allow", allow) + w.Header().Set("DAV", "1, 2, sabredav-partialupdate") + w.Header().Set("MS-Author-Via", "DAV") + w.Header().Set("Accept-Patch", partialUpdateContentType) + w.WriteHeader(http.StatusOK) +} + +func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request, reqPath string) { + contentRange := r.Header.Get("Content-Range") + isContentRangePut := r.Method == "PUT" && contentRange != "" + + var ( + updateRange updateRange + err error + ) + if isContentRangePut { + updateRange, err = parseContentRange(contentRange) + } else { + if err := checkPartialUpdateContentType(r.Header.Get("Content-Type")); err != nil { + http.Error(w, err.Error(), http.StatusUnsupportedMediaType) + return + } + updateRange, err = parseUpdateRange(r.Header.Get("X-Update-Range")) + } + if err != nil { + writePartialUpdateError(w, err, http.StatusBadRequest) + return + } + if r.Method == "PATCH" && r.ContentLength < 0 { + http.Error(w, "missing content length", http.StatusLengthRequired) + return + } + + release, status, err := u.confirmPartialUpdateLocks(r, reqPath) + if err != nil { + http.Error(w, err.Error(), status) + return + } + defer release() + + ctx := r.Context() + fi, statErr := u.FileSystem.Stat(ctx, reqPath) + exists := statErr == nil + if statErr != nil && !os.IsNotExist(statErr) { + http.Error(w, statErr.Error(), http.StatusMethodNotAllowed) + return + } + if exists && fi.IsDir() { + http.Error(w, "cannot update a collection", http.StatusMethodNotAllowed) + return + } + + etag, status, err := u.checkPartialUpdatePreconditions(r, exists, fi) + if err != nil { + if etag != "" { + w.Header().Set("ETag", etag) + } + http.Error(w, err.Error(), status) + return + } + + currentSize := int64(0) + if exists { + currentSize = fi.Size() + } + if updateRange.append { + updateRange.offset = currentSize + } else if updateRange.offset < 0 { + updateRange.offset += currentSize + if updateRange.offset < 0 { + updateRange.offset = 0 + } + } + + if updateRange.hasEnd { + expected := updateRange.end - updateRange.offset + 1 + if expected < 0 { + http.Error(w, "invalid byte range", http.StatusRequestedRangeNotSatisfiable) + return + } + if r.ContentLength >= 0 && r.ContentLength != expected { + http.Error(w, "content length does not match byte range", http.StatusRequestedRangeNotSatisfiable) + return + } + } + + body := io.Reader(r.Body) + var cleanup func() + if updateRange.hasEnd { + body, cleanup, err = spoolBoundedBody(r.Body, updateRange.end-updateRange.offset+1) + if err != nil { + writePartialUpdateError(w, err, http.StatusMethodNotAllowed) + return + } + defer cleanup() + } + + flag := os.O_RDWR + if !exists { + flag |= os.O_CREATE + } + f, err := u.FileSystem.OpenFile(ctx, reqPath, flag, 0666) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + http.Error(w, err.Error(), http.StatusNotFound) + return + } + defer func() { _ = f.Close() }() + + if _, err := f.Seek(updateRange.offset, io.SeekStart); err != nil { + http.Error(w, err.Error(), http.StatusMethodNotAllowed) + return + } + if _, err := io.Copy(f, body); err != nil { + http.Error(w, err.Error(), http.StatusMethodNotAllowed) + return + } + + if !exists { + w.WriteHeader(http.StatusCreated) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func checkPartialUpdateContentType(contentType string) error { + if contentType == "" { + return errors.New("missing content type") + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return err + } + if mediaType != partialUpdateContentType { + return fmt.Errorf("unsupported content type %q", mediaType) + } + return nil +} + +func (u *handlerUser) checkPartialUpdatePreconditions(r *http.Request, exists bool, fi os.FileInfo) (etag string, status int, err error) { + ifMatch := r.Header.Get("If-Match") + ifNoneMatch := r.Header.Get("If-None-Match") + if ifMatch == "" && ifNoneMatch == "" { + return "", 0, nil + } + + if ifMatch != "" && !exists { + return "", http.StatusPreconditionFailed, errors.New("resource does not exist") + } + + if exists { + etag, err = findPartialETag(r.Context(), fi) + if err != nil { + return "", http.StatusInternalServerError, err + } + } + + if ifMatch != "" && !partialETagHeaderMatches(ifMatch, etag, exists) { + return etag, http.StatusPreconditionFailed, errors.New("etag does not match") + } + + if ifNoneMatch != "" && exists && partialETagHeaderMatches(ifNoneMatch, etag, true) { + return etag, http.StatusPreconditionFailed, errors.New("etag matches") + } + + return etag, 0, nil +} + +func findPartialETag(ctx context.Context, fi os.FileInfo) (string, error) { + if etager, ok := fi.(webdav.ETager); ok { + etag, err := etager.ETag(ctx) + if !errors.Is(err, webdav.ErrNotImplemented) { + return etag, err + } + } + return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()), nil +} + +func partialETagHeaderMatches(header, etag string, exists bool) bool { + for _, item := range strings.Split(header, ",") { + item = strings.TrimSpace(item) + if item == "*" { + return exists + } + if item == etag || strings.ReplaceAll(item, `\"`, `"`) == etag { + return true + } + } + return false +} + +func parseUpdateRange(header string) (updateRange, error) { + if header == "" { + return updateRange{}, errors.New("missing X-Update-Range header") + } + if header == "append" { + return updateRange{append: true}, nil + } + if !strings.HasPrefix(header, "bytes=") { + return updateRange{}, errors.New("invalid X-Update-Range header") + } + return parseByteRange(strings.TrimPrefix(header, "bytes="), true) +} + +func parseContentRange(header string) (updateRange, error) { + if !strings.HasPrefix(header, "bytes ") { + return updateRange{}, errors.New("invalid Content-Range header") + } + spec, _, ok := strings.Cut(strings.TrimPrefix(header, "bytes "), "/") + if !ok { + return updateRange{}, errors.New("invalid Content-Range header") + } + return parseByteRange(spec, false) +} + +func parseByteRange(spec string, allowNegativeStart bool) (updateRange, error) { + if strings.HasPrefix(spec, "-") { + if !allowNegativeStart { + return updateRange{}, errors.New("invalid byte range start") + } + start, err := strconv.ParseInt(strings.TrimPrefix(spec, "-"), 10, 64) + if err != nil || start < 0 { + return updateRange{}, errors.New("invalid byte range start") + } + if start == 0 { + return updateRange{append: true}, nil + } + return updateRange{offset: -start}, nil + } + + startText, endText, ok := strings.Cut(spec, "-") + if !ok || startText == "" { + return updateRange{}, errors.New("invalid byte range") + } + + start, err := strconv.ParseInt(startText, 10, 64) + if err != nil { + return updateRange{}, errors.New("invalid byte range start") + } + if start < 0 && !allowNegativeStart { + return updateRange{}, errors.New("invalid byte range start") + } + + r := updateRange{offset: start} + if endText == "" { + return r, nil + } + if start < 0 { + return updateRange{}, errors.New("negative byte range cannot include an end") + } + + end, err := strconv.ParseInt(endText, 10, 64) + if err != nil { + return updateRange{}, errors.New("invalid byte range end") + } + if end < start { + return updateRange{}, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "invalid byte range") + } + r.end = end + r.hasEnd = true + return r, nil +} + +func spoolBoundedBody(body io.Reader, expected int64) (io.Reader, func(), error) { + tmp, err := os.CreateTemp("", "webdav-partial-update-*") + if err != nil { + return nil, nil, err + } + cleanup := func() { + name := tmp.Name() + _ = tmp.Close() + _ = os.Remove(name) + } + cleanupOnError := true + defer func() { + if cleanupOnError { + cleanup() + } + }() + + n, err := io.Copy(tmp, io.LimitReader(body, expected+1)) + if err != nil { + return nil, nil, err + } + if n != expected { + return nil, nil, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "body length does not match byte range") + } + + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + return nil, nil, err + } + cleanupOnError = false + return tmp, cleanup, nil +} + +// confirmPartialUpdateLocks mirrors the unexported confirmLocks helper from +// golang.org/x/net/webdav so that partial updates honor WebDAV locks the same +// way regular PUT requests do. Keep it in sync if the upstream behavior changes. +func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (release func(), status int, err error) { + hdr := r.Header.Get("If") + if hdr == "" { + now := time.Now() + token, err := u.LockSystem.Create(now, webdav.LockDetails{ + Root: src, + Duration: -1, + ZeroDepth: true, + }) + if err != nil { + if errors.Is(err, webdav.ErrLocked) { + return nil, webdav.StatusLocked, err + } + return nil, http.StatusInternalServerError, err + } + return func() { + _ = u.LockSystem.Unlock(now, token) + }, 0, nil + } + + ifLists, ok := parsePartialIfHeader(hdr) + if !ok { + return nil, http.StatusBadRequest, errors.New("webdav: invalid If header") + } + for _, l := range ifLists { + lsrc := l.resourceTag + if lsrc == "" { + lsrc = src + } else { + parsedURL, err := url.Parse(lsrc) + if err != nil { + continue + } + if parsedURL.Host != r.Host { + continue + } + lsrc, err = stripPartialPrefix(parsedURL.Path, u.Prefix) + if err != nil { + return nil, http.StatusNotFound, err + } + if lsrc == "" { + lsrc = src + } + } + release, err = u.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...) + if errors.Is(err, webdav.ErrConfirmationFailed) { + continue + } + if err != nil { + return nil, http.StatusInternalServerError, err + } + return release, 0, nil + } + return nil, http.StatusPreconditionFailed, webdav.ErrLocked +} + +type partialIfList struct { + resourceTag string + conditions []webdav.Condition +} + +// parsePartialIfHeader, parsePartialIfConditions and cutPartialIfToken +// reimplement the unexported If-header parser from golang.org/x/net/webdav, +// which is not accessible from outside that package. Keep them in sync with the +// upstream parseIfHeader if it changes. +func parsePartialIfHeader(header string) ([]partialIfList, bool) { + s := strings.TrimSpace(header) + tagged := strings.HasPrefix(s, "<") + var lists []partialIfList + for s != "" { + resourceTag := "" + if strings.HasPrefix(s, "<") { + if !tagged { + return nil, false + } + var ok bool + resourceTag, s, ok = cutPartialIfToken(s, '<', '>') + if !ok { + return nil, false + } + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "(") { + return nil, false + } + } + for strings.HasPrefix(s, "(") { + body, rest, ok := cutPartialIfToken(s, '(', ')') + if !ok { + return nil, false + } + conditions, ok := parsePartialIfConditions(body) + if !ok { + return nil, false + } + lists = append(lists, partialIfList{resourceTag: resourceTag, conditions: conditions}) + s = strings.TrimSpace(rest) + } + if s != "" && !strings.HasPrefix(s, "<") { + return nil, false + } + } + return lists, len(lists) > 0 +} + +func parsePartialIfConditions(s string) ([]webdav.Condition, bool) { + var conditions []webdav.Condition + for { + s = strings.TrimSpace(s) + if s == "" { + return conditions, len(conditions) > 0 + } + not := false + if strings.HasPrefix(s, "Not ") || strings.HasPrefix(s, "Not\t") { + not = true + s = strings.TrimSpace(s[3:]) + } + if s == "" { + return nil, false + } + var token string + switch s[0] { + case '<': + var ok bool + token, s, ok = cutPartialIfToken(s, '<', '>') + if !ok { + return nil, false + } + conditions = append(conditions, webdav.Condition{Not: not, Token: token}) + case '[': + var ok bool + token, s, ok = cutPartialIfToken(s, '[', ']') + if !ok { + return nil, false + } + conditions = append(conditions, webdav.Condition{Not: not, ETag: token}) + default: + i := strings.IndexAny(s, " \t") + if i < 0 { + token, s = s, "" + } else { + token, s = s[:i], s[i:] + } + if token == "" || strings.ContainsAny(token, "()<>[]") { + return nil, false + } + conditions = append(conditions, webdav.Condition{Not: not, Token: token}) + } + } +} + +func cutPartialIfToken(s string, open, close byte) (string, string, bool) { + if s == "" || s[0] != open { + return "", "", false + } + token, rest, ok := strings.Cut(s[1:], string(close)) + return token, rest, ok +} + +func stripPartialPrefix(p, prefix string) (string, error) { + if prefix == "" { + return p, nil + } + if stripped := strings.TrimPrefix(p, prefix); len(stripped) < len(p) { + return stripped, nil + } + return "", errors.New("webdav: prefix mismatch") +} diff --git a/lib/permissions.go b/lib/permissions.go index 8d1d67a..c43e757 100644 --- a/lib/permissions.go +++ b/lib/permissions.go @@ -152,7 +152,7 @@ func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool { return p.Create case "PROPPATCH": return p.Update - case "PUT": + case "PUT", "PATCH": if fileExists(r.path) { return p.Update } else {