mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 21:03:59 +00:00
1ca49734ff
SSE progress hook crashed React when backend returned nested error
object from writeError ({"error": {"code": ..., "message": ...}})
instead of flat string. Now parses both formats correctly.
PolicyEngine.IsOwner lacked the "system" fallback that isHTTPOwnerID
already had — when owner_ids is empty, "system" user was rejected from
all backup/restore endpoints. Added consistent fallback logic.
Also added slog.Warn to all silent owner-check rejections across
backup, restore, tenant backup/restore, and S3 handlers.
196 lines
6.0 KiB
Go
196 lines
6.0 KiB
Go
package http
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/backup"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
const maxRestoreSize = 10 << 30 // 10 GB
|
|
|
|
// RestoreHandler handles the POST /v1/system/restore endpoint.
|
|
type RestoreHandler struct {
|
|
cfg *config.Config
|
|
dsn string
|
|
isOwner func(string) bool
|
|
}
|
|
|
|
// NewRestoreHandler creates a handler for system restore endpoints.
|
|
func NewRestoreHandler(cfg *config.Config, dsn string, isOwner func(string) bool) *RestoreHandler {
|
|
return &RestoreHandler{cfg: cfg, dsn: dsn, isOwner: isOwner}
|
|
}
|
|
|
|
// RegisterRoutes registers the restore route on the given mux.
|
|
func (h *RestoreHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /v1/system/restore",
|
|
requireAuth(permissions.RoleAdmin, h.handleRestore))
|
|
}
|
|
|
|
// handleRestore accepts a multipart tar.gz upload and streams restore progress via SSE.
|
|
// Query params: skip_db=true, skip_files=true, dry_run=true
|
|
func (h *RestoreHandler) handleRestore(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := extractLocale(r)
|
|
|
|
if !h.isOwnerUser(userID) {
|
|
slog.Warn("security.restore_owner_denied", "user_id", userID)
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgNoAccess, "system restore"))
|
|
return
|
|
}
|
|
|
|
if !backupInProgress.CompareAndSwap(false, true) {
|
|
writeError(w, http.StatusConflict, protocol.ErrInternal, "a backup or restore operation is already in progress")
|
|
return
|
|
}
|
|
defer backupInProgress.Store(false)
|
|
|
|
q := r.URL.Query()
|
|
skipDB := q.Get("skip_db") == "true" || q.Get("skip_db") == "1"
|
|
skipFiles := q.Get("skip_files") == "true" || q.Get("skip_files") == "1"
|
|
dryRun := q.Get("dry_run") == "true" || q.Get("dry_run") == "1"
|
|
|
|
// Preflight: verify psql is available for PG builds (no-op for SQLite builds).
|
|
if !skipDB && h.dsn != "" {
|
|
if err := checkPsqlAvailable(); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
|
|
fmt.Sprintf("psql not found on PATH: %v", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check active connections before accepting upload (only meaningful for PG).
|
|
if !dryRun && !skipDB && h.dsn != "" {
|
|
conns, connErr := backup.CheckActiveConnections(r.Context(), h.dsn)
|
|
if connErr == nil && conns > 0 {
|
|
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest,
|
|
fmt.Sprintf("%d active DB connection(s) detected; stop the gateway and all clients before restoring", conns))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse multipart upload.
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRestoreSize)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFileTooLarge))
|
|
return
|
|
}
|
|
|
|
file, _, err := r.FormFile("archive")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgMissingFileField))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Save upload to a temp file so we can seek / re-read.
|
|
tmp, err := os.CreateTemp("", "goclaw-restore-*.tar.gz")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal,
|
|
i18n.T(locale, i18n.MsgInternalError))
|
|
return
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
written, err := copyWithLimit(tmp, file, maxRestoreSize)
|
|
tmp.Close()
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgFileTooLarge))
|
|
return
|
|
}
|
|
if written == 0 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "archive is empty")
|
|
return
|
|
}
|
|
|
|
// Switch to SSE for progress streaming.
|
|
flusher := initSSE(w)
|
|
if flusher == nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported")
|
|
return
|
|
}
|
|
|
|
opts := backup.RestoreOptions{
|
|
ArchivePath: tmpPath,
|
|
DSN: h.dsn,
|
|
DataDir: h.cfg.ResolvedDataDir(),
|
|
WorkspacePath: h.cfg.WorkspacePath(),
|
|
DryRun: dryRun,
|
|
SkipDB: skipDB,
|
|
SkipFiles: skipFiles,
|
|
Force: true, // HTTP caller already authenticated as owner/admin
|
|
ProgressFn: func(phase, detail string) {
|
|
sendSSE(w, flusher, "progress", ProgressEvent{Phase: phase, Status: "running", Detail: detail})
|
|
},
|
|
}
|
|
|
|
result, runErr := backup.Restore(r.Context(), opts)
|
|
if runErr != nil {
|
|
slog.Error("system.restore.sse", "error", runErr, "user", userID)
|
|
sendSSE(w, flusher, "error", ProgressEvent{
|
|
Phase: "restore",
|
|
Status: "error",
|
|
Detail: runErr.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
sendSSE(w, flusher, "complete", map[string]any{
|
|
"manifest_version": result.ManifestVersion,
|
|
"schema_version": result.SchemaVersion,
|
|
"database_restored": result.DatabaseRestored,
|
|
"files_extracted": result.FilesExtracted,
|
|
"bytes_extracted": result.BytesExtracted,
|
|
"warnings": result.Warnings,
|
|
"dry_run": dryRun,
|
|
})
|
|
}
|
|
|
|
// isOwnerUser returns true if userID belongs to a configured system owner.
|
|
func (h *RestoreHandler) isOwnerUser(userID string) bool {
|
|
return userID != "" && h.isOwner != nil && h.isOwner(userID)
|
|
}
|
|
|
|
// checkPsqlAvailable verifies that psql is on PATH (PG builds only).
|
|
// For SQLite builds this is a no-op returning nil.
|
|
func checkPsqlAvailable() error {
|
|
_, err := exec.LookPath("psql")
|
|
return err
|
|
}
|
|
|
|
// copyWithLimit copies at most limit bytes from src to dst.
|
|
// Returns an error if the source exceeds limit.
|
|
func copyWithLimit(dst io.Writer, src io.Reader, limit int64) (int64, error) {
|
|
n, err := io.Copy(dst, io.LimitReader(src, limit+1))
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
if n > limit {
|
|
return n, fmt.Errorf("upload exceeds %s limit", formatBytes(limit))
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// formatBytes formats a byte count as a human-readable string.
|
|
func formatBytes(b int64) string {
|
|
if b >= 1<<30 {
|
|
return strconv.FormatInt(b>>30, 10) + " GB"
|
|
}
|
|
return strconv.FormatInt(b>>20, 10) + " MB"
|
|
}
|