diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 91c7fe42..0f47a3a6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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 { diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index c02ba5af..104660d3 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -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/.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 { diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index 7ec8af07..3e0e8f75 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -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) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 484ac6e5..fba69b7a 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -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) } diff --git a/backend/internal/caddy/importer.go b/backend/internal/caddy/importer.go index ffc8473c..06eb67db 100644 --- a/backend/internal/caddy/importer.go +++ b/backend/internal/caddy/importer.go @@ -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 diff --git a/backend/internal/caddy/importer_test.go b/backend/internal/caddy/importer_test.go index 14f4dc1f..f26b5fce 100644 --- a/backend/internal/caddy/importer_test.go +++ b/backend/internal/caddy/importer_test.go @@ -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(`{ diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 682da56f..e71165e2 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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