package article import ( "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" "io" "log" "net/http" ) type SEOHandler struct { ResolveUseCase *useCases.ResolveArticleSEOUseCase CheckUseCase *useCases.CheckArticleSEOUseCase GetUserUseCase *useCases.GetUserUseCase } func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler { return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser} } type CheckArticleSEOBody struct { Body *string `json:"body"` Slug *string `json:"slug" validate:"omitempty,articleslug"` Excerpt *string `json:"excerpt" validate:"omitempty,max=300"` SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"` SEODescription *string `json:"seo_description" validate:"omitempty,max=320"` CoverImageID optionalImageID `json:"cover_image_id"` CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"` SocialTitle *string `json:"social_title" validate:"omitempty,max=120"` SocialDescription *string `json:"social_description" validate:"omitempty,max=320"` SocialImageID optionalImageID `json:"social_image_id"` CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"` Noindex *bool `json:"noindex"` SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"` } var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"} func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) { raw, err := io.ReadAll(r.Body) if err != nil { return CheckArticleSEOBody{}, false } var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { return CheckArticleSEOBody{}, false } known, ok := countKnownFields(fields, checkNonNullableFields) if !ok || known != len(fields) { return CheckArticleSEOBody{}, false } var request CheckArticleSEOBody decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil { return CheckArticleSEOBody{}, false } return request, true } func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) { id, err := parseArticleId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase)) if err != nil { writeArticleErrorResponse(w, err) return } writeSEOJson(w, resolved) } func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) { id, err := parseArticleId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } request, ok := readCheckBody(r) if !ok { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } if err := validator.Validate.Struct(request); err != nil { writeValidationError(w, err) return } checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{ Body: request.Body, Slug: request.Slug, Excerpt: request.Excerpt, SEOTitle: request.SEOTitle, SEODescription: request.SEODescription, CoverImageID: request.CoverImageID.toNullable(), CoverImageAlt: request.CoverImageAlt, SocialTitle: request.SocialTitle, SocialDescription: request.SocialDescription, SocialImageID: request.SocialImageID.toNullable(), CanonicalURL: request.CanonicalURL, Noindex: request.Noindex, SEODismissed: request.SEODismissed, }) if err != nil { writeArticleErrorResponse(w, err) return } writeSEOJson(w, checked) } func writeSEOJson(w http.ResponseWriter, payload any) { encoded, err := json.Marshal(payload) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } if _, err := w.Write(encoded); err != nil { log.Printf("Failed to write response: %v", err) } }