feat: Enhance import handler to support mounted Caddyfile and improve conflict reporting

This commit is contained in:
Wikid82
2025-11-24 17:32:56 +00:00
parent fce717f7d9
commit 8babd2f430
7 changed files with 198 additions and 87 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ func main() {
}
// Register import handler with config dependencies
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir)
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
// Check for mounted Caddyfile on startup
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
+160 -58
View File
@@ -24,15 +24,17 @@ type ImportHandler struct {
proxyHostSvc *services.ProxyHostService
importerservice *caddy.Importer
importDir string
mountPath string
}
// NewImportHandler creates a new import handler.
func NewImportHandler(db *gorm.DB, caddyBinary, importDir string) *ImportHandler {
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,
}
}
@@ -86,42 +88,77 @@ func (h *ImportHandler) GetPreview(c *gin.Context) {
}
var result caddy.ImportResult
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
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
}
// Update status to reviewing
session.Status = "reviewing"
h.db.Save(&session)
// No DB session found or failed to parse session. Try transient preview from mountPath.
if h.mountPath != "" {
if _, err := os.Stat(h.mountPath); err == nil {
// 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
}
// Read original Caddyfile content if available
var caddyfileContent string
if session.SourceFile != "" {
// Try to read from the source file path (if it's a mounted file)
if content, err := os.ReadFile(session.SourceFile); err == nil {
caddyfileContent = string(content)
} else {
// If source file not readable (e.g. uploaded temp file deleted), try to find backup
// This is a best-effort attempt
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
if content, err := os.ReadFile(backupPath); err == nil {
// 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 append raw domain names
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, eh := range existingHosts {
existingDomains[eh.DomainNames] = true
}
for _, ph := range transient.Hosts {
if existingDomains[ph.DomainNames] {
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
"preview": transient,
"caddyfile_content": caddyfileContent,
})
return
}
}
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,
})
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
}
// Upload handles manual Caddyfile upload or paste.
@@ -136,25 +173,43 @@ func (h *ImportHandler) Upload(c *gin.Context) {
return
}
// Create temporary file
tempPath := filepath.Join(h.importDir, fmt.Sprintf("upload-%s.caddyfile", uuid.NewString()))
if err := os.MkdirAll(h.importDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create import directory"})
// Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
sid := uuid.NewString()
uploadsDir := filepath.Join(h.importDir, "uploads")
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}
tempPath := filepath.Join(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
}
// Process the uploaded file
if err := h.processImport(tempPath, req.Filename); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// Parse uploaded file transiently
result, err := h.importerservice.ImportFile(tempPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "upload processed, ready for review"})
// Check for conflicts with existing hosts and append raw domain names
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": tempPath},
"preview": result,
})
}
// Commit finalizes the import with user's conflict resolutions.
@@ -169,16 +224,44 @@ func (h *ImportHandler) Commit(c *gin.Context) {
return
}
// Try to find a DB-backed session first
var session models.ImportSession
if err := h.db.Where("uuid = ? AND status = ?", req.SessionUUID, "reviewing").First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or not in reviewing state"})
return
}
var result caddy.ImportResult
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
return
var result *caddy.ImportResult
if err := h.db.Where("uuid = ? AND status = ?", req.SessionUUID, "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
uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", req.SessionUUID))
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: req.SessionUUID, SourceFile: uploadsPath}
} else if h.mountPath != "" {
if _, err := os.Stat(h.mountPath); err == nil {
r, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
result = r
session = models.ImportSession{UUID: req.SessionUUID, SourceFile: h.mountPath}
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
return
}
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
}
// Convert parsed hosts to ProxyHost models
@@ -213,12 +296,21 @@ func (h *ImportHandler) Commit(c *gin.Context) {
}
}
// Mark session as committed
// Persist an import session record now that user confirmed
now := time.Now()
session.Status = "committed"
session.CommittedAt = &now
session.UserResolutions = string(mustMarshal(req.Resolutions))
h.db.Save(&session)
// 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 {
log.Printf("Warning: failed to save import session: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"created": created,
@@ -236,15 +328,23 @@ func (h *ImportHandler) Cancel(c *gin.Context) {
}
var session models.ImportSession
if err := h.db.Where("uuid = ?", sessionUUID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
if err := h.db.Where("uuid = ?", sessionUUID).First(&session).Error; err == nil {
session.Status = "rejected"
h.db.Save(&session)
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
return
}
session.Status = "rejected"
h.db.Save(&session)
// If no DB session, check for uploaded temp file and delete it
uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", sessionUUID))
if _, err := os.Stat(uploadsPath); err == nil {
os.Remove(uploadsPath)
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
// If neither exists, return not found
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
}
// processImport handles the import logic for both mounted and uploaded files.
@@ -269,8 +369,8 @@ func (h *ImportHandler) processImport(caddyfilePath, originalName string) error
for _, parsed := range result.Hosts {
if existingDomains[parsed.DomainNames] {
result.Conflicts = append(result.Conflicts,
fmt.Sprintf("Domain '%s' already exists in CPM+", parsed.DomainNames))
// Append the raw domain name so frontend can match conflicts against domain strings
result.Conflicts = append(result.Conflicts, parsed.DomainNames)
}
}
@@ -299,10 +399,12 @@ func (h *ImportHandler) processImport(caddyfilePath, originalName string) error
// 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) {
return nil // No mounted file, skip
// 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
// 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)
@@ -311,8 +413,8 @@ func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) e
return nil // Already processed
}
handler := NewImportHandler(db, caddyBinary, importDir)
return handler.processImport(mountPath, mountPath)
// Do not create a DB session automatically for mounted imports; preview will be transient.
return nil
}
func mustMarshal(v interface{}) []byte {
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -34,7 +35,7 @@ func TestImportHandler_GetStatus(t *testing.T) {
db := setupImportTestDB(t)
// Case 1: No active session
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.GET("/import/status", handler.GetStatus)
@@ -68,7 +69,7 @@ func TestImportHandler_GetStatus(t *testing.T) {
func TestImportHandler_GetPreview(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
@@ -107,7 +108,7 @@ func TestImportHandler_GetPreview(t *testing.T) {
func TestImportHandler_Cancel(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
@@ -131,7 +132,7 @@ func TestImportHandler_Cancel(t *testing.T) {
func TestImportHandler_Commit(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/commit", handler.Commit)
@@ -178,7 +179,7 @@ func TestImportHandler_Upload(t *testing.T) {
os.Chmod(fakeCaddy, 0755)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
@@ -205,7 +206,7 @@ func TestImportHandler_GetPreview_WithContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir)
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
@@ -239,7 +240,7 @@ func TestImportHandler_GetPreview_WithContent(t *testing.T) {
func TestImportHandler_Commit_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/commit", handler.Commit)
@@ -282,7 +283,7 @@ func TestImportHandler_Commit_Errors(t *testing.T) {
func TestImportHandler_Cancel_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
@@ -314,10 +315,10 @@ func TestCheckMountedImport(t *testing.T) {
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Check if session created
// Check if session created (transient preview behavior: no DB session should be created)
var count int64
db.Model(&models.ImportSession{}).Where("source_file = ?", mountPath).Count(&count)
assert.Equal(t, int64(1), count)
assert.Equal(t, int64(0), count)
// Case 3: Already processed
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
@@ -333,7 +334,7 @@ func TestImportHandler_Upload_Failure(t *testing.T) {
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_fail.sh")
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
@@ -370,7 +371,7 @@ func TestImportHandler_Upload_Conflict(t *testing.T) {
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
@@ -386,18 +387,27 @@ func TestImportHandler_Upload_Conflict(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
// Verify session created with conflict
var session models.ImportSession
db.First(&session)
assert.Equal(t, "pending", session.Status)
assert.Contains(t, session.ConflictReport, "Domain 'example.com' already exists")
// Verify response contains conflict in preview (upload is transient)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
preview := resp["preview"].(map[string]interface{})
conflicts := preview["conflicts"].([]interface{})
found := false
for _, c := range conflicts {
if c.(string) == "example.com" || strings.Contains(c.(string), "example.com") {
found = true
break
}
}
assert.True(t, found, "expected conflict for example.com in preview")
}
func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir)
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
@@ -430,7 +440,7 @@ func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
func TestImportHandler_RegisterRoutes(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
@@ -445,7 +455,7 @@ func TestImportHandler_RegisterRoutes(t *testing.T) {
func TestImportHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.POST("/import/commit", handler.Commit)
+2 -2
View File
@@ -214,8 +214,8 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
}
// RegisterImportHandler wires up import routes with config dependencies.
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir string) {
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir)
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir, mountPath)
api := router.Group("/api/v1")
importHandler.RegisterRoutes(api)
}
+2 -3
View File
@@ -139,10 +139,9 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
for _, hostMatcher := range match.Host {
domain := hostMatcher
// Check for duplicate domains
// Check for duplicate domains (report domain names only)
if seenDomains[domain] {
result.Conflicts = append(result.Conflicts,
fmt.Sprintf("Duplicate domain detected: %s", domain))
result.Conflicts = append(result.Conflicts, domain)
continue
}
seenDomains[domain] = true
+1 -1
View File
@@ -138,7 +138,7 @@ func TestImporter_ExtractHosts(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, result.Hosts, 1)
assert.Len(t, result.Conflicts, 1)
assert.Contains(t, result.Conflicts[0], "Duplicate domain detected")
assert.Equal(t, "example.com", result.Conflicts[0])
// Test Case 5: Unsupported Features
unsupportedJSON := []byte(`{
+2 -2
View File
@@ -36,8 +36,8 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery
- ./backend:/app/backend:ro # Mount source for debugging
# Mount your existing Caddyfile for automatic import (optional)
# - ./my-existing-Caddyfile:/import/Caddyfile:ro
# - ./sites:/import/sites:ro # If your Caddyfile imports other files
# - /root/docker/containers/caddy/Caddyfile:/import/Caddyfile:ro
# - /root/docker/containers/caddy/sites:/import/sites:ro # If your Caddyfile imports other files
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
interval: 30s