package site import ( "errors" "net/url" "regexp" "strings" "unicode/utf8" ) var ( ErrSiteNotFound = errors.New("site not found") ErrSiteKeyTaken = errors.New("a site with that key already exists") ErrSiteHasArticles = errors.New("the site still has articles") ErrDefaultSite = errors.New("the default site cannot be deleted") ErrKeyImmutable = errors.New("the key of a site cannot be changed") ErrInvalidSiteURL = errors.New("invalid site url") ErrInvalidLocale = errors.New("invalid locale") ErrUseSetDefault = errors.New("the default flag is changed through the default route") ErrInvalidReassign = errors.New("invalid reassign destination") ) const ( RobotsIndex = "index" RobotsNoIndex = "noindex" DefaultName = "Default" DefaultKey = "default" MinNameRunes = 1 MaxNameRunes = 100 MinKeyRunes = 2 MaxKeyRunes = 32 ) var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) var localePattern = regexp.MustCompile(`^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$`) var loopbackHosts = []string{"localhost", "127.0.0.1", "::1"} type Site struct { ID uint32 `json:"id"` Name string `json:"name"` Key string `json:"key"` BaseURL string `json:"base_url"` DefaultSocialImageID *uint32 `json:"default_social_image_id"` DefaultRobots string `json:"default_robots"` Locale *string `json:"locale"` IsDefault bool `json:"is_default"` ArticleCount int `json:"article_count"` } type Nullable[T any] struct { Set bool Value *T } type Patch struct { Name *string Key *string BaseURL *string DefaultRobots *string Locale Nullable[string] DefaultSocialImageID Nullable[uint32] IsDefault *bool } func IsValidKey(key string) bool { length := utf8.RuneCountInString(key) return length >= MinKeyRunes && length <= MaxKeyRunes && keyPattern.MatchString(key) } func IsValidName(name string) bool { length := utf8.RuneCountInString(name) return length >= MinNameRunes && length <= MaxNameRunes } func NormalizeLocale(locale *string) (*string, error) { if locale == nil || *locale == "" { return nil, nil } if !localePattern.MatchString(*locale) { return nil, ErrInvalidLocale } return locale, nil } func isLoopback(hostname string) bool { for _, loopback := range loopbackHosts { if hostname == loopback { return true } } return false } type origin struct { scheme string host string hostname string parsed *url.URL } func parseOrigin(raw string) (origin, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Host == "" || parsed.Hostname() == "" { return origin{}, ErrInvalidSiteURL } scheme := strings.ToLower(parsed.Scheme) if scheme != "http" && scheme != "https" { return origin{}, ErrInvalidSiteURL } return origin{scheme: scheme, host: strings.ToLower(parsed.Host), hostname: strings.ToLower(parsed.Hostname()), parsed: parsed}, nil } func NormalizeBaseURL(raw string) (string, error) { found, err := parseOrigin(raw) if err != nil { return "", err } parsed := found.parsed if parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { return "", ErrInvalidSiteURL } if found.scheme == "http" && !isLoopback(found.hostname) { return "", ErrInvalidSiteURL } return found.scheme + "://" + found.host, nil } func SeedBaseURL(publicURL string) (string, error) { found, err := parseOrigin(publicURL) if err != nil { return "", err } scheme := found.scheme if scheme == "http" && !isLoopback(found.hostname) { scheme = "https" } return scheme + "://" + found.host, nil }