feat: activity log backend foundation
This commit is contained in:
1 parent
8907bb49fe
commit
50b1649965
74 files changed
+5949
-239
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user