feat(API): media library
This commit is contained in:
1 parent
95ae043d3e
commit
0d99544579
65 files changed
+3992
-1530
No files matched your search
@@ -2,8 +2,9 @@ package image
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/upload"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -16,16 +17,12 @@ import (
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
GetUseCase *useCases.GetArticleUseCase
|
||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||
DeleteUseCase *useCases.DeleteImageUseCase
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase, deleteUseCase *useCases.DeleteImageUseCase) *Handler {
|
||||
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
GetUseCase: get,
|
||||
UpdateUseCase: update,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +38,7 @@ func writeArticleError(w http.ResponseWriter, err error) {
|
||||
writeInternalServerError(w)
|
||||
}
|
||||
|
||||
func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) PostLegacyArticleImage(w http.ResponseWriter, r *http.Request) {
|
||||
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
@@ -53,30 +50,16 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
file, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
userId, _ := claims.UserId(r)
|
||||
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer closeFile()
|
||||
|
||||
newImage, err := h.CreateUseCase.CreateImage(domainImage.ImageInput{
|
||||
Content: file,
|
||||
Filename: fileHeader.Filename,
|
||||
Size: fileHeader.Size,
|
||||
ContentType: fileHeader.Header.Get("Content-Type"),
|
||||
})
|
||||
newImage, err := h.CreateUseCase.CreateImage(input)
|
||||
if err != nil {
|
||||
if errors.Is(err, domainImage.ErrImageTypeNotAllowed) || errors.Is(err, domainImage.ErrImageTooLarge) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeInternalServerError(w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.UpdateUseCase.AddImage(uint32(articleId), newImage.ID); err != nil {
|
||||
writeArticleError(w, err)
|
||||
upload.WriteCreateError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,33 +72,3 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write(responseJson)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
imageId, err := strconv.ParseUint(chi.URLParam(r, "imageId"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil {
|
||||
writeArticleError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DeleteUseCase.DeleteImage(uint32(imageId)); err != nil {
|
||||
if errors.Is(err, domainImage.ErrImageNotFound) {
|
||||
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeInternalServerError(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type countsResponse struct {
|
||||
All int `json:"all"`
|
||||
Unused int `json:"unused"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Items []mediaItemResponse `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
Counts countsResponse `json:"counts"`
|
||||
}
|
||||
|
||||
type usageResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
type mergeResponse struct {
|
||||
ArticlesUpdated int `json:"articles_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) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
ListUseCase: list,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
query.Cursor = values.Get("cursor")
|
||||
if values.Has("filter") {
|
||||
query.Filter = domainImage.Filter(values.Get("filter"))
|
||||
}
|
||||
if values.Has("sort") {
|
||||
query.Sort = domainImage.Sort(values.Get("sort"))
|
||||
}
|
||||
if values.Has("limit") {
|
||||
limit, err := strconv.Atoi(values.Get("limit"))
|
||||
if err != nil {
|
||||
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
|
||||
}
|
||||
query.Limit = limit
|
||||
}
|
||||
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,
|
||||
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
|
||||
}
|
||||
if page.NextCursor != "" {
|
||||
response.NextCursor = &page.NextCursor
|
||||
}
|
||||
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(input)
|
||||
if err != nil {
|
||||
upload.WriteCreateError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
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(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(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(request.KeepID, request.MergeIDs)
|
||||
if err != nil {
|
||||
writeMergeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user