package media import ( "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/upload" validator "RenewCMS/api/pkg/validator" domainImage "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) type Handler struct { 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 ListVariantsUseCase *useCases.ListImageVariantsUseCase GenerateVariantUseCase *useCases.GenerateImageVariantUseCase GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase PurgeVariantUseCase *useCases.PurgeImageVariantUseCase GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase } type RenameRequest struct { Filename string `json:"filename" validate:"required"` } type MergeRequest struct { KeepID uint32 `json:"keep_id" validate:"required,gt=0"` MergeIDs []uint32 `json:"merge_ids" validate:"required,min=1,dive,gt=0"` } type mediaItemResponse struct { ID uint32 `json:"id"` Filename string `json:"filename"` URL string `json:"url"` Size int64 `json:"size"` ContentType string `json:"content_type"` Width *int `json:"width"` Height *int `json:"height"` SHA256 string `json:"sha256"` UploadedBy string `json:"uploaded_by"` CreatedAt time.Time `json:"created_at"` UsedIn int `json:"used_in"` Duplicate bool `json:"duplicate"` VariantCount int `json:"variant_count"` VariantSize int64 `json:"variant_size"` } type countsResponse struct { All int `json:"all"` Unused int `json:"unused"` Duplicates int `json:"duplicates"` } type listResponse struct { Items []mediaItemResponse `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Counts countsResponse `json:"counts"` } type pagingQuery struct { Page int `validate:"gte=1"` Size int `validate:"oneof=5 25 50 100"` } func intQueryParam(values url.Values, name string, fallback int) (int, error) { if !values.Has(name) { return fallback, nil } return strconv.Atoi(values.Get(name)) } type usageResponse struct { ID uint32 `json:"id"` Title string `json:"title"` Published bool `json:"published"` } type mergeResponse struct { ArticlesUpdated int `json:"articles_updated"` SitesUpdated int `json:"sites_updated"` AuthorsUpdated int `json:"authors_updated"` Merged int `json:"merged"` } 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, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler { return &Handler{ CreateUseCase: create, ListUseCase: list, RenameUseCase: rename, DeleteUseCase: deleteUseCase, MergeUseCase: merge, UsageUseCase: usage, PurgeSettingsUseCase: purgeSettings, UpdatePurgeSettingsUseCase: updatePurgeSettings, PreviewPurgeUseCase: previewPurge, RunPurgeUseCase: runPurge, ListVariantsUseCase: listVariants, GenerateVariantUseCase: generateVariant, GenerateAllUseCase: generateAll, PurgeVariantUseCase: purgeVariant, GenerateOnUploadUseCase: generateOnUpload, } } func writeInternalServerError(w http.ResponseWriter) { http.Error(w, "Internal server error", http.StatusInternalServerError) } func writeJSON(w http.ResponseWriter, status int, payload any) { body, err := json.Marshal(payload) if err != nil { writeInternalServerError(w) return } w.WriteHeader(status) _, _ = w.Write(body) } func writeImageError(w http.ResponseWriter, err error) { if errors.Is(err, domainImage.ErrImageNotFound) { http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound) return } writeInternalServerError(w) } func parseImageId(w http.ResponseWriter, r *http.Request) (uint32, bool) { id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return 0, false } return uint32(id), true } func dimension(value int) *int { if value == 0 { return nil } return &value } func toResponse(item useCases.MediaItem) mediaItemResponse { return mediaItemResponse{ ID: item.Image.ID, Filename: item.Image.Filename, URL: item.Image.Path, Size: item.Image.Size, ContentType: item.Image.ContentType, Width: dimension(item.Image.Width), Height: dimension(item.Image.Height), SHA256: item.Image.SHA256, UploadedBy: item.UploadedBy, CreatedAt: item.Image.CreatedAt, UsedIn: item.UsedIn, Duplicate: item.Duplicate, VariantCount: item.VariantCount, VariantSize: item.VariantSize, } } func parseListQuery(r *http.Request) (domainImage.ListQuery, error) { values, err := url.ParseQuery(r.URL.RawQuery) if err != nil { return domainImage.ListQuery{}, domainImage.ErrInvalidQuery } query := domainImage.NewListQuery() query.Search = values.Get("q") query.Hash = strings.ToLower(values.Get("hash")) if values.Has("filter") { query.Filter = domainImage.Filter(values.Get("filter")) } if values.Has("variants") { if values.Get("variants") == "" { return domainImage.ListQuery{}, domainImage.ErrInvalidQuery } query.Variants = domainImage.VariantsFilter(values.Get("variants")) } if values.Has("sort") { query.Sort = domainImage.Sort(values.Get("sort")) } page, err := intQueryParam(values, "page", domainImage.DefaultListPage) if err != nil { return domainImage.ListQuery{}, domainImage.ErrInvalidQuery } size, err := intQueryParam(values, "size", domainImage.DefaultListSize) if err != nil { return domainImage.ListQuery{}, domainImage.ErrInvalidQuery } if validator.Validate.Struct(pagingQuery{Page: page, Size: size}) != nil { return domainImage.ListQuery{}, domainImage.ErrInvalidQuery } query.Page = page query.Size = size return query, nil } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { query, err := parseListQuery(r) if err != nil { http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest) return } page, err := h.ListUseCase.List(query) if err != nil { if errors.Is(err, domainImage.ErrInvalidQuery) { http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest) return } writeInternalServerError(w) return } items := make([]mediaItemResponse, 0, len(page.Items)) for _, item := range page.Items { items = append(items, toResponse(item)) } response := listResponse{ Items: items, Total: page.Total, Page: page.Page, Size: page.Size, Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates}, } writeJSON(w, http.StatusOK, response) } func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { userId, _ := claims.UserId(r) input, closeFile, ok := upload.ReadImageInput(w, r, userId) if !ok { return } defer closeFile() created, err := h.CreateUseCase.CreateImage(r.Context(), input) if err != nil { upload.WriteCreateError(w, err) return } h.GenerateOnUploadUseCase.Generate(r.Context(), created) h.writeItem(w, http.StatusCreated, created) } func (h *Handler) writeItem(w http.ResponseWriter, status int, img domainImage.Image) { item, err := h.ListUseCase.Describe(img) if err != nil { writeInternalServerError(w) return } writeJSON(w, status, toResponse(item)) } func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) { id, ok := parseImageId(w, r) if !ok { return } var request RenameRequest 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.MediaFilenameErrorMessage, http.StatusBadRequest) return } renamed, err := h.RenameUseCase.Rename(r.Context(), id, request.Filename) if err != nil { if errors.Is(err, domainImage.ErrInvalidFilename) { http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest) return } writeImageError(w, err) return } h.writeItem(w, http.StatusOK, renamed) } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { id, ok := parseImageId(w, r) if !ok { return } if err := h.DeleteUseCase.Delete(r.Context(), id); err != nil { writeImageError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) { id, ok := parseImageId(w, r) if !ok { return } articles, err := h.UsageUseCase.Usage(id) if err != nil { writeImageError(w, err) return } response := make([]usageResponse, 0, len(articles)) for _, a := range articles { response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline}) } writeJSON(w, http.StatusOK, response) } func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) { var request MergeRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest) return } if err := validator.Validate.Struct(request); err != nil { http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest) return } result, err := h.MergeUseCase.Merge(r.Context(), request.KeepID, request.MergeIDs) if err != nil { writeMergeError(w, err) return } writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, AuthorsUpdated: result.AuthorsUpdated, Merged: result.Merged}) } func writeMergeError(w http.ResponseWriter, err error) { switch { case errors.Is(err, domainImage.ErrInvalidMergeRequest): http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest) case errors.Is(err, domainImage.ErrMergeCopyMissing), errors.Is(err, domainImage.ErrMergeHashMismatch): http.Error(w, apperr.MediaMergeConflictErrorMessage, http.StatusConflict) default: writeImageError(w, err) } }