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>
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
// Package domain contains pure domain models with no external dependencies.
|
|
package domain
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DomainType represents the type of domain association with a project.
|
|
type DomainType string
|
|
|
|
const (
|
|
// DomainTypePrimaryAuto is a system-generated random subdomain (e.g., k7m2x9p4.threesix.ai).
|
|
DomainTypePrimaryAuto DomainType = "primary_auto"
|
|
|
|
// DomainTypePrimaryCustom is a user-chosen subdomain (e.g., my-app.threesix.ai).
|
|
DomainTypePrimaryCustom DomainType = "primary_custom"
|
|
|
|
// DomainTypeAlias is an additional domain pointing to the project (e.g., www.myapp.com).
|
|
DomainTypeAlias DomainType = "alias"
|
|
)
|
|
|
|
// Valid returns true if the domain type is recognized.
|
|
func (t DomainType) Valid() bool {
|
|
switch t {
|
|
case DomainTypePrimaryAuto, DomainTypePrimaryCustom, DomainTypeAlias:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsPrimary returns true if this is a primary domain (auto or custom).
|
|
func (t DomainType) IsPrimary() bool {
|
|
return t == DomainTypePrimaryAuto || t == DomainTypePrimaryCustom
|
|
}
|
|
|
|
// ProjectDomain represents a domain associated with a project.
|
|
type ProjectDomain struct {
|
|
ID int64
|
|
ProjectID string
|
|
Domain string
|
|
Type DomainType
|
|
DNSRecordID string // Cloudflare record ID for cleanup
|
|
DNSRecordType string // A, CNAME, etc.
|
|
Verified bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Slug generation constants.
|
|
const (
|
|
// SlugLength is the length of generated slugs.
|
|
SlugLength = 8
|
|
|
|
// slugChars are the allowed characters for slugs.
|
|
// Excludes ambiguous chars: 0/o, 1/l/i to prevent confusion.
|
|
slugChars = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
)
|
|
|
|
// slugCharRegex validates that a slug contains only allowed characters.
|
|
var slugCharRegex = regexp.MustCompile(`^[23456789abcdefghjkmnpqrstuvwxyz]+$`)
|
|
|
|
// GenerateSlug creates a random 8-character slug for project identification.
|
|
// Uses a restricted character set that excludes ambiguous characters.
|
|
func GenerateSlug() (string, error) {
|
|
bytes := make([]byte, SlugLength)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
}
|
|
|
|
result := make([]byte, SlugLength)
|
|
for i := range SlugLength {
|
|
result[i] = slugChars[int(bytes[i])%len(slugChars)]
|
|
}
|
|
return string(result), nil
|
|
}
|
|
|
|
// ValidateSlug checks if a slug is valid (correct length and characters).
|
|
func ValidateSlug(slug string) error {
|
|
if len(slug) != SlugLength {
|
|
return fmt.Errorf("slug must be exactly %d characters", SlugLength)
|
|
}
|
|
if !slugCharRegex.MatchString(slug) {
|
|
return fmt.Errorf("slug contains invalid characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Domain validation patterns.
|
|
var (
|
|
// subdomainRegex validates subdomains under a base domain.
|
|
// Lowercase letters, digits, and hyphens. Must start with a letter.
|
|
subdomainRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
|
|
// fqdnRegex validates fully qualified domain names.
|
|
fqdnRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
|
|
)
|
|
|
|
// ValidateSubdomain validates a subdomain name (without the base domain).
|
|
func ValidateSubdomain(subdomain string) error {
|
|
if subdomain == "" {
|
|
return fmt.Errorf("subdomain cannot be empty")
|
|
}
|
|
if len(subdomain) > 63 {
|
|
return fmt.Errorf("subdomain cannot exceed 63 characters")
|
|
}
|
|
if !subdomainRegex.MatchString(subdomain) {
|
|
return fmt.Errorf("subdomain must be lowercase alphanumeric with hyphens, starting with a letter")
|
|
}
|
|
if reservedProjectNames[subdomain] {
|
|
return fmt.Errorf("subdomain %q is reserved", subdomain)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateFQDN validates a fully qualified domain name.
|
|
func ValidateFQDN(domain string) error {
|
|
if domain == "" {
|
|
return fmt.Errorf("domain cannot be empty")
|
|
}
|
|
if len(domain) > 253 {
|
|
return fmt.Errorf("domain cannot exceed 253 characters")
|
|
}
|
|
domain = strings.ToLower(domain)
|
|
if !fqdnRegex.MatchString(domain) {
|
|
return fmt.Errorf("invalid domain format")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsSubdomainOf checks if domain is a subdomain of baseDomain.
|
|
func IsSubdomainOf(domain, baseDomain string) bool {
|
|
domain = strings.ToLower(domain)
|
|
baseDomain = strings.ToLower(baseDomain)
|
|
suffix := "." + baseDomain
|
|
return strings.HasSuffix(domain, suffix)
|
|
}
|
|
|
|
// ExtractSubdomain extracts the subdomain portion from a full domain.
|
|
// Returns empty string if domain is not a subdomain of baseDomain.
|
|
func ExtractSubdomain(domain, baseDomain string) string {
|
|
domain = strings.ToLower(domain)
|
|
baseDomain = strings.ToLower(baseDomain)
|
|
suffix := "." + baseDomain
|
|
if subdomain, found := strings.CutSuffix(domain, suffix); found {
|
|
return subdomain
|
|
}
|
|
return ""
|
|
}
|