217 lines
5.5 KiB
Go
217 lines
5.5 KiB
Go
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
|
||
}
|