feat: activity log producers
This commit is contained in:
1 parent
50b1649965
commit
e37a46a027
93 files changed
+3396
-440
No files matched your search
@@ -0,0 +1,30 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TextChange(field string, before string, after string) Change {
|
||||
return Change{Field: field, Kind: ChangeText, Before: &before, After: &after}
|
||||
}
|
||||
|
||||
func BodyChange(before string, after string) Change {
|
||||
delta := utf8.RuneCountInString(after) - utf8.RuneCountInString(before)
|
||||
return Change{Field: FieldBody, Kind: ChangeBody, Delta: &delta}
|
||||
}
|
||||
|
||||
func ArticleChanges(before article.Article, after article.Article) []Change {
|
||||
changes := []Change{}
|
||||
if before.Title != after.Title {
|
||||
changes = append(changes, TextChange(FieldTitle, before.Title, after.Title))
|
||||
}
|
||||
if before.IsOnline != after.IsOnline {
|
||||
changes = append(changes, TextChange(FieldOnline, strconv.FormatBool(before.IsOnline), strconv.FormatBool(after.IsOnline)))
|
||||
}
|
||||
if before.Body != after.Body {
|
||||
changes = append(changes, BodyChange(before.Body, after.Body))
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -66,11 +66,36 @@ const (
|
||||
ActionAPIKeyRevoke = "api_key.revoke"
|
||||
ActionRetentionChanged = "system.activity_retention_changed"
|
||||
ActionCleanup = "system.activity_cleanup"
|
||||
ActionArticleCreate = "article.create"
|
||||
ActionArticleEdit = "article.edit"
|
||||
ActionArticlePublish = "article.publish"
|
||||
ActionArticleUnpublish = "article.unpublish"
|
||||
ActionArticleDelete = "article.delete"
|
||||
ActionMediaUpload = "media.upload"
|
||||
ActionMediaRename = "media.rename"
|
||||
ActionMediaDelete = "media.delete"
|
||||
ActionMediaMerge = "media.merge"
|
||||
ActionMediaPurge = "media.purge"
|
||||
ActionUserRegister = "user.register"
|
||||
ActionUserVerify = "user.verify"
|
||||
ActionUserUpdateProfile = "user.update_profile"
|
||||
ActionInviteCreate = "invite.create"
|
||||
ActionInviteCancel = "invite.cancel"
|
||||
ActionInviteRenew = "invite.renew"
|
||||
ActionInviteAccept = "invite.accept"
|
||||
ActionMediaPurgeScheduled = "system.media_purge"
|
||||
ActionUpdateChecked = "system.update_checked"
|
||||
ActionUpdateDownloaded = "system.update_downloaded"
|
||||
ActionRestartRequested = "system.restart_requested"
|
||||
)
|
||||
|
||||
const (
|
||||
TargetAPIKey = "api_key"
|
||||
TargetUser = "user"
|
||||
TargetAPIKey = "api_key"
|
||||
TargetUser = "user"
|
||||
TargetArticle = "article"
|
||||
TargetImage = "image"
|
||||
TargetInvite = "invite"
|
||||
TargetRelease = "release"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,6 +105,17 @@ const (
|
||||
ParamRemoved = "removed"
|
||||
ParamFrom = "from"
|
||||
ParamTo = "to"
|
||||
ParamMerged = "merged"
|
||||
ParamArticlesUpdated = "articles_updated"
|
||||
ParamFailedGroups = "failed_groups"
|
||||
ParamUpdateAvailable = "update_available"
|
||||
ParamKind = "kind"
|
||||
ParamTag = "tag"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
FieldFilename = "filename"
|
||||
FieldDisplayName = "display_name"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package audit
|
||||
|
||||
import "strconv"
|
||||
|
||||
func NewTarget(targetType string, id uint32, name string) Target {
|
||||
return Target{Type: targetType, ID: strconv.FormatUint(uint64(id), 10), Name: name}
|
||||
}
|
||||
|
||||
func ReleaseTarget(tag string) Target {
|
||||
return Target{Type: TargetRelease, ID: tag, Name: tag}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
|
||||
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
|
||||
var ErrInvalidDisplayName = errors.New("invalid display name")
|
||||
var ErrUsernameTaken = errors.New("username taken")
|
||||
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
|
||||
|
||||
type Role string
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,6 +15,7 @@ type AcceptInviteUseCase struct {
|
||||
userRepository user.Repository
|
||||
inviteRepository invite.Repository
|
||||
hasher hash.Hasher
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type AcceptInviteCommand struct {
|
||||
@@ -21,18 +24,37 @@ type AcceptInviteCommand struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher) *AcceptInviteUseCase {
|
||||
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher}
|
||||
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, recorder audit.Recorder) *AcceptInviteUseCase {
|
||||
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher, recorder}
|
||||
}
|
||||
|
||||
func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.User, error) {
|
||||
func (a *AcceptInviteUseCase) AcceptInvite(ctx context.Context, command AcceptInviteCommand) (user.User, error) {
|
||||
event := audit.NewEvent(audit.ActionInviteAccept, audit.CategoryUsers)
|
||||
now := time.Now()
|
||||
|
||||
pending, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now)
|
||||
if err != nil {
|
||||
a.recordRefusal(ctx, event, command, err)
|
||||
return user.User{}, err
|
||||
}
|
||||
event.Target = inviteTarget(pending)
|
||||
|
||||
created, err := a.accept(command, pending, now)
|
||||
if err != nil {
|
||||
a.recordRefusal(ctx, event, command, err)
|
||||
return user.User{}, err
|
||||
}
|
||||
event.Actor = audit.UserActor(created)
|
||||
a.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (a *AcceptInviteUseCase) recordRefusal(ctx context.Context, event audit.Event, command AcceptInviteCommand, cause error) {
|
||||
event.Actor = audit.AnonymousActor(command.Username)
|
||||
recordFailure(ctx, a.recorder, event, cause)
|
||||
}
|
||||
|
||||
func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, now time.Time) (user.User, error) {
|
||||
if a.emailHasAccount(pending.Email) {
|
||||
if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil {
|
||||
return user.User{}, err
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/invite"
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"context"
|
||||
)
|
||||
|
||||
type CancelInviteUseCase struct {
|
||||
inviteRepository invite.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCancelInviteUseCase(inviteRepository invite.Repository) *CancelInviteUseCase {
|
||||
return &CancelInviteUseCase{inviteRepository}
|
||||
func NewCancelInviteUseCase(inviteRepository invite.Repository, recorder audit.Recorder) *CancelInviteUseCase {
|
||||
return &CancelInviteUseCase{inviteRepository, recorder}
|
||||
}
|
||||
|
||||
func (c *CancelInviteUseCase) CancelInvite(id uint32) error {
|
||||
if _, err := findManageableInvite(c.inviteRepository, id); err != nil {
|
||||
func (c *CancelInviteUseCase) CancelInvite(ctx context.Context, id uint32) error {
|
||||
event := audit.NewEvent(audit.ActionInviteCancel, audit.CategoryUsers)
|
||||
pending, err := findManageableInvite(c.inviteRepository, id)
|
||||
if err != nil {
|
||||
recordFailure(ctx, c.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
return c.inviteRepository.Delete(id)
|
||||
event.Target = inviteTarget(pending)
|
||||
if err := c.inviteRepository.Delete(id); err != nil {
|
||||
recordFailure(ctx, c.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
c.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func inviteTarget(i invite.Invite) audit.Target {
|
||||
return audit.NewTarget(audit.TargetInvite, i.ID, i.Email)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -10,13 +12,27 @@ type CheckForUpdateUseCase struct {
|
||||
store update.StatusStore
|
||||
currentVersion string
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCheckForUpdateUseCase(feed update.ReleaseFeed, store update.StatusStore, currentVersion string, now func() time.Time) *CheckForUpdateUseCase {
|
||||
return &CheckForUpdateUseCase{feed: feed, store: store, currentVersion: currentVersion, now: now}
|
||||
func NewCheckForUpdateUseCase(feed update.ReleaseFeed, store update.StatusStore, currentVersion string, now func() time.Time, recorder audit.Recorder) *CheckForUpdateUseCase {
|
||||
return &CheckForUpdateUseCase{feed: feed, store: store, currentVersion: currentVersion, now: now, recorder: recorder}
|
||||
}
|
||||
|
||||
func (c *CheckForUpdateUseCase) Check() (update.UpdateStatus, error) {
|
||||
func (c *CheckForUpdateUseCase) Check(ctx context.Context) (update.UpdateStatus, error) {
|
||||
event := audit.NewEvent(audit.ActionUpdateChecked, audit.CategorySystem)
|
||||
status, err := c.check()
|
||||
if err != nil {
|
||||
recordFailure(ctx, c.recorder, event, err)
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
event.Target = audit.ReleaseTarget(status.Latest)
|
||||
event.Params = map[string]any{audit.ParamUpdateAvailable: status.Available}
|
||||
c.recorder.Record(ctx, event)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *CheckForUpdateUseCase) check() (update.UpdateStatus, error) {
|
||||
release, err := c.feed.Latest()
|
||||
if err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
|
||||
@@ -2,11 +2,14 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
)
|
||||
|
||||
type CreateArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateArticleCommand struct {
|
||||
@@ -15,12 +18,14 @@ type CreateArticleCommand struct {
|
||||
Format article.BodyFormat
|
||||
}
|
||||
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository, bodyRenderer}
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository, bodyRenderer, recorder}
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
|
||||
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 {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
@@ -30,7 +35,15 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand)
|
||||
IsOnline: false,
|
||||
})
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
event.Target = articleTarget(created)
|
||||
g.recorder.Record(ctx, event)
|
||||
return renderArticle(g.bodyRenderer, created, createArticle.Format)
|
||||
}
|
||||
|
||||
func articleTarget(a article.Article) audit.Target {
|
||||
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
stdimage "image"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
_ "image/jpeg"
|
||||
@@ -25,13 +28,38 @@ var rasterFormats = map[string]string{
|
||||
type CreateImageUseCase struct {
|
||||
imageRepository image.Repository
|
||||
storage image.Storage
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage) *CreateImageUseCase {
|
||||
return &CreateImageUseCase{imageRepository, storage}
|
||||
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *CreateImageUseCase {
|
||||
return &CreateImageUseCase{imageRepository, storage, recorder}
|
||||
}
|
||||
|
||||
func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, error) {
|
||||
func (g *CreateImageUseCase) CreateImage(ctx context.Context, input image.ImageInput) (image.Image, error) {
|
||||
created, err := g.create(input)
|
||||
event := audit.NewEvent(audit.ActionMediaUpload, audit.CategoryMedia)
|
||||
if err != nil {
|
||||
event.Target = attemptedUploadTarget(input)
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return image.Image{}, err
|
||||
}
|
||||
event.Target = imageTarget(created)
|
||||
g.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func imageTarget(img image.Image) audit.Target {
|
||||
return audit.NewTarget(audit.TargetImage, img.ID, img.Filename)
|
||||
}
|
||||
|
||||
func attemptedUploadTarget(input image.ImageInput) audit.Target {
|
||||
if strings.TrimSpace(input.Filename) == "" {
|
||||
return audit.Target{}
|
||||
}
|
||||
return audit.Target{Type: audit.TargetImage, Name: image.NormalizeUploadFilename(input.Filename, input.ContentType)}
|
||||
}
|
||||
|
||||
func (g *CreateImageUseCase) create(input image.ImageInput) (image.Image, error) {
|
||||
if !image.IsAllowedContentType(input.ContentType) {
|
||||
return image.Image{}, image.ErrImageTypeNotAllowed
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +17,7 @@ type CreateInviteUseCase struct {
|
||||
inviteRepository invite.Repository
|
||||
hasher hash.Hasher
|
||||
mailer inviteMailer
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateInviteCommand struct {
|
||||
@@ -22,11 +25,23 @@ type CreateInviteCommand struct {
|
||||
InvitedBy uint32
|
||||
}
|
||||
|
||||
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase {
|
||||
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}}
|
||||
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateInviteUseCase {
|
||||
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder}
|
||||
}
|
||||
|
||||
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
|
||||
func (g *CreateInviteUseCase) CreateInvite(ctx context.Context, command CreateInviteCommand) (invite.Invite, error) {
|
||||
event := audit.NewEvent(audit.ActionInviteCreate, audit.CategoryUsers)
|
||||
created, err := g.create(command)
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
event.Target = inviteTarget(created)
|
||||
g.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite, error) {
|
||||
email := strings.ToLower(command.Email)
|
||||
now := time.Now()
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -16,6 +18,7 @@ type CreateUserUseCase struct {
|
||||
hasher hash.Hasher
|
||||
mailRepository mail.Repository
|
||||
publicURL string
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateUserCommand struct {
|
||||
@@ -29,11 +32,25 @@ type MailValidationData struct {
|
||||
VerificationCode string
|
||||
}
|
||||
|
||||
func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateUserUseCase {
|
||||
return &CreateUserUseCase{userRepository, hasher, mailRepository, publicURL}
|
||||
func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *CreateUserUseCase {
|
||||
return &CreateUserUseCase{userRepository, hasher, mailRepository, publicURL, recorder}
|
||||
}
|
||||
|
||||
func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User, error) {
|
||||
func (g *CreateUserUseCase) CreateUser(ctx context.Context, createUser CreateUserCommand) (user.User, error) {
|
||||
event := audit.NewEvent(audit.ActionUserRegister, audit.CategoryUsers)
|
||||
created, err := g.create(createUser)
|
||||
if err != nil {
|
||||
event.Actor = audit.AnonymousActor(createUser.Username)
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return user.User{}, err
|
||||
}
|
||||
event.Actor = audit.UserActor(created)
|
||||
event.Target = audit.NewTarget(audit.TargetUser, created.ID, created.Username)
|
||||
g.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, error) {
|
||||
if g.registrationClosed() {
|
||||
return user.User{}, user.ErrRegistrationClosed
|
||||
}
|
||||
@@ -70,7 +87,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
|
||||
Host: g.publicURL,
|
||||
VerificationCode: rawUuid,
|
||||
}); err != nil {
|
||||
sendErr := fmt.Errorf("failed to send verification email: %w", err)
|
||||
sendErr := fmt.Errorf("%w: %w", user.ErrVerificationEmailNotSent, err)
|
||||
if deleteErr := g.userRepository.Delete(createdUser.ID); deleteErr != nil {
|
||||
return user.User{}, errors.Join(sendErr, deleteErr)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/article"
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
)
|
||||
|
||||
type DeleteArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteArticleUseCase(articleRepository article.Repository) *DeleteArticleUseCase {
|
||||
return &DeleteArticleUseCase{articleRepository}
|
||||
func NewDeleteArticleUseCase(articleRepository article.Repository, recorder audit.Recorder) *DeleteArticleUseCase {
|
||||
return &DeleteArticleUseCase{articleRepository, recorder}
|
||||
}
|
||||
|
||||
func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error {
|
||||
return g.articleRepository.Delete(id)
|
||||
func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32) error {
|
||||
target, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionArticleDelete, audit.CategoryArticles)
|
||||
event.Target = articleTarget(target)
|
||||
if err := g.articleRepository.Delete(id); err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
g.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +1,33 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type DeleteMediaUseCase struct {
|
||||
imageRepository image.Repository
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}}
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}, recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteMediaUseCase) Delete(id uint32) error {
|
||||
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
|
||||
img, err := u.imageRepository.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.remover.remove(img)
|
||||
|
||||
event := audit.NewEvent(audit.ActionMediaDelete, audit.CategoryMedia)
|
||||
event.Target = imageTarget(img)
|
||||
if err := u.remover.remove(img); err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
@@ -11,11 +11,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
targetArticle = "article"
|
||||
targetImage = "image"
|
||||
)
|
||||
|
||||
type ListActivityUseCase struct {
|
||||
events audit.Repository
|
||||
users user.Repository
|
||||
@@ -118,7 +113,7 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
switch target.Type {
|
||||
case targetArticle:
|
||||
case audit.TargetArticle:
|
||||
_, err = l.articles.Get(uint32(id))
|
||||
return existsUnless(err, article.ErrArticleNotFound)
|
||||
case audit.TargetUser:
|
||||
@@ -127,7 +122,7 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
|
||||
case audit.TargetAPIKey:
|
||||
_, err = l.apiKeys.Get(uint32(id))
|
||||
return existsUnless(err, apikey.ErrAPIKeyNotFound)
|
||||
case targetImage:
|
||||
case audit.TargetImage:
|
||||
_, err = l.images.Get(uint32(id))
|
||||
return existsUnless(err, image.ErrImageNotFound)
|
||||
default:
|
||||
|
||||
@@ -2,8 +2,10 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -19,10 +21,11 @@ type MergeMediaUseCase struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}}
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, recorder audit.Recorder) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}, recorder}
|
||||
}
|
||||
|
||||
type bodyRewrite struct {
|
||||
@@ -31,7 +34,30 @@ type bodyRewrite struct {
|
||||
rewritten string
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
|
||||
func (u *MergeMediaUseCase) Merge(ctx context.Context, keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
|
||||
result, err := u.merge(keepId, mergeIds)
|
||||
event := audit.NewEvent(audit.ActionMediaMerge, audit.CategoryMedia)
|
||||
if kept, found := u.keptImage(keepId); found {
|
||||
event.Target = imageTarget(kept)
|
||||
}
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
event.Params = map[string]any{audit.ParamMerged: result.Merged, audit.ParamArticlesUpdated: result.ArticlesUpdated}
|
||||
u.recorder.Record(ctx, event)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) keptImage(keepId uint32) (image.Image, bool) {
|
||||
if keepId == 0 {
|
||||
return image.Image{}, false
|
||||
}
|
||||
kept, err := u.imageRepository.Get(keepId)
|
||||
return kept, err == nil
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
|
||||
if err := validateMergeRequest(keepId, mergeIds); err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PurgeRunner interface {
|
||||
Run(fingerprint *string) (purge.RunResult, error)
|
||||
Execute(fingerprint *string) (purge.RunResult, error)
|
||||
}
|
||||
|
||||
type PurgeSchedulerUseCase struct {
|
||||
repository purge.Repository
|
||||
runner PurgeRunner
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner) *PurgeSchedulerUseCase {
|
||||
return &PurgeSchedulerUseCase{repository, runner}
|
||||
func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner, recorder audit.Recorder) *PurgeSchedulerUseCase {
|
||||
return &PurgeSchedulerUseCase{repository, runner, recorder}
|
||||
}
|
||||
|
||||
func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
@@ -27,9 +31,29 @@ func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, erro
|
||||
if next == nil || now.Before(*next) {
|
||||
return purge.RunResult{}, false, nil
|
||||
}
|
||||
result, err := u.runner.Run(nil)
|
||||
result, err := u.runner.Execute(nil)
|
||||
if err != nil {
|
||||
u.recordFailedRun(err)
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
if result.GroupsTotal > 0 {
|
||||
event := systemPurgeEvent()
|
||||
event.Params = purgeParams(result)
|
||||
u.recorder.Record(context.Background(), event)
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func (u *PurgeSchedulerUseCase) recordFailedRun(err error) {
|
||||
if errors.Is(err, purge.ErrPurgeBusy) {
|
||||
return
|
||||
}
|
||||
failFrom(context.Background(), u.recorder, systemPurgeEvent(), reasonFor(err))
|
||||
}
|
||||
|
||||
func systemPurgeEvent() audit.Event {
|
||||
event := audit.NewEvent(audit.ActionMediaPurgeScheduled, audit.CategorySystem)
|
||||
event.Actor = audit.SystemActor()
|
||||
event.Source = audit.SourceSystem
|
||||
return event
|
||||
}
|
||||
@@ -1,25 +1,43 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type RenameMediaUseCase struct {
|
||||
imageRepository image.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewRenameMediaUseCase(imageRepository image.Repository) *RenameMediaUseCase {
|
||||
return &RenameMediaUseCase{imageRepository}
|
||||
func NewRenameMediaUseCase(imageRepository image.Repository, recorder audit.Recorder) *RenameMediaUseCase {
|
||||
return &RenameMediaUseCase{imageRepository, recorder}
|
||||
}
|
||||
|
||||
func (u *RenameMediaUseCase) Rename(id uint32, stem string) (image.Image, error) {
|
||||
validStem, err := image.ValidateFilenameStem(stem)
|
||||
if err != nil {
|
||||
return image.Image{}, err
|
||||
}
|
||||
|
||||
func (u *RenameMediaUseCase) Rename(ctx context.Context, id uint32, stem string) (image.Image, error) {
|
||||
current, err := u.imageRepository.Get(id)
|
||||
if err != nil {
|
||||
return image.Image{}, err
|
||||
}
|
||||
|
||||
return u.imageRepository.Rename(id, validStem+image.ExtensionOf(current.Filename))
|
||||
event := audit.NewEvent(audit.ActionMediaRename, audit.CategoryMedia)
|
||||
event.Target = imageTarget(current)
|
||||
validStem, err := image.ValidateFilenameStem(stem)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return image.Image{}, err
|
||||
}
|
||||
|
||||
renamed, err := u.imageRepository.Rename(id, validStem+image.ExtensionOf(current.Filename))
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return image.Image{}, err
|
||||
}
|
||||
if renamed.Filename != current.Filename {
|
||||
event.Target = imageTarget(renamed)
|
||||
event.Changes = []audit.Change{audit.TextChange(audit.FieldFilename, current.Filename, renamed.Filename)}
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
return renamed, nil
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
@@ -14,6 +16,7 @@ type RenewInviteUseCase struct {
|
||||
inviteRepository invite.Repository
|
||||
hasher hash.Hasher
|
||||
mailer inviteMailer
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type RenewInviteCommand struct {
|
||||
@@ -21,11 +24,23 @@ type RenewInviteCommand struct {
|
||||
RenewedBy uint32
|
||||
}
|
||||
|
||||
func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *RenewInviteUseCase {
|
||||
return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}}
|
||||
func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RenewInviteUseCase {
|
||||
return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}, recorder}
|
||||
}
|
||||
|
||||
func (u *RenewInviteUseCase) RenewInvite(command RenewInviteCommand) (invite.Invite, error) {
|
||||
func (u *RenewInviteUseCase) RenewInvite(ctx context.Context, command RenewInviteCommand) (invite.Invite, error) {
|
||||
event := audit.NewEvent(audit.ActionInviteRenew, audit.CategoryUsers)
|
||||
renewed, err := u.renew(command)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
event.Target = inviteTarget(renewed)
|
||||
u.recorder.Record(ctx, event)
|
||||
return renewed, nil
|
||||
}
|
||||
|
||||
func (u *RenewInviteUseCase) renew(command RenewInviteCommand) (invite.Invite, error) {
|
||||
now := time.Now()
|
||||
|
||||
renewer, err := u.userRepository.Get(command.RenewedBy)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -13,10 +15,11 @@ type RequestRestartUseCase struct {
|
||||
requests update.RestartRequestStore
|
||||
status updateStatusBuilder
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewRequestRestartUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time) *RequestRestartUseCase {
|
||||
func NewRequestRestartUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time, recorder audit.Recorder) *RequestRestartUseCase {
|
||||
return &RequestRestartUseCase{
|
||||
jobs: jobs,
|
||||
helper: helper,
|
||||
@@ -24,12 +27,33 @@ func NewRequestRestartUseCase(store update.StatusStore, jobs update.JobStore, he
|
||||
requests: requests,
|
||||
status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
|
||||
now: now,
|
||||
recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RequestRestartUseCase) Request(userId uint32, kind string) (update.UpdateStatus, error) {
|
||||
func (r *RequestRestartUseCase) Request(ctx context.Context, userId uint32, kind string) (update.UpdateStatus, error) {
|
||||
event := audit.NewEvent(audit.ActionRestartRequested, audit.CategorySystem)
|
||||
status, request, err := r.request(userId, kind)
|
||||
if err != nil {
|
||||
recordFailure(ctx, r.recorder, event, err, restartFailureReasons...)
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
event.Params = restartParams(request)
|
||||
r.recorder.Record(ctx, event)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func restartParams(request update.RestartRequest) map[string]any {
|
||||
params := map[string]any{audit.ParamKind: request.Kind}
|
||||
if request.Kind == update.RestartKindUpdate {
|
||||
params[audit.ParamTag] = request.Tag
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (r *RequestRestartUseCase) request(userId uint32, kind string) (update.UpdateStatus, update.RestartRequest, error) {
|
||||
if !r.helper.Installed() {
|
||||
return update.UpdateStatus{}, update.ErrNotPrepared
|
||||
return update.UpdateStatus{}, update.RestartRequest{}, update.ErrNotPrepared
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
@@ -37,19 +61,19 @@ func (r *RequestRestartUseCase) Request(userId uint32, kind string) (update.Upda
|
||||
|
||||
current := r.status.build(true)
|
||||
if err := refuseWhileBusy(current); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
return update.UpdateStatus{}, update.RestartRequest{}, err
|
||||
}
|
||||
request, err := r.requestFor(kind, userId)
|
||||
if err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
return update.UpdateStatus{}, update.RestartRequest{}, err
|
||||
}
|
||||
if err := r.results.Delete(); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
return update.UpdateStatus{}, update.RestartRequest{}, err
|
||||
}
|
||||
if err := r.requests.Write(request); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
return update.UpdateStatus{}, update.RestartRequest{}, err
|
||||
}
|
||||
return r.status.build(true), nil
|
||||
return r.status.build(true), request, nil
|
||||
}
|
||||
|
||||
func refuseWhileBusy(status update.UpdateStatus) error {
|
||||
|
||||
@@ -2,8 +2,10 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
@@ -16,20 +18,42 @@ type RunPurgeUseCase struct {
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
recorder audit.Recorder
|
||||
guard sync.Mutex
|
||||
}
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger) *RunPurgeUseCase {
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository, articleRepository},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
logger: logger,
|
||||
recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Run(fingerprint *string) (purge.RunResult, error) {
|
||||
func (u *RunPurgeUseCase) Run(ctx context.Context, fingerprint *string) (purge.RunResult, error) {
|
||||
result, err := u.Execute(fingerprint)
|
||||
event := audit.NewEvent(audit.ActionMediaPurge, audit.CategoryMedia)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
event.Params = purgeParams(result)
|
||||
u.recorder.Record(ctx, event)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func purgeParams(result purge.RunResult) map[string]any {
|
||||
params := map[string]any{audit.ParamMerged: result.Merged, audit.ParamArticlesUpdated: result.ArticlesUpdated}
|
||||
if result.FailedGroups > 0 {
|
||||
params[audit.ParamFailedGroups] = result.FailedGroups
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Execute(fingerprint *string) (purge.RunResult, error) {
|
||||
if !u.guard.TryLock() {
|
||||
return purge.RunResult{}, purge.ErrPurgeBusy
|
||||
}
|
||||
@@ -55,7 +79,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
result := purge.RunResult{GroupsTotal: len(plan.groups)}
|
||||
updated := map[uint32]bool{}
|
||||
for _, group := range plan.groups {
|
||||
merged, err := u.merge.Merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
merged, err := u.merge.merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
if err != nil {
|
||||
result.FailedGroups++
|
||||
continue
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
@@ -24,6 +26,7 @@ type StartUpdateDownloadUseCase struct {
|
||||
platform update.Platform
|
||||
feedHost string
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -43,6 +46,7 @@ type StartUpdateDownloadDependencies struct {
|
||||
Platform update.Platform
|
||||
FeedURL string
|
||||
Now func() time.Time
|
||||
Recorder audit.Recorder
|
||||
}
|
||||
|
||||
type jobFailure struct {
|
||||
@@ -78,10 +82,19 @@ func NewStartUpdateDownloadUseCase(dependencies StartUpdateDownloadDependencies)
|
||||
platform: dependencies.Platform,
|
||||
feedHost: update.HostOf(dependencies.FeedURL),
|
||||
now: dependencies.Now,
|
||||
recorder: dependencies.Recorder,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) {
|
||||
func (s *StartUpdateDownloadUseCase) Start(ctx context.Context) (update.UpdateStatus, error) {
|
||||
status, err := s.start(ctx)
|
||||
if err != nil {
|
||||
recordFailure(ctx, s.recorder, audit.NewEvent(audit.ActionUpdateDownloaded, audit.CategorySystem), err)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (s *StartUpdateDownloadUseCase) start(ctx context.Context) (update.UpdateStatus, error) {
|
||||
if !s.helper.Installed() {
|
||||
return update.UpdateStatus{}, update.ErrNotPrepared
|
||||
}
|
||||
@@ -118,7 +131,7 @@ func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
status := s.status.build(true)
|
||||
go s.run(job, binary, checksums, signature)
|
||||
go s.run(context.WithoutCancel(ctx), job, binary, checksums, signature)
|
||||
|
||||
return status, nil
|
||||
}
|
||||
@@ -139,15 +152,19 @@ func (s *StartUpdateDownloadUseCase) preflight(result update.CheckResult) (binar
|
||||
return found[0], found[1], found[2], nil
|
||||
}
|
||||
|
||||
func (s *StartUpdateDownloadUseCase) run(job update.Job, binary, checksums, signature update.Asset) {
|
||||
func (s *StartUpdateDownloadUseCase) run(ctx context.Context, job update.Job, binary, checksums, signature update.Asset) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
s.fail(job, downloadFailure(fmt.Errorf("panic: %v", recovered)))
|
||||
s.fail(ctx, job, downloadFailure(fmt.Errorf("panic: %v", recovered)))
|
||||
}
|
||||
}()
|
||||
if err := s.execute(&job, binary, checksums, signature); err != nil {
|
||||
s.fail(job, err)
|
||||
s.fail(ctx, job, err)
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionUpdateDownloaded, audit.CategorySystem)
|
||||
event.Target = audit.ReleaseTarget(job.Tag)
|
||||
s.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func (s *StartUpdateDownloadUseCase) execute(job *update.Job, binary, checksums, signature update.Asset) error {
|
||||
@@ -263,7 +280,7 @@ func downloadFailure(err error) error {
|
||||
return jobFailure{kind: update.ErrorKindDownload, reason: err}
|
||||
}
|
||||
|
||||
func (s *StartUpdateDownloadUseCase) fail(job update.Job, err error) {
|
||||
func (s *StartUpdateDownloadUseCase) fail(ctx context.Context, job update.Job, err error) {
|
||||
kind := update.ErrorKindDownload
|
||||
var failure jobFailure
|
||||
if errors.As(err, &failure) {
|
||||
@@ -279,4 +296,7 @@ func (s *StartUpdateDownloadUseCase) fail(job update.Job, err error) {
|
||||
if saveErr := s.jobs.Save(job); saveErr != nil {
|
||||
s.logger.Printf("update job record could not be saved: %v", saveErr)
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionUpdateDownloaded, audit.CategorySystem)
|
||||
event.Target = audit.ReleaseTarget(job.Tag)
|
||||
failFrom(ctx, s.recorder, event, downloadFailureReasons[kind])
|
||||
}
|
||||
@@ -1,28 +1,69 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/article"
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
)
|
||||
|
||||
type UpdateArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository, bodyRenderer}
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository, bodyRenderer, recorder}
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) {
|
||||
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
|
||||
before, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.UpdateBody(id, body)
|
||||
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)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
|
||||
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
|
||||
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
|
||||
before, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, publishAction(isOnline), before, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
g.recordChange(ctx, publishAction(isOnline), before, updated)
|
||||
return renderArticle(g.bodyRenderer, updated, format)
|
||||
}
|
||||
|
||||
func publishAction(isOnline bool) string {
|
||||
if isOnline {
|
||||
return audit.ActionArticlePublish
|
||||
}
|
||||
return audit.ActionArticleUnpublish
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) recordChange(ctx context.Context, action string, before article.Article, after article.Article) {
|
||||
changes := audit.ArticleChanges(before, after)
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(after)
|
||||
event.Changes = changes
|
||||
g.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) recordFailure(ctx context.Context, action string, before article.Article, cause error) {
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(before)
|
||||
recordFailure(ctx, g.recorder, event, cause)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -10,16 +12,40 @@ const maxDisplayNameRunes = 60
|
||||
|
||||
type UpdateUserProfileUseCase struct {
|
||||
userRepository user.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateUserProfileUseCase(userRepository user.Repository) *UpdateUserProfileUseCase {
|
||||
return &UpdateUserProfileUseCase{userRepository}
|
||||
func NewUpdateUserProfileUseCase(userRepository user.Repository, recorder audit.Recorder) *UpdateUserProfileUseCase {
|
||||
return &UpdateUserProfileUseCase{userRepository, recorder}
|
||||
}
|
||||
|
||||
func (u *UpdateUserProfileUseCase) UpdateDisplayName(userId uint32, displayName string) (user.User, error) {
|
||||
func (u *UpdateUserProfileUseCase) UpdateDisplayName(ctx context.Context, userId uint32, displayName string) (user.User, error) {
|
||||
before, err := u.userRepository.Get(userId)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionUserUpdateProfile, audit.CategoryUsers)
|
||||
event.Target = userTarget(before)
|
||||
trimmed := strings.TrimSpace(displayName)
|
||||
if utf8.RuneCountInString(trimmed) > maxDisplayNameRunes {
|
||||
recordFailure(ctx, u.recorder, event, user.ErrInvalidDisplayName)
|
||||
return user.User{}, user.ErrInvalidDisplayName
|
||||
}
|
||||
return u.userRepository.UpdateDisplayName(userId, trimmed)
|
||||
|
||||
updated, err := u.userRepository.UpdateDisplayName(userId, trimmed)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return user.User{}, err
|
||||
}
|
||||
if updated.DisplayName != before.DisplayName {
|
||||
event.Target = userTarget(updated)
|
||||
event.Changes = []audit.Change{audit.TextChange(audit.FieldDisplayName, before.DisplayName, updated.DisplayName)}
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func userTarget(u user.User) audit.Target {
|
||||
return audit.NewTarget(audit.TargetUser, u.ID, audit.UserActor(u).Name)
|
||||
}
|
||||
@@ -1,26 +1,42 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VerifyUserUseCase struct {
|
||||
userRepository user.Repository
|
||||
hasher hash.Hasher
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewVerifyUserUseCase(userRepository user.Repository, hasher hash.Hasher) *VerifyUserUseCase {
|
||||
return &VerifyUserUseCase{userRepository, hasher}
|
||||
func NewVerifyUserUseCase(userRepository user.Repository, hasher hash.Hasher, recorder audit.Recorder) *VerifyUserUseCase {
|
||||
return &VerifyUserUseCase{userRepository, hasher, recorder}
|
||||
}
|
||||
|
||||
func (v *VerifyUserUseCase) Verify(userId uint32, code string) (user.User, error) {
|
||||
func (v *VerifyUserUseCase) Verify(ctx context.Context, userId uint32, code string) (user.User, error) {
|
||||
dbUser, err := v.userRepository.Get(userId)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionUserVerify, audit.CategoryUsers)
|
||||
event.Actor = audit.UserActor(dbUser)
|
||||
event.Target = audit.NewTarget(audit.TargetUser, dbUser.ID, dbUser.Username)
|
||||
verified, err := v.verify(dbUser, code)
|
||||
if err != nil {
|
||||
recordFailure(ctx, v.recorder, event, err)
|
||||
return user.User{}, err
|
||||
}
|
||||
v.recorder.Record(ctx, event)
|
||||
return verified, nil
|
||||
}
|
||||
|
||||
func (v *VerifyUserUseCase) verify(dbUser user.User, code string) (user.User, error) {
|
||||
if dbUser.VerificationCode == "" || v.hasher.Compare(dbUser.VerificationCode, code) != nil {
|
||||
return user.User{}, user.ErrInvalidVerificationCode
|
||||
}
|
||||
@@ -29,5 +45,5 @@ func (v *VerifyUserUseCase) Verify(userId uint32, code string) (user.User, error
|
||||
return user.User{}, user.ErrVerificationCodeExpired
|
||||
}
|
||||
|
||||
return v.userRepository.ClearVerificationCode(userId)
|
||||
return v.userRepository.ClearVerificationCode(dbUser.ID)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
)
|
||||
|
||||
const GenericFailureReason = "Something went wrong on our side. Please try again."
|
||||
|
||||
type failureReason struct {
|
||||
cause error
|
||||
reason string
|
||||
}
|
||||
|
||||
var failureReasons = []failureReason{
|
||||
{article.ErrInvalidTitle, "The title must be between 3 and 200 characters."},
|
||||
{image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."},
|
||||
{image.ErrImageTooLarge, "The file is too large to upload."},
|
||||
{image.ErrImageUnreadable, "The image could not be read."},
|
||||
{image.ErrInvalidFilename, "That file name is not valid."},
|
||||
{image.ErrInvalidMergeRequest, "The merge request is not valid."},
|
||||
{image.ErrMergeCopyMissing, "One of the copies no longer exists."},
|
||||
{image.ErrMergeHashMismatch, "The images are not identical copies."},
|
||||
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
|
||||
{purge.ErrPurgeBusy, "A purge is already running."},
|
||||
{user.ErrRegistrationClosed, "Registration is closed on this site."},
|
||||
{user.ErrVerificationEmailNotSent, "The verification email could not be sent."},
|
||||
{user.ErrInvalidVerificationCode, "The verification code is not valid."},
|
||||
{user.ErrVerificationCodeExpired, "The verification code has expired."},
|
||||
{user.ErrInvalidDisplayName, "That display name is too long."},
|
||||
{user.ErrUsernameTaken, "That username is already taken."},
|
||||
{invite.ErrEmailHasAccount, "That email already has an account."},
|
||||
{invite.ErrPendingInvite, "An invitation is already pending for that email."},
|
||||
{invite.ErrInviteEmailNotSent, "The invitation email could not be sent."},
|
||||
{invite.ErrInviteAlreadyAccepted, "That invitation was already accepted."},
|
||||
{invite.ErrInviteInvalid, "That invitation is not valid."},
|
||||
{invite.ErrInviteExpired, "That invitation has expired."},
|
||||
{update.ErrFeedUnavailable, "The release feed could not be reached."},
|
||||
{update.ErrNoRelease, "No release was found."},
|
||||
{update.ErrNotPrepared, "This server cannot apply updates."},
|
||||
{update.ErrJobInProgress, "An update is already being downloaded."},
|
||||
{update.ErrRestartPending, "A restart is already pending."},
|
||||
{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."},
|
||||
}
|
||||
|
||||
var restartFailureReasons = []failureReason{
|
||||
{update.ErrJobInProgress, "An update is still being downloaded."},
|
||||
}
|
||||
|
||||
var downloadFailureReasons = map[string]string{
|
||||
update.ErrorKindDownload: "The update could not be downloaded.",
|
||||
update.ErrorKindVerification: "The downloaded update failed its integrity check.",
|
||||
}
|
||||
|
||||
var notFoundCauses = []error{
|
||||
article.ErrArticleNotFound,
|
||||
image.ErrImageNotFound,
|
||||
invite.ErrInviteNotFound,
|
||||
user.ErrUserNotFound,
|
||||
}
|
||||
|
||||
func reasonFor(err error, overrides ...failureReason) string {
|
||||
for _, candidate := range slices.Concat(overrides, failureReasons) {
|
||||
if errors.Is(err, candidate.cause) {
|
||||
return candidate.reason
|
||||
}
|
||||
}
|
||||
return GenericFailureReason
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
for _, cause := range notFoundCauses {
|
||||
if errors.Is(err, cause) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recordFailure(ctx context.Context, recorder audit.Recorder, event audit.Event, err error, overrides ...failureReason) {
|
||||
if isNotFound(err) {
|
||||
return
|
||||
}
|
||||
failFrom(ctx, recorder, event, reasonFor(err, overrides...))
|
||||
}
|
||||
|
||||
func failFrom(ctx context.Context, recorder audit.Recorder, event audit.Event, reason string) {
|
||||
event.Outcome = audit.OutcomeFailed
|
||||
event.FailureReason = &reason
|
||||
recorder.Record(ctx, event)
|
||||
}
|
||||
Reference in new issue
Block a user