From c42bc265c83206c00aa7803dbf82a2ccb52c786d Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 12 Oct 2026 21:28:28 +0200 Subject: [PATCH] fix(17): CR-01 refuse non-http sitemap URLs on save, import and output A url item stored as javascript://%0aalert(1)%0a//x passed Url::to and reached , which sitemap.xsl renders as a link on the site origin. - The definitions form refuses, per row, a url that is not a path or an http(s) URL (classes.AllowedItemURL) and a urlPattern that is not a path (signpost.ValidItemPattern), keyed data..url / .urlPattern. - Generator.addItem drops a loc or alternate that is not http(s). - sitemap:import-winter drops such url items and clears such patterns with a report line; sitemap:import refuses such an entry. --- README.md | 14 ++++++---- classes/generator.go | 15 +++++++++-- classes/generator_test.go | 32 +++++++++++++++++++++++ classes/urlto.go | 33 +++++++++++++++++++++++ classes/urlto_test.go | 27 +++++++++++++++++++ console/import.go | 7 ++++- console/import_winter.go | 24 ++++++++++++++--- controllers/definitions.go | 46 +++++++++++++++++++++++++++++++++ controllers/definitions_test.go | 39 ++++++++++++++++++++++++++++ import_test.go | 25 ++++++++++++++++++ lang/en/lang.yaml | 3 +++ lang/pl/lang.yaml | 3 +++ 12 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 controllers/definitions_test.go diff --git a/README.md b/README.md index d05fe5b..07e47a8 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,10 @@ Each URL is written in the PHP order (`classes.Generator`): 2. A URL item's address goes through the port of Winter's `Url::to` (`classes.URLTo`): root plus the path trimmed of slashes, `/` keeps its slash, every path segment re-encoded like `rawurlencode`, valid absolute, `mailto:`, `tel:` and fragment URLs kept. 3. `lastmod` is the record's modification time, else the updates manifest's date for the URL path, else omitted. It is written as `2006-01-02T15:04:05+00:00` in UTC. 4. `classes.BeforeAddItem` may drop the URL. -5. The priority is the first matching tier's setting when tiers are on, else the item's priority, written as stored text (`1.0` stays `1.0`). -6. `loc`, `lastmod`, `changefreq` and `priority` are written, then the alternates as `xhtml:link` elements, only when there are more than one. -7. `classes.AddItem` may append XML before ``. +5. A URL that does not come out as an `http` or `https` URL (for example a `javascript:` URL an import or a registry type let through) is dropped, and so is such an alternate. The PHP plugin wrote any URL `Url::to` kept; the stylesheet renders `` as a link on the site's origin. +6. The priority is the first matching tier's setting when tiers are on, else the item's priority, written as stored text (`1.0` stays `1.0`). +7. `loc`, `lastmod`, `changefreq` and `priority` are written, then the alternates as `xhtml:link` elements, only when there are more than one. +8. `classes.AddItem` may append XML before ``. A registry item is resolved with its URL pattern, its nesting flag and the settings' frontend default locale as the unprefixed locale. A resolved item with a URL is written, then its sub-items once each with their own URL. A list type (a resolved item without a URL, such as all posts) writes each sub-item once per alternate, with that alternate's URL as `loc` and every alternate as `hreflang`, which is what the PHP list types returned. @@ -91,6 +92,8 @@ location = /sitemap.xsl { The admin's type dropdown offers `url` and every registry type in registry order with its label. The reference dropdown depends on the row's type and lists the type's references (`signpost.ReferenceOptions`); `url` and types without references take none, and a reference the type does not offer is refused on save. +A save also refuses, per row, a `url` that is not a path (relative or starting with a single `/`) or an absolute `http`/`https` URL (`classes.AllowedItemURL`: no other scheme, no protocol-relative `//host`, no bare fragment, no surrounding whitespace or control characters), and a `urlPattern` that is not a path starting with a single `/` (`signpost.ValidItemPattern`). The errors are keyed `data..url` and `data..urlPattern`. + ### Settings | Setting | Default | Meaning | @@ -188,6 +191,7 @@ The WinterCMS forms are adapted to the admin's YAML: | `classes.Serialize`, `classes.URLEntry`, `classes.Alternate` | The libxml-format writer and its entries. | | `classes.EscapeText`, `classes.EscapeAttr` | libxml's text and attribute escaping. | | `classes.URLTo`, `classes.BuildURL`, `classes.URLPath` | Ports of Winter `Url::to`, `Url::buildUrl` and `parse_url(..., PHP_URL_PATH)`. | +| `classes.AllowedItemURL` | Whether a url item's address may be stored: a path or an absolute `http`/`https` URL. | | `classes.Rewrite`, `classes.BackendURL` | The `frontend_url` rewrite and the backend URL it replaces. | | `classes.TiersConfigKey`, `classes.TierConfig`, `classes.TierRule`, `classes.Tiers`, `classes.CompileTiers`, `classes.LoadTiers` | The tier rules. | | `classes.Manifest`, `classes.LoadManifest`, `classes.ManifestMaxBytes` | The updates manifest. | @@ -200,8 +204,8 @@ The WinterCMS forms are adapted to the admin's YAML: | Command | Arguments and flags | Effect | |---------|---------------------|--------| -| `sitemap:import` | `FILE`, `--theme`, `--append` | The PHP `ImportCommand`: reads a JSON array of `{url, changefreq, priority}` entries (`monthly` and `0.5` by default). Without `--append` the definition keeps its non-URL items first and the imported URLs replace its URL items; with it the URLs follow every existing item. The definition is always `default`; a `--theme` other than `default` is reported. | -| `sitemap:import-winter` | `--definitions FILE`, `--settings FILE`, `--theme`, `--map old=new` (repeatable), `--page-url reference=/path` (repeatable), `--force`, `--dry-run` | Copies a WinterCMS export into the definition and the settings row. URL items are kept; static and CMS page items are dropped unless `--page-url` names the URL of their reference (for example `--page-url blog=/blog` copies the CMS page item `blog` as a url item `/blog`); `--map` renames types first (for example `--map blog-post=journal-post`); an item of an unknown type, or whose reference is not among its type's current references, is dropped. Every dropped or remapped item and every dropped `cmsPage` is printed. A definition that already has items is refused without `--force`; `--dry-run` writes nothing. Settings missing from the export take the defaults. `--theme` picks a definition when the export holds several. | +| `sitemap:import` | `FILE`, `--theme`, `--append` | The PHP `ImportCommand`: reads a JSON array of `{url, changefreq, priority}` entries (`monthly` and `0.5` by default). Without `--append` the definition keeps its non-URL items first and the imported URLs replace its URL items; with it the URLs follow every existing item. The definition is always `default`; a `--theme` other than `default` is reported. An entry whose URL `classes.AllowedItemURL` refuses stops the import. | +| `sitemap:import-winter` | `--definitions FILE`, `--settings FILE`, `--theme`, `--map old=new` (repeatable), `--page-url reference=/path` (repeatable), `--force`, `--dry-run` | Copies a WinterCMS export into the definition and the settings row. URL items are kept; static and CMS page items are dropped unless `--page-url` names the URL of their reference (for example `--page-url blog=/blog` copies the CMS page item `blog` as a url item `/blog`); `--map` renames types first (for example `--map blog-post=journal-post`); an item of an unknown type, or whose reference is not among its type's current references, is dropped; a url item whose URL `classes.AllowedItemURL` refuses is dropped, and so is a page item whose `--page-url` it refuses; a `urlPattern` that is not a path is cleared. Every dropped, remapped or cleared item and every dropped `cmsPage` is printed. A definition that already has items is refused without `--force`; `--dry-run` writes nothing. Settings missing from the export take the defaults. `--theme` picks a definition when the export holds several. | Both commands open the database themselves when the application has not published one. diff --git a/classes/generator.go b/classes/generator.go index c57192a..8960c9a 100644 --- a/classes/generator.go +++ b/classes/generator.go @@ -196,7 +196,8 @@ func (g *Generator) manifest() Manifest { // missing URL filled through Url::to, lastmod from the record or the // manifest, BeforeAddItem, an empty URL dropped and the URL passed through // Url::to again, the count, the tier or item priority, the elements and -// AddItem. +// AddItem. Unlike PHP, a URL or alternate that does not come out as an +// http or https URL is dropped. func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, info itemInfo) error { if g.count >= MaxURLs { return nil @@ -229,6 +230,12 @@ func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, in return nil } loc := URLTo(g.Root, info.url) + if !httpURL(loc) { + // Only http and https URLs are written: a javascript: or other + // scheme that an import, a registry type or a pattern let through + // would be a clickable link in sitemap.xsl. + return nil + } g.count++ priority, ok := g.Tiers.Priority(loc, g.Settings) if !ok { @@ -242,7 +249,11 @@ func (g *Generator) addItem(ctx context.Context, item *models.DefinitionItem, in } if len(info.alternates) > 1 { for _, a := range info.alternates { - entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: URLTo(g.Root, a.Href)}) + href := URLTo(g.Root, a.Href) + if !httpURL(href) { + continue + } + entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: href}) } } if g.Events != nil { diff --git a/classes/generator_test.go b/classes/generator_test.go index d04abe5..2d28c45 100644 --- a/classes/generator_test.go +++ b/classes/generator_test.go @@ -149,3 +149,35 @@ func TestURLCap(t *testing.T) { t.Fatalf("no items = %q %v", empty, err) } } + +// TestOnlyHTTPLocs pins the CR-01 output filter: a URL that Url::to keeps +// but that is not http or https (a javascript: URL FILTER_VALIDATE_URL +// accepts, mailto:, a bare fragment) is not written and not counted. A +// protocol-relative URL comes out of Url::to on the root host. +func TestOnlyHTTPLocs(t *testing.T) { + gen := &classes.Generator{Root: "https://api.example.test"} + xml, err := gen.Generate(context.Background(), urlItems( + "javascript://%0aalert(1)%0a//x", + "JavaScript://example.test/%0aalert(1)", + "//evil.test/x", + "mailto:a@example.test", + "#top", + "/ok", + "https://other.example.test/page", + )) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(xml, ""); n != 3 { + t.Fatalf("%d url elements, want 3:\n%s", n, xml) + } + if !strings.Contains(xml, "https://api.example.test/evil.test/x") { + t.Fatalf("the protocol-relative URL did not stay on the root host:\n%s", xml) + } + if strings.Contains(strings.ToLower(xml), "javascript") || strings.Contains(xml, "mailto:") || strings.Contains(xml, "#top") { + t.Fatalf("a non-http URL was written:\n%s", xml) + } + if !strings.Contains(xml, "https://api.example.test/ok") || !strings.Contains(xml, "https://other.example.test/page") { + t.Fatalf("an http(s) URL is missing:\n%s", xml) + } +} diff --git a/classes/urlto.go b/classes/urlto.go index b5a9a53..c14d022 100644 --- a/classes/urlto.go +++ b/classes/urlto.go @@ -531,3 +531,36 @@ func URLPath(raw string) string { } return u.path } + +// schemePrefix matches a URL that starts with a scheme and its colon. +var schemePrefix = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.\-]*:`) + +// AllowedItemURL reports whether a url item's address may be stored: empty, +// a path (relative or starting with a single "/"), or an absolute http or +// https URL. Any other scheme (javascript:, data:, mailto: and the like), a +// protocol-relative "//host" or "/\host", a bare fragment, surrounding +// whitespace and control characters are refused. Url::to keeps any URL +// FILTER_VALIDATE_URL accepts, so without this a javascript: URL reached +// , which sitemap.xsl renders as a link. +func AllowedItemURL(u string) bool { + if u == "" { + return true + } + if strings.TrimSpace(u) != u || strings.ContainsFunc(u, func(r rune) bool { return r < 0x20 || r == 0x7f }) { + return false + } + if httpURL(u) { + return true + } + if schemePrefix.MatchString(u) || u[0] == '#' || u[0] == '\\' { + return false + } + return !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, `/\`) +} + +// httpURL reports whether u starts with http:// or https:// +// (case-insensitive). +func httpURL(u string) bool { + lower := strings.ToLower(u) + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") +} diff --git a/classes/urlto_test.go b/classes/urlto_test.go index f2e6d0c..bbf424e 100644 --- a/classes/urlto_test.go +++ b/classes/urlto_test.go @@ -55,3 +55,30 @@ func TestURLTo(t *testing.T) { } } } + +// TestAllowedItemURL pins the save rule of a url item's address (CR-01). +func TestAllowedItemURL(t *testing.T) { + for u, want := range map[string]bool{ + "": true, + "/": true, + "/about": true, + "about/team?x=1": true, + "https://example.test/a": true, + "HTTP://example.test/a": true, + "javascript://%0aalert(1)%0a//x": false, + "javascript:alert(1)": false, + "data:text/html,x": false, + "mailto:a@example.test": false, + "//evil.test/x": false, + `/\evil.test/x`: false, + `\\evil.test`: false, + "#top": false, + " /about": false, + "/about\n": false, + "java\tscript:alert(1)": false, + } { + if got := classes.AllowedItemURL(u); got != want { + t.Errorf("AllowedItemURL(%q) = %v, want %v", u, got, want) + } + } +} diff --git a/console/import.go b/console/import.go index 3fe6c45..2a472c7 100644 --- a/console/import.go +++ b/console/import.go @@ -8,6 +8,7 @@ import ( "fmt" "os" + "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" @@ -22,7 +23,8 @@ import ( // its url items; with --append the URLs are added after every existing item. // The definition is always the one /sitemap.xml serves (theme default); // --theme is accepted for PHP compatibility and a theme other than default -// is reported. +// is reported. An entry whose url is not a path or an http(s) URL +// (classes.AllowedItemURL) is refused, as the admin form refuses it. func Import(app *backpack.App) bonfire.Command { return bonfire.Command{ Name: "sitemap:import", @@ -67,6 +69,9 @@ func ImportFile(ctx context.Context, db *gorm.DB, file, theme string, appendMode if fields["url"] == nil || err != nil || phpEmpty(fields["url"]) { return fmt.Errorf("Entry %s is missing a 'url' field", labels[i]) } + if !classes.AllowedItemURL(models.Text(url)) { + return fmt.Errorf("Entry %s has a url that is not a path or an http(s) URL", labels[i]) + } item := models.DefinitionItem{Type: models.ItemTypeURL, URL: url} item.Changefreq = textOr(fields["changefreq"], "monthly") item.Priority = textOr(fields["priority"], "0.5") diff --git a/console/import_winter.go b/console/import_winter.go index b4439d8..eb90176 100644 --- a/console/import_winter.go +++ b/console/import_winter.go @@ -12,6 +12,7 @@ import ( "sort" "strings" + "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" @@ -65,9 +66,12 @@ type WinterReport struct { // their URL, --map old=new renames applied first), // an item of a registry type that takes references is dropped when its // reference is not among the type's current references, an item of an -// unknown type is dropped, and every dropped or remapped item is printed. A -// definition that already has items is refused without --force. Settings -// keys missing from the export take the PHP defaults. +// unknown type is dropped, a url item whose URL is not a path or an http(s) +// URL (classes.AllowedItemURL) is dropped, a URL pattern that is not a path +// (signpost.ValidItemPattern) is cleared, and every dropped, remapped or +// cleared item is printed. A definition that already has items is refused +// without --force. Settings keys missing from the export take the PHP +// defaults. func ImportWinter(app *backpack.App) bonfire.Command { return bonfire.Command{ Name: "sitemap:import-winter", @@ -328,7 +332,16 @@ func mapItems(ctx context.Context, reg *signpost.Registry, data json.RawMessage, if page, ok := fields["cmsPage"].(string); ok && page != "" { report.Lines = append(report.Lines, fmt.Sprintf("%s: cmsPage '%s' dropped (CMS pages are not supported)", label, page)) } + if p := models.Text(item.URLPattern); p != "" && !signpost.ValidItemPattern(p) { + report.Lines = append(report.Lines, fmt.Sprintf("%s: urlPattern '%s' cleared (not a path starting with a single /)", label, p)) + item.URLPattern = nil + } if url, ok := pageURLs[models.Text(item.Reference)]; ok && droppedTypes[item.Type] { + if !classes.AllowedItemURL(url) { + report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (--page-url '%s' is not a path or an http(s) URL)", label, url)) + report.Dropped++ + continue + } report.Lines = append(report.Lines, fmt.Sprintf("%s: copied as type 'url' with url '%s'", label, url)) report.Remapped++ item.Type = models.ItemTypeURL @@ -349,6 +362,11 @@ func mapItems(ctx context.Context, reg *signpost.Registry, data json.RawMessage, } } if item.Type == models.ItemTypeURL { + if !classes.AllowedItemURL(models.Text(item.URL)) { + report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (the url is not a path or an http(s) URL)", label)) + report.Dropped++ + continue + } out = append(out, item) report.Kept++ continue diff --git a/controllers/definitions.go b/controllers/definitions.go index a6963c3..1587e9d 100644 --- a/controllers/definitions.go +++ b/controllers/definitions.go @@ -4,9 +4,12 @@ package controllers import ( "context" "errors" + "fmt" + "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/signpost" "gorm.io/gorm" @@ -23,6 +26,12 @@ const ( // typeURLLabel is the label of the built-in url item type. const typeURLLabel = "golem15.sitemap::lang.item.url" +// The phrase keys of the item row refusals. +const ( + errURLScheme = "golem15.sitemap::lang.errors.url_scheme" + errURLPattern = "golem15.sitemap::lang.errors.url_pattern" +) + // definitionsAdminController edits the one sitemap definition of the // application (theme default): a singleton form whose items are a repeater // with a type dropdown (url plus the registry types) and a reference @@ -96,6 +105,41 @@ func (c definitionsAdminController) DependentOptions(ctx context.Context, _ stri return signpost.ReferenceOptions(ctx, c.registry(), typ) } +// FormBeforeCreate checks the item rows (see checkItems). +func (c definitionsAdminController) FormBeforeCreate(_ context.Context, model any) error { + return c.checkItems(model) +} + +// FormBeforeUpdate checks the item rows (see checkItems). +func (c definitionsAdminController) FormBeforeUpdate(_ context.Context, model any) error { + return c.checkItems(model) +} + +// checkItems refuses, per row, a url outside classes.AllowedItemURL +// (data..url) and a URL pattern that is not a path +// (signpost.ValidItemPattern, data..urlPattern). The PHP plugin checked +// neither; a javascript: URL would reach , which sitemap.xsl renders as +// a link. +func (definitionsAdminController) checkItems(model any) error { + def, ok := model.(*models.Definition) + if !ok || def == nil { + return nil + } + details := map[string]any{} + for i, item := range def.Items() { + if !classes.AllowedItemURL(models.Text(item.URL)) { + details[fmt.Sprintf("data.%d.url", i)] = []string{errURLScheme} + } + if p := models.Text(item.URLPattern); p != "" && !signpost.ValidItemPattern(p) { + details[fmt.Sprintf("data.%d.urlPattern", i)] = []string{errURLPattern} + } + } + if len(details) > 0 { + return &cabana.ValidationError{Details: details} + } + return nil +} + var ( _ pact.AdminController = definitionsAdminController{} _ pact.AdminPermissioned = definitionsAdminController{} @@ -103,4 +147,6 @@ var ( _ pact.AdminSingleton = definitionsAdminController{} _ pact.DropdownOptionsProvider = definitionsAdminController{} _ pact.DependentOptionsProvider = definitionsAdminController{} + _ pact.FormBeforeCreate = definitionsAdminController{} + _ pact.FormBeforeUpdate = definitionsAdminController{} ) diff --git a/controllers/definitions_test.go b/controllers/definitions_test.go new file mode 100644 index 0000000..1ae0677 --- /dev/null +++ b/controllers/definitions_test.go @@ -0,0 +1,39 @@ +package controllers + +import ( + "errors" + "testing" + + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/cabana" +) + +func ptr(s string) *string { return &s } + +// TestCheckItems pins the CR-01 save check: per row, a url outside +// classes.AllowedItemURL and a URL pattern that is not a path are refused +// under data..url and data..urlPattern. +func TestCheckItems(t *testing.T) { + c := definitionsAdminController{} + def := &models.Definition{} + def.SetItems([]models.DefinitionItem{ + {Type: models.ItemTypeURL, URL: ptr("/about")}, + {Type: models.ItemTypeURL, URL: ptr("javascript://%0aalert(1)%0a//x")}, + {Type: "acme-post", URLPattern: ptr("//evil.test/:slug")}, + {Type: "acme-post", URLPattern: ptr("/blog/:slug")}, + }) + err := c.checkItems(def) + var verr *cabana.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("checkItems = %v, want a validation error", err) + } + if len(verr.Details) != 2 || verr.Details["data.1.url"] == nil || verr.Details["data.2.urlPattern"] == nil { + t.Fatalf("details = %v", verr.Details) + } + + ok := &models.Definition{} + ok.SetItems([]models.DefinitionItem{{Type: models.ItemTypeURL, URL: ptr("https://example.test/")}}) + if err := c.checkItems(ok); err != nil { + t.Fatalf("a valid definition was refused: %v", err) + } +} diff --git a/import_test.go b/import_test.go index 1350a7d..191abe1 100644 --- a/import_test.go +++ b/import_test.go @@ -164,6 +164,31 @@ func TestImportWinter(t *testing.T) { } }) + t.Run("url and pattern checks", func(t *testing.T) { + bad := writeFile(t, dir, "bad-urls.jsonl", row(7, "t", `[{"type":"url","url":"javascript://%0aalert(1)%0a//x"},`+ + `{"type":"url","url":"/ok"},`+ + `{"type":"acme-post","reference":"7","urlPattern":"//evil.test/:slug"}]`)) + report, out, err := run(console.WinterOptions{DefinitionsFile: bad, Force: true}) + if err != nil { + t.Fatalf("import: %v\n%s", err, out) + } + if report.Kept != 2 || report.Dropped != 1 { + t.Fatalf("report = %+v\n%s", report, out) + } + for _, line := range []string{ + "item 0 (type 'url', url 'javascript://%0aalert(1)%0a//x'): dropped (the url is not a path or an http(s) URL)", + "item 2 (type 'acme-post', reference '7'): urlPattern '//evil.test/:slug' cleared (not a path starting with a single /)", + } { + if !strings.Contains(out, line) { + t.Errorf("output lacks %q:\n%s", line, out) + } + } + got := stored(t) + if len(got) != 2 || models.Text(got[0].URL) != "/ok" || got[1].URLPattern != nil { + t.Fatalf("stored = %+v", got) + } + }) + t.Run("settings upsert", func(t *testing.T) { first := writeFile(t, dir, "settings.json", `{"frontend_url":"https://www.example.test","enable_priority_tiers":"1","priority_blog":0.7,"unknown":"x"}`) report, out, err := run(console.WinterOptions{SettingsFile: first}) diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index cafdcf2..5898899 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -37,6 +37,9 @@ item: save_definition: Saving Definition... load_indicator: Emptying Definition... empty_confirm: Empty this definition? +errors: + url_scheme: Use a path such as /about, or a link that starts with http:// or https://. + url_pattern: Use a path that starts with a single /, for example /blog/:slug. definition: not_found: No sitemap definition was found. Try creating one first. title: Sitemap definition diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 8e8ad8f..072abe0 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -37,6 +37,9 @@ item: save_definition: Zapisywanie definicji... load_indicator: Czyszczenie definicji... empty_confirm: Wyczyścić tę definicję? +errors: + url_scheme: Podaj ścieżkę, np. /o-nas, albo link zaczynający się od http:// lub https://. + url_pattern: Podaj ścieżkę zaczynającą się od jednego /, np. /blog/:slug. definition: not_found: Nie znaleziono definicji mapy strony. Musisz ją utworzyć. title: Definicja mapy strony