feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

+11
View File
@@ -11,6 +11,7 @@ import (
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"
domainRole "RenewCMS/internal/domain/role"
@@ -70,6 +71,10 @@ type UseCases struct {
ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase
GetSiteLanguagesUseCase *useCases.GetSiteLanguagesUseCase
UpdateSiteLanguagesUseCase *useCases.UpdateSiteLanguagesUseCase
GetLanguageSetupUseCase *useCases.GetLanguageSetupUseCase
ApplyLanguageSetupUseCase *useCases.ApplyLanguageSetupUseCase
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
DeleteSiteUseCase *useCases.DeleteSiteUseCase
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
@@ -178,6 +183,7 @@ type Repositories struct {
ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository
LanguageRepo domainLanguage.Repository
LocalizationRepo domainLocalization.Repository
ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository
@@ -327,6 +333,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db),
LanguageRepo: persistence.NewSiteLanguageRepository(db),
LocalizationRepo: persistence.NewLocalizationRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db),
@@ -403,6 +410,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
GetSiteLanguagesUseCase: useCases.NewGetSiteLanguagesUseCase(repos.SiteRepo, repos.LocalizationRepo),
UpdateSiteLanguagesUseCase: useCases.NewUpdateSiteLanguagesUseCase(repos.Transactor, recorder),
GetLanguageSetupUseCase: useCases.NewGetLanguageSetupUseCase(repos.SiteRepo, repos.LocalizationRepo),
ApplyLanguageSetupUseCase: useCases.NewApplyLanguageSetupUseCase(repos.Transactor, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
+164
View File
@@ -0,0 +1,164 @@
package language
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainLanguage "RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
)
type SiteLanguagesHandler struct {
GetUseCase *useCases.GetSiteLanguagesUseCase
UpdateUseCase *useCases.UpdateSiteLanguagesUseCase
}
func NewSiteLanguagesHandler(get *useCases.GetSiteLanguagesUseCase, update *useCases.UpdateSiteLanguagesUseCase) *SiteLanguagesHandler {
return &SiteLanguagesHandler{GetUseCase: get, UpdateUseCase: update}
}
type PutLanguage struct {
Lang string `json:"lang"`
Enabled *bool `json:"enabled" validate:"required"`
SocialLocale *string `json:"social_locale"`
}
type PutSiteLanguages struct {
Revision *string `json:"revision" validate:"required"`
Default *string `json:"default" validate:"required"`
Languages []PutLanguage `json:"languages" validate:"required,dive"`
}
func (p PutSiteLanguages) command(key string) useCases.UpdateSiteLanguagesCommand {
entries := make([]useCases.SiteLanguageEntry, 0, len(p.Languages))
for _, entry := range p.Languages {
entries = append(entries, useCases.SiteLanguageEntry{Lang: entry.Lang, Enabled: *entry.Enabled, SocialLocale: entry.SocialLocale})
}
return useCases.UpdateSiteLanguagesCommand{Key: key, Revision: *p.Revision, Default: *p.Default, Languages: entries}
}
func (h *SiteLanguagesHandler) Get(w http.ResponseWriter, r *http.Request) {
view, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, view)
}
func (h *SiteLanguagesHandler) Update(w http.ResponseWriter, r *http.Request) {
var request PutSiteLanguages
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.BodyErrorMessage, http.StatusBadRequest)
return
}
view, err := h.UpdateUseCase.Update(r.Context(), request.command(chi.URLParam(r, "key")))
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, view)
}
type SetupHandler struct {
GetUseCase *useCases.GetLanguageSetupUseCase
ApplyUseCase *useCases.ApplyLanguageSetupUseCase
}
func NewSetupHandler(get *useCases.GetLanguageSetupUseCase, apply *useCases.ApplyLanguageSetupUseCase) *SetupHandler {
return &SetupHandler{GetUseCase: get, ApplyUseCase: apply}
}
type PostSetupSite struct {
Key string `json:"key" validate:"required"`
Lang string `json:"lang"`
}
type PostSetup struct {
Sites []PostSetupSite `json:"sites" validate:"required,dive"`
}
func (h *SetupHandler) Get(w http.ResponseWriter, r *http.Request) {
view, err := h.GetUseCase.Get()
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, view)
}
func (h *SetupHandler) Apply(w http.ResponseWriter, r *http.Request) {
var request PostSetup
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.BodyErrorMessage, http.StatusBadRequest)
return
}
choices := make([]localization.SetupChoice, 0, len(request.Sites))
for _, entry := range request.Sites {
choices = append(choices, localization.SetupChoice{Key: entry.Key, Lang: entry.Lang})
}
result, err := h.ApplyUseCase.Apply(r.Context(), choices)
if err != nil {
writeLocalizationError(w, err)
return
}
writeJSON(w, result)
}
func writeJSON(w http.ResponseWriter, value any) {
body, err := json.Marshal(value)
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)
}
}
type localizationFailure struct {
sentinel error
status int
body string
}
var localizationFailures = []localizationFailure{
{site.ErrSiteNotFound, http.StatusNotFound, apperr.SiteNotFoundErrorMessage},
{localization.ErrSetupRequired, http.StatusConflict, apperr.SetupRequiredErrorMessage},
{localization.ErrLanguagesChanged, http.StatusConflict, apperr.LanguagesChangedErrorMessage},
{localization.ErrLanguageInUse, http.StatusConflict, apperr.LanguageInUseErrorMessage},
{localization.ErrSetupInProgress, http.StatusConflict, apperr.SetupInProgressErrorMessage},
{localization.ErrDefaultNotEnabled, http.StatusUnprocessableEntity, apperr.DefaultNotEnabledErrorMessage},
{localization.ErrNoEnabledLanguage, http.StatusUnprocessableEntity, apperr.NoEnabledLanguageErrorMessage},
{localization.ErrDuplicateLanguage, http.StatusUnprocessableEntity, apperr.DuplicateLanguageErrorMessage},
{localization.ErrInvalidSocialLocale, http.StatusUnprocessableEntity, apperr.InvalidSocialLocaleErrorMessage},
{localization.ErrSetupIncomplete, http.StatusUnprocessableEntity, apperr.SetupIncompleteErrorMessage},
{localization.ErrDuplicateSite, http.StatusUnprocessableEntity, apperr.DuplicateSiteErrorMessage},
{domainLanguage.ErrInvalidLanguage, http.StatusUnprocessableEntity, apperr.InvalidLanguageErrorMessage},
{domainLanguage.ErrUnsupportedLanguage, http.StatusUnprocessableEntity, apperr.UnsupportedLanguageErrorMessage},
}
func writeLocalizationError(w http.ResponseWriter, err error) {
for _, failure := range localizationFailures {
if errors.Is(err, failure.sentinel) {
http.Error(w, failure.body, failure.status)
return
}
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+14
View File
@@ -71,6 +71,7 @@ type PatchSite struct {
SiteName *string `json:"site_name" validate:"omitempty,max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
PublicURLPattern optional[string] `json:"public_url_pattern"`
}
func (p PatchSite) toPatch() domain.Patch {
@@ -85,6 +86,7 @@ func (p PatchSite) toPatch() domain.Patch {
SiteName: p.SiteName,
TitleTemplate: p.TitleTemplate,
DefaultDescription: p.DefaultDescription,
PublicURLPattern: domain.Nullable[string]{Set: p.PublicURLPattern.Set, Value: p.PublicURLPattern.Value},
}
}
@@ -125,6 +127,18 @@ func writeSiteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidLocale):
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidPublicURLPattern):
http.Error(w, apperr.InvalidPublicURLPatternErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLScheme):
http.Error(w, apperr.PublicURLSchemeErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLHost):
http.Error(w, apperr.PublicURLHostErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLPlaceholder):
http.Error(w, apperr.PublicURLPlaceholderErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLNotEncoded):
http.Error(w, apperr.PublicURLNotEncodedErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrPublicURLMissingSlug):
http.Error(w, apperr.PublicURLMissingSlugErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrUseSetDefault):
+19
View File
@@ -69,6 +69,25 @@ const DefaultSiteErrorMessage = "default_site"
const SiteKeyImmutableErrorMessage = "key_immutable"
const InvalidSiteURLErrorMessage = "invalid_site_url"
const InvalidLocaleErrorMessage = "invalid_locale"
const InvalidPublicURLPatternErrorMessage = "invalid_pattern"
const PublicURLSchemeErrorMessage = "invalid_scheme"
const PublicURLHostErrorMessage = "invalid_host"
const PublicURLPlaceholderErrorMessage = "unknown_placeholder"
const PublicURLNotEncodedErrorMessage = "not_encoded"
const PublicURLMissingSlugErrorMessage = "missing_slug"
const SetupRequiredErrorMessage = "setup_required"
const LanguagesChangedErrorMessage = "languages_changed"
const LanguageInUseErrorMessage = "language_in_use"
const DefaultNotEnabledErrorMessage = "default_not_enabled"
const NoEnabledLanguageErrorMessage = "no_enabled_language"
const InvalidLanguageErrorMessage = "invalid_language"
const UnsupportedLanguageErrorMessage = "unsupported_language"
const DuplicateLanguageErrorMessage = "duplicate_language"
const InvalidSocialLocaleErrorMessage = "invalid_social_locale"
const SetupInProgressErrorMessage = "setup_in_progress"
const SetupIncompleteErrorMessage = "setup_incomplete"
const DuplicateSiteErrorMessage = "duplicate_site"
const SiteImageNotFoundErrorMessage = "image_not_found"
const UseSetDefaultErrorMessage = "use_set_default"
const InvalidReassignErrorMessage = "invalid_reassign"
+26
View File
@@ -11,6 +11,7 @@ import (
"RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
languagehandler "RenewCMS/api/handlers/language"
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership"
@@ -295,6 +296,8 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
siteLanguagesHandler := languagehandler.NewSiteLanguagesHandler(container.GetSiteLanguagesUseCase, container.UpdateSiteLanguagesUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
@@ -305,6 +308,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", siteHandler.List)
r.Get("/{key}/languages", siteLanguagesHandler.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
@@ -317,12 +321,33 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.With(container.require(role.KeySettingsManageSites)).Put("/{key}/languages", siteLanguagesHandler.Update)
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
})
return r
}
func NewLanguageRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
setupHandler := languagehandler.NewSetupHandler(container.GetLanguageSetupUseCase, container.ApplyLanguageSetupUseCase)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySettingsManageSites))
r.Get("/setup", setupHandler.Get)
r.Post("/setup", setupHandler.Apply)
})
return r
}
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
@@ -586,6 +611,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
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))
r.Mount("/language", NewLanguageRouter(container, settings.TrustedProxies))
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))