forked from discourse/discourse
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpost_destroyer.rb
508 lines (435 loc) · 16.4 KB
/
post_destroyer.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# frozen_string_literal: true
#
# How a post is deleted is affected by who is performing the action.
# this class contains the logic to delete it.
#
class PostDestroyer
def self.destroy_old_hidden_posts
Post
.where(deleted_at: nil, hidden: true)
.where("hidden_at < ?", 30.days.ago)
.find_each { |post| PostDestroyer.new(Discourse.system_user, post).destroy }
end
def self.destroy_stubs
context = I18n.t("remove_posts_deleted_by_author")
# exclude deleted topics and posts that are actively flagged
Post
.where(deleted_at: nil, user_deleted: true)
.where(
"NOT EXISTS (
SELECT 1 FROM topics t
WHERE t.deleted_at IS NOT NULL AND
t.id = posts.topic_id
)",
)
.where("updated_at < ?", SiteSetting.delete_removed_posts_after.hours.ago)
.where(
"NOT EXISTS (
SELECT 1
FROM post_actions pa
WHERE pa.post_id = posts.id
AND pa.deleted_at IS NULL
AND pa.deferred_at IS NULL
AND pa.post_action_type_id IN (?)
)",
PostActionType.notify_flag_type_ids,
)
.find_each { |post| PostDestroyer.new(Discourse.system_user, post, context: context).destroy }
end
def self.delete_with_replies(performed_by, post, reviewable = nil, defer_reply_flags: true)
reply_ids = post.reply_ids(Guardian.new(performed_by), only_replies_to_single_post: false)
replies = Post.where(id: reply_ids.map { |r| r[:id] })
PostDestroyer.new(performed_by, post, reviewable: reviewable).destroy
options = { defer_flags: defer_reply_flags }
if SiteSetting.notify_users_after_responses_deleted_on_flagged_post
options.merge!({ reviewable: reviewable, notify_responders: true, parent_post: post })
end
replies.each { |reply| PostDestroyer.new(performed_by, reply, options).destroy }
end
def initialize(user, post, opts = {})
@user = user
@post = post
@topic = post.topic || Topic.with_deleted.find_by(id: @post.topic_id)
@opts = opts
end
def destroy
payload = WebHook.generate_payload(:post, @post) if WebHook.active_web_hooks(
:post_destroyed,
).exists?
is_first_post = @post.is_first_post? && @topic
has_topic_web_hooks = is_first_post && WebHook.active_web_hooks(:topic_destroyed).exists?
if has_topic_web_hooks
topic_view = TopicView.new(@topic.id, Discourse.system_user, skip_staff_action: true)
topic_payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer)
end
delete_removed_posts_after =
@opts[:delete_removed_posts_after] || SiteSetting.delete_removed_posts_after
if delete_removed_posts_after < 1 || post_is_reviewable? ||
Guardian.new(@user).can_moderate_topic?(@topic) || permanent?
perform_delete
elsif @user.id == @post.user_id
mark_for_deletion(delete_removed_posts_after)
end
UserActionManager.post_destroyed(@post)
DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user)
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic
if is_first_post
UserProfile.remove_featured_topic_from_all_profiles(@topic)
UserActionManager.topic_destroyed(@topic)
DiscourseEvent.trigger(:topic_destroyed, @topic, @user)
WebHook.enqueue_topic_hooks(:topic_destroyed, @topic, topic_payload) if has_topic_web_hooks
if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id
Discourse.clear_urls!
end
end
end
def recover
if (post_is_reviewable? || Guardian.new(@user).can_moderate_topic?(@post.topic)) &&
@post.deleted_at
staff_recovered
elsif @user.staff? || @user.id == @post.user_id
user_recovered
end
@topic.update_column(:user_id, Discourse::SYSTEM_USER_ID) if [email protected]_id
@topic.recover!(@user) if @post.is_first_post?
@topic.update_statistics
Topic.publish_stats_to_clients!(@topic.id, :recovered)
UserActionManager.post_created(@post)
DiscourseEvent.trigger(:post_recovered, @post, @opts, @user)
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic
Jobs.enqueue(:notify_mailing_list_subscribers, post_id: @post.id)
if @post.is_first_post?
UserActionManager.topic_created(@topic)
DiscourseEvent.trigger(:topic_recovered, @topic, @user)
if @user.id != @post.user_id
StaffActionLogger.new(@user).log_topic_delete_recover(
@topic,
"recover_topic",
@opts.slice(:context),
)
end
update_imap_sync(@post, false)
if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id
Discourse.clear_urls!
end
end
end
def staff_recovered
new_post_attrs = { user_deleted: false }
new_post_attrs[:user_id] = Discourse::SYSTEM_USER_ID if [email protected]_id
@post.update_columns(new_post_attrs)
@post.recover!
mark_topic_changed
if @post.topic && [email protected]_message?
if author = @post.user
if @post.is_first_post?
author.user_stat.topic_count += 1
else
author.user_stat.post_count += 1
end
author.user_stat.save!
end
if @post.is_first_post?
# Update stats of all people who replied
update_post_counts(:increment)
end
end
# skip also publishing topic stats because they weren't updated yet
@post.publish_change_to_clients! :recovered, { skip_topic_stats: true }
TopicTrackingState.publish_recover(@post.topic) if @post.topic && @post.is_first_post?
end
# When a post is properly deleted. Well, it's still soft deleted, but it will no longer
# show up in the topic
# Permanent option allows to hard delete.
def perform_delete
# All posts in the topic must be force deleted if the first is force
# deleted (except @post which is destroyed by current instance).
if @topic && @post.is_first_post? && permanent?
@topic.ordered_posts.with_deleted.reverse_order.find_each do |post|
PostDestroyer.new(@user, post, @opts).destroy if post.id != @post.id
end
end
Post.transaction do
permanent? ? @post.destroy! : @post.trash!(@user)
if @post.topic
make_previous_post_the_last_one
mark_topic_changed
clear_user_posted_flag
end
Topic.reset_highest(@post.topic_id)
trash_public_post_actions
trash_revisions
trash_user_actions
remove_associated_replies
remove_associated_notifications
if @user.id != @post.user_id && !@opts[:skip_staff_log]
if @post.topic && @post.is_first_post?
StaffActionLogger.new(@user).log_topic_delete_recover(
@post.topic,
"delete_topic",
@opts.slice(:context),
)
else
StaffActionLogger.new(@user).log_post_deletion(@post, @opts.slice(:context))
end
end
if @topic && @post.is_first_post?
permanent? ? @topic.destroy! : @topic.trash!(@user)
PublishedPage.unpublish!(@user, @topic) if @topic.published_page
end
TopicLink.where(link_post_id: @post.id).destroy_all
update_associated_category_latest_topic
update_user_counts if !permanent?
TopicUser.update_post_action_cache(post_id: @post.id)
DB.after_commit do
if @opts[:reviewable]
notify_deletion(
@opts[:reviewable],
{ notify_responders: @opts[:notify_responders], parent_post: @opts[:parent_post] },
)
if @post.reviewable_flag &&
SiteSetting.notify_users_after_responses_deleted_on_flagged_post
ignore(@post.reviewable_flag)
end
elsif reviewable = @post.reviewable_flag
@opts[:defer_flags] ? ignore(reviewable) : agree(reviewable)
end
end
end
update_imap_sync(@post, true) if @post.topic&.deleted_at
feature_users_in_the_topic if @post.topic
@post.publish_change_to_clients!(permanent? ? :destroyed : :deleted) if @post.topic
if @post.topic && @post.post_number == 1
TopicTrackingState.send(permanent? ? :publish_destroy : :publish_delete, @post.topic)
end
end
def permanent?
@opts[:force_destroy] ||
(@opts[:permanent] && @user == @post.user && @post.topic.private_message?)
end
# When a user 'deletes' their own post. We just change the text.
def mark_for_deletion(delete_removed_posts_after = SiteSetting.delete_removed_posts_after)
I18n.with_locale(SiteSetting.default_locale) do
# don't call revise from within transaction, high risk of deadlock
key =
(
if @post.is_first_post?
"js.topic.deleted_by_author_simple"
else
"js.post.deleted_by_author_simple"
end
)
@post.revise(
@user,
{ raw: I18n.t(key) },
force_new_version: true,
deleting_post: true,
skip_validations: true,
)
Post.transaction do
@post.update_column(:user_deleted, true)
@post.topic_links.each(&:destroy)
@post.topic.update_column(:closed, true) if @post.is_first_post?
end
end
end
def user_recovered
return unless @post.user_deleted?
Post.transaction do
@post.update_column(:user_deleted, false)
@post.skip_unique_check = true
@post.topic.update_column(:closed, false) if @post.is_first_post?
end
# has internal transactions, if we nest then there are some very high risk deadlocks
last_revision = @post.revisions.last
if last_revision.present? && last_revision.modifications["raw"].present?
@post.revise(@user, { raw: last_revision.modifications["raw"][0] }, force_new_version: true)
end
end
private
def post_is_reviewable?
return true if @user.staff?
Guardian.new(@user).can_review_topic?(@topic) && Reviewable.exists?(target: @post)
end
# we need topics to change if ever a post in them is deleted or created
# this ensures users relying on this information can keep unread tracking
# working as desired
def mark_topic_changed
# make this as fast as possible, can bypass everything
DB.exec(<<~SQL, updated_at: Time.now, id: @post.topic_id)
UPDATE topics
SET updated_at = :updated_at
WHERE id = :id
SQL
end
def make_previous_post_the_last_one
last_post =
Post
.select(:created_at, :user_id, :post_number)
.where("topic_id = ? and id <> ?", @post.topic_id, @post.id)
.where.not(user_id: nil)
.where.not(post_type: Post.types[:whisper])
.order("created_at desc")
.first
if last_post.present?
topic = @post.topic
topic.last_posted_at = last_post.created_at
topic.last_post_user_id = last_post.user_id
topic.highest_post_number = last_post.post_number
# we go via save here cause we need to run hooks
topic.save!(validate: false)
end
end
def clear_user_posted_flag
unless Post.exists?(
["topic_id = ? and user_id = ? and id <> ?", @post.topic_id, @post.user_id, @post.id],
)
TopicUser.where(topic_id: @post.topic_id, user_id: @post.user_id).update_all "posted = false"
end
end
def feature_users_in_the_topic
Jobs.enqueue(:feature_topic_users, topic_id: @post.topic_id)
end
def trash_public_post_actions
if public_post_actions = PostAction.publics.where(post_id: @post.id)
public_post_actions.each { |pa| permanent? ? pa.destroy! : pa.trash!(@user) }
return if permanent?
@post.custom_fields["deleted_public_actions"] = public_post_actions.ids
@post.save_custom_fields
f = PostActionType.public_types.map { |k, _| ["#{k}_count", 0] }
Post.with_deleted.where(id: @post.id).update_all(Hash[*f.flatten])
end
end
def trash_revisions
return unless permanent?
@post.revisions.each(&:destroy!)
end
def agree(reviewable)
notify_deletion(reviewable)
result = reviewable.perform(@user, :agree_and_keep, post_was_deleted: true)
reviewable.transition_to(result.transition_to, @user)
end
def ignore(reviewable)
reviewable.perform_ignore_and_do_nothing(@user, post_was_deleted: true)
reviewable.transition_to(:ignored, @user)
end
def notify_deletion(reviewable, options = {})
return if @post.user.blank?
allowed_user = @user.human? && @user.staff?
return unless allowed_user && rs = reviewable.reviewable_scores.order("created_at DESC").first
# ReviewableScore#types is a superset of PostActionType#flag_types.
# If the reviewable score type is not on the latter, it means it's not a flag by a user and
# must be an automated flag like `needs_approval`. There's no flag reason for these kind of types.
flag_type = PostActionType.flag_types[rs.reviewable_score_type]
return unless flag_type
notify_responders = options[:notify_responders]
Jobs.enqueue(
:send_system_message,
user_id: @post.user_id,
message_type:
(
if notify_responders
"flags_agreed_and_post_deleted_for_responders"
else
"flags_agreed_and_post_deleted"
end
),
message_options: {
flagged_post_raw_content: notify_responders ? options[:parent_post].raw : @post.raw,
flagged_post_response_raw_content: @post.raw,
url: notify_responders ? options[:parent_post].url : @post.url,
flag_reason:
I18n.t(
"flag_reasons#{".responder" if notify_responders}.#{flag_type}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path,
),
},
)
end
def trash_user_actions
UserAction
.where(target_post_id: @post.id)
.each do |ua|
row = {
action_type: ua.action_type,
user_id: ua.user_id,
acting_user_id: ua.acting_user_id,
target_topic_id: ua.target_topic_id,
target_post_id: ua.target_post_id,
}
UserAction.remove_action!(row)
end
end
def remove_associated_replies
post_ids = PostReply.where(reply_post_id: @post.id).pluck(:post_id)
if post_ids.present?
PostReply.where(reply_post_id: @post.id).delete_all
Post.where(id: post_ids).each { |p| p.update_column :reply_count, p.replies.count }
end
end
def remove_associated_notifications
Notification.where(topic_id: @post.topic_id, post_number: @post.post_number).delete_all
end
def update_associated_category_latest_topic
return unless @post.topic && @post.topic.category
if @post.id != @post.topic.category.latest_post_id &&
!(@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id)
return
end
@post.topic.category.update_latest
end
def update_user_counts
author = @post.user
return unless author
author.create_user_stat if author.user_stat.nil?
if @post.created_at == author.user_stat.first_post_created_at
author.user_stat.update!(
first_post_created_at: author.posts.order("created_at ASC").first.try(:created_at),
)
end
UserStatCountUpdater.decrement!(@post)
if @post.created_at == author.last_posted_at
author.update_column(
:last_posted_at,
author.posts.order("created_at DESC").first.try(:created_at),
)
end
if @post.is_first_post? && @post.topic && [email protected]_message?
# Update stats of all people who replied
update_post_counts(:decrement)
end
end
def update_imap_sync(post, sync)
return if !SiteSetting.enable_imap
incoming = IncomingEmail.find_by(post_id: post.id, topic_id: post.topic_id)
return if !incoming || !incoming.imap_uid
incoming.update(imap_sync: sync)
end
def update_post_counts(operator)
counts =
Post
.where(post_type: Post.types[:regular], topic_id: @post.topic_id)
.where("post_number > 1")
.group(:user_id)
.count
counts.each do |user_id, count|
if user_stat = UserStat.where(user_id: user_id).first
if operator == :decrement
UserStatCountUpdater.set!(
user_stat: user_stat,
count: user_stat.post_count - count,
count_column: :post_count,
)
else
UserStatCountUpdater.set!(
user_stat: user_stat,
count: user_stat.post_count + count,
count_column: :post_count,
)
end
end
end
end
end