feat: Authors feature backend (and upgraded deprecated mods)

This commit is contained in:
Florian Sylvain committed 2026-10-14 17:45:00 +02:00
1 parent a11338094e
commit b34a173d83
103 files changed
+6454 -240

No files matched your search

+9 -7
View File
@@ -1,6 +1,7 @@
package article
import (
"RenewCMS/internal/domain/author"
"errors"
"time"
"unicode/utf8"
@@ -26,11 +27,12 @@ func ValidateTitle(title string) error {
}
type Article struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
Authors []author.Byline `json:"authors"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -39,8 +39,9 @@ type Filters struct {
}
type Update struct {
Body *string
SiteID *uint32
Body *string
SiteID *uint32
AuthorIDs *[]uint32
}
type Counts struct {
+13 -1
View File
@@ -33,6 +33,7 @@ const (
CategoryAuth Category = "auth"
CategorySystem Category = "system"
CategorySites Category = "sites"
CategoryAuthors Category = "authors"
)
type Outcome string
@@ -76,6 +77,9 @@ const (
ActionSiteEdit = "site.edit"
ActionSiteSetDefault = "site.set_default"
ActionSiteDelete = "site.delete"
ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
@@ -113,6 +117,7 @@ const (
TargetInvite = "invite"
TargetRelease = "release"
TargetSite = "site"
TargetAuthor = "author"
)
const (
@@ -158,6 +163,13 @@ const (
FieldLocale = "locale"
FieldSocialImage = "default_social_image"
FieldSite = "site"
FieldSlug = "slug"
FieldBio = "bio"
FieldWebsite = "website"
FieldSocialLinks = "social_links"
FieldAvatar = "avatar"
FieldUser = "user"
FieldAuthors = "authors"
maxNameLength = 255
maxActionLength = 64
maxFailureReasonSize = 255
@@ -168,7 +180,7 @@ func ActorTypes() []ActorType {
}
func Categories() []Category {
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites}
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors}
}
func Outcomes() []Outcome {
+216
View File
@@ -0,0 +1,216 @@
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
}
@@ -0,0 +1,16 @@
package author
type Repository interface {
List(query ListQuery) (Page, error)
Get(id uint32) (Author, error)
GetBySlug(slug string) (Author, error)
GetByUserID(userID uint32) (Author, error)
Create(author Author) (Author, error)
Update(author Author) (Author, error)
DeleteMovingCredits(id uint32, reassignTo *uint32) error
DeleteUncrediting(id uint32) error
AvatarUsage() (map[uint32]int, error)
ClearAvatar(imageID uint32) ([]uint32, error)
AssignAvatar(authorIDs []uint32, imageID uint32) error
RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error)
}
+1
View File
@@ -53,5 +53,6 @@ type RunResult struct {
Merged int
ArticlesUpdated int
SitesUpdated int
AuthorsUpdated int
FailedGroups int
}
@@ -7,7 +7,7 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{})
if err != nil {
return err
}
@@ -0,0 +1,59 @@
package mappers
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
const emptyLinks = "[]"
func decodeLinks(raw string) []author.SocialLink {
links := []author.SocialLink{}
if raw == "" {
return links
}
if err := json.Unmarshal([]byte(raw), &links); err != nil || links == nil {
return []author.SocialLink{}
}
return links
}
func encodeLinks(links []author.SocialLink) string {
if len(links) == 0 {
return emptyLinks
}
encoded, err := json.Marshal(links)
if err != nil {
return emptyLinks
}
return string(encoded)
}
func AuthorToDomain(m models.Author, articleCount int) author.Author {
return author.Author{
ID: m.ID,
Name: m.Name,
Slug: m.Slug,
Bio: m.Bio,
AvatarID: m.AvatarID,
Website: m.Website,
SocialLinks: decodeLinks(m.SocialLinks),
UserID: m.UserID,
IsLinked: m.UserID != nil,
ArticleCount: articleCount,
}
}
func AuthorToModel(a author.Author) models.Author {
return models.Author{
ID: a.ID,
Name: a.Name,
Slug: a.Slug,
Bio: a.Bio,
AvatarID: a.AvatarID,
Website: a.Website,
SocialLinks: encodeLinks(a.SocialLinks),
UserID: a.UserID,
}
}
@@ -0,0 +1,23 @@
package models
import "time"
type Author struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:100;not null"`
Slug string `gorm:"size:100;not null;uniqueIndex"`
Bio string `gorm:"size:280;not null"`
AvatarID *uint32 `gorm:"index"`
Website *string `gorm:"size:2048"`
SocialLinks string `gorm:"type:text;not null"`
UserID *uint32 `gorm:"uniqueIndex"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
type ArticleAuthor struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
ArticleID uint32 `gorm:"not null;uniqueIndex:idx_article_authors_pair"`
AuthorID uint32 `gorm:"not null;uniqueIndex:idx_article_authors_pair;index"`
Position int `gorm:"not null"`
}
@@ -2,6 +2,7 @@ package persistence
import (
domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
@@ -89,7 +90,11 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
if err != nil {
return domain.PageResult{}, err
}
return domain.PageResult{Items: toDomainArticles(entityArticles), Total: int(total)}, nil
items, err := a.withAuthors(a.db, toDomainArticles(entityArticles))
if err != nil {
return domain.PageResult{}, err
}
return domain.PageResult{Items: items, Total: int(total)}, nil
}
func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.PageResult, error) {
@@ -125,7 +130,11 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
ordered = append(ordered, entityArticle)
}
}
page.Items = toDomainArticles(ordered)
items, err := a.withAuthors(a.db, toDomainArticles(ordered))
if err != nil {
return domain.PageResult{}, err
}
page.Items = items
return page, nil
}
@@ -150,6 +159,77 @@ func toDomainArticles(entityArticles []entity.Article) []domain.Article {
return domainArticles
}
func (a *ArticleRepository) withAuthors(db *gorm.DB, articles []domain.Article) ([]domain.Article, error) {
ids := make([]uint32, 0, len(articles))
for _, article := range articles {
ids = append(ids, article.ID)
}
bylines, err := loadBylines(db, ids)
if err != nil {
return nil, err
}
for index := range articles {
articles[index].Authors = bylines[articles[index].ID]
if articles[index].Authors == nil {
articles[index].Authors = []authordomain.Byline{}
}
}
return articles, nil
}
func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article) (domain.Article, error) {
loaded, err := a.withAuthors(db, []domain.Article{article})
if err != nil {
return domain.Article{}, err
}
return loaded[0], nil
}
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
if len(articleIDs) == 0 {
return bylines, nil
}
var credits []entity.ArticleAuthor
if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil {
return nil, err
}
if len(credits) == 0 {
return bylines, nil
}
authorIDs := make([]uint32, 0, len(credits))
for _, credit := range credits {
authorIDs = append(authorIDs, credit.AuthorID)
}
var found []entity.Author
if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil {
return nil, err
}
byID := make(map[uint32]entity.Author, len(found))
for _, model := range found {
byID[model.ID] = model
}
for _, credit := range credits {
if model, ok := byID[credit.AuthorID]; ok {
bylines[credit.ArticleID] = append(bylines[credit.ArticleID], mappers.AuthorToDomain(model, 0).Byline())
}
}
return bylines, nil
}
func replaceCredits(tx *gorm.DB, articleID uint32, authorIDs []uint32) error {
if err := tx.Where("article_id = ?", articleID).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
for position, authorID := range authorIDs {
credit := entity.ArticleAuthor{ArticleID: articleID, AuthorID: authorID, Position: position}
if err := tx.Create(&credit).Error; err != nil {
return err
}
}
return nil
}
func foldForSearch(value string) string {
folder := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
folded, _, err := transform.String(folder, value)
@@ -166,7 +246,7 @@ func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return mappers.ArticleToDomain(article), nil
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
}
func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
@@ -176,7 +256,7 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return mappers.ArticleToDomain(article), nil
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
@@ -192,12 +272,23 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return err
}
}
return tx.Create(&created).Error
if err := tx.Create(&created).Error; err != nil {
return err
}
return replaceCredits(tx, created.ID, bylineIDs(article.Authors))
})
if err != nil {
return domain.Article{}, err
}
return mappers.ArticleToDomain(created), nil
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func bylineIDs(bylines []authordomain.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
}
return ids
}
func (a *ArticleRepository) GetAll() []domain.Article {
@@ -228,9 +319,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
return domain.Article{}, err
}
newArticle := mappers.ArticleToDomain(localArticle)
return newArticle, nil
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
@@ -250,12 +339,18 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
if update.SiteID != nil {
localArticle.SiteID = *update.SiteID
}
return tx.Save(&localArticle).Error
if err := tx.Save(&localArticle).Error; err != nil {
return err
}
if update.AuthorIDs != nil {
return replaceCredits(tx, id, *update.AuthorIDs)
}
return nil
})
if err != nil {
return domain.Article{}, err
}
return mappers.ArticleToDomain(localArticle), nil
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
@@ -271,12 +366,16 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
return domain.Article{}, err
}
return mappers.ArticleToDomain(localArticle), nil
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func (a *ArticleRepository) Delete(id uint32) error {
result := a.db.Delete(&entity.Article{}, id)
return deleteOrNotFound(result, domain.ErrArticleNotFound)
return a.db.Transaction(func(tx *gorm.DB) error {
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err
}
return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error
})
}
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
@@ -0,0 +1,357 @@
package persistence
import (
domain "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AuthorRepository struct {
db *gorm.DB
}
func NewAuthorRepository(db *gorm.DB) *AuthorRepository {
return &AuthorRepository{db}
}
type creditTotal struct {
AuthorID uint32
Total int
}
func creditCounts(db *gorm.DB, authorIDs []uint32) (map[uint32]int, error) {
counts := make(map[uint32]int, len(authorIDs))
if len(authorIDs) == 0 {
return counts, nil
}
var totals []creditTotal
err := db.Table("article_authors").
Select("article_authors.author_id AS author_id, COUNT(*) AS total").
Joins("JOIN articles ON articles.id = article_authors.article_id").
Where("articles.deleted_at IS NULL").
Where("article_authors.author_id IN ?", authorIDs).
Group("article_authors.author_id").
Scan(&totals).Error
if err != nil {
return nil, err
}
for _, total := range totals {
counts[total.AuthorID] = total.Total
}
return counts, nil
}
func (a *AuthorRepository) toDomain(db *gorm.DB, models []entity.Author) ([]domain.Author, error) {
ids := make([]uint32, 0, len(models))
for _, model := range models {
ids = append(ids, model.ID)
}
counts, err := creditCounts(db, ids)
if err != nil {
return nil, err
}
authors := make([]domain.Author, 0, len(models))
for _, model := range models {
authors = append(authors, mappers.AuthorToDomain(model, counts[model.ID]))
}
return authors, nil
}
func (a *AuthorRepository) loadOne(db *gorm.DB, model entity.Author) (domain.Author, error) {
authors, err := a.toDomain(db, []entity.Author{model})
if err != nil {
return domain.Author{}, err
}
return authors[0], nil
}
func (a *AuthorRepository) List(query domain.ListQuery) (domain.Page, error) {
query.Page = max(query.Page, 1)
query.Size = max(query.Size, 0)
needle := strings.TrimSpace(query.Q)
if needle != "" {
return a.findMatching(query, foldForSearch(needle))
}
var total int64
if err := a.db.Model(&entity.Author{}).Count(&total).Error; err != nil {
return domain.Page{}, err
}
var found []entity.Author
err := a.db.Order("id DESC").Limit(query.Size).Offset(pageStart(query.Page, query.Size, int(total))).Find(&found).Error
if err != nil {
return domain.Page{}, err
}
items, err := a.toDomain(a.db, found)
if err != nil {
return domain.Page{}, err
}
return domain.Page{Items: items, Total: int(total)}, nil
}
func pageStart(page int, size int, total int) int {
if size <= 0 || page-1 > total/size {
return total
}
return min((page-1)*size, total)
}
func (a *AuthorRepository) findMatching(query domain.ListQuery, needle string) (domain.Page, error) {
var candidates []entity.Author
if err := a.db.Select("id", "name", "slug").Order("id DESC").Find(&candidates).Error; err != nil {
return domain.Page{}, err
}
matching := make([]uint32, 0)
for _, candidate := range candidates {
if strings.Contains(foldForSearch(candidate.Name), needle) || strings.Contains(foldForSearch(candidate.Slug), needle) {
matching = append(matching, candidate.ID)
}
}
start := pageStart(query.Page, query.Size, len(matching))
end := min(start+query.Size, len(matching))
page := domain.Page{Items: []domain.Author{}, Total: len(matching)}
if start == end {
return page, nil
}
var found []entity.Author
if err := a.db.Where("id IN ?", matching[start:end]).Order("id DESC").Find(&found).Error; err != nil {
return domain.Page{}, err
}
items, err := a.toDomain(a.db, found)
if err != nil {
return domain.Page{}, err
}
page.Items = items
return page, nil
}
func (a *AuthorRepository) Get(id uint32) (domain.Author, error) {
var model entity.Author
if err := a.db.First(&model, id).Error; err != nil {
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return a.loadOne(a.db, model)
}
func (a *AuthorRepository) GetBySlug(slug string) (domain.Author, error) {
var model entity.Author
if err := a.db.Where("slug = ?", slug).First(&model).Error; err != nil {
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return a.loadOne(a.db, model)
}
func (a *AuthorRepository) GetByUserID(userID uint32) (domain.Author, error) {
var model entity.Author
if err := a.db.Where("user_id = ?", userID).First(&model).Error; err != nil {
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return a.loadOne(a.db, model)
}
func slugInUse(tx *gorm.DB, slug string, exceptID uint32) (bool, error) {
var total int64
err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", slug, exceptID).Count(&total).Error
return total > 0, err
}
func userInUse(tx *gorm.DB, userID *uint32, exceptID uint32) (bool, error) {
if userID == nil {
return false, nil
}
var total int64
err := tx.Model(&entity.Author{}).Where("user_id = ? AND id <> ?", *userID, exceptID).Count(&total).Error
return total > 0, err
}
func ensureUnique(tx *gorm.DB, author domain.Author) error {
taken, err := slugInUse(tx, author.Slug, author.ID)
if err != nil {
return err
}
if taken {
return domain.ErrSlugTaken
}
linked, err := userInUse(tx, author.UserID, author.ID)
if err != nil {
return err
}
if linked {
return domain.ErrUserAlreadyLinked
}
return nil
}
func (a *AuthorRepository) explainWriteFailure(author domain.Author, cause error) error {
if err := ensureUnique(a.db, author); err != nil {
return err
}
return cause
}
func (a *AuthorRepository) Create(author domain.Author) (domain.Author, error) {
model := mappers.AuthorToModel(author)
model.ID = 0
author.ID = 0
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := ensureUnique(tx, author); err != nil {
return err
}
return tx.Create(&model).Error
})
if err != nil {
return domain.Author{}, a.explainWriteFailure(author, err)
}
return mappers.AuthorToDomain(model, 0), nil
}
func (a *AuthorRepository) Update(author domain.Author) (domain.Author, error) {
changes := map[string]any{
"name": author.Name,
"slug": author.Slug,
"bio": author.Bio,
"avatar_id": author.AvatarID,
"website": author.Website,
"social_links": mappers.AuthorToModel(author).SocialLinks,
"user_id": author.UserID,
}
err := a.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockAuthor(tx, author.ID); err != nil {
return err
}
if err := ensureUnique(tx, author); err != nil {
return err
}
return tx.Model(&entity.Author{}).Where("id = ?", author.ID).Updates(changes).Error
})
if err != nil {
return domain.Author{}, a.explainWriteFailure(author, err)
}
return a.Get(author.ID)
}
func lockAuthor(tx *gorm.DB, id uint32) (entity.Author, error) {
var locked entity.Author
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
return entity.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return locked, nil
}
func (a *AuthorRepository) DeleteMovingCredits(id uint32, reassignTo *uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockAuthor(tx, id); err != nil {
return err
}
if reassignTo == nil {
credited, err := hasCredits(tx, id)
if err != nil {
return err
}
if credited {
return domain.ErrAuthorHasArticles
}
return removeAuthor(tx, id)
}
if _, err := lockAuthor(tx, *reassignTo); err != nil {
return err
}
if err := moveCredits(tx, id, *reassignTo); err != nil {
return err
}
return removeAuthor(tx, id)
})
}
func (a *AuthorRepository) DeleteUncrediting(id uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockAuthor(tx, id); err != nil {
return err
}
return removeAuthor(tx, id)
})
}
func hasCredits(tx *gorm.DB, id uint32) (bool, error) {
var total int64
err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", id).Count(&total).Error
return total > 0, err
}
func removeAuthor(tx *gorm.DB, id uint32) error {
if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
return deleteOrNotFound(tx.Delete(&entity.Author{}, id), domain.ErrAuthorNotFound)
}
func moveCredits(tx *gorm.DB, from uint32, to uint32) error {
var alreadyCredited []uint32
if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", to).Pluck("article_id", &alreadyCredited).Error; err != nil {
return err
}
if len(alreadyCredited) > 0 {
if err := tx.Where("author_id = ? AND article_id IN ?", from, alreadyCredited).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
}
return tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", from).UpdateColumn("author_id", to).Error
}
type avatarTotal struct {
AvatarID uint32
Total int
}
func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) {
var totals []avatarTotal
err := a.db.Model(&entity.Author{}).Select("avatar_id, COUNT(*) AS total").Where("avatar_id IS NOT NULL").Group("avatar_id").Scan(&totals).Error
if err != nil {
return nil, err
}
usage := make(map[uint32]int, len(totals))
for _, total := range totals {
usage[total.AvatarID] = total.Total
}
return usage, nil
}
func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) {
ids := []uint32{}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.Author{}).Where("avatar_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
return err
}
if len(ids) == 0 {
return nil
}
return tx.Model(&entity.Author{}).Where("id IN ?", ids).UpdateColumn("avatar_id", nil).Error
})
if err != nil {
return nil, err
}
return ids, nil
}
func (a *AuthorRepository) AssignAvatar(authorIDs []uint32, imageID uint32) error {
if len(authorIDs) == 0 {
return nil
}
return a.db.Model(&entity.Author{}).Where("id IN ?", authorIDs).UpdateColumn("avatar_id", imageID).Error
}
func (a *AuthorRepository) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
if len(fromImageIDs) == 0 {
return 0, nil
}
result := a.db.Model(&entity.Author{}).Where("avatar_id IN ?", fromImageIDs).UpdateColumn("avatar_id", toImageID)
if result.Error != nil {
return 0, result.Error
}
return int(result.RowsAffected), nil
}
var _ domain.Repository = &AuthorRepository{}
@@ -3,8 +3,10 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"context"
"errors"
)
type CreateArticleUseCase struct {
@@ -12,16 +14,18 @@ type CreateArticleUseCase struct {
bodyRenderer article.BodyRenderer
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
type CreateArticleCommand struct {
Title string
Body string
Format article.BodyFormat
Title string
Body string
Format article.BodyFormat
CreatedBy uint32
}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
}
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
@@ -32,6 +36,17 @@ func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
return defaultSite.ID, nil
}
func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, error) {
found, err := g.authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return []author.Byline{}, nil
}
if err != nil {
return nil, err
}
return []author.Byline{found.Byline()}, nil
}
func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
event := audit.NewEvent(audit.ActionArticleCreate, audit.CategoryArticles)
if err := article.ValidateTitle(createArticle.Title); err != nil {
@@ -45,11 +60,18 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err
}
creditedAuthors, err := g.creatorBylines(createArticle.CreatedBy)
if err != nil {
recordFailure(ctx, g.recorder, event, err)
return article.Article{}, err
}
created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
Authors: creditedAuthors,
})
if err != nil {
recordFailure(ctx, g.recorder, event, err)
@@ -0,0 +1,106 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"context"
"strings"
)
type CreateAuthorUseCase struct {
authors author.Repository
users user.Repository
images image.Repository
recorder audit.Recorder
}
type CreateAuthorCommand struct {
Name string
Slug string
Bio string
AvatarID *uint32
Website *string
SocialLinks []author.SocialLink
UserID *uint32
}
func NewCreateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *CreateAuthorUseCase {
return &CreateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder}
}
func (u *CreateAuthorUseCase) Create(ctx context.Context, command CreateAuthorCommand) (author.Author, error) {
command.Name = strings.TrimSpace(command.Name)
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
event.Target = audit.Target{Type: audit.TargetAuthor, Name: command.Name}
created, err := u.create(command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
event.Target = authorTarget(created)
u.recorder.Record(ctx, event)
return withUserEmail(u.users, created), nil
}
func (u *CreateAuthorUseCase) create(command CreateAuthorCommand) (author.Author, error) {
website, err := validateAuthorContent(command.Bio, command.Website, command.SocialLinks)
if err != nil {
return author.Author{}, err
}
if err := ensureImageExists(u.images, command.AvatarID); err != nil {
return author.Author{}, err
}
if command.UserID != nil {
if err := ensureUserCanBeLinked(u.users, u.authors, *command.UserID); err != nil {
return author.Author{}, err
}
}
slug, err := u.slugFor(command)
if err != nil {
return author.Author{}, err
}
links := command.SocialLinks
if links == nil {
links = []author.SocialLink{}
}
return u.authors.Create(author.Author{
Name: command.Name,
Slug: slug,
Bio: command.Bio,
AvatarID: command.AvatarID,
Website: website,
SocialLinks: links,
UserID: command.UserID,
IsLinked: command.UserID != nil,
})
}
func (u *CreateAuthorUseCase) slugFor(command CreateAuthorCommand) (string, error) {
slug := command.Slug
if slug == "" {
slug = author.GenerateSlug(command.Name)
}
if !author.IsValidSlug(slug) {
return "", author.ErrInvalidSlug
}
taken, err := isSlugTaken(u.authors, slug)
if err != nil {
return "", err
}
if taken {
return "", author.ErrSlugTaken
}
return slug, nil
}
func validateAuthorContent(bio string, website *string, links []author.SocialLink) (*string, error) {
if err := author.ValidateBio(bio); err != nil {
return nil, err
}
if err := author.ValidateLinks(links); err != nil {
return nil, err
}
return author.NormalizeWebsite(website)
}
@@ -0,0 +1,80 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/user"
"context"
"errors"
"strings"
)
const maxOwnSlugAttempts = 50
type CreateMyAuthorUseCase struct {
authors author.Repository
users user.Repository
recorder audit.Recorder
}
func NewCreateMyAuthorUseCase(authors author.Repository, users user.Repository, recorder audit.Recorder) *CreateMyAuthorUseCase {
return &CreateMyAuthorUseCase{authors: authors, users: users, recorder: recorder}
}
func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (author.Author, error) {
caller, err := u.users.Get(userID)
if err != nil {
return author.Author{}, err
}
name := authorNameOf(caller)
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
event.Target = audit.Target{Type: audit.TargetAuthor, Name: name}
created, err := u.create(caller, name)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
event.Target = authorTarget(created)
u.recorder.Record(ctx, event)
return created, nil
}
func authorNameOf(caller user.User) string {
name := strings.TrimSpace(caller.DisplayName)
if name == "" {
name = strings.TrimSpace(caller.Username)
}
runes := []rune(name)
return string(runes[:min(len(runes), author.MaxNameRunes)])
}
func (u *CreateMyAuthorUseCase) create(caller user.User, name string) (author.Author, error) {
_, err := u.authors.GetByUserID(caller.ID)
if err == nil {
return author.Author{}, author.ErrUserAlreadyLinked
}
if !errors.Is(err, author.ErrAuthorNotFound) {
return author.Author{}, err
}
return u.createWithFreeSlug(caller.ID, name)
}
func (u *CreateMyAuthorUseCase) createWithFreeSlug(userID uint32, name string) (author.Author, error) {
base := author.GenerateSlug(name)
for attempt := 1; attempt <= maxOwnSlugAttempts; attempt++ {
slug := author.SlugWithSuffix(base, attempt)
taken, err := isSlugTaken(u.authors, slug)
if err != nil {
return author.Author{}, err
}
if taken {
continue
}
created, err := u.authors.Create(author.Author{Name: name, Slug: slug, SocialLinks: []author.SocialLink{}, UserID: &userID, IsLinked: true})
if errors.Is(err, author.ErrSlugTaken) {
continue
}
return created, err
}
return author.Author{}, author.ErrSlugTaken
}
@@ -0,0 +1,76 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"context"
)
type ReassignMode int
const (
ReassignAbsent ReassignMode = iota
ReassignNone
ReassignToAuthor
ReassignMalformed
)
type Reassignment struct {
Mode ReassignMode
Destination uint32
}
type DeleteAuthorUseCase struct {
authors author.Repository
recorder audit.Recorder
}
func NewDeleteAuthorUseCase(authors author.Repository, recorder audit.Recorder) *DeleteAuthorUseCase {
return &DeleteAuthorUseCase{authors: authors, recorder: recorder}
}
func (u *DeleteAuthorUseCase) Delete(ctx context.Context, id uint32, reassignment Reassignment) error {
target, err := u.authors.Get(id)
if err != nil {
return err
}
event := audit.NewEvent(audit.ActionAuthorDelete, audit.CategoryAuthors)
event.Target = authorTarget(target)
if err := u.delete(target, reassignment); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
func (u *DeleteAuthorUseCase) delete(target author.Author, reassignment Reassignment) error {
if reassignment.Mode == ReassignNone {
return u.authors.DeleteUncrediting(target.ID)
}
if target.ArticleCount == 0 {
return u.authors.DeleteMovingCredits(target.ID, nil)
}
destination, err := u.destination(target, reassignment)
if err != nil {
return err
}
return u.authors.DeleteMovingCredits(target.ID, &destination)
}
func (u *DeleteAuthorUseCase) destination(target author.Author, reassignment Reassignment) (uint32, error) {
switch reassignment.Mode {
case ReassignAbsent:
return 0, author.ErrAuthorHasArticles
case ReassignMalformed:
return 0, author.ErrInvalidReassign
}
if reassignment.Destination == 0 || reassignment.Destination == target.ID {
return 0, author.ErrInvalidReassign
}
if _, err := u.authors.Get(reassignment.Destination); err != nil {
return 0, err
}
return reassignment.Destination, nil
}
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
@@ -12,10 +13,11 @@ type DeleteMediaUseCase struct {
remover mediaRemover
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
}
func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
@@ -23,8 +25,14 @@ func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
if err != nil {
return err
}
clearedAuthors, err := u.authors.ClearAvatar(img.ID)
if err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
return err
}
if err := u.remover.remove(img); err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
_ = u.authors.AssignAvatar(clearedAuthors, img.ID)
return err
}
return nil
@@ -0,0 +1,25 @@
package useCases
import (
"RenewCMS/internal/domain/author"
"errors"
)
type GetMyAuthorUseCase struct {
authors author.Repository
}
func NewGetMyAuthorUseCase(authors author.Repository) *GetMyAuthorUseCase {
return &GetMyAuthorUseCase{authors: authors}
}
func (u *GetMyAuthorUseCase) Get(userID uint32) (*author.Author, error) {
found, err := u.authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &found, nil
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
@@ -20,6 +21,7 @@ type ListActivityUseCase struct {
apiKeys apikey.Repository
now func() time.Time
sites site.Repository
authors author.Repository
}
type ListActivityCommand struct {
@@ -39,8 +41,8 @@ type ActivityPage struct {
Size int
}
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository) *ListActivityUseCase {
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites}
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository, authors author.Repository) *ListActivityUseCase {
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites, authors: authors}
}
func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
@@ -130,6 +132,9 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
case audit.TargetSite:
_, err = l.sites.Get(uint32(id))
return existsUnless(err, site.ErrSiteNotFound)
case audit.TargetAuthor:
_, err = l.authors.Get(uint32(id))
return existsUnless(err, author.ErrAuthorNotFound)
default:
return true, nil
}
@@ -0,0 +1,44 @@
package useCases
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/user"
)
type ListAuthorsUseCase struct {
authors author.Repository
users user.Repository
}
type ListAuthorsCommand struct {
Q string
Page int
Size int
CallerRole user.Role
}
type AuthorPage struct {
Items []author.Author
Total int
Page int
Size int
}
func NewListAuthorsUseCase(authors author.Repository, users user.Repository) *ListAuthorsUseCase {
return &ListAuthorsUseCase{authors: authors, users: users}
}
func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error) {
found, err := u.authors.List(author.ListQuery{Q: command.Q, Page: command.Page, Size: command.Size})
if err != nil {
return AuthorPage{}, err
}
items := make([]author.Author, 0, len(found.Items))
for _, item := range found.Items {
if isAdminRole(command.CallerRole) {
item = withUserEmail(u.users, item)
}
items = append(items, item)
}
return AuthorPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
}
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
@@ -38,10 +39,11 @@ type ListMediaUseCase struct {
userRepository user.Repository
variantFiles image.VariantFileRepository
sites site.Repository
authors author.Repository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository, authors author.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites, authors: authors}
}
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
@@ -108,7 +110,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
if err != nil {
return MediaItem{}, err
}
siteUsage, err := siteImageUsage(l.sites)
siteUsage, err := imageUsageCount(l.sites, l.authors)
if err != nil {
return MediaItem{}, err
}
@@ -143,7 +145,7 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
if err != nil {
return nil, err
}
siteUsage, err := siteImageUsage(l.sites)
siteUsage, err := imageUsageCount(l.sites, l.authors)
if err != nil {
return nil, err
}
+22 -5
View File
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"cmp"
@@ -16,6 +17,7 @@ type MergeMediaResult struct {
ArticlesUpdated int
Merged int
SitesUpdated int
AuthorsUpdated int
UpdatedArticleIDs []uint32
}
@@ -25,18 +27,27 @@ type MergeMediaUseCase struct {
remover mediaRemover
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
}
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
func copyIdsOf(copies []image.Image) []uint32 {
copyIds := make([]uint32, 0, len(copies))
for _, duplicate := range copies {
copyIds = append(copyIds, duplicate.ID)
}
return u.sites.RepointImage(copyIds, keptId)
return copyIds
}
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
return u.sites.RepointImage(copyIdsOf(copies), keptId)
}
func (u *MergeMediaUseCase) repointAuthors(copies []image.Image, keptId uint32) (int, error) {
return u.authors.RepointAvatar(copyIdsOf(copies), keptId)
}
type bodyRewrite struct {
@@ -95,13 +106,19 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
return MergeMediaResult{}, err
}
authorsUpdated, err := u.repointAuthors(copies, kept.ID)
if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err
}
for _, duplicate := range copies {
if err := u.remover.remove(duplicate); err != nil {
return MergeMediaResult{}, err
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
}
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
)
@@ -29,8 +30,8 @@ type PreviewPurgeUseCase struct {
planner *purgePlanner
}
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository) *PreviewPurgeUseCase {
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}}
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *PreviewPurgeUseCase {
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}}
}
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
+4 -2
View File
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/site"
@@ -23,9 +24,9 @@ type RunPurgeUseCase struct {
guard sync.Mutex
}
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository) *RunPurgeUseCase {
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository, authors author.Repository) *RunPurgeUseCase {
return &RunPurgeUseCase{
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites},
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors},
merge: merge,
repository: repository,
now: now,
@@ -88,6 +89,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
result.GroupsPurged++
result.Merged += merged.Merged
result.SitesUpdated += merged.SitesUpdated
result.AuthorsUpdated += merged.AuthorsUpdated
for _, id := range merged.UpdatedArticleIDs {
updated[id] = true
}
@@ -3,9 +3,12 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
"context"
"slices"
"strconv"
"strings"
)
type UpdateArticleUseCase struct {
@@ -13,16 +16,18 @@ type UpdateArticleUseCase struct {
bodyRenderer article.BodyRenderer
recorder audit.Recorder
sites site.Repository
authors author.Repository
}
type UpdateArticleCommand struct {
Body *string
SiteID *uint32
Format article.BodyFormat
Body *string
SiteID *uint32
AuthorIDs *[]uint32
Format article.BodyFormat
}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
}
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
@@ -38,7 +43,10 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
if err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID})
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID, AuthorIDs: command.AuthorIDs})
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
@@ -58,11 +66,51 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s
return &destination, nil
}
func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
if ids == nil {
return nil
}
for _, id := range *ids {
if _, err := g.authors.Get(id); err != nil {
return err
}
}
return nil
}
func authorsChange(before article.Article, after article.Article) (audit.Change, bool) {
beforeIDs := bylineIDs(before.Authors)
afterIDs := bylineIDs(after.Authors)
if slices.Equal(beforeIDs, afterIDs) {
return audit.Change{}, false
}
return audit.TextChange(audit.FieldAuthors, bylineNames(before.Authors), bylineNames(after.Authors)), true
}
func bylineIDs(bylines []author.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
}
return ids
}
func bylineNames(bylines []author.Byline) string {
names := make([]string, 0, len(bylines))
for _, byline := range bylines {
names = append(names, byline.Name)
}
return strings.Join(names, authorChangeSeparator)
}
func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) {
changes := audit.ArticleChanges(before, after)
if destination != nil && before.SiteID != after.SiteID {
changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name))
}
if change, changed := authorsChange(before, after); changed {
changes = append(changes, change)
}
if len(changes) == 0 {
return
}
@@ -0,0 +1,152 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"context"
"strings"
)
type UpdateAuthorUseCase struct {
authors author.Repository
users user.Repository
images image.Repository
recorder audit.Recorder
}
func NewUpdateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *UpdateAuthorUseCase {
return &UpdateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder}
}
func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller AuthorCaller, patch author.Patch) (author.Author, error) {
before, err := u.authors.Get(id)
if err != nil {
return author.Author{}, err
}
event := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
event.Target = authorTarget(before)
after, err := u.prepare(before, caller, patch)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
changes := authorChanges(u.users, u.images, before, after)
if len(changes) == 0 {
return u.present(before, caller), nil
}
saved, err := u.authors.Update(after)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return author.Author{}, err
}
event.Target = authorTarget(saved)
event.Changes = changes
u.recorder.Record(ctx, event)
return u.present(saved, caller), nil
}
func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author {
if caller.isAdmin() {
return withUserEmail(u.users, a)
}
a.UserEmail = nil
return a
}
func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller, patch author.Patch) (author.Author, error) {
if err := authorizeEdit(before, caller, patch); err != nil {
return author.Author{}, err
}
after := before
if patch.Name != nil {
after.Name = strings.TrimSpace(*patch.Name)
}
if patch.Bio != nil {
after.Bio = *patch.Bio
}
if patch.Website.Set {
after.Website = patch.Website.Value
}
if patch.SocialLinks != nil {
after.SocialLinks = *patch.SocialLinks
}
website, err := validateAuthorContent(after.Bio, after.Website, after.SocialLinks)
if err != nil {
return author.Author{}, err
}
after.Website = website
if err := u.applySlug(&after, patch); err != nil {
return author.Author{}, err
}
if err := u.applyAvatar(&after, patch); err != nil {
return author.Author{}, err
}
return after, u.applyLink(&after, patch)
}
func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
if caller.isAdmin() {
return nil
}
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
return author.ErrAuthorRights
}
return nil
}
func (u *UpdateAuthorUseCase) applySlug(after *author.Author, patch author.Patch) error {
if patch.Slug == nil || *patch.Slug == after.Slug {
return nil
}
if !author.IsValidSlug(*patch.Slug) {
return author.ErrInvalidSlug
}
taken, err := isSlugTaken(u.authors, *patch.Slug)
if err != nil {
return err
}
if taken {
return author.ErrSlugTaken
}
after.Slug = *patch.Slug
return nil
}
func (u *UpdateAuthorUseCase) applyAvatar(after *author.Author, patch author.Patch) error {
if !patch.AvatarID.Set {
return nil
}
if err := ensureImageExists(u.images, patch.AvatarID.Value); err != nil {
return err
}
after.AvatarID = patch.AvatarID.Value
return nil
}
func (u *UpdateAuthorUseCase) applyLink(after *author.Author, patch author.Patch) error {
if !patch.UserID.Set {
return nil
}
requested := patch.UserID.Value
if requested == nil {
after.UserID = nil
after.IsLinked = false
return nil
}
if after.UserID != nil {
if *after.UserID == *requested {
return nil
}
return author.ErrAuthorAlreadyLinked
}
if err := ensureUserCanBeLinked(u.users, u.authors, *requested); err != nil {
return err
}
after.UserID = requested
after.IsLinked = true
return nil
}
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/purge"
@@ -64,6 +65,12 @@ var failureReasons = []failureReason{
{site.ErrInvalidLocale, "The locale is not valid."},
{site.ErrUseSetDefault, "Use the default site action to change the default."},
{site.ErrInvalidReassign, "The destination site is not valid."},
{author.ErrSlugTaken, "That slug is already used by another author."},
{author.ErrUserAlreadyLinked, "That user already has an author."},
{author.ErrAuthorAlreadyLinked, "That author is already linked to a user."},
{author.ErrAuthorRights, "Only an owner or admin can change this author."},
{author.ErrAuthorHasArticles, "The author is still credited on articles."},
{author.ErrInvalidReassign, "The destination author is not valid."},
}
var restartFailureReasons = []failureReason{
@@ -82,6 +89,11 @@ var notFoundCauses = []error{
invite.ErrInviteNotFound,
user.ErrUserNotFound,
site.ErrSiteNotFound,
author.ErrAuthorNotFound,
author.ErrBioTooLong,
author.ErrInvalidURL,
author.ErrTooManyLinks,
author.ErrInvalidSlug,
}
func reasonFor(err error, overrides ...failureReason) string {
@@ -0,0 +1,115 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"errors"
"strconv"
"strings"
)
const authorChangeSeparator = ", "
type AuthorCaller struct {
UserID uint32
Role user.Role
}
func isAdminRole(role user.Role) bool {
return role == user.RoleOwner || role == user.RoleAdmin
}
func (c AuthorCaller) isAdmin() bool {
return isAdminRole(c.Role)
}
func authorTarget(a author.Author) audit.Target {
return audit.NewTarget(audit.TargetAuthor, a.ID, a.Name)
}
func isSlugTaken(authors author.Repository, slug string) (bool, error) {
_, err := authors.GetBySlug(slug)
if errors.Is(err, author.ErrAuthorNotFound) {
return false, nil
}
return err == nil, err
}
func ensureUserCanBeLinked(users user.Repository, authors author.Repository, userID uint32) error {
if _, err := users.Get(userID); err != nil {
return err
}
_, err := authors.GetByUserID(userID)
if err == nil {
return author.ErrUserAlreadyLinked
}
if errors.Is(err, author.ErrAuthorNotFound) {
return nil
}
return err
}
func withUserEmail(users user.Repository, a author.Author) author.Author {
a.UserEmail = nil
if a.UserID == nil {
return a
}
linked, err := users.Get(*a.UserID)
if err != nil {
return a
}
email := linked.Email
a.UserEmail = &email
return a
}
func linksLabel(links []author.SocialLink) string {
labels := make([]string, 0, len(links))
for _, link := range links {
labels = append(labels, link.Type+": "+link.URL)
}
return strings.Join(labels, authorChangeSeparator)
}
func websiteLabel(website *string) string {
if website == nil {
return ""
}
return *website
}
func usernameLabel(users user.Repository, id *uint32) string {
if id == nil {
return ""
}
found, err := users.Get(*id)
if err != nil {
return strconv.FormatUint(uint64(*id), 10)
}
return found.Username
}
func sameID(a *uint32, b *uint32) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func authorChanges(users user.Repository, images image.Repository, before author.Author, after author.Author) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
changes = appendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
changes = appendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
changes = appendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
if !sameID(before.AvatarID, after.AvatarID) {
changes = append(changes, audit.TextChange(audit.FieldAvatar, imageLabel(images, before.AvatarID), imageLabel(images, after.AvatarID)))
}
if !sameID(before.UserID, after.UserID) {
changes = append(changes, audit.TextChange(audit.FieldUser, usernameLabel(users, before.UserID), usernameLabel(users, after.UserID)))
}
return changes
}
@@ -2,6 +2,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"crypto/sha256"
@@ -28,6 +29,7 @@ type purgePlanner struct {
imageRepository image.Repository
articleRepository article.Repository
sites site.Repository
authors author.Repository
}
func (p *purgePlanner) plan() (purgePlan, error) {
@@ -40,7 +42,7 @@ func (p *purgePlanner) plan() (purgePlan, error) {
return purgePlan{}, err
}
siteUsage, err := siteImageUsage(p.sites)
siteUsage, err := imageUsageCount(p.sites, p.authors)
if err != nil {
return purgePlan{}, err
}
@@ -1,6 +1,9 @@
package useCases
import "RenewCMS/internal/domain/site"
import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/site"
)
func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
usage := map[uint32]int{}
@@ -15,3 +18,18 @@ func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
}
return usage, nil
}
func imageUsageCount(sites site.Repository, authors author.Repository) (map[uint32]int, error) {
usage, err := siteImageUsage(sites)
if err != nil {
return nil, err
}
avatars, err := authors.AvatarUsage()
if err != nil {
return nil, err
}
for imageID, total := range avatars {
usage[imageID] += total
}
return usage, nil
}