From cff2d29fdd27fe49ff17e18d925cba7aa4c05b3f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 15:37:31 +0100 Subject: [PATCH] fix: set_url_seo and record imports refuse a locale the record does not have (WR-18) A locale given with a URL write overrode the matched one unchecked, so "xx" created an entry nothing ever reads while the answer reported success. SetURLSEO (API, MCP, batch set_seo, records import) now requires one of the record's locales and refuses others with api.locale_unknown. --- classes/owner.go | 10 +++++++++- controllers/api/seo.go | 2 +- lang/en/lang.yaml | 1 + lang/pl/lang.yaml | 1 + mcp_test.go | 11 +++++++++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/classes/owner.go b/classes/owner.go index 6dbb318..cf91165 100644 --- a/classes/owner.go +++ b/classes/owner.go @@ -156,7 +156,15 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U } if owner.WrittenTo == WrittenToRecord { locale := owner.Locale - if l := strings.TrimSpace(in.Locale); l != "" { + if l := strings.TrimSpace(in.Locale); l != "" && l != locale { + // Another locale must be one the record describes (WR-18): an + // entry in any other would never be read by the resolver or the + // SEO tab. + if _, known := s.recordLocales(ctx, typ.Key, owner.ID)[l]; !known { + return URLSEOResult{}, &ProblemsError{Problems: map[string][]models.Problem{ + "locale": {{Key: "golem15.seo::lang.api.locale_unknown", Params: map[string]string{"locale": l}}}, + }} + } locale = l } owner.Locale = locale diff --git a/controllers/api/seo.go b/controllers/api/seo.go index 61548ed..99164ca 100644 --- a/controllers/api/seo.go +++ b/controllers/api/seo.go @@ -40,7 +40,7 @@ func (v SEOValues) Map() map[string]string { // SetSEOInput is the body of PUT /_seo/api/v1/seo. type SetSEOInput struct { URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"` - Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO; the locale the URL matches when omitted"` + Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO, one of the record's locales (422 locale_unknown otherwise); the locale the URL matches when omitted"` Values SEOValues `json:"values" jsonschema:"the values to change"` ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"` } diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 05e0ab6..e2bfb6f 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -315,6 +315,7 @@ api: canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL. bot_unknown: This is not an AI bot of the robots.txt screen. value_invalid: This value is not valid here. + locale_unknown: "The record behind this address has no locale “:locale”." type_unknown: No content type with this key is registered. batch_too_large: "A batch takes at most :max changes." change_not_found: The record this change names does not exist. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index b762a68..39fce3a 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -325,6 +325,7 @@ api: canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s). bot_unknown: To nie jest bot AI z ekranu robots.txt. value_invalid: Ta wartość jest tu nieprawidłowa. + locale_unknown: "Rekord pod tym adresem nie ma wersji językowej „:locale”." type_unknown: Nie zarejestrowano typu treści o tym kluczu. batch_too_large: "Partia może mieć najwyżej :max zmian." change_not_found: Rekord wskazany w tej zmianie nie istnieje. diff --git a/mcp_test.go b/mcp_test.go index b78798b..55aac30 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -257,6 +257,17 @@ func TestManagementAPI(t *testing.T) { } env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": "https://cdn.example/a.png"}}) + // A locale the record does not describe is refused (WR-18); one it + // does is written. + for _, bad := range []string{"xx", "EN "} { + if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": bad, "values": map[string]any{"title": "Lost"}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"locale"`) { + t.Fatalf("set_url_seo locale %q = %d %s, want 422 on locale", bad, rec.Code, rec.Body.String()) + } + } + if got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": "pl", "values": map[string]any{"title": "Po polsku"}}); got["locale"] != "pl" { + t.Fatalf("set_url_seo locale pl = %v", got) + } + // URL rules. rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"}) data, _ := rule["data"].(map[string]any)