SECURITY: add pagination to post replies
When a post has some replies, and the user click on the button to show them, we would load ALL the replies. This could lead to DoS if there were a very large number of replies. This adds support for pagination to these post replies. Internal ref t/129773 FIX: Duplicated parent posts DEV: Query refactor
This commit is contained in:
parent
e081cc14fb
commit
34d04e7507
|
@ -2,12 +2,9 @@ import RestAdapter from "discourse/adapters/rest";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
|
||||||
export default class PostReplyHistoryAdapter extends RestAdapter {
|
export default class PostReplyHistoryAdapter extends RestAdapter {
|
||||||
find(store, type, findArgs) {
|
find(_store, _type, { postId }) {
|
||||||
const maxReplies = this.siteSettings.max_reply_history;
|
return ajax(`/posts/${postId}/reply-history`).then(
|
||||||
return ajax(
|
(post_reply_histories) => ({ post_reply_histories })
|
||||||
`/posts/${findArgs.postId}/reply-history?max_replies=${maxReplies}`
|
);
|
||||||
).then((replies) => {
|
|
||||||
return { post_reply_histories: replies };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,9 @@ import RestAdapter from "discourse/adapters/rest";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
|
||||||
export default class PostReplyAdapter extends RestAdapter {
|
export default class PostReplyAdapter extends RestAdapter {
|
||||||
find(store, type, findArgs) {
|
find(_store, _type, { postId, after = 1 }) {
|
||||||
return ajax(`/posts/${findArgs.postId}/replies`).then((replies) => {
|
return ajax(`/posts/${postId}/replies?after=${after || 1}`).then(
|
||||||
return { post_replies: replies };
|
(post_replies) => ({ post_replies })
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,10 +100,7 @@ registerButton("read-count", (attrs, state) => {
|
||||||
if (attrs.showReadIndicator) {
|
if (attrs.showReadIndicator) {
|
||||||
const count = attrs.readCount;
|
const count = attrs.readCount;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
let ariaPressed = "false";
|
let ariaPressed = (state?.readers?.length > 0).toString();
|
||||||
if (state?.readers && state.readers.length > 0) {
|
|
||||||
ariaPressed = "true";
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
action: "toggleWhoRead",
|
action: "toggleWhoRead",
|
||||||
title: "post.controls.read_indicator",
|
title: "post.controls.read_indicator",
|
||||||
|
@ -151,10 +148,8 @@ function likeCount(attrs, state) {
|
||||||
addContainer = true;
|
addContainer = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ariaPressed = "false";
|
let ariaPressed = (state?.likedUsers?.length > 0).toString();
|
||||||
if (state?.likedUsers && state.likedUsers.length > 0) {
|
|
||||||
ariaPressed = "true";
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
action: "toggleWhoLiked",
|
action: "toggleWhoLiked",
|
||||||
title,
|
title,
|
||||||
|
@ -287,53 +282,52 @@ registerButton("wiki-edit", (attrs) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
registerButton("replies", (attrs, state, siteSettings) => {
|
registerButton("replies", (attrs, state, siteSettings) => {
|
||||||
const replyCount = attrs.replyCount;
|
const count = attrs.replyCount;
|
||||||
if (!replyCount) {
|
|
||||||
|
if (!count) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = "toggleRepliesBelow",
|
let title;
|
||||||
icon = state.repliesShown ? "chevron-up" : "chevron-down";
|
let action = "toggleRepliesBelow";
|
||||||
|
let icon = state.repliesShown ? "chevron-up" : "chevron-down";
|
||||||
|
|
||||||
if (siteSettings.enable_filtered_replies_view) {
|
if (siteSettings.enable_filtered_replies_view) {
|
||||||
action = "toggleFilteredRepliesView";
|
action = "toggleFilteredRepliesView";
|
||||||
icon = state.filteredRepliesShown ? "chevron-up" : "chevron-down";
|
icon = state.filteredRepliesShown ? "chevron-up" : "chevron-down";
|
||||||
|
title = state.filteredRepliesShown
|
||||||
|
? "post.view_all_posts"
|
||||||
|
: "post.filtered_replies_hint";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Omit replies if the setting `suppress_reply_directly_below` is enabled
|
// Omit replies if the setting `suppress_reply_directly_below` is enabled
|
||||||
if (
|
if (
|
||||||
replyCount === 1 &&
|
count === 1 &&
|
||||||
attrs.replyDirectlyBelow &&
|
attrs.replyDirectlyBelow &&
|
||||||
siteSettings.suppress_reply_directly_below
|
siteSettings.suppress_reply_directly_below
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ariaPressed;
|
let ariaPressed, ariaExpanded;
|
||||||
|
|
||||||
if (!siteSettings.enable_filtered_replies_view) {
|
if (!siteSettings.enable_filtered_replies_view) {
|
||||||
ariaPressed = state.repliesShown ? "true" : "false";
|
ariaPressed = state.repliesShown.toString();
|
||||||
|
ariaExpanded = state.repliesShown.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action,
|
action,
|
||||||
icon,
|
icon,
|
||||||
className: "show-replies",
|
className: "show-replies",
|
||||||
titleOptions: { count: replyCount },
|
titleOptions: { count },
|
||||||
title: siteSettings.enable_filtered_replies_view
|
title,
|
||||||
? state.filteredRepliesShown
|
labelOptions: { count },
|
||||||
? "post.view_all_posts"
|
|
||||||
: "post.filtered_replies_hint"
|
|
||||||
: "",
|
|
||||||
labelOptions: { count: replyCount },
|
|
||||||
label: attrs.mobileView ? "post.has_replies_count" : "post.has_replies",
|
label: attrs.mobileView ? "post.has_replies_count" : "post.has_replies",
|
||||||
iconRight: !siteSettings.enable_filtered_replies_view || attrs.mobileView,
|
iconRight: !siteSettings.enable_filtered_replies_view || attrs.mobileView,
|
||||||
disabled: !!attrs.deleted,
|
disabled: !!attrs.deleted,
|
||||||
translatedAriaLabel: I18n.t("post.sr_expand_replies", {
|
translatedAriaLabel: I18n.t("post.sr_expand_replies", { count }),
|
||||||
count: replyCount,
|
ariaExpanded,
|
||||||
}),
|
|
||||||
ariaExpanded:
|
|
||||||
!siteSettings.enable_filtered_replies_view && state.repliesShown
|
|
||||||
? "true"
|
|
||||||
: "false",
|
|
||||||
ariaPressed,
|
ariaPressed,
|
||||||
ariaControls: `embedded-posts__bottom--${attrs.post_number}`,
|
ariaControls: `embedded-posts__bottom--${attrs.post_number}`,
|
||||||
};
|
};
|
||||||
|
|
|
@ -31,9 +31,13 @@ import getURL, {
|
||||||
import { iconNode } from "discourse-common/lib/icon-library";
|
import { iconNode } from "discourse-common/lib/icon-library";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
|
||||||
function transformWithCallbacks(post) {
|
function transformWithCallbacks(post, topicUrl, store) {
|
||||||
let transformed = transformBasicPost(post);
|
let transformed = transformBasicPost(post);
|
||||||
postTransformCallbacks(transformed);
|
postTransformCallbacks(transformed);
|
||||||
|
|
||||||
|
transformed.customShare = `${topicUrl}/${post.post_number}`;
|
||||||
|
transformed.asPost = store.createRecord("post", post);
|
||||||
|
|
||||||
return transformed;
|
return transformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,12 +166,10 @@ createWidget("reply-to-tab", {
|
||||||
tabindex: "0",
|
tabindex: "0",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.attrs.mobileView) {
|
if (!attrs.mobileView) {
|
||||||
result["role"] = "button";
|
result["role"] = "button";
|
||||||
result["aria-controls"] = `embedded-posts__top--${attrs.post_number}`;
|
result["aria-controls"] = `embedded-posts__top--${attrs.post_number}`;
|
||||||
result["aria-expanded"] = this.attrs.repliesAbove.length
|
result["aria-expanded"] = (attrs.repliesAbove.length > 0).toString();
|
||||||
? "true"
|
|
||||||
: "false";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -526,7 +528,7 @@ createWidget("post-contents", {
|
||||||
|
|
||||||
const extraState = {
|
const extraState = {
|
||||||
state: {
|
state: {
|
||||||
repliesShown: !!state.repliesBelow.length,
|
repliesShown: state.repliesBelow.length > 0,
|
||||||
filteredRepliesShown: state.filteredRepliesShown,
|
filteredRepliesShown: state.filteredRepliesShown,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -534,12 +536,11 @@ createWidget("post-contents", {
|
||||||
|
|
||||||
const repliesBelow = state.repliesBelow;
|
const repliesBelow = state.repliesBelow;
|
||||||
if (repliesBelow.length) {
|
if (repliesBelow.length) {
|
||||||
result.push(
|
let children = [];
|
||||||
h(
|
|
||||||
`section.embedded-posts.bottom#embedded-posts__bottom--${this.attrs.post_number}`,
|
repliesBelow.forEach((p) => {
|
||||||
[
|
children.push(
|
||||||
repliesBelow.map((p) => {
|
this.attach("embedded-post", p, {
|
||||||
return this.attach("embedded-post", p, {
|
|
||||||
model: p.asPost,
|
model: p.asPost,
|
||||||
state: {
|
state: {
|
||||||
role: "region",
|
role: "region",
|
||||||
|
@ -548,17 +549,36 @@ createWidget("post-contents", {
|
||||||
username: p.username,
|
username: p.username,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}),
|
|
||||||
|
children.push(
|
||||||
this.attach("button", {
|
this.attach("button", {
|
||||||
title: "post.collapse",
|
title: "post.collapse",
|
||||||
icon: "chevron-up",
|
icon: "chevron-up",
|
||||||
action: "toggleRepliesBelow",
|
action: "toggleRepliesBelow",
|
||||||
actionParam: "true",
|
actionParam: true,
|
||||||
className: "btn collapse-up",
|
className: "btn collapse-up",
|
||||||
translatedAriaLabel: I18n.t("post.sr_collapse_replies"),
|
translatedAriaLabel: I18n.t("post.sr_collapse_replies"),
|
||||||
}),
|
})
|
||||||
]
|
);
|
||||||
|
|
||||||
|
if (repliesBelow.length < this.attrs.replyCount) {
|
||||||
|
children.push(
|
||||||
|
this.attach("button", {
|
||||||
|
label: "post.load_more_replies",
|
||||||
|
action: "loadMoreReplies",
|
||||||
|
actionParam: repliesBelow[repliesBelow.length - 1]?.post_number,
|
||||||
|
className: "btn load-more-replies",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
h(
|
||||||
|
`section.embedded-posts.bottom#embedded-posts__bottom--${this.attrs.post_number}`,
|
||||||
|
children
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -599,30 +619,28 @@ createWidget("post-contents", {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleRepliesBelow(goToPost = "false") {
|
loadMoreReplies(after = 1) {
|
||||||
|
return this.store
|
||||||
|
.find("post-reply", { postId: this.attrs.id, after })
|
||||||
|
.then((replies) => {
|
||||||
|
replies.forEach((reply) => {
|
||||||
|
this.state.repliesBelow.push(
|
||||||
|
transformWithCallbacks(reply, this.attrs.topicUrl, this.store)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleRepliesBelow(goToPost = false) {
|
||||||
if (this.state.repliesBelow.length) {
|
if (this.state.repliesBelow.length) {
|
||||||
this.state.repliesBelow = [];
|
this.state.repliesBelow = [];
|
||||||
if (goToPost === "true") {
|
if (goToPost === true) {
|
||||||
DiscourseURL.routeTo(
|
const { topicUrl, post_number } = this.attrs;
|
||||||
`${this.attrs.topicUrl}/${this.attrs.post_number}`
|
DiscourseURL.routeTo(`${topicUrl}/${post_number}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
} else {
|
||||||
|
return this.loadMoreReplies();
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = this.findAncestorModel();
|
|
||||||
const topicUrl = post ? post.get("topic.url") : null;
|
|
||||||
return this.store
|
|
||||||
.find("post-reply", { postId: this.attrs.id })
|
|
||||||
.then((posts) => {
|
|
||||||
this.state.repliesBelow = posts.map((p) => {
|
|
||||||
let result = transformWithCallbacks(p);
|
|
||||||
|
|
||||||
result.customShare = `${topicUrl}/${p.post_number}`;
|
|
||||||
result.asPost = this.store.createRecord("post", p);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
expandFirstPost() {
|
expandFirstPost() {
|
||||||
|
@ -799,7 +817,7 @@ createWidget("post-article", {
|
||||||
title: "post.collapse",
|
title: "post.collapse",
|
||||||
icon: "chevron-down",
|
icon: "chevron-down",
|
||||||
action: "toggleReplyAbove",
|
action: "toggleReplyAbove",
|
||||||
actionParam: "true",
|
actionParam: true,
|
||||||
className: "btn collapse-down",
|
className: "btn collapse-down",
|
||||||
}),
|
}),
|
||||||
replies,
|
replies,
|
||||||
|
@ -835,7 +853,7 @@ createWidget("post-article", {
|
||||||
return post ? post.get("topic.url") : null;
|
return post ? post.get("topic.url") : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleReplyAbove(goToPost = "false") {
|
toggleReplyAbove(goToPost = false) {
|
||||||
const replyPostNumber = this.attrs.reply_to_post_number;
|
const replyPostNumber = this.attrs.reply_to_post_number;
|
||||||
|
|
||||||
if (this.siteSettings.enable_filtered_replies_view) {
|
if (this.siteSettings.enable_filtered_replies_view) {
|
||||||
|
@ -860,10 +878,9 @@ createWidget("post-article", {
|
||||||
|
|
||||||
if (this.state.repliesAbove.length) {
|
if (this.state.repliesAbove.length) {
|
||||||
this.state.repliesAbove = [];
|
this.state.repliesAbove = [];
|
||||||
if (goToPost === "true") {
|
if (goToPost === true) {
|
||||||
DiscourseURL.routeTo(
|
const { topicUrl, post_number } = this.attrs;
|
||||||
`${this.attrs.topicUrl}/${this.attrs.post_number}`
|
DiscourseURL.routeTo(`${topicUrl}/${post_number}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} else {
|
} else {
|
||||||
|
@ -871,12 +888,10 @@ createWidget("post-article", {
|
||||||
return this.store
|
return this.store
|
||||||
.find("post-reply-history", { postId: this.attrs.id })
|
.find("post-reply-history", { postId: this.attrs.id })
|
||||||
.then((posts) => {
|
.then((posts) => {
|
||||||
this.state.repliesAbove = posts.map((p) => {
|
posts.forEach((post) => {
|
||||||
let result = transformWithCallbacks(p);
|
this.state.repliesAbove.push(
|
||||||
|
transformWithCallbacks(post, topicUrl, this.store)
|
||||||
result.customShare = `${topicUrl}/${p.post_number}`;
|
);
|
||||||
result.asPost = this.store.createRecord("post", p);
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -205,7 +205,6 @@ pre.codeblock-buttons:hover {
|
||||||
border: none;
|
border: none;
|
||||||
> div {
|
> div {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 1.5em;
|
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
.row {
|
.row {
|
||||||
|
@ -296,6 +295,13 @@ pre.codeblock-buttons:hover {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.load-more-replies {
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
position: absolute;
|
||||||
|
left: 55%;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
padding: 0.35em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
.topic-avatar {
|
.topic-avatar {
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
|
|
|
@ -217,6 +217,13 @@ nav.post-controls button.reply .d-icon {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.load-more-replies {
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 150%);
|
||||||
|
padding: 0.35em 0.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-actions {
|
.post-actions {
|
||||||
|
|
|
@ -310,13 +310,16 @@ class PostsController < ApplicationController
|
||||||
def reply_history
|
def reply_history
|
||||||
post = find_post_from_params
|
post = find_post_from_params
|
||||||
|
|
||||||
reply_history = post.reply_history(params[:max_replies].to_i, guardian)
|
topic_view =
|
||||||
user_custom_fields = {}
|
TopicView.new(
|
||||||
if (added_fields = User.allowed_user_custom_fields(guardian)).present?
|
post.topic,
|
||||||
user_custom_fields = User.custom_fields_for_ids(reply_history.pluck(:user_id), added_fields)
|
current_user,
|
||||||
end
|
include_suggested: false,
|
||||||
|
include_related: false,
|
||||||
|
reply_history_for: post.id,
|
||||||
|
)
|
||||||
|
|
||||||
render_serialized(reply_history, PostSerializer, user_custom_fields: user_custom_fields)
|
render_json_dump(TopicViewPostsSerializer.new(topic_view, scope: guardian).post_stream[:posts])
|
||||||
end
|
end
|
||||||
|
|
||||||
def reply_ids
|
def reply_ids
|
||||||
|
@ -426,17 +429,38 @@ class PostsController < ApplicationController
|
||||||
render_json_error(e.message)
|
render_json_error(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Direct replies to this post
|
MAX_POST_REPLIES ||= 20
|
||||||
|
|
||||||
def replies
|
def replies
|
||||||
|
params.permit(:after)
|
||||||
|
|
||||||
|
after = [params[:after].to_i, 1].max
|
||||||
post = find_post_from_params
|
post = find_post_from_params
|
||||||
replies = post.replies.secured(guardian)
|
|
||||||
|
|
||||||
user_custom_fields = {}
|
post_ids =
|
||||||
if (added_fields = User.allowed_user_custom_fields(guardian)).present?
|
post
|
||||||
user_custom_fields = User.custom_fields_for_ids(replies.pluck(:user_id), added_fields)
|
.replies
|
||||||
|
.secured(guardian)
|
||||||
|
.where(post_number: after + 1..)
|
||||||
|
.limit(MAX_POST_REPLIES)
|
||||||
|
.pluck(:id)
|
||||||
|
|
||||||
|
if post_ids.blank?
|
||||||
|
render_json_dump []
|
||||||
|
else
|
||||||
|
topic_view =
|
||||||
|
TopicView.new(
|
||||||
|
post.topic,
|
||||||
|
current_user,
|
||||||
|
post_ids:,
|
||||||
|
include_related: false,
|
||||||
|
include_suggested: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
render_json_dump(
|
||||||
|
TopicViewPostsSerializer.new(topic_view, scope: guardian).post_stream[:posts],
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
render_serialized(replies, PostSerializer, user_custom_fields: user_custom_fields)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def revisions
|
def revisions
|
||||||
|
@ -995,20 +1019,15 @@ class PostsController < ApplicationController
|
||||||
# A deleted post can be seen by staff or a category group moderator for the topic.
|
# A deleted post can be seen by staff or a category group moderator for the topic.
|
||||||
# But we must find the deleted post to determine which category it belongs to, so
|
# But we must find the deleted post to determine which category it belongs to, so
|
||||||
# we must find.with_deleted
|
# we must find.with_deleted
|
||||||
post = finder.with_deleted.first
|
raise Discourse::NotFound unless post = finder.with_deleted.first
|
||||||
raise Discourse::NotFound unless post
|
raise Discourse::NotFound unless post.topic ||= Topic.with_deleted.find_by(id: post.topic_id)
|
||||||
|
|
||||||
post.topic = Topic.with_deleted.find_by(id: post.topic_id)
|
if post.deleted_at.present? || post.topic.deleted_at.present?
|
||||||
|
raise Discourse::NotFound unless guardian.can_moderate_topic?(post.topic)
|
||||||
if !post.topic ||
|
|
||||||
(
|
|
||||||
(post.deleted_at.present? || post.topic.deleted_at.present?) &&
|
|
||||||
!guardian.can_moderate_topic?(post.topic)
|
|
||||||
)
|
|
||||||
raise Discourse::NotFound
|
|
||||||
end
|
end
|
||||||
|
|
||||||
guardian.ensure_can_see!(post)
|
guardian.ensure_can_see!(post)
|
||||||
|
|
||||||
post
|
post
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -975,27 +975,6 @@ class Post < ActiveRecord::Base
|
||||||
.count
|
.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def reply_history(max_replies = 100, guardian = nil)
|
|
||||||
post_ids = DB.query_single(<<~SQL, post_id: id, topic_id: topic_id)
|
|
||||||
WITH RECURSIVE breadcrumb(id, reply_to_post_number) AS (
|
|
||||||
SELECT p.id, p.reply_to_post_number FROM posts AS p
|
|
||||||
WHERE p.id = :post_id
|
|
||||||
UNION
|
|
||||||
SELECT p.id, p.reply_to_post_number FROM posts AS p, breadcrumb
|
|
||||||
WHERE breadcrumb.reply_to_post_number = p.post_number
|
|
||||||
AND p.topic_id = :topic_id
|
|
||||||
)
|
|
||||||
SELECT id from breadcrumb
|
|
||||||
WHERE id <> :post_id
|
|
||||||
ORDER by id
|
|
||||||
SQL
|
|
||||||
|
|
||||||
# [1,2,3][-10,-1] => nil
|
|
||||||
post_ids = (post_ids[(0 - max_replies)..-1] || post_ids)
|
|
||||||
|
|
||||||
Post.secured(guardian).where(id: post_ids).includes(:user, :topic).order(:id).to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
MAX_REPLY_LEVEL ||= 1000
|
MAX_REPLY_LEVEL ||= 1000
|
||||||
|
|
||||||
def reply_ids(guardian = nil, only_replies_to_single_post: true)
|
def reply_ids(guardian = nil, only_replies_to_single_post: true)
|
||||||
|
|
|
@ -3728,7 +3728,9 @@ en:
|
||||||
show_hidden: "View ignored content."
|
show_hidden: "View ignored content."
|
||||||
deleted_by_author_simple: "(post deleted by author)"
|
deleted_by_author_simple: "(post deleted by author)"
|
||||||
collapse: "collapse"
|
collapse: "collapse"
|
||||||
|
load_more_replies: "load more replies"
|
||||||
sr_collapse_replies: "Collapse embedded replies"
|
sr_collapse_replies: "Collapse embedded replies"
|
||||||
|
sr_load_more_replies: "Load more embedded replies"
|
||||||
sr_date: "Post date"
|
sr_date: "Post date"
|
||||||
sr_expand_replies:
|
sr_expand_replies:
|
||||||
one: "This post has %{count} reply"
|
one: "This post has %{count} reply"
|
||||||
|
|
|
@ -875,6 +875,29 @@ class TopicView
|
||||||
Topic.with_deleted.includes(:category).find_by(id: topic_or_topic_id)
|
Topic.with_deleted.includes(:category).find_by(id: topic_or_topic_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def find_post_replies_ids(post_id)
|
||||||
|
DB.query_single(<<~SQL, post_id: post_id)
|
||||||
|
WITH RECURSIVE breadcrumb(id, reply_to_post_number, topic_id, level) AS (
|
||||||
|
SELECT id, reply_to_post_number, topic_id, 0
|
||||||
|
FROM posts
|
||||||
|
WHERE id = :post_id
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT p.id, p.reply_to_post_number, p.topic_id, b.level + 1
|
||||||
|
FROM posts AS p
|
||||||
|
, breadcrumb AS b
|
||||||
|
WHERE b.reply_to_post_number = p.post_number
|
||||||
|
AND b.topic_id = p.topic_id
|
||||||
|
AND b.level < #{SiteSetting.max_reply_history}
|
||||||
|
)
|
||||||
|
SELECT id
|
||||||
|
FROM breadcrumb
|
||||||
|
WHERE id <> :post_id
|
||||||
|
ORDER BY id
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
def unfiltered_posts
|
def unfiltered_posts
|
||||||
result = filter_post_types(@topic.posts)
|
result = filter_post_types(@topic.posts)
|
||||||
result = result.with_deleted if @guardian.can_see_deleted_posts?(@topic.category)
|
result = result.with_deleted if @guardian.can_see_deleted_posts?(@topic.category)
|
||||||
|
@ -974,33 +997,23 @@ class TopicView
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Reply history
|
||||||
|
if @reply_history_for.present?
|
||||||
|
post_ids = find_post_replies_ids(@reply_history_for)
|
||||||
|
|
||||||
|
@filtered_posts = @filtered_posts.where("posts.id IN (:post_ids)", post_ids:)
|
||||||
|
@contains_gaps = true
|
||||||
|
end
|
||||||
|
|
||||||
# Filtering upwards
|
# Filtering upwards
|
||||||
if @filter_upwards_post_id.present?
|
if @filter_upwards_post_id.present?
|
||||||
post = Post.find(@filter_upwards_post_id)
|
post_ids = find_post_replies_ids(@filter_upwards_post_id) | [@filter_upwards_post_id.to_i]
|
||||||
post_ids = DB.query_single(<<~SQL, post_id: post.id, topic_id: post.topic_id)
|
|
||||||
WITH RECURSIVE breadcrumb(id, reply_to_post_number) AS (
|
|
||||||
SELECT p.id, p.reply_to_post_number FROM posts AS p
|
|
||||||
WHERE p.id = :post_id
|
|
||||||
UNION
|
|
||||||
SELECT p.id, p.reply_to_post_number FROM posts AS p, breadcrumb
|
|
||||||
WHERE breadcrumb.reply_to_post_number = p.post_number
|
|
||||||
AND p.topic_id = :topic_id
|
|
||||||
)
|
|
||||||
SELECT id from breadcrumb
|
|
||||||
WHERE id <> :post_id
|
|
||||||
ORDER by id
|
|
||||||
SQL
|
|
||||||
|
|
||||||
post_ids = (post_ids[(0 - SiteSetting.max_reply_history)..-1] || post_ids)
|
|
||||||
post_ids.push(post.id)
|
|
||||||
|
|
||||||
@filtered_posts =
|
@filtered_posts =
|
||||||
@filtered_posts.where(
|
@filtered_posts.where(
|
||||||
"
|
"posts.post_number = 1 OR posts.id IN (:post_ids) OR posts.id > :max_post_id",
|
||||||
posts.post_number = 1
|
post_ids:,
|
||||||
OR posts.id IN (:post_ids)
|
max_post_id: post_ids.max,
|
||||||
OR posts.id > :max_post_id",
|
|
||||||
{ post_ids: post_ids, max_post_id: post_ids.max },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@contains_gaps = true
|
@contains_gaps = true
|
||||||
|
|
|
@ -1027,20 +1027,6 @@ RSpec.describe Post do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "reply_history" do
|
|
||||||
let!(:p1) { Fabricate(:post, post_args) }
|
|
||||||
let!(:p2) { Fabricate(:post, post_args.merge(reply_to_post_number: p1.post_number)) }
|
|
||||||
let!(:p3) { Fabricate(:post, post_args) }
|
|
||||||
let!(:p4) { Fabricate(:post, post_args.merge(reply_to_post_number: p2.post_number)) }
|
|
||||||
|
|
||||||
it "returns the posts in reply to this post" do
|
|
||||||
expect(p4.reply_history).to eq([p1, p2])
|
|
||||||
expect(p4.reply_history(1)).to eq([p2])
|
|
||||||
expect(p3.reply_history).to be_blank
|
|
||||||
expect(p2.reply_history).to eq([p1])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "reply_ids" do
|
describe "reply_ids" do
|
||||||
fab!(:topic)
|
fab!(:topic)
|
||||||
let!(:p1) { Fabricate(:post, topic: topic, post_number: 1) }
|
let!(:p1) { Fabricate(:post, topic: topic, post_number: 1) }
|
||||||
|
|
|
@ -208,6 +208,31 @@ RSpec.describe PostsController do
|
||||||
expect(json[0]["user_custom_fields"]["hello"]).to eq("world")
|
expect(json[0]["user_custom_fields"]["hello"]).to eq("world")
|
||||||
expect(json[0]["user_custom_fields"]["hidden"]).to be_blank
|
expect(json[0]["user_custom_fields"]["hidden"]).to be_blank
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "supports pagination" do
|
||||||
|
parent = Fabricate(:post)
|
||||||
|
30.times do
|
||||||
|
reply = Fabricate(:post, topic: parent.topic, reply_to_post_number: parent.post_number)
|
||||||
|
PostReply.create!(post: parent, reply:)
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/posts/#{parent.id}/replies.json", params: { after: parent.post_number }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
replies = response.parsed_body
|
||||||
|
expect(replies.size).to eq(20)
|
||||||
|
|
||||||
|
after = replies.last["post_number"]
|
||||||
|
|
||||||
|
get "/posts/#{parent.id}/replies.json", params: { after: }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
replies = response.parsed_body
|
||||||
|
expect(replies.size).to eq(10)
|
||||||
|
expect(replies[0][:post_number]).to eq(after + 1)
|
||||||
|
|
||||||
|
get "/posts/#{parent.id}/replies.json", params: { after: 999_999 }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body.size).to eq(0)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#destroy" do
|
describe "#destroy" do
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PageObjects
|
||||||
|
module Components
|
||||||
|
class Post < PageObjects::Components::Base
|
||||||
|
def initialize(post_number)
|
||||||
|
@post_number = post_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_replies
|
||||||
|
find("#post_#{@post_number} .show-replies").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_more_replies
|
||||||
|
find("#embedded-posts__bottom--#{@post_number} .load-more-replies").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_replies?(count: nil)
|
||||||
|
find("#embedded-posts__bottom--#{@post_number}").has_css?(".reply", count:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_more_replies?
|
||||||
|
find("#embedded-posts__bottom--#{@post_number}").has_css?(".load-more-replies")
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_loaded_all_replies?
|
||||||
|
find("#embedded-posts__bottom--#{@post_number}").has_no_css?(".load-more-replies")
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_parent_posts
|
||||||
|
find("#post_#{@post_number} .reply-to-tab").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_parent_posts?(count: nil)
|
||||||
|
find("#embedded-posts__top--#{@post_number}").has_css?(".reply", count:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_parent_post_content?(content)
|
||||||
|
find("#embedded-posts__top--#{@post_number}").has_no_content?(content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,60 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe "Post replies", type: :system do
|
||||||
|
fab!(:user)
|
||||||
|
fab!(:topic)
|
||||||
|
fab!(:post) { Fabricate(:post, user:, topic:) }
|
||||||
|
|
||||||
|
let(:first_post) { PageObjects::Components::Post.new(1) }
|
||||||
|
let(:third_chained_reply) { PageObjects::Components::Post.new(7) }
|
||||||
|
|
||||||
|
context "when loading post replies" do
|
||||||
|
before do
|
||||||
|
25.times do
|
||||||
|
reply = Fabricate(:post, topic:, reply_to_post_number: 1)
|
||||||
|
PostReply.create!(post:, reply:)
|
||||||
|
end
|
||||||
|
|
||||||
|
post.update!(reply_count: post.replies.count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "supports pagination" do
|
||||||
|
sign_in(user)
|
||||||
|
visit(topic.url)
|
||||||
|
|
||||||
|
first_post.show_replies
|
||||||
|
|
||||||
|
expect(first_post).to have_replies(count: 20)
|
||||||
|
expect(first_post).to have_more_replies
|
||||||
|
|
||||||
|
first_post.load_more_replies
|
||||||
|
|
||||||
|
expect(first_post).to have_replies(count: post.replies.count)
|
||||||
|
expect(first_post).to have_loaded_all_replies
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when loading parent posts" do
|
||||||
|
before do
|
||||||
|
SiteSetting.max_reply_history = 2
|
||||||
|
|
||||||
|
3.times do |i|
|
||||||
|
PostReply.create!(post:, reply: Fabricate(:post, topic:))
|
||||||
|
|
||||||
|
reply = Fabricate(:post, topic:, reply_to_post_number: (i * 2) + 1, raw: "reply #{i + 1}")
|
||||||
|
PostReply.create!(post:, reply:)
|
||||||
|
end
|
||||||
|
|
||||||
|
post.update!(reply_count: post.replies.count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not duplicate replies" do
|
||||||
|
sign_in(user)
|
||||||
|
visit(topic.url)
|
||||||
|
|
||||||
|
third_chained_reply.show_parent_posts
|
||||||
|
expect(third_chained_reply).to have_parent_posts(count: 2)
|
||||||
|
expect(third_chained_reply).to have_no_parent_post_content("reply 3")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue