refac: article reads, story delete, no leaks, stored SEO status, rate limit, cleanup (%site%, article repo split, old lang repos, one default per site rule to domain)
This commit is contained in:
1 parent
9d284e65a9
commit
7c15206170
85 files changed
+1788
-593
No files matched your search
@@ -10,7 +10,6 @@ import (
|
||||
domainEntry "RenewCMS/internal/domain/entry"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainLanguage "RenewCMS/internal/domain/language"
|
||||
domainLocalization "RenewCMS/internal/domain/localization"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
@@ -47,6 +46,8 @@ import (
|
||||
type UseCases struct {
|
||||
apiKeyAuthOnce sync.Once
|
||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||
publicReadLimitOnce sync.Once
|
||||
publicReadLimitMiddleware func(http.Handler) http.Handler
|
||||
Token *authtoken.Auth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
ActivityCleanupScheduler *scheduler.Runner
|
||||
@@ -63,6 +64,7 @@ type UseCases struct {
|
||||
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
||||
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
||||
MoveStoryUseCase *useCases.MoveStoryUseCase
|
||||
DeleteStoryUseCase *useCases.DeleteStoryUseCase
|
||||
ListAuthorsUseCase *useCases.ListAuthorsUseCase
|
||||
CreateAuthorUseCase *useCases.CreateAuthorUseCase
|
||||
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
|
||||
@@ -184,9 +186,8 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
}
|
||||
|
||||
type Repositories struct {
|
||||
ArticleRepo domainArticle.Repository
|
||||
ArticleRepo domainArticle.ListingRepository
|
||||
SiteRepo domainSite.Repository
|
||||
LanguageRepo domainLanguage.Repository
|
||||
LocalizationRepo domainLocalization.Repository
|
||||
ContentTypeRepo domainContentType.Repository
|
||||
EntryRepo domainEntry.Repository
|
||||
@@ -336,7 +337,6 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
return &Repositories{
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
SiteRepo: persistence.NewSiteRepository(db),
|
||||
LanguageRepo: persistence.NewSiteLanguageRepository(db),
|
||||
LocalizationRepo: persistence.NewLocalizationRepository(db),
|
||||
ContentTypeRepo: persistence.NewContentTypeRepository(db),
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
@@ -404,8 +404,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
|
||||
MoveStoryUseCase: moveStory,
|
||||
DeleteStoryUseCase: useCases.NewDeleteStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder),
|
||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo),
|
||||
ResolvePublicArticleUseCase: useCases.NewResolvePublicArticleUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo, renderer),
|
||||
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
|
||||
@@ -419,7 +420,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
|
||||
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
|
||||
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder, repos.ArticleRepo),
|
||||
GetSiteLanguagesUseCase: useCases.NewGetSiteLanguagesUseCase(repos.SiteRepo, repos.LocalizationRepo),
|
||||
UpdateSiteLanguagesUseCase: useCases.NewUpdateSiteLanguagesUseCase(repos.Transactor, recorder),
|
||||
GetLanguageSetupUseCase: useCases.NewGetLanguageSetupUseCase(repos.SiteRepo, repos.LocalizationRepo),
|
||||
@@ -552,6 +553,9 @@ func InitContainer(settings config.Settings) (*UseCases, func() error) {
|
||||
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
|
||||
panic("Failed to backfill image metadata: " + err.Error())
|
||||
}
|
||||
if err := useCases.NewRefreshArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo).RefreshStale(); err != nil {
|
||||
panic("Failed to backfill the article SEO status: " + err.Error())
|
||||
}
|
||||
container := getUseCases(repos, imageStorage, settings)
|
||||
return container, container.runUsageTracker(connection.Close)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,26 @@ type articleResponse struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Translations []domain.Translation `json:"translations"`
|
||||
Source *domain.Source `json:"source"`
|
||||
*editorialContext
|
||||
}
|
||||
|
||||
type editorialContext struct {
|
||||
SourceUnavailable bool `json:"source_unavailable"`
|
||||
MissingLangs []string `json:"missing_langs"`
|
||||
ExcludedFromPublic bool `json:"excluded_from_public"`
|
||||
PartialView bool `json:"partial_view"`
|
||||
}
|
||||
|
||||
func editorialContextFor(viewer access.Caller, described useCases.ArticleContext) *editorialContext {
|
||||
if viewer.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
return &editorialContext{
|
||||
SourceUnavailable: described.SourceUnavailable,
|
||||
MissingLangs: described.MissingLangs,
|
||||
ExcludedFromPublic: described.ExcludedFromPublic,
|
||||
PartialView: described.PartialView,
|
||||
}
|
||||
}
|
||||
|
||||
type listItemResponse struct {
|
||||
@@ -171,6 +191,7 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
|
||||
Capabilities: described.Capabilities,
|
||||
Translations: described.Translations,
|
||||
Source: described.Source,
|
||||
editorialContext: editorialContextFor(viewer, described),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -584,11 +605,12 @@ const groupByStory = "story"
|
||||
|
||||
type storyItemResponse struct {
|
||||
listItemResponse
|
||||
StoryUpdatedAt time.Time `json:"story_updated_at"`
|
||||
StoryAuthors []author.Byline `json:"story_authors"`
|
||||
Coverage []domain.Translation `json:"coverage"`
|
||||
MissingLangs []string `json:"missing_langs"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
StoryUpdatedAt time.Time `json:"story_updated_at"`
|
||||
StoryAuthors []author.Byline `json:"story_authors"`
|
||||
Coverage []domain.Translation `json:"coverage"`
|
||||
MissingLangs []string `json:"missing_langs"`
|
||||
ExcludedFromPublic bool `json:"excluded_from_public"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type storyListResponse struct {
|
||||
@@ -704,7 +726,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
Lang: query.Lang,
|
||||
Lang: stringOrEmpty(query.Lang),
|
||||
Author: query.Author,
|
||||
Page: query.Page,
|
||||
Size: query.Size,
|
||||
@@ -771,12 +793,13 @@ func (h *Handler) listStories(w http.ResponseWriter, r *http.Request, format dom
|
||||
return
|
||||
}
|
||||
responses = append(responses, storyItemResponse{
|
||||
listItemResponse: item,
|
||||
StoryUpdatedAt: row.UpdatedAt,
|
||||
StoryAuthors: row.Authors,
|
||||
Coverage: row.Coverage,
|
||||
MissingLangs: row.MissingLangs,
|
||||
Capabilities: row.Capabilities,
|
||||
listItemResponse: item,
|
||||
StoryUpdatedAt: row.UpdatedAt,
|
||||
StoryAuthors: row.Authors,
|
||||
Coverage: row.Coverage,
|
||||
MissingLangs: row.MissingLangs,
|
||||
ExcludedFromPublic: row.ExcludedFromPublic,
|
||||
Capabilities: row.Capabilities,
|
||||
})
|
||||
}
|
||||
writeListJSON(w, storyListResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
@@ -798,7 +821,7 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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)
|
||||
http.Error(w, apperr.DeleteArticleFailedErrorMessage, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
MoveUseCase *useCases.MoveStoryUseCase
|
||||
MoveUseCase *useCases.MoveStoryUseCase
|
||||
DeleteUseCase *useCases.DeleteStoryUseCase
|
||||
}
|
||||
|
||||
func NewHandler(move *useCases.MoveStoryUseCase) *Handler {
|
||||
return &Handler{MoveUseCase: move}
|
||||
func NewHandler(move *useCases.MoveStoryUseCase, delete *useCases.DeleteStoryUseCase) *Handler {
|
||||
return &Handler{MoveUseCase: move, DeleteUseCase: delete}
|
||||
}
|
||||
|
||||
func parseStoryID(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
type moveRequest struct {
|
||||
@@ -33,7 +39,7 @@ type moveResponse struct {
|
||||
}
|
||||
|
||||
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||
id, err := parseStoryID(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
@@ -43,9 +49,9 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
||||
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)})
|
||||
result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: id, SiteKey: *request.Site, Caller: access.CallerOf(r)})
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
writeError(w, err, apperr.MoveStoryFailedErrorMessage)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs})
|
||||
@@ -58,12 +64,25 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseStoryID(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.DeleteUseCase.DeleteStory(r.Context(), useCases.DeleteStoryCommand{StoryID: id, Caller: access.CallerOf(r)}); err != nil {
|
||||
writeError(w, err, apperr.DeleteArticleFailedErrorMessage)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error, failure string) {
|
||||
if access.WriteIfMissingPermission(w, err) {
|
||||
return
|
||||
}
|
||||
if articleerr.Write(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to move story", http.StatusInternalServerError)
|
||||
http.Error(w, failure, http.StatusInternalServerError)
|
||||
}
|
||||
@@ -172,6 +172,8 @@ const ConflictingArticlesHeader = "X-Conflicting-Articles"
|
||||
const StoryMoveRequiredErrorMessage = "story_move_required"
|
||||
const StoryBusyErrorMessage = "story_busy"
|
||||
const TargetSiteDeletedErrorMessage = "target_site_deleted"
|
||||
const MoveStoryFailedErrorMessage = "Failed to move story"
|
||||
const DeleteArticleFailedErrorMessage = "Failed to delete article"
|
||||
|
||||
const ArticleNotFoundCode = "article_not_found"
|
||||
const LanguageNotAvailableCode = "language_not_available"
|
||||
|
||||
+20
-4
@@ -55,6 +55,9 @@ const (
|
||||
apiKeyMaxRequests = 120
|
||||
apiKeyWindow = time.Minute
|
||||
|
||||
publicReadMaxRequests = apiKeyMaxRequests
|
||||
publicReadWindow = apiKeyWindow
|
||||
|
||||
emailChangeMaxRequests = 5
|
||||
emailChangeWindow = 15 * time.Minute
|
||||
|
||||
@@ -71,6 +74,17 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
return c.apiKeyAuthMiddleware
|
||||
}
|
||||
|
||||
func (c *UseCases) publicReadLimit(trustedProxies []string) func(http.Handler) http.Handler {
|
||||
c.publicReadLimitOnce.Do(func() {
|
||||
realIP := middleware.TrustedProxyRealIP(trustedProxies)
|
||||
limiter := middleware.NewRateLimiter(publicReadMaxRequests, publicReadWindow)
|
||||
c.publicReadLimitMiddleware = func(next http.Handler) http.Handler {
|
||||
return realIP(limiter(next))
|
||||
}
|
||||
})
|
||||
return c.publicReadLimitMiddleware
|
||||
}
|
||||
|
||||
func (c *UseCases) require(key string) func(http.Handler) http.Handler {
|
||||
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
|
||||
}
|
||||
@@ -121,8 +135,9 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
return r
|
||||
}
|
||||
|
||||
func NewPublicRouter(container *UseCases) http.Handler {
|
||||
func NewPublicRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.publicReadLimit(trustedProxies))
|
||||
|
||||
publicHandler := article.NewPublicHandler(container.ResolvePublicArticleUseCase)
|
||||
|
||||
@@ -136,13 +151,14 @@ func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
|
||||
storyHandler := story.NewHandler(container.MoveStoryUseCase)
|
||||
storyHandler := story.NewHandler(container.MoveStoryUseCase, container.DeleteStoryUseCase)
|
||||
|
||||
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)
|
||||
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{story_id}", storyHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -328,7 +344,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
|
||||
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
|
||||
|
||||
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
r.With(container.publicReadLimit(trustedProxies)).Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -637,7 +653,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
|
||||
})
|
||||
r.Mount("/public", NewPublicRouter(container))
|
||||
r.Mount("/public", NewPublicRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
|
||||
Reference in new issue
Block a user