diff --git a/README.md b/README.md index b494449..b2e2ed3 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 6f74b55..e44a23d 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -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(`= 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 用户不可写 diff --git a/internal/api/api.go b/internal/api/api.go index f247c04..c80fef3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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))) } // ---- 中间件 ---- diff --git a/internal/api/events.go b/internal/api/events.go index 6eab93d..16c94e6 100644 --- a/internal/api/events.go +++ b/internal/api/events.go @@ -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() diff --git a/internal/api/lifecycle.go b/internal/api/lifecycle.go new file mode 100644 index 0000000..e6bf8d6 --- /dev/null +++ b/internal/api/lifecycle.go @@ -0,0 +1,4 @@ +package api + +// CloseEvents 在数据库关闭前断开并等待全部 WebSocket 处理结束。 +func (s *Server) CloseEvents() { s.hub.Close(); s.eventWG.Wait() } diff --git a/internal/api/maintenance.go b/internal/api/maintenance.go new file mode 100644 index 0000000..99ea1c6 --- /dev/null +++ b/internal/api/maintenance.go @@ -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) + } +} diff --git a/internal/api/maintenance_test.go b/internal/api/maintenance_test.go new file mode 100644 index 0000000..3b8e6f3 --- /dev/null +++ b/internal/api/maintenance_test.go @@ -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("成功回退后仍有待执行记录") + } +} diff --git a/internal/api/maintenance_uploads.go b/internal/api/maintenance_uploads.go new file mode 100644 index 0000000..db9b290 --- /dev/null +++ b/internal/api/maintenance_uploads.go @@ -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()} +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index d7e4748..a7ad2dd 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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": { diff --git a/internal/api/trash.go b/internal/api/trash.go index d232b06..4e0d783 100644 --- a/internal/api/trash.go +++ b/internal/api/trash.go @@ -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() diff --git a/internal/events/hub.go b/internal/events/hub.go index abaa204..d1c7f96 100644 --- a/internal/events/hub.go +++ b/internal/events/hub.go @@ -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) + } +} diff --git a/internal/watch/watch.go b/internal/watch/watch.go index 3e59bf7..c0c8d95 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -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) { diff --git a/web/src/app/MaintenancePanel.vue b/web/src/app/MaintenancePanel.vue new file mode 100644 index 0000000..544d3a0 --- /dev/null +++ b/web/src/app/MaintenancePanel.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/web/src/app/StoragePanel.vue b/web/src/app/StoragePanel.vue index 4766fdd..43a0ed9 100644 --- a/web/src/app/StoragePanel.vue +++ b/web/src/app/StoragePanel.vue @@ -1,6 +1,7 @@