feat: activity log backend foundation

This commit is contained in:
fsylvain committed 2026-10-06 20:35:03 +02:00
1 parent 8907bb49fe
commit 50b1649965
74 files changed
+5949 -239

No files matched your search

+126 -90
View File
@@ -3,12 +3,14 @@ package api
import (
domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
@@ -19,6 +21,7 @@ import (
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
"log"
"net/http"
@@ -32,52 +35,73 @@ import (
)
type UseCases struct {
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
}
func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
if runner == nil {
continue
}
running.Add(1)
go func() {
defer running.Done()
runner.Run(ctx)
}()
}
running.Wait()
}
type Repositories struct {
@@ -87,6 +111,8 @@ type Repositories struct {
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
@@ -217,6 +243,8 @@ func getRepositories(connection *database.Connection, settings config.Settings)
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
@@ -240,52 +268,60 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default())
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge)
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder),
}
}
+243
View File
@@ -0,0 +1,243 @@
package activity
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
)
const (
csvContentType = "text/csv; charset=utf-8"
csvContentDisposition = `attachment; filename="activity.csv"`
)
type Handler struct {
ListUseCase *useCases.ListActivityUseCase
SummaryUseCase *useCases.GetActivitySummaryUseCase
ExportUseCase *useCases.ExportActivityUseCase
}
type filterQuery struct {
Q string `validate:"max=200"`
Actor string `validate:"max=40"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d custom"`
From string `validate:"max=10"`
To string `validate:"max=10"`
}
type listQuery struct {
filterQuery
Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100"`
}
type actorResponse struct {
Type audit.ActorType `json:"type"`
ID *uint32 `json:"id"`
Name string `json:"name"`
}
type targetResponse struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Exists bool `json:"exists"`
}
type itemResponse struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Actor actorResponse `json:"actor"`
Target *targetResponse `json:"target"`
Outcome audit.Outcome `json:"outcome"`
FailureReason *string `json:"failure_reason"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
Changes []audit.Change `json:"changes"`
}
type listResponse struct {
Items []itemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func NewHandler(list *useCases.ListActivityUseCase, summary *useCases.GetActivitySummaryUseCase, export *useCases.ExportActivityUseCase) *Handler {
return &Handler{ListUseCase: list, SummaryUseCase: summary, ExportUseCase: export}
}
func readFilterQuery(values url.Values) filterQuery {
return filterQuery{
Q: values.Get("q"),
Actor: values.Get("actor"),
Category: values.Get("category"),
Outcome: values.Get("outcome"),
Security: values.Get("security"),
Range: values.Get("range"),
From: values.Get("from"),
To: values.Get("to"),
}
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intParam(values, "page", 1)
if err != nil {
return listQuery{}, err
}
size, err := intParam(values, "size", 25)
if err != nil {
return listQuery{}, err
}
return listQuery{filterQuery: readFilterQuery(values), Page: page, Size: size}, nil
}
func intParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func toActivityQuery(query filterQuery) useCases.ActivityQuery {
return useCases.ActivityQuery{
Q: query.Q,
Actor: query.Actor,
Category: query.Category,
Outcome: query.Outcome,
Security: query.Security,
Range: query.Range,
From: query.From,
To: query.To,
}
}
func writeActivityError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
activityQuery := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
if err != nil {
writeActivityError(w, err)
return
}
items := make([]itemResponse, 0, len(page.Items))
for _, entry := range page.Items {
items = append(items, toItemResponse(entry))
}
writeJSON(w, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func toItemResponse(entry useCases.ActivityEntry) itemResponse {
event := entry.Event
item := itemResponse{
ID: event.ID,
CreatedAt: event.CreatedAt,
Action: event.Action,
Category: event.Category,
Actor: actorResponse{Type: event.Actor.Type, ID: event.Actor.ID, Name: event.Actor.Name},
Outcome: event.Outcome,
FailureReason: event.FailureReason,
Security: event.Security,
Source: event.Source,
IP: event.IP,
UserAgent: event.UserAgent,
Params: event.Params,
Changes: event.Changes,
}
if item.Params == nil {
item.Params = map[string]any{}
}
if item.Changes == nil {
item.Changes = []audit.Change{}
}
if event.Target.Type != "" {
item.Target = &targetResponse{Type: event.Target.Type, ID: event.Target.ID, Name: event.Target.Name, Exists: entry.TargetExists}
}
return item
}
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
summary, err := h.SummaryUseCase.GetActivitySummary(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
writeJSON(w, summary)
}
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
w.Header().Set("Content-Type", csvContentType)
w.Header().Set("Content-Disposition", csvContentDisposition)
w.Header().Set("Cache-Control", "no-store")
_ = prepared.WriteCSV(w)
}
func writeJSON(w http.ResponseWriter, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+2 -2
View File
@@ -71,7 +71,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
created, err := h.CreateUseCase.CreateAPIKey(useCases.CreateAPIKeyCommand{
created, err := h.CreateUseCase.CreateAPIKey(r.Context(), useCases.CreateAPIKeyCommand{
UserID: userId,
Name: request.Name,
ExpiresAt: request.ExpiresAt,
@@ -161,7 +161,7 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.RevokeUseCase.RevokeAPIKey(userId, uint32(id)); err != nil {
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
switch {
case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
+3 -2
View File
@@ -87,7 +87,7 @@ func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
dbUser, err := h.AuthenticateUseCase.Authenticate(credentials.Username, credentials.Password)
dbUser, err := h.AuthenticateUseCase.Authenticate(r.Context(), credentials.Username, credentials.Password)
if err != nil {
http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden)
return
@@ -139,7 +139,8 @@ func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request
_, _ = w.Write(body)
}
func (h *LoginHandler) Logout(w http.ResponseWriter, _ *http.Request) {
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.AuthenticateUseCase.Logout(r.Context())
removeJwtCookie(w, h.IsProduction)
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message)
+2 -2
View File
@@ -46,7 +46,7 @@ func (h *PasswordResetHandler) RequestPasswordReset(w http.ResponseWriter, r *ht
return
}
_ = h.RequestPasswordResetUseCase.RequestPasswordReset(credentials.Email)
_ = h.RequestPasswordResetUseCase.RequestPasswordReset(r.Context(), credentials.Email)
message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage})
_, _ = w.Write(message)
@@ -66,7 +66,7 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
return
}
err = h.ResetPasswordUseCase.ResetPassword(credentials.Email, credentials.Code, credentials.Password)
err = h.ResetPasswordUseCase.ResetPassword(r.Context(), credentials.Email, credentials.Code, credentials.Password)
if err != nil {
if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) {
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
+59 -4
View File
@@ -1,21 +1,76 @@
package settings
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type Handler struct {
GetSettingsUseCase *useCases.GetSettingsUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
}
func NewHandler(getSettings *useCases.GetSettingsUseCase) *Handler {
return &Handler{GetSettingsUseCase: getSettings}
type UpdateSettingsRequest struct {
ActivityRetentionDays *int `json:"activity_retention_days" validate:"required,oneof=0 30 90 365"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
}
func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler {
return &Handler{GetSettingsUseCase: getSettings, GetActivityRetentionUseCase: getRetention, UpdateActivityRetentionUseCase: updateRetention}
}
func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
body, err := json.Marshal(h.GetSettingsUseCase.GetSettings())
retention, err := h.GetActivityRetentionUseCase.GetActivityRetention()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
var request UpdateSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), *request.ActivityRetentionDays)
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
+8
View File
@@ -19,6 +19,8 @@ const bearerScheme = "bearer "
type apiKeyIdContextKey struct{}
type apiKeyNameContextKey struct{}
func bearerAPIKey(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
@@ -34,6 +36,11 @@ func apiKeyIdFrom(r *http.Request) (uint32, bool) {
return id, ok
}
func apiKeyNameFrom(r *http.Request) string {
name, _ := r.Context().Value(apiKeyNameContextKey{}).(string)
return name
}
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, isKey := bearerAPIKey(r); isKey {
@@ -70,6 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx))
})
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
)
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := audit.Origin{
Actor: auditActor(getUser, r),
Source: auditSource(r),
IP: clientIP(r, trustedProxies),
UserAgent: truncatedUserAgent(r),
}
next.ServeHTTP(w, r.WithContext(audit.WithOrigin(r.Context(), origin)))
})
}
}
func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if id, ok := apiKeyIdFrom(r); ok {
return audit.APIKeyActor(id, apiKeyNameFrom(r))
}
userId, ok := claims.UserId(r)
if !ok {
return audit.AnonymousActor("")
}
current, err := getUser.GetUser(userId)
if err != nil {
return audit.AnonymousActor("")
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != current.TokenVersion {
return audit.AnonymousActor("")
}
return audit.UserActor(current)
}
func auditSource(r *http.Request) audit.Source {
if _, ok := apiKeyIdFrom(r); ok {
return audit.SourceAPIKey
}
return audit.SourceWeb
}
func clientIP(r *http.Request, trustedProxies []string) string {
realIP := r.Header.Get("X-Real-IP")
if realIP != "" && isTrustedProxy(r.RemoteAddr, trustedProxies) {
return realIP
}
return hostOf(r.RemoteAddr)
}
func truncatedUserAgent(r *http.Request) string {
runes := []rune(r.Header.Get("User-Agent"))
if len(runes) > audit.MaxUserAgentLength {
runes = runes[:audit.MaxUserAgentLength]
}
return string(runes)
}
+20
View File
@@ -0,0 +1,20 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil || jwt.Validate(token) != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+46 -7
View File
@@ -1,6 +1,7 @@
package api
import (
"RenewCMS/api/handlers/activity"
"RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
@@ -71,6 +72,8 @@ func NewArticleRouter(container *UseCases) http.Handler {
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
@@ -140,17 +143,51 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
return r
}
func NewSettingsRouter(container *UseCases) http.Handler {
func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Patch("/", settingsHandler.UpdateSettings)
})
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings)
r.Get("/", activityHandler.List)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
return r
}
@@ -189,13 +226,14 @@ func NewSystemRouter(container *UseCases) http.Handler {
return r
}
func NewAPIKeyRouter(container *UseCases) http.Handler {
func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -244,9 +282,10 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/article", NewArticleRouter(container))
})
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container))
r.Mount("/api-key", NewAPIKeyRouter(container))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))