Skip to content

Commit b013cd3

Browse files
authored
[1.x] fix(core): render only the requested posts on the no-JS discussion page (#4852)
The server-rendered discussion page chose what to print by scanning the whole API response for anything post-shaped: foreach ($apiDocument->included as $resource) { if ($resource->type === 'posts' && isset($resource->relationships->discussion) && isset($resource->attributes->contentHtml)) { Extensions register their own post relationships on that endpoint. flarum/mentions adds posts.mentionedBy together with posts.mentionedBy.discussion, so every post that quoted a post on the page arrives in `included` as a full post, carrying a discussion relationship and rendered content. That makes it indistinguishable from the page's own posts, and the quoted post gets printed on a page it does not belong to, then printed again on the page it does. On a forum with mentions enabled the effect is easy to see. Fetching the first eight pages of a busy discussion returned 29, 20, 23, 24, 21, 23, 20 and 24 posts rather than 20 each, with 21 posts appearing on two different pages. The page also disagreed with itself about which page it was. A numbered URL computes $page as 1 + intdiv($near, 20) and uses it for the previous and next links and for the canonical URL, while the window it rendered was centred on the linked post. So /d/1/25 declared page 2 as canonical and then printed posts 15 to 30, putting six of page 1's posts on a page that claims to be page 2. Ask the posts endpoint for the page instead, and render its primary data. Extensions cannot add posts to another endpoint's primary data, so the render is correct by construction rather than by filtering more cleverly, and the rendered window now always matches the canonical URL. The discussion document, and therefore the payload the JS app boots from, is deliberately left alone. It still carries the posts surrounding the one being linked to, which is what lets the app scroll to it. Verified in a dev forum (1.8, headless): with the fix, pages of a 30 post discussion render 20 and 10 with no post on two pages, against 23 and 10 before. Reverting the change reproduces the old counts.
1 parent d113dc2 commit b013cd3

3 files changed

Lines changed: 251 additions & 1 deletion

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Mentions\Tests\integration\frontend;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
14+
use Flarum\Testing\integration\TestCase;
15+
use Illuminate\Support\Str;
16+
17+
class DiscussionPageMentionsTest extends TestCase
18+
{
19+
use RetrievesAuthorizedUsers;
20+
21+
protected function setUp(): void
22+
{
23+
parent::setUp();
24+
25+
$this->extension('flarum-mentions');
26+
27+
$posts = [];
28+
29+
for ($i = 1; $i <= 25; $i++) {
30+
$posts[] = [
31+
'id' => $i,
32+
'number' => $i,
33+
'discussion_id' => 1,
34+
'created_at' => Carbon::parse('2024-01-01 00:00:00')->addMinutes($i),
35+
'user_id' => 2,
36+
'type' => 'comment',
37+
'content' => '<t><p>POST-MARKER-'.str_pad((string) $i, 2, '0', STR_PAD_LEFT).'</p></t>',
38+
];
39+
}
40+
41+
$this->prepareDatabase([
42+
'discussions' => [
43+
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 1, 'last_post_id' => 25, 'comment_count' => 25, 'is_private' => 0],
44+
],
45+
'posts' => $posts,
46+
// Post 21 sits on page 2 and quotes post 2, which sits on page 1.
47+
'post_mentions_post' => [
48+
['post_id' => 21, 'mentions_post_id' => 2],
49+
],
50+
'users' => [
51+
$this->normalUser(),
52+
],
53+
]);
54+
}
55+
56+
private function html(string $path): string
57+
{
58+
$body = $this->send($this->request('GET', $path))->getBody()->getContents();
59+
60+
return Str::before($body, '<script id="flarum-json-payload"');
61+
}
62+
63+
private function marker(int $number): string
64+
{
65+
return 'POST-MARKER-'.str_pad((string) $number, 2, '0', STR_PAD_LEFT);
66+
}
67+
68+
/**
69+
* This extension asks the discussion endpoint to include, for every post on
70+
* the page, the posts that quoted it. Those arrive as full posts, with a
71+
* discussion relationship and rendered content, so a page must not decide
72+
* what to render by looking for post-shaped resources in the response.
73+
*
74+
* @test
75+
*/
76+
public function page_does_not_render_a_later_post_that_quotes_one_of_its_posts()
77+
{
78+
$body = $this->html('/d/1');
79+
80+
// Page 1 is posts 1 to 20.
81+
$this->assertStringContainsString($this->marker(2), $body);
82+
$this->assertStringContainsString($this->marker(20), $body);
83+
84+
// Post 21 belongs to page 2. It quotes post 2, and that must not drag
85+
// it onto page 1.
86+
$this->assertStringNotContainsString($this->marker(21), $body);
87+
}
88+
}

framework/core/src/Forum/Content/Discussion.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,27 @@ public function __invoke(Document $document, Request $request)
8585
($queryString ? '?'.$queryString : '');
8686
};
8787

88+
// Ask for the posts this page is supposed to show, instead of sifting the
89+
// included resources for anything post-shaped. Extensions register their
90+
// own post relationships on this endpoint: flarum/mentions includes
91+
// posts.mentionedBy along with posts.mentionedBy.discussion, so every post
92+
// that quoted a post on this page arrives as a full post, with a discussion
93+
// relationship and rendered content, and is indistinguishable from the
94+
// page's own posts once it is in `included`. That is how quoted posts ended
95+
// up printed on pages they do not belong to, and printed again on the page
96+
// they do.
97+
$postsApiDocument = $this->getPostsApiDocument($request, [
98+
'filter' => ['discussion' => $apiDocument->data->id],
99+
'sort' => 'number',
100+
'page' => [
101+
'offset' => ($page - 1) * 20,
102+
'limit' => 20,
103+
],
104+
]);
105+
88106
$posts = [];
89107

90-
foreach ($apiDocument->included as $resource) {
108+
foreach ($postsApiDocument->data as $resource) {
91109
if ($resource->type === 'posts' && isset($resource->relationships->discussion) && isset($resource->attributes->contentHtml)) {
92110
$posts[] = $resource;
93111
}
@@ -132,4 +150,21 @@ protected function getApiDocument(Request $request, string $id, array $params)
132150

133151
return json_decode($response->getBody());
134152
}
153+
154+
/**
155+
* Get the result of an API request to list one page of a discussion's posts.
156+
*
157+
* The primary data of this response is exactly the posts being asked for, so
158+
* unlike the discussion endpoint's `included`, extensions cannot add posts of
159+
* their own to it.
160+
*/
161+
protected function getPostsApiDocument(Request $request, array $params)
162+
{
163+
$response = $this->api
164+
->withParentRequest($request)
165+
->withQueryParams($params)
166+
->get('/posts');
167+
168+
return json_decode($response->getBody());
169+
}
135170
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\integration\frontend;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
14+
use Flarum\Testing\integration\TestCase;
15+
use Illuminate\Support\Str;
16+
17+
class DiscussionPageContentTest extends TestCase
18+
{
19+
use RetrievesAuthorizedUsers;
20+
21+
protected function setUp(): void
22+
{
23+
parent::setUp();
24+
25+
$posts = [];
26+
27+
// Markers are zero padded so that no marker is a prefix of another one,
28+
// which keeps assertStringNotContainsString honest.
29+
for ($i = 1; $i <= 30; $i++) {
30+
$posts[] = [
31+
'id' => $i,
32+
'number' => $i,
33+
'discussion_id' => 1,
34+
'created_at' => Carbon::parse('2024-01-01 00:00:00')->addMinutes($i)->toDateTimeString(),
35+
'user_id' => 2,
36+
'type' => 'comment',
37+
'content' => '<t><p>POST-MARKER-'.str_pad((string) $i, 2, '0', STR_PAD_LEFT).'</p></t>',
38+
];
39+
}
40+
41+
$this->prepareDatabase([
42+
'discussions' => [
43+
['id' => 1, 'title' => 'Paginated discussion', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'last_post_id' => 30, 'comment_count' => 30, 'is_private' => 0],
44+
],
45+
'posts' => $posts,
46+
'users' => [
47+
$this->normalUser(),
48+
],
49+
]);
50+
}
51+
52+
/**
53+
* The rendered markup, without the JSON payload that follows it.
54+
*
55+
* The markup is what a client with no JavaScript, and a crawler, reads as
56+
* the page. The payload after it is the data the JS app boots from, which
57+
* still carries the posts surrounding the one being linked to so that the
58+
* app can scroll to it, and is not page text.
59+
*/
60+
private function html(string $path, array $query = []): string
61+
{
62+
$request = $this->request('GET', $path);
63+
64+
if ($query) {
65+
$request = $request->withQueryParams($query);
66+
}
67+
68+
$body = $this->send($request)->getBody()->getContents();
69+
70+
return Str::before($body, '<script id="flarum-json-payload"');
71+
}
72+
73+
private function marker(int $number): string
74+
{
75+
return 'POST-MARKER-'.str_pad((string) $number, 2, '0', STR_PAD_LEFT);
76+
}
77+
78+
/**
79+
* @test
80+
*/
81+
public function first_page_renders_only_the_first_twenty_posts()
82+
{
83+
$body = $this->html('/d/1');
84+
85+
$this->assertStringContainsString($this->marker(1), $body);
86+
$this->assertStringContainsString($this->marker(20), $body);
87+
$this->assertStringNotContainsString($this->marker(21), $body);
88+
$this->assertStringNotContainsString($this->marker(30), $body);
89+
}
90+
91+
/**
92+
* @test
93+
*/
94+
public function second_page_renders_only_the_second_page_of_posts()
95+
{
96+
$body = $this->html('/d/1', ['page' => 2]);
97+
98+
$this->assertStringNotContainsString($this->marker(1), $body);
99+
$this->assertStringNotContainsString($this->marker(20), $body);
100+
$this->assertStringContainsString($this->marker(21), $body);
101+
$this->assertStringContainsString($this->marker(30), $body);
102+
}
103+
104+
/**
105+
* A discussion page must not serve content that belongs to another page.
106+
* A numbered URL declares a canonical page, so the page it renders has to
107+
* be that one, otherwise a crawler indexes one page's posts under another
108+
* page's URL.
109+
*
110+
* @test
111+
*/
112+
public function numbered_url_renders_the_page_it_declares_as_canonical()
113+
{
114+
$body = $this->html('/d/1/25');
115+
116+
$this->assertStringContainsString('?page=2', $body, 'Expected the numbered URL to declare page 2 as its canonical URL.');
117+
118+
// Post 25 sits on page 2, so page 2 is what should be rendered.
119+
$this->assertStringContainsString($this->marker(21), $body);
120+
$this->assertStringContainsString($this->marker(30), $body);
121+
122+
// Posts 15 to 20 belong to page 1, and must not appear on a page whose
123+
// canonical URL says it is page 2.
124+
$this->assertStringNotContainsString($this->marker(15), $body);
125+
$this->assertStringNotContainsString($this->marker(20), $body);
126+
}
127+
}

0 commit comments

Comments
 (0)