feat(storage): expose backup restoration and maintenance tools
This commit is contained in:
1 parent
88492fdcde
commit
9449a1aa65
18 files changed
+2194
-61
No files matched your search
+32
-7
@@ -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)))
|
||||
}
|
||||
|
||||
// ---- 中间件 ----
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package api
|
||||
|
||||
// CloseEvents 在数据库关闭前断开并等待全部 WebSocket 处理结束。
|
||||
func (s *Server) CloseEvents() { s.hub.Close(); s.eventWG.Wait() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ")
|
||||
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 " {
|
||||
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("成功回退后仍有待执行记录")
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user