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

179 lines
4.8 KiB
Go

package gui
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/yetone/magpie/internal/backup"
"github.com/yetone/magpie/internal/davsync"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/settings"
)
// backupRoutes are the Settings page's Export and Import, and the WebDAV
// sync set up there.
func backupRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("POST /api/backup/export", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Pass string
Keys bool
Library *bool // nil is yes
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
b, err := backup.Collect(in.Keys, Version)
if err != nil {
fail(rw, err)
return
}
if in.Library != nil && !*in.Library {
b.Library = nil
}
data, err := backup.Seal(b, in.Pass)
if err != nil {
fail(rw, err)
return
}
dir := downloads()
name := filepath.Join(dir, "magpie-"+time.Now().Format("2006-01-02")+backup.Ext)
for i := 2; ; i++ { // never over an earlier one
if _, err := os.Stat(name); err != nil {
break
}
name = filepath.Join(dir, fmt.Sprintf("magpie-%s-%d%s", time.Now().Format("2006-01-02"), i, backup.Ext))
}
if err := edit.WriteAtomic(name, data); err != nil {
fail(rw, err)
return
}
os.Chmod(name, 0o600)
_ = w.OpenFolder(dir) // saved either way; the path is in the answer
writeJSON(rw, map[string]any{"path": tilde(name), "providers": len(b.Providers), "profiles": len(b.Profiles), "agents": len(b.Agents), "library": b.Library != nil})
})
mux.HandleFunc("POST /api/backup/import", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Data string
Pass string
Agents bool
Library *bool // nil is yes
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
data, err := base64.StdEncoding.DecodeString(in.Data)
if err != nil {
fail(rw, err)
return
}
b, err := backup.Open(data, in.Pass)
if err != nil {
fail(rw, err)
return
}
parts := backup.All
parts.Agents = in.Agents
parts.Library = in.Library == nil || *in.Library
res, err := backup.Restore(b, parts)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, res)
})
mux.HandleFunc("GET /api/davsync", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, davsync.Status())
})
mux.HandleFunc("POST /api/davsync/{action}", func(rw http.ResponseWriter, r *http.Request) {
// no time limit: a large backup on a slow line takes minutes (#657),
// and davsync ends a request that stops moving
ctx := r.Context()
var err error
var brought []string
switch r.PathValue("action") {
case "save": // and sync at once, so a wrong address or password shows now
var c davsync.Config
if err = json.NewDecoder(r.Body).Decode(&c); err == nil {
err = davsync.Configure(c)
}
if err != nil { // said in the form, what was typed in it kept
fail(rw, err)
return
}
err = busy(davsync.SyncNow(ctx)) // how it went is in the status
case "now":
err = busy(davsync.SyncNow(ctx))
case "off":
err = davsync.Off()
case "dismiss":
err = davsync.Dismiss()
case "auto": // how often it syncs by itself, or never (#847)
var in struct{ Minutes int }
if err = json.NewDecoder(r.Body).Decode(&in); err == nil {
err = davsync.SetAuto(in.Minutes)
}
case "restore": // the server's setup over this computer's, which is kept
brought, err = davsync.Restore(ctx)
case "undo":
_, err = davsync.Undo()
case "upload": // this computer's setup over a server file that isn't a backup, kept first
err = davsync.Upload(ctx)
case "reveal":
err = w.OpenFolder(filepath.Join(settings.Dir(), "sync"))
default:
http.NotFound(rw, r)
return
}
v := davsync.Status()
switch {
case errors.Is(err, davsync.ErrBusy):
// nothing ran to put it in the status: it is said there all the same
v.Error = err.Error()
case err != nil:
fail(rw, err)
return
}
if r.PathValue("action") == "restore" { // and what it brought in: nothing when it was the same
writeJSON(rw, struct {
davsync.View
Brought []string `json:"brought"`
}{v, brought})
return
}
writeJSON(rw, v)
})
}
// busy is what of a sync's failure the status doesn't hold: that it waited
// its time out for another magpie's. The rest is in the status.
func busy(err error) error {
if errors.Is(err, davsync.ErrBusy) {
return err
}
return nil
}
// downloads is where an export is saved: the Downloads folder, or home
// when there is none.
func downloads() string {
home, _ := os.UserHomeDir()
if d := filepath.Join(home, "Downloads"); isDir(d) {
return d
}
return home
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}