feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -59,6 +59,9 @@ type UseCases struct {
|
||||
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
||||
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
||||
MoveStoryUseCase *useCases.MoveStoryUseCase
|
||||
ListAuthorsUseCase *useCases.ListAuthorsUseCase
|
||||
CreateAuthorUseCase *useCases.CreateAuthorUseCase
|
||||
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
|
||||
@@ -385,6 +388,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
|
||||
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
|
||||
|
||||
moveStory := useCases.NewMoveStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder)
|
||||
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
@@ -393,8 +398,11 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo, repos.LocalizationRepo, moveStory),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
|
||||
MoveStoryUseCase: moveStory,
|
||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
|
||||
|
||||
@@ -3,11 +3,10 @@ package article
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/articleerr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -26,25 +25,40 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateArticleUseCase
|
||||
GetUseCase *useCases.GetArticleUseCase
|
||||
ListUseCase *useCases.ListArticlesUseCase
|
||||
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||
createUseCase *useCases.CreateArticleUseCase
|
||||
getUseCase *useCases.GetArticleUseCase
|
||||
listUseCase *useCases.ListArticlesUseCase
|
||||
deleteUseCase *useCases.DeleteArticleUseCase
|
||||
updateUseCase *useCases.UpdateArticleUseCase
|
||||
translationUseCase *useCases.CreateTranslationUseCase
|
||||
describeUseCase *useCases.DescribeArticleUseCase
|
||||
}
|
||||
|
||||
type PostArticle struct {
|
||||
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||
Body string `json:"body"`
|
||||
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||
Body string `json:"body"`
|
||||
Lang *string `json:"lang"`
|
||||
StoryID *uint32 `json:"story_id" validate:"omitempty,gte=1"`
|
||||
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
|
||||
Copy *bool `json:"copy"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
|
||||
type PostTranslation struct {
|
||||
Lang string `json:"lang" validate:"required"`
|
||||
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||
SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"`
|
||||
Copy *bool `json:"copy" validate:"required"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, translation *useCases.CreateTranslationUseCase, describe *useCases.DescribeArticleUseCase) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
GetUseCase: get,
|
||||
ListUseCase: list,
|
||||
DeleteUseCase: delete,
|
||||
UpdateUseCase: update,
|
||||
createUseCase: create,
|
||||
getUseCase: get,
|
||||
listUseCase: list,
|
||||
deleteUseCase: delete,
|
||||
updateUseCase: update,
|
||||
translationUseCase: translation,
|
||||
describeUseCase: describe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,22 +75,26 @@ type baseArticleResponse struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
Revision uint32 `json:"revision"`
|
||||
}
|
||||
|
||||
type articleResponse struct {
|
||||
baseArticleResponse
|
||||
Slug string `json:"slug"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
SEOTitle string `json:"seo_title"`
|
||||
SEODescription string `json:"seo_description"`
|
||||
CoverImageID *uint32 `json:"cover_image_id"`
|
||||
CoverImageAlt string `json:"cover_image_alt"`
|
||||
SocialTitle string `json:"social_title"`
|
||||
SocialDescription string `json:"social_description"`
|
||||
SocialImageID *uint32 `json:"social_image_id"`
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Noindex bool `json:"noindex"`
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
Slug string `json:"slug"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
SEOTitle string `json:"seo_title"`
|
||||
SEODescription string `json:"seo_description"`
|
||||
CoverImageID *uint32 `json:"cover_image_id"`
|
||||
CoverImageAlt string `json:"cover_image_alt"`
|
||||
SocialTitle string `json:"social_title"`
|
||||
SocialDescription string `json:"social_description"`
|
||||
SocialImageID *uint32 `json:"social_image_id"`
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Noindex bool `json:"noindex"`
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Translations []domain.Translation `json:"translations"`
|
||||
Source *domain.Source `json:"source"`
|
||||
}
|
||||
|
||||
type listItemResponse struct {
|
||||
@@ -121,10 +139,11 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
Lang: article.Lang,
|
||||
StoryID: article.StoryID,
|
||||
Revision: article.EditRevision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) (articleResponse, error) {
|
||||
base, err := toBaseResponse(article, format, viewer)
|
||||
if err != nil {
|
||||
return articleResponse{}, err
|
||||
@@ -147,6 +166,9 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
|
||||
CanonicalURL: article.CanonicalURL,
|
||||
Noindex: article.Noindex,
|
||||
SEODismissed: dismissed,
|
||||
Capabilities: described.Capabilities,
|
||||
Translations: described.Translations,
|
||||
Source: described.Source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -158,8 +180,8 @@ func toListItemResponse(article domain.Article, summary domain.SEOSummary, forma
|
||||
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
|
||||
}
|
||||
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format, viewer)
|
||||
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller, described useCases.ArticleContext) ([]byte, error) {
|
||||
response, err := toArticleResponse(article, format, viewer, described)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,25 +214,13 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
|
||||
article, err := h.getUseCase.GetArticle(id, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(articleJson); err != nil {
|
||||
fmt.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
h.writeArticle(w, r, http.StatusOK, article, format, printToStdout)
|
||||
}
|
||||
|
||||
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -230,24 +240,89 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||
if localArticle.StoryID != nil {
|
||||
h.postTranslation(w, r, format, localArticle)
|
||||
return
|
||||
}
|
||||
if localArticle.SourceArticleID != nil || localArticle.Copy != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
lang := ""
|
||||
if localArticle.Lang != nil {
|
||||
lang = *localArticle.Lang
|
||||
}
|
||||
createdArticle, err := h.createUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||
Title: localArticle.Title,
|
||||
Body: localArticle.Body,
|
||||
Format: format,
|
||||
Lang: lang,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
|
||||
h.writeArticle(w, r, http.StatusCreated, createdArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
func (h *Handler) postTranslation(w http.ResponseWriter, r *http.Request, format domain.BodyFormat, request PostArticle) {
|
||||
if request.Lang == nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.createTranslation(w, r, useCases.CreateTranslationCommand{
|
||||
StoryID: *request.StoryID,
|
||||
Lang: *request.Lang,
|
||||
Title: request.Title,
|
||||
Copy: request.Copy,
|
||||
SourceArticleID: request.SourceArticleID,
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) PostTranslation(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseArticleId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
format, ok := parseBodyFormat(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request PostTranslation
|
||||
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 {
|
||||
writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
h.createTranslation(w, r, useCases.CreateTranslationCommand{
|
||||
ArticleID: id,
|
||||
Lang: request.Lang,
|
||||
Title: request.Title,
|
||||
Copy: request.Copy,
|
||||
SourceArticleID: request.SourceArticleID,
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) createTranslation(w http.ResponseWriter, r *http.Request, command useCases.CreateTranslationCommand) {
|
||||
created, err := h.translationUseCase.CreateTranslation(r.Context(), command)
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
h.writeArticle(w, r, http.StatusCreated, created, command.Format, log.Printf)
|
||||
}
|
||||
|
||||
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
@@ -257,46 +332,13 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrSlugTaken) {
|
||||
writeSlugTaken(w, err)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidSlug) {
|
||||
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
|
||||
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, image.ErrImageNotFound) {
|
||||
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return access.WriteIfMissingPermission(w, err) || articleerr.Write(w, err)
|
||||
}
|
||||
|
||||
func writeSlugTaken(w http.ResponseWriter, err error) {
|
||||
var conflict *domain.SlugTakenError
|
||||
if errors.As(err, &conflict) && conflict.Suggestion != "" {
|
||||
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
|
||||
}
|
||||
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
|
||||
var bodyMismatchFailures = map[string]string{
|
||||
"Copy": "required",
|
||||
"IfRevision": "min",
|
||||
"AuthorIDs": "unique",
|
||||
}
|
||||
|
||||
func writeValidationError(w http.ResponseWriter, err error) {
|
||||
@@ -308,12 +350,26 @@ func writeValidationError(w http.ResponseWriter, err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 && bodyMismatchFailures[failures[0].Field()] == failures[0].Tag() {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
|
||||
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
|
||||
func printToStdout(format string, args ...any) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
func (h *Handler) writeArticle(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat, reportFailure func(string, ...any)) {
|
||||
caller := access.CallerOf(r)
|
||||
described, err := h.describeUseCase.Describe(article, caller)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
articleJson, err := encodeArticle(article, format, caller, described)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -323,7 +379,7 @@ func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(articleJson); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
reportFailure("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,13 +395,13 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
|
||||
updatedArticle, err := h.updateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
type optionalImageID struct {
|
||||
@@ -372,9 +428,11 @@ func (o optionalImageID) toNullable() domain.Nullable[uint32] {
|
||||
}
|
||||
|
||||
type UpdateArticleBody struct {
|
||||
Title *string `json:"title"`
|
||||
IfRevision *uint32 `json:"if_revision" validate:"omitempty,min=1"`
|
||||
Body *string `json:"body"`
|
||||
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
|
||||
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
|
||||
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,unique,dive,gte=1"`
|
||||
Slug *string `json:"slug" validate:"omitempty,articleslug"`
|
||||
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
|
||||
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
|
||||
@@ -386,24 +444,13 @@ type UpdateArticleBody struct {
|
||||
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"`
|
||||
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
|
||||
}
|
||||
|
||||
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
|
||||
var nonNullableArticleFields = []string{"title", "body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
|
||||
|
||||
func hasDuplicates(ids []uint32) bool {
|
||||
seen := make(map[uint32]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
return true
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
|
||||
known := 0
|
||||
for name, value := range fields {
|
||||
@@ -430,16 +477,13 @@ func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
known, ok := countKnownFields(fields, nonNullableArticleFields)
|
||||
if !ok || known == 0 {
|
||||
if !ok || known == 0 || string(fields["if_revision"]) == "null" {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
var request UpdateArticleBody
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
@@ -466,7 +510,9 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
|
||||
updatedArticle, err := h.updateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
|
||||
Title: request.Title,
|
||||
IfRevision: request.IfRevision,
|
||||
Body: request.Body,
|
||||
SiteID: request.SiteID,
|
||||
AuthorIDs: request.AuthorIDs,
|
||||
@@ -489,7 +535,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
|
||||
h.writeArticle(w, r, http.StatusOK, updatedArticle, format, log.Printf)
|
||||
}
|
||||
|
||||
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -581,7 +627,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
Page: query.Page,
|
||||
@@ -625,15 +671,10 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
||||
}
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ type CheckArticleSEOBody struct {
|
||||
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"`
|
||||
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,seodismissed"`
|
||||
}
|
||||
|
||||
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package story
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/articleerr"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
MoveUseCase *useCases.MoveStoryUseCase
|
||||
}
|
||||
|
||||
func NewHandler(move *useCases.MoveStoryUseCase) *Handler {
|
||||
return &Handler{MoveUseCase: move}
|
||||
}
|
||||
|
||||
type moveRequest struct {
|
||||
Site *string `json:"site"`
|
||||
}
|
||||
|
||||
type moveResponse struct {
|
||||
StoryID uint32 `json:"story_id"`
|
||||
Site string `json:"site"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
ArticleIDs []uint32 `json:"article_ids"`
|
||||
}
|
||||
|
||||
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var request moveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Site == nil || *request.Site == "" {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: uint32(id), SiteKey: *request.Site, Caller: access.CallerOf(r)})
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
if articleerr.Write(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to move story", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -155,3 +155,18 @@ const EmailUnavailableErrorCode = "email_unavailable"
|
||||
const LastAdminErrorCode = "last_admin"
|
||||
const AccountNotDeletableErrorCode = "account_not_deletable"
|
||||
const LastRoleHeader = "X-Last-Role"
|
||||
|
||||
const RevisionConflictErrorMessage = "revision_conflict"
|
||||
const CurrentRevisionHeader = "X-Current-Revision"
|
||||
const LanguageUnavailableErrorMessage = "language_unavailable"
|
||||
const LanguageExistsErrorMessage = "language_exists"
|
||||
const ExistingArticleHeader = "X-Existing-Article-Id"
|
||||
const LanguageNotEnabledErrorMessage = "language_not_enabled"
|
||||
const MissingLanguagesHeader = "X-Missing-Languages"
|
||||
const LanguageDisabledErrorMessage = "language_disabled"
|
||||
const SourceUnavailableErrorMessage = "source_unavailable"
|
||||
const SourceOtherStoryErrorMessage = "source_other_story"
|
||||
const SlugConflictErrorMessage = "slug_conflict"
|
||||
const ConflictingArticlesHeader = "X-Conflicting-Articles"
|
||||
const StoryMoveRequiredErrorMessage = "story_move_required"
|
||||
const StoryBusyErrorMessage = "story_busy"
|
||||
@@ -0,0 +1,99 @@
|
||||
package articleerr
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Write(w http.ResponseWriter, err error) bool {
|
||||
var conflict *domain.RevisionConflictError
|
||||
var existing *domain.LanguageExistsError
|
||||
var notEnabled *domain.LanguageNotEnabledError
|
||||
var addresses *domain.SlugConflictError
|
||||
var taken *domain.SlugTakenError
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
w.Header().Set(apperr.CurrentRevisionHeader, strconv.FormatUint(uint64(conflict.Current), 10))
|
||||
http.Error(w, apperr.RevisionConflictErrorMessage, http.StatusConflict)
|
||||
case errors.As(err, &existing):
|
||||
w.Header().Set(apperr.ExistingArticleHeader, strconv.FormatUint(uint64(existing.ArticleID), 10))
|
||||
http.Error(w, apperr.LanguageExistsErrorMessage, http.StatusConflict)
|
||||
case errors.As(err, ¬Enabled):
|
||||
if len(notEnabled.Missing) > 0 {
|
||||
w.Header().Set(apperr.MissingLanguagesHeader, strings.Join(notEnabled.Missing, ","))
|
||||
}
|
||||
http.Error(w, apperr.LanguageNotEnabledErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.As(err, &addresses):
|
||||
if len(addresses.ArticleIDs) > 0 {
|
||||
w.Header().Set(apperr.ConflictingArticlesHeader, joinIDs(addresses.ArticleIDs))
|
||||
}
|
||||
http.Error(w, apperr.SlugConflictErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrSlugTaken):
|
||||
if errors.As(err, &taken) && taken.Suggestion != "" {
|
||||
w.Header().Set(apperr.SlugSuggestionHeader, taken.Suggestion)
|
||||
}
|
||||
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
return writeSentinel(w, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func joinIDs(ids []uint32) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
parts = append(parts, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type sentinelResponse struct {
|
||||
cause error
|
||||
message string
|
||||
status int
|
||||
}
|
||||
|
||||
var articleSentinels = []sentinelResponse{
|
||||
{domain.ErrInvalidTitle, apperr.ArticleTitleErrorMessage, http.StatusBadRequest},
|
||||
{domain.ErrArticleNotFound, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound},
|
||||
{site.ErrSiteNotFound, apperr.SiteNotFoundErrorMessage, http.StatusNotFound},
|
||||
{author.ErrAuthorNotFound, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound},
|
||||
{domain.ErrInvalidSlug, apperr.InvalidSlugErrorMessage, http.StatusBadRequest},
|
||||
{domain.ErrInvalidCanonicalURL, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity},
|
||||
{image.ErrImageNotFound, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
var localizationSentinels = []sentinelResponse{
|
||||
{domain.ErrLanguageUnavailable, apperr.LanguageUnavailableErrorMessage, http.StatusConflict},
|
||||
{domain.ErrLanguageTaken, apperr.LanguageUnavailableErrorMessage, http.StatusConflict},
|
||||
{domain.ErrLanguageDisabled, apperr.LanguageDisabledErrorMessage, http.StatusUnprocessableEntity},
|
||||
{domain.ErrSourceUnavailable, apperr.SourceUnavailableErrorMessage, http.StatusNotFound},
|
||||
{domain.ErrSourceOtherStory, apperr.SourceOtherStoryErrorMessage, http.StatusUnprocessableEntity},
|
||||
{domain.ErrInvalidTranslation, apperr.BodyErrorMessage, http.StatusBadRequest},
|
||||
{domain.ErrStoryMoveRequired, apperr.StoryMoveRequiredErrorMessage, http.StatusConflict},
|
||||
{localization.ErrSetupRequired, apperr.SetupRequiredErrorMessage, http.StatusConflict},
|
||||
{language.ErrInvalidLanguage, apperr.InvalidLanguageErrorMessage, http.StatusUnprocessableEntity},
|
||||
{language.ErrUnsupportedLanguage, apperr.UnsupportedLanguageErrorMessage, http.StatusUnprocessableEntity},
|
||||
{transaction.ErrLockContention, apperr.StoryBusyErrorMessage, http.StatusConflict},
|
||||
}
|
||||
|
||||
func writeSentinel(w http.ResponseWriter, err error) bool {
|
||||
for _, candidate := range slices.Concat(articleSentinels, localizationSentinels) {
|
||||
if errors.Is(err, candidate.cause) {
|
||||
http.Error(w, candidate.message, candidate.status)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
@@ -19,6 +20,7 @@ const AuthorNameTag = "authorname"
|
||||
const AuthorSlugTag = "authorslug"
|
||||
const SocialTypeTag = "socialtype"
|
||||
const ArticleSlugTag = "articleslug"
|
||||
const SEODismissedTag = "seodismissed"
|
||||
|
||||
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||
|
||||
@@ -34,6 +36,7 @@ func InitValidator() {
|
||||
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
|
||||
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
|
||||
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
|
||||
_ = Validate.RegisterValidation(SEODismissedTag, isSEODismissed)
|
||||
}
|
||||
|
||||
func isArticleSlug(field validator.FieldLevel) bool {
|
||||
@@ -44,6 +47,10 @@ func isArticleSlug(field validator.FieldLevel) bool {
|
||||
return value == "" || article.IsValidSlug(value)
|
||||
}
|
||||
|
||||
func isSEODismissed(field validator.FieldLevel) bool {
|
||||
return slices.Contains(article.DismissCodes(), field.Field().String())
|
||||
}
|
||||
|
||||
func isAbsent(field validator.FieldLevel) bool {
|
||||
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
|
||||
}
|
||||
|
||||
+20
-1
@@ -20,6 +20,7 @@ import (
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
"RenewCMS/api/handlers/story"
|
||||
"RenewCMS/api/handlers/update"
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
@@ -95,7 +96,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.CreateTranslationUseCase, container.DescribeArticleUseCase)
|
||||
|
||||
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
|
||||
|
||||
@@ -110,6 +111,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
|
||||
r.With(container.requireArticle(role.KeyContentCreate)).Post("/{id}/translations", articleHandler.PostTranslation)
|
||||
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
|
||||
r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||
@@ -119,6 +121,22 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
return r
|
||||
}
|
||||
|
||||
func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
storyHandler := story.NewHandler(container.MoveStoryUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.With(container.requireArticle(role.KeyContentUpdate)).Post("/{story_id}/move", storyHandler.Move)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -607,6 +625,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
|
||||
})
|
||||
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
|
||||
Reference in new issue
Block a user