From 22eb177b3463e4201c23cde3d8a997beb218fe15 Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Sun, 25 Oct 2026 06:56:34 -0400 Subject: [PATCH 01/17] fix: require ASCII digits for page numbers --- docs/src/content/docs/reference/content-api.md | 5 +++-- .../content/docs/self-hosting/configuration.md | 16 +++++++++------- internal/content/resolver.go | 5 +++++ internal/content/resolver_test.go | 8 ++++++-- internal/publicsite/canonical_address_test.go | 2 -- internal/publicsite/publicsite_test.go | 17 +++++++++++++++-- internal/server/content_values_test.go | 12 ++++++++++++ 7 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/src/content/docs/reference/content-api.md b/docs/src/content/docs/reference/content-api.md index 9972180d..487ec73b 100644 --- a/docs/src/content/docs/reference/content-api.md +++ b/docs/src/content/docs/reference/content-api.md @@ -390,8 +390,9 @@ archive page. The front page is the exception: `/page/{n}` always answers the front page listing, and no item can be stored at `page/{n}`. Nothing published at an address answers `404 {"error":"content: not found"}`, and so does a page number below -one. The resolver reads `/page/1` and `/page/02` as pages 1 and 2, -while the built-in renderer redirects them to `/` and `/page/2`, as +one or one that contains anything but ASCII digits. The resolver reads +`/page/1` and `/page/02` as pages 1 and 2, while the built-in renderer +redirects them to `/` and `/page/2`, as [what the binary serves where](/self-hosting/configuration/#what-the-binary-serves-where) describes. diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index b46e9e14..e50dc0ea 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -844,10 +844,12 @@ The last row answering 404 from outside is correct behavior, not an outage. The built-in renderer gives each page of a listing one address. A -visit to page 1, to a page number with leading zeros or to a -listing address that ends in a slash gets a `301` redirect to the -plain address, with the query kept. So `/page/1` goes to `/`, -`/page/02` goes to `/page/2` and `/pages/` goes to `/pages`. Only a -`GET` or `HEAD` is redirected, and a listing page stays where it -was asked when other content is stored at its plain address. While -a theme serves the site, the theme answers these addresses itself. +visit to page 1, to a positive page number made only of ASCII digits +with leading zeros, or to a listing address that ends in a slash gets +a `301` redirect to the plain address, with the query kept. So +`/page/1` goes to `/`, `/page/02` goes to `/page/2` and `/pages/` +goes to `/pages`. A page number with a sign or any other non-digit +answers `404`. Only a `GET` or `HEAD` is redirected, and a listing +page stays where it was asked when other content is stored at its +plain address. While a theme serves the site, the theme answers these +addresses itself. diff --git a/internal/content/resolver.go b/internal/content/resolver.go index a7f705ce..3ab03db5 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -148,6 +148,11 @@ func listingAt(address string) (string, int, error) { if !numbered { return address, 1, nil } + for i := 0; i < len(raw); i++ { + if raw[i] < '0' || raw[i] > '9' { + return "", 0, ErrNotFound + } + } page, err := strconv.Atoi(raw) if err != nil || page < 1 { return "", 0, ErrNotFound diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index 39375ff0..e0b5c7a2 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -158,16 +158,20 @@ func TestResolveLetsContentBeatPagination(t *testing.T) { } } -func TestResolveRefusesAPageNumberBelowTheFirst(t *testing.T) { +func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { t.Parallel() resolver := newResolver(t, newFakeAddresses()) _, zero := resolver.Resolve(t.Context(), "/page/0") _, negative := resolver.Resolve(t.Context(), "/pages/page/-2") + _, signed := resolver.Resolve(t.Context(), "/page/+2") + _, nonASCIIDigits := resolver.Resolve(t.Context(), "/page/2") _, words := resolver.Resolve(t.Context(), "/page/last") - for name, err := range map[string]error{"zero": zero, "negative": negative, "words": words} { + for name, err := range map[string]error{ + "zero": zero, "negative": negative, "signed": signed, "non-ASCII digits": nonASCIIDigits, "words": words, + } { if !errors.Is(err, content.ErrNotFound) { t.Errorf("%s page error = %v, want %v", name, err, content.ErrNotFound) } diff --git a/internal/publicsite/canonical_address_test.go b/internal/publicsite/canonical_address_test.go index 9c051ca9..aa0a3a40 100644 --- a/internal/publicsite/canonical_address_test.go +++ b/internal/publicsite/canonical_address_test.go @@ -38,7 +38,6 @@ func TestSiteSendsAListingPageToTheOneAddressItAnswersAt(t *testing.T) { {"/page/1", "/"}, {"/page/01", "/"}, {"/page/02", "/page/2"}, - {"/page/+2", "/page/2"}, {"/page/1?utm_source=feed", "/?utm_source=feed"}, {"/page/02?utm_source=feed", "/page/2?utm_source=feed"}, {"/pages/page/1", "/pages"}, @@ -98,7 +97,6 @@ func TestSiteKeepsAListingPageWhoseOneAddressHoldsContent(t *testing.T) { want string }{ {[]content.Content{two}, "/pages/page/02", "Pages | A Test Site"}, - {[]content.Content{two}, "/pages/page/+2", "Pages | A Test Site"}, {[]content.Content{guide}, "/pages/page/1", "Pages | A Test Site"}, {[]content.Content{guide}, "/pages/page/01", "Pages | A Test Site"}, {[]content.Content{news, three}, "/categories/news/page/03", "News | A Test Site"}, diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index 6b54886f..58b12b8e 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -321,12 +321,14 @@ func TestSiteOffersNoNewerPageOnTheFirst(t *testing.T) { } } -func TestSiteRefusesAPageBelowTheFirst(t *testing.T) { +func TestSiteRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { t.Parallel() handler, _ := siteWith(publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC())) - for _, address := range []string{"/page/0", "/page/-1", "/pages/page/0"} { + for _, address := range []string{ + "/page/0", "/page/-1", "/page/+2", "/page/2", "/pages/page/0", "/pages/page/+2", + } { recorder := get(t, handler, address) if recorder.Code != http.StatusNotFound { @@ -335,6 +337,17 @@ func TestSiteRefusesAPageBelowTheFirst(t *testing.T) { } } +func TestSiteRefusesASignedPageAliasWhenItsDigitEquivalentHoldsContent(t *testing.T) { + t.Parallel() + + handler, _ := siteWith(storedAt("page", "Two", "pages/page/2")) + recorder := get(t, handler, "/pages/page/+2") + + if recorder.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d for a signed page alias", recorder.Code, http.StatusNotFound) + } +} + func TestSiteServesAPageBeyondTheLastAsEmpty(t *testing.T) { t.Parallel() diff --git a/internal/server/content_values_test.go b/internal/server/content_values_test.go index 10cbd60b..9b6e8e2e 100644 --- a/internal/server/content_values_test.go +++ b/internal/server/content_values_test.go @@ -938,6 +938,18 @@ func TestResolveKeepsASingleItemOffThePageWord(t *testing.T) { } } +func TestResolveRefusesASignedPageNumber(t *testing.T) { + t.Parallel() + + handler := termTypeServer(t) + recorder := doRequest(t, handler, http.MethodGet, + "/api/content/v1/resolve?path=%2Fpage%2F%2B2", "") + + if recorder.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d for a signed page number", recorder.Code, http.StatusNotFound) + } +} + func TestResolveCarriesRelationSummaries(t *testing.T) { t.Parallel() From 554a1c4be05e4c89537721d1f32adc755f51fd78 Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Sun, 25 Oct 2026 07:06:42 -0400 Subject: [PATCH 02/17] test: isolate page-number routing coverage --- internal/publicsite/page_number_test.go | 36 +++++++++++++++++++++++++ internal/publicsite/publicsite_test.go | 17 ++---------- 2 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 internal/publicsite/page_number_test.go diff --git a/internal/publicsite/page_number_test.go b/internal/publicsite/page_number_test.go new file mode 100644 index 00000000..ab27d280 --- /dev/null +++ b/internal/publicsite/page_number_test.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +package publicsite_test + +import ( + "net/http" + "testing" + "time" +) + +func TestSiteRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { + t.Parallel() + + handler, _ := siteWith(publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC())) + + for _, address := range []string{ + "/page/0", "/page/-1", "/page/+2", "/page/2", "/pages/page/0", "/pages/page/+2", + } { + recorder := get(t, handler, address) + + if recorder.Code != http.StatusNotFound { + t.Errorf("status at %q = %d, want %d", address, recorder.Code, http.StatusNotFound) + } + } +} + +func TestSiteRefusesASignedPageAliasWhenItsDigitEquivalentHoldsContent(t *testing.T) { + t.Parallel() + + handler, _ := siteWith(storedAt("page", "Two", "pages/page/2")) + recorder := get(t, handler, "/pages/page/+2") + + if recorder.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d for a signed page alias", recorder.Code, http.StatusNotFound) + } +} diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index 58b12b8e..6b54886f 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -321,14 +321,12 @@ func TestSiteOffersNoNewerPageOnTheFirst(t *testing.T) { } } -func TestSiteRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { +func TestSiteRefusesAPageBelowTheFirst(t *testing.T) { t.Parallel() handler, _ := siteWith(publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC())) - for _, address := range []string{ - "/page/0", "/page/-1", "/page/+2", "/page/2", "/pages/page/0", "/pages/page/+2", - } { + for _, address := range []string{"/page/0", "/page/-1", "/pages/page/0"} { recorder := get(t, handler, address) if recorder.Code != http.StatusNotFound { @@ -337,17 +335,6 @@ func TestSiteRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { } } -func TestSiteRefusesASignedPageAliasWhenItsDigitEquivalentHoldsContent(t *testing.T) { - t.Parallel() - - handler, _ := siteWith(storedAt("page", "Two", "pages/page/2")) - recorder := get(t, handler, "/pages/page/+2") - - if recorder.Code != http.StatusNotFound { - t.Errorf("status = %d, want %d for a signed page alias", recorder.Code, http.StatusNotFound) - } -} - func TestSiteServesAPageBeyondTheLastAsEmpty(t *testing.T) { t.Parallel() From b9a2761b571059cc04814dc3f0c959a6d2e0a81a Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Sun, 25 Oct 2026 07:09:23 -0400 Subject: [PATCH 03/17] fix: reject invalid page suffixes before lookup --- internal/content/resolver.go | 3 +++ internal/content/resolver_test.go | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/internal/content/resolver.go b/internal/content/resolver.go index 3ab03db5..6cfa55b1 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -60,6 +60,9 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) (Address, error) { return Address{}, ErrNotFound } address := tidy(raw) + if _, _, err := listingAt(address); err != nil { + return Address{}, err + } if RootPagination(address) { return r.archiveAt(ctx, address) } diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index e0b5c7a2..8747290b 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -178,6 +178,28 @@ func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { } } +func TestResolveRefusesInvalidPageSuffixesBeforeLookingUpContent(t *testing.T) { + t.Parallel() + + for name, suffix := range map[string]string{"signed": "+2", "non-ASCII digits": "2"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + store := newFakeAddresses() + store.hold("page", "pages/page/"+suffix) + resolver := newResolver(t, store) + + _, err := resolver.Resolve(t.Context(), "/pages/page/"+suffix) + if !errors.Is(err, content.ErrNotFound) { + t.Errorf("Resolve error = %v, want %v", err, content.ErrNotFound) + } + if store.lookups != 0 { + t.Errorf("content lookups = %d, want none for an invalid page suffix", store.lookups) + } + }) + } +} + func TestResolveReportsAnAddressHoldingNothing(t *testing.T) { t.Parallel() From c008e0dbce367aaa2acee0aeef64a62a0c57d25a Mon Sep 17 00:00:00 2001 From: dannyward630 Date: Sun, 25 Oct 2026 20:02:39 -0400 Subject: [PATCH 04/17] fix(content): preserve paths under page segments --- .../src/content/docs/reference/content-api.md | 6 ++-- .../docs/self-hosting/configuration.md | 5 +-- internal/content/resolver.go | 23 ++++++++++-- internal/content/resolver_test.go | 35 ++++++++++++++++++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/reference/content-api.md b/docs/src/content/docs/reference/content-api.md index 487ec73b..c55619e4 100644 --- a/docs/src/content/docs/reference/content-api.md +++ b/docs/src/content/docs/reference/content-api.md @@ -389,8 +389,10 @@ genuinely filed at `pages/page/2` is served instead of the second archive page. The front page is the exception: `/page/{n}` always answers the front page listing, and no item can be stored at `page/{n}`. Nothing published at an address answers -`404 {"error":"content: not found"}`, and so does a page number below -one or one that contains anything but ASCII digits. The resolver reads +`404 {"error":"content: not found"}`. Page numbers must be positive ASCII +digits; zero, signed, or non-ASCII numeral aliases return the same 404 even +when content is stored at that address. Other suffixes can still name +stored content. The resolver reads `/page/1` and `/page/02` as pages 1 and 2, while the built-in renderer redirects them to `/` and `/page/2`, as [what the binary serves where](/self-hosting/configuration/#what-the-binary-serves-where) diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index e50dc0ea..559dce38 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -848,8 +848,9 @@ visit to page 1, to a positive page number made only of ASCII digits with leading zeros, or to a listing address that ends in a slash gets a `301` redirect to the plain address, with the query kept. So `/page/1` goes to `/`, `/page/02` goes to `/page/2` and `/pages/` -goes to `/pages`. A page number with a sign or any other non-digit -answers `404`. Only a `GET` or `HEAD` is redirected, and a listing +goes to `/pages`. Zero, signed, or non-ASCII numeral page aliases answer +`404`. A suffix that is not numeric can still name stored content. +Only a `GET` or `HEAD` is redirected, and a listing page stays where it was asked when other content is stored at its plain address. While a theme serves the site, the theme answers these addresses itself. diff --git a/internal/content/resolver.go b/internal/content/resolver.go index 6cfa55b1..ab149bb6 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -8,6 +8,7 @@ import ( "path" "strconv" "strings" + "unicode" "unicode/utf8" ) @@ -60,8 +61,10 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) (Address, error) { return Address{}, ErrNotFound } address := tidy(raw) - if _, _, err := listingAt(address); err != nil { - return Address{}, err + if _, rawPageNumber, numbered := cutPageNumber(address); numbered && pageNumberLike(rawPageNumber) { + if _, _, err := listingAt(address); err != nil { + return Address{}, err + } } if RootPagination(address) { return r.archiveAt(ctx, address) @@ -163,6 +166,22 @@ func listingAt(address string) (string, int, error) { return root, page, nil } +// pageNumberLike reports whether a suffix looks like a signed or unsigned numeral. +func pageNumberLike(raw string) bool { + if strings.HasPrefix(raw, "+") || strings.HasPrefix(raw, "-") { + raw = raw[1:] + } + if raw == "" { + return false + } + for _, digit := range raw { + if !unicode.IsDigit(digit) { + return false + } + } + return true +} + // RootPagination reports whether the address names a positive page of the root listing. func RootPagination(address string) bool { word, _, err := listingAt(address) diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index 8747290b..1821c954 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -158,6 +158,33 @@ func TestResolveLetsContentBeatPagination(t *testing.T) { } } +func TestResolveLetsContentBeatNonNumericPageSuffixes(t *testing.T) { + t.Parallel() + + for name, storedPath := range map[string]string{ + "root page word": "page/about", + "nested page word": "chapter/page/intro", + "ordinal-like suffix": "page/2nd", + } { + storedPath := storedPath + t.Run(name, func(t *testing.T) { + t.Parallel() + + store := newFakeAddresses() + held := store.hold("page", storedPath) + resolver := newResolver(t, store) + + answer, err := resolver.Resolve(t.Context(), "/"+storedPath) + if err != nil { + t.Fatalf("Resolve error = %v, want nil", err) + } + if answer.Kind != content.KindItem || answer.Item.ID != held.ID { + t.Errorf("answer = %q with item %q, want stored item %q", answer.Kind, answer.Item.ID, held.ID) + } + }) + } +} + func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { t.Parallel() @@ -181,7 +208,13 @@ func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { func TestResolveRefusesInvalidPageSuffixesBeforeLookingUpContent(t *testing.T) { t.Parallel() - for name, suffix := range map[string]string{"signed": "+2", "non-ASCII digits": "2"} { + for name, suffix := range map[string]string{ + "zero": "0", + "negative": "-2", + "signed": "+2", + "non-ASCII": "2", + "overflow": "999999999999999999999999999999999999", + } { t.Run(name, func(t *testing.T) { t.Parallel() From 4dafeeade214fb72217682ad6df80e09a352dc28 Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Wed, 28 Oct 2026 12:56:11 -0400 Subject: [PATCH 05/17] fix(content): reject Unicode numeric aliases Co-Authored-By: OpenAI Codex --- internal/content/resolver.go | 2 +- internal/content/resolver_test.go | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/content/resolver.go b/internal/content/resolver.go index ab149bb6..39af586e 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -175,7 +175,7 @@ func pageNumberLike(raw string) bool { return false } for _, digit := range raw { - if !unicode.IsDigit(digit) { + if !unicode.IsNumber(digit) { return false } } diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index 1821c954..534a16ea 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -194,10 +194,12 @@ func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { _, negative := resolver.Resolve(t.Context(), "/pages/page/-2") _, signed := resolver.Resolve(t.Context(), "/page/+2") _, nonASCIIDigits := resolver.Resolve(t.Context(), "/page/2") + _, superscriptNumber := resolver.Resolve(t.Context(), "/page/²") _, words := resolver.Resolve(t.Context(), "/page/last") for name, err := range map[string]error{ - "zero": zero, "negative": negative, "signed": signed, "non-ASCII digits": nonASCIIDigits, "words": words, + "zero": zero, "negative": negative, "signed": signed, + "non-ASCII digits": nonASCIIDigits, "superscript": superscriptNumber, "words": words, } { if !errors.Is(err, content.ErrNotFound) { t.Errorf("%s page error = %v, want %v", name, err, content.ErrNotFound) @@ -209,11 +211,12 @@ func TestResolveRefusesInvalidPageSuffixesBeforeLookingUpContent(t *testing.T) { t.Parallel() for name, suffix := range map[string]string{ - "zero": "0", - "negative": "-2", - "signed": "+2", - "non-ASCII": "2", - "overflow": "999999999999999999999999999999999999", + "zero": "0", + "negative": "-2", + "signed": "+2", + "non-ASCII": "2", + "superscript": "²", + "overflow": "999999999999999999999999999999999999", } { t.Run(name, func(t *testing.T) { t.Parallel() From fa94865c80f739789ed16d34afdeafa517769b3e Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Wed, 28 Oct 2026 16:07:05 -0400 Subject: [PATCH 06/17] fix: reject sign-only page suffixes --- internal/content/resolver.go | 5 +++-- internal/content/resolver_test.go | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/content/resolver.go b/internal/content/resolver.go index 39af586e..301f52d7 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -168,11 +168,12 @@ func listingAt(address string) (string, int, error) { // pageNumberLike reports whether a suffix looks like a signed or unsigned numeral. func pageNumberLike(raw string) bool { - if strings.HasPrefix(raw, "+") || strings.HasPrefix(raw, "-") { + signed := strings.HasPrefix(raw, "+") || strings.HasPrefix(raw, "-") + if signed { raw = raw[1:] } if raw == "" { - return false + return signed } for _, digit := range raw { if !unicode.IsNumber(digit) { diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index 534a16ea..ccd39642 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -193,12 +193,15 @@ func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { _, zero := resolver.Resolve(t.Context(), "/page/0") _, negative := resolver.Resolve(t.Context(), "/pages/page/-2") _, signed := resolver.Resolve(t.Context(), "/page/+2") + _, plusOnly := resolver.Resolve(t.Context(), "/page/+") + _, minusOnly := resolver.Resolve(t.Context(), "/page/-") _, nonASCIIDigits := resolver.Resolve(t.Context(), "/page/2") _, superscriptNumber := resolver.Resolve(t.Context(), "/page/²") _, words := resolver.Resolve(t.Context(), "/page/last") for name, err := range map[string]error{ "zero": zero, "negative": negative, "signed": signed, + "sign only +": plusOnly, "sign only -": minusOnly, "non-ASCII digits": nonASCIIDigits, "superscript": superscriptNumber, "words": words, } { if !errors.Is(err, content.ErrNotFound) { @@ -214,6 +217,8 @@ func TestResolveRefusesInvalidPageSuffixesBeforeLookingUpContent(t *testing.T) { "zero": "0", "negative": "-2", "signed": "+2", + "sign only +": "+", + "sign only -": "-", "non-ASCII": "2", "superscript": "²", "overflow": "999999999999999999999999999999999999", From d6582ed88d3b8acdc3c3f741f43878e8561fd9a4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:38:02 +0100 Subject: [PATCH 07/17] test(features): expect exact content to beat a number that is no page number --- test/features/features/content-serving.feature | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/features/features/content-serving.feature b/test/features/features/content-serving.feature index 773e5fb6..a6fad84c 100644 --- a/test/features/features/content-serving.feature +++ b/test/features/features/content-serving.feature @@ -45,6 +45,11 @@ Feature: Serving content by address And the published page "2" filed under "Page" Then "/pages/page/2" answers with "2" + Scenario: Exact content beats a number that is no page number + Given the published page "Page" + And the published page "٢" filed under "Page" + Then "/pages/page/٢" answers with "٢" + Scenario: A padded page number stays where content holds the plain number Given the published page "Page" And the published page "2" filed under "Page" From fbb7a1b98d9d7ae331b73eb7168f1106ea293b96 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:38:03 +0100 Subject: [PATCH 08/17] fix(content): let stored content beat a page number the resolver refuses --- internal/content/resolver.go | 23 -------------- internal/content/resolver_test.go | 51 +++++++++---------------------- 2 files changed, 14 insertions(+), 60 deletions(-) diff --git a/internal/content/resolver.go b/internal/content/resolver.go index 301f52d7..3ab03db5 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -8,7 +8,6 @@ import ( "path" "strconv" "strings" - "unicode" "unicode/utf8" ) @@ -61,11 +60,6 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) (Address, error) { return Address{}, ErrNotFound } address := tidy(raw) - if _, rawPageNumber, numbered := cutPageNumber(address); numbered && pageNumberLike(rawPageNumber) { - if _, _, err := listingAt(address); err != nil { - return Address{}, err - } - } if RootPagination(address) { return r.archiveAt(ctx, address) } @@ -166,23 +160,6 @@ func listingAt(address string) (string, int, error) { return root, page, nil } -// pageNumberLike reports whether a suffix looks like a signed or unsigned numeral. -func pageNumberLike(raw string) bool { - signed := strings.HasPrefix(raw, "+") || strings.HasPrefix(raw, "-") - if signed { - raw = raw[1:] - } - if raw == "" { - return signed - } - for _, digit := range raw { - if !unicode.IsNumber(digit) { - return false - } - } - return true -} - // RootPagination reports whether the address names a positive page of the root listing. func RootPagination(address string) bool { word, _, err := listingAt(address) diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index ccd39642..36a95635 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -158,15 +158,22 @@ func TestResolveLetsContentBeatPagination(t *testing.T) { } } -func TestResolveLetsContentBeatNonNumericPageSuffixes(t *testing.T) { +func TestResolveLetsContentBeatARefusedPageNumber(t *testing.T) { t.Parallel() for name, storedPath := range map[string]string{ - "root page word": "page/about", - "nested page word": "chapter/page/intro", - "ordinal-like suffix": "page/2nd", + "a word": "page/about", + "a nested word": "chapter/page/intro", + "an ordinal": "page/2nd", + "zero": "pages/page/0", + "negative": "pages/page/-2", + "signed": "pages/page/+2", + "a plus sign alone": "pages/page/+", + "a minus sign alone": "pages/page/-", + "a fullwidth two": "pages/page/\U0000FF12", + "a superscript two": "pages/page/\U000000B2", + "too long to read": "pages/page/99999999999999999999", } { - storedPath := storedPath t.Run(name, func(t *testing.T) { t.Parallel() @@ -178,8 +185,9 @@ func TestResolveLetsContentBeatNonNumericPageSuffixes(t *testing.T) { if err != nil { t.Fatalf("Resolve error = %v, want nil", err) } + if answer.Kind != content.KindItem || answer.Item.ID != held.ID { - t.Errorf("answer = %q with item %q, want stored item %q", answer.Kind, answer.Item.ID, held.ID) + t.Errorf("answer = %q %v, want the item stored at the address", answer.Kind, answer.Item.ID) } }) } @@ -210,37 +218,6 @@ func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { } } -func TestResolveRefusesInvalidPageSuffixesBeforeLookingUpContent(t *testing.T) { - t.Parallel() - - for name, suffix := range map[string]string{ - "zero": "0", - "negative": "-2", - "signed": "+2", - "sign only +": "+", - "sign only -": "-", - "non-ASCII": "2", - "superscript": "²", - "overflow": "999999999999999999999999999999999999", - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - - store := newFakeAddresses() - store.hold("page", "pages/page/"+suffix) - resolver := newResolver(t, store) - - _, err := resolver.Resolve(t.Context(), "/pages/page/"+suffix) - if !errors.Is(err, content.ErrNotFound) { - t.Errorf("Resolve error = %v, want %v", err, content.ErrNotFound) - } - if store.lookups != 0 { - t.Errorf("content lookups = %d, want none for an invalid page suffix", store.lookups) - } - }) - } -} - func TestResolveReportsAnAddressHoldingNothing(t *testing.T) { t.Parallel() From e89217dc5aa970eece78d7c1aaa8f30bda8d9bd6 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:38:03 +0100 Subject: [PATCH 09/17] test(publicsite): put the plain digit page number test beside its twin --- internal/publicsite/page_number_test.go | 36 ------------------------- internal/publicsite/publicsite_test.go | 18 +++++++++++++ 2 files changed, 18 insertions(+), 36 deletions(-) delete mode 100644 internal/publicsite/page_number_test.go diff --git a/internal/publicsite/page_number_test.go b/internal/publicsite/page_number_test.go deleted file mode 100644 index ab27d280..00000000 --- a/internal/publicsite/page_number_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package publicsite_test - -import ( - "net/http" - "testing" - "time" -) - -func TestSiteRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { - t.Parallel() - - handler, _ := siteWith(publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC())) - - for _, address := range []string{ - "/page/0", "/page/-1", "/page/+2", "/page/2", "/pages/page/0", "/pages/page/+2", - } { - recorder := get(t, handler, address) - - if recorder.Code != http.StatusNotFound { - t.Errorf("status at %q = %d, want %d", address, recorder.Code, http.StatusNotFound) - } - } -} - -func TestSiteRefusesASignedPageAliasWhenItsDigitEquivalentHoldsContent(t *testing.T) { - t.Parallel() - - handler, _ := siteWith(storedAt("page", "Two", "pages/page/2")) - recorder := get(t, handler, "/pages/page/+2") - - if recorder.Code != http.StatusNotFound { - t.Errorf("status = %d, want %d for a signed page alias", recorder.Code, http.StatusNotFound) - } -} diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index 6b54886f..353bff50 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -335,6 +335,24 @@ func TestSiteRefusesAPageBelowTheFirst(t *testing.T) { } } +func TestSiteReadsAPageNumberOnlyFromPlainDigits(t *testing.T) { + t.Parallel() + + handler, _ := siteWith( + publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC()), + storedAt("page", "Two", "pages/page/2"), + storedAt("category", "News", "categories/news"), + ) + + for _, address := range []string{"/page/+2", "/page/\U0000FF12", "/pages/page/+2", "/categories/news/page/+2"} { + recorder := get(t, handler, address) + + if recorder.Code != http.StatusNotFound { + t.Errorf("status at %q = %d, want %d", address, recorder.Code, http.StatusNotFound) + } + } +} + func TestSiteServesAPageBeyondTheLastAsEmpty(t *testing.T) { t.Parallel() From ad43353df8736afa0c04310453c79c157dc240a0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:38:04 +0100 Subject: [PATCH 10/17] test(server): read a page number only from plain digits under a route word and a term --- internal/server/content_values_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/server/content_values_test.go b/internal/server/content_values_test.go index 9b6e8e2e..3075bab6 100644 --- a/internal/server/content_values_test.go +++ b/internal/server/content_values_test.go @@ -938,15 +938,24 @@ func TestResolveKeepsASingleItemOffThePageWord(t *testing.T) { } } -func TestResolveRefusesASignedPageNumber(t *testing.T) { +func TestResolveReadsAPageNumberOnlyFromPlainDigits(t *testing.T) { t.Parallel() handler := termTypeServer(t) - recorder := doRequest(t, handler, http.MethodGet, - "/api/content/v1/resolve?path=%2Fpage%2F%2B2", "") + publishItemAt(t, handler, storedCategoryItem(t, handler)) - if recorder.Code != http.StatusNotFound { - t.Errorf("status = %d, want %d for a signed page number", recorder.Code, http.StatusNotFound) + for path, want := range map[string]int{ + "page/%2B2": http.StatusNotFound, + "categories/page/%2B2": http.StatusNotFound, + "categories/news/page/%2B2": http.StatusNotFound, + "categories/page/02": http.StatusOK, + "categories/news/page/02": http.StatusOK, + } { + recorder := doRequest(t, handler, http.MethodGet, "/api/content/v1/resolve?path="+path, "") + + if recorder.Code != want { + t.Errorf("resolve %s status = %d, want %d", path, recorder.Code, want) + } } } From 99781dd6dfc1b86d7dd36651ae927cd444414dd2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:38:04 +0100 Subject: [PATCH 11/17] docs(reference): say which page numbers answer 404 in plain words --- docs/src/content/docs/reference/content-api.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/reference/content-api.md b/docs/src/content/docs/reference/content-api.md index c55619e4..9ba402a5 100644 --- a/docs/src/content/docs/reference/content-api.md +++ b/docs/src/content/docs/reference/content-api.md @@ -389,12 +389,10 @@ genuinely filed at `pages/page/2` is served instead of the second archive page. The front page is the exception: `/page/{n}` always answers the front page listing, and no item can be stored at `page/{n}`. Nothing published at an address answers -`404 {"error":"content: not found"}`. Page numbers must be positive ASCII -digits; zero, signed, or non-ASCII numeral aliases return the same 404 even -when content is stored at that address. Other suffixes can still name -stored content. The resolver reads -`/page/1` and `/page/02` as pages 1 and 2, while the built-in renderer -redirects them to `/` and `/page/2`, as +`404 {"error":"content: not found"}`, and so does a page number below +one or holding a sign other than the digits 0 to 9, such as `/page/+2`. +The resolver reads `/page/1` and `/page/02` as pages 1 and 2, +while the built-in renderer redirects them to `/` and `/page/2`, as [what the binary serves where](/self-hosting/configuration/#what-the-binary-serves-where) describes. From 66022a9cbd239944c7292c9db86945e6fa67cabd Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:38:04 +0100 Subject: [PATCH 12/17] docs(self-hosting): keep the listing redirect paragraph to redirects --- .../content/docs/self-hosting/configuration.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 559dce38..b46e9e14 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -844,13 +844,10 @@ The last row answering 404 from outside is correct behavior, not an outage. The built-in renderer gives each page of a listing one address. A -visit to page 1, to a positive page number made only of ASCII digits -with leading zeros, or to a listing address that ends in a slash gets -a `301` redirect to the plain address, with the query kept. So -`/page/1` goes to `/`, `/page/02` goes to `/page/2` and `/pages/` -goes to `/pages`. Zero, signed, or non-ASCII numeral page aliases answer -`404`. A suffix that is not numeric can still name stored content. -Only a `GET` or `HEAD` is redirected, and a listing -page stays where it was asked when other content is stored at its -plain address. While a theme serves the site, the theme answers these -addresses itself. +visit to page 1, to a page number with leading zeros or to a +listing address that ends in a slash gets a `301` redirect to the +plain address, with the query kept. So `/page/1` goes to `/`, +`/page/02` goes to `/page/2` and `/pages/` goes to `/pages`. Only a +`GET` or `HEAD` is redirected, and a listing page stays where it +was asked when other content is stored at its plain address. While +a theme serves the site, the theme answers these addresses itself. From bba2d7ce5224cce60d13a19d08b594c76442ea91 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:41:08 +0100 Subject: [PATCH 13/17] test(content): give the plain digit cases their own tests --- internal/content/resolver_test.go | 43 ++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/internal/content/resolver_test.go b/internal/content/resolver_test.go index 36a95635..298084af 100644 --- a/internal/content/resolver_test.go +++ b/internal/content/resolver_test.go @@ -193,24 +193,36 @@ func TestResolveLetsContentBeatARefusedPageNumber(t *testing.T) { } } -func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { +func TestResolveRefusesAPageNumberBelowTheFirst(t *testing.T) { t.Parallel() resolver := newResolver(t, newFakeAddresses()) _, zero := resolver.Resolve(t.Context(), "/page/0") _, negative := resolver.Resolve(t.Context(), "/pages/page/-2") - _, signed := resolver.Resolve(t.Context(), "/page/+2") - _, plusOnly := resolver.Resolve(t.Context(), "/page/+") - _, minusOnly := resolver.Resolve(t.Context(), "/page/-") - _, nonASCIIDigits := resolver.Resolve(t.Context(), "/page/2") - _, superscriptNumber := resolver.Resolve(t.Context(), "/page/²") _, words := resolver.Resolve(t.Context(), "/page/last") + for name, err := range map[string]error{"zero": zero, "negative": negative, "words": words} { + if !errors.Is(err, content.ErrNotFound) { + t.Errorf("%s page error = %v, want %v", name, err, content.ErrNotFound) + } + } +} + +func TestResolveReadsAPageNumberOnlyFromPlainDigits(t *testing.T) { + t.Parallel() + + resolver := newResolver(t, newFakeAddresses()) + + _, signed := resolver.Resolve(t.Context(), "/page/+2") + _, plusAlone := resolver.Resolve(t.Context(), "/page/+") + _, minusAlone := resolver.Resolve(t.Context(), "/page/-") + _, fullwidth := resolver.Resolve(t.Context(), "/page/\U0000FF12") + _, superscript := resolver.Resolve(t.Context(), "/page/\U000000B2") + for name, err := range map[string]error{ - "zero": zero, "negative": negative, "signed": signed, - "sign only +": plusOnly, "sign only -": minusOnly, - "non-ASCII digits": nonASCIIDigits, "superscript": superscriptNumber, "words": words, + "signed": signed, "a plus sign alone": plusAlone, "a minus sign alone": minusAlone, + "a fullwidth two": fullwidth, "a superscript two": superscript, } { if !errors.Is(err, content.ErrNotFound) { t.Errorf("%s page error = %v, want %v", name, err, content.ErrNotFound) @@ -218,6 +230,19 @@ func TestResolveRefusesPageNumbersThatAreNotPositiveASCIIDigits(t *testing.T) { } } +func TestResolveReadsEveryDigitOfAPageNumber(t *testing.T) { + t.Parallel() + + answer, err := newResolver(t, newFakeAddresses()).Resolve(t.Context(), "/pages/page/1234567890") + if err != nil { + t.Fatalf("Resolve error = %v, want nil", err) + } + + if answer.Kind != content.KindArchive || answer.Page != 1234567890 { + t.Errorf("answer = %q page %d, want the page archive at page 1234567890", answer.Kind, answer.Page) + } +} + func TestResolveReportsAnAddressHoldingNothing(t *testing.T) { t.Parallel() From 9265821a13dc72c37b0f0ebea9f2d2070075c9eb Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:41:08 +0100 Subject: [PATCH 14/17] refactor(content): check a page number for plain digits in one condition --- internal/content/resolver.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/internal/content/resolver.go b/internal/content/resolver.go index 3ab03db5..8fc7e437 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -148,13 +148,8 @@ func listingAt(address string) (string, int, error) { if !numbered { return address, 1, nil } - for i := 0; i < len(raw); i++ { - if raw[i] < '0' || raw[i] > '9' { - return "", 0, ErrNotFound - } - } page, err := strconv.Atoi(raw) - if err != nil || page < 1 { + if err != nil || page < 1 || strings.Trim(raw, "0123456789") != "" { return "", 0, ErrNotFound } return root, page, nil From 30c6e57a294cc2d3b049cd80f3168930413bc7dd Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 11:41:09 +0100 Subject: [PATCH 15/17] test(features): expect a signed page number to answer not found --- test/features/features/content-serving.feature | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/features/features/content-serving.feature b/test/features/features/content-serving.feature index a6fad84c..c6af87f4 100644 --- a/test/features/features/content-serving.feature +++ b/test/features/features/content-serving.feature @@ -77,6 +77,10 @@ Feature: Serving content by address Scenario: A page number below the first answers not found Then "/page/0" answers not found + Scenario: A signed page number answers not found + Then "/page/+2" answers not found + And "/pages/page/+2" answers not found + Scenario: An archive of an inactive type answers not found Given the type "page" is deactivated Then "/pages" answers not found From 81a6b1dae4c160adea7f6f94311b5c5d7a6ac43c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 12:07:37 +0100 Subject: [PATCH 16/17] refactor(content): check for plain digits before reading the page number --- internal/content/resolver.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/content/resolver.go b/internal/content/resolver.go index 8fc7e437..9095acef 100644 --- a/internal/content/resolver.go +++ b/internal/content/resolver.go @@ -148,8 +148,11 @@ func listingAt(address string) (string, int, error) { if !numbered { return address, 1, nil } + if strings.Trim(raw, "0123456789") != "" { + return "", 0, ErrNotFound + } page, err := strconv.Atoi(raw) - if err != nil || page < 1 || strings.Trim(raw, "0123456789") != "" { + if err != nil || page < 1 { return "", 0, ErrNotFound } return root, page, nil From f34359fc47e552554c99d997c53707f7547361ba Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 31 Oct 2026 12:07:37 +0100 Subject: [PATCH 17/17] test(publicsite): move the plain digit page number test to its own file --- internal/publicsite/page_number_test.go | 27 +++++++++++++++++++++++++ internal/publicsite/publicsite_test.go | 18 ----------------- 2 files changed, 27 insertions(+), 18 deletions(-) create mode 100644 internal/publicsite/page_number_test.go diff --git a/internal/publicsite/page_number_test.go b/internal/publicsite/page_number_test.go new file mode 100644 index 00000000..f67e708d --- /dev/null +++ b/internal/publicsite/page_number_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package publicsite_test + +import ( + "net/http" + "testing" + "time" +) + +func TestSiteReadsAPageNumberOnlyFromPlainDigits(t *testing.T) { + t.Parallel() + + handler, _ := siteWith( + publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC()), + storedAt("page", "Two", "pages/page/2"), + storedAt("category", "News", "categories/news"), + ) + + for _, address := range []string{"/page/+2", "/page/\U0000FF12", "/pages/page/+2", "/categories/news/page/+2"} { + recorder := get(t, handler, address) + + if recorder.Code != http.StatusNotFound { + t.Errorf("status at %q = %d, want %d", address, recorder.Code, http.StatusNotFound) + } + } +} diff --git a/internal/publicsite/publicsite_test.go b/internal/publicsite/publicsite_test.go index 353bff50..6b54886f 100644 --- a/internal/publicsite/publicsite_test.go +++ b/internal/publicsite/publicsite_test.go @@ -335,24 +335,6 @@ func TestSiteRefusesAPageBelowTheFirst(t *testing.T) { } } -func TestSiteReadsAPageNumberOnlyFromPlainDigits(t *testing.T) { - t.Parallel() - - handler, _ := siteWith( - publishedPost("A Post", "a-post", blockMarkup, time.Now().UTC()), - storedAt("page", "Two", "pages/page/2"), - storedAt("category", "News", "categories/news"), - ) - - for _, address := range []string{"/page/+2", "/page/\U0000FF12", "/pages/page/+2", "/categories/news/page/+2"} { - recorder := get(t, handler, address) - - if recorder.Code != http.StatusNotFound { - t.Errorf("status at %q = %d, want %d", address, recorder.Code, http.StatusNotFound) - } - } -} - func TestSiteServesAPageBeyondTheLastAsEmpty(t *testing.T) { t.Parallel()