FEATURE: Allow admins to permanently delete posts and topics (#14406)
Sometimes administrators want to permanently delete posts and topics from the database. To make sure that this is done for a good reasons, administrators can do this only after one minute has passed since the post was deleted or immediately if another administrator does it.
This commit is contained in:
parent
76c9de2d04
commit
c4843fc1c1
|
@ -595,9 +595,9 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||||
post.get("post_number") === 1 ? this.recoverTopic() : post.recover();
|
post.get("post_number") === 1 ? this.recoverTopic() : post.recover();
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePost(post) {
|
deletePost(post, opts) {
|
||||||
if (post.get("post_number") === 1) {
|
if (post.get("post_number") === 1) {
|
||||||
return this.deleteTopic();
|
return this.deleteTopic(opts);
|
||||||
} else if (!post.can_delete) {
|
} else if (!post.can_delete) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -611,7 +611,7 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||||
ajax(`/posts/${post.id}/reply-ids.json`).then((replies) => {
|
ajax(`/posts/${post.id}/reply-ids.json`).then((replies) => {
|
||||||
if (replies.length === 0) {
|
if (replies.length === 0) {
|
||||||
return post
|
return post
|
||||||
.destroy(user)
|
.destroy(user, opts)
|
||||||
.then(refresh)
|
.then(refresh)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
popupAjaxError(error);
|
popupAjaxError(error);
|
||||||
|
@ -630,7 +630,7 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||||
label: I18n.t("post.controls.delete_replies.just_the_post"),
|
label: I18n.t("post.controls.delete_replies.just_the_post"),
|
||||||
callback() {
|
callback() {
|
||||||
post
|
post
|
||||||
.destroy(user)
|
.destroy(user, opts)
|
||||||
.then(refresh)
|
.then(refresh)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
popupAjaxError(error);
|
popupAjaxError(error);
|
||||||
|
@ -685,7 +685,7 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return post
|
return post
|
||||||
.destroy(user)
|
.destroy(user, opts)
|
||||||
.then(refresh)
|
.then(refresh)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
popupAjaxError(error);
|
popupAjaxError(error);
|
||||||
|
@ -694,6 +694,19 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
permanentlyDeletePost(post) {
|
||||||
|
return bootbox.confirm(
|
||||||
|
I18n.t("post.controls.permanently_delete_confirmation"),
|
||||||
|
I18n.t("no_value"),
|
||||||
|
I18n.t("yes_value"),
|
||||||
|
(result) => {
|
||||||
|
if (result) {
|
||||||
|
this.send("deletePost", post, { force_destroy: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
editPost(post) {
|
editPost(post) {
|
||||||
if (!this.currentUser) {
|
if (!this.currentUser) {
|
||||||
return bootbox.alert(I18n.t("post.controls.edit_anonymous"));
|
return bootbox.alert(I18n.t("post.controls.edit_anonymous"));
|
||||||
|
@ -1497,13 +1510,13 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||||
this.model.recover();
|
this.model.recover();
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTopic() {
|
deleteTopic(opts) {
|
||||||
if (
|
if (
|
||||||
this.model.views > this.siteSettings.min_topic_views_for_delete_confirm
|
this.model.views > this.siteSettings.min_topic_views_for_delete_confirm
|
||||||
) {
|
) {
|
||||||
this.deleteTopicModal();
|
this.deleteTopicModal();
|
||||||
} else {
|
} else {
|
||||||
this.model.destroy(this.currentUser);
|
this.model.destroy(this.currentUser, opts);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ export function transformBasicPost(post) {
|
||||||
created_at: post.created_at,
|
created_at: post.created_at,
|
||||||
updated_at: post.updated_at,
|
updated_at: post.updated_at,
|
||||||
canDelete: post.can_delete,
|
canDelete: post.can_delete,
|
||||||
|
canPermanentlyDelete: post.can_permanently_delete,
|
||||||
showFlagDelete: false,
|
showFlagDelete: false,
|
||||||
canRecover: post.can_recover,
|
canRecover: post.can_recover,
|
||||||
canEdit: post.can_edit,
|
canEdit: post.can_edit,
|
||||||
|
@ -261,6 +262,7 @@ export default function transformPost(
|
||||||
postAtts.canRecoverTopic = postAtts.isDeleted && details.can_recover;
|
postAtts.canRecoverTopic = postAtts.isDeleted && details.can_recover;
|
||||||
postAtts.canDeleteTopic = !postAtts.isDeleted && details.can_delete;
|
postAtts.canDeleteTopic = !postAtts.isDeleted && details.can_delete;
|
||||||
postAtts.expandablePost = topic.expandable_first_post;
|
postAtts.expandablePost = topic.expandable_first_post;
|
||||||
|
postAtts.canPermanentlyDeleteTopic = details.can_permanently_delete;
|
||||||
|
|
||||||
// Show a "Flag to delete" message if not staff and you can't
|
// Show a "Flag to delete" message if not staff and you can't
|
||||||
// otherwise delete it.
|
// otherwise delete it.
|
||||||
|
|
|
@ -249,10 +249,10 @@ const Post = RestModel.extend({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy(deletedBy) {
|
destroy(deletedBy, opts) {
|
||||||
return this.setDeletedState(deletedBy).then(() => {
|
return this.setDeletedState(deletedBy).then(() => {
|
||||||
return ajax("/posts/" + this.id, {
|
return ajax("/posts/" + this.id, {
|
||||||
data: { context: window.location.pathname },
|
data: { context: window.location.pathname, ...opts },
|
||||||
type: "DELETE",
|
type: "DELETE",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -433,9 +433,9 @@ const Topic = RestModel.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete this topic
|
// Delete this topic
|
||||||
destroy(deleted_by) {
|
destroy(deleted_by, opts) {
|
||||||
return ajax(`/t/${this.id}`, {
|
return ajax(`/t/${this.id}`, {
|
||||||
data: { context: window.location.pathname },
|
data: { context: window.location.pathname, ...opts },
|
||||||
type: "DELETE",
|
type: "DELETE",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
@ -217,6 +217,7 @@
|
||||||
showLogin=(route-action "showLogin")
|
showLogin=(route-action "showLogin")
|
||||||
showRawEmail=(route-action "showRawEmail")
|
showRawEmail=(route-action "showRawEmail")
|
||||||
deletePost=(action "deletePost")
|
deletePost=(action "deletePost")
|
||||||
|
permanentlyDeletePost=(action "permanentlyDeletePost")
|
||||||
recoverPost=(action "recoverPost")
|
recoverPost=(action "recoverPost")
|
||||||
expandHidden=(action "expandHidden")
|
expandHidden=(action "expandHidden")
|
||||||
toggleBookmark=(action "toggleBookmark")
|
toggleBookmark=(action "toggleBookmark")
|
||||||
|
|
|
@ -37,6 +37,15 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attrs.canPermanentlyDelete || attrs.canPermanentlyDeleteTopic) {
|
||||||
|
contents.push({
|
||||||
|
icon: "trash-alt",
|
||||||
|
className: "popup-menu-button permanently-delete",
|
||||||
|
label: "post.controls.permanently_delete",
|
||||||
|
action: "permanentlyDeletePost",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!attrs.isWhisper && currentUser.staff) {
|
if (!attrs.isWhisper && currentUser.staff) {
|
||||||
const buttonAtts = {
|
const buttonAtts = {
|
||||||
action: "togglePostType",
|
action: "togglePostType",
|
||||||
|
|
|
@ -649,6 +649,36 @@ discourseModule("Integration | Component | Widget | post", function (hooks) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
componentTest("permanently delete topic", {
|
||||||
|
template: hbs`{{mount-widget widget="post" args=args permanentlyDeletePost=permanentlyDeletePost}}`,
|
||||||
|
beforeEach() {
|
||||||
|
this.set("args", { canManage: true, canPermanentlyDeleteTopic: true });
|
||||||
|
this.set("permanentlyDeletePost", () => (this.deleted = true));
|
||||||
|
},
|
||||||
|
async test(assert) {
|
||||||
|
await click(".post-menu-area .show-post-admin-menu");
|
||||||
|
await click(".post-admin-menu .permanently-delete");
|
||||||
|
assert.ok(this.deleted);
|
||||||
|
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
componentTest("permanently delete post", {
|
||||||
|
template: hbs`
|
||||||
|
{{mount-widget widget="post" args=args permanentlyDeletePost=permanentlyDeletePost}}
|
||||||
|
`,
|
||||||
|
beforeEach() {
|
||||||
|
this.set("args", { canManage: true, canPermanentlyDelete: true });
|
||||||
|
this.set("permanentlyDeletePost", () => (this.deleted = true));
|
||||||
|
},
|
||||||
|
async test(assert) {
|
||||||
|
await click(".post-menu-area .show-post-admin-menu");
|
||||||
|
await click(".post-admin-menu .permanently-delete");
|
||||||
|
assert.ok(this.deleted);
|
||||||
|
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
componentTest("toggle moderator post", {
|
componentTest("toggle moderator post", {
|
||||||
template: hbs`
|
template: hbs`
|
||||||
{{mount-widget widget="post" args=args togglePostType=togglePostType}}
|
{{mount-widget widget="post" args=args togglePostType=togglePostType}}
|
||||||
|
|
|
@ -303,14 +303,24 @@ class PostsController < ApplicationController
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
post = find_post_from_params
|
post = find_post_from_params
|
||||||
guardian.ensure_can_delete!(post)
|
|
||||||
|
force_destroy = false
|
||||||
|
if params[:force_destroy].present?
|
||||||
|
if !guardian.can_permanently_delete?(post)
|
||||||
|
return render_json_error post.cannot_permanently_delete_reason(current_user), status: 403
|
||||||
|
end
|
||||||
|
|
||||||
|
force_destroy = true
|
||||||
|
else
|
||||||
|
guardian.ensure_can_delete!(post)
|
||||||
|
end
|
||||||
|
|
||||||
unless guardian.can_moderate_topic?(post.topic)
|
unless guardian.can_moderate_topic?(post.topic)
|
||||||
RateLimiter.new(current_user, "delete_post_per_min", SiteSetting.max_post_deletions_per_minute, 1.minute).performed!
|
RateLimiter.new(current_user, "delete_post_per_min", SiteSetting.max_post_deletions_per_minute, 1.minute).performed!
|
||||||
RateLimiter.new(current_user, "delete_post_per_day", SiteSetting.max_post_deletions_per_day, 1.day).performed!
|
RateLimiter.new(current_user, "delete_post_per_day", SiteSetting.max_post_deletions_per_day, 1.day).performed!
|
||||||
end
|
end
|
||||||
|
|
||||||
destroyer = PostDestroyer.new(current_user, post, context: params[:context])
|
destroyer = PostDestroyer.new(current_user, post, context: params[:context], force_destroy: force_destroy)
|
||||||
destroyer.destroy
|
destroyer.destroy
|
||||||
|
|
||||||
render body: nil
|
render body: nil
|
||||||
|
|
|
@ -606,11 +606,21 @@ class TopicsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
topic = Topic.find_by(id: params[:id])
|
topic = Topic.with_deleted.find_by(id: params[:id])
|
||||||
guardian.ensure_can_delete!(topic)
|
|
||||||
|
|
||||||
first_post = topic.ordered_posts.first
|
force_destroy = false
|
||||||
PostDestroyer.new(current_user, first_post, context: params[:context]).destroy
|
if params[:force_destroy].present?
|
||||||
|
if !guardian.can_permanently_delete?(topic)
|
||||||
|
return render_json_error topic.cannot_permanently_delete_reason(current_user), status: 403
|
||||||
|
end
|
||||||
|
|
||||||
|
force_destroy = true
|
||||||
|
else
|
||||||
|
guardian.ensure_can_delete!(topic)
|
||||||
|
end
|
||||||
|
|
||||||
|
first_post = topic.posts.with_deleted.order(:post_number).first
|
||||||
|
PostDestroyer.new(current_user, first_post, context: params[:context], force_destroy: force_destroy).destroy
|
||||||
|
|
||||||
render body: nil
|
render body: nil
|
||||||
rescue Discourse::InvalidAccess
|
rescue Discourse::InvalidAccess
|
||||||
|
|
|
@ -920,6 +920,21 @@ class Category < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cannot_delete_reason
|
||||||
|
return I18n.t('category.cannot_delete.uncategorized') if self.uncategorized?
|
||||||
|
return I18n.t('category.cannot_delete.has_subcategories') if self.has_children?
|
||||||
|
|
||||||
|
if self.topic_count != 0
|
||||||
|
oldest_topic = self.topics.where.not(id: self.topic_id).order('created_at ASC').limit(1).first
|
||||||
|
if oldest_topic
|
||||||
|
I18n.t('category.cannot_delete.topic_exists', count: self.topic_count, topic_link: "<a href=\"#{oldest_topic.url}\">#{CGI.escapeHTML(oldest_topic.title)}</a>")
|
||||||
|
else
|
||||||
|
# This is a weird case, probably indicating a bug.
|
||||||
|
I18n.t('category.cannot_delete.topic_exists_no_oldest', count: self.topic_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def should_update_reviewables?
|
def should_update_reviewables?
|
||||||
|
|
|
@ -25,6 +25,9 @@ class Post < ActiveRecord::Base
|
||||||
# Version 2 15-12-2017, introduces CommonMark and a huge number of onebox fixes
|
# Version 2 15-12-2017, introduces CommonMark and a huge number of onebox fixes
|
||||||
BAKED_VERSION = 2
|
BAKED_VERSION = 2
|
||||||
|
|
||||||
|
# Time between the delete and permanent delete of a post
|
||||||
|
PERMANENT_DELETE_TIMER = 5.minutes
|
||||||
|
|
||||||
rate_limit
|
rate_limit
|
||||||
rate_limit :limit_posts_per_day
|
rate_limit :limit_posts_per_day
|
||||||
|
|
||||||
|
@ -1090,6 +1093,13 @@ class Post < ActiveRecord::Base
|
||||||
UrlHelper.cook_url(raw_url, secure: image_upload&.secure?, local: true) if raw_url
|
UrlHelper.cook_url(raw_url, secure: image_upload&.secure?, local: true) if raw_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cannot_permanently_delete_reason(user)
|
||||||
|
if self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago
|
||||||
|
time_left = RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i)
|
||||||
|
I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: time_left)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def parse_quote_into_arguments(quote)
|
def parse_quote_into_arguments(quote)
|
||||||
|
|
|
@ -1787,6 +1787,15 @@ class Topic < ActiveRecord::Base
|
||||||
def apply_per_day_rate_limit_for(key, method_name)
|
def apply_per_day_rate_limit_for(key, method_name)
|
||||||
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
|
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cannot_permanently_delete_reason(user)
|
||||||
|
if self.posts_count > 1
|
||||||
|
I18n.t('post.cannot_permanently_delete.many_posts')
|
||||||
|
elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago
|
||||||
|
time_left = RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i)
|
||||||
|
I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: time_left)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -71,10 +71,6 @@ class CategorySerializer < SiteCategorySerializer
|
||||||
scope && scope.can_delete?(object)
|
scope && scope.can_delete?(object)
|
||||||
end
|
end
|
||||||
|
|
||||||
def cannot_delete_reason
|
|
||||||
scope && scope.cannot_delete_category_reason(object)
|
|
||||||
end
|
|
||||||
|
|
||||||
def include_cannot_delete_reason?
|
def include_cannot_delete_reason?
|
||||||
!include_can_delete? && scope && scope.can_edit?(object)
|
!include_can_delete? && scope && scope.can_edit?(object)
|
||||||
end
|
end
|
||||||
|
|
|
@ -43,6 +43,7 @@ class PostSerializer < BasicPostSerializer
|
||||||
:version,
|
:version,
|
||||||
:can_edit,
|
:can_edit,
|
||||||
:can_delete,
|
:can_delete,
|
||||||
|
:can_permanently_delete,
|
||||||
:can_recover,
|
:can_recover,
|
||||||
:can_wiki,
|
:can_wiki,
|
||||||
:link_counts,
|
:link_counts,
|
||||||
|
@ -166,6 +167,14 @@ class PostSerializer < BasicPostSerializer
|
||||||
scope.can_delete?(object)
|
scope.can_delete?(object)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_permanently_delete
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_can_permanently_delete?
|
||||||
|
SiteSetting.can_permanently_delete && object.deleted_at
|
||||||
|
end
|
||||||
|
|
||||||
def can_recover
|
def can_recover
|
||||||
scope.can_recover_post?(object)
|
scope.can_recover_post?(object)
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,7 @@ class TopicViewDetailsSerializer < ApplicationSerializer
|
||||||
def self.can_attributes
|
def self.can_attributes
|
||||||
[:can_move_posts,
|
[:can_move_posts,
|
||||||
:can_delete,
|
:can_delete,
|
||||||
|
:can_permanently_delete,
|
||||||
:can_recover,
|
:can_recover,
|
||||||
:can_remove_allowed_users,
|
:can_remove_allowed_users,
|
||||||
:can_invite_to,
|
:can_invite_to,
|
||||||
|
@ -110,6 +111,10 @@ class TopicViewDetailsSerializer < ApplicationSerializer
|
||||||
scope.can_delete?(object.topic)
|
scope.can_delete?(object.topic)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_can_permanently_delete?
|
||||||
|
SiteSetting.can_permanently_delete && object.topic.deleted_at
|
||||||
|
end
|
||||||
|
|
||||||
def include_can_recover?
|
def include_can_recover?
|
||||||
scope.can_recover_topic?(object.topic)
|
scope.can_recover_topic?(object.topic)
|
||||||
end
|
end
|
||||||
|
|
|
@ -3125,6 +3125,8 @@ en:
|
||||||
other: "Yes, and all %{count} replies"
|
other: "Yes, and all %{count} replies"
|
||||||
just_the_post: "No, just this post"
|
just_the_post: "No, just this post"
|
||||||
admin: "post admin actions"
|
admin: "post admin actions"
|
||||||
|
permanently_delete: "Permanently Delete"
|
||||||
|
permanently_delete_confirmation: "Are you sure you permanently want to delete this post? You will not be able to recover it."
|
||||||
wiki: "Make Wiki"
|
wiki: "Make Wiki"
|
||||||
unwiki: "Remove Wiki"
|
unwiki: "Remove Wiki"
|
||||||
convert_to_moderator: "Add Staff Color"
|
convert_to_moderator: "Add Staff Color"
|
||||||
|
|
|
@ -721,6 +721,9 @@ en:
|
||||||
has_likes:
|
has_likes:
|
||||||
one: "%{count} Like"
|
one: "%{count} Like"
|
||||||
other: "%{count} Likes"
|
other: "%{count} Likes"
|
||||||
|
cannot_permanently_delete:
|
||||||
|
many_posts: "You can not permanently delete this topic because there are other posts."
|
||||||
|
wait_or_different_admin: "You must wait %{time_left} before permanently deleting this post or a different administrator must do it."
|
||||||
|
|
||||||
rate_limiter:
|
rate_limiter:
|
||||||
slow_down: "You’ve performed this action too many times, please try again later."
|
slow_down: "You’ve performed this action too many times, please try again later."
|
||||||
|
|
|
@ -1677,6 +1677,9 @@ security:
|
||||||
disable_onebox_media_download_controls:
|
disable_onebox_media_download_controls:
|
||||||
default: false
|
default: false
|
||||||
hidden: true
|
hidden: true
|
||||||
|
can_permanently_delete:
|
||||||
|
default: false
|
||||||
|
hidden: true
|
||||||
|
|
||||||
onebox:
|
onebox:
|
||||||
post_onebox_maxlength:
|
post_onebox_maxlength:
|
||||||
|
|
|
@ -172,6 +172,10 @@ class Guardian
|
||||||
can_do?(:delete, obj)
|
can_do?(:delete, obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_permanently_delete?(obj)
|
||||||
|
can_do?(:permanently_delete, obj)
|
||||||
|
end
|
||||||
|
|
||||||
def can_moderate?(obj)
|
def can_moderate?(obj)
|
||||||
obj && authenticated? && !is_silenced? && (
|
obj && authenticated? && !is_silenced? && (
|
||||||
is_staff? ||
|
is_staff? ||
|
||||||
|
|
|
@ -38,23 +38,6 @@ module CategoryGuardian
|
||||||
!category.has_children?
|
!category.has_children?
|
||||||
end
|
end
|
||||||
|
|
||||||
def cannot_delete_category_reason(category)
|
|
||||||
return I18n.t('category.cannot_delete.uncategorized') if category.uncategorized?
|
|
||||||
return I18n.t('category.cannot_delete.has_subcategories') if category.has_children?
|
|
||||||
|
|
||||||
if category.topic_count != 0
|
|
||||||
oldest_topic = category.topics.where.not(id: category.topic_id).order('created_at ASC').limit(1).first
|
|
||||||
if oldest_topic
|
|
||||||
return I18n.t('category.cannot_delete.topic_exists', count: category.topic_count, topic_link: "<a href=\"#{oldest_topic.url}\">#{CGI.escapeHTML(oldest_topic.title)}</a>")
|
|
||||||
else
|
|
||||||
# This is a weird case, probably indicating a bug.
|
|
||||||
return I18n.t('category.cannot_delete.topic_exists_no_oldest', count: category.topic_count)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def can_see_serialized_category?(category_id:, read_restricted: true)
|
def can_see_serialized_category?(category_id:, read_restricted: true)
|
||||||
# Guard to ensure only a boolean is passed in
|
# Guard to ensure only a boolean is passed in
|
||||||
read_restricted = true unless !!read_restricted == read_restricted
|
read_restricted = true unless !!read_restricted == read_restricted
|
||||||
|
|
|
@ -205,6 +205,16 @@ module PostGuardian
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_permanently_delete_post?(post)
|
||||||
|
return false if !SiteSetting.can_permanently_delete
|
||||||
|
return false if !post
|
||||||
|
return false if post.is_first_post?
|
||||||
|
return false if !is_admin? || !can_edit_post?(post)
|
||||||
|
return false if !post.deleted_at
|
||||||
|
return false if post.deleted_by_id == @user.id && post.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def can_recover_post?(post)
|
def can_recover_post?(post)
|
||||||
return false unless post
|
return false unless post
|
||||||
|
|
||||||
|
|
|
@ -153,6 +153,16 @@ module TopicGuardian
|
||||||
!Discourse.static_doc_topic_ids.include?(topic.id)
|
!Discourse.static_doc_topic_ids.include?(topic.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_permanently_delete_topic?(topic)
|
||||||
|
return false if !SiteSetting.can_permanently_delete
|
||||||
|
return false if !topic
|
||||||
|
return false if topic.posts_count > 1
|
||||||
|
return false if !is_admin? || !can_see_topic?(topic)
|
||||||
|
return false if !topic.deleted_at
|
||||||
|
return false if topic.deleted_by_id == @user.id && topic.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def can_toggle_topic_visibility?(topic)
|
def can_toggle_topic_visibility?(topic)
|
||||||
can_moderate?(topic) || can_perform_action_available_to_group_moderators?(topic)
|
can_moderate?(topic) || can_perform_action_available_to_group_moderators?(topic)
|
||||||
end
|
end
|
||||||
|
|
|
@ -55,7 +55,7 @@ class PostDestroyer
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
payload = WebHook.generate_payload(:post, @post) if WebHook.active_web_hooks(:post).exists?
|
payload = WebHook.generate_payload(:post, @post) if WebHook.active_web_hooks(:post).exists?
|
||||||
topic = @post.topic
|
topic = Topic.with_deleted.find_by(id: @post.topic_id)
|
||||||
is_first_post = @post.is_first_post? && topic
|
is_first_post = @post.is_first_post? && topic
|
||||||
has_topic_web_hooks = is_first_post && WebHook.active_web_hooks(:topic).exists?
|
has_topic_web_hooks = is_first_post && WebHook.active_web_hooks(:topic).exists?
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ class PostDestroyer
|
||||||
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic
|
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic
|
||||||
|
|
||||||
if is_first_post
|
if is_first_post
|
||||||
UserProfile.remove_featured_topic_from_all_profiles(@topic)
|
UserProfile.remove_featured_topic_from_all_profiles(topic)
|
||||||
UserActionManager.topic_destroyed(topic)
|
UserActionManager.topic_destroyed(topic)
|
||||||
DiscourseEvent.trigger(:topic_destroyed, topic, @user)
|
DiscourseEvent.trigger(:topic_destroyed, topic, @user)
|
||||||
WebHook.enqueue_topic_hooks(:topic_destroyed, topic, topic_payload) if has_topic_web_hooks
|
WebHook.enqueue_topic_hooks(:topic_destroyed, topic, topic_payload) if has_topic_web_hooks
|
||||||
|
|
|
@ -2,6 +2,18 @@
|
||||||
|
|
||||||
class RateLimiter
|
class RateLimiter
|
||||||
|
|
||||||
|
def self.time_left(available_in)
|
||||||
|
if available_in <= 3
|
||||||
|
I18n.t("rate_limiter.short_time")
|
||||||
|
elsif available_in < 1.minute.to_i
|
||||||
|
I18n.t("rate_limiter.seconds", count: available_in)
|
||||||
|
elsif available_in < 1.hour.to_i
|
||||||
|
I18n.t("rate_limiter.minutes", count: (available_in / 1.minute.to_i))
|
||||||
|
else
|
||||||
|
I18n.t("rate_limiter.hours", count: (available_in / 1.hour.to_i))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# A rate limit has been exceeded.
|
# A rate limit has been exceeded.
|
||||||
class LimitExceeded < StandardError
|
class LimitExceeded < StandardError
|
||||||
attr_reader :type, :available_in
|
attr_reader :type, :available_in
|
||||||
|
@ -12,16 +24,7 @@ class RateLimiter
|
||||||
end
|
end
|
||||||
|
|
||||||
def time_left
|
def time_left
|
||||||
@time_left ||=
|
@time_left ||= RateLimiter.time_left(@available_in)
|
||||||
if @available_in <= 3
|
|
||||||
I18n.t("rate_limiter.short_time")
|
|
||||||
elsif @available_in < 1.minute.to_i
|
|
||||||
I18n.t("rate_limiter.seconds", count: @available_in)
|
|
||||||
elsif @available_in < 1.hour.to_i
|
|
||||||
I18n.t("rate_limiter.minutes", count: (@available_in / 1.minute.to_i))
|
|
||||||
else
|
|
||||||
I18n.t("rate_limiter.hours", count: (@available_in / 1.hour.to_i))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def description
|
def description
|
||||||
|
|
|
@ -1,49 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe CategoryGuardian do
|
|
||||||
fab!(:admin) { Fabricate(:admin) }
|
|
||||||
let(:guardian) { Guardian.new(admin) }
|
|
||||||
fab!(:category) { Fabricate(:category) }
|
|
||||||
|
|
||||||
describe '#cannot_delete_category_reason' do
|
|
||||||
describe 'when category is uncategorized' do
|
|
||||||
it 'should return the reason' do
|
|
||||||
category = Category.find(SiteSetting.uncategorized_category_id)
|
|
||||||
|
|
||||||
expect(guardian.cannot_delete_category_reason(category)).to eq(
|
|
||||||
I18n.t('category.cannot_delete.uncategorized')
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'when category has subcategories' do
|
|
||||||
it 'should return the right reason' do
|
|
||||||
category.subcategories << Fabricate(:category)
|
|
||||||
|
|
||||||
expect(guardian.cannot_delete_category_reason(category)).to eq(
|
|
||||||
I18n.t('category.cannot_delete.has_subcategories')
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'when category has topics' do
|
|
||||||
it 'should return the right reason' do
|
|
||||||
topic = Fabricate(:topic,
|
|
||||||
title: '</a><script>alert(document.cookie);</script><a>',
|
|
||||||
category: category
|
|
||||||
)
|
|
||||||
|
|
||||||
category.reload
|
|
||||||
|
|
||||||
expect(guardian.cannot_delete_category_reason(category)).to eq(
|
|
||||||
I18n.t('category.cannot_delete.topic_exists',
|
|
||||||
count: 1,
|
|
||||||
topic_link: "<a href=\"#{topic.url}\"></a><script>alert(document.cookie);</script><a></a>"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1220,4 +1220,48 @@ describe Category do
|
||||||
expect(Category.find_by_slug_path(["#{category.id}-category", "#{subcategory.id}-category"])).to eq(subcategory)
|
expect(Category.find_by_slug_path(["#{category.id}-category", "#{subcategory.id}-category"])).to eq(subcategory)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#cannot_delete_reason' do
|
||||||
|
fab!(:admin) { Fabricate(:admin) }
|
||||||
|
let(:guardian) { Guardian.new(admin) }
|
||||||
|
fab!(:category) { Fabricate(:category) }
|
||||||
|
|
||||||
|
describe 'when category is uncategorized' do
|
||||||
|
it 'should return the reason' do
|
||||||
|
category = Category.find(SiteSetting.uncategorized_category_id)
|
||||||
|
|
||||||
|
expect(category.cannot_delete_reason).to eq(
|
||||||
|
I18n.t('category.cannot_delete.uncategorized')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when category has subcategories' do
|
||||||
|
it 'should return the right reason' do
|
||||||
|
category.subcategories << Fabricate(:category)
|
||||||
|
|
||||||
|
expect(category.cannot_delete_reason).to eq(
|
||||||
|
I18n.t('category.cannot_delete.has_subcategories')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when category has topics' do
|
||||||
|
it 'should return the right reason' do
|
||||||
|
topic = Fabricate(:topic,
|
||||||
|
title: '</a><script>alert(document.cookie);</script><a>',
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
|
||||||
|
category.reload
|
||||||
|
|
||||||
|
expect(category.cannot_delete_reason).to eq(
|
||||||
|
I18n.t('category.cannot_delete.topic_exists',
|
||||||
|
count: 1,
|
||||||
|
topic_link: "<a href=\"#{topic.url}\"></a><script>alert(document.cookie);</script><a></a>"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -224,6 +224,65 @@ describe PostsController do
|
||||||
|
|
||||||
delete "/posts/#{post.id}.json"
|
delete "/posts/#{post.id}.json"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "permanently destroy" do
|
||||||
|
let!(:post) { Fabricate(:post, topic_id: topic.id, post_number: 3) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
SiteSetting.can_permanently_delete = true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not work for a post that was not deleted yet" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json", params: { force_destroy: true }
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "needs some time to pass to permanently delete a topic" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(post.reload.deleted_by_id).to eq(admin.id)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json", params: { force_destroy: true }
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
|
||||||
|
post.update!(deleted_at: 10.minutes.ago)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json", params: { force_destroy: true }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect { post.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "needs two users to permanently delete a topic" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(post.reload.deleted_by_id).to eq(admin.id)
|
||||||
|
|
||||||
|
sign_in(Fabricate(:admin))
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json", params: { force_destroy: true }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect { post.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "moderators cannot permanently delete topics" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(post.reload.deleted_by_id).to eq(admin.id)
|
||||||
|
|
||||||
|
sign_in(moderator)
|
||||||
|
|
||||||
|
delete "/posts/#{post.id}.json", params: { force_destroy: true }
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue