feat(backup): add validated archives and recoverable restore journals
This commit is contained in:
1 parent
ee95b9a181
commit
1408982782
5 files changed
+1068
No files matched your search
@@ -0,0 +1,294 @@
|
|||||||
|
// Package backup 处理 RawMark 自有的实例与设置归档;普通 Markdown 导出由 vault 负责。
|
||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxArchiveBytes int64 = 2 << 30
|
||||||
|
const MaxExpandedBytes int64 = 8 << 30
|
||||||
|
const MaxEntries = 100000
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
Dir bool `json:"dir,omitempty"`
|
||||||
|
Modified int64 `json:"modified"`
|
||||||
|
}
|
||||||
|
type Manifest struct {
|
||||||
|
Format int `json:"format"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
Entries []Entry `json:"entries"`
|
||||||
|
}
|
||||||
|
type Source struct{ Name, Path string }
|
||||||
|
|
||||||
|
func validPath(name string) bool {
|
||||||
|
return name != "" && path.Clean(name) == name && name != "." && !strings.HasPrefix(name, "/") && name != ".." && !strings.HasPrefix(name, "../") && !strings.ContainsAny(name, "\\\x00")
|
||||||
|
}
|
||||||
|
func validEntry(kind, name string) bool {
|
||||||
|
if !validPath(name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"app/fonts", "app/themes", "app/plugins"} {
|
||||||
|
if name == prefix || strings.HasPrefix(name, prefix+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "full" {
|
||||||
|
return name == "app/rawmark.db" || name == "vault" || strings.HasPrefix(name, "vault/")
|
||||||
|
}
|
||||||
|
return kind == "settings" && name == "settings.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。
|
||||||
|
func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) {
|
||||||
|
if kind != "full" && kind != "settings" {
|
||||||
|
return fmt.Errorf("备份类型无效")
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = errors.Join(err, f.Close())
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(dest)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
z := zip.NewWriter(f)
|
||||||
|
manifest := Manifest{Format: 1, Kind: kind, CreatedAt: time.Now().UnixMilli(), Entries: []Entry{}}
|
||||||
|
names := map[string]bool{}
|
||||||
|
var total int64
|
||||||
|
for _, source := range sources {
|
||||||
|
err = filepath.WalkDir(source.Path, func(full string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(source.Path, full)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := source.Name
|
||||||
|
if rel != "." {
|
||||||
|
name += "/" + filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
if skip != nil && skip(name) {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !validEntry(kind, name) || names[name] {
|
||||||
|
return fmt.Errorf("备份路径无效或重复: %s", name)
|
||||||
|
}
|
||||||
|
names[name] = true
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||||
|
return fmt.Errorf("备份不支持符号链接或特殊文件: %s", name)
|
||||||
|
}
|
||||||
|
e := Entry{Path: name, Dir: info.IsDir(), Modified: info.ModTime().UnixMilli()}
|
||||||
|
if len(manifest.Entries) >= MaxEntries {
|
||||||
|
return fmt.Errorf("备份文件数超过上限")
|
||||||
|
}
|
||||||
|
h, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.Name = name
|
||||||
|
h.Method = zip.Deflate
|
||||||
|
if e.Dir {
|
||||||
|
h.Name += "/"
|
||||||
|
}
|
||||||
|
out, err := z.CreateHeader(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !e.Dir {
|
||||||
|
in, err := os.Open(full)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sum := sha256.New()
|
||||||
|
e.Size, err = io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, MaxExpandedBytes-total+1))
|
||||||
|
err = errors.Join(err, in.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += e.Size
|
||||||
|
if total > MaxExpandedBytes {
|
||||||
|
return fmt.Errorf("备份展开大小超过 8 GiB")
|
||||||
|
}
|
||||||
|
if e.Size != info.Size() {
|
||||||
|
return fmt.Errorf("备份期间文件发生变化: %s", name)
|
||||||
|
}
|
||||||
|
after, err := os.Stat(full)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !os.SameFile(info, after) || !info.ModTime().Equal(after.ModTime()) || info.Size() != after.Size() {
|
||||||
|
return fmt.Errorf("备份期间文件发生变化: %s", name)
|
||||||
|
}
|
||||||
|
e.SHA256 = hex.EncodeToString(sum.Sum(nil))
|
||||||
|
}
|
||||||
|
manifest.Entries = append(manifest.Entries, e)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := z.Create("manifest.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = json.NewEncoder(out).Encode(manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = z.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() > MaxArchiveBytes {
|
||||||
|
return fmt.Errorf("备份压缩大小超过 2 GiB")
|
||||||
|
}
|
||||||
|
return f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract 在私有暂存目录校验完整清单和全部文件哈希,绝不写入活动数据目录。
|
||||||
|
func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
||||||
|
var manifest Manifest
|
||||||
|
z, err := zip.OpenReader(archive)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
defer z.Close()
|
||||||
|
if len(z.File) > MaxEntries+1 {
|
||||||
|
return manifest, fmt.Errorf("备份文件数超过上限")
|
||||||
|
}
|
||||||
|
entries := map[string]*zip.File{}
|
||||||
|
for _, f := range z.File {
|
||||||
|
name := strings.TrimSuffix(f.Name, "/")
|
||||||
|
if !validPath(name) || entries[name] != nil {
|
||||||
|
return manifest, fmt.Errorf("归档路径无效或重复")
|
||||||
|
}
|
||||||
|
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
||||||
|
return manifest, fmt.Errorf("归档不允许符号链接或特殊文件")
|
||||||
|
}
|
||||||
|
entries[name] = f
|
||||||
|
}
|
||||||
|
m := entries["manifest.json"]
|
||||||
|
if m == nil || m.UncompressedSize64 > 32<<20 {
|
||||||
|
return manifest, fmt.Errorf("缺少有效备份清单")
|
||||||
|
}
|
||||||
|
r, err := m.Open()
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest)
|
||||||
|
err = errors.Join(err, r.Close())
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if manifest.Format != 1 || (manifest.Kind != "full" && manifest.Kind != "settings") || len(manifest.Entries) != len(entries)-1 {
|
||||||
|
return manifest, fmt.Errorf("备份版本、类型或清单无效")
|
||||||
|
}
|
||||||
|
var expanded int64
|
||||||
|
for _, e := range manifest.Entries {
|
||||||
|
if e.Size < 0 || e.Size > MaxExpandedBytes-expanded {
|
||||||
|
return manifest, fmt.Errorf("备份展开大小超过 8 GiB")
|
||||||
|
}
|
||||||
|
expanded += e.Size
|
||||||
|
}
|
||||||
|
var disk syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(dest, &disk); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) {
|
||||||
|
return manifest, fmt.Errorf("磁盘空间不足")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var total int64
|
||||||
|
for _, e := range manifest.Entries {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
f := entries[e.Path]
|
||||||
|
if !validEntry(manifest.Kind, e.Path) || seen[e.Path] || f == nil || e.Size < 0 || e.Dir != f.FileInfo().IsDir() || uint64(e.Size) != f.UncompressedSize64 {
|
||||||
|
return manifest, fmt.Errorf("备份清单与文件不符")
|
||||||
|
}
|
||||||
|
seen[e.Path] = true
|
||||||
|
total += e.Size
|
||||||
|
if total > MaxExpandedBytes {
|
||||||
|
return manifest, fmt.Errorf("备份展开大小超过 8 GiB")
|
||||||
|
}
|
||||||
|
target := filepath.Join(dest, filepath.FromSlash(e.Path))
|
||||||
|
if e.Dir {
|
||||||
|
if e.Size != 0 {
|
||||||
|
return manifest, fmt.Errorf("目录大小无效")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(target, 0700); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(e.SHA256) != 64 {
|
||||||
|
return manifest, fmt.Errorf("文件校验值无效")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
in, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
in.Close()
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
sum := sha256.New()
|
||||||
|
n, copyErr := io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, e.Size+1))
|
||||||
|
err = errors.Join(copyErr, in.Close(), out.Close())
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if n != e.Size || hex.EncodeToString(sum.Sum(nil)) != e.SHA256 {
|
||||||
|
return manifest, fmt.Errorf("文件校验失败: %s", e.Path)
|
||||||
|
}
|
||||||
|
t := time.UnixMilli(e.Modified)
|
||||||
|
if err := os.Chtimes(target, t, t); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manifest.Kind == "full" && (!seen["app/rawmark.db"] || !seen["vault"]) {
|
||||||
|
return manifest, fmt.Errorf("实例备份缺少数据库或笔记库")
|
||||||
|
}
|
||||||
|
if manifest.Kind == "settings" && !seen["settings.json"] {
|
||||||
|
return manifest, fmt.Errorf("设置备份缺少设置")
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"rawmark/internal/auth"
|
||||||
|
"rawmark/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTest(t *testing.T, file, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func database(t *testing.T, file string) {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = auth.New(st).Setup("disposable-backup-password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = st.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func fullArchive(t *testing.T, dir, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
source := filepath.Join(dir, "source")
|
||||||
|
os.MkdirAll(source, 0700)
|
||||||
|
database(t, filepath.Join(source, "rawmark.db"))
|
||||||
|
writeTest(t, filepath.Join(source, "vault/note.md"), content)
|
||||||
|
archive := filepath.Join(dir, "backup.zip")
|
||||||
|
if err := Create(context.Background(), archive, "full", []Source{{"app/rawmark.db", filepath.Join(source, "rawmark.db")}, {"vault", filepath.Join(source, "vault")}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return archive
|
||||||
|
}
|
||||||
|
func TestRecoveryCommitAndRollback(t *testing.T) {
|
||||||
|
for _, mode := range []string{"prepared", "applying", "invalid-source", "verifying"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
data := t.TempDir()
|
||||||
|
vault := filepath.Join(data, "vault")
|
||||||
|
oldArchive := fullArchive(t, t.TempDir(), "old")
|
||||||
|
oldStage := t.TempDir()
|
||||||
|
if _, err := Extract(context.Background(), oldArchive, oldStage); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CopyTree(filepath.Join(oldStage, "app"), data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CopyTree(filepath.Join(oldStage, "vault"), vault); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Join(data, "backups"), 0700)
|
||||||
|
saved := filepath.Join(data, "backups/old.zip")
|
||||||
|
if err := copyFile(oldArchive, saved, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
upload := filepath.Join(data, ".maintenance/uploads/test/stage")
|
||||||
|
os.MkdirAll(upload, 0700)
|
||||||
|
newArchive := fullArchive(t, t.TempDir(), "new")
|
||||||
|
if _, err := Extract(context.Background(), newArchive, upload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := mode
|
||||||
|
if mode == "invalid-source" {
|
||||||
|
state = "prepared"
|
||||||
|
writeTest(t, filepath.Join(upload, "app/rawmark.db"), "invalid SQLite")
|
||||||
|
}
|
||||||
|
if mode == "applying" || mode == "verifying" {
|
||||||
|
writeTest(t, filepath.Join(vault, "note.md"), "partial")
|
||||||
|
}
|
||||||
|
if err := WriteRecord(data, RestoreRecord{State: state, Stage: upload, Backup: saved}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := Recover(data, vault)
|
||||||
|
expected := "old"
|
||||||
|
if mode == "prepared" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pending, _ := Pending(data); !pending {
|
||||||
|
t.Fatal("验证启动前不应提交")
|
||||||
|
}
|
||||||
|
if err = FinishRecovery(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected = "new"
|
||||||
|
} else if err == nil {
|
||||||
|
t.Fatal("回退应保留失败原因")
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(vault, "note.md"))
|
||||||
|
if err != nil || string(body) != expected {
|
||||||
|
t.Fatalf("恢复内容错误: %q %v", body, err)
|
||||||
|
}
|
||||||
|
if pending, err := Pending(data); err != nil || pending {
|
||||||
|
t.Fatalf("回退或提交后仍有记录: %v", err)
|
||||||
|
}
|
||||||
|
if err = CheckDatabase(filepath.Join(data, "rawmark.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) {
|
||||||
|
for _, mode := range []string{"traversal", "duplicate", "symlink", "checksum"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "invalid.zip")
|
||||||
|
f, _ := os.Create(file)
|
||||||
|
z := zip.NewWriter(f)
|
||||||
|
switch mode {
|
||||||
|
case "traversal":
|
||||||
|
out, _ := z.Create("../outside")
|
||||||
|
out.Write([]byte("bad"))
|
||||||
|
case "duplicate":
|
||||||
|
z.Create("settings.json")
|
||||||
|
z.Create("settings.json")
|
||||||
|
case "symlink":
|
||||||
|
header := &zip.FileHeader{Name: "app/fonts/link"}
|
||||||
|
header.SetMode(os.ModeSymlink | 0777)
|
||||||
|
out, _ := z.CreateHeader(header)
|
||||||
|
out.Write([]byte("/etc/passwd"))
|
||||||
|
case "checksum":
|
||||||
|
out, _ := z.Create("settings.json")
|
||||||
|
out.Write([]byte("{}"))
|
||||||
|
manifest, _ := z.Create("manifest.json")
|
||||||
|
io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","size":2,"sha256":"`+strings.Repeat("0", 64)+`"}]}`)
|
||||||
|
}
|
||||||
|
z.Close()
|
||||||
|
f.Close()
|
||||||
|
if _, err := Extract(context.Background(), file, t.TempDir()); err == nil {
|
||||||
|
t.Fatal("无效归档被接受")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestSettingsInterruptedImportRestoresPreferencesAndResources(t *testing.T) {
|
||||||
|
data := t.TempDir()
|
||||||
|
database(t, filepath.Join(data, "rawmark.db"))
|
||||||
|
st, _ := store.OpenExisting(filepath.Join(data, "rawmark.db"))
|
||||||
|
st.SetSetting("app.settings", `{"fontSize":18}`)
|
||||||
|
source := t.TempDir()
|
||||||
|
writeTest(t, filepath.Join(source, "settings.json"), `{"app.settings":{"fontSize":18},"plugin.data.example":{"content":"old"}}`)
|
||||||
|
writeTest(t, filepath.Join(source, "fonts/original/font.woff2"), "old-font")
|
||||||
|
os.MkdirAll(filepath.Join(data, "backups"), 0700)
|
||||||
|
archive := filepath.Join(data, "backups/settings.zip")
|
||||||
|
if err := Create(context.Background(), archive, "settings", []Source{{"settings.json", filepath.Join(source, "settings.json")}, {"app/fonts", filepath.Join(source, "fonts")}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st.SetSetting("app.settings", `{"fontSize":24}`)
|
||||||
|
st.Close()
|
||||||
|
writeTest(t, filepath.Join(data, "fonts/new/font.woff2"), "new-font")
|
||||||
|
stage := filepath.Join(data, ".maintenance/uploads/test/stage")
|
||||||
|
os.MkdirAll(stage, 0700)
|
||||||
|
if err := WriteRecord(data, RestoreRecord{State: "applying", Kind: "settings", Stage: stage, Backup: archive}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Recover(data, filepath.Join(data, "vault")); err == nil {
|
||||||
|
t.Fatal("应报告中断并回退")
|
||||||
|
}
|
||||||
|
st, _ = store.OpenExisting(filepath.Join(data, "rawmark.db"))
|
||||||
|
defer st.Close()
|
||||||
|
value, _, err := st.Setting("app.settings")
|
||||||
|
if err != nil || value != `{"fontSize":18}` {
|
||||||
|
t.Fatal("偏好未回退")
|
||||||
|
}
|
||||||
|
if body, err := os.ReadFile(filepath.Join(data, "fonts/original/font.woff2")); err != nil || string(body) != "old-font" {
|
||||||
|
t.Fatal("资源未回退")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(data, "fonts/new")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("残留新资源")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestLogRotationIsBounded(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
log, err := OpenLog(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer log.Close()
|
||||||
|
chunk := []byte(strings.Repeat("a", 6<<20))
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, err = log.Write(chunk); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err = log.Snapshot(dest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("轮转文件数错误: %d", len(entries))
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
info, _ := entry.Info()
|
||||||
|
if info.Size() > 10<<20 || info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("日志未限量或权限不符: %+v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogWriter 保留三份各不超过 10 MiB 的应用日志,权限 0600。
|
||||||
|
type LogWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dir string
|
||||||
|
file *os.File
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenLog(dir string) (*LogWriter, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(filepath.Join(dir, "rawmark.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &LogWriter{dir: dir, file: f, size: info.Size()}, nil
|
||||||
|
}
|
||||||
|
func (l *LogWriter) Write(p []byte) (int, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if len(p) > 10<<20 {
|
||||||
|
return 0, errors.New("单条日志超过大小上限")
|
||||||
|
}
|
||||||
|
if l.size+int64(len(p)) > 10<<20 {
|
||||||
|
if err := l.file.Close(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for i := 2; i >= 1; i-- {
|
||||||
|
dest := filepath.Join(l.dir, "rawmark.log.2")
|
||||||
|
src := filepath.Join(l.dir, "rawmark.log.1")
|
||||||
|
if i == 1 {
|
||||||
|
dest = src
|
||||||
|
src = filepath.Join(l.dir, "rawmark.log")
|
||||||
|
}
|
||||||
|
if err := os.Rename(src, dest); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(filepath.Join(l.dir, "rawmark.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
l.file = f
|
||||||
|
l.size = 0
|
||||||
|
}
|
||||||
|
n, err := l.file.Write(p)
|
||||||
|
l.size += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
func (l *LogWriter) Close() error { l.mu.Lock(); defer l.mu.Unlock(); return l.file.Close() }
|
||||||
|
|
||||||
|
// Snapshot 读取当前日志的稳定副本,避免导出与日志轮转竞争。
|
||||||
|
func (l *LogWriter) Snapshot(dest string) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if err := l.file.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range []string{"rawmark.log", "rawmark.log.1", "rawmark.log.2"} {
|
||||||
|
src, err := os.Open(filepath.Join(l.dir, name))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(filepath.Join(dest, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
src.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, src)
|
||||||
|
err = errors.Join(err, src.Close(), out.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"rawmark/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。
|
||||||
|
// prepared 尚未改动活动数据,applying 表示上次中断,必须先回退。
|
||||||
|
type RestoreRecord struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Backup string `json:"backup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordPath(data string) string { return filepath.Join(data, ".maintenance", "restore.json") }
|
||||||
|
func WriteRecord(data string, record RestoreRecord) error {
|
||||||
|
bytes, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dest := recordPath(data)
|
||||||
|
if err = os.MkdirAll(filepath.Dir(dest), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(dest), "record-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if err = f.Chmod(0600); err == nil {
|
||||||
|
_, err = f.Write(bytes)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
err = errors.Join(err, f.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = os.Rename(f.Name(), dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, err := os.Open(filepath.Dir(dest))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
return dir.Sync()
|
||||||
|
}
|
||||||
|
func Pending(data string) (bool, error) {
|
||||||
|
_, err := os.Stat(recordPath(data))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckDatabase(file string) error {
|
||||||
|
// 先检查文件签名,Open 的 schema 初始化不能将错误文件伪装为新实例。
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header := make([]byte, 16)
|
||||||
|
_, err = io.ReadFull(f, header)
|
||||||
|
err = errors.Join(err, f.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(header) != "SQLite format 3\x00" {
|
||||||
|
return fmt.Errorf("备份数据库格式无效")
|
||||||
|
}
|
||||||
|
st, err := store.OpenExisting(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if err = st.CheckBackup(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
password, ok, err := st.Setting("auth.password")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parts := strings.Split(password, "$")
|
||||||
|
if !ok || len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=4" {
|
||||||
|
return fmt.Errorf("备份密码格式不受支持")
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil || len(salt) != 16 {
|
||||||
|
return fmt.Errorf("备份密码盐无效")
|
||||||
|
}
|
||||||
|
key, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
return fmt.Errorf("备份密码校验值无效")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultExcluded 支持外置与嵌套仓库,避免将应用文件重复备份为笔记。
|
||||||
|
func VaultExcluded(data, vault, rel string) bool {
|
||||||
|
for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
|
||||||
|
if strings.HasPrefix(part, ".rawmark-tmp-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full := filepath.Join(vault, filepath.FromSlash(rel))
|
||||||
|
data, _ = filepath.Abs(data)
|
||||||
|
vault, _ = filepath.Abs(vault)
|
||||||
|
if data == vault {
|
||||||
|
top := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||||
|
switch top {
|
||||||
|
case "rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm", "fonts", "themes", "plugins", "logs", "temp", "backups", ".maintenance":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(data, vault+string(filepath.Separator)) && (full == data || strings.HasPrefix(full, data+string(filepath.Separator))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string, mode os.FileMode) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
if err = os.MkdirAll(filepath.Dir(dst), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
if err == nil {
|
||||||
|
err = out.Sync()
|
||||||
|
}
|
||||||
|
return errors.Join(err, out.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyTree 不覆盖任何已有目标,也不允许跟随链接。
|
||||||
|
func CopyTree(src, dest string) error {
|
||||||
|
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dest, rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0700)
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("不允许复制特殊文件")
|
||||||
|
}
|
||||||
|
if err = copyFile(p, target, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chtimes(target, info.ModTime(), info.ModTime())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func clearVault(data, root, dir string) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
full := filepath.Join(dir, entry.Name())
|
||||||
|
rel, err := filepath.Rel(root, full)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if VaultExcluded(data, root, rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// data 在某个祖先目录内时保留祖先,只清理其余子项。
|
||||||
|
if entry.IsDir() && strings.HasPrefix(data, full+string(filepath.Separator)) {
|
||||||
|
if err = clearVault(data, root, full); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = os.RemoveAll(full); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func apply(data, vault, stage string) error {
|
||||||
|
if err := clearVault(data, vault, vault); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := CopyTree(filepath.Join(stage, "vault"), vault); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range []string{"rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm", "fonts", "themes", "plugins"} {
|
||||||
|
dest := filepath.Join(data, name)
|
||||||
|
if err := os.RemoveAll(dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source := filepath.Join(stage, "app", name)
|
||||||
|
info, err := os.Stat(source)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
err = CopyTree(source, dest)
|
||||||
|
} else {
|
||||||
|
err = copyFile(source, dest, 0600)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckDatabase(filepath.Join(data, "rawmark.db")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := store.OpenExisting(filepath.Join(data, "rawmark.db"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = st.ClearSessions()
|
||||||
|
return errors.Join(err, st.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover 在启动时完成预先确认的恢复;若中断或失败,使用操作前备份回退。
|
||||||
|
func Recover(data, vault string) (err error) {
|
||||||
|
raw, err := os.ReadFile(recordPath(data))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var record RestoreRecord
|
||||||
|
if err = json.Unmarshal(raw, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.State != "prepared" && record.State != "applying" && record.State != "verifying" {
|
||||||
|
return fmt.Errorf("恢复记录状态无效")
|
||||||
|
}
|
||||||
|
// 记录由本机生成;仍限制其读取范围,避免错误记录读取任意文件。
|
||||||
|
if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) {
|
||||||
|
return fmt.Errorf("恢复记录路径无效")
|
||||||
|
}
|
||||||
|
if record.Kind == "settings" {
|
||||||
|
return recoverSettings(data, record)
|
||||||
|
}
|
||||||
|
if record.State != "prepared" {
|
||||||
|
err = fmt.Errorf("上次实例恢复被中断")
|
||||||
|
}
|
||||||
|
if record.State == "prepared" {
|
||||||
|
record.State = "applying"
|
||||||
|
if err = WriteRecord(data, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = apply(data, vault, record.Stage); err == nil {
|
||||||
|
record.State = "verifying"
|
||||||
|
return WriteRecord(data, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage, stageErr := os.MkdirTemp(filepath.Join(data, ".maintenance"), "rollback-")
|
||||||
|
if stageErr != nil {
|
||||||
|
return errors.Join(err, stageErr)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(stage)
|
||||||
|
_, restoreErr := Extract(context.Background(), record.Backup, stage)
|
||||||
|
if restoreErr == nil {
|
||||||
|
restoreErr = apply(data, vault, stage)
|
||||||
|
}
|
||||||
|
if restoreErr != nil {
|
||||||
|
return errors.Join(err, fmt.Errorf("实例回退失败: %w", restoreErr))
|
||||||
|
}
|
||||||
|
if removeErr := os.Remove(recordPath(data)); removeErr != nil {
|
||||||
|
return errors.Join(err, removeErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("导入恢复失败,已回退原实例: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRecovery 在新实例的索引、监听与端口初始化成功后提交恢复。
|
||||||
|
func FinishRecovery(data string) error {
|
||||||
|
raw, err := os.ReadFile(recordPath(data))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var record RestoreRecord
|
||||||
|
if err = json.Unmarshal(raw, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.State != "verifying" {
|
||||||
|
return fmt.Errorf("恢复尚未完成初始化")
|
||||||
|
}
|
||||||
|
if err = os.RemoveAll(filepath.Dir(record.Stage)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Remove(recordPath(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveRecord(data string) error { return os.Remove(recordPath(data)) }
|
||||||
|
func recoverSettings(data string, record RestoreRecord) error {
|
||||||
|
stage, err := os.MkdirTemp(filepath.Join(data, ".maintenance"), "settings-rollback-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(stage)
|
||||||
|
if _, err = Extract(context.Background(), record.Backup, stage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, kind := range []string{"fonts", "themes", "plugins"} {
|
||||||
|
target := filepath.Join(data, kind)
|
||||||
|
if err = os.RemoveAll(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source := filepath.Join(stage, "app", kind)
|
||||||
|
if _, err = os.Stat(source); errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = CopyTree(source, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(stage, "settings.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var values map[string]json.RawMessage
|
||||||
|
if err = json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := store.OpenExisting(filepath.Join(data, "rawmark.db"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = st.ReplacePreferences(values)
|
||||||
|
err = errors.Join(err, st.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = os.Remove(recordPath(data)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("设置导入被中断,已回退原设置和资源")
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot 使用 SQLite 生成一致的独立数据库,不复制活动 WAL 文件。
|
||||||
|
func (s *Store) Snapshot(ctx context.Context, dest string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, dest)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (s *Store) Preferences() (map[string]json.RawMessage, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT key,value FROM settings WHERE key IN ('app.settings','plugins.enabled') OR key GLOB 'plugin.data.*' ORDER BY key`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := map[string]json.RawMessage{}
|
||||||
|
for rows.Next() {
|
||||||
|
var key, value string
|
||||||
|
if err := rows.Scan(&key, &value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !json.Valid([]byte(value)) {
|
||||||
|
return nil, fmt.Errorf("设置 %s 格式无效", key)
|
||||||
|
}
|
||||||
|
result[key] = json.RawMessage(value)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplacePreferences 一次提交全部偏好,账户与会话不属于设置包。
|
||||||
|
func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err = tx.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled') OR key GLOB 'plugin.data.*'`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key, value := range values {
|
||||||
|
if (key != "app.settings" && key != "plugins.enabled" && !strings.HasPrefix(key, "plugin.data.")) || !json.Valid(value) {
|
||||||
|
return fmt.Errorf("设置包含无效设置项")
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, string(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
func (s *Store) ResetPreferences() error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err }
|
||||||
|
|
||||||
|
// CheckBackup 确认数据库可读取且包含当前版本所需表;不会初始化或修复传入文件。
|
||||||
|
func (s *Store) CheckBackup() error {
|
||||||
|
var result string
|
||||||
|
if err := s.db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result != "ok" {
|
||||||
|
return errors.New("备份数据库完整性检查失败")
|
||||||
|
}
|
||||||
|
for _, query := range []string{`SELECT key,value FROM settings LIMIT 0`, `SELECT token_hash,created_at,expires_at FROM sessions LIMIT 0`, `SELECT id,name,token_hash,scope,created_at,last_used_at FROM api_tokens LIMIT 0`} {
|
||||||
|
rows, err := s.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenExisting 只打开已有库;导入校验不得创建表或更改日志模式。
|
||||||
|
func OpenExisting(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user