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

+3
View File
@@ -44,6 +44,9 @@ docker run -d --name rawmark \
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
- 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「存储空间」进入即统计数据目录、外置笔记库及内置可选字体(不含默认 Maple),支持刷新和字体明细;不跟随内部符号链接,附件与回收站包含在笔记库总量中。
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。
- 备份与维护:设置「存储空间」提供完整实例、普通 Markdown 笔记库、设置与资源三种迁移入口。完整实例含数据库、登录信息、回收站与资源,恢复会替换当前业务数据并要求重新登录;设置包含偏好、插件数据及字体/主题/插件,保留包外资源,同 ID 资源替换。两者应用前均在数据目录 `backups/` 保存恢复包;不包含运维环境配置、派生索引或临时文件。普通笔记导出保留目录与原文,缺失附件记录于 `.rawmark-export/missing-assets.json`,导回时忽略该报告并导入新文件夹。
- 维护边界:备份恢复只接受本程序的版本化 ZIP,顺序分块上传,每块 8 MiB,压缩上限 2 GiB、展开上限 8 GiB/100000 项;完整恢复前需停止外部程序直接改写仓库。维护期间暂时暂停 API 操作,实例恢复在关闭数据库和后台任务后执行,中断时回退操作前备份。优化/重建仅作用于派生索引;临时清理不删除用户文件、活动上传或恢复备份。重置偏好保留插件存储、账户、资源和本机标签页。日志保存于 `logs/`,三份、每份至多 10 MiB,可导出日志及版本运行摘要,不补录历史日志。
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
- 自动化:在设置「账户与安全 → API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。
+150 -48
View File
@@ -6,9 +6,11 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
@@ -16,6 +18,8 @@ import (
"os"
"os/signal"
"path/filepath"
"rawmark/internal/backup"
"sync"
"syscall"
"time"
@@ -53,71 +57,169 @@ func run(addr string) error {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return permissionHint(err)
}
v, err := vault.Open(vaultDir)
if err != nil {
return permissionHint(fmt.Errorf("打开笔记仓库失败: %w", err))
}
st, err := store.Open(filepath.Join(dataDir, "rawmark.db"))
dataDir, err := filepath.Abs(dataDir)
if err != nil {
return err
}
defer st.Close()
am := auth.New(st)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
idx, err := search.Open(filepath.Join(dataDir, "index.db"), v)
vaultDir, err = filepath.Abs(vaultDir)
if err != nil {
return err
}
defer idx.Close()
hub := events.NewHub()
idx.OnIndexed = func() { hub.Broadcast(events.Event{Type: "indexed"}) }
go idx.Run(ctx)
if err := watch.Start(ctx, v, hub, idx.Update); err != nil {
return fmt.Errorf("启动文件监听失败: %w", err)
// 所有标准库上传、导出临时文件归本进程专属目录,不能清理宿主机 /tmp。
tempDir := filepath.Join(dataDir, "temp")
if err = os.MkdirAll(tempDir, 0700); err != nil {
return err
}
go cleanupSessions(ctx, st)
srv := api.New(v, am, st, hub, idx)
srv.DataDir = dataDir
srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.PluginsDir = filepath.Join(dataDir, "plugins")
srv.FontsDir = filepath.Join(dataDir, "fonts")
if err = os.Setenv("TMPDIR", tempDir); err != nil {
return err
}
logs, err := backup.OpenLog(filepath.Join(dataDir, "logs"))
if err != nil {
return err
}
defer logs.Close()
slog.SetDefault(slog.New(slog.NewTextHandler(io.MultiWriter(os.Stderr, logs), nil)))
fontsSize, err := web.FontStorage()
if err != nil {
return fmt.Errorf("读取内置字体统计失败: %w", err)
}
srv.BuiltinFontsSize = fontsSize
trashDone := make(chan struct{})
go func() {
defer close(trashDone)
srv.RunTrashCleanup(ctx)
}()
defer func() { stop(); <-trashDone }()
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(web.Dist()),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
// 构建标识与前端入口一致,不另建版本来源。
static := web.Dist()
html, err := fs.ReadFile(static, "index.html")
if err != nil {
return err
}
errCh := make(chan error, 1)
go func() { errCh <- httpServer.ListenAndServe() }()
slog.Info("RawMark 已启动", "addr", addr, "vault", v.Root())
buildVersion := ""
marker := []byte(`<meta name="rawmark-version" content="`)
if start := bytes.Index(html, marker); start >= 0 {
rest := html[start+len(marker):]
if end := bytes.IndexByte(rest, '"'); end >= 0 {
buildVersion = string(rest[:end])
}
}
if buildVersion == "" {
return fmt.Errorf("缺少构建版本")
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
for {
var restoreErr error
if restoreErr = backup.Recover(dataDir, vaultDir); restoreErr != nil {
pending, err := backup.Pending(dataDir)
if err != nil {
return err
}
if pending {
return restoreErr
}
slog.Error("实例恢复失败,已回退", "err", restoreErr)
}
restart, err := serve(ctx, addr, dataDir, vaultDir, buildVersion, fontsSize, logs, restoreErr)
if err != nil {
pending, pendingErr := backup.Pending(dataDir)
if pendingErr != nil {
return errors.Join(err, pendingErr)
}
if pending {
slog.Error("新实例初始化失败,回退原实例", "err", err)
continue
}
return err
}
if !restart {
return nil
}
}
}
func serve(parent context.Context, addr, dataDir, vaultDir, version string, fontSize int64, logs *backup.LogWriter, restoreErr error) (bool, error) {
v, err := vault.Open(vaultDir)
if err != nil {
return false, permissionHint(err)
}
st, err := store.Open(filepath.Join(dataDir, "rawmark.db"))
if err != nil {
return false, err
}
defer st.Close()
idx, err := search.Open(filepath.Join(dataDir, "index.db"), v)
if err != nil {
return false, err
}
defer idx.Close()
ctx, cancel := context.WithCancel(parent)
pending, err := backup.Pending(dataDir)
if err != nil {
cancel()
return false, err
}
if pending {
if err = idx.Rebuild(ctx); err != nil {
cancel()
return false, err
}
}
var workers sync.WaitGroup
defer func() { cancel(); workers.Wait() }()
hub := events.NewHub()
idx.OnIndexed = func() { hub.Broadcast(events.Event{Type: "indexed"}) }
workers.Go(func() { idx.Run(ctx) })
watchDone, err := watch.Start(ctx, v, hub, idx.Update)
if err != nil {
return false, err
}
defer func() { cancel(); <-watchDone }()
srv := api.New(v, auth.New(st), st, hub, idx)
srv.DataDir = dataDir
srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.PluginsDir = filepath.Join(dataDir, "plugins")
srv.FontsDir = filepath.Join(dataDir, "fonts")
srv.BuiltinFontsSize = fontSize
srv.Logs = logs
srv.Version = version
if restoreErr != nil {
srv.SetRestoreFailure(restoreErr)
}
restart := make(chan struct{}, 1)
srv.Restart = func() {
select {
case restart <- struct{}{}:
default:
}
}
workers.Go(func() { srv.RunTrashCleanup(ctx) })
workers.Go(func() { cleanupSessions(ctx, st) })
server := &http.Server{Addr: addr, Handler: srv.Handler(web.Dist()), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
errorsCh := make(chan error, 1)
listener, err := net.Listen("tcp", addr)
if err != nil {
return false, err
}
if err = backup.FinishRecovery(dataDir); err != nil {
listener.Close()
return false, err
}
go func() { errorsCh <- server.Serve(listener) }()
slog.Info("RawMark 已启动", "addr", addr, "vault", v.Root())
requested := false
select {
case err := <-errCh:
return err
case err = <-errorsCh:
if !errors.Is(err, http.ErrServerClosed) {
return false, err
}
case <-ctx.Done():
case <-restart:
requested = true
}
slog.Info("正在关闭")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
shutdown, done := context.WithTimeout(context.Background(), 15*time.Second)
defer done()
if err = server.Shutdown(shutdown); err != nil {
return false, err
}
return nil
srv.CloseEvents()
cancel()
return requested, nil
}
// permissionHint 为权限错误补充排查提示:Docker 绑定挂载的宿主机目录常常对容器内的非 root 用户不可写
+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) {
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { api, type BackupKind, type MaintenanceStatus } from './api'
import { flushAll, readyToReload, ws } from './workspace'
import { flushSettings, loadSettings, reloadTheme } from './settings'
import { refreshPlugins } from '../plugins/host'
import SettingRow from './SettingRow.vue'
const emit = defineEmits<{ changed: [] }>()
const busy = ref(false)
const progress = ref('')
const error = ref('')
const message = ref('')
const last = ref<MaintenanceStatus | null>(null)
const fullInput = ref<HTMLInputElement>()
const settingsInput = ref<HTMLInputElement>()
let mounted = true
onBeforeUnmount(() => { mounted = false })
onMounted(async () => {
try { const status = await api.maintenanceStatus(); if (mounted) last.value = status }
catch (cause) { if (mounted) error.value = '读取维护状态失败:' + String(cause) }
})
async function prepare() {
await Promise.all([flushAll(), flushSettings()])
if (!readyToReload()) throw new Error('仍有未保存内容或正在上传、操作文件,请完成后再试')
}
async function perform(label: string, action: () => Promise<void>) {
if (busy.value) return
busy.value = true; progress.value = label; error.value = ''; message.value = ''
try {
await prepare()
await action()
if (mounted) { emit('changed'); last.value = await api.maintenanceStatus() }
} catch (cause) { if (mounted) error.value = String(cause) }
finally { if (mounted) { busy.value = false; progress.value = '' } }
}
async function download(response: Response, fallback: string) {
const blob = await response.blob()
const header = response.headers.get('Content-Disposition') ?? ''
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
const plain = /filename="([^"]+)"/i.exec(header)?.[1]
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
const url = URL.createObjectURL(blob)
const link = Object.assign(document.createElement('a'), { href: url, download: name })
link.click();setTimeout(() => URL.revokeObjectURL(url), 1000)
message.value = '导出文件已生成'
}
async function exportPackage(kind: BackupKind) { await perform('正在导出…', async () => download(await api.exportBackup(kind), `rawmark-${kind}.zip`)) }
async function exportMarkdown() { await perform('正在导出笔记…', async () => download(await api.exportNotes(), 'rawmark-notes.zip')) }
async function exportSystemLogs() { await perform('正在导出日志…', async () => download(await api.exportLogs(), 'rawmark-system-logs.zip')) }
async function syncPreferences() { await loadSettings(); await reloadTheme(); await refreshPlugins() }
async function reset() {
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
await perform('正在重置设置…', async () => { await api.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份' })
}
async function restore(kind: BackupKind, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]; input.value = ''
if (!file) return
await perform('正在上传…', async () => {
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
const { id } = await api.startBackupUpload(kind, file.size)
let submitted = false
try {
let offset = 0
while (offset < file.size) {
if (!mounted) throw new Error('已取消上传')
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
const result = await api.uploadBackupChunk(id, offset, chunk)
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
}
progress.value = '正在校验备份…'
const summary = await api.validateBackup(id)
if (!mounted) throw new Error('已取消导入')
const description = kind === 'full'
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
if (!confirm(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
await prepare()
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
if (kind === 'settings') {
await api.importSettings(id);submitted = true
await syncPreferences();message.value = '设置与资源已导入,原设置已备份'
} else {
await api.restoreBackup(id);submitted = true
for (let attempt = 0; attempt < 30; attempt++) {
await new Promise(resolve => setTimeout(resolve, 2000))
try { if (!(await api.authState()).authenticated) { location.reload(); return } }
catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
}
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
}
} finally {
if (!submitted) {
try { await api.cancelBackupUpload(id) }
catch (cause) { if (mounted) error.value = '清理上传暂存失败:' + String(cause) }
}
}
})
}
async function index(operation: 'optimize' | 'rebuild') {
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
await api.maintainIndex(operation);message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
})
}
async function clearTemp() {
await perform('正在清理临时文件…', async () => {
const result = await api.clearTemp()
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
if (result.failed.length) error.value = '部分清理失败:\n' + result.failed.join('\n')
})
}
function importNotes() { if (!busy.value) { ws.settings = null; ws.importing = '' } }
</script>
<template>
<div class="rm-maintenance" :aria-busy="busy">
<h3 class="rm-settings-group-title">备份与迁移</h3>
<SettingRow id="backup-full"><button class="rm-btn" :disabled="busy" @click="exportPackage('full')">导出</button><button class="rm-btn" :disabled="busy" @click="fullInput!.click()">恢复</button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)" /></SettingRow>
<SettingRow id="backup-notes"><button class="rm-btn" :disabled="busy" @click="exportMarkdown">导出</button><button class="rm-btn" :disabled="busy" @click="importNotes">导入</button></SettingRow>
<SettingRow id="backup-settings"><button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">导出</button><button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">导入</button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)" /></SettingRow>
<h3 class="rm-settings-group-title">维护工具</h3>
<SettingRow id="index-optimize"><button class="rm-btn" :disabled="busy" @click="index('optimize')">优化</button></SettingRow>
<SettingRow id="index-rebuild"><button class="rm-btn" :disabled="busy" @click="index('rebuild')">重建</button></SettingRow>
<SettingRow id="temp-clear"><button class="rm-btn" :disabled="busy" @click="clearTemp">清理</button></SettingRow>
<SettingRow id="settings-reset"><button class="rm-btn" :disabled="busy" @click="reset">重置</button></SettingRow>
<SettingRow id="logs-export"><button class="rm-btn" :disabled="busy" @click="exportSystemLogs">导出</button></SettingRow>
<p v-if="busy" role="status" aria-live="polite">{{ progress }}</p>
<p v-if="message" role="status">{{ message }}</p>
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
<p v-if="last?.error" class="rm-auth-error" role="alert">上次维护失败:{{ last.error }}</p>
<p v-if="last?.backup" class="rm-share-note">操作前备份:backups/{{ last.backup }}</p>
</div>
</template>
<style scoped>
.rm-maintenance [role="alert"] { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
+2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, type StorageUsage } from './api'
import MaintenancePanel from './MaintenancePanel.vue'
import SettingRow from './SettingRow.vue'
const usage = ref<StorageUsage | null>(null)
@@ -69,6 +70,7 @@ onMounted(scan)
</template>
</dl>
</div>
<MaintenancePanel @changed="scan" />
</div>
</template>
+18
View File
@@ -67,6 +67,10 @@ export interface StorageUsage {
calculatedAt: number
}
export interface MaintenanceStatus { busy: boolean; operation: string; error: string; finishedAt: number; backup?: string }
export interface BackupSummary { kind: 'full' | 'settings'; createdAt: number; entries: number; size: number }
export type BackupKind = 'full' | 'settings'
export interface TrashPurgeResult {
deleted: string[]
failed: { id: string; error: string }[]
@@ -143,6 +147,20 @@ export const api = {
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
maintenanceStatus: () => json<MaintenanceStatus>(request('GET', '/api/v1/maintenance/status')),
exportBackup: (kind: BackupKind) => request('POST', '/api/v1/maintenance/export/' + kind),
exportNotes: () => request('POST', '/api/v1/maintenance/notes/export'),
startBackupUpload: (kind: BackupKind, size: number) => json<{ id: string }>(request('POST', '/api/v1/maintenance/uploads', { json: { kind, size } })),
uploadBackupChunk: (id: string, offset: number, body: Blob) => json<{ offset: number }>(request('PUT', '/api/v1/maintenance/uploads/' + id, { body, headers: { 'Upload-Offset': String(offset) } })),
validateBackup: (id: string) => json<BackupSummary>(request('POST', '/api/v1/maintenance/uploads/' + id + '/validate')),
cancelBackupUpload: (id: string) => request('DELETE', '/api/v1/maintenance/uploads/' + id),
importSettings: (id: string) => request('POST', '/api/v1/maintenance/settings/import', { json: { id, confirmed: true } }),
restoreBackup: (id: string) => request('POST', '/api/v1/maintenance/restore', { json: { id, confirmed: true } }),
resetSettings: () => request('POST', '/api/v1/maintenance/settings/reset', { json: { confirmed: true } }),
maintainIndex: (operation: 'optimize' | 'rebuild') => request('POST', '/api/v1/maintenance/index/' + operation),
clearTemp: () => json<{ files: number; bytes: number; failed: string[] }>(request('POST', '/api/v1/maintenance/temp/clear')),
exportLogs: () => request('POST', '/api/v1/maintenance/logs/export'),
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
importFont: (file: File) => {
+13
View File
@@ -20,6 +20,7 @@ beforeEach(() => {
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'maintenanceStatus').mockResolvedValue({ busy: false, operation: '', error: '', finishedAt: 0 })
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
})
afterEach(() => {
@@ -428,6 +429,18 @@ describe('应用存储与回收站', () => {
expect(host!.textContent).toContain('1 KiB')
})
it('维护重置取消时不发送请求,确认时等待保存并保留真实失败', async () => {
vi.spyOn(api, 'storage').mockResolvedValue(usage)
const reset = vi.spyOn(api, 'resetSettings').mockRejectedValue(new Error('backup disk full'))
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(false).mockReturnValue(true))
ws.settings = 'storage'; mount(SettingsDialog)
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-settings-reset button')!
button.click(); expect(reset).not.toHaveBeenCalled()
button.click()
await vi.waitFor(() => expect(host!.textContent).toContain('backup disk full'))
expect(reset).toHaveBeenCalledTimes(1)
expect(button.disabled).toBe(false)
})
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
vi.spyOn(api, 'trash').mockResolvedValue([item])
+6
View File
@@ -143,3 +143,9 @@ function saveSettings(patch: Partial<Settings>) {
saveQueue = pending.catch(() => {})
return pending
}
/** 维护前等待现有设置保存;失败时不得继续替换配置。 */
export async function flushSettings() {
await saveQueue
if (settingsSave.result === 'error') throw new Error('设置保存失败:' + settingsSave.error)
}
+9 -1
View File
@@ -6,7 +6,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
{ id: 'storage', title: '存储空间', icon: 'grid', description: '笔记库、数据库、字体、主题与插件占用' },
{ id: 'storage', title: '存储空间', icon: 'grid', description: '空间占用、备份迁移及维护工具' },
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
{ id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' },
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
@@ -14,6 +14,14 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
export const SETTING_ITEMS = {
'storage': { section: 'storage', title: '空间占用', description: '自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。', keywords: '容量 占用 大小 附件 图片 回收站' },
'backup-full': { section: 'storage', title: '完整服务备份', description: '包含笔记、回收站、登录信息、设置和资源,用于恢复实例;恢复前备份当前数据,完成后重新登录。请妥善保管导出包。', keywords: 'Data 导入 导出 备份 恢复 迁移' },
'backup-notes': { section: 'storage', title: '笔记数据', description: '导出普通 Markdown 与附件 ZIP,保留目录和相对链接,缺失附件另附清单;导入到新文件夹,不覆盖现有笔记。', keywords: 'Data MD Markdown 导入 导出 迁移' },
'backup-settings': { section: 'storage', title: '设置与资源', description: '备份偏好、快捷键、插件数据及字体、主题、插件;不包含笔记或登录信息。导入前备份,同 ID 资源替换。', keywords: '设置 导入 导出 字体 主题 插件' },
'index-optimize': { section: 'storage', title: '优化索引', description: '整理全文索引并回收索引数据库空闲空间,不修改笔记。', keywords: '搜索 数据库 整理 VACUUM' },
'index-rebuild': { section: 'storage', title: '重建索引', description: '从当前 Markdown 笔记重新生成全文搜索和元数据索引。', keywords: '搜索 数据库 重建 修复' },
'temp-clear': { section: 'storage', title: '清理临时文件', description: '清理本程序不再使用的临时文件;保留活动上传、恢复备份与用户文件。', keywords: '临时 缓存 空间 清理' },
'settings-reset': { section: 'storage', title: '重置设置', description: '恢复全部 RawMark 偏好并停用插件,保留笔记、账户、已安装资源、插件存储和本机标签页/布局。', keywords: '重置 默认 恢复' },
'logs-export': { section: 'storage', title: '导出系统日志', description: '导出有限轮转的应用日志和运行环境摘要,不包含笔记正文及认证信息。历史未保存的日志无法补回。', keywords: '日志 系统 错误 排查' },
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },