// 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 "" }