Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
# or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile ~/.gitignore_global

# Ignore bundler config
# Ignore bundler config and locally-installed gems
/.bundle
/vendor/bundle

# Ingore Yardoc temp files
/.yardoc
Expand Down
27 changes: 27 additions & 0 deletions app/jobs/notify_discord_of_pending_post_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class NotifyDiscordOfPendingPostJob < ApplicationJob
require 'discordrb/webhooks'

queue_as :low_priority

def perform(post_id)
post = Thredded::Post.find_by(id: post_id)
return if post.nil?
return unless post.moderation_state == "pending_moderation"

webhook_url = ENV.fetch('DISCORD_MODERATION_WEBHOOK', '').freeze
return if webhook_url.blank?

author = post.user ? post.user.display_name : 'an anonymous user'
client = Discordrb::Webhooks::Client.new(url: webhook_url)
client.execute do |builder|
builder.content = "Post by **#{author}** in **#{post.messageboard.name}** is waiting for review"
builder.add_embed do |embed|
embed.title = post.postable.title
embed.description = post.content.truncate(140)
embed.timestamp = Time.now
embed.url = "https://www.notebook.ai/forum/moderation"
embed.colour = 15158332
end
end
end
end
23 changes: 18 additions & 5 deletions app/jobs/notify_discord_of_thread_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ class NotifyDiscordOfThreadJob < ApplicationJob

queue_as :low_priority

def perform(*args)
thread_id = args.shift
thread = Thredded::Topic.find_by(id: thread_id)
raise "No thread found for new ID #{thread.id.inspect}" unless thread
return if thread.moderation_state == "blocked"
# from_moderation is true when this job was enqueued because a moderator
# approved a thread that was held for moderation (rather than at creation).
def perform(thread_id, from_moderation = false)
thread = Thredded::Topic.find_by(id: thread_id)
return if thread.nil? # deleted before the announcement went out
return unless thread.moderation_state == "approved"

# Threads that went through the moderation queue get announced by the
# approval-time enqueue; skip the creation-time enqueue so approving a
# thread within the 1-minute announcement delay can't announce it twice.
return if !from_moderation && went_through_moderation?(thread)

webhook_url = ENV.fetch('DISCORD_FORUMS_WEBHOOK', '').freeze
return if webhook_url.blank?

client = Discordrb::Webhooks::Client.new(url: webhook_url)
client.execute do |builder|
Expand All @@ -22,6 +29,12 @@ def perform(*args)
embed.colour = 2201331
end
end
end

private

def went_through_moderation?(thread)
first_post = thread.first_post
first_post.present? && Thredded::PostModerationRecord.where(post_id: first_post.id).exists?
end
end
83 changes: 81 additions & 2 deletions lib/extensions/thredded/post.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,89 @@ module Extensions
module Thredded
module Post
extend ActiveSupport::Concern


# Matches http(s) URLs, protocol-less www. links, and markdown-style links.
LINK_PATTERN = %r{https?://|\bwww\.|\[[^\]]*\]\([^)\s]+\)}i

included do
acts_as_paranoid

before_create :hold_first_post_with_links_for_moderation
after_create :cascade_pending_moderation_to_topic
after_commit :notify_moderators_of_new_pending_post, on: :create
after_commit :notify_moderators_of_newly_pended_post, on: :update
end

def contains_link?
content.to_s.match?(LINK_PATTERN)
end

private

# Anti-spam: a user's first-ever forum post containing a link goes to the
# moderation queue instead of being auto-approved. Pending the user's
# thredded_user_detail (rather than just this post) means any further posts
# they make before review are also held, and approving any of their posts
# from the moderation queue approves the user again (see Thredded::ModeratePost).
def hold_first_post_with_links_for_moderation
return unless first_post_with_links_by_new_user?

# In the new-topic flow the topic's save has already inserted the user's
# detail row, while this post's user_detail association still points at a
# separate unsaved instance -- always pend the persisted row and repoint
# the association at it.
detail = ::Thredded::UserDetail.find_or_create_by!(user_id: user_id)
detail.update!(moderation_state: :pending_moderation)
self.user_detail = detail
self.moderation_state = :pending_moderation
end

def first_post_with_links_by_new_user?
return false if user.nil?
return false unless approved? # already pending/blocked users are handled by Thredded
return false if user.forum_moderator? || user.forum_administrator? || user.site_administrator?
return false unless contains_link?

!::Thredded::Post.where(user_id: user_id).exists?
end

# Topics are saved before their first post (see Thredded::TopicForm#save),
# so when that first post is held for moderation the topic has already been
# created as approved; bring it in line so it is held too.
def cascade_pending_moderation_to_topic
return unless pending_moderation?

topic = postable
return unless topic.is_a?(::Thredded::Topic)
return unless topic.approved?
return unless topic.first_post.nil? || topic.first_post.id == id

topic.update_columns(moderation_state: ::Thredded::Topic.moderation_states[:pending_moderation])
end

# Ping the moderator Discord channel whenever a post lands in the moderation
# queue (held first post, reported post, or a post by a still-pending user).
# Only one ping per user while they already have posts waiting in the queue.
# Note: saved_change_to_moderation_state? can't be checked on create -- in
# the TopicForm flow the post is inserted by the topic's autosave and then
# saved again, which clears the change tracking before commit callbacks run.
def notify_moderators_of_new_pending_post
return unless pending_moderation?

notify_moderators_of_pending_post
end

def notify_moderators_of_newly_pended_post
return unless pending_moderation? && saved_change_to_moderation_state?

notify_moderators_of_pending_post
end

def notify_moderators_of_pending_post
return if ::Thredded::Post.pending_moderation.where(user_id: user_id).where.not(id: id).exists?

NotifyDiscordOfPendingPostJob.perform_later(id)
end
end
end
end
end
13 changes: 13 additions & 0 deletions lib/extensions/thredded/topic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module Topic
included do
after_create :create_content_page_share
after_create :notify_discord
after_commit :notify_discord_of_approval, on: :update
has_many :content_page_shares, as: :content

acts_as_paranoid
Expand All @@ -30,6 +31,18 @@ def notify_discord
NotifyDiscordOfThreadJob.set(wait: 1.minute).perform_later(self.id) if Rails.env.production?
end

# Threads held for moderation aren't announced on Discord when they're
# created (see NotifyDiscordOfThreadJob); announce them once a moderator
# approves them instead.
def notify_discord_of_approval
return unless saved_change_to_moderation_state?

previous_state, new_state = saved_change_to_moderation_state
return unless previous_state == 'pending_moderation' && new_state == 'approved'

NotifyDiscordOfThreadJob.perform_later(self.id, true) if Rails.env.production?
end

def create_content_page_share
ContentPageShare.create(
user_id: self.user_id,
Expand Down
67 changes: 67 additions & 0 deletions test/jobs/notify_discord_of_thread_job_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require 'test_helper'
require 'webmock'

class NotifyDiscordOfThreadJobTest < ActiveSupport::TestCase
WEBHOOK_URL = 'https://discord.test/api/webhooks/123/abc'.freeze

def setup
@user = users(:one)
@moderator = users(:two)
@moderator.update!(forum_moderator: true)
@messageboard = Thredded::Messageboard.create!(name: "Job test board")

ENV['DISCORD_FORUMS_WEBHOOK'] = WEBHOOK_URL
WebMock.enable!
WebMock.stub_request(:post, WEBHOOK_URL).to_return(status: 204)
end

def teardown
WebMock.reset!
WebMock.disable!
ENV.delete('DISCORD_FORUMS_WEBHOOK')
end

def create_topic(content)
form = Thredded::TopicForm.new(
title: "A topic",
content: content,
user: @user,
messageboard: @messageboard
)
assert form.save, "expected topic form to save"
form.topic
end

test "announces approved threads" do
topic = create_topic("Hello, no links here")

NotifyDiscordOfThreadJob.perform_now(topic.id)

WebMock.assert_requested(:post, WEBHOOK_URL)
end

test "does not announce threads held for moderation" do
topic = create_topic("Spam: https://spam.example.com")
assert topic.reload.pending_moderation?

NotifyDiscordOfThreadJob.perform_now(topic.id)

WebMock.assert_not_requested(:post, WEBHOOK_URL)
end

test "announces a held thread once when it is approved" do
topic = create_topic("Link: https://example.com")
Thredded::ModeratePost.run!(
post: topic.reload.first_post,
moderation_state: :approved,
moderator: @moderator
)

# The approval-time enqueue announces it...
NotifyDiscordOfThreadJob.perform_now(topic.id, true)
# ...and the creation-time enqueue (fires 1 minute after creation) skips it.
NotifyDiscordOfThreadJob.perform_now(topic.id)

WebMock.assert_requested(:post, WEBHOOK_URL, times: 1)
end
end
122 changes: 122 additions & 0 deletions test/models/forum_first_post_moderation_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
require 'test_helper'

class ForumFirstPostModerationTest < ActiveSupport::TestCase
include ActiveJob::TestHelper

def setup
@user = users(:one)
@other_user = users(:two)
@messageboard = Thredded::Messageboard.create!(name: "Test board")
end

def create_topic_with_post(user, content, title: "A topic")
form = Thredded::TopicForm.new(
title: title,
content: content,
user: user,
messageboard: @messageboard
)
assert form.save, "expected topic form to save"
[form.topic, form.post]
end

test "first-ever post containing a link is held for moderation, along with its topic and author" do
topic, post = create_topic_with_post(@user, "Check out https://spam.example.com for cheap stuff")

assert post.pending_moderation?
assert topic.reload.pending_moderation?
assert @user.reload.thredded_user_detail.pending_moderation?
end

test "first-ever post without a link is approved as usual" do
topic, post = create_topic_with_post(@user, "Hello everyone, happy to be here!")

assert post.approved?
assert topic.reload.approved?
assert @user.reload.thredded_user_detail.approved?
end

test "posts with links from users with existing posts are approved as usual" do
create_topic_with_post(@user, "My first post, no links here")
topic, post = create_topic_with_post(@user, "Now a link: https://example.com", title: "Second topic")

assert post.approved?
assert topic.reload.approved?
end

test "held first post as a reply does not affect someone else's topic" do
topic, _post = create_topic_with_post(@other_user, "A perfectly normal thread")

reply = Thredded::Post.create!(
content: "Buy now at www.spam.example",
user: @user,
postable: topic,
messageboard: @messageboard
)

assert reply.pending_moderation?
assert topic.reload.approved?
assert @user.reload.thredded_user_detail.pending_moderation?
end

test "subsequent posts by a held user are also held" do
create_topic_with_post(@user, "First post with https://spam.example.com link")
topic, post = create_topic_with_post(@user, "A follow-up with no links at all", title: "Second topic")

assert post.pending_moderation?
assert topic.reload.pending_moderation?
end

test "forum staff are exempt from first-post link moderation" do
@user.update!(forum_moderator: true)
_topic, post = create_topic_with_post(@user, "Announcement: https://notebook.ai/some/page")

assert post.approved?
end

test "approving a held post from the moderation queue approves the topic and author" do
topic, post = create_topic_with_post(@user, "Check out https://spam.example.com")
moderator = @other_user
moderator.update!(forum_moderator: true)

Thredded::ModeratePost.run!(post: post, moderation_state: :approved, moderator: moderator)

assert post.reload.approved?
assert topic.reload.approved?
assert @user.reload.thredded_user_detail.approved?
end

test "detects common link formats" do
["https://example.com", "http://example.com", "visit www.example.com now",
"a [markdown link](https://example.com)"].each do |content|
assert Thredded::Post.new(content: content).contains_link?, "expected link in: #{content}"
end

["no links here", "just talking about wwwater", "parentheses (like these)"].each do |content|
assert_not Thredded::Post.new(content: content).contains_link?, "expected no link in: #{content}"
end
end

test "moderators are pinged on Discord when a post is held" do
assert_enqueued_with(job: NotifyDiscordOfPendingPostJob) do
create_topic_with_post(@user, "Spam here: https://spam.example.com")
end
end

test "moderators are only pinged once while a user has posts in the queue" do
create_topic_with_post(@user, "Spam here: https://spam.example.com")

assert_no_enqueued_jobs(only: NotifyDiscordOfPendingPostJob) do
create_topic_with_post(@user, "More posts while pending", title: "Second topic")
end
end

test "moderators are pinged when an approved post is reported" do
_topic, post = create_topic_with_post(@user, "A perfectly normal first post")
assert post.approved?

assert_enqueued_with(job: NotifyDiscordOfPendingPostJob) do
post.update!(moderation_state: :pending_moderation)
end
end
end
Loading