feat(storage): expose backup restoration and maintenance tools

This commit is contained in:
eezd committed 2026-10-25 13:22:16 +08:00
1 parent 88492fdcde
commit 9449a1aa65
18 files changed
+2194 -61

No files matched your search

+32 -7
View File
@@ -11,8 +11,10 @@ import (
"net"
"net/http"
"path"
"rawmark/internal/backup"
"strings"
"sync"
"sync/atomic"
"time"
"rawmark/internal/auth"
@@ -38,12 +40,22 @@ type Server struct {
// 串行化设置的"读取-合并-写入"
settingsMu sync.Mutex
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
DataDir string
ThemesDir string
PluginsDir string
FontsDir string
pluginsMu sync.Mutex
BuiltinFontsSize int64
DataDir string
ThemesDir string
PluginsDir string
FontsDir string
pluginsMu sync.Mutex
BuiltinFontsSize int64
activity sync.RWMutex
maintenanceMu sync.Mutex
maintenanceBusy atomic.Bool
restarting atomic.Bool
maintenanceStatusMu sync.Mutex
maintenanceState maintenanceStatus
Restart func()
Logs *backup.LogWriter
Version string
eventWG sync.WaitGroup
}
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
@@ -96,6 +108,19 @@ func (s *Server) routes() []route {
{"GET /api/v1/trash", authed, s.trashList},
{"DELETE /api/v1/trash", authed, s.trashClear},
{"GET /api/v1/storage", sessionOnly, s.getStorage},
{"GET /api/v1/maintenance/status", sessionOnly, s.maintenanceInfo},
{"POST /api/v1/maintenance/export/{kind}", sessionOnly, s.exportBackup},
{"POST /api/v1/maintenance/notes/export", sessionOnly, s.exportNotes},
{"POST /api/v1/maintenance/uploads", sessionOnly, s.startMaintenanceUpload},
{"PUT /api/v1/maintenance/uploads/{id}", sessionOnly, s.appendMaintenanceUpload},
{"POST /api/v1/maintenance/uploads/{id}/validate", sessionOnly, s.validateMaintenanceUpload},
{"DELETE /api/v1/maintenance/uploads/{id}", sessionOnly, s.deleteMaintenanceUpload},
{"POST /api/v1/maintenance/settings/import", sessionOnly, s.applySettings},
{"POST /api/v1/maintenance/settings/reset", sessionOnly, s.resetSettings},
{"POST /api/v1/maintenance/restore", sessionOnly, s.restoreInstance},
{"POST /api/v1/maintenance/index/{operation}", sessionOnly, s.maintainIndex},
{"POST /api/v1/maintenance/temp/clear", sessionOnly, s.clearTemp},
{"POST /api/v1/maintenance/logs/export", sessionOnly, s.exportLogs},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
{"GET /api/v1/events", authed, s.events},
@@ -136,7 +161,7 @@ func (s *Server) Handler(static fs.FS) http.Handler {
}
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
mux.Handle("/", newStaticHandler(static))
return securityHeaders(sameOrigin(mux))
return securityHeaders(sameOrigin(s.guardAPI(mux)))
}
// ---- 中间件 ----
+2
View File
@@ -12,6 +12,8 @@ import (
// events 通过 WebSocket 推送仓库变更。websocket.Accept 默认校验 Origin 与 Host 一致
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
s.eventWG.Add(1)
defer s.eventWG.Done()
// 先订阅再完成握手,避免连接建立后首条变更落在订阅空窗中。
msgs, unsubscribe := s.hub.Subscribe()
defer unsubscribe()
+4
View File
@@ -0,0 +1,4 @@
package api
// CloseEvents 在数据库关闭前断开并等待全部 WebSocket 处理结束。
func (s *Server) CloseEvents() { s.hub.Close(); s.eventWG.Wait() }
+514
View File
@@ -0,0 +1,514 @@
package api
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"rawmark/internal/backup"
"rawmark/internal/events"
)
type maintenanceStatus struct {
Busy bool `json:"busy"`
Operation string `json:"operation"`
Error string `json:"error"`
FinishedAt int64 `json:"finishedAt"`
Backup string `json:"backup,omitempty"`
}
// gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。
func (s *Server) guardAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/v1/health" {
next.ServeHTTP(w, r)
return
}
if s.maintenanceBusy.Load() && r.URL.Path != "/api/v1/maintenance/status" {
writeError(w, 503, "正在维护,请稍后重试")
return
}
if strings.HasPrefix(r.URL.Path, "/api/v1/maintenance/") || r.URL.Path == "/api/v1/events" {
next.ServeHTTP(w, r)
return
}
if !s.activity.TryRLock() {
writeError(w, 503, "正在维护,请稍后重试")
return
}
defer s.activity.RUnlock()
next.ServeHTTP(w, r)
})
}
func (s *Server) maintain(operation string, fn func() error) error {
if !s.maintenanceMu.TryLock() {
return fmt.Errorf("其他维护操作正在执行")
}
defer s.maintenanceMu.Unlock()
s.maintenanceBusy.Store(true)
s.maintenanceStatusMu.Lock()
s.maintenanceState = maintenanceStatus{Busy: true, Operation: operation}
s.maintenanceStatusMu.Unlock()
s.activity.Lock()
defer s.activity.Unlock()
err := fn()
s.maintenanceStatusMu.Lock()
s.maintenanceState.Busy = false
s.maintenanceState.FinishedAt = time.Now().UnixMilli()
if err != nil {
s.maintenanceState.Error = err.Error()
}
s.maintenanceStatusMu.Unlock()
if !s.restarting.Load() {
s.maintenanceBusy.Store(false)
}
return err
}
func (s *Server) maintenanceInfo(w http.ResponseWriter, r *http.Request) {
s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock()
writeJSON(w, 200, s.maintenanceState)
}
func (s *Server) maintenanceError(w http.ResponseWriter, err error) {
writeError(w, 400, "操作失败:"+err.Error())
}
func (s *Server) tempDir() (string, error) {
dir := filepath.Join(s.DataDir, "temp")
return dir, os.MkdirAll(dir, 0700)
}
func (s *Server) backupDir() (string, error) {
dir := filepath.Join(s.DataDir, "backups")
return dir, os.MkdirAll(dir, 0700)
}
func (s *Server) archiveSources(ctx context.Context, stage, kind string) ([]backup.Source, error) {
sources := []backup.Source{}
if kind == "full" {
snapshot := filepath.Join(stage, "rawmark.db")
if err := s.store.Snapshot(ctx, snapshot); err != nil {
return nil, err
}
sources = append(sources, backup.Source{Name: "app/rawmark.db", Path: snapshot}, backup.Source{Name: "vault", Path: s.vault.Root()})
} else {
prefs, err := s.store.Preferences()
if err != nil {
return nil, err
}
data, err := json.Marshal(prefs)
if err != nil {
return nil, err
}
file := filepath.Join(stage, "settings.json")
if err = os.WriteFile(file, data, 0600); err != nil {
return nil, err
}
sources = append(sources, backup.Source{Name: "settings.json", Path: file})
}
for _, name := range []string{"fonts", "themes", "plugins"} {
root := filepath.Join(s.DataDir, name)
_, err := os.Stat(root)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
sources = append(sources, backup.Source{Name: "app/" + name, Path: root})
}
return sources, nil
}
func (s *Server) createArchive(ctx context.Context, kind, dest string) error {
temp, err := s.tempDir()
if err != nil {
return err
}
stage, err := os.MkdirTemp(temp, "backup-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
sources, err := s.archiveSources(ctx, stage, kind)
if err != nil {
return err
}
return backup.Create(ctx, dest, kind, sources, func(name string) bool {
if strings.HasPrefix(name, "vault/") {
return backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(name, "vault/"))
}
for _, part := range strings.Split(name, "/") {
if strings.HasPrefix(part, ".import-") || strings.HasPrefix(part, ".install-") {
return true
}
}
return false
})
}
func (s *Server) saveRollback(ctx context.Context, kind string) (string, error) {
dir, err := s.backupDir()
if err != nil {
return "", err
}
file := filepath.Join(dir, kind+"-"+time.Now().Format("20060102T150405.000000000")+".zip")
if err = s.createArchive(ctx, kind, file); err != nil {
return "", err
}
s.maintenanceStatusMu.Lock()
s.maintenanceState.Backup = filepath.Base(file)
s.maintenanceStatusMu.Unlock()
return file, nil
}
func sendDownload(w http.ResponseWriter, r *http.Request, file, name string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
http.ServeContent(w, r, name, time.Time{}, f)
return nil
}
func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
kind := r.PathValue("kind")
if kind != "full" && kind != "settings" {
writeError(w, 400, "备份类型无效")
return
}
temp, err := s.tempDir()
if err != nil {
internalError(w, r, err)
return
}
file, err := os.CreateTemp(temp, "download-*.zip")
if err != nil {
internalError(w, r, err)
return
}
name := file.Name()
file.Close()
os.Remove(name)
defer os.Remove(name)
err = s.maintain("导出备份", func() error {
if err := s.createArchive(r.Context(), kind, name); err != nil {
return err
}
return sendDownload(w, r, name, "rawmark-"+kind+"-"+time.Now().Format("20060102-150405")+".zip")
})
if err != nil {
s.maintenanceError(w, err)
}
}
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
err := s.maintain("导出笔记数据", func() error {
file, err := s.vault.ExportNotes(r.Context())
if err != nil {
return err
}
defer os.Remove(file.Name())
if err = file.Close(); err != nil {
return err
}
return sendDownload(w, r, file.Name(), "rawmark-notes-"+time.Now().Format("20060102-150405")+".zip")
})
if err != nil {
s.maintenanceError(w, err)
}
}
func validatePreferences(values map[string]json.RawMessage) error {
for key, raw := range values {
if len(raw) > maxJSONBytes {
return fmt.Errorf("单项设置超过 1 MiB")
}
switch {
case key == settingsKey:
var settings map[string]json.RawMessage
if err := json.Unmarshal(raw, &settings); err != nil || settings == nil {
return fmt.Errorf("偏好设置必须为对象")
}
if _, err := trashRetentionDays(settings); err != nil {
return err
}
if value, ok := settings["shortcuts"]; ok {
var shortcuts map[string][]string
if err := json.Unmarshal(value, &shortcuts); err != nil {
return fmt.Errorf("快捷键格式无效")
}
}
case key == pluginEnabledKey:
var ids map[string]bool
if err := json.Unmarshal(raw, &ids); err != nil {
return err
}
for id := range ids {
if !validPluginID(id) {
return fmt.Errorf("插件 ID 无效")
}
}
case strings.HasPrefix(key, "plugin.data."):
if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) {
return fmt.Errorf("插件数据无效")
}
default:
return fmt.Errorf("设置包包含不允许的设置项")
}
}
return nil
}
func readPreferences(stage string) (map[string]json.RawMessage, error) {
info, err := os.Stat(filepath.Join(stage, "settings.json"))
if err != nil {
return nil, err
}
if info.Size() > 32<<20 {
return nil, fmt.Errorf("设置包设置内容超过 32 MiB")
}
raw, err := os.ReadFile(filepath.Join(stage, "settings.json"))
if err != nil {
return nil, err
}
var values map[string]json.RawMessage
if err = json.Unmarshal(raw, &values); err != nil || values == nil {
return nil, fmt.Errorf("设置包格式无效")
}
if err = validatePreferences(values); err != nil {
return nil, err
}
return values, nil
}
// importSettings 在确认后合并资源;原资源与数据库值都保留到成功提交为止。
func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string
Confirmed bool
}
if !readJSON(w, r, &body) {
return
}
if !body.Confirmed {
writeError(w, 400, "请先确认导入设置")
return
}
err := s.maintain("导入设置", func() error {
meta, dir, err := s.readUpload(r, body.ID)
if err != nil {
return err
}
if !meta.Validated || meta.Kind != "settings" {
return fmt.Errorf("请先校验设置包")
}
stage := filepath.Join(dir, "stage")
values, err := readPreferences(stage)
if err != nil {
return err
}
rollbackArchive, err := s.saveRollback(r.Context(), "settings")
if err != nil {
return err
}
original, err := s.store.Preferences()
if err != nil {
return err
}
temp, err := s.tempDir()
if err != nil {
return err
}
rollback, err := os.MkdirTemp(temp, "settings-rollback-")
if err != nil {
return err
}
defer os.RemoveAll(rollback)
type resource struct {
target, old string
existed bool
}
var changed []resource
restore := func() error {
var failures []error
for i := len(changed) - 1; i >= 0; i-- {
item := changed[i]
failures = append(failures, os.RemoveAll(item.target))
if item.existed {
failures = append(failures, os.Rename(item.old, item.target))
}
}
failures = append(failures, s.store.ReplacePreferences(original))
failure := errors.Join(failures...)
if failure == nil {
return backup.RemoveRecord(s.DataDir)
}
return failure
}
if err = backup.WriteRecord(s.DataDir, backup.RestoreRecord{State: "applying", Kind: "settings", Stage: stage, Backup: rollbackArchive}); err != nil {
return err
}
for _, kind := range []string{"fonts", "themes", "plugins"} {
source := filepath.Join(stage, "app", kind)
entries, e := os.ReadDir(source)
if errors.Is(e, os.ErrNotExist) {
continue
}
if e != nil {
return errors.Join(e, restore())
}
if e = os.MkdirAll(filepath.Join(s.DataDir, kind), 0700); e != nil {
return errors.Join(e, restore())
}
for _, entry := range entries {
target := filepath.Join(s.DataDir, kind, entry.Name())
old := filepath.Join(rollback, kind, entry.Name())
if e = os.MkdirAll(filepath.Dir(old), 0700); e != nil {
return errors.Join(e, restore())
}
existed := false
if _, e = os.Lstat(target); e == nil {
existed = true
if e = os.Rename(target, old); e != nil {
return errors.Join(e, restore())
}
} else if !errors.Is(e, os.ErrNotExist) {
return errors.Join(e, restore())
}
changed = append(changed, resource{target, old, existed})
if e = backup.CopyTree(filepath.Join(source, entry.Name()), target); e != nil {
return errors.Join(e, restore())
}
}
}
if err = s.store.ReplacePreferences(values); err != nil {
return errors.Join(err, restore())
}
if err = os.RemoveAll(dir); err != nil {
return errors.Join(err, restore())
}
if err = backup.RemoveRecord(s.DataDir); err != nil {
return errors.Join(err, restore())
}
return nil
})
if err != nil {
s.maintenanceError(w, err)
return
}
s.hub.Broadcast(events.Event{Type: "settings"}, events.Event{Type: "plugins"})
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) {
var body struct{ Confirmed bool }
if !readJSON(w, r, &body) {
return
}
if !body.Confirmed {
writeError(w, 400, "请先确认重置")
return
}
err := s.maintain("重置设置", func() error {
if _, err := s.saveRollback(r.Context(), "settings"); err != nil {
return err
}
if err := s.store.ResetPreferences(); err != nil {
return err
}
return nil
})
if err != nil {
s.maintenanceError(w, err)
return
}
s.hub.Broadcast(events.Event{Type: "settings"}, events.Event{Type: "plugins"})
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) {
operation := r.PathValue("operation")
if operation != "optimize" && operation != "rebuild" {
writeError(w, 400, "索引操作无效")
return
}
err := s.maintain("索引维护", func() error {
if operation == "rebuild" {
return s.index.Rebuild(r.Context())
}
return s.index.Optimize(r.Context())
})
if err != nil {
s.maintenanceError(w, err)
return
}
s.hub.Broadcast(events.Event{Type: "indexed"})
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) exportLogs(w http.ResponseWriter, r *http.Request) {
if s.Logs == nil {
writeError(w, 503, "当前服务尚未启用日志文件")
return
}
err := s.maintain("导出系统日志", func() error {
temp, err := s.tempDir()
if err != nil {
return err
}
dir, err := os.MkdirTemp(temp, "logs-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
if err = s.Logs.Snapshot(dir); err != nil {
return err
}
file := filepath.Join(dir, "system-logs.zip")
out, err := os.OpenFile(file, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return err
}
z := zip.NewWriter(out)
entries, err := os.ReadDir(dir)
if err != nil {
out.Close()
return err
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "rawmark.log") {
continue
}
dest, e := z.Create(entry.Name())
if e != nil {
out.Close()
return e
}
in, e := os.Open(filepath.Join(dir, entry.Name()))
if e != nil {
out.Close()
return e
}
_, e = io.Copy(dest, in)
e = errors.Join(e, in.Close())
if e != nil {
out.Close()
return e
}
}
dest, err := z.Create("runtime.json")
if err == nil {
err = json.NewEncoder(dest).Encode(map[string]string{"go": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, "version": s.Version})
}
err = errors.Join(err, z.Close(), out.Close())
if err != nil {
return err
}
return sendDownload(w, r, file, "rawmark-system-logs.zip")
})
if err != nil {
s.maintenanceError(w, err)
}
}
+257
View File
@@ -0,0 +1,257 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"rawmark/internal/backup"
"rawmark/internal/store"
)
func exportedPackage(t *testing.T, f *fixture, kind string) []byte {
t.Helper()
res := f.do(f.admin, "POST", "/api/v1/maintenance/export/"+kind, "", nil)
data, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != 200 {
t.Fatalf("导出失败: %d %s", res.StatusCode, data)
}
return data
}
func stagedPackage(t *testing.T, f *fixture, kind string, data []byte) string {
t.Helper()
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"`+kind+`","size":`+strconv.Itoa(len(data))+`}`, nil)
if res.StatusCode != 201 {
t.Fatalf("创建上传失败: %d", res.StatusCode)
}
id := decode[struct{ ID string }](t, res).ID
// 跨多个分块验证偏移,不引入测试开关。
offset := 0
for offset < len(data) {
end := min(offset+137, len(data))
res = f.do(f.admin, "PUT", "/api/v1/maintenance/uploads/"+id, string(data[offset:end]), map[string]string{"Upload-Offset": strconv.Itoa(offset)})
if res.StatusCode != 200 {
t.Fatalf("追加上传失败: %d", res.StatusCode)
}
next := decode[struct{ Offset int }](t, res).Offset
if next != end {
t.Fatalf("偏移不符: %d", next)
}
offset = next
}
res = f.do(f.admin, "POST", "/api/v1/maintenance/uploads/"+id+"/validate", "", nil)
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
t.Fatalf("校验失败: %d %s", res.StatusCode, body)
}
return id
}
func assertOK(t *testing.T, res *http.Response) {
t.Helper()
if res.StatusCode != 200 {
data, _ := io.ReadAll(res.Body)
t.Fatalf("操作失败: %d %s", res.StatusCode, data)
}
}
func TestSettingsPackageRoundTripMergesResourcesAndPreservesAccount(t *testing.T) {
f := newFixture(t)
f.write("note.md", "source")
font := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "original.woff2", testFontData(t)))
assertOK(t, f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":24,"uiFont":"`+font.ID+`"}`, nil))
if err := f.store.SetSetting("plugin.data.example", `{"content":"keep"}`); err != nil {
t.Fatal(err)
}
data := exportedPackage(t, f, "settings")
packagePath := filepath.Join(t.TempDir(), "settings.zip")
os.WriteFile(packagePath, data, 0600)
stage := t.TempDir()
m, err := backup.Extract(context.Background(), packagePath, stage)
if err != nil || m.Kind != "settings" {
t.Fatalf("设置包无效: %v", err)
}
prefs, err := readPreferences(stage)
if err != nil {
t.Fatal(err)
}
if _, ok := prefs["auth.password"]; ok {
t.Fatal("设置包不得含账户密码")
}
assertOK(t, f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":18}`, nil))
second := append([]byte(nil), testFontData(t)...)
second = append(second, 0)
extra := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "extra.woff2", second))
id := stagedPackage(t, f, "settings", data)
assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/import", `{"id":"`+id+`","confirmed":true}`, nil))
settings := decode[map[string]json.RawMessage](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if string(settings["fontSize"]) != "24" {
t.Fatal("偏好未恢复")
}
fonts := decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil))
if len(fonts.Fonts) != 2 {
t.Fatalf("未保留包外字体: %+v", fonts)
}
assertOK(t, f.do(f.admin, "GET", "/api/v1/fonts/"+extra.ID+"/file", "", nil))
if body := f.do(f.admin, "GET", "/api/v1/files/note.md", "", nil); body.StatusCode != 200 {
t.Fatal("笔记被改变")
}
rollback, err := filepath.Glob(filepath.Join(filepath.Dir(f.root), "backups", "settings-*.zip"))
if err != nil || len(rollback) != 1 {
t.Fatal("未保存操作前备份")
}
if pending, err := backup.Pending(filepath.Dir(f.root)); err != nil || pending {
t.Fatal("成功导入未提交恢复记录")
}
}
func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) {
f := newFixture(t)
f.write("note.md", "source")
f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0}`)
f.store.SetSetting(pluginEnabledKey, `{"example":true}`)
f.store.SetSetting("plugin.data.example", `{"important":"content"}`)
if status := f.status(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":false}`, nil); status != 400 {
t.Fatal(status)
}
assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":true}`, nil))
for _, key := range []string{settingsKey, pluginEnabledKey} {
if _, ok, err := f.store.Setting(key); err != nil || ok {
t.Fatalf("未重置 %s", key)
}
}
if value, ok, err := f.store.Setting("plugin.data.example"); err != nil || !ok || value != `{"important":"content"}` {
t.Fatal("插件数据丢失")
}
assertOK(t, f.do(f.admin, "GET", "/api/v1/tree", "", nil))
}
func TestFullBackupRestoresFilesDatabaseAndInvalidatesSessions(t *testing.T) {
f := newFixture(t)
f.write("folder/note.md", "before ![](./assets/a.png)")
f.write("folder/assets/a.png", "image bytes")
f.store.SetSetting(settingsKey, `{"fontSize":24}`)
data := exportedPackage(t, f, "full")
archive := filepath.Join(t.TempDir(), "full.zip")
os.WriteFile(archive, data, 0600)
stage := t.TempDir()
_, err := backup.Extract(context.Background(), archive, stage)
if err != nil {
t.Fatal(err)
}
if err = backup.CheckDatabase(filepath.Join(stage, "app/rawmark.db")); err != nil {
t.Fatal(err)
}
root := t.TempDir()
vault := filepath.Join(root, "vault")
os.MkdirAll(vault, 0700)
// 解包后落入目标实例,目标的环境路径与源实例不同。
if err = backup.CopyTree(filepath.Join(stage, "vault"), vault); err != nil {
t.Fatal(err)
}
if err = backup.CopyTree(filepath.Join(stage, "app"), root); err != nil {
t.Fatal(err)
}
st, err := store.OpenExisting(filepath.Join(root, "rawmark.db"))
if err != nil {
t.Fatal(err)
}
if value, _, err := st.Setting(settingsKey); err != nil || !strings.Contains(value, "24") {
t.Fatal("数据库快照不一致")
}
if err = st.ClearSessions(); err != nil {
t.Fatal(err)
}
st.Close()
bytes, err := os.ReadFile(filepath.Join(vault, "folder/note.md"))
if err != nil || string(bytes) != "before ![](./assets/a.png)" {
t.Fatal("笔记未原样恢复")
}
}
func TestMaintenanceUploadsEnforceOwnershipOffsetsAndValidation(t *testing.T) {
f := newFixture(t)
data := exportedPackage(t, f, "settings")
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":`+strconv.Itoa(len(data))+`}`, nil)
id := decode[struct{ ID string }](t, res).ID
if code := f.status(f.admin, "PUT", "/api/v1/maintenance/uploads/"+id, "x", map[string]string{"Upload-Offset": "1"}); code != 400 {
t.Fatal("允许错误偏移")
}
if code := f.status(f.admin, "POST", "/api/v1/maintenance/uploads/"+id+"/validate", "", nil); code != 400 {
t.Fatal("允许校验未完成上传")
}
other := newClient()
assertOK(t, f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil))
if code := f.status(other, "DELETE", "/api/v1/maintenance/uploads/"+id, "", nil); code != 400 {
t.Fatal("允许其他会话删除上传")
}
if code := f.status(f.admin, "POST", "/api/v1/maintenance/settings/import", `{"id":"`+id+`","confirmed":true}`, nil); code != 400 {
t.Fatal("允许未校验应用")
}
assertOK(t, f.do(f.admin, "DELETE", "/api/v1/maintenance/uploads/"+id, "", nil))
}
func TestMaintenanceRequiresSessionAndClearTempPreservesData(t *testing.T) {
f := newFixture(t)
f.write("note.md", "keep")
f.write("assets/image.png", "keep")
created := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"test","scope":"write"}`, nil))
for _, path := range []string{"/maintenance/export/full", "/maintenance/notes/export", "/maintenance/settings/reset", "/maintenance/temp/clear", "/maintenance/index/rebuild", "/maintenance/logs/export"} {
if code := f.status(newClient(), "POST", "/api/v1"+path, "{}", nil); code != 401 {
t.Fatalf("匿名可访问 %s: %d", path, code)
}
if code := f.status(newClient(), "POST", "/api/v1"+path, "{}", map[string]string{"Authorization": "Bearer " + created.Token}); code != 403 {
t.Fatalf("令牌可访问 %s: %d", path, code)
}
}
data := filepath.Dir(f.root)
temp := filepath.Join(data, "temp")
os.MkdirAll(temp, 0700)
os.WriteFile(filepath.Join(temp, "abandoned"), []byte("abc"), 0600)
os.MkdirAll(filepath.Join(data, "backups"), 0700)
os.WriteFile(filepath.Join(data, "backups/keep.zip"), []byte("backup"), 0600)
id := stagedPackage(t, f, "settings", exportedPackage(t, f, "settings"))
result := decode[cleanupResult](t, f.do(f.admin, "POST", "/api/v1/maintenance/temp/clear", "", nil))
if result.Files != 1 || result.Bytes != 3 || len(result.Failed) != 0 {
t.Fatalf("清理结果错误: %+v", result)
}
if _, err := os.Stat(filepath.Join(data, ".maintenance/uploads", id, "archive.zip")); err != nil {
t.Fatal("删除了新上传")
}
if _, err := os.Stat(filepath.Join(data, "backups/keep.zip")); err != nil {
t.Fatal("删除了回退备份")
}
assertOK(t, f.do(f.admin, "GET", "/api/v1/files/note.md", "", nil))
}
func TestSettingsPackageDatabaseFailureRollsBack(t *testing.T) {
f := newFixture(t)
f.store.SetSetting(settingsKey, `{"fontSize":24}`)
data := exportedPackage(t, f, "settings")
f.store.SetSetting(settingsKey, `{"fontSize":18}`)
// 通过实际 SQLite 约束制造提交失败,验证备份和事务边界。
raw, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer raw.Close()
_, err = raw.Exec(`CREATE TRIGGER reject_import BEFORE INSERT ON settings WHEN NEW.key='app.settings' AND NEW.value LIKE '%24%' BEGIN SELECT RAISE(ABORT,'intentional import failure'); END`)
if err != nil {
t.Fatal(err)
}
id := stagedPackage(t, f, "settings", data)
res := f.do(f.admin, "POST", "/api/v1/maintenance/settings/import", `{"id":"`+id+`","confirmed":true}`, nil)
if res.StatusCode == 200 {
t.Fatal("错误提交返回成功")
}
value, _, err := f.store.Setting(settingsKey)
if err != nil || !strings.Contains(value, "18") {
t.Fatal("原设置未保留")
}
if pending, _ := backup.Pending(filepath.Dir(f.root)); pending {
t.Fatal("成功回退后仍有待执行记录")
}
}
+438
View File
@@ -0,0 +1,438 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"rawmark/internal/backup"
"rawmark/internal/store"
)
const maintenanceChunkBytes int64 = 8 << 20
var uploadIDPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
type maintenanceUpload struct {
Kind string `json:"kind"`
Size int64 `json:"size"`
Owner string `json:"owner"`
Validated bool `json:"validated"`
}
func uploadOwner(r *http.Request) string {
cookie, _ := r.Cookie(cookieName)
sum := sha256.Sum256([]byte(cookie.Value))
return hex.EncodeToString(sum[:])
}
func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) {
var meta maintenanceUpload
if !uploadIDPattern.MatchString(id) {
return meta, "", fmt.Errorf("上传 ID 无效")
}
dir := filepath.Join(s.DataDir, ".maintenance", "uploads", id)
data, err := os.ReadFile(filepath.Join(dir, "upload.json"))
if err != nil {
return meta, "", err
}
if err = json.Unmarshal(data, &meta); err != nil {
return meta, "", err
}
if meta.Owner != uploadOwner(r) {
return meta, "", fmt.Errorf("上传不属于当前会话")
}
return meta, dir, nil
}
func saveUpload(dir string, meta maintenanceUpload) error {
data, err := json.Marshal(meta)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "upload.json"), data, 0600)
}
func enoughSpace(dir string, size int64) error {
var stat syscall.Statfs_t
if err := syscall.Statfs(dir, &stat); err != nil {
return err
}
if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) {
return fmt.Errorf("磁盘空间不足")
}
return nil
}
func (s *Server) startMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
var body struct {
Kind string
Size int64
}
if !readJSON(w, r, &body) {
return
}
if (body.Kind != "full" && body.Kind != "settings") || body.Size <= 0 || body.Size > backup.MaxArchiveBytes {
writeError(w, 400, "请选择有效备份,压缩大小上限 2 GiB")
return
}
var id string
err := s.maintain("准备上传", func() error {
root := filepath.Join(s.DataDir, ".maintenance", "uploads")
if err := os.MkdirAll(root, 0700); err != nil {
return err
}
if err := enoughSpace(root, body.Size); err != nil {
return err
}
id = hex.EncodeToString(randBytes())
dir := filepath.Join(root, id)
if err := os.Mkdir(dir, 0700); err != nil {
return err
}
if err := saveUpload(dir, maintenanceUpload{Kind: body.Kind, Size: body.Size, Owner: uploadOwner(r)}); err != nil {
os.RemoveAll(dir)
return err
}
f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
os.RemoveAll(dir)
return err
}
return f.Close()
})
if err != nil {
s.maintenanceError(w, err)
return
}
writeJSON(w, 201, map[string]string{"id": id})
}
func randBytes() []byte { bytes := make([]byte, 16); rand.Read(bytes); return bytes }
func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
if err != nil || offset < 0 {
writeError(w, 400, "上传偏移无效")
return
}
var next int64
err = s.maintain("上传备份", func() error {
meta, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil {
return err
}
if meta.Validated {
return fmt.Errorf("已校验的上传不可修改")
}
f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_WRONLY, 0600)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
if info.Size() != offset {
return fmt.Errorf("上传偏移不匹配,请重新上传")
}
limit := min(maintenanceChunkBytes, meta.Size-offset)
if limit <= 0 {
return fmt.Errorf("上传大小超出清单")
}
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil {
return err
}
if len(data) == 0 {
return fmt.Errorf("上传内容为空")
}
if err = enoughSpace(dir, int64(len(data))); err != nil {
return err
}
n, err := f.WriteAt(data, offset)
if err != nil {
return errors.Join(err, f.Truncate(offset))
}
if err = f.Sync(); err != nil {
return errors.Join(err, f.Truncate(offset))
}
next = offset + int64(n)
return nil
})
if err != nil {
s.maintenanceError(w, err)
return
}
writeJSON(w, 200, map[string]int64{"offset": next})
}
func (s *Server) validateResources(stage string) error {
temp := &Server{FontsDir: filepath.Join(stage, "app", "fonts"), ThemesDir: filepath.Join(stage, "app", "themes"), PluginsDir: filepath.Join(stage, "app", "plugins")}
for _, kind := range []string{"fonts", "themes", "plugins"} {
entries, err := os.ReadDir(filepath.Join(stage, "app", kind))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
return fmt.Errorf("资源目录内包含非目录条目")
}
switch kind {
case "fonts":
dir, _, err := temp.openFont(entry.Name())
if err != nil {
return err
}
dir.Close()
case "themes":
if _, _, err := temp.readTheme(entry.Name()); err != nil {
return err
}
case "plugins":
dir, _, err := temp.openPlugin(entry.Name())
if err != nil {
return err
}
dir.Close()
}
}
}
return nil
}
func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
var manifest backup.Manifest
var bytes int64
err := s.maintain("校验备份", func() error {
meta, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil {
return err
}
info, err := os.Stat(filepath.Join(dir, "archive.zip"))
if err != nil {
return err
}
if info.Size() != meta.Size {
return fmt.Errorf("上传尚未完成")
}
meta.Validated = false
if err = saveUpload(dir, meta); err != nil {
return err
}
stage := filepath.Join(dir, "stage")
if err = os.RemoveAll(stage); err != nil {
return err
}
if err = os.Mkdir(stage, 0700); err != nil {
return err
}
manifest, err = backup.Extract(r.Context(), filepath.Join(dir, "archive.zip"), stage)
if err != nil {
return err
}
if manifest.Kind != meta.Kind {
return fmt.Errorf("备份类型与所选操作不符")
}
for _, entry := range manifest.Entries {
bytes += entry.Size
if strings.HasPrefix(entry.Path, "vault/") && backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(entry.Path, "vault/")) {
return fmt.Errorf("笔记库内容与应用目录冲突")
}
}
if err = enoughSpace(dir, bytes*2); err != nil {
return err
}
if err = s.validateResources(stage); err != nil {
return err
}
if meta.Kind == "settings" {
_, err = readPreferences(stage)
} else {
err = backup.CheckDatabase(filepath.Join(stage, "app", "rawmark.db"))
if err == nil {
var st *store.Store
st, err = store.OpenExisting(filepath.Join(stage, "app", "rawmark.db"))
if err == nil {
var prefs map[string]json.RawMessage
prefs, err = st.Preferences()
if err == nil {
err = validatePreferences(prefs)
}
err = errors.Join(err, st.Close())
}
}
}
if err != nil {
return err
}
meta.Validated = true
return saveUpload(dir, meta)
})
if err != nil {
s.maintenanceError(w, err)
return
}
writeJSON(w, 200, map[string]any{"kind": manifest.Kind, "createdAt": manifest.CreatedAt, "entries": len(manifest.Entries), "size": bytes})
}
func (s *Server) deleteMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
err := s.maintain("取消上传", func() error {
_, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil {
return err
}
return os.RemoveAll(dir)
})
if err != nil {
s.maintenanceError(w, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) restoreInstance(w http.ResponseWriter, r *http.Request) {
var body struct {
ID string
Confirmed bool
}
if !readJSON(w, r, &body) {
return
}
if !body.Confirmed {
writeError(w, 400, "请先确认替换实例数据并重新登录")
return
}
if s.Restart == nil {
writeError(w, 503, "当前服务不支持实例恢复")
return
}
err := s.maintain("恢复完整实例", func() error {
meta, dir, err := s.readUpload(r, body.ID)
if err != nil {
return err
}
if !meta.Validated || meta.Kind != "full" {
return fmt.Errorf("请先校验完整服务备份")
}
file, err := s.saveRollback(r.Context(), "full")
if err != nil {
return err
}
if err = backup.WriteRecord(s.DataDir, backup.RestoreRecord{State: "prepared", Stage: filepath.Join(dir, "stage"), Backup: file}); err != nil {
return err
}
s.restarting.Store(true)
return nil
})
if err != nil {
s.maintenanceError(w, err)
return
}
writeJSON(w, 202, map[string]bool{"restarting": true})
s.Restart()
}
type cleanupResult struct {
Files int `json:"files"`
Bytes int64 `json:"bytes"`
Failed []string `json:"failed"`
}
func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
result := cleanupResult{Failed: []string{}}
err := s.maintain("清理临时文件", func() error {
// temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。
temp, err := s.tempDir()
if err != nil {
return err
}
entries, err := os.ReadDir(temp)
if err != nil {
return err
}
targets := []string{}
for _, entry := range entries {
targets = append(targets, filepath.Join(temp, entry.Name()))
}
// 只清理过期未使用上传;当前会话新上传和待恢复内容不会被清除。
uploads := filepath.Join(s.DataDir, ".maintenance", "uploads")
entries, err = os.ReadDir(uploads)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
for _, entry := range entries {
if !uploadIDPattern.MatchString(entry.Name()) {
continue
}
info, e := entry.Info()
if e != nil {
return e
}
if time.Since(info.ModTime()) > 24*time.Hour {
targets = append(targets, filepath.Join(uploads, entry.Name()))
}
}
err = filepath.WalkDir(s.vault.Root(), func(p string, d os.DirEntry, e error) error {
if e != nil {
return e
}
if strings.HasPrefix(d.Name(), ".rawmark-tmp-") {
targets = append(targets, p)
if d.IsDir() {
return filepath.SkipDir
}
}
return nil
})
if err != nil {
return err
}
for _, target := range targets {
var count int
var size int64
e := filepath.WalkDir(target, func(p string, d os.DirEntry, e error) error {
if e != nil {
return e
}
info, e := d.Info()
if e != nil {
return e
}
if info.Mode().IsRegular() {
count++
size += info.Size()
}
return nil
})
if e == nil {
e = os.RemoveAll(target)
}
if e != nil {
result.Failed = append(result.Failed, e.Error())
} else {
result.Files += count
result.Bytes += size
}
}
return nil
})
if err != nil {
s.maintenanceError(w, err)
return
}
writeJSON(w, 200, result)
}
// 仅用于服务启动前读取恢复结果,不以空响应伪装已恢复。
func (s *Server) SetRestoreFailure(err error) {
s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock()
s.maintenanceState = maintenanceStatus{Operation: "恢复完整实例", Error: err.Error(), FinishedAt: time.Now().UnixMilli()}
}
+581
View File
@@ -2217,6 +2217,587 @@
}
}
}
},
"/maintenance/status": {
"get": {
"summary": "维护状态",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
}
}
},
"/maintenance/export/{kind}": {
"post": {
"summary": "导出完整实例或设置包",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "ZIP下载",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/maintenance/notes/export": {
"post": {
"summary": "导出普通 Markdown 笔记库",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "ZIP下载",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
}
}
},
"/maintenance/uploads": {
"post": {
"summary": "创建备份上传,压缩上限2GiB,分块上限8MiB",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
},
"201": {
"description": "返回上传id"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"size"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"full",
"settings"
]
},
"size": {
"type": "integer",
"minimum": 1,
"maximum": 2147483648
}
}
}
}
}
}
}
},
"/maintenance/uploads/{id}": {
"put": {
"summary": "顺序追加备份分块",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "Upload-Offset",
"in": "header",
"required": true,
"schema": {
"type": "integer",
"minimum": 0
}
}
],
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
},
"delete": {
"summary": "取消当前会话的上传",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/maintenance/uploads/{id}/validate": {
"post": {
"summary": "校验归档并生成摘要,展开上限8GiB/100000项",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/maintenance/settings/import": {
"post": {
"summary": "确认后导入设置和资源,操作前备份",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"confirmed",
"id"
],
"properties": {
"confirmed": {
"type": "boolean",
"const": true
},
"id": {
"type": "string"
}
}
}
}
}
}
}
},
"/maintenance/settings/reset": {
"post": {
"summary": "确认后重置偏好,保留插件存储",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"confirmed"
],
"properties": {
"confirmed": {
"type": "boolean",
"const": true
}
}
}
}
}
}
}
},
"/maintenance/restore": {
"post": {
"summary": "确认后恢复完整实例,操作前备份并重新登录",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
},
"202": {
"description": "已提交恢复;服务将短暂维护并要求重新登录"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"confirmed",
"id"
],
"properties": {
"confirmed": {
"type": "boolean",
"const": true
},
"id": {
"type": "string"
}
}
}
}
}
}
}
},
"/maintenance/index/{operation}": {
"post": {
"summary": "优化或重建索引",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
},
"parameters": [
{
"name": "operation",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/maintenance/temp/clear": {
"post": {
"summary": "清理本程序未使用临时文件",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
}
}
},
"/maintenance/logs/export": {
"post": {
"summary": "导出应用日志和运行摘要",
"tags": [
"维护"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "ZIP下载",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "操作或输入无效"
},
"401": {
"description": "未登录"
},
"403": {
"description": "不接受API令牌"
},
"503": {
"description": "正在维护"
}
}
}
}
},
"components": {
+2
View File
@@ -35,6 +35,8 @@ func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) cleanupTrash(now time.Time) error {
s.activity.RLock()
defer s.activity.RUnlock()
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
+17
View File
@@ -24,6 +24,7 @@ type Event struct {
type Hub struct {
mu sync.Mutex
clients map[chan []byte]struct{}
closed bool
}
func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} }
@@ -32,6 +33,11 @@ func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} }
func (h *Hub) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 64)
h.mu.Lock()
if h.closed {
close(ch)
h.mu.Unlock()
return ch, func() {}
}
h.clients[ch] = struct{}{}
h.mu.Unlock()
return ch, func() {
@@ -64,3 +70,14 @@ func (h *Hub) Broadcast(events ...Event) {
}
}
}
// Close 禁止新订阅,并通知现有连接关闭,供实例恢复重新打开数据库使用。
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
h.closed = true
for ch := range h.clients {
delete(h.clients, ch)
close(ch)
}
}
+7 -5
View File
@@ -44,21 +44,23 @@ type Watcher struct {
}
// Start 开始监听。onScan 在首次扫描及之后每次有变化的扫描后调用,参数为全部笔记的路径 -> rev(用于更新搜索索引)
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) error {
// 返回退出通知,实例恢复须等文件监听停止后再替换笔记库。
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub, onScan func(notes map[string]string)) (<-chan struct{}, error) {
fw, err := fsnotify.NewWatcher()
if err != nil {
return err
return nil, err
}
w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}, onScan: onScan}
snap, err := w.scan()
if err != nil {
fw.Close()
return err
return nil, err
}
w.snap = snap
w.onScan(notes(snap))
go w.loop(ctx)
return nil
done := make(chan struct{})
go func() { defer close(done); w.loop(ctx) }()
return done, nil
}
func (w *Watcher) loop(ctx context.Context) {