diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index e4005f0d..8cb45518 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/gin-gonic/gin" @@ -43,6 +44,8 @@ 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) } @@ -224,6 +227,133 @@ func (h *ImportHandler) Upload(c *gin.Context) { }) } +// 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 { + 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 := filepath.Join(h.importDir, "uploads", sid) + if err := os.MkdirAll(sessionDir, 0755); 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 := filepath.Join(sessionDir, cleanName) + + // Create parent directory if file is in a subdirectory + if dir := filepath.Dir(targetPath); dir != sessionDir { + if err := os.MkdirAll(dir, 0755); 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), 0644); 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 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)}) + 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 ") { + path := strings.TrimSpace(strings.TrimPrefix(trimmed, "import")) + // Remove any trailing comments + if idx := strings.Index(path, "#"); idx != -1 { + path = strings.TrimSpace(path[:idx]) + } + imports = append(imports, path) + } + } + return imports +} + // Commit finalizes the import with user's conflict resolutions. func (h *ImportHandler) Commit(c *gin.Context) { var req struct { diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index b666d015..5afc84a3 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -712,3 +712,160 @@ func TestImportHandler_Errors(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } + +func TestImportHandler_DetectImports(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + handler := handlers.NewImportHandler(db, "echo", "/tmp", "") + router := gin.New() + router.POST("/import/detect-imports", handler.DetectImports) + + tests := []struct { + name string + content string + hasImport bool + imports []string + }{ + { + name: "no imports", + content: "example.com { reverse_proxy localhost:8080 }", + hasImport: false, + imports: []string{}, + }, + { + name: "single import", + content: "import sites/*\nexample.com { reverse_proxy localhost:8080 }", + hasImport: true, + imports: []string{"sites/*"}, + }, + { + name: "multiple imports", + content: "import sites/*\nimport config/ssl.conf\nexample.com { reverse_proxy localhost:8080 }", + hasImport: true, + imports: []string{"sites/*", "config/ssl.conf"}, + }, + { + name: "import with comment", + content: "import sites/* # Load all sites\nexample.com { reverse_proxy localhost:8080 }", + hasImport: true, + imports: []string{"sites/*"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := map[string]string{"content": tt.content} + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/detect-imports", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Equal(t, tt.hasImport, resp["has_imports"]) + + imports := resp["imports"].([]interface{}) + assert.Len(t, imports, len(tt.imports)) + }) + } +} + +func TestImportHandler_UploadMulti(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupImportTestDB(t) + tmpDir := t.TempDir() + + // Use fake caddy script + cwd, _ := os.Getwd() + fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh") + os.Chmod(fakeCaddy, 0755) + + handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "") + router := gin.New() + router.POST("/import/upload-multi", handler.UploadMulti) + + t.Run("single Caddyfile", func(t *testing.T) { + payload := map[string]interface{}{ + "files": []map[string]string{ + {"filename": "Caddyfile", "content": "example.com"}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.NotNil(t, resp["session"]) + assert.NotNil(t, resp["preview"]) + }) + + t.Run("Caddyfile with site files", func(t *testing.T) { + payload := map[string]interface{}{ + "files": []map[string]string{ + {"filename": "Caddyfile", "content": "import sites/*\n"}, + {"filename": "sites/site1", "content": "site1.com"}, + {"filename": "sites/site2", "content": "site2.com"}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + session := resp["session"].(map[string]interface{}) + assert.Equal(t, "transient", session["state"]) + }) + + t.Run("missing Caddyfile", func(t *testing.T) { + payload := map[string]interface{}{ + "files": []map[string]string{ + {"filename": "sites/site1", "content": "site1.com"}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("empty file content", func(t *testing.T) { + payload := map[string]interface{}{ + "files": []map[string]string{ + {"filename": "Caddyfile", "content": "example.com"}, + {"filename": "sites/site1", "content": " "}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Contains(t, resp["error"], "empty") + }) +} diff --git a/backend/internal/api/routes/routes_import_test.go b/backend/internal/api/routes/routes_import_test.go index 23c0ca27..339dac6b 100644 --- a/backend/internal/api/routes/routes_import_test.go +++ b/backend/internal/api/routes/routes_import_test.go @@ -33,11 +33,13 @@ func TestRegisterImportHandler(t *testing.T) { routeInfo := router.Routes() expectedRoutes := map[string]bool{ - "GET /api/v1/import/status": false, - "GET /api/v1/import/preview": false, - "POST /api/v1/import/upload": false, - "POST /api/v1/import/commit": false, - "DELETE /api/v1/import/cancel": false, + "GET /api/v1/import/status": false, + "GET /api/v1/import/preview": false, + "POST /api/v1/import/upload": false, + "POST /api/v1/import/upload-multi": false, + "POST /api/v1/import/detect-imports": false, + "POST /api/v1/import/commit": false, + "DELETE /api/v1/import/cancel": false, } for _, route := range routeInfo { diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts index 54a46186..9dbcf600 100644 --- a/frontend/src/api/import.ts +++ b/frontend/src/api/import.ts @@ -2,9 +2,10 @@ import client from './client'; export interface ImportSession { id: string; - state: 'pending' | 'reviewing' | 'completed' | 'failed'; + state: 'pending' | 'reviewing' | 'completed' | 'failed' | 'transient'; created_at: string; updated_at: string; + source_file?: string; } export interface ImportPreview { diff --git a/frontend/src/hooks/useImport.ts b/frontend/src/hooks/useImport.ts index 9772ec7e..d16e32c1 100644 --- a/frontend/src/hooks/useImport.ts +++ b/frontend/src/hooks/useImport.ts @@ -20,7 +20,7 @@ export function useImport() { queryFn: getImportStatus, refetchInterval: (query) => { const data = query.state.data; - // Poll if we have a pending session in reviewing state + // Poll if we have a pending session in reviewing state (but not transient, as those don't change) if (data?.has_pending && data?.session?.state === 'reviewing') { return 3000; } @@ -31,7 +31,7 @@ export function useImport() { const previewQuery = useQuery({ queryKey: ['import-preview'], queryFn: getImportPreview, - enabled: !!statusQuery.data?.has_pending && (statusQuery.data?.session?.state === 'reviewing' || statusQuery.data?.session?.state === 'pending'), + enabled: !!statusQuery.data?.has_pending && (statusQuery.data?.session?.state === 'reviewing' || statusQuery.data?.session?.state === 'pending' || statusQuery.data?.session?.state === 'transient'), }); const uploadMutation = useMutation({