package author import ( "errors" "net/url" "regexp" "strconv" "strings" "unicode/utf8" ) var ( ErrAuthorNotFound = errors.New("author not found") ErrSlugTaken = errors.New("an author with that slug already exists") ErrAuthorHasArticles = errors.New("the author is still credited on articles") ErrUserAlreadyLinked = errors.New("the user already has an author") ErrAuthorAlreadyLinked = errors.New("the author is already linked to a user") ErrAuthorRights = errors.New("only an owner or admin can change this author") ErrBioTooLong = errors.New("the bio is too long") ErrInvalidURL = errors.New("invalid url") ErrTooManyLinks = errors.New("too many social links") ErrInvalidSlug = errors.New("invalid slug") ErrInvalidReassign = errors.New("invalid reassign destination") ErrInvalidSocialType = errors.New("invalid social link type") ) const ( MinNameRunes = 1 MaxNameRunes = 100 MaxSlugRunes = 100 MaxBioRunes = 280 MaxURLLength = 2048 MaxLinks = 5 FallbackSlug = "author" SocialX = "x" SocialLinkedIn = "linkedin" SocialGitHub = "github" SocialMastodon = "mastodon" SocialCustom = "custom" ) var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) var nonSlugRun = regexp.MustCompile(`[^a-z0-9]+`) var diacriticFolder = strings.NewReplacer( "à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a", "æ", "ae", "ç", "c", "ć", "c", "č", "c", "ĉ", "c", "ď", "d", "đ", "d", "è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e", "ğ", "g", "ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i", "ł", "l", "ľ", "l", "ñ", "n", "ń", "n", "ň", "n", "ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o", "œ", "oe", "ř", "r", "ś", "s", "š", "s", "ş", "s", "ș", "s", "ß", "ss", "ť", "t", "ț", "t", "ţ", "t", "ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u", "ý", "y", "ÿ", "y", "ź", "z", "ż", "z", "ž", "z", ) type SocialLink struct { Type string `json:"type"` URL string `json:"url"` } type Author struct { ID uint32 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Bio string `json:"bio"` AvatarID *uint32 `json:"avatar_id"` Website *string `json:"website"` SocialLinks []SocialLink `json:"social_links"` UserID *uint32 `json:"user_id"` IsLinked bool `json:"is_linked"` ArticleCount int `json:"article_count"` UserEmail *string `json:"user_email,omitempty"` } type Byline struct { ID uint32 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Bio string `json:"bio"` AvatarID *uint32 `json:"avatar_id"` Website *string `json:"website"` SocialLinks []SocialLink `json:"social_links"` } func (a Author) Byline() Byline { links := a.SocialLinks if links == nil { links = []SocialLink{} } return Byline{ID: a.ID, Name: a.Name, Slug: a.Slug, Bio: a.Bio, AvatarID: a.AvatarID, Website: a.Website, SocialLinks: links} } type Nullable[T any] struct { Set bool Value *T } type Patch struct { Name *string Slug *string Bio *string AvatarID Nullable[uint32] Website Nullable[string] SocialLinks *[]SocialLink UserID Nullable[uint32] } type ListQuery struct { Q string Page int Size int } type Page struct { Items []Author Total int } func IsValidSlug(slug string) bool { length := utf8.RuneCountInString(slug) return length >= 1 && length <= MaxSlugRunes && slugPattern.MatchString(slug) } func IsValidName(name string) bool { length := utf8.RuneCountInString(strings.TrimSpace(name)) return length >= MinNameRunes && length <= MaxNameRunes } func ValidateBio(bio string) error { if utf8.RuneCountInString(bio) > MaxBioRunes { return ErrBioTooLong } return nil } func ValidateURL(raw string) error { if raw == "" || len(raw) > MaxURLLength { return ErrInvalidURL } parsed, err := url.Parse(raw) if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { return ErrInvalidURL } return nil } func NormalizeWebsite(website *string) (*string, error) { if website == nil || *website == "" { return nil, nil } if err := ValidateURL(*website); err != nil { return nil, err } return website, nil } func IsValidSocialType(linkType string) bool { switch linkType { case SocialX, SocialLinkedIn, SocialGitHub, SocialMastodon, SocialCustom: return true } return false } func ValidateLinks(links []SocialLink) error { if len(links) > MaxLinks { return ErrTooManyLinks } for _, link := range links { if !IsValidSocialType(link.Type) { return ErrInvalidSocialType } if err := ValidateURL(link.URL); err != nil { return err } } return nil } func GenerateSlug(name string) string { folded := diacriticFolder.Replace(strings.ToLower(name)) slug := strings.Trim(nonSlugRun.ReplaceAllString(folded, "-"), "-") slug = truncateSlug(slug, MaxSlugRunes) if slug == "" { return FallbackSlug } return slug } func truncateSlug(slug string, limit int) string { if len(slug) <= limit { return slug } return strings.TrimRight(slug[:limit], "-") } func SlugWithSuffix(base string, attempt int) string { if attempt <= 1 { return base } suffix := "-" + strconv.Itoa(attempt) return truncateSlug(base, MaxSlugRunes-len(suffix)) + suffix }