package handlers import ( "encoding/json" "errors" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote" ) // BackupRemotesHandler exposes: // // GET /api/v1/backup-remotes // POST /api/v1/backup-remotes // PUT /api/v1/backup-remotes/:id // DELETE /api/v1/backup-remotes/:id // POST /api/v1/backup-remotes/:id/test type BackupRemotesHandler struct { Pool *pgxpool.Pool Svc *remote.Service Audit *audit.Repo NodeID string } func NewBackupRemotesHandler(pool *pgxpool.Pool, a *audit.Repo, nodeID string) *BackupRemotesHandler { return &BackupRemotesHandler{Pool: pool, Svc: remote.New(pool), Audit: a, NodeID: nodeID} } func (h *BackupRemotesHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/backup-remotes") g.GET("", h.List) g.POST("", h.Create) g.PUT("/:id", h.Update) g.DELETE("/:id", h.Delete) g.POST("/:id/test", h.Test) } type remoteRow struct { ID int64 `json:"id"` Name string `json:"name"` Kind string `json:"kind"` TargetURL string `json:"target_url"` Settings json.RawMessage `json:"settings"` Active bool `json:"active"` LastUpload *time.Time `json:"last_upload_at,omitempty"` LastError *string `json:"last_error,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } const remoteSelect = ` SELECT id, name, kind, target_url, settings, active, last_upload_at, last_error, created_at, updated_at FROM backup_remotes` func (h *BackupRemotesHandler) List(c *gin.Context) { rows, err := h.Pool.Query(c.Request.Context(), remoteSelect+" ORDER BY id ASC") if err != nil { response.Internal(c, err) return } defer rows.Close() out := []remoteRow{} for rows.Next() { var r remoteRow if err := rows.Scan(&r.ID, &r.Name, &r.Kind, &r.TargetURL, &r.Settings, &r.Active, &r.LastUpload, &r.LastError, &r.CreatedAt, &r.UpdatedAt); err != nil { response.Internal(c, err) return } // Sensitive Felder maskieren in der List-Response — Operator // soll nicht versehentlich beim Screenshare den S3-SecretKey // preisgeben. Edit-Modal verwendet einen separaten endpoint // nicht (UI darf das masked-Settings nicht zurückschreiben). r.Settings = maskSecrets(r.Settings) out = append(out, r) } response.OK(c, gin.H{"remotes": out}) } func (h *BackupRemotesHandler) Create(c *gin.Context) { var req remoteRow if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if req.Settings == nil || len(req.Settings) == 0 { req.Settings = json.RawMessage(`{}`) } row := h.Pool.QueryRow(c.Request.Context(), ` INSERT INTO backup_remotes (name, kind, target_url, settings, active) VALUES ($1, $2, $3, $4, $5) RETURNING `+remoteSelectFields(), req.Name, req.Kind, req.TargetURL, req.Settings, req.Active) var out remoteRow if err := row.Scan(&out.ID, &out.Name, &out.Kind, &out.TargetURL, &out.Settings, &out.Active, &out.LastUpload, &out.LastError, &out.CreatedAt, &out.UpdatedAt); err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.remote.create", out.Name, gin.H{"id": out.ID, "kind": out.Kind}, h.NodeID) out.Settings = maskSecrets(out.Settings) response.Created(c, out) } func (h *BackupRemotesHandler) Update(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req remoteRow if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if req.Settings == nil || len(req.Settings) == 0 { req.Settings = json.RawMessage(`{}`) } // Wenn die Settings masked-Fields enthalten (***), übernehmen wir // die bestehenden Werte aus der DB — Operator kann das Modal mit // Settings öffnen, einen Klick speichern, ohne sein S3-Secret neu // eingeben zu müssen. merged, err := mergeMaskedSettings(c.Request.Context(), h.Pool, id, req.Settings) if err == nil { req.Settings = merged } row := h.Pool.QueryRow(c.Request.Context(), ` UPDATE backup_remotes SET name = $1, kind = $2, target_url = $3, settings = $4, active = $5, updated_at = NOW() WHERE id = $6 RETURNING `+remoteSelectFields(), req.Name, req.Kind, req.TargetURL, req.Settings, req.Active, id) var out remoteRow if err := row.Scan(&out.ID, &out.Name, &out.Kind, &out.TargetURL, &out.Settings, &out.Active, &out.LastUpload, &out.LastError, &out.CreatedAt, &out.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.remote.update", out.Name, gin.H{"id": id}, h.NodeID) out.Settings = maskSecrets(out.Settings) response.OK(c, out) } func (h *BackupRemotesHandler) Delete(c *gin.Context) { id, ok := parseID(c) if !ok { return } if _, err := h.Pool.Exec(c.Request.Context(), `DELETE FROM backup_remotes WHERE id = $1`, id); err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.remote.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c) } // Test schickt eine 1KB-Probe in den Target. Failures = Misconfig. func (h *BackupRemotesHandler) Test(c *gin.Context) { id, ok := parseID(c) if !ok { return } var t remote.Target err := h.Pool.QueryRow(c.Request.Context(), ` SELECT id, name, kind, target_url, settings, active, last_upload_at, last_error FROM backup_remotes WHERE id = $1`, id).Scan( &t.ID, &t.Name, &t.Kind, &t.TargetURL, &t.Settings, &t.Active, &t.LastUpload, &t.LastError) if err != nil { response.NotFound(c, err) return } if err := h.Svc.Test(c.Request.Context(), t); err != nil { response.Err(c, http.StatusBadGateway, err) return } response.OK(c, gin.H{"ok": true}) } func remoteSelectFields() string { return `id, name, kind, target_url, settings, active, last_upload_at, last_error, created_at, updated_at` } // maskSecrets ersetzt sensitive Felder in settings-JSON durch ***SET*** // damit GET /backup-remotes nichts leakt (Screenshare, Browser-Cache, // Browser-DevTools). func maskSecrets(raw json.RawMessage) json.RawMessage { if len(raw) == 0 { return raw } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return raw } for _, k := range []string{"secret_key", "password", "private_key"} { if v, ok := m[k]; ok { if s, isStr := v.(string); isStr && s != "" { m[k] = "***SET***" } } } b, err := json.Marshal(m) if err != nil { return raw } return b } // mergeMaskedSettings: wenn der Operator das Edit-Modal speichert ohne // die secret-Felder neu zu setzen, schickt das UI ***SET*** zurück — // wir lassen dann den DB-Wert stehen statt das Secret zu überschreiben. func mergeMaskedSettings(ctx pgxContext, pool *pgxpool.Pool, id int64, incoming json.RawMessage) (json.RawMessage, error) { var current json.RawMessage if err := pool.QueryRow(ctx, `SELECT settings FROM backup_remotes WHERE id = $1`, id). Scan(¤t); err != nil { return incoming, err } var cur, inc map[string]any if err := json.Unmarshal(current, &cur); err != nil { return incoming, nil } if err := json.Unmarshal(incoming, &inc); err != nil { return incoming, nil } for _, k := range []string{"secret_key", "password", "private_key"} { if v, ok := inc[k]; ok { if s, isStr := v.(string); isStr && s == "***SET***" { if cv, hasCur := cur[k]; hasCur { inc[k] = cv } else { delete(inc, k) } } } } b, err := json.Marshal(inc) if err != nil { return incoming, err } return b, nil } // pgxContext-Alias damit die Helper-Funktion keinen direkten context- // Import oben braucht. type pgxContext = interface { Done() <-chan struct{} Err() error Deadline() (time.Time, bool) Value(any) any }