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
+2191 -58

No files matched your search

+3
View File
@@ -44,6 +44,9 @@ docker run -d --name rawmark \
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。 - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
- 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「存储空间」进入即统计数据目录、外置笔记库及内置可选字体(不含默认 Maple),支持刷新和字体明细;不跟随内部符号链接,附件与回收站包含在笔记库总量中。 - 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「存储空间」进入即统计数据目录、外置笔记库及内置可选字体(不含默认 Maple),支持刷新和字体明细;不跟随内部符号链接,附件与回收站包含在笔记库总量中。
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。 - 快捷键:支持按命令名称、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/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
- 自动化:在设置「账户与安全 → API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 - 自动化:在设置「账户与安全 → API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。
+153 -51
View File
@@ -6,9 +6,11 @@
package main package main
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net" "net"
@@ -16,6 +18,8 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"rawmark/internal/backup"
"sync"
"syscall" "syscall"
"time" "time"
@@ -53,71 +57,169 @@ func run(addr string) error {
if err := os.MkdirAll(dataDir, 0o755); err != nil { if err := os.MkdirAll(dataDir, 0o755); err != nil {
return permissionHint(err) return permissionHint(err)
} }
v, err := vault.Open(vaultDir)
if err != nil { dataDir, err := filepath.Abs(dataDir)
return permissionHint(fmt.Errorf("打开笔记仓库失败: %w", err))
}
st, err := store.Open(filepath.Join(dataDir, "rawmark.db"))
if err != nil { if err != nil {
return err return err
} }
defer st.Close() vaultDir, err = filepath.Abs(vaultDir)
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)
if err != nil { if err != nil {
return err return err
} }
defer idx.Close() // 所有标准库上传、导出临时文件归本进程专属目录,不能清理宿主机 /tmp。
hub := events.NewHub() tempDir := filepath.Join(dataDir, "temp")
idx.OnIndexed = func() { hub.Broadcast(events.Event{Type: "indexed"}) } if err = os.MkdirAll(tempDir, 0700); err != nil {
go idx.Run(ctx) return err
if err := watch.Start(ctx, v, hub, idx.Update); err != nil {
return fmt.Errorf("启动文件监听失败: %w", err)
} }
go cleanupSessions(ctx, st) if err = os.Setenv("TMPDIR", tempDir); err != nil {
return err
srv := api.New(v, am, st, hub, idx) }
srv.DataDir = dataDir logs, err := backup.OpenLog(filepath.Join(dataDir, "logs"))
srv.ThemesDir = filepath.Join(dataDir, "themes") if err != nil {
srv.PluginsDir = filepath.Join(dataDir, "plugins") return err
srv.FontsDir = filepath.Join(dataDir, "fonts") }
defer logs.Close()
slog.SetDefault(slog.New(slog.NewTextHandler(io.MultiWriter(os.Stderr, logs), nil)))
fontsSize, err := web.FontStorage() fontsSize, err := web.FontStorage()
if err != nil { if err != nil {
return fmt.Errorf("读取内置字体统计失败: %w", err) return fmt.Errorf("读取内置字体统计失败: %w", err)
} }
srv.BuiltinFontsSize = fontsSize // 构建标识与前端入口一致,不另建版本来源。
trashDone := make(chan struct{}) static := web.Dist()
go func() { html, err := fs.ReadFile(static, "index.html")
defer close(trashDone) if err != nil {
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,
}
errCh := make(chan error, 1)
go func() { errCh <- httpServer.ListenAndServe() }()
slog.Info("RawMark 已启动", "addr", addr, "vault", v.Root())
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
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 return err
} }
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 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 = <-errorsCh:
if !errors.Is(err, http.ErrServerClosed) {
return false, err
}
case <-ctx.Done():
case <-restart:
requested = true
}
shutdown, done := context.WithTimeout(context.Background(), 15*time.Second)
defer done()
if err = server.Shutdown(shutdown); err != nil {
return false, err
}
srv.CloseEvents()
cancel()
return requested, nil
} }
// permissionHint 为权限错误补充排查提示:Docker 绑定挂载的宿主机目录常常对容器内的非 root 用户不可写 // permissionHint 为权限错误补充排查提示:Docker 绑定挂载的宿主机目录常常对容器内的非 root 用户不可写
+26 -1
View File
@@ -11,8 +11,10 @@ import (
"net" "net"
"net/http" "net/http"
"path" "path"
"rawmark/internal/backup"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"rawmark/internal/auth" "rawmark/internal/auth"
@@ -44,6 +46,16 @@ type Server struct {
FontsDir string FontsDir string
pluginsMu sync.Mutex pluginsMu sync.Mutex
BuiltinFontsSize int64 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 { 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}, {"GET /api/v1/trash", authed, s.trashList},
{"DELETE /api/v1/trash", authed, s.trashClear}, {"DELETE /api/v1/trash", authed, s.trashClear},
{"GET /api/v1/storage", sessionOnly, s.getStorage}, {"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}, {"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge}, {"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
{"GET /api/v1/events", authed, s.events}, {"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.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
mux.Handle("/", newStaticHandler(static)) 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 一致 // events 通过 WebSocket 推送仓库变更。websocket.Accept 默认校验 Origin 与 Host 一致
func (s *Server) events(w http.ResponseWriter, r *http.Request) { func (s *Server) events(w http.ResponseWriter, r *http.Request) {
s.eventWG.Add(1)
defer s.eventWG.Done()
// 先订阅再完成握手,避免连接建立后首条变更落在订阅空窗中。 // 先订阅再完成握手,避免连接建立后首条变更落在订阅空窗中。
msgs, unsubscribe := s.hub.Subscribe() msgs, unsubscribe := s.hub.Subscribe()
defer unsubscribe() 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": { "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 { func (s *Server) cleanupTrash(now time.Time) error {
s.activity.RLock()
defer s.activity.RUnlock()
// 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。 // 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。
s.settingsMu.Lock() s.settingsMu.Lock()
defer s.settingsMu.Unlock() defer s.settingsMu.Unlock()
+17
View File
@@ -24,6 +24,7 @@ type Event struct {
type Hub struct { type Hub struct {
mu sync.Mutex mu sync.Mutex
clients map[chan []byte]struct{} clients map[chan []byte]struct{}
closed bool
} }
func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} } 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()) { func (h *Hub) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 64) ch := make(chan []byte, 64)
h.mu.Lock() h.mu.Lock()
if h.closed {
close(ch)
h.mu.Unlock()
return ch, func() {}
}
h.clients[ch] = struct{}{} h.clients[ch] = struct{}{}
h.mu.Unlock() h.mu.Unlock()
return ch, func() { 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(用于更新搜索索引) // 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() fw, err := fsnotify.NewWatcher()
if err != nil { 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} w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}, onScan: onScan}
snap, err := w.scan() snap, err := w.scan()
if err != nil { if err != nil {
fw.Close() fw.Close()
return err return nil, err
} }
w.snap = snap w.snap = snap
w.onScan(notes(snap)) w.onScan(notes(snap))
go w.loop(ctx) done := make(chan struct{})
return nil go func() { defer close(done); w.loop(ctx) }()
return done, nil
} }
func (w *Watcher) loop(ctx context.Context) { 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"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, type StorageUsage } from './api' import { api, type StorageUsage } from './api'
import MaintenancePanel from './MaintenancePanel.vue'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
const usage = ref<StorageUsage | null>(null) const usage = ref<StorageUsage | null>(null)
@@ -69,6 +70,7 @@ onMounted(scan)
</template> </template>
</dl> </dl>
</div> </div>
<MaintenancePanel @changed="scan" />
</div> </div>
</template> </template>
+18
View File
@@ -67,6 +67,10 @@ export interface StorageUsage {
calculatedAt: number 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 { export interface TrashPurgeResult {
deleted: string[] deleted: string[]
failed: { id: string; error: 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 } }), 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')), settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts, fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
importFont: (file: File) => { importFont: (file: File) => {
+13
View File
@@ -20,6 +20,7 @@ beforeEach(() => {
vi.spyOn(api, 'patchSettings').mockResolvedValue({}) vi.spyOn(api, 'patchSettings').mockResolvedValue({})
vi.spyOn(api, 'settings').mockResolvedValue({}) vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').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' }]) vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
}) })
afterEach(() => { afterEach(() => {
@@ -428,6 +429,18 @@ describe('应用存储与回收站', () => {
expect(host!.textContent).toContain('1 KiB') 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 () => { it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() } const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
vi.spyOn(api, 'trash').mockResolvedValue([item]) vi.spyOn(api, 'trash').mockResolvedValue([item])
+6
View File
@@ -143,3 +143,9 @@ function saveSettings(patch: Partial<Settings>) {
saveQueue = pending.catch(() => {}) saveQueue = pending.catch(() => {})
return pending 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: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' }, { id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
{ id: 'plugins', title: '插件', icon: 'grid', 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: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
{ id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' }, { id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' },
{ id: 'about', title: '关于', icon: 'info', 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 = { export const SETTING_ITEMS = {
'storage': { section: 'storage', title: '空间占用', description: '自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。', keywords: '容量 占用 大小 附件 图片 回收站' }, '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-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' }, 'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' }, 'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },