212 lines
4.3 KiB
Go
212 lines
4.3 KiB
Go
package app
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Backup is an offline operation: Open has already acquired the exclusive service lock.
|
|
func (a *App) Backup(configPath, target string) error {
|
|
abs, e := filepath.Abs(target)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
data, e := filepath.Abs(a.cfg.DataDir)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if abs == data || strings.HasPrefix(abs, data+string(os.PathSeparator)) {
|
|
return errors.New("备份目标必须在数据目录之外")
|
|
}
|
|
if e = a.db.Close(); e != nil {
|
|
return e
|
|
}
|
|
a.db = nil
|
|
out, e := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
success := false
|
|
defer func() {
|
|
out.Close()
|
|
if !success {
|
|
os.Remove(abs)
|
|
}
|
|
}()
|
|
gz := gzip.NewWriter(out)
|
|
tw := tar.NewWriter(gz)
|
|
write := func(name string, f *os.File, st os.FileInfo) error {
|
|
h := &tar.Header{Name: name, Mode: 0600, Size: st.Size(), ModTime: st.ModTime(), Typeflag: tar.TypeReg}
|
|
if e := tw.WriteHeader(h); e != nil {
|
|
return e
|
|
}
|
|
_, e := io.Copy(tw, f)
|
|
return e
|
|
}
|
|
cf, e := os.Open(configPath)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
st, e := cf.Stat()
|
|
if e == nil {
|
|
e = write("config.toml", cf, st)
|
|
}
|
|
cf.Close()
|
|
if e != nil {
|
|
return e
|
|
}
|
|
e = fs.WalkDir(a.root.FS(), ".", func(path string, d fs.DirEntry, e error) error {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("拒绝备份符号链接: %s", path)
|
|
}
|
|
if d.IsDir() || path == "service.lock" {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(path, "tmp/") {
|
|
return nil
|
|
}
|
|
st, e := d.Info()
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if !st.Mode().IsRegular() {
|
|
return errors.New("数据目录包含特殊文件")
|
|
}
|
|
f, e := a.root.Open(path)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer f.Close()
|
|
return write("var/"+path, f, st)
|
|
})
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if e = tw.Close(); e != nil {
|
|
return e
|
|
}
|
|
if e = gz.Close(); e != nil {
|
|
return e
|
|
}
|
|
if e = out.Sync(); e != nil {
|
|
return e
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
func Restore(c Config, source string) error {
|
|
if e := c.Validate(); e != nil {
|
|
return e
|
|
}
|
|
if e := os.MkdirAll(c.DataDir, 0700); e != nil {
|
|
return e
|
|
}
|
|
entries, e := os.ReadDir(c.DataDir)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if len(entries) != 0 {
|
|
return errors.New("恢复数据目录必须为空")
|
|
}
|
|
root, e := os.OpenRoot(c.DataDir)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer root.Close()
|
|
if e = root.WriteFile("restore.incomplete", []byte("restoring"), 0600); e != nil {
|
|
return e
|
|
}
|
|
input, e := os.Open(source)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer input.Close()
|
|
gz, e := gzip.NewReader(input)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer gz.Close()
|
|
tr := tar.NewReader(gz)
|
|
var total int64
|
|
var count int
|
|
for {
|
|
h, e := tr.Next()
|
|
if errors.Is(e, io.EOF) {
|
|
break
|
|
}
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if h.Typeflag != tar.TypeReg || !safeRelative(h.Name) {
|
|
return errors.New("备份包含非法路径或文件类型")
|
|
}
|
|
count++
|
|
if count > c.MaxRecords+32 {
|
|
return errors.New("备份文件数量超出恢复限制")
|
|
}
|
|
name := ""
|
|
if h.Name == "config.toml" {
|
|
name = "restored-config.toml"
|
|
} else if strings.HasPrefix(h.Name, "var/") {
|
|
name = strings.TrimPrefix(h.Name, "var/")
|
|
} else {
|
|
return errors.New("未知备份路径")
|
|
}
|
|
if !safeRelative(name) || h.Size < 0 || h.Size > c.DataBudget || total > c.DataBudget-h.Size {
|
|
return errors.New("备份超过恢复容量")
|
|
}
|
|
total += h.Size
|
|
if e = root.MkdirAll(filepath.Dir(name), 0700); e != nil {
|
|
return e
|
|
}
|
|
f, e := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
_, e = io.CopyN(f, tr, h.Size)
|
|
if e == nil {
|
|
e = f.Sync()
|
|
}
|
|
ce := f.Close()
|
|
if e == nil {
|
|
e = ce
|
|
}
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
if _, e = io.Copy(io.Discard, gz); e != nil {
|
|
return e
|
|
}
|
|
// Invalidate before Open runs lifecycle cleanup, leaving payloads retained for admin review.
|
|
dbPath := filepath.Join(c.DataDir, "db", "bluebird.db")
|
|
if _, e = os.Stat(dbPath); e != nil {
|
|
return errors.New("备份缺少数据库")
|
|
}
|
|
a, e := OpenForRestore(c)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer a.Close()
|
|
if e = a.Check(); e != nil {
|
|
return e
|
|
}
|
|
if e = a.InvalidateRestored(); e != nil {
|
|
return e
|
|
}
|
|
if e = root.Remove("restore.incomplete"); e != nil {
|
|
return e
|
|
}
|
|
return syncDir(root, ".")
|
|
}
|