diff --git a/internal/net/preconditions.go b/internal/net/preconditions.go new file mode 100644 index 000000000..dc4727134 --- /dev/null +++ b/internal/net/preconditions.go @@ -0,0 +1,271 @@ +package net + +import ( + "net/http" + "net/textproto" + "strings" + "time" +) + +// scanETag determines if a syntactically valid ETag is present at s. If so, +// the ETag and remaining text after consuming ETag is returned. Otherwise, +// it returns "", "". +func scanETag(s string) (etag string, remain string) { + s = textproto.TrimString(s) + start := 0 + if strings.HasPrefix(s, "W/") { + start = 2 + } + if len(s[start:]) < 2 || s[start] != '"' { + return "", "" + } + // ETag is either W/"text" or "text". + // See RFC 7232 2.3. + for i := start + 1; i < len(s); i++ { + c := s[i] + switch { + // Character values allowed in ETags. + case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80: + case c == '"': + return s[:i+1], s[i+1:] + default: + return "", "" + } + } + return "", "" +} + +// etagStrongMatch reports whether a and b match using strong ETag comparison. +// Assumes a and b are valid ETags. +func etagStrongMatch(a, b string) bool { + return a == b && a != "" && a[0] == '"' +} + +// etagWeakMatch reports whether a and b match using weak ETag comparison. +// Assumes a and b are valid ETags. +func etagWeakMatch(a, b string) bool { + return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/") +} + +// condResult is the result of an HTTP request precondition check. +// See https://tools.ietf.org/html/rfc7232 section 3. +type condResult int + +const ( + condNone condResult = iota + condTrue + condFalse +) + +func checkIfMatch(w http.ResponseWriter, r *http.Request, exists bool) condResult { + values := r.Header.Values("If-Match") + if len(values) == 0 { + return condNone + } + im := strings.Join(values, ",") + r.Header.Del("If-Match") + if !exists { + return condFalse + } + for { + im = textproto.TrimString(im) + if len(im) == 0 { + break + } + if im[0] == ',' { + im = im[1:] + continue + } + if im[0] == '*' { + return condTrue + } + etag, remain := scanETag(im) + if etag == "" { + break + } + if etagStrongMatch(etag, w.Header().Get("Etag")) { + return condTrue + } + im = remain + } + + return condFalse +} + +func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult { + ius := r.Header.Get("If-Unmodified-Since") + if ius == "" { + return condNone + } + r.Header.Del("If-Unmodified-Since") + if isZeroTime(modtime) { + return condNone + } + t, err := http.ParseTime(ius) + if err != nil { + return condNone + } + + // The Last-Modified header truncates sub-second precision so + // the modtime needs to be truncated too. + modtime = modtime.Truncate(time.Second) + if ret := modtime.Compare(t); ret <= 0 { + return condTrue + } + return condFalse +} + +func checkIfNoneMatch(w http.ResponseWriter, r *http.Request, exists bool) condResult { + values := r.Header.Values("If-None-Match") + if len(values) == 0 { + return condNone + } + inm := strings.Join(values, ",") + r.Header.Del("If-None-Match") + if !exists { + return condTrue + } + buf := inm + for { + buf = textproto.TrimString(buf) + if len(buf) == 0 { + break + } + if buf[0] == ',' { + buf = buf[1:] + continue + } + if buf[0] == '*' { + return condFalse + } + etag, remain := scanETag(buf) + if etag == "" { + break + } + if etagWeakMatch(etag, w.Header().Get("Etag")) { + return condFalse + } + buf = remain + } + return condTrue +} + +func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult { + if r.Method != "GET" && r.Method != "HEAD" { + return condNone + } + ims := r.Header.Get("If-Modified-Since") + if ims == "" { + return condNone + } + r.Header.Del("If-Modified-Since") + if isZeroTime(modtime) { + return condNone + } + t, err := http.ParseTime(ims) + if err != nil { + return condNone + } + // The Last-Modified header truncates sub-second precision so + // the modtime needs to be truncated too. + modtime = modtime.Truncate(time.Second) + if ret := modtime.Compare(t); ret <= 0 { + return condFalse + } + return condTrue +} + +func checkIfRange(w http.ResponseWriter, r *http.Request, modtime time.Time) condResult { + if r.Method != "GET" && r.Method != "HEAD" { + return condNone + } + ir := r.Header.Get("If-Range") + if ir == "" { + return condNone + } + r.Header.Del("If-Range") + etag, _ := scanETag(ir) + if etag != "" { + if etagStrongMatch(etag, w.Header().Get("Etag")) { + return condTrue + } + return condFalse + } + // The If-Range value is typically the ETag value, but it may also be + // the modtime date. See golang.org/issue/8367. + if modtime.IsZero() { + return condFalse + } + t, err := http.ParseTime(ir) + if err != nil { + return condFalse + } + if t.Unix() == modtime.Unix() { + return condTrue + } + return condFalse +} + +var unixEpochTime = time.Unix(0, 0) + +// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). +func isZeroTime(t time.Time) bool { + return t.IsZero() || t.Equal(unixEpochTime) +} + +func setLastModified(w http.ResponseWriter, modtime time.Time) { + if !isZeroTime(modtime) { + w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) + } +} + +func writeNotModified(w http.ResponseWriter) { + // RFC 7232 section 4.1: + // a sender SHOULD NOT generate representation metadata other than the + // above listed fields unless said metadata exists for the purpose of + // guiding cache updates (e.g., Last-Modified might be useful if the + // response does not have an ETag field). + h := w.Header() + delete(h, "Content-Type") + delete(h, "Content-Length") + delete(h, "Content-Encoding") + if h.Get("Etag") != "" { + delete(h, "Last-Modified") + } + w.WriteHeader(http.StatusNotModified) +} + +// CheckPreconditions evaluates request preconditions and reports whether a precondition +// resulted in sending StatusNotModified or StatusPreconditionFailed. The caller must +// set the current ETag response header and indicate whether the representation exists. +func CheckPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time, exists bool) (done bool, rangeHeader string) { + // Evaluate preconditions in the order specified by RFC 9110 section 13.2.2. + ch := checkIfMatch(w, r, exists) + if ch == condNone { + ch = checkIfUnmodifiedSince(r, modtime) + } + if ch == condFalse { + w.WriteHeader(http.StatusPreconditionFailed) + return true, "" + } + switch checkIfNoneMatch(w, r, exists) { + case condFalse: + if r.Method == "GET" || r.Method == "HEAD" { + writeNotModified(w) + return true, "" + } + w.WriteHeader(http.StatusPreconditionFailed) + return true, "" + case condNone: + if checkIfModifiedSince(r, modtime) == condFalse { + writeNotModified(w) + return true, "" + } + } + + rangeHeader = r.Header.Get("Range") + if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse { + rangeHeader = "" + } + return false, rangeHeader +} diff --git a/internal/net/preconditions_test.go b/internal/net/preconditions_test.go new file mode 100644 index 000000000..f1ab6966a --- /dev/null +++ b/internal/net/preconditions_test.go @@ -0,0 +1,114 @@ +package net + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestCheckPreconditions(t *testing.T) { + modified := time.Date(2026, time.January, 2, 3, 4, 5, 123456789, time.UTC) + date := modified.Format(http.TimeFormat) + older := modified.Add(-time.Second).Format(http.TimeFormat) + for _, tt := range []struct { + name string + method string + exists bool + etag string + header http.Header + status int + }{ + {"unconditional create", "PUT", false, "", nil, 0}, + {"unconditional overwrite", "PUT", true, `"current"`, nil, 0}, + {"create only missing", "PUT", false, "", http.Header{"If-None-Match": {"*"}}, 0}, + {"create only existing", "PUT", true, `"current"`, http.Header{"If-None-Match": {"*"}}, 412}, + {"create only existing without etag", "PUT", true, "", http.Header{"If-None-Match": {"*"}}, 412}, + {"match missing", "PUT", false, "", http.Header{"If-Match": {"*"}}, 412}, + {"match existing", "PUT", true, `"current"`, http.Header{"If-Match": {"*"}}, 0}, + {"match existing without etag", "PUT", true, "", http.Header{"If-Match": {"*"}}, 0}, + {"match current", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}}, 0}, + {"match stale", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale"`}}, 412}, + {"match missing tag", "PUT", false, "", http.Header{"If-Match": {`"current"`}}, 412}, + {"match weak request", "PUT", true, `"current"`, http.Header{"If-Match": {`W/"current"`}}, 412}, + {"match weak representation", "PUT", true, `W/"current"`, http.Header{"If-Match": {`"current"`}}, 412}, + {"match list", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale", "current"`}}, 0}, + {"match multiple lines", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale"`, `"current"`}}, 0}, + {"match empty list members", "PUT", true, `"current"`, http.Header{"If-Match": {`, , "current",`}}, 0}, + {"match quoted comma", "PUT", true, `"one,two"`, http.Header{"If-Match": {`"stale", "one,two"`}}, 0}, + {"malformed match", "PUT", true, `"current"`, http.Header{"If-Match": {"current"}}, 412}, + {"empty match", "PUT", true, `"current"`, http.Header{"If-Match": {""}}, 412}, + {"none match current", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"current"`}}, 412}, + {"none match stale", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"stale"`}}, 0}, + {"none match missing tag", "PUT", false, "", http.Header{"If-None-Match": {`"current"`}}, 0}, + {"none match weak request", "PUT", true, `"current"`, http.Header{"If-None-Match": {`W/"current"`}}, 412}, + {"none match weak representation", "PUT", true, `W/"current"`, http.Header{"If-None-Match": {`"current"`}}, 412}, + {"none match multiple lines", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"stale"`, `"current"`}}, 412}, + {"none match quoted comma", "PUT", true, `"one,two"`, http.Header{"If-None-Match": {`"stale", "one,two"`}}, 412}, + {"both conditions must pass", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-None-Match": {`"current"`}}, 412}, + {"both conditions pass", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-None-Match": {`"stale"`}}, 0}, + {"both wildcards missing", "PUT", false, "", http.Header{"If-Match": {"*"}, "If-None-Match": {"*"}}, 412}, + {"unmodified since older", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {older}}, 412}, + {"unmodified since same second", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {date}}, 0}, + {"unmodified since missing", "PUT", false, "", http.Header{"If-Unmodified-Since": {older}}, 0}, + {"invalid unmodified since", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {"invalid"}}, 0}, + {"match overrides unmodified since", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-Unmodified-Since": {older}}, 0}, + {"ignore modified since on put", "PUT", true, `"current"`, http.Header{"If-Modified-Since": {date}}, 0}, + {"get not modified", "GET", true, `"current"`, http.Header{"If-None-Match": {`W/"current"`}}, 304}, + {"head not modified", "HEAD", true, `"current"`, http.Header{"If-None-Match": {"*"}}, 304}, + {"get stale match", "GET", true, `"current"`, http.Header{"If-Match": {`"stale"`}}, 412}, + {"get modified since", "GET", true, `"current"`, http.Header{"If-Modified-Since": {date}}, 304}, + {"none match overrides modified since", "GET", true, `"current"`, http.Header{"If-None-Match": {`"stale"`}, "If-Modified-Since": {date}}, 0}, + {"empty none match overrides modified since", "GET", true, `"current"`, http.Header{"If-None-Match": {""}, "If-Modified-Since": {date}}, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(tt.method, "/file.txt", nil) + for name, values := range tt.header { + for _, value := range values { + r.Header.Add(name, value) + } + } + w := httptest.NewRecorder() + if tt.etag != "" { + w.Header().Set("Etag", tt.etag) + } + var modTime time.Time + if tt.exists { + modTime = modified + } + done, _ := CheckPreconditions(w, r, modTime, tt.exists) + if done != (tt.status != 0) { + t.Fatalf("done = %v, want status %d", done, tt.status) + } + if done && w.Code != tt.status { + t.Errorf("status = %d, want %d", w.Code, tt.status) + } + if w.Body.Len() != 0 { + t.Errorf("unexpected response body: %q", w.Body.String()) + } + }) + } +} + +func TestCheckPreconditionsIfRange(t *testing.T) { + for _, tt := range []struct { + etag string + want string + }{ + {`"current"`, "bytes=0-3"}, + {`"stale"`, ""}, + {`W/"current"`, ""}, + } { + t.Run(tt.etag, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/file.txt", nil) + r.Header.Set("Range", "bytes=0-3") + r.Header.Set("If-Range", tt.etag) + w := httptest.NewRecorder() + w.Header().Set("Etag", `"current"`) + done, got := CheckPreconditions(w, r, time.Time{}, true) + if done || got != tt.want { + t.Fatalf("CheckPreconditions() = (%v, %q), want (false, %q)", done, got, tt.want) + } + }) + } +} diff --git a/internal/net/serve.go b/internal/net/serve.go index 89a209d88..46185caec 100644 --- a/internal/net/serve.go +++ b/internal/net/serve.go @@ -55,7 +55,7 @@ import ( func ServeHTTP(w http.ResponseWriter, r *http.Request, name string, modTime time.Time, size int64, RangeReadCloser model.RangeReadCloserIF) error { defer RangeReadCloser.Close() setLastModified(w, modTime) - done, rangeReq := checkPreconditions(w, r, modTime) + done, rangeReq := CheckPreconditions(w, r, modTime, true) if done { return nil } diff --git a/internal/net/util.go b/internal/net/util.go index 4bf2ba6cf..f0f770331 100644 --- a/internal/net/util.go +++ b/internal/net/util.go @@ -4,10 +4,7 @@ import ( "io" "mime/multipart" "net/http" - "net/textproto" "net/url" - "strings" - "time" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -18,260 +15,6 @@ import ( log "github.com/sirupsen/logrus" ) -// scanETag determines if a syntactically valid ETag is present at s. If so, -// the ETag and remaining text after consuming ETag is returned. Otherwise, -// it returns "", "". -func scanETag(s string) (etag string, remain string) { - s = textproto.TrimString(s) - start := 0 - if strings.HasPrefix(s, "W/") { - start = 2 - } - if len(s[start:]) < 2 || s[start] != '"' { - return "", "" - } - // ETag is either W/"text" or "text". - // See RFC 7232 2.3. - for i := start + 1; i < len(s); i++ { - c := s[i] - switch { - // Character values allowed in ETags. - case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80: - case c == '"': - return s[:i+1], s[i+1:] - default: - return "", "" - } - } - return "", "" -} - -// etagStrongMatch reports whether a and b match using strong ETag comparison. -// Assumes a and b are valid ETags. -func etagStrongMatch(a, b string) bool { - return a == b && a != "" && a[0] == '"' -} - -// etagWeakMatch reports whether a and b match using weak ETag comparison. -// Assumes a and b are valid ETags. -func etagWeakMatch(a, b string) bool { - return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/") -} - -// condResult is the result of an HTTP request precondition check. -// See https://tools.ietf.org/html/rfc7232 section 3. -type condResult int - -const ( - condNone condResult = iota - condTrue - condFalse -) - -func checkIfMatch(w http.ResponseWriter, r *http.Request) condResult { - im := r.Header.Get("If-Match") - if im == "" { - return condNone - } - r.Header.Del("If-Match") - for { - im = textproto.TrimString(im) - if len(im) == 0 { - break - } - if im[0] == ',' { - im = im[1:] - continue - } - if im[0] == '*' { - return condTrue - } - etag, remain := scanETag(im) - if etag == "" { - break - } - if etagStrongMatch(etag, w.Header().Get("Etag")) { - return condTrue - } - im = remain - } - - return condFalse -} - -func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult { - ius := r.Header.Get("If-Unmodified-Since") - if ius == "" { - return condNone - } - r.Header.Del("If-Unmodified-Since") - if isZeroTime(modtime) { - return condNone - } - t, err := http.ParseTime(ius) - if err != nil { - return condNone - } - - // The Last-Modified header truncates sub-second precision so - // the modtime needs to be truncated too. - modtime = modtime.Truncate(time.Second) - if ret := modtime.Compare(t); ret <= 0 { - return condTrue - } - return condFalse -} - -func checkIfNoneMatch(w http.ResponseWriter, r *http.Request) condResult { - inm := r.Header.Get("If-None-Match") - if inm == "" { - return condNone - } - r.Header.Del("If-None-Match") - buf := inm - for { - buf = textproto.TrimString(buf) - if len(buf) == 0 { - break - } - if buf[0] == ',' { - buf = buf[1:] - continue - } - if buf[0] == '*' { - return condFalse - } - etag, remain := scanETag(buf) - if etag == "" { - break - } - if etagWeakMatch(etag, w.Header().Get("Etag")) { - return condFalse - } - buf = remain - } - return condTrue -} - -func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult { - if r.Method != "GET" && r.Method != "HEAD" { - return condNone - } - ims := r.Header.Get("If-Modified-Since") - if ims == "" { - return condNone - } - r.Header.Del("If-Modified-Since") - if isZeroTime(modtime) { - return condNone - } - t, err := http.ParseTime(ims) - if err != nil { - return condNone - } - // The Last-Modified header truncates sub-second precision so - // the modtime needs to be truncated too. - modtime = modtime.Truncate(time.Second) - if ret := modtime.Compare(t); ret <= 0 { - return condFalse - } - return condTrue -} - -func checkIfRange(w http.ResponseWriter, r *http.Request, modtime time.Time) condResult { - if r.Method != "GET" && r.Method != "HEAD" { - return condNone - } - ir := r.Header.Get("If-Range") - if ir == "" { - return condNone - } - r.Header.Del("If-Range") - etag, _ := scanETag(ir) - if etag != "" { - if etagStrongMatch(etag, w.Header().Get("Etag")) { - return condTrue - } - return condFalse - } - // The If-Range value is typically the ETag value, but it may also be - // the modtime date. See golang.org/issue/8367. - if modtime.IsZero() { - return condFalse - } - t, err := http.ParseTime(ir) - if err != nil { - return condFalse - } - if t.Unix() == modtime.Unix() { - return condTrue - } - return condFalse -} - -var unixEpochTime = time.Unix(0, 0) - -// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). -func isZeroTime(t time.Time) bool { - return t.IsZero() || t.Equal(unixEpochTime) -} - -func setLastModified(w http.ResponseWriter, modtime time.Time) { - if !isZeroTime(modtime) { - w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) - } -} - -func writeNotModified(w http.ResponseWriter) { - // RFC 7232 section 4.1: - // a sender SHOULD NOT generate representation metadata other than the - // above listed fields unless said metadata exists for the purpose of - // guiding cache updates (e.g., Last-Modified might be useful if the - // response does not have an ETag field). - h := w.Header() - delete(h, "Content-Type") - delete(h, "Content-Length") - delete(h, "Content-Encoding") - if h.Get("Etag") != "" { - delete(h, "Last-Modified") - } - w.WriteHeader(http.StatusNotModified) -} - -// checkPreconditions evaluates request preconditions and reports whether a precondition -// resulted in sending StatusNotModified or StatusPreconditionFailed. -func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) (done bool, rangeHeader string) { - // This function carefully follows RFC 7232 section 6. - ch := checkIfMatch(w, r) - if ch == condNone { - ch = checkIfUnmodifiedSince(r, modtime) - } - if ch == condFalse { - w.WriteHeader(http.StatusPreconditionFailed) - return true, "" - } - switch checkIfNoneMatch(w, r) { - case condFalse: - if r.Method == "GET" || r.Method == "HEAD" { - writeNotModified(w) - return true, "" - } - w.WriteHeader(http.StatusPreconditionFailed) - return true, "" - case condNone: - if checkIfModifiedSince(r, modtime) == condFalse { - writeNotModified(w) - return true, "" - } - } - - rangeHeader = r.Header.Get("Range") - if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse { - rangeHeader = "" - } - return false, rangeHeader -} - func sumRangesSize(ranges []http_range.Range) (size int64) { for _, ra := range ranges { size += ra.Length diff --git a/server/common/etag_test.go b/server/common/etag_test.go new file mode 100644 index 000000000..d80d6deb8 --- /dev/null +++ b/server/common/etag_test.go @@ -0,0 +1,41 @@ +package common + +import ( + "testing" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) + +func TestGetEtagSubsecondChanges(t *testing.T) { + file := &model.Object{Size: 4, Modified: time.Unix(1700000000, 100)} + before := GetEtag(file, file.Size) + if got := GetEtag(file, file.Size); got != before { + t.Fatalf("unchanged file ETag = %q, want %q", got, before) + } + file.Modified = file.Modified.Add(time.Nanosecond) + if got := GetEtag(file, file.Size); got == before { + t.Errorf("same-size overwrite within one second retained ETag %q", got) + } + file.Modified = file.Modified.Add(-time.Nanosecond) + if got := GetEtag(file, file.Size+1); got == before { + t.Errorf("changed size retained ETag %q", got) + } +} + +func TestGetEtagContentHash(t *testing.T) { + file := &model.Object{ + Size: 4, + HashInfo: utils.NewHashInfo(utils.SHA256, utils.HashData(utils.SHA256, []byte("data"))), + } + before := GetEtag(file, file.Size) + file.Modified = time.Unix(1700000000, 100) + if got := GetEtag(file, file.Size); got != before { + t.Errorf("unchanged content ETag = %q, want %q", got, before) + } + file.HashInfo = utils.NewHashInfo(utils.SHA256, utils.HashData(utils.SHA256, []byte("next"))) + if got := GetEtag(file, file.Size); got == before { + t.Errorf("changed content retained ETag %q", got) + } +} diff --git a/server/common/proxy.go b/server/common/proxy.go index c76f43fc7..b67aaa43b 100644 --- a/server/common/proxy.go +++ b/server/common/proxy.go @@ -97,8 +97,8 @@ func GetEtag(file model.Obj, size int64) string { if len(hash) > 0 { return fmt.Sprintf(`"%s"`, hash) } - // 参考nginx - return fmt.Sprintf(`"%x-%x"`, file.ModTime().Unix(), size) + // Preserve sub-second changes when files of the same size are overwritten. + return fmt.Sprintf(`"%x-%x"`, file.ModTime().UnixNano(), size) } func ProxyRange(ctx context.Context, link *model.Link, size int64) *model.Link { diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go index 06d1431ac..5729543e6 100644 --- a/server/webdav/webdav.go +++ b/server/webdav/webdav.go @@ -355,8 +355,6 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, return status, err } defer release() - // TODO(rost): Support the If-Match, If-None-Match headers? See bradfitz' - // comments in http.checkEtag. ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) @@ -394,6 +392,30 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if !common.CanWrite(user, parentMeta, parentPath) { return http.StatusForbidden, errs.PermissionDenied } + fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) + exists := err == nil + if err != nil && !errs.IsObjectNotFound(err) { + if errs.IsNotFoundError(err) { + return http.StatusNotFound, err + } + return http.StatusInternalServerError, err + } + var modTime time.Time + if exists { + if fi.IsDir() { + return http.StatusMethodNotAllowed, nil + } + etag, err := findETag(ctx, h.LockSystem, reqPath, fi) + if err != nil { + return http.StatusInternalServerError, err + } + w.Header().Set("Etag", etag) + modTime = fi.ModTime() + } + if done, _ := net.CheckPreconditions(w, r, modTime, exists); done { + return 0, nil + } + w.Header().Del("Etag") fsStream := &stream.FileStream{ Obj: &obj, Reader: r.Body, @@ -411,16 +433,21 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if err != nil { return http.StatusMethodNotAllowed, err } - fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) + fi, err = fs.Get(ctx, reqPath, &fs.GetArgs{}) if err != nil { - fi = &obj + return http.StatusInternalServerError, err } etag, err := findETag(ctx, h.LockSystem, reqPath, fi) if err != nil { return http.StatusInternalServerError, err } w.Header().Set("Etag", etag) - return http.StatusCreated, nil + if exists { + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusCreated) + } + return 0, nil } func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status int, err error) {