package author import ( "RenewCMS/internal/domain/slug" "errors" "net/url" "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" ) 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} } func BylineIDs(bylines []Byline) []uint32 { ids := make([]uint32, 0, len(bylines)) for _, byline := range bylines { ids = append(ids, byline.ID) } return ids } 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(candidate string) bool { return slug.IsValid(candidate) } 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 { return slug.Generate(name, FallbackSlug) } func SlugWithSuffix(base string, attempt int) string { return slug.WithSuffix(base, attempt) }