Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
22eb177
fix: require ASCII digits for page numbers
dannyward630 Oct 25, 2026
554a1c4
test: isolate page-number routing coverage
dannyward630 Oct 25, 2026
b9a2761
fix: reject invalid page suffixes before lookup
dannyward630 Oct 25, 2026
c008e0d
fix(content): preserve paths under page segments
dannyward630 Oct 26, 2026
4dafeea
fix(content): reject Unicode numeric aliases
dannyward630 Oct 28, 2026
fa94865
fix: reject sign-only page suffixes
dannyward630 Oct 28, 2026
d6582ed
test(features): expect exact content to beat a number that is no page…
SirLouen Oct 31, 2026
fbb7a1b
fix(content): let stored content beat a page number the resolver refuses
SirLouen Oct 31, 2026
e89217d
test(publicsite): put the plain digit page number test beside its twin
SirLouen Oct 31, 2026
ad43353
test(server): read a page number only from plain digits under a route…
SirLouen Oct 31, 2026
99781dd
docs(reference): say which page numbers answer 404 in plain words
SirLouen Oct 31, 2026
66022a9
docs(self-hosting): keep the listing redirect paragraph to redirects
SirLouen Oct 31, 2026
bba2d7c
test(content): give the plain digit cases their own tests
SirLouen Oct 31, 2026
9265821
refactor(content): check a page number for plain digits in one condition
SirLouen Oct 31, 2026
30c6e57
test(features): expect a signed page number to answer not found
SirLouen Oct 31, 2026
81a6b1d
refactor(content): check for plain digits before reading the page number
SirLouen Oct 31, 2026
f34359f
test(publicsite): move the plain digit page number test to its own file
SirLouen Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/content-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@ 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,
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.
Expand Down
3 changes: 3 additions & 0 deletions internal/content/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ 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 {
return "", 0, ErrNotFound
Expand Down
69 changes: 69 additions & 0 deletions internal/content/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,41 @@ func TestResolveLetsContentBeatPagination(t *testing.T) {
}
}

func TestResolveLetsContentBeatARefusedPageNumber(t *testing.T) {
t.Parallel()

for name, storedPath := range map[string]string{
"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",
} {
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 %v, want the item stored at the address", answer.Kind, answer.Item.ID)
}
})
}
}

func TestResolveRefusesAPageNumberBelowTheFirst(t *testing.T) {
t.Parallel()

Expand All @@ -174,6 +209,40 @@ func TestResolveRefusesAPageNumberBelowTheFirst(t *testing.T) {
}
}

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{
"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)
}
}
}

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()

Expand Down
2 changes: 0 additions & 2 deletions internal/publicsite/canonical_address_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
27 changes: 27 additions & 0 deletions internal/publicsite/page_number_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
21 changes: 21 additions & 0 deletions internal/server/content_values_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,27 @@ func TestResolveKeepsASingleItemOffThePageWord(t *testing.T) {
}
}

func TestResolveReadsAPageNumberOnlyFromPlainDigits(t *testing.T) {
t.Parallel()

handler := termTypeServer(t)
publishItemAt(t, handler, storedCategoryItem(t, handler))

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)
}
}
}

func TestResolveCarriesRelationSummaries(t *testing.T) {
t.Parallel()

Expand Down
9 changes: 9 additions & 0 deletions test/features/features/content-serving.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -72,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
Expand Down
Loading