Files
RawMark/internal/api/maintenance.go
T

541 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"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
}
resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/")
_, resourceTail, hasTicket := strings.Cut(resourcePath, "/")
eventResource := resource && hasTicket && resourceTail == "events/"
if strings.HasPrefix(r.URL.Path, "/api/v1/maintenance/") || r.URL.Path == "/api/v1/events" || eventResource {
next.ServeHTTP(w, r)
return
}
if !s.activity.TryRLock() {
writeError(w, 503, "正在维护,请稍后重试")
return
}
defer s.activity.RUnlock()
next.ServeHTTP(w, r)
})
}
var errMaintenanceBusy = errors.New("其他维护操作正在执行")
var errMaintenanceInput = errors.New("维护请求无效")
func (s *Server) maintain(operation string, fn func() error) error {
if !s.maintenanceMu.TryLock() {
return errMaintenanceBusy
}
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, r *http.Request, err error) {
var pathError *os.PathError
switch {
case errors.Is(err, errMaintenanceBusy):
writeError(w, 503, err.Error())
case errors.Is(err, syscall.ENOSPC):
writeError(w, 503, "磁盘空间不足")
case errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR):
internalError(w, r, err)
case errors.Is(err, errMaintenanceInput), errors.Is(err, backup.ErrInvalidArchive):
writeError(w, 400, "操作失败:"+err.Error())
default:
internalError(w, r, err)
}
}
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, r, err)
}
}
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
err := s.maintain("导出笔记数据", func() error {
dataRoot, err := filepath.EvalSymlinks(s.DataDir)
if err != nil {
return err
}
file, err := s.vault.ExportNotes(r.Context(), func(rel string) bool {
return backup.VaultExcluded(dataRoot, s.vault.Root(), rel)
})
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, r, err)
}
}
func validatePreferences(values map[string]json.RawMessage) error {
for key, raw := range values {
if len(raw) > maxJSONBytes {
return fmt.Errorf("%w:单项设置超过 1 MiB", errMaintenanceInput)
}
switch {
case key == settingsKey:
var settings map[string]json.RawMessage
if err := json.Unmarshal(raw, &settings); err != nil || settings == nil {
return fmt.Errorf("%w:偏好设置必须为对象", errMaintenanceInput)
}
if _, err := trashRetentionDays(settings); err != nil {
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
}
if value, ok := settings["shortcuts"]; ok {
var shortcuts map[string][]string
if err := json.Unmarshal(value, &shortcuts); err != nil {
return fmt.Errorf("%w:快捷键格式无效", errMaintenanceInput)
}
}
case key == pluginEnabledKey:
var ids map[string]bool
if err := json.Unmarshal(raw, &ids); err != nil {
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
}
for id := range ids {
if !validPluginID(id) {
return fmt.Errorf("%w:插件 ID 无效", errMaintenanceInput)
}
}
case strings.HasPrefix(key, "plugin.data."):
if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) {
return fmt.Errorf("%w:插件数据无效", errMaintenanceInput)
}
default:
return fmt.Errorf("%w:设置包包含不允许的设置项", errMaintenanceInput)
}
}
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("%w:设置包设置内容超过 32 MiB", errMaintenanceInput)
}
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("%w:设置包格式无效", errMaintenanceInput)
}
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("%w:请先校验设置包", errMaintenanceInput)
}
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, r, 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, r, 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, r, 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, r, err)
}
}