feat: Sites feature backend

This commit is contained in:
Florian Sylvain committed 2026-10-14 03:56:50 +02:00
1 parent 45fcbd890d
commit a11338094e
98 files changed
+5365 -214

No files matched your search

@@ -6,15 +6,18 @@ import (
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) 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{})
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{})
if err != nil {
return err
}
if err := ensureImagePathIndex(db); err != nil {
return err
}
return BackfillUserRoles(db)
if err := BackfillUserRoles(db); err != nil {
return err
}
return SeedDefaultSite(db, publicURL)
}
const (
@@ -0,0 +1,52 @@
package database
import (
domainSite "RenewCMS/internal/domain/site"
entity "RenewCMS/internal/infrastructure/persistence/models"
"fmt"
"gorm.io/gorm"
)
func SeedDefaultSite(db *gorm.DB, publicURL string) error {
return db.Transaction(func(tx *gorm.DB) error {
defaultSite, err := ensureDefaultSite(tx, publicURL)
if err != nil {
return err
}
return tx.Unscoped().Model(&entity.Article{}).Where("site_id IS NULL OR site_id = 0").UpdateColumn("site_id", defaultSite.ID).Error
})
}
func ensureDefaultSite(tx *gorm.DB, publicURL string) (entity.Site, error) {
var total int64
if err := tx.Model(&entity.Site{}).Count(&total).Error; err != nil {
return entity.Site{}, err
}
if total > 0 {
return firstDefaultSite(tx)
}
baseURL, err := domainSite.SeedBaseURL(publicURL)
if err != nil {
return entity.Site{}, fmt.Errorf("PUBLIC_URL %q cannot seed the first site: %w", publicURL, err)
}
seeded := entity.Site{
Name: domainSite.DefaultName,
Key: domainSite.DefaultKey,
BaseURL: baseURL,
DefaultRobots: domainSite.RobotsIndex,
IsDefault: true,
}
if err := tx.Create(&seeded).Error; err != nil {
return entity.Site{}, err
}
return seeded, nil
}
func firstDefaultSite(tx *gorm.DB) (entity.Site, error) {
var found entity.Site
if err := tx.Order("is_default DESC, id ASC").First(&found).Error; err != nil {
return entity.Site{}, err
}
return found, nil
}
@@ -11,6 +11,7 @@ func ArticleToDomain(m models.Article) article.Article {
Title: m.Title,
Body: m.Body,
IsOnline: m.IsOnline,
SiteID: m.SiteID,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
@@ -21,6 +22,7 @@ func ArticleToModel(a article.Article) models.Article {
Title: a.Title,
Body: a.Body,
IsOnline: a.IsOnline,
SiteID: a.SiteID,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
@@ -0,0 +1,33 @@
package mappers
import (
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/models"
)
func SiteToDomain(m models.Site, articleCount int) site.Site {
return site.Site{
ID: m.ID,
Name: m.Name,
Key: m.Key,
BaseURL: m.BaseURL,
DefaultSocialImageID: m.DefaultSocialImageID,
DefaultRobots: m.DefaultRobots,
Locale: m.Locale,
IsDefault: m.IsDefault,
ArticleCount: articleCount,
}
}
func SiteToModel(s site.Site) models.Site {
return models.Site{
ID: s.ID,
Name: s.Name,
Key: s.Key,
BaseURL: s.BaseURL,
DefaultSocialImageID: s.DefaultSocialImageID,
DefaultRobots: s.DefaultRobots,
Locale: s.Locale,
IsDefault: s.IsDefault,
}
}
@@ -12,6 +12,7 @@ type Article struct {
Title string
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32 `gorm:"index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -0,0 +1,16 @@
package models
import "time"
type Site struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:100;not null"`
Key string `gorm:"column:site_key;size:32;not null;uniqueIndex"`
BaseURL string `gorm:"size:255;not null"`
DefaultSocialImageID *uint32
DefaultRobots string `gorm:"size:16;not null"`
Locale *string `gorm:"size:35"`
IsDefault bool `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -40,6 +40,9 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline)
}
if filters.SiteID != nil {
query = query.Where("site_id = ?", *filters.SiteID)
}
switch filters.Status {
case domain.StatusOnline:
query = query.Where("is_online = ?", true)
@@ -177,19 +180,24 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
creationResult := a.db.Create(&entity.Article{
created := entity.Article{
Title: article.Title,
Body: article.Body,
IsOnline: article.IsOnline,
})
if creationResult.Error != nil {
return domain.Article{}, creationResult.Error
SiteID: article.SiteID,
}
var createdArticle entity.Article
creationResult.Scan(&createdArticle)
return mappers.ArticleToDomain(createdArticle), nil
err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil {
return err
}
}
return tx.Create(&created).Error
})
if err != nil {
return domain.Article{}, err
}
return mappers.ArticleToDomain(created), nil
}
func (a *ArticleRepository) GetAll() []domain.Article {
@@ -225,6 +233,31 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
return newArticle, nil
}
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Transaction(func(tx *gorm.DB) error {
if update.SiteID != nil {
if _, err := lockSite(tx, *update.SiteID); err != nil {
return err
}
}
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if update.Body != nil {
localArticle.Body = *update.Body
}
if update.SiteID != nil {
localArticle.SiteID = *update.SiteID
}
return tx.Save(&localArticle).Error
})
if err != nil {
return domain.Article{}, err
}
return mappers.ArticleToDomain(localArticle), nil
}
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
@@ -0,0 +1,234 @@
package persistence
import (
domain "RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type SiteRepository struct {
db *gorm.DB
}
func NewSiteRepository(db *gorm.DB) *SiteRepository {
return &SiteRepository{db}
}
type articleTotal struct {
SiteID uint32
Total int
}
func (s *SiteRepository) articleCounts(db *gorm.DB) (map[uint32]int, error) {
var totals []articleTotal
err := db.Model(&entity.Article{}).Select("site_id, COUNT(*) AS total").Group("site_id").Scan(&totals).Error
if err != nil {
return nil, err
}
counts := make(map[uint32]int, len(totals))
for _, total := range totals {
counts[total.SiteID] = total.Total
}
return counts, nil
}
func (s *SiteRepository) articleCount(db *gorm.DB, id uint32) (int, error) {
var total int64
if err := db.Model(&entity.Article{}).Where("site_id = ?", id).Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
func (s *SiteRepository) List() ([]domain.Site, error) {
var found []entity.Site
if err := s.db.Order("is_default DESC, LOWER(name) ASC, id ASC").Find(&found).Error; err != nil {
return nil, err
}
counts, err := s.articleCounts(s.db)
if err != nil {
return nil, err
}
sites := make([]domain.Site, 0, len(found))
for _, model := range found {
sites = append(sites, mappers.SiteToDomain(model, counts[model.ID]))
}
return sites, nil
}
func (s *SiteRepository) load(db *gorm.DB, model entity.Site) (domain.Site, error) {
total, err := s.articleCount(db, model.ID)
if err != nil {
return domain.Site{}, err
}
return mappers.SiteToDomain(model, total), nil
}
func (s *SiteRepository) Get(id uint32) (domain.Site, error) {
var model entity.Site
if err := s.db.First(&model, id).Error; err != nil {
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
}
return s.load(s.db, model)
}
func (s *SiteRepository) GetByKey(key string) (domain.Site, error) {
var model entity.Site
if err := s.db.Where("site_key = ?", key).First(&model).Error; err != nil {
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
}
return s.load(s.db, model)
}
func (s *SiteRepository) GetDefault() (domain.Site, error) {
var model entity.Site
if err := s.db.Where("is_default = ?", true).Order("id ASC").First(&model).Error; err != nil {
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
}
return s.load(s.db, model)
}
func (s *SiteRepository) Create(site domain.Site) (domain.Site, error) {
model := mappers.SiteToModel(site)
model.ID = 0
err := s.db.Transaction(func(tx *gorm.DB) error {
if model.IsDefault {
if err := lockAllSites(tx); err != nil {
return err
}
if err := unflagDefaults(tx, 0); err != nil {
return err
}
}
return tx.Create(&model).Error
})
if err != nil {
return domain.Site{}, err
}
return mappers.SiteToDomain(model, 0), nil
}
func lockAllSites(tx *gorm.DB) error {
var locked []entity.Site
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&locked).Error
}
func lockSite(tx *gorm.DB, id uint32) (entity.Site, error) {
var locked entity.Site
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
return entity.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
}
return locked, nil
}
func unflagDefaults(tx *gorm.DB, exceptID uint32) error {
return tx.Model(&entity.Site{}).Where("is_default = ? AND id <> ?", true, exceptID).UpdateColumn("is_default", false).Error
}
func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
changes := map[string]any{
"name": site.Name,
"base_url": site.BaseURL,
"default_social_image_id": site.DefaultSocialImageID,
"default_robots": site.DefaultRobots,
"locale": site.Locale,
}
if _, err := s.Get(site.ID); err != nil {
return domain.Site{}, err
}
if err := s.db.Model(&entity.Site{}).Where("id = ?", site.ID).Updates(changes).Error; err != nil {
return domain.Site{}, err
}
return s.Get(site.ID)
}
func (s *SiteRepository) SetDefault(id uint32) (domain.Site, error) {
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := lockAllSites(tx); err != nil {
return err
}
if _, err := lockSite(tx, id); err != nil {
return err
}
if err := unflagDefaults(tx, id); err != nil {
return err
}
return tx.Model(&entity.Site{}).Where("id = ?", id).UpdateColumn("is_default", true).Error
})
if err != nil {
return domain.Site{}, err
}
return s.Get(id)
}
func (s *SiteRepository) DeleteMovingArticles(id uint32, reassignTo *uint32) error {
return s.db.Transaction(func(tx *gorm.DB) error {
target, err := lockSite(tx, id)
if err != nil {
return err
}
if target.IsDefault {
return domain.ErrDefaultSite
}
remaining, err := s.articleCount(tx, id)
if err != nil {
return err
}
if remaining > 0 && reassignTo == nil {
return domain.ErrSiteHasArticles
}
if reassignTo != nil {
if err := moveArticles(tx, id, *reassignTo); err != nil {
return err
}
}
return deleteOrNotFound(tx.Delete(&entity.Site{}, id), domain.ErrSiteNotFound)
})
}
func moveArticles(tx *gorm.DB, from uint32, to uint32) error {
if _, err := lockSite(tx, to); err != nil {
return err
}
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumn("site_id", to).Error
}
func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) {
ids := []uint32{}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.Site{}).Where("default_social_image_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
return err
}
if len(ids) == 0 {
return nil
}
return tx.Model(&entity.Site{}).Where("id IN ?", ids).UpdateColumn("default_social_image_id", nil).Error
})
if err != nil {
return nil, err
}
return ids, nil
}
func (s *SiteRepository) AssignImage(siteIDs []uint32, imageID uint32) error {
if len(siteIDs) == 0 {
return nil
}
return s.db.Model(&entity.Site{}).Where("id IN ?", siteIDs).UpdateColumn("default_social_image_id", imageID).Error
}
func (s *SiteRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error) {
if len(fromImageIDs) == 0 {
return 0, nil
}
result := s.db.Model(&entity.Site{}).Where("default_social_image_id IN ?", fromImageIDs).UpdateColumn("default_social_image_id", toImageID)
if result.Error != nil {
return 0, result.Error
}
return int(result.RowsAffected), nil
}
var _ domain.Repository = &SiteRepository{}
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"context"
)
@@ -10,6 +11,7 @@ type CreateArticleUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
recorder audit.Recorder
sites site.Repository
}
type CreateArticleCommand struct {
@@ -18,8 +20,16 @@ type CreateArticleCommand struct {
Format article.BodyFormat
}
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *CreateArticleUseCase {
return &CreateArticleUseCase{articleRepository, bodyRenderer, recorder}
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 (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
defaultSite, err := g.sites.GetDefault()
if err != nil {
return 0, err
}
return defaultSite.ID, nil
}
func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
@@ -29,10 +39,17 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err
}
siteID, err := g.defaultSiteID()
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,
})
if err != nil {
recordFailure(ctx, g.recorder, event, err)
@@ -0,0 +1,75 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
)
type CreateSiteUseCase struct {
siteRepository site.Repository
imageRepository image.Repository
recorder audit.Recorder
}
type CreateSiteCommand struct {
Name string
Key string
BaseURL string
DefaultRobots string
Locale *string
DefaultSocialImageID *uint32
IsDefault bool
}
func NewCreateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *CreateSiteUseCase {
return &CreateSiteUseCase{siteRepository, imageRepository, recorder}
}
func (u *CreateSiteUseCase) Create(ctx context.Context, command CreateSiteCommand) (site.Site, error) {
event := audit.NewEvent(audit.ActionSiteCreate, audit.CategorySites)
event.Target = audit.Target{Type: audit.TargetSite, Name: command.Name}
created, err := u.create(command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return site.Site{}, err
}
event.Target = siteTarget(created)
u.recorder.Record(ctx, event)
return created, nil
}
func (u *CreateSiteUseCase) create(command CreateSiteCommand) (site.Site, error) {
baseURL, err := site.NormalizeBaseURL(command.BaseURL)
if err != nil {
return site.Site{}, err
}
locale, err := site.NormalizeLocale(command.Locale)
if err != nil {
return site.Site{}, err
}
if err := ensureImageExists(u.imageRepository, command.DefaultSocialImageID); err != nil {
return site.Site{}, err
}
taken, err := isKeyTaken(u.siteRepository, command.Key)
if err != nil {
return site.Site{}, err
}
if taken {
return site.Site{}, site.ErrSiteKeyTaken
}
robots := command.DefaultRobots
if robots == "" {
robots = site.RobotsIndex
}
return u.siteRepository.Create(site.Site{
Name: command.Name,
Key: command.Key,
BaseURL: baseURL,
DefaultSocialImageID: command.DefaultSocialImageID,
DefaultRobots: robots,
Locale: locale,
IsDefault: command.IsDefault,
})
}
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
)
@@ -10,10 +11,23 @@ type DeleteMediaUseCase struct {
imageRepository image.Repository
remover mediaRemover
recorder audit.Recorder
sites site.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
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 (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
clearedSites, err := u.sites.ClearImage(img.ID)
if err != nil {
return err
}
if err := u.remover.remove(img); err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
return err
}
return nil
}
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
@@ -24,7 +38,7 @@ func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
event := audit.NewEvent(audit.ActionMediaDelete, audit.CategoryMedia)
event.Target = imageTarget(img)
if err := u.remover.remove(img); err != nil {
if err := u.removeClearingSites(img); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
@@ -0,0 +1,68 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"context"
)
type DeleteSiteUseCase struct {
siteRepository site.Repository
recorder audit.Recorder
}
type DeleteSiteCommand struct {
ID uint32
ReassignTo *uint32
ReassignInvalid bool
}
func NewDeleteSiteUseCase(siteRepository site.Repository, recorder audit.Recorder) *DeleteSiteUseCase {
return &DeleteSiteUseCase{siteRepository, recorder}
}
func (u *DeleteSiteUseCase) Delete(ctx context.Context, command DeleteSiteCommand) error {
target, err := u.siteRepository.Get(command.ID)
if err != nil {
return err
}
event := audit.NewEvent(audit.ActionSiteDelete, audit.CategorySites)
event.Target = siteTarget(target)
if err := u.delete(target, command); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
func (u *DeleteSiteUseCase) delete(target site.Site, command DeleteSiteCommand) error {
if target.IsDefault {
return site.ErrDefaultSite
}
if target.ArticleCount == 0 {
return u.siteRepository.DeleteMovingArticles(target.ID, nil)
}
destination, err := u.destination(target, command)
if err != nil {
return err
}
return u.siteRepository.DeleteMovingArticles(target.ID, &destination)
}
func (u *DeleteSiteUseCase) destination(target site.Site, command DeleteSiteCommand) (uint32, error) {
if command.ReassignInvalid {
return 0, site.ErrInvalidReassign
}
if command.ReassignTo == nil {
return 0, site.ErrSiteHasArticles
}
if *command.ReassignTo == 0 || *command.ReassignTo == target.ID {
return 0, site.ErrInvalidReassign
}
if _, err := u.siteRepository.Get(*command.ReassignTo); err != nil {
return 0, err
}
return *command.ReassignTo, nil
}
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"errors"
"strconv"
@@ -18,6 +19,7 @@ type ListActivityUseCase struct {
images image.Repository
apiKeys apikey.Repository
now func() time.Time
sites site.Repository
}
type ListActivityCommand struct {
@@ -37,8 +39,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) *ListActivityUseCase {
return &ListActivityUseCase{events, users, articles, images, apiKeys, now}
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 (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
@@ -125,6 +127,9 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
case audit.TargetImage:
_, err = l.images.Get(uint32(id))
return existsUnless(err, image.ErrImageNotFound)
case audit.TargetSite:
_, err = l.sites.Get(uint32(id))
return existsUnless(err, site.ErrSiteNotFound)
default:
return true, nil
}
@@ -1,14 +1,19 @@
package useCases
import "RenewCMS/internal/domain/article"
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"errors"
)
type ListArticlesUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
sites site.Repository
}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository, bodyRenderer}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites}
}
type ListArticlesCommand struct {
@@ -19,6 +24,7 @@ type ListArticlesCommand struct {
Q string
Status string
Sort article.Sort
SiteKey *string
}
type ArticlePage struct {
@@ -38,7 +44,18 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
if !command.Verified && command.Status == article.StatusDraft {
return page, nil
}
found, err := u.articleRepository.FindPage(pageFilters(command))
filters := pageFilters(command)
if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil {
return ArticlePage{}, err
}
if !known {
return page, nil
}
filters.SiteID = &siteID
}
found, err := u.articleRepository.FindPage(filters)
if err != nil {
return ArticlePage{}, err
}
@@ -51,6 +68,17 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
return page, nil
}
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
found, err := u.sites.GetByKey(key)
if errors.Is(err, site.ErrSiteNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return found.ID, true, nil
}
func pageFilters(command ListArticlesCommand) article.Filters {
filters := article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
if !command.Verified {
+14 -4
View File
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"strings"
)
@@ -36,10 +37,11 @@ type ListMediaUseCase struct {
articleRepository article.Repository
userRepository user.Repository
variantFiles image.VariantFileRepository
sites site.Repository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository, variantFiles}
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 (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
@@ -106,6 +108,10 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
if err != nil {
return MediaItem{}, err
}
siteUsage, err := siteImageUsage(l.sites)
if err != nil {
return MediaItem{}, err
}
uploadedBy, err := newUploaderNames(l.userRepository).nameOf(img.UploadedByID)
if err != nil {
return MediaItem{}, err
@@ -129,7 +135,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
variantSize += row.Size
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles) + siteUsage[img.ID], Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
@@ -137,9 +143,13 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
if err != nil {
return nil, err
}
siteUsage, err := siteImageUsage(l.sites)
if err != nil {
return nil, err
}
return func(img image.Image) int {
storedName := image.StoredName(img.Path)
used := 0
used := siteUsage[img.ID]
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
used++
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/site"
type ListSitesUseCase struct {
siteRepository site.Repository
}
func NewListSitesUseCase(siteRepository site.Repository) *ListSitesUseCase {
return &ListSitesUseCase{siteRepository}
}
func (u *ListSitesUseCase) List() ([]site.Site, error) {
return u.siteRepository.List()
}
+19 -3
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"cmp"
"context"
"errors"
@@ -14,6 +15,7 @@ import (
type MergeMediaResult struct {
ArticlesUpdated int
Merged int
SitesUpdated int
UpdatedArticleIDs []uint32
}
@@ -22,10 +24,19 @@ type MergeMediaUseCase struct {
articleRepository article.Repository
remover mediaRemover
recorder audit.Recorder
sites site.Repository
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
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 (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
copyIds := make([]uint32, 0, len(copies))
for _, duplicate := range copies {
copyIds = append(copyIds, duplicate.ID)
}
return u.sites.RepointImage(copyIds, keptId)
}
type bodyRewrite struct {
@@ -78,6 +89,11 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
if err := u.applyRewrites(rewrites); err != nil {
return MergeMediaResult{}, err
}
sitesUpdated, err := u.repointSites(copies, kept.ID)
if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err
}
for _, duplicate := range copies {
if err := u.remover.remove(duplicate); err != nil {
@@ -85,7 +101,7 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
}
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
)
const purgePreviewListLimit = 5
@@ -28,8 +29,8 @@ type PreviewPurgeUseCase struct {
planner *purgePlanner
}
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository) *PreviewPurgeUseCase {
return &PreviewPurgeUseCase{&purgePlanner{imageRepository, articleRepository}}
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository) *PreviewPurgeUseCase {
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}}
}
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
+4 -2
View File
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/site"
"context"
"log"
"slices"
@@ -22,9 +23,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) *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) *RunPurgeUseCase {
return &RunPurgeUseCase{
planner: &purgePlanner{imageRepository, articleRepository},
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites},
merge: merge,
repository: repository,
now: now,
@@ -86,6 +87,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
}
result.GroupsPurged++
result.Merged += merged.Merged
result.SitesUpdated += merged.SitesUpdated
for _, id := range merged.UpdatedArticleIDs {
updated[id] = true
}
@@ -0,0 +1,36 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"context"
)
type SetDefaultSiteUseCase struct {
siteRepository site.Repository
recorder audit.Recorder
}
func NewSetDefaultSiteUseCase(siteRepository site.Repository, recorder audit.Recorder) *SetDefaultSiteUseCase {
return &SetDefaultSiteUseCase{siteRepository, recorder}
}
func (u *SetDefaultSiteUseCase) SetDefault(ctx context.Context, id uint32) (site.Site, error) {
current, err := u.siteRepository.Get(id)
if err != nil {
return site.Site{}, err
}
if current.IsDefault {
return current, nil
}
event := audit.NewEvent(audit.ActionSiteSetDefault, audit.CategorySites)
event.Target = siteTarget(current)
updated, err := u.siteRepository.SetDefault(id)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return site.Site{}, err
}
u.recorder.Record(ctx, event)
return updated, nil
}
@@ -3,31 +3,81 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"context"
"strconv"
)
type UpdateArticleUseCase struct {
articleRepository article.Repository
bodyRenderer article.BodyRenderer
recorder audit.Recorder
sites site.Repository
}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository, bodyRenderer, recorder}
type UpdateArticleCommand struct {
Body *string
SiteID *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 (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
return g.Update(ctx, id, UpdateArticleCommand{Body: &body, Format: format})
}
func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command UpdateArticleCommand) (article.Article, error) {
before, err := g.articleRepository.Get(id)
if err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.UpdateBody(id, body)
destination, err := g.destinationSite(command)
if err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID})
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
}
g.recordChange(ctx, audit.ActionArticleEdit, before, updated)
return renderArticle(g.bodyRenderer, updated, format)
g.recordEdit(ctx, before, updated, destination)
return renderArticle(g.bodyRenderer, updated, command.Format)
}
func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
if command.SiteID == nil {
return nil, nil
}
destination, err := g.sites.Get(*command.SiteID)
if err != nil {
return nil, err
}
return &destination, nil
}
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 len(changes) == 0 {
return
}
event := audit.NewEvent(audit.ActionArticleEdit, audit.CategoryArticles)
event.Target = articleTarget(after)
event.Changes = changes
g.recorder.Record(ctx, event)
}
func (g *UpdateArticleUseCase) siteName(id uint32) string {
found, err := g.sites.Get(id)
if err != nil {
return strconv.FormatUint(uint64(id), 10)
}
return found.Name
}
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
@@ -0,0 +1,110 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
)
type UpdateSiteUseCase struct {
siteRepository site.Repository
imageRepository image.Repository
recorder audit.Recorder
}
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *UpdateSiteUseCase {
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder}
}
func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Patch) (site.Site, error) {
before, err := u.siteRepository.Get(id)
if err != nil {
return site.Site{}, err
}
event := audit.NewEvent(audit.ActionSiteEdit, audit.CategorySites)
event.Target = siteTarget(before)
after, err := u.apply(before, patch)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return site.Site{}, err
}
changes := u.changes(before, after)
if len(changes) == 0 {
return before, nil
}
saved, err := u.siteRepository.Update(after)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return site.Site{}, err
}
event.Target = siteTarget(saved)
event.Changes = changes
u.recorder.Record(ctx, event)
return saved, nil
}
func (u *UpdateSiteUseCase) apply(before site.Site, patch site.Patch) (site.Site, error) {
after := before
if patch.IsDefault != nil {
return site.Site{}, site.ErrUseSetDefault
}
if patch.Key != nil && *patch.Key != before.Key {
return site.Site{}, site.ErrKeyImmutable
}
if patch.Name != nil {
after.Name = *patch.Name
}
if patch.BaseURL != nil {
baseURL, err := site.NormalizeBaseURL(*patch.BaseURL)
if err != nil {
return site.Site{}, err
}
after.BaseURL = baseURL
}
if patch.DefaultRobots != nil {
after.DefaultRobots = *patch.DefaultRobots
}
if patch.Locale.Set {
locale, err := site.NormalizeLocale(patch.Locale.Value)
if err != nil {
return site.Site{}, err
}
after.Locale = locale
}
if patch.DefaultSocialImageID.Set {
if err := ensureImageExists(u.imageRepository, patch.DefaultSocialImageID.Value); err != nil {
return site.Site{}, err
}
after.DefaultSocialImageID = patch.DefaultSocialImageID.Value
}
return after, nil
}
func (u *UpdateSiteUseCase) changes(before site.Site, after site.Site) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldBaseURL, before.BaseURL, after.BaseURL)
changes = appendTextChange(changes, audit.FieldDefaultRobots, before.DefaultRobots, after.DefaultRobots)
changes = appendTextChange(changes, audit.FieldLocale, localeLabel(before.Locale), localeLabel(after.Locale))
if !sameImage(before.DefaultSocialImageID, after.DefaultSocialImageID) {
changes = append(changes, audit.TextChange(audit.FieldSocialImage, imageLabel(u.imageRepository, before.DefaultSocialImageID), imageLabel(u.imageRepository, after.DefaultSocialImageID)))
}
return changes
}
func appendTextChange(changes []audit.Change, field string, before string, after string) []audit.Change {
if before == after {
return changes
}
return append(changes, audit.TextChange(field, before, after))
}
func sameImage(a *uint32, b *uint32) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"context"
@@ -55,6 +56,14 @@ var failureReasons = []failureReason{
{update.ErrNothingToUpdate, "There is nothing to update."},
{update.ErrPreflightRefused, "The update cannot be downloaded."},
{update.ErrRestartKindMismatch, "The requested restart does not match the server state."},
{site.ErrSiteKeyTaken, "A site with that key already exists."},
{site.ErrKeyImmutable, "The key of a site cannot be changed."},
{site.ErrDefaultSite, "The default site cannot be deleted."},
{site.ErrSiteHasArticles, "The site still has articles."},
{site.ErrInvalidSiteURL, "The site address is not valid."},
{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."},
}
var restartFailureReasons = []failureReason{
@@ -72,6 +81,7 @@ var notFoundCauses = []error{
image.ErrVariantNotFound,
invite.ErrInviteNotFound,
user.ErrUserNotFound,
site.ErrSiteNotFound,
}
func reasonFor(err error, overrides ...failureReason) string {
+10 -3
View File
@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"crypto/sha256"
"encoding/hex"
"slices"
@@ -26,6 +27,7 @@ type purgePlan struct {
type purgePlanner struct {
imageRepository image.Repository
articleRepository article.Repository
sites site.Repository
}
func (p *purgePlanner) plan() (purgePlan, error) {
@@ -38,11 +40,16 @@ func (p *purgePlanner) plan() (purgePlan, error) {
return purgePlan{}, err
}
siteUsage, err := siteImageUsage(p.sites)
if err != nil {
return purgePlan{}, err
}
groups := []purgeGroup{}
copies := 0
touched := map[uint32]bool{}
for _, members := range groupByHash(duplicates) {
group, planned := planGroup(members, articles)
group, planned := planGroup(members, articles, siteUsage)
if !planned {
continue
}
@@ -87,11 +94,11 @@ func usageOf(img image.Image, articles []article.Article) int {
return used
}
func planGroup(members []image.Image, articles []article.Article) (purgeGroup, bool) {
func planGroup(members []image.Image, articles []article.Article, siteUsage map[uint32]int) (purgeGroup, bool) {
usage := make(map[uint32]int, len(members))
anyUsed := false
for _, member := range members {
usage[member.ID] = usageOf(member, articles)
usage[member.ID] = usageOf(member, articles) + siteUsage[member.ID]
anyUsed = anyUsed || usage[member.ID] > 0
}
if !anyUsed {
@@ -0,0 +1,47 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"errors"
"strconv"
)
func siteTarget(s site.Site) audit.Target {
return audit.NewTarget(audit.TargetSite, s.ID, s.Name)
}
func ensureImageExists(images image.Repository, id *uint32) error {
if id == nil {
return nil
}
_, err := images.Get(*id)
return err
}
func isKeyTaken(sites site.Repository, key string) (bool, error) {
_, err := sites.GetByKey(key)
if errors.Is(err, site.ErrSiteNotFound) {
return false, nil
}
return err == nil, err
}
func imageLabel(images image.Repository, id *uint32) string {
if id == nil {
return ""
}
found, err := images.Get(*id)
if err != nil || found.Filename == "" {
return strconv.FormatUint(uint64(*id), 10)
}
return found.Filename
}
func localeLabel(locale *string) string {
if locale == nil {
return ""
}
return *locale
}
@@ -0,0 +1,17 @@
package useCases
import "RenewCMS/internal/domain/site"
func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
usage := map[uint32]int{}
all, err := sites.List()
if err != nil {
return nil, err
}
for _, s := range all {
if s.DefaultSocialImageID != nil {
usage[*s.DefaultSocialImageID]++
}
}
return usage, nil
}