Files
magpie/internal/gui/backup.go
T
yetone 76f3b97c09 sync: it can sync by itself less often or only when asked, and the server's setup can be restored over this computer's, undoably (Sun1090, #847: 同步太快了,需要设置个开关控制自动同步,还有恢复按钮) — the gateway's magpie synced every 3 minutes with no way to change it, and a free 坚果云 account is allowed only so many WebDAV requests (600 per 30 minutes, 1 GB up and 3 GB down a month); and there was no way to take the server's setup back over a computer's own, which the next sync would otherwise push up. Settings › Sync now has Sync by itself, the app's segmented control (no <select>): Off, 3 min (the default, as before), 15 min, 30 min, 1 hour; Off syncs only on Sync now, and the sync row says only when asked. The loop reads the setting each minute (no request), so a change takes effect without a restart, and a server limiting requests is still waited for as backoff says. Restore from the server asks first in the app's own dialog, naming the parts replaced; it reads the server's file as it is now, seals this computer's setup (keys and all) into the sync folder as <time>-before-restore (newest 5 kept), brings in each part that differs as a sync brings one changed only there, and leaves the state as such a sync would, so the next sync pushes nothing back. Its notice says what was restored, with Undo, which brings the kept setup back (the next sync then takes it to the others); OK, or a later sync's own notice, drops the undo. The form's Save keeps the setting (SetAuto alone sets it). CLI: magpie webdav|s3 auto off|3|15|30|60, restore, undo; the status says how often it syncs by itself. zh, ja, de strings. TestAutoSync (off: no request by itself; back to 15 min: one sync, then none; Save keeps it; 7 refused) and TestRestore (b's own changes replaced by the server's with no push, the kept copy holds b's provider and key, Undo puts it back and a gets it, nothing differing restores nothing) pass -race; sync-auto-restore.test.cjs (WebKit, en and zh: Off posts -1 and the rows say so, Cancel posts nothing, Restore and Undo post, no click moves the page) fails on the old code. sync-other-kind, sync-spin, s3-sync, webdav-settings-refresh, usage-computers, click-scroll, gui-ja, gui-de and settings-sections pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-05 10:36:46 +08:00

177 lines
4.7 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 "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()
}