Files
kandev/apps/backend/internal/system/backups/handler.go
T
2026-08-25 15:05:14 +01:00

118 lines
3.9 KiB
Go

package backups
import (
"errors"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
// RegisterRoutes wires the backups endpoints onto the /api/v1/system groups.
// Only the metadata listing is readable by any authenticated caller. Creating,
// downloading, restoring, and deleting a snapshot act on the whole install:
// a snapshot is a copy of the multi-user database, so the download route is an
// export of every user's data and belongs behind the admin role along with the
// destructive mutations.
func RegisterRoutes(read, admin *gin.RouterGroup, svc *Service) {
read.GET("/backups", HandleList(svc))
admin.POST("/backups", HandleCreate(svc))
admin.GET("/backups/:name/download", HandleDownload(svc))
admin.POST("/backups/:name/restore", HandleRestore(svc))
admin.DELETE("/backups/:name", HandleDelete(svc))
}
// HandleList returns GET /api/v1/system/backups -> { snapshots: [...] }.
func HandleList(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
snaps, err := svc.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"snapshots": snaps})
}
}
// HandleCreate returns POST /api/v1/system/backups -> 202 { job_id }.
func HandleCreate(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
id := svc.Create(c.Request.Context())
c.JSON(http.StatusAccepted, gin.H{"job_id": id})
}
}
// HandleDownload returns GET /api/v1/system/backups/:name/download. Streams
// the file as an attachment.
func HandleDownload(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if _, err := svc.resolveSnapshotPath(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
f, size, err := svc.OpenForDownload(name)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
defer func() { _ = f.Close() }()
c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(name, `"`, `\"`)+`"`)
c.Header("Content-Type", "application/octet-stream")
c.DataFromReader(http.StatusOK, size, "application/octet-stream", f, nil)
}
}
// HandleRestore returns POST /api/v1/system/backups/:name/restore. Body
// must include {"confirm":"RESTORE"}.
func HandleRestore(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if _, err := svc.resolveSnapshotPath(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
var body struct {
Confirm string `json:"confirm"`
}
_ = c.ShouldBindJSON(&body)
id, err := svc.Restore(c.Request.Context(), name, body.Confirm)
if err != nil {
if errors.Is(err, errRestoreConfirm) || errors.Is(err, errRestoreUnsupported) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"job_id": id})
}
}
// HandleDelete returns DELETE /api/v1/system/backups/:name -> 204.
func HandleDelete(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if _, err := svc.resolveSnapshotPath(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if err := svc.Delete(name); err != nil {
// Differentiate by cause: pre-reset rejections and not-found
// map to client-visible statuses; anything else (filesystem
// errors etc.) is a 500 so we don't leak raw storage detail.
switch {
case strings.Contains(err.Error(), "cannot delete pre-reset"):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, os.ErrNotExist):
c.JSON(http.StatusNotFound, gin.H{"error": "snapshot not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
}
return
}
c.Status(http.StatusNoContent)
}
}