Files
yetone be2037285a davsync, gui: a server file that isn't a backup is said as what it is, and can be replaced by this computer's setup (JasonLeeForOnly on Discord)
JasonLeeForOnly's WebDAV sync failed with only "not a magpie backup" for
several versions, and nothing on the page got past it. A WebDAV GET of the
backup can come back as a body that isn't the file in several ways; each is
now reproduced with an httptest server that answers the way the real one
does:

Unpacked (WebDAV and S3): gzip with or without Content-Encoding (a proxy
that drops the header), x-gzip, zstd with or without the header, deflate
(zlib or raw), and a header that says gzip over a plain body. Bodies are
told by magic number; a backup is JSON, so either magic is never the file.
Brotli (br) can't be unpacked and is said as itself, with what to turn off.

Said, with no upload offered: an HTML page answered with 200 (Nextcloud
login after a redirect, Cloudflare Access, an AList/OpenList web app, an
rclone directory listing, a captive portal), with its <title> and the
host/path a redirect ended at (query, fragment and user dropped), and
where the WebDAV address is for 坚果云 and Nextcloud; an XML answer, by its
root element.

Said, with Upload as the way out: an empty (0-byte) file, a backup cut
short or damaged (the automatic rebuild is unchanged and still goes first
when its rules allow), another app's file ("format": "…"), other JSON,
and any other file by its content type. Messages carry the size, kind and a
page title only, never the file's contents.

backup.Open now tells ErrNotBackup (doesn't begin like a backup) from
ErrCorrupt (begins like one, cut short). The status carries the kind as
serverFile; Settings › Sync and backup says it in the reader's language,
wraps it whole, and shows "Upload this computer's setup" only when the file
may be replaced. davsync.Upload (POST /api/davsync/upload, `magpie webdav
upload`, `magpie s3 upload`) asks first in the GUI, re-reads the file,
refuses a backup this computer opens, one sealed with another passphrase,
a newer magpie's, a page and an XML answer, keeps the server's copy in the
sync folder (0600, newest 5) and writes nothing if that fails, then writes
with If-Match on the version read. New strings in en, zh, zh-TW, ja, de.

Verified: internal/davsync/not_backup_test.go (TestReadCompressedBackup,
TestReadBrotliSaid, TestServerAnswerSaid, TestCutBackupSaidAndUploaded,
TestS3ReadCompressedObject; Upload-only parts stripped to compile) fails
on e4a90aa6 for every compressed case but "gzip said", Brotli, every
server-answer case, the cut case and S3 gzip, and passes here. Dropping
the Replace check in Upload turns the page/XML cases red ("Upload wrote
over a page"). gofmt -l clean; go vet darwin, GOOS=linux and GOOS=windows
-tags nogui on ., davsync, backup, gui; full go test -tags nogui ./... ok;
-race on davsync and backup ok. GUI: sync-not-backup.test.cjs (en, zh,
zh-TW, ja, de; 900 and 440px), sync-*, s3-sync, gui-de, gui-ja, gui-zh-tw,
click-scroll in Chromium and WebKit, 81 pass. Not tried against a real
WebDAV server or on the Windows box; the reporter's server is unknown.

Note: 2018882e on main carries this subject by mistake (another session read a shared message file); it holds only GTK DPI code. This commit is the actual WebDAV change.
2026-10-09 12:14:26 +08:00

510 lines
18 KiB
Go

package davsync
import (
"bytes"
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
pathpkg "path"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/backup"
)
// folder and file are where the backup lives under the address given.
const (
folder = "magpie"
file = "magpie.magpie-backup"
)
// errChanged is a PUT the server refused because the file changed since it
// was read: another computer synced in between.
var errChanged = errors.New("the file on the server changed meanwhile")
// errNotModified is a read sent with the version last seen that the server
// answered 304: the file is the one read then, and wasn't sent again.
var errNotModified = errors.New("the file on the server is unchanged")
// version is how the server tells one version of the file from another:
// its ETag, or, from a server without them, its Last-Modified — kept only
// when it was a second or more before the server's clock at the read, so
// that a write later in that same second can't go unseen.
type version struct {
ETag string `json:"etag,omitempty"`
Modified string `json:"modified,omitempty"`
}
// conditions are the headers that ask for the file only when it isn't v.
func (v version) conditions() map[string]string {
switch {
case v.ETag != "":
return map[string]string{"If-None-Match": v.ETag}
case v.Modified != "":
return map[string]string{"If-Modified-Since": v.Modified}
}
return nil
}
// versionOf is the version an answer's headers tell; none when they tell
// nothing that can be asked about again.
func versionOf(h http.Header) version {
if e := h.Get("ETag"); e != "" {
return version{ETag: e}
}
lm := h.Get("Last-Modified")
t, err := http.ParseTime(lm)
if err != nil {
return version{}
}
if d, err := http.ParseTime(h.Get("Date")); err != nil || d.Sub(t) < time.Second {
return version{}
}
return version{Modified: lm}
}
// rateLimited is a server that answered 429 Too Many Requests or 503: it
// is limiting how often it is reached — 坚果云 (Nutstore) counts requests
// and downloads per account — or is busy. Run waits longer before trying
// again: after, when the server said how long.
type rateLimited struct {
kind string // "WebDAV" or "S3"
status int
after time.Duration
}
func (e *rateLimited) Error() string {
if e.status == http.StatusTooManyRequests {
return fmt.Sprintf("the %s server is limiting how often magpie may reach it (HTTP 429 Too Many Requests): sync waits longer before trying again", e.kind)
}
return fmt.Sprintf("the %s server is busy, or limiting how often magpie may reach it (HTTP %d): sync waits longer before trying again", e.kind, e.status)
}
// limited is the error for an answer that says the server is limiting
// requests; nil for any other.
func limited(kind string, status int, h http.Header) error {
if status != http.StatusTooManyRequests && status != http.StatusServiceUnavailable {
return nil
}
return &rateLimited{kind: kind, status: status, after: retryAfter(h.Get("Retry-After"))}
}
// retryAfter is a Retry-After header's wait, in seconds or until a time;
// 0 when it says none.
func retryAfter(v string) time.Duration {
v = strings.TrimSpace(v)
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
return max(time.Until(t), 0)
}
return 0
}
// remote is where the backup is kept: a WebDAV folder, or an S3 bucket.
type remote interface {
// get reads the backup and its version; nil data and no error when
// there is none yet. With have, the version last read, it is asked
// for only if it changed since: errNotModified when it didn't.
get(ctx context.Context, have version) (data []byte, v version, err error)
// put writes it over the version read (etag) — with none, only where
// there is none yet — and errChanged when another computer wrote in
// between. The version written is the one the server says, if it does.
put(ctx context.Context, data []byte, etag string) (version, error)
}
// newRemote is c's: an S3 bucket for an s3:// address, WebDAV for the rest.
func newRemote(c Config) (remote, error) {
if c.S3() {
return newS3(c)
}
return newDAV(c)
}
type dav struct {
base *url.URL
user, pass string
client *http.Client
}
func newDAV(c Config) (*dav, error) {
u, err := url.Parse(strings.TrimSpace(c.URL))
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return nil, fmt.Errorf("%q is not a WebDAV address (https://…)", c.URL)
}
u.Path = strings.TrimRight(u.Path, "/")
return &dav{base: u, user: c.User, pass: c.Password, client: syncClient}, nil
}
func (d *dav) url(parts ...string) string {
u := *d.base
u.Path += "/" + strings.Join(parts, "/")
return u.String()
}
func (d *dav) do(ctx context.Context, method, u string, body []byte, h map[string]string) (*http.Response, error) {
res, err := d.send(ctx, method, u, body, h)
if err == nil && res.StatusCode == http.StatusForbidden {
res.Body.Close()
return nil, d.forbidden(ctx)
}
return res, err
}
// send is do with a 403 handed back as it is, for the calls that can tell
// what it meant.
func (d *dav) send(ctx context.Context, method, u string, body []byte, h map[string]string) (*http.Response, error) {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
if d.user != "" || d.pass != "" {
req.SetBasicAuth(d.user, d.pass)
}
for k, v := range h {
req.Header.Set(k, v)
}
res, err := d.client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusUnauthorized {
res.Body.Close()
return nil, errLogin
}
if err := limited("WebDAV", res.StatusCode, res.Header); err != nil {
res.Body.Close()
return nil, err
}
return res, nil
}
var errLogin = errors.New("the WebDAV server refused the user name or password (HTTP 401)")
// forbidden says why a 403 was: servers answer it for a folder that isn't
// there as much as for one the account may not use — a Synology does for a
// shared folder not made — so the folder in the address is looked at first.
func (d *dav) forbidden(ctx context.Context) error {
dir := d.base.Path
if dir == "" {
dir = "/"
}
req, err := http.NewRequestWithContext(ctx, "PROPFIND", d.url(), nil)
if err == nil {
req.Header.Set("Depth", "0")
if d.user != "" || d.pass != "" {
req.SetBasicAuth(d.user, d.pass)
}
if res, err := d.client.Do(req); err == nil {
res.Body.Close()
switch {
case res.StatusCode == http.StatusUnauthorized:
return errLogin
case res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusConflict || res.StatusCode == http.StatusGone:
return fmt.Errorf("there is no folder %s on the WebDAV server (HTTP 403): make it there first, or leave it out of the address", dir)
case res.StatusCode >= 200 && res.StatusCode < 300:
return fmt.Errorf("the WebDAV server doesn't let this account write in %s (HTTP 403): check the account may change files in that folder", dir)
}
}
}
return fmt.Errorf("the WebDAV server doesn't let this account use %s (HTTP 403): the folder may not be there, or the account may not have access to it", dir)
}
// there is whether PROPFIND finds u: a server that answers 403 for what
// isn't there may do so for PROPFIND too.
func (d *dav) there(ctx context.Context, u string) bool {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", u, nil)
if err != nil {
return true
}
req.Header.Set("Depth", "0")
if d.user != "" || d.pass != "" {
req.SetBasicAuth(d.user, d.pass)
}
res, err := d.client.Do(req)
if err != nil {
return true
}
res.Body.Close()
return res.StatusCode >= 200 && res.StatusCode < 300
}
// get reads the backup; nil data and no error when there is none yet, and
// errNotModified when it is still have. A server that doesn't do
// conditional reads sends the file, as it always did.
//
// The read asks for the file as it is, not compressed: the ETag it gives
// back is what the write after it matches with If-Match, and a server that
// compresses changes that ETag for the compressed copy. Apache's
// mod_deflate — InfiniCLOUD's (TeraCloud's) WebDAV is Apache — adds
// "-gzip" inside the quotes, and nginx and other proxies weaken it to
// W/"…"; either never matches the file in an If-Match, so every write after
// such a read was refused as changed meanwhile (#1362).
func (d *dav) get(ctx context.Context, have version) (data []byte, v version, err error) {
cond := have.conditions()
h := map[string]string{"Accept-Encoding": "identity"}
for k, v := range cond {
h[k] = v
}
res, err := d.send(ctx, http.MethodGet, d.url(folder, file), nil, h)
if err != nil {
return nil, version{}, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotModified && cond != nil {
return nil, have, errNotModified
}
// a Synology answers 403, not 404, for a file in a folder that isn't
// there: before the first sync, magpie's folder isn't
if res.StatusCode == http.StatusForbidden {
if !d.there(ctx, d.url(folder)+"/") {
return nil, version{}, nil
}
return nil, version{}, d.forbidden(ctx)
}
switch {
// 409: the folder isn't there yet — how 坚果云 (Nutstore) answers a
// read in it, where others say 404; put makes it
case res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone || res.StatusCode == http.StatusConflict:
return nil, version{}, nil
case res.StatusCode != http.StatusOK:
return nil, version{}, fmt.Errorf("reading %s from the WebDAV server: HTTP %d", file, res.StatusCode)
}
data, err = io.ReadAll(io.LimitReader(res.Body, 64<<20))
if err != nil {
return nil, version{}, err
}
// asked for as it is, the file still came compressed: Go unpacks it
// only when it asked for gzip itself
if data, err = unpacked("WebDAV", data, res.Header.Get("Content-Encoding")); err != nil {
return nil, version{}, fmt.Errorf("reading %s from the WebDAV server: %w", file, err)
}
// a redirect that ended at something other than the file — a web
// app's sign-in or files page — is said with where it went: the
// address is the thing to fix
if res.Request != nil && res.Request.Response != nil && !backupHead(bytes.TrimSpace(data)) {
if f := whatIs(data); !f.Replace {
to := *res.Request.URL
to.RawQuery, to.Fragment, to.User = "", "", nil
f.Moved = to.Host + to.Path
return nil, version{}, &notBackup{f: f, where: "the file " + folder + "/" + file + " on the WebDAV server", cmd: "webdav", err: backup.ErrNotBackup}
}
}
return data, versionOf(res.Header), nil
}
// put writes the backup, only over the version read (etag) when there was
// one, making the folder if the server has none. Relays and tunnels cut
// long writes short, and the half that lands reads back as "not a magpie
// backup" on every sync after, so what a write kept is read back and
// compared; a short write is tried again and told as itself in the end.
func (d *dav) put(ctx context.Context, data []byte, etag string) (version, error) {
h := map[string]string{"Content-Type": "application/octet-stream"}
if etag != "" {
h["If-Match"] = etag
}
for try, writes := 0, 0; ; try++ {
res, err := d.send(ctx, http.MethodPut, d.url(folder, file), data, h)
if err != nil {
return version{}, err
}
res.Body.Close()
switch {
case res.StatusCode >= 200 && res.StatusCode < 300:
writes++
he, cerr := d.check(ctx, d.url(folder, file), file, len(data))
if cerr != nil {
if writes < 3 {
// the half file is a new version: the retry matches
// it — the ETag the PUT answered, else the one the
// check read; with neither, the old one goes, for it
// can't hold any more
if e := res.Header.Get("ETag"); e != "" {
h["If-Match"] = e
} else if he != "" {
h["If-Match"] = he
} else {
delete(h, "If-Match")
}
continue
}
return version{}, cerr
}
// an ETag said here is the version written; with none, the
// next sync reads the file in full, as before
return version{ETag: res.Header.Get("ETag")}, nil
case res.StatusCode == http.StatusPreconditionFailed:
return version{}, errChanged
case res.StatusCode == http.StatusForbidden && (try > 0 || d.there(ctx, d.url(folder)+"/")):
return version{}, d.forbidden(ctx)
// a 403 for a folder not there (a Synology) is made as a 404 is
case (res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusConflict || res.StatusCode == http.StatusForbidden) && try == 0:
if err := d.mkcol(ctx); err != nil {
return version{}, err
}
continue
case res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusConflict:
return version{}, d.nowhere(ctx, res.StatusCode)
}
return version{}, fmt.Errorf("writing %s to the WebDAV server: HTTP %d", file, res.StatusCode)
}
}
// check says so when the server kept less than what was sent: relays and
// tunnels cut long writes short, and the half that lands reads back as
// "not a magpie backup" on every sync after. The size is enough — a short
// file is the whole failure — so no download rides on a write. It gives
// back the ETag it saw, for a retry to match the file as the short write
// left it. A HEAD that can't be done — an error, or not a 200 — leaves the
// write unchecked: it landed either way. A HEAD that says short is asked
// again of PROPFIND, which costs a request only then.
func (d *dav) check(ctx context.Context, u, name string, want int) (etag string, err error) {
res, err := d.send(ctx, http.MethodHead, u, nil, nil)
if err != nil {
return "", nil
}
res.Body.Close()
// a server without a say on HEAD can't be checked; the read after it
// tells
if res.StatusCode != http.StatusOK {
return "", nil
}
etag = res.Header.Get("ETag")
n := res.ContentLength
if n < 0 || int(n) >= want {
return etag, nil
}
// a HEAD's Content-Length isn't always the file's: 坚果云 (Nutstore)
// answered a 6869-byte usage file's with 0 on every try (#1259). The
// length WebDAV keeps for a file is PROPFIND's getcontentlength, which
// every client of it reads, so a short HEAD is asked of that before it
// is believed; with no length there, the HEAD's stands
if m, ok := d.length(ctx, u); ok {
if m >= int64(want) {
return etag, nil
}
n = m
}
return etag, fmt.Errorf("the WebDAV server kept %d of %d bytes of %s: the write was cut short — sync again, and if it keeps happening the network to the server is dropping long uploads", n, want, name)
}
// length is the file at u's size as PROPFIND (Depth 0) says it; false when
// it says none.
func (d *dav) length(ctx context.Context, u string) (int64, bool) {
res, err := d.send(ctx, "PROPFIND", u, nil, map[string]string{"Depth": "0"})
if err != nil {
return 0, false
}
defer res.Body.Close()
if res.StatusCode != http.StatusMultiStatus {
return 0, false
}
var ms struct {
Responses []struct {
Length string `xml:"propstat>prop>getcontentlength"`
} `xml:"response"`
}
if xml.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&ms) != nil {
return 0, false
}
for _, r := range ms.Responses {
if n, err := strconv.ParseInt(strings.TrimSpace(r.Length), 10, 64); err == nil && n >= 0 {
return n, true
}
}
return 0, false
}
func (d *dav) mkcol(ctx context.Context) error {
res, err := d.do(ctx, "MKCOL", d.url(folder)+"/", nil, nil)
if err != nil {
return err
}
res.Body.Close()
// 405: it is there already; 409: the folder above it isn't
if res.StatusCode == http.StatusConflict {
return fmt.Errorf("there is no folder %s on the WebDAV server (HTTP 409): make it there first, or leave it out of the address", d.base.Path)
}
if res.StatusCode >= 300 && res.StatusCode != http.StatusMethodNotAllowed {
return fmt.Errorf("making the folder %s on the WebDAV server: HTTP %d", folder, res.StatusCode)
}
return nil
}
// nowhere is a folder made, or said to be there, that a file still can't be
// put in: OpenList's and Alist's /dav/ is a list of their storages, where
// MKCOL answers 405 and PUT 404 — the address has to go into one of them.
func (d *dav) nowhere(ctx context.Context, code int) error {
dir := d.base.Path
if dir == "" {
dir = "/"
}
subs, status := d.folders(ctx)
if status == http.StatusNotFound || status == http.StatusConflict || status == http.StatusGone {
return fmt.Errorf("there is no folder %s on the WebDAV server (HTTP %d): make it there first, or leave it out of the address", dir, code)
}
msg := fmt.Sprintf("the WebDAV server has nowhere to put the folder %s in %s (HTTP %d): the address has to be a folder files can be written in", folder, dir, code)
if len(subs) > 0 {
u := *d.base
u.Path = strings.TrimRight(u.Path, "/") + "/" + subs[0]
msg += fmt.Sprintf(" — on OpenList or Alist, one of its storages (%s), like %s", strings.Join(subs, ", "), u.String())
}
return errors.New(msg)
}
// folders are the folders in the address's folder, as PROPFIND lists them,
// and what it answered.
func (d *dav) folders(ctx context.Context) ([]string, int) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", d.url(), nil)
if err != nil {
return nil, 0
}
req.Header.Set("Depth", "1")
if d.user != "" || d.pass != "" {
req.SetBasicAuth(d.user, d.pass)
}
res, err := d.client.Do(req)
if err != nil {
return nil, 0
}
defer res.Body.Close()
if res.StatusCode != http.StatusMultiStatus {
return nil, res.StatusCode
}
var ms struct {
Responses []struct {
Href string `xml:"href"`
Collection *struct{} `xml:"propstat>prop>resourcetype>collection"`
} `xml:"response"`
}
if xml.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&ms) != nil {
return nil, res.StatusCode
}
self := strings.TrimRight(d.base.Path, "/")
var out []string
for _, r := range ms.Responses {
h := r.Href
if u, err := url.Parse(h); err == nil {
h = u.Path
}
h = strings.TrimRight(h, "/")
if r.Collection == nil || h == self || pathpkg.Dir(h) != self && !(self == "" && pathpkg.Dir(h) == "/") {
continue
}
if len(out) < 5 {
out = append(out, pathpkg.Base(h))
}
}
return out, res.StatusCode
}