refac: debt on role backend
This commit is contained in:
1 parent
de142f6c5f
commit
a043f7f41a
130 files changed
+3591
-512
No files matched your search
@@ -2,7 +2,9 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
|
||||
@@ -32,11 +34,17 @@ func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.
|
||||
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
|
||||
}
|
||||
|
||||
func (u *CheckArticleSEOUseCase) Check(id uint32, command CheckArticleSEOCommand) (article.SEOCheck, error) {
|
||||
func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command CheckArticleSEOCommand) (article.SEOCheck, error) {
|
||||
if err := caller.requireHolding(role.KeyContentRead, builtintype.KeyArticle); err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
saved, err := u.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
if !caller.seesArticle(saved) {
|
||||
return article.SEOCheck{}, article.ErrArticleNotFound
|
||||
}
|
||||
if err := u.validate(command); err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
|
||||
@@ -14,42 +14,39 @@ func NewCheckPermissionUseCase(roles role.Repository) *CheckPermissionUseCase {
|
||||
return &CheckPermissionUseCase{roles: roles}
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) Require(account user.User, key string) (role.GrantSet, error) {
|
||||
grants, err := c.grantsOf(account, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (c *CheckPermissionUseCase) Grants(account user.User) (role.GrantSet, error) {
|
||||
resolved, err := resolveRole(c.roles, account)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return role.GrantSet{}, nil
|
||||
}
|
||||
if !grants.Holds(key) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
if err != nil {
|
||||
return role.GrantSet{}, err
|
||||
}
|
||||
return resolved.GrantSet(), nil
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) Require(account user.User, key string) (role.GrantSet, error) {
|
||||
return c.RequireFor(account, key, "")
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) RequireFor(account user.User, key string, typeKey string) (role.GrantSet, error) {
|
||||
grants, err := c.Grants(account)
|
||||
if err != nil {
|
||||
return role.GrantSet{}, err
|
||||
}
|
||||
if !grants.HoldsFor(key, typeKey) {
|
||||
return role.GrantSet{}, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) RequireAll(account user.User, key string) (role.GrantSet, error) {
|
||||
grants, err := c.grantsOf(account, key)
|
||||
grants, err := c.Grants(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return role.GrantSet{}, err
|
||||
}
|
||||
if !grants.HoldsAll(key) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
return role.GrantSet{}, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (c *CheckPermissionUseCase) grantsOf(account user.User, key string) (role.GrantSet, error) {
|
||||
resolved, err := resolveRole(c.roles, account)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
granted, err := c.roles.ListGrants(resolved.ID)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil, role.MissingPermissionError{Key: key}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return role.NewGrantSet(granted), nil
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -18,10 +20,10 @@ type CreateArticleUseCase struct {
|
||||
}
|
||||
|
||||
type CreateArticleCommand struct {
|
||||
Title string
|
||||
Body string
|
||||
Format article.BodyFormat
|
||||
CreatedBy uint32
|
||||
Title string
|
||||
Body string
|
||||
Format article.BodyFormat
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
|
||||
@@ -48,6 +50,9 @@ func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, e
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
|
||||
if err := createArticle.Caller.requireHolding(role.KeyContentCreate, builtintype.KeyArticle); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionArticleCreate, audit.CategoryArticles)
|
||||
if err := article.ValidateTitle(createArticle.Title); err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
@@ -60,7 +65,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
creditedAuthors, err := g.creatorBylines(createArticle.CreatedBy)
|
||||
creditedAuthors, err := g.creatorBylines(createArticle.Caller.UserID)
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
@@ -71,7 +76,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
SiteID: siteID,
|
||||
CreatedBy: createArticle.CreatedBy,
|
||||
CreatedBy: createArticle.Caller.UserID,
|
||||
Authors: creditedAuthors,
|
||||
SEODismissed: []string{},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"errors"
|
||||
"time"
|
||||
@@ -55,6 +56,9 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
|
||||
}
|
||||
|
||||
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
|
||||
if _, err := builtintype.Find(key); err == nil {
|
||||
return contenttype.ErrKeyTaken
|
||||
}
|
||||
_, err := u.contentTypes.GetByKey(key)
|
||||
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
|
||||
return nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,7 @@ type CreateEntryUseCase struct {
|
||||
type CreateEntryCommand struct {
|
||||
ContentTypeKey string
|
||||
Data map[string]any
|
||||
UserID uint32
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository, now func() time.Time) *CreateEntryUseCase {
|
||||
@@ -25,6 +26,9 @@ func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Re
|
||||
}
|
||||
|
||||
func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, error) {
|
||||
if err := command.Caller.requireHolding(role.KeyContentCreate, command.ContentTypeKey); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
@@ -47,8 +51,8 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
|
||||
stored, err := u.entries.Create(entry.Entry{
|
||||
ContentTypeID: owner.ID,
|
||||
Data: data,
|
||||
CreatedBy: command.UserID,
|
||||
UpdatedBy: command.UserID,
|
||||
CreatedBy: command.Caller.UserID,
|
||||
UpdatedBy: command.Caller.UserID,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"context"
|
||||
)
|
||||
|
||||
@@ -15,11 +17,17 @@ func NewDeleteArticleUseCase(articleRepository article.Repository, recorder audi
|
||||
return &DeleteArticleUseCase{articleRepository, recorder}
|
||||
}
|
||||
|
||||
func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32) error {
|
||||
func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32, caller ContentCaller) error {
|
||||
if err := caller.requireHolding(role.KeyContentDelete, builtintype.KeyArticle); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := caller.authorize(role.KeyContentDelete, builtintype.KeyArticle, target.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionArticleDelete, audit.CategoryArticles)
|
||||
event.Target = articleTarget(target)
|
||||
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/role"
|
||||
)
|
||||
|
||||
type DeleteEntryUseCase struct {
|
||||
@@ -14,10 +15,20 @@ func NewDeleteEntryUseCase(contentTypes contenttype.Repository, entries entry.Re
|
||||
return &DeleteEntryUseCase{contentTypes, entries}
|
||||
}
|
||||
|
||||
func (u *DeleteEntryUseCase) Delete(contentTypeKey string, id uint32) error {
|
||||
func (u *DeleteEntryUseCase) Delete(contentTypeKey string, id uint32, caller ContentCaller) error {
|
||||
if err := caller.requireHolding(role.KeyContentDelete, contentTypeKey); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, err := u.contentTypes.GetByKey(contentTypeKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found, err := u.entries.Get(owner.ID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := caller.authorize(role.KeyContentDelete, contentTypeKey, found.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.entries.Delete(owner.ID, id)
|
||||
}
|
||||
@@ -11,24 +11,14 @@ func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer art
|
||||
return &GetArticleUseCase{articleRepository, bodyRenderer}
|
||||
}
|
||||
|
||||
func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) {
|
||||
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat, caller ContentCaller) (article.Article, error) {
|
||||
art, err := u.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
if !art.IsOnline {
|
||||
if !caller.seesArticle(art) {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
}
|
||||
|
||||
return renderArticle(u.bodyRenderer, art, format)
|
||||
}
|
||||
|
||||
func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) {
|
||||
art, err := u.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
return renderArticle(u.bodyRenderer, art, format)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
)
|
||||
|
||||
type GetEntryUseCase struct {
|
||||
@@ -16,7 +17,10 @@ func NewGetEntryUseCase(contentTypes contenttype.Repository, entries entry.Repos
|
||||
return &GetEntryUseCase{contentTypes, entries, images}
|
||||
}
|
||||
|
||||
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, error) {
|
||||
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32, caller ContentCaller) (TypedEntry, error) {
|
||||
if err := caller.requireHolding(role.KeyContentRead, contentTypeKey); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
owner, err := u.contentTypes.GetByKey(contentTypeKey)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
@@ -25,5 +29,8 @@ func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, err
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if !caller.reads(contentTypeKey, found.CreatedBy) {
|
||||
return TypedEntry{}, entry.ErrEntryNotFound
|
||||
}
|
||||
return typedEntry(owner, found, newEntryResolvers(u.images, u.contentTypes, u.entries))
|
||||
}
|
||||
@@ -46,24 +46,15 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
granted, err := u.roles.ListGrants(resolved.ID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
return Access{Role: resolved, Permissions: orderedGrants(granted)}, nil
|
||||
return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, nil
|
||||
}
|
||||
|
||||
func orderedGrants(granted []role.Grant) []role.Grant {
|
||||
scopes := make(map[string]role.Scope, len(granted))
|
||||
for _, grant := range granted {
|
||||
scopes[grant.Key] = grant.Scope
|
||||
}
|
||||
ordered := []role.Grant{}
|
||||
func effectiveGrants(grants role.GrantSet) []role.Grant {
|
||||
effective := []role.Grant{}
|
||||
for _, permission := range role.Catalogue() {
|
||||
scope, present := scopes[permission.Key]
|
||||
if present && scope != role.ScopeNone {
|
||||
ordered = append(ordered, role.Grant{Key: permission.Key, Scope: scope})
|
||||
if scope := grants.ScopeFor(permission.Key, ""); scope != role.ScopeNone {
|
||||
effective = append(effective, role.Grant{Key: permission.Key, Scope: scope})
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
return effective
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository us
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32, grants role.GrantSet) ([]APIKeyEntry, error) {
|
||||
scope := grants.Scope(role.KeyAPIKeysView)
|
||||
scope := grants.ScopeFor(role.KeyAPIKeysView, "")
|
||||
if scope == role.ScopeNone {
|
||||
return nil, role.MissingPermissionError{Key: role.KeyAPIKeysView}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
)
|
||||
@@ -17,15 +19,15 @@ func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer a
|
||||
}
|
||||
|
||||
type ListArticlesCommand struct {
|
||||
Format article.BodyFormat
|
||||
Verified bool
|
||||
Page int
|
||||
Size int
|
||||
Q string
|
||||
Status string
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
Format article.BodyFormat
|
||||
Caller ContentCaller
|
||||
Page int
|
||||
Size int
|
||||
Q string
|
||||
Status string
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
}
|
||||
|
||||
type ArticlePage struct {
|
||||
@@ -38,20 +40,31 @@ type ArticlePage struct {
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
|
||||
counts, err := u.articleRepository.CountByVisibility(!command.Verified)
|
||||
switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
|
||||
case role.ScopeAll:
|
||||
return u.listVisibleByRepository(command, false)
|
||||
case role.ScopeOwn:
|
||||
return u.listOwnedAndOnline(command)
|
||||
default:
|
||||
return u.listVisibleByRepository(command, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, onlineOnly bool) (ArticlePage, error) {
|
||||
counts, err := u.articleRepository.CountByVisibility(onlineOnly)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
summaries, err := u.summarizeVisible(!command.Verified)
|
||||
summaries, err := u.summarizeVisible(onlineOnly)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
counts.Suggestions = countSuggestions(summaries)
|
||||
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
|
||||
if !command.Verified && command.Status == article.StatusDraft {
|
||||
page := emptyArticlePage(command, counts)
|
||||
if onlineOnly && command.Status == article.StatusDraft {
|
||||
return page, nil
|
||||
}
|
||||
filters := pageFilters(command)
|
||||
filters := pageFilters(command, onlineOnly)
|
||||
if command.SiteKey != nil {
|
||||
siteID, known, err := u.siteIDOf(*command.SiteKey)
|
||||
if err != nil {
|
||||
@@ -81,6 +94,99 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func emptyArticlePage(command ListArticlesCommand, counts article.Counts) ArticlePage {
|
||||
return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) listOwnedAndOnline(command ListArticlesCommand) (ArticlePage, error) {
|
||||
everything, err := u.articleRepository.FindPage(article.Filters{Unpaged: true})
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
visible := visibleTo(command.Caller, everything.Items)
|
||||
summaries, err := u.summarize(visible)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
counts := tally(visible)
|
||||
counts.Suggestions = countSuggestions(summaries)
|
||||
page := emptyArticlePage(command, counts)
|
||||
filters := pageFilters(command, false)
|
||||
filters.Unpaged = true
|
||||
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
|
||||
}
|
||||
matching := visibleTo(command.Caller, found.Items)
|
||||
if command.SEO != "" {
|
||||
matching = withSEOStatus(matching, summaries, command.SEO)
|
||||
}
|
||||
rendered, err := renderArticles(u.bodyRenderer, pageSlice(matching, command.Page, command.Size), command.Format)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
page.Items = rendered
|
||||
page.Total = len(matching)
|
||||
page.SEO = summaries
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func visibleTo(caller ContentCaller, items []article.Article) []article.Article {
|
||||
visible := make([]article.Article, 0, len(items))
|
||||
for _, item := range items {
|
||||
if caller.seesArticle(item) {
|
||||
visible = append(visible, item)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func tally(visible []article.Article) article.Counts {
|
||||
counts := article.Counts{All: len(visible)}
|
||||
for _, item := range visible {
|
||||
if item.IsOnline {
|
||||
counts.Online++
|
||||
} else {
|
||||
counts.Draft++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article {
|
||||
matching := make([]article.Article, 0, len(items))
|
||||
for _, item := range items {
|
||||
if summaries[item.ID].Status == status {
|
||||
matching = append(matching, item)
|
||||
}
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
func pageSlice[T any](items []T, page int, size int) []T {
|
||||
size = min(max(size, 0), len(items))
|
||||
if size == 0 {
|
||||
return []T{}
|
||||
}
|
||||
pages := (len(items) + size - 1) / size
|
||||
pageIndex := max(page, 1) - 1
|
||||
if pageIndex >= pages {
|
||||
return []T{}
|
||||
}
|
||||
start := pageIndex * size
|
||||
return items[start:min(start+size, len(items))]
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarizeVisible(onlineOnly bool) (map[uint32]article.SEOSummary, error) {
|
||||
filters := article.Filters{Unpaged: true}
|
||||
if onlineOnly {
|
||||
@@ -105,24 +211,8 @@ func countSuggestions(summaries map[uint32]article.SEOSummary) int {
|
||||
}
|
||||
|
||||
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
|
||||
matching := make([]article.Article, 0, len(items))
|
||||
for _, item := range items {
|
||||
if summaries[item.ID].Status == command.SEO {
|
||||
matching = append(matching, item)
|
||||
}
|
||||
}
|
||||
size := min(max(command.Size, 0), len(matching))
|
||||
if size == 0 {
|
||||
return []article.Article{}, len(matching)
|
||||
}
|
||||
pages := (len(matching) + size - 1) / size
|
||||
pageIndex := max(command.Page, 1) - 1
|
||||
if pageIndex >= pages {
|
||||
return []article.Article{}, len(matching)
|
||||
}
|
||||
start := pageIndex * size
|
||||
end := min(start+size, len(matching))
|
||||
return matching[start:end], len(matching)
|
||||
matching := withSEOStatus(items, summaries, command.SEO)
|
||||
return pageSlice(matching, command.Page, command.Size), len(matching)
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
|
||||
@@ -155,9 +245,9 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
|
||||
return found.ID, true, nil
|
||||
}
|
||||
|
||||
func pageFilters(command ListArticlesCommand) article.Filters {
|
||||
func pageFilters(command ListArticlesCommand, onlineOnly bool) article.Filters {
|
||||
filters := article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
|
||||
if !command.Verified {
|
||||
if onlineOnly {
|
||||
online := true
|
||||
filters.IsOnline = &online
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,7 @@ type ListEntriesCommand struct {
|
||||
Size int
|
||||
Sort entry.Sort
|
||||
Query string
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
type EntryPage struct {
|
||||
@@ -33,6 +35,9 @@ func NewListEntriesUseCase(contentTypes contenttype.Repository, entries entry.Re
|
||||
}
|
||||
|
||||
func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, error) {
|
||||
if err := command.Caller.requireHolding(role.KeyContentRead, command.ContentTypeKey); err != nil {
|
||||
return EntryPage{}, err
|
||||
}
|
||||
if err := entry.ValidatePage(command.Page, command.Size); err != nil {
|
||||
return EntryPage{}, err
|
||||
}
|
||||
@@ -61,7 +66,8 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
|
||||
|
||||
func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEntriesCommand) (entry.PageResult, error) {
|
||||
query := strings.TrimSpace(command.Query)
|
||||
if query == "" {
|
||||
readScope := command.Caller.Grants.ScopeFor(role.KeyContentRead, command.ContentTypeKey)
|
||||
if query == "" && readScope == role.ScopeAll {
|
||||
return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
|
||||
}
|
||||
all, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: command.Sort})
|
||||
@@ -70,13 +76,9 @@ func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEnt
|
||||
}
|
||||
matching := make([]entry.Entry, 0, len(all.Items))
|
||||
for _, record := range all.Items {
|
||||
if entry.MatchesQuery(owner.Fields, record.Data, query) {
|
||||
if role.ScopePermits(readScope, command.Caller.UserID, record.CreatedBy) && (query == "" || entry.MatchesQuery(owner.Fields, record.Data, query)) {
|
||||
matching = append(matching, record)
|
||||
}
|
||||
}
|
||||
pageNumber := max(command.Page, 1)
|
||||
size := max(command.Size, 0)
|
||||
start := min((pageNumber-1)*size, len(matching))
|
||||
end := min(start+size, len(matching))
|
||||
return entry.PageResult{Items: matching[start:end], Total: len(matching)}, nil
|
||||
return entry.PageResult{Items: pageSlice(matching, command.Page, command.Size), Total: len(matching)}, nil
|
||||
}
|
||||
@@ -32,12 +32,12 @@ func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites sit
|
||||
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
|
||||
}
|
||||
|
||||
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, verified bool) (article.ResolvedSEO, error) {
|
||||
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (article.ResolvedSEO, error) {
|
||||
art, err := u.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.ResolvedSEO{}, err
|
||||
}
|
||||
if !art.IsOnline && !verified {
|
||||
if !caller.seesArticle(art) {
|
||||
return article.ResolvedSEO{}, article.ErrArticleNotFound
|
||||
}
|
||||
owner, err := u.sites.Get(art.SiteID)
|
||||
|
||||
@@ -19,8 +19,7 @@ func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, now func() time.
|
||||
}
|
||||
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32, grants role.GrantSet) error {
|
||||
scope := grants.Scope(role.KeyAPIKeysRevoke)
|
||||
if scope == role.ScopeNone {
|
||||
if !grants.Holds(role.KeyAPIKeysRevoke) {
|
||||
return role.MissingPermissionError{Key: role.KeyAPIKeysRevoke}
|
||||
}
|
||||
|
||||
@@ -29,7 +28,7 @@ func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint
|
||||
return err
|
||||
}
|
||||
|
||||
if scope != role.ScopeAll && key.UserID != requesterId {
|
||||
if !grants.Permits(role.KeyAPIKeysRevoke, "", requesterId, key.UserID) {
|
||||
return apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"slices"
|
||||
@@ -44,15 +46,21 @@ func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images}
|
||||
}
|
||||
|
||||
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) UpdateBody(ctx context.Context, id uint32, caller ContentCaller, body string, format article.BodyFormat) (article.Article, error) {
|
||||
return g.Update(ctx, id, caller, UpdateArticleCommand{Body: &body, Format: format})
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command UpdateArticleCommand) (article.Article, error) {
|
||||
func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller ContentCaller, command UpdateArticleCommand) (article.Article, error) {
|
||||
if err := caller.requireHolding(role.KeyContentUpdate, builtintype.KeyArticle); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
before, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, before.CreatedBy); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
destination, err := g.destinationSite(command)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
@@ -247,11 +255,17 @@ func (g *UpdateArticleUseCase) siteName(id uint32) string {
|
||||
return found.Name
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
|
||||
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, caller ContentCaller, isOnline bool, format article.BodyFormat) (article.Article, error) {
|
||||
if err := caller.requireHolding(role.KeyContentPublish, builtintype.KeyArticle); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
before, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if err := caller.authorize(role.KeyContentPublish, builtintype.KeyArticle, before.CreatedBy); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, publishAction(isOnline), before, err)
|
||||
|
||||
@@ -93,10 +93,14 @@ func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller,
|
||||
}
|
||||
|
||||
func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
|
||||
if caller.Grants.HoldsAll(role.KeyAuthorsEdit) {
|
||||
return nil
|
||||
var linkedID uint32
|
||||
if before.UserID != nil {
|
||||
linkedID = *before.UserID
|
||||
}
|
||||
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
|
||||
if !caller.Grants.Permits(role.KeyAuthorsEdit, "", caller.UserID, linkedID) {
|
||||
return author.ErrAuthorRights
|
||||
}
|
||||
if patch.UserID.Set && !caller.Grants.HoldsAll(role.KeyAuthorsEdit) {
|
||||
return author.ErrAuthorRights
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ type UpdateEntryCommand struct {
|
||||
ContentTypeKey string
|
||||
ID uint32
|
||||
Data map[string]any
|
||||
UserID uint32
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository, now func() time.Time) *UpdateEntryUseCase {
|
||||
@@ -26,6 +27,9 @@ func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Re
|
||||
}
|
||||
|
||||
func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, error) {
|
||||
if err := command.Caller.requireHolding(role.KeyContentUpdate, command.ContentTypeKey); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
owner, err := u.contentTypes.GetByKey(command.ContentTypeKey)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
@@ -34,6 +38,9 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := command.Caller.authorize(role.KeyContentUpdate, command.ContentTypeKey, current.CreatedBy); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := entry.Validate(owner.Fields, command.Data); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
@@ -48,7 +55,7 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
current.Data = command.Data
|
||||
current.UpdatedBy = command.UserID
|
||||
current.UpdatedBy = command.Caller.UserID
|
||||
current.UpdatedAt = u.now()
|
||||
stored, err := u.entries.Update(current)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,7 +34,7 @@ type ActivityQuery struct {
|
||||
}
|
||||
|
||||
func scopeFilterToViewer(filter *audit.Filter, viewerID uint32, grants role.GrantSet) error {
|
||||
switch grants.Scope(role.KeyActivityView) {
|
||||
switch grants.ScopeFor(role.KeyActivityView, "") {
|
||||
case role.ScopeAll:
|
||||
return nil
|
||||
case role.ScopeOwn:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
)
|
||||
|
||||
type ContentCaller struct {
|
||||
UserID uint32
|
||||
Grants role.GrantSet
|
||||
}
|
||||
|
||||
func (c ContentCaller) requireHolding(key string, typeKey string) error {
|
||||
if !c.Grants.HoldsFor(key, typeKey) {
|
||||
return role.MissingPermissionError{Key: key}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ContentCaller) authorize(key string, typeKey string, createdBy uint32) error {
|
||||
if !c.Grants.Permits(key, typeKey, c.UserID, createdBy) {
|
||||
return role.MissingPermissionError{Key: key}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ContentCaller) reads(typeKey string, createdBy uint32) bool {
|
||||
return c.Grants.Permits(role.KeyContentRead, typeKey, c.UserID, createdBy)
|
||||
}
|
||||
|
||||
func (c ContentCaller) seesArticle(art article.Article) bool {
|
||||
return art.IsOnline || c.reads(builtintype.KeyArticle, art.CreatedBy)
|
||||
}
|
||||
Reference in new issue
Block a user