Landing page cookbook implementation (Weeks 1-4): Domain Infrastructure: - Add project_domains table with migration (013_project_domains.sql) - Add ProjectDomain model with domain types (primary_auto, primary_custom, alias) - Add SlugGenerator and ProjectDomainRepository interfaces - Implement postgres adapters for domain and slug management Service Layer: - Add domain CRUD methods to ProjectInfraService - Generate 8-char random slugs for auto-domains - Support custom subdomains during project creation - Add site_live health check to project status - Trigger CI build after template seeding Handler Updates: - Add DomainService interface and adapter pattern - Rewrite domain handlers to use database-backed service - Add proper error handling for duplicate/missing domains CI Integration: - Add TriggerBuild to CIProvider interface - Implement TriggerBuild in Woodpecker adapter - Manually trigger initial build after template seed Cookbook & Scripts: - Add landing-test.sh script for E2E testing - Add release.sh for version releases - Add logs.sh for quick log access Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
199 lines
5.5 KiB
Go
199 lines
5.5 KiB
Go
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",
|
|
})
|
|
}
|