feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API

This commit is contained in:
fsylvain committed 2026-10-05 18:24:03 +02:00
1 parent 161ddc0f7d
commit e82d0ce254
25 files changed
+2459 -41

No files matched your search

+14 -2
View File
@@ -6,6 +6,7 @@ import (
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/config"
@@ -62,6 +63,10 @@ type UseCases struct {
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
@@ -80,6 +85,7 @@ type Repositories struct {
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
@@ -116,7 +122,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
panic("Failed to migrate database: " + err.Error())
}
@@ -147,6 +153,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
@@ -166,6 +173,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
@@ -194,8 +202,12 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
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: useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default()),
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),
+21 -13
View File
@@ -19,12 +19,16 @@ import (
)
type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
ListUseCase *useCases.ListMediaUseCase
RenameUseCase *useCases.RenameMediaUseCase
DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase
CreateUseCase *useCases.CreateImageUseCase
ListUseCase *useCases.ListMediaUseCase
RenameUseCase *useCases.RenameMediaUseCase
DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase
PurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
}
type RenameRequest struct {
@@ -74,14 +78,18 @@ type mergeResponse struct {
Merged int `json:"merged"`
}
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase) *Handler {
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
RenameUseCase: rename,
DeleteUseCase: deleteUseCase,
MergeUseCase: merge,
UsageUseCase: usage,
CreateUseCase: create,
ListUseCase: list,
RenameUseCase: rename,
DeleteUseCase: deleteUseCase,
MergeUseCase: merge,
UsageUseCase: usage,
PurgeSettingsUseCase: purgeSettings,
UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge,
}
}
+169
View File
@@ -0,0 +1,169 @@
package media
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type PurgeSettingsRequest struct {
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
}
type PurgeRunRequest struct {
Fingerprint *string `json:"fingerprint"`
}
type purgeSettingsResponse struct {
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
LastPurgeAt *time.Time `json:"last_purge_at"`
LastPurgeMerged int `json:"last_purge_merged"`
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
EnabledAt *time.Time `json:"enabled_at"`
NextPurgeAt *time.Time `json:"next_purge_at"`
}
type purgePreviewGroupResponse struct {
KeepID uint32 `json:"keep_id"`
Filename string `json:"filename"`
URL string `json:"url"`
UsedIn int `json:"used_in"`
Merged int `json:"merged"`
}
type purgePreviewResponse struct {
Groups int `json:"groups"`
Copies int `json:"copies"`
Articles int `json:"articles"`
List []purgePreviewGroupResponse `json:"list"`
More int `json:"more"`
Fingerprint string `json:"fingerprint"`
}
type purgeRunResponse struct {
GroupsTotal int `json:"groups_total"`
GroupsPurged int `json:"groups_purged"`
Merged int `json:"merged"`
ArticlesUpdated int `json:"articles_updated"`
FailedGroups int `json:"failed_groups"`
}
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
return purgeSettingsResponse{
AutoPurgeEnabled: settings.AutoPurgeEnabled,
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
LastPurgeAt: settings.LastPurgeAt,
LastPurgeMerged: settings.LastPurgeMerged,
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
EnabledAt: settings.EnabledAt,
NextPurgeAt: settings.NextPurgeAt(),
}
}
func writePurgeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, purge.ErrInvalidPurgeInterval):
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
case errors.Is(err, purge.ErrPurgeConflict):
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
case errors.Is(err, purge.ErrPurgeBusy):
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
default:
writeInternalServerError(w)
}
}
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
settings, err := h.PurgeSettingsUseCase.Get()
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
var request PurgeSettingsRequest
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.PurgeIntervalErrorMessage, http.StatusBadRequest)
return
}
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
AutoPurgeEnabled: request.AutoPurgeEnabled,
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
})
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
preview, err := h.PreviewPurgeUseCase.Preview()
if err != nil {
writePurgeError(w, err)
return
}
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
for _, group := range preview.List {
list = append(list, purgePreviewGroupResponse{
KeepID: group.KeepID,
Filename: group.Filename,
URL: group.URL,
UsedIn: group.UsedIn,
Merged: group.Merged,
})
}
writeJSON(w, http.StatusOK, purgePreviewResponse{
Groups: preview.Groups,
Copies: preview.Copies,
Articles: preview.Articles,
List: list,
More: preview.More,
Fingerprint: preview.Fingerprint,
})
}
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request PurgeRunRequest
if len(raw) > 0 {
if err := json.Unmarshal(raw, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
}
result, err := h.RunPurgeUseCase.Run(request.Fingerprint)
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, purgeRunResponse{
GroupsTotal: result.GroupsTotal,
GroupsPurged: result.GroupsPurged,
Merged: result.Merged,
ArticlesUpdated: result.ArticlesUpdated,
FailedGroups: result.FailedGroups,
})
}
+18
View File
@@ -95,3 +95,21 @@ func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Hand
})
}
}
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+4
View File
@@ -38,3 +38,7 @@ const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html
const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
const SessionRequiredErrorMessage = "This action needs a real session."
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
+8 -1
View File
@@ -208,13 +208,20 @@ func NewAPIKeyRouter(container *UseCases) http.Handler {
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
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.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)