package handlers import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/pkg/api" ) // DomainAliasRequest is the request body for POST /projects/{id}/domains. type DomainAliasRequest struct { Domain string `json:"domain"` // The domain to add (e.g., "my-app.threesix.ai" or "custom.example.com") Type string `json:"type,omitempty"` // "A" or "CNAME" (default: "A") Proxied bool `json:"proxied,omitempty"` // Cloudflare proxy (default: false) DomainType string `json:"domain_type,omitempty"` // "primary_custom" or "alias" (default: "alias") } // DomainResponse is the response for domain operations. type DomainResponse struct { ID int64 `json:"id"` Domain string `json:"domain"` Type string `json:"type"` // DomainType: primary_auto, primary_custom, alias RecordType string `json:"record_type"` // DNS record type: A, CNAME Verified bool `json:"verified"` CreatedAt string `json:"created_at"` } // ListDomains returns all domains associated with a project. // GET /projects/{id}/domains func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := validateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if h.domainService == nil { api.WriteInternalError(w, r, "domain service not configured") return } domains, err := h.domainService.ListDomains(ctx, projectID) if err != nil { api.WriteInternalError(w, r, fmt.Sprintf("failed to list domains: %v", err)) return } // Convert to response format response := make([]DomainResponse, 0, len(domains)) for _, d := range domains { response = append(response, DomainResponse{ ID: d.ID, Domain: d.Domain, Type: string(d.Type), RecordType: d.DNSRecordType, Verified: d.Verified, CreatedAt: d.CreatedAt.Format(time.RFC3339), }) } api.WriteSuccess(w, r, map[string]any{ "project_id": projectID, "domains": response, "total": len(response), }) } // AddDomainAlias adds a domain to a project. // POST /projects/{id}/domains func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() if err := validateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if h.domainService == nil { api.WriteInternalError(w, r, "domain service not configured") return } var req DomainAliasRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Domain == "" { api.WriteBadRequest(w, r, "domain is required") return } // Default record type is A recordType := strings.ToUpper(req.Type) if recordType == "" { recordType = "A" } if recordType != "A" && recordType != "CNAME" { api.WriteBadRequest(w, r, "type must be A or CNAME") return } // Determine domain type domainType := domain.DomainTypeAlias if req.DomainType == "primary_custom" { domainType = domain.DomainTypePrimaryCustom } pd, err := h.domainService.AddDomain(ctx, DomainAddRequest{ ProjectID: projectID, Domain: req.Domain, Type: domainType, RecordType: recordType, Proxied: req.Proxied, }) if err != nil { if errors.Is(err, domain.ErrDuplicateDomain) { api.WriteBadRequest(w, r, "domain already exists") return } api.WriteInternalError(w, r, fmt.Sprintf("failed to add domain: %v", err)) return } note := "Domain configured" if !isSubdomain(req.Domain, h.defaultDomain) && recordType == "A" { note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP) } api.WriteCreated(w, r, map[string]any{ "id": pd.ID, "project": projectID, "domain": pd.Domain, "type": string(pd.Type), "record_type": pd.DNSRecordType, "verified": pd.Verified, "status": "configured", "note": note, }) } // RemoveDomainAlias removes a domain from a project. // DELETE /projects/{id}/domains/{domain} func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") aliasDomain := chi.URLParam(r, "domain") ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() if err := validateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if aliasDomain == "" { api.WriteBadRequest(w, r, "domain path parameter is required") return } if h.domainService == nil { api.WriteInternalError(w, r, "domain service not configured") return } err := h.domainService.RemoveDomain(ctx, projectID, aliasDomain) if err != nil { if errors.Is(err, domain.ErrDomainNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("domain not found: %s", aliasDomain)) return } // Check for primary domain protection if strings.Contains(err.Error(), "auto-generated primary domain") { api.WriteBadRequest(w, r, "cannot remove auto-generated primary domain; delete the project instead") return } api.WriteInternalError(w, r, fmt.Sprintf("failed to remove domain: %v", err)) return } api.WriteSuccess(w, r, map[string]string{ "project": projectID, "domain": aliasDomain, "status": "removed", }) }