mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 17:04:21 +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.
206 lines
7.1 KiB
Go
206 lines
7.1 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// backupInProgress prevents concurrent backup/restore operations.
|
|
var backupInProgress atomic.Bool
|
|
|
|
// BackupHandler handles system backup endpoints.
|
|
// All routes require admin role; download/preflight routes further require owner.
|
|
type BackupHandler struct {
|
|
cfg *config.Config
|
|
dsn string
|
|
version string
|
|
isOwner func(string) bool
|
|
}
|
|
|
|
// NewBackupHandler creates a handler for system backup endpoints.
|
|
func NewBackupHandler(cfg *config.Config, dsn, version string, isOwner func(string) bool) *BackupHandler {
|
|
return &BackupHandler{cfg: cfg, dsn: dsn, version: version, isOwner: isOwner}
|
|
}
|
|
|
|
// RegisterRoutes registers system backup routes on the given mux.
|
|
func (h *BackupHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /v1/system/backup",
|
|
requireAuth(permissions.RoleAdmin, h.handleBackup))
|
|
mux.HandleFunc("GET /v1/system/backup/preflight",
|
|
requireAuth(permissions.RoleAdmin, h.handlePreflight))
|
|
mux.HandleFunc("GET /v1/system/backup/download/{token}",
|
|
requireAuth(permissions.RoleAdmin, h.handleDownload))
|
|
}
|
|
|
|
// preflightHTTPResponse is the flat JSON shape consumed by the web UI.
|
|
type preflightHTTPResponse struct {
|
|
PgDumpAvailable bool `json:"pg_dump_available"`
|
|
DiskSpaceOK bool `json:"disk_space_ok"`
|
|
DbSizeBytes int64 `json:"db_size_bytes"`
|
|
DbSizeHuman string `json:"db_size_human"`
|
|
FreeDiskBytes int64 `json:"free_disk_bytes"`
|
|
FreeDiskHuman string `json:"free_disk_human"`
|
|
DataDirSizeBytes int64 `json:"data_dir_size_bytes"`
|
|
DataDirSizeHuman string `json:"data_dir_size_human"`
|
|
WorkspaceSizeBytes int64 `json:"workspace_size_bytes"`
|
|
WorkspaceSizeHuman string `json:"workspace_size_human"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
// handlePreflight returns a preflight check result for the backup operation.
|
|
func (h *BackupHandler) handlePreflight(w http.ResponseWriter, r *http.Request) {
|
|
result := backup.RunPreflight(r.Context(), h.dsn, h.cfg.ResolvedDataDir(), h.cfg.WorkspacePath())
|
|
resp := preflightHTTPResponse{
|
|
PgDumpAvailable: result.PgDumpAvailable,
|
|
DiskSpaceOK: result.DiskSpaceOK,
|
|
DbSizeBytes: result.DbSizeBytes,
|
|
DbSizeHuman: backup.FormatBytes(result.DbSizeBytes),
|
|
FreeDiskBytes: result.FreeDiskBytes,
|
|
FreeDiskHuman: backup.FormatBytes(result.FreeDiskBytes),
|
|
DataDirSizeBytes: result.DataDirSizeBytes,
|
|
DataDirSizeHuman: backup.FormatBytes(result.DataDirSizeBytes),
|
|
WorkspaceSizeBytes: result.WorkspaceSizeBytes,
|
|
WorkspaceSizeHuman: backup.FormatBytes(result.WorkspaceSizeBytes),
|
|
Warnings: result.Warnings,
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleBackup runs the backup as an SSE-streamed operation.
|
|
// Request body (JSON, optional): {"exclude_db": false, "exclude_files": false}
|
|
func (h *BackupHandler) handleBackup(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := extractLocale(r)
|
|
|
|
if !h.isOwnerUser(userID) {
|
|
slog.Warn("security.backup_owner_denied", "user_id", userID)
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgNoAccess, "system backup"))
|
|
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)
|
|
|
|
var req struct {
|
|
ExcludeDB bool `json:"exclude_db"`
|
|
ExcludeFiles bool `json:"exclude_files"`
|
|
}
|
|
// Ignore decode errors — all fields have safe zero-value defaults.
|
|
_ = decodeJSONOptional(r, &req)
|
|
|
|
flusher := initSSE(w)
|
|
if flusher == nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported")
|
|
return
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "goclaw-backup-*.tar.gz")
|
|
if err != nil {
|
|
sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"})
|
|
return
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
tmpFile.Close()
|
|
|
|
ts := time.Now().UTC().Format("20060102-150405")
|
|
fileName := fmt.Sprintf("backup-%s.tar.gz", ts)
|
|
|
|
opts := backup.Options{
|
|
DSN: h.dsn,
|
|
DataDir: h.cfg.ResolvedDataDir(),
|
|
WorkspacePath: h.cfg.WorkspacePath(),
|
|
OutputPath: tmpPath,
|
|
CreatedBy: userID,
|
|
GoclawVersion: h.version,
|
|
ExcludeDB: req.ExcludeDB,
|
|
ExcludeFiles: req.ExcludeFiles,
|
|
ProgressFn: func(phase, detail string) {
|
|
sendSSE(w, flusher, "progress", ProgressEvent{Phase: phase, Status: "running", Detail: detail})
|
|
},
|
|
}
|
|
|
|
manifest, runErr := backup.Run(r.Context(), opts)
|
|
if runErr != nil {
|
|
slog.Error("system.backup.sse", "error", runErr)
|
|
sendSSE(w, flusher, "error", ProgressEvent{Phase: "backup", Status: "error", Detail: runErr.Error()})
|
|
os.Remove(tmpPath)
|
|
return
|
|
}
|
|
|
|
token := storeExportToken("system", userID, tmpPath, fileName)
|
|
sendSSE(w, flusher, "complete", map[string]any{
|
|
"download_url": "/v1/system/backup/download/" + token,
|
|
"file_name": fileName,
|
|
"total_bytes": manifest.Stats.TotalBytes,
|
|
"schema_version": manifest.SchemaVersion,
|
|
})
|
|
}
|
|
|
|
// handleDownload serves a previously-prepared backup archive by token.
|
|
func (h *BackupHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := extractLocale(r)
|
|
|
|
token := r.PathValue("token")
|
|
if token == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest,
|
|
i18n.T(locale, i18n.MsgRequired, "token"))
|
|
return
|
|
}
|
|
|
|
entry, ok := lookupExportToken(token)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound,
|
|
i18n.T(locale, i18n.MsgNotFound, "backup token", token))
|
|
return
|
|
}
|
|
|
|
if entry.userID != userID && !h.isOwnerUser(userID) {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized,
|
|
i18n.T(locale, i18n.MsgNoAccess, "backup download"))
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(entry.filePath)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal,
|
|
i18n.T(locale, i18n.MsgInternalError))
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, entry.fileName))
|
|
http.ServeContent(w, r, entry.fileName, time.Time{}, f)
|
|
}
|
|
|
|
// isOwnerUser returns true if userID belongs to a configured system owner.
|
|
func (h *BackupHandler) isOwnerUser(userID string) bool {
|
|
return userID != "" && h.isOwner != nil && h.isOwner(userID)
|
|
}
|
|
|
|
// decodeJSONOptional decodes the request body into dest.
|
|
// Returns nil (no error) when body is absent or empty — all fields keep zero values.
|
|
func decodeJSONOptional(r *http.Request, dest any) error {
|
|
if r.Body == nil || r.ContentLength == 0 {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(r.Body).Decode(dest)
|
|
}
|