- Created `pr_461_remediation_complete.md` detailing the final remediation status, including bug fixes, test results, and coverage metrics. - Added `pr_461_vulnerability_comment.md` summarizing the supply chain vulnerabilities accepted for PR #461, including risk assessments and mitigation strategies. - Established `VULNERABILITY_ACCEPTANCE.md` to formally document the acceptance of 9 vulnerabilities in Alpine Linux packages, outlining the rationale, monitoring plans, and compliance with industry standards. These documents ensure transparency and provide a clear audit trail for the vulnerability management process associated with PR #461.
776 lines
26 KiB
Go
776 lines
26 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/api/middleware"
|
|
"github.com/Wikid82/charon/backend/internal/caddy"
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
"github.com/Wikid82/charon/backend/internal/util"
|
|
)
|
|
|
|
// ImportHandler handles Caddyfile import operations.
|
|
type ImportHandler struct {
|
|
db *gorm.DB
|
|
proxyHostSvc *services.ProxyHostService
|
|
importerservice *caddy.Importer
|
|
importDir string
|
|
mountPath string
|
|
}
|
|
|
|
// NewImportHandler creates a new import handler.
|
|
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
|
|
return &ImportHandler{
|
|
db: db,
|
|
proxyHostSvc: services.NewProxyHostService(db),
|
|
importerservice: caddy.NewImporter(caddyBinary),
|
|
importDir: importDir,
|
|
mountPath: mountPath,
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers import-related routes.
|
|
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
|
|
router.GET("/import/status", h.GetStatus)
|
|
router.GET("/import/preview", h.GetPreview)
|
|
router.POST("/import/upload", h.Upload)
|
|
router.POST("/import/upload-multi", h.UploadMulti)
|
|
router.POST("/import/detect-imports", h.DetectImports)
|
|
router.POST("/import/commit", h.Commit)
|
|
router.DELETE("/import/cancel", h.Cancel)
|
|
}
|
|
|
|
// GetStatus returns current import session status.
|
|
func (h *ImportHandler) GetStatus(c *gin.Context) {
|
|
var session models.ImportSession
|
|
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
|
Order("created_at DESC").
|
|
First(&session).Error
|
|
|
|
if err == gorm.ErrRecordNotFound {
|
|
// No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview
|
|
if h.mountPath != "" {
|
|
if fileInfo, err := os.Stat(h.mountPath); err == nil {
|
|
// Check if this mount has already been committed recently
|
|
var committedSession models.ImportSession
|
|
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
|
|
Order("committed_at DESC").
|
|
First(&committedSession).Error
|
|
|
|
// Allow re-import if:
|
|
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
|
|
// 2. File was modified after last commit
|
|
allowImport := err == gorm.ErrRecordNotFound
|
|
if !allowImport && committedSession.CommittedAt != nil {
|
|
fileMod := fileInfo.ModTime()
|
|
commitTime := *committedSession.CommittedAt
|
|
allowImport = fileMod.After(commitTime)
|
|
}
|
|
|
|
if allowImport {
|
|
// Mount file is available for import
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"has_pending": true,
|
|
"session": gin.H{
|
|
"id": "transient",
|
|
"state": "transient",
|
|
"source_file": h.mountPath,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
// Mount file was already committed and hasn't been modified, don't offer it again
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"has_pending": false})
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"has_pending": true,
|
|
"session": gin.H{
|
|
"id": session.UUID,
|
|
"state": session.Status,
|
|
"created_at": session.CreatedAt,
|
|
"updated_at": session.UpdatedAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetPreview returns parsed hosts and conflicts for review.
|
|
func (h *ImportHandler) GetPreview(c *gin.Context) {
|
|
var session models.ImportSession
|
|
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
|
Order("created_at DESC").
|
|
First(&session).Error
|
|
|
|
if err == nil {
|
|
// DB session found
|
|
var result caddy.ImportResult
|
|
if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
|
|
// Update status to reviewing
|
|
session.Status = "reviewing"
|
|
h.db.Save(&session)
|
|
|
|
// Read original Caddyfile content if available
|
|
var caddyfileContent string
|
|
if session.SourceFile != "" {
|
|
if content, err := os.ReadFile(session.SourceFile); err == nil {
|
|
caddyfileContent = string(content)
|
|
} else {
|
|
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
|
|
if content, err := os.ReadFile(backupPath); err == nil {
|
|
caddyfileContent = string(content)
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"session": gin.H{
|
|
"id": session.UUID,
|
|
"state": session.Status,
|
|
"created_at": session.CreatedAt,
|
|
"updated_at": session.UpdatedAt,
|
|
"source_file": session.SourceFile,
|
|
},
|
|
"preview": result,
|
|
"caddyfile_content": caddyfileContent,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// No DB session found or failed to parse session. Try transient preview from mountPath.
|
|
if h.mountPath != "" {
|
|
if fileInfo, err := os.Stat(h.mountPath); err == nil {
|
|
// Check if this mount has already been committed recently
|
|
var committedSession models.ImportSession
|
|
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
|
|
Order("committed_at DESC").
|
|
First(&committedSession).Error
|
|
|
|
// Allow preview if:
|
|
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
|
|
// 2. File was modified after last commit
|
|
allowPreview := err == gorm.ErrRecordNotFound
|
|
if !allowPreview && committedSession.CommittedAt != nil {
|
|
allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt)
|
|
}
|
|
|
|
if !allowPreview {
|
|
// Mount file was already committed and hasn't been modified, don't offer preview again
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
|
|
return
|
|
}
|
|
|
|
// Parse mounted Caddyfile transiently
|
|
transient, err := h.importerservice.ImportFile(h.mountPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
|
|
return
|
|
}
|
|
|
|
// Build a transient session id (not persisted)
|
|
sid := uuid.NewString()
|
|
var caddyfileContent string
|
|
if content, err := os.ReadFile(h.mountPath); err == nil {
|
|
caddyfileContent = string(content)
|
|
}
|
|
|
|
// Check for conflicts with existing hosts and build conflict details
|
|
existingHosts, _ := h.proxyHostSvc.List()
|
|
existingDomainsMap := make(map[string]models.ProxyHost)
|
|
for _, eh := range existingHosts {
|
|
existingDomainsMap[eh.DomainNames] = eh
|
|
}
|
|
|
|
conflictDetails := make(map[string]gin.H)
|
|
for _, ph := range transient.Hosts {
|
|
if existing, found := existingDomainsMap[ph.DomainNames]; found {
|
|
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
|
|
conflictDetails[ph.DomainNames] = gin.H{
|
|
"existing": gin.H{
|
|
"forward_scheme": existing.ForwardScheme,
|
|
"forward_host": existing.ForwardHost,
|
|
"forward_port": existing.ForwardPort,
|
|
"ssl_forced": existing.SSLForced,
|
|
"websocket": existing.WebsocketSupport,
|
|
"enabled": existing.Enabled,
|
|
},
|
|
"imported": gin.H{
|
|
"forward_scheme": ph.ForwardScheme,
|
|
"forward_host": ph.ForwardHost,
|
|
"forward_port": ph.ForwardPort,
|
|
"ssl_forced": ph.SSLForced,
|
|
"websocket": ph.WebsocketSupport,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
|
|
"preview": transient,
|
|
"caddyfile_content": caddyfileContent,
|
|
"conflict_details": conflictDetails,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
|
|
}
|
|
|
|
// Upload handles manual Caddyfile upload or paste.
|
|
func (h *ImportHandler) Upload(c *gin.Context) {
|
|
var req struct {
|
|
Content string `json:"content" binding:"required"`
|
|
Filename string `json:"filename"`
|
|
}
|
|
|
|
// Capture raw request for better diagnostics in tests
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
// Try to include raw body preview when binding fails
|
|
entry := middleware.GetRequestLogger(c)
|
|
if raw, _ := c.GetRawData(); len(raw) > 0 {
|
|
entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import Upload: failed to bind JSON")
|
|
} else {
|
|
entry.WithError(err).Error("Import Upload: failed to bind JSON")
|
|
}
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload")
|
|
|
|
// Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
|
|
sid := uuid.NewString()
|
|
uploadsDir, err := safeJoin(h.importDir, "uploads")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"})
|
|
return
|
|
}
|
|
if err := os.MkdirAll(uploadsDir, 0o755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
|
|
return
|
|
}
|
|
tempPath, err := safeJoin(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"})
|
|
return
|
|
}
|
|
if err := os.WriteFile(tempPath, []byte(req.Content), 0o644); err != nil {
|
|
middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(err).Error("Import Upload: failed to write temp file")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
|
|
return
|
|
}
|
|
|
|
// Parse uploaded file transiently
|
|
result, err := h.importerservice.ImportFile(tempPath)
|
|
if err != nil {
|
|
// Read a small preview of the uploaded file for diagnostics
|
|
preview := ""
|
|
if b, rerr := os.ReadFile(tempPath); rerr == nil {
|
|
if len(b) > 200 {
|
|
preview = string(b[:200])
|
|
} else {
|
|
preview = string(b)
|
|
}
|
|
}
|
|
middleware.GetRequestLogger(c).WithError(err).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithField("content_preview", util.SanitizeForLog(preview)).Error("Import Upload: import failed")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
|
|
return
|
|
}
|
|
|
|
// If no hosts were parsed, provide a clearer error when import directives exist
|
|
if len(result.Hosts) == 0 {
|
|
imports := detectImportDirectives(req.Content)
|
|
if len(imports) > 0 {
|
|
sanitizedImports := make([]string, 0, len(imports))
|
|
for _, imp := range imports {
|
|
sanitizedImports = append(sanitizedImports, util.SanitizeForLog(filepath.Base(imp)))
|
|
}
|
|
middleware.GetRequestLogger(c).WithField("imports", sanitizedImports).Warn("Import Upload: no hosts parsed but imports detected")
|
|
} else {
|
|
middleware.GetRequestLogger(c).WithField("content_len", len(req.Content)).Warn("Import Upload: no hosts parsed and no imports detected")
|
|
}
|
|
if len(imports) > 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow", "imports": imports})
|
|
return
|
|
}
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile"})
|
|
return
|
|
}
|
|
|
|
// Check for conflicts with existing hosts and build conflict details
|
|
existingHosts, _ := h.proxyHostSvc.List()
|
|
existingDomainsMap := make(map[string]models.ProxyHost)
|
|
for _, eh := range existingHosts {
|
|
existingDomainsMap[eh.DomainNames] = eh
|
|
}
|
|
|
|
conflictDetails := make(map[string]gin.H)
|
|
for _, ph := range result.Hosts {
|
|
if existing, found := existingDomainsMap[ph.DomainNames]; found {
|
|
result.Conflicts = append(result.Conflicts, ph.DomainNames)
|
|
conflictDetails[ph.DomainNames] = gin.H{
|
|
"existing": gin.H{
|
|
"forward_scheme": existing.ForwardScheme,
|
|
"forward_host": existing.ForwardHost,
|
|
"forward_port": existing.ForwardPort,
|
|
"ssl_forced": existing.SSLForced,
|
|
"websocket": existing.WebsocketSupport,
|
|
"enabled": existing.Enabled,
|
|
},
|
|
"imported": gin.H{
|
|
"forward_scheme": ph.ForwardScheme,
|
|
"forward_host": ph.ForwardHost,
|
|
"forward_port": ph.ForwardPort,
|
|
"ssl_forced": ph.SSLForced,
|
|
"websocket": ph.WebsocketSupport,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
|
|
"conflict_details": conflictDetails,
|
|
"preview": result,
|
|
})
|
|
}
|
|
|
|
// DetectImports analyzes Caddyfile content and returns detected import directives.
|
|
func (h *ImportHandler) DetectImports(c *gin.Context) {
|
|
var req struct {
|
|
Content string `json:"content" binding:"required"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
entry := middleware.GetRequestLogger(c)
|
|
if raw, _ := c.GetRawData(); len(raw) > 0 {
|
|
entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import UploadMulti: failed to bind JSON")
|
|
} else {
|
|
entry.WithError(err).Error("Import UploadMulti: failed to bind JSON")
|
|
}
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
imports := detectImportDirectives(req.Content)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"has_imports": len(imports) > 0,
|
|
"imports": imports,
|
|
})
|
|
}
|
|
|
|
// UploadMulti handles upload of main Caddyfile + multiple site files.
|
|
func (h *ImportHandler) UploadMulti(c *gin.Context) {
|
|
var req struct {
|
|
Files []struct {
|
|
Filename string `json:"filename" binding:"required"`
|
|
Content string `json:"content" binding:"required"`
|
|
} `json:"files" binding:"required,min=1"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate: at least one file must be named "Caddyfile" or have no path separator
|
|
hasCaddyfile := false
|
|
for _, f := range req.Files {
|
|
if f.Filename == "Caddyfile" || !strings.Contains(f.Filename, "/") {
|
|
hasCaddyfile = true
|
|
break
|
|
}
|
|
}
|
|
if !hasCaddyfile {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "must include a main Caddyfile"})
|
|
return
|
|
}
|
|
|
|
// Create session directory
|
|
sid := uuid.NewString()
|
|
sessionDir, err := safeJoin(h.importDir, filepath.Join("uploads", sid))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"})
|
|
return
|
|
}
|
|
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
|
|
return
|
|
}
|
|
|
|
// Write all files
|
|
mainCaddyfile := ""
|
|
for _, f := range req.Files {
|
|
if strings.TrimSpace(f.Content) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file '%s' is empty", f.Filename)})
|
|
return
|
|
}
|
|
|
|
// Clean filename and create subdirectories if needed
|
|
cleanName := filepath.Clean(f.Filename)
|
|
targetPath, err := safeJoin(sessionDir, cleanName)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid filename: %s", f.Filename)})
|
|
return
|
|
}
|
|
|
|
// Create parent directory if file is in a subdirectory
|
|
if dir := filepath.Dir(targetPath); dir != sessionDir {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := os.WriteFile(targetPath, []byte(f.Content), 0o644); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
|
|
return
|
|
}
|
|
|
|
// Track main Caddyfile
|
|
if cleanName == "Caddyfile" || !strings.Contains(cleanName, "/") {
|
|
mainCaddyfile = targetPath
|
|
}
|
|
}
|
|
|
|
// Parse the main Caddyfile (which will automatically resolve imports)
|
|
result, err := h.importerservice.ImportFile(mainCaddyfile)
|
|
if err != nil {
|
|
// Provide diagnostics
|
|
preview := ""
|
|
if b, rerr := os.ReadFile(mainCaddyfile); rerr == nil {
|
|
if len(b) > 200 {
|
|
preview = string(b[:200])
|
|
} else {
|
|
preview = string(b)
|
|
}
|
|
}
|
|
middleware.GetRequestLogger(c).WithError(err).WithField("mainCaddyfile", util.SanitizeForLog(filepath.Base(mainCaddyfile))).WithField("preview", util.SanitizeForLog(preview)).Error("Import UploadMulti: import failed")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
|
|
return
|
|
}
|
|
|
|
// If parsing succeeded but no hosts were found, and imports were present in the main file,
|
|
// inform the caller to upload the site files.
|
|
if len(result.Hosts) == 0 {
|
|
mainContentBytes, _ := os.ReadFile(mainCaddyfile)
|
|
imports := detectImportDirectives(string(mainContentBytes))
|
|
if len(imports) > 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile; import directives detected; please include site files in upload", "imports": imports})
|
|
return
|
|
}
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile"})
|
|
return
|
|
}
|
|
|
|
// Check for conflicts
|
|
existingHosts, _ := h.proxyHostSvc.List()
|
|
existingDomains := make(map[string]bool)
|
|
for _, eh := range existingHosts {
|
|
existingDomains[eh.DomainNames] = true
|
|
}
|
|
for _, ph := range result.Hosts {
|
|
if existingDomains[ph.DomainNames] {
|
|
result.Conflicts = append(result.Conflicts, ph.DomainNames)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"session": gin.H{"id": sid, "state": "transient", "source_file": mainCaddyfile},
|
|
"preview": result,
|
|
})
|
|
}
|
|
|
|
// detectImportDirectives scans Caddyfile content for import directives.
|
|
func detectImportDirectives(content string) []string {
|
|
imports := []string{}
|
|
lines := strings.Split(content, "\n")
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "import ") {
|
|
importPath := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
|
|
// Remove any trailing comments
|
|
if idx := strings.Index(importPath, "#"); idx != -1 {
|
|
importPath = strings.TrimSpace(importPath[:idx])
|
|
}
|
|
imports = append(imports, importPath)
|
|
}
|
|
}
|
|
return imports
|
|
}
|
|
|
|
// safeJoin joins a user-supplied path to a base directory and ensures
|
|
// the resulting path is contained within the base directory.
|
|
func safeJoin(baseDir, userPath string) (string, error) {
|
|
clean := filepath.Clean(userPath)
|
|
if clean == "" || clean == "." {
|
|
return "", fmt.Errorf("empty path not allowed")
|
|
}
|
|
if filepath.IsAbs(clean) {
|
|
return "", fmt.Errorf("absolute paths not allowed")
|
|
}
|
|
|
|
// Prevent attempts like ".." at start
|
|
if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || clean == ".." {
|
|
return "", fmt.Errorf("path traversal detected")
|
|
}
|
|
|
|
target := filepath.Join(baseDir, clean)
|
|
rel, err := filepath.Rel(baseDir, target)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid path")
|
|
}
|
|
if strings.HasPrefix(rel, "..") {
|
|
return "", fmt.Errorf("path traversal detected")
|
|
}
|
|
|
|
// Normalize to use base's separators
|
|
target = path.Clean(target)
|
|
return target, nil
|
|
}
|
|
|
|
// Commit finalizes the import with user's conflict resolutions.
|
|
func (h *ImportHandler) Commit(c *gin.Context) {
|
|
var req struct {
|
|
SessionUUID string `json:"session_uuid" binding:"required"`
|
|
Resolutions map[string]string `json:"resolutions"` // domain -> action (keep/skip, overwrite, rename)
|
|
Names map[string]string `json:"names"` // domain -> custom name
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Try to find a DB-backed session first
|
|
var session models.ImportSession
|
|
// Basic sanitize of session id to prevent path separators
|
|
sid := filepath.Base(req.SessionUUID)
|
|
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
|
|
return
|
|
}
|
|
var result *caddy.ImportResult
|
|
if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&session).Error; err == nil {
|
|
// DB session found
|
|
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
|
|
return
|
|
}
|
|
} else {
|
|
// No DB session: check for uploaded temp file
|
|
var parseErr error
|
|
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
|
|
if err == nil {
|
|
if _, err := os.Stat(uploadsPath); err == nil {
|
|
r, err := h.importerservice.ImportFile(uploadsPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
|
|
return
|
|
}
|
|
result = r
|
|
// We'll create a committed DB session after applying
|
|
session = models.ImportSession{UUID: sid, SourceFile: uploadsPath}
|
|
}
|
|
}
|
|
// If not found yet, check mounted Caddyfile
|
|
if result == nil && h.mountPath != "" {
|
|
if _, err := os.Stat(h.mountPath); err == nil {
|
|
r, err := h.importerservice.ImportFile(h.mountPath)
|
|
if err != nil {
|
|
parseErr = err
|
|
} else {
|
|
result = r
|
|
session = models.ImportSession{UUID: sid, SourceFile: h.mountPath}
|
|
}
|
|
}
|
|
}
|
|
// If still not parsed, return not found or error
|
|
if result == nil {
|
|
if parseErr != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Convert parsed hosts to ProxyHost models
|
|
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
|
|
middleware.GetRequestLogger(c).WithField("parsed_hosts", len(result.Hosts)).WithField("proxy_hosts", len(proxyHosts)).Info("Import Commit: Parsed and converted hosts")
|
|
|
|
created := 0
|
|
updated := 0
|
|
skipped := 0
|
|
errors := []string{}
|
|
|
|
// Get existing hosts to check for overwrites
|
|
existingHosts, _ := h.proxyHostSvc.List()
|
|
existingMap := make(map[string]*models.ProxyHost)
|
|
for i := range existingHosts {
|
|
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
|
|
}
|
|
|
|
for _, host := range proxyHosts {
|
|
action := req.Resolutions[host.DomainNames]
|
|
|
|
// Apply custom name from user input
|
|
if customName, ok := req.Names[host.DomainNames]; ok && customName != "" {
|
|
host.Name = customName
|
|
}
|
|
|
|
// "keep" means keep existing (don't import), same as "skip"
|
|
if action == "skip" || action == "keep" {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
if action == "rename" {
|
|
host.DomainNames += "-imported"
|
|
}
|
|
|
|
// Handle overwrite: preserve existing ID, UUID, and certificate
|
|
if action == "overwrite" {
|
|
if existing, found := existingMap[host.DomainNames]; found {
|
|
host.ID = existing.ID
|
|
host.UUID = existing.UUID
|
|
host.CertificateID = existing.CertificateID // Preserve certificate association
|
|
host.CreatedAt = existing.CreatedAt
|
|
|
|
if err := h.proxyHostSvc.Update(&host); err != nil {
|
|
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
|
|
errors = append(errors, errMsg)
|
|
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", util.SanitizeForLog(errMsg)).Error("Import Commit Error (update)")
|
|
} else {
|
|
updated++
|
|
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Updated host")
|
|
}
|
|
continue
|
|
}
|
|
// If "overwrite" but doesn't exist, fall through to create
|
|
}
|
|
|
|
// Create new host
|
|
host.UUID = uuid.NewString()
|
|
if err := h.proxyHostSvc.Create(&host); err != nil {
|
|
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
|
|
errors = append(errors, errMsg)
|
|
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", util.SanitizeForLog(errMsg)).Error("Import Commit Error")
|
|
} else {
|
|
created++
|
|
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Created host")
|
|
}
|
|
}
|
|
|
|
// Persist an import session record now that user confirmed
|
|
now := time.Now()
|
|
session.Status = "committed"
|
|
session.CommittedAt = &now
|
|
session.UserResolutions = string(mustMarshal(req.Resolutions))
|
|
// If ParsedData/ConflictReport not set, fill from result
|
|
if session.ParsedData == "" {
|
|
session.ParsedData = string(mustMarshal(result))
|
|
}
|
|
if session.ConflictReport == "" {
|
|
session.ConflictReport = string(mustMarshal(result.Conflicts))
|
|
}
|
|
if err := h.db.Save(&session).Error; err != nil {
|
|
middleware.GetRequestLogger(c).WithError(err).Warn("Warning: failed to save import session")
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"created": created,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"errors": errors,
|
|
})
|
|
}
|
|
|
|
// Cancel discards a pending import session.
|
|
func (h *ImportHandler) Cancel(c *gin.Context) {
|
|
sessionUUID := c.Query("session_uuid")
|
|
if sessionUUID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
|
return
|
|
}
|
|
|
|
sid := filepath.Base(sessionUUID)
|
|
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
|
|
return
|
|
}
|
|
|
|
var session models.ImportSession
|
|
if err := h.db.Where("uuid = ?", sid).First(&session).Error; err == nil {
|
|
session.Status = "rejected"
|
|
h.db.Save(&session)
|
|
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
|
|
return
|
|
}
|
|
|
|
// If no DB session, check for uploaded temp file and delete it
|
|
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
|
|
if err == nil {
|
|
if _, err := os.Stat(uploadsPath); err == nil {
|
|
if err := os.Remove(uploadsPath); err != nil {
|
|
logger.Log().WithError(err).Warn("Failed to remove upload file")
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// If neither exists, return not found
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
|
}
|
|
|
|
// CheckMountedImport checks for mounted Caddyfile on startup.
|
|
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
|
|
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
|
|
// If mount is gone, remove any pending/reviewing sessions created previously for this mount
|
|
db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&models.ImportSession{})
|
|
return nil // No mounted file, nothing to import
|
|
}
|
|
|
|
// Check if already processed (includes committed to avoid re-imports)
|
|
var count int64
|
|
db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
|
|
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
|
|
|
|
if count > 0 {
|
|
return nil // Already processed
|
|
}
|
|
|
|
// Do not create a DB session automatically for mounted imports; preview will be transient.
|
|
return nil
|
|
}
|
|
|
|
func mustMarshal(v any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|