feat: activity log producers

This commit is contained in:
fsylvain committed 2026-10-06 22:25:23 +02:00
1 parent 50b1649965
commit e37a46a027
93 files changed
+3396 -440

No files matched your search

+30
View File
@@ -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
}
+38 -2
View File
@@ -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
+11
View File
@@ -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}
}
+1
View File
@@ -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()
+21 -4
View File
@@ -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:
+29 -3
View File
@@ -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
}
+28 -10
View File
@@ -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 {
+27 -3
View File
@@ -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)
}
+20 -4
View File
@@ -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)
}