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
272 changes: 127 additions & 145 deletions app/controllers/discourse_moderation_api/webhooks_controller.rb
Original file line number Diff line number Diff line change
@@ -1,150 +1,132 @@
# frozen_string_literal: true

module DiscourseModerationApi
class WebhooksController < ::ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_webhook_signature

def receive
Rails.logger.info("Received webhook: #{request.raw_post}")

# Get the system user for moderation actions
@system_user = DiscourseModerationApi.system_user
unless @system_user && @system_user.id > 0
Rails.logger.error("Moderation API bot user not found or has invalid ID: #{@system_user&.id}")
render json: { error: "System configuration error - bot user not available" }, status: 500
return
end

payload = JSON.parse(request.raw_post) rescue {}

# Check if the payload has type=QUEUE_ITEM_ACTION
unless payload["type"] == "QUEUE_ITEM_ACTION"
Rails.logger.info("Ignoring webhook with type: #{payload["type"]}")
render json: { status: "ignored", message: "Only QUEUE_ITEM_ACTION type is processed" }, status: 200
return
end

item = payload["item"]
action = payload["action"]

content_id = item["id"].to_i
context_id = item["contextId"].to_i

if content_id <= 0
Rails.logger.error("Invalid content ID: #{item["id"]}")
render json: { error: "Invalid content ID" }, status: 400
return
end

# Find the content based on content_type
post = nil
reviewables = []

post = Post.unscoped.find_by(id: content_id)
reviewables = Reviewable.where(target: post) if post

unless post || reviewables.any?
Rails.logger.error("Could not find post, topic, or reviewable with id: #{content_id}")
render json: { error: "Content not found for id: #{content_id}" }, status: 404
return
end

case action["key"]
when "discourse:delete"
if post
PostDestroyer.new(@system_user, post).destroy
end
# Process reviewables in all cases
reviewables.each do |reviewable|
reviewable.destroy
end
when "discourse:hide"
if post
# Hide the post with the moderator action reason
post.hide!(
PostActionType.types[:moderator_action],
Post.hidden_reasons[:moderator_action],
custom_message: action["value"]
)
# if post is the first post in the topic, show the topic
if post.post_number == 1
# get the topic
post.topic.update!(visible: false)
end
end
# Process reviewables in all cases
reviewables.each do |reviewable|
reviewable.destroy
end
when "discourse:show"
if post
Rails.logger.debug("LOG:ModerationAPI: showing post: #{post.id}")
post.update!(hidden: false)
# if post is the first post in the topic, show the topic
if post.post_number == 1
# get the topic
post.topic.update!(visible: true)
end
end
# Process reviewables in all cases
reviewables.each do |reviewable|
reviewable.destroy
end
else
Rails.logger.warn("Unknown action key: #{action["key"]}")
render json: { error: "Unknown action" }, status: 400
return
end

render json: {
status: "success",
action: action["key"],
content_type: content_type,
content_id: content_id,
topic_id: context_id
}, status: 200
class WebhooksController < ::ApplicationController
requires_plugin DiscourseModerationApi::PLUGIN_NAME

skip_before_action :verify_authenticity_token
before_action :verify_webhook_signature

def receive
Rails.logger.info("Received webhook: #{request.raw_post}")

# Get the system user for moderation actions
@system_user = DiscourseModerationApi.system_user
unless @system_user && @system_user.id > 0
Rails.logger.error(
"Moderation API bot user not found or has invalid ID: #{@system_user&.id}",
)
render json: { error: "System configuration error - bot user not available" }, status: 500
return
end

payload =
begin
JSON.parse(request.raw_post)
rescue StandardError
{}
end

private

def verify_webhook_signature
# Skip verification if no signing secret is configured
return true if SiteSetting.moderation_api_webhook_signing_secret.blank?

signature = request.headers['modapi-signature'].to_s

# Require signature if secret is configured
if signature.blank?
Rails.logger.warn("Webhook signature missing")
render json: {
error: "Signature required",
time: Time.current
}, status: 401
return false
end

raw_body = request.raw_post
expected_signature = OpenSSL::HMAC.hexdigest(
'sha256',
SiteSetting.moderation_api_webhook_signing_secret,
raw_body
)

# Convert both signatures to buffers for comparison
actual_sig = signature.strip
expected_sig = expected_signature.strip

# Verify signatures match using secure comparison
unless actual_sig.bytesize == expected_sig.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(actual_sig, expected_sig)
Rails.logger.warn("Webhook signature verification failed")
render json: {
error: "Signature verification failed",
time: Time.current
}, status: 401
return false
end

true
# Check if the payload has type=QUEUE_ITEM_ACTION
unless payload["type"] == "QUEUE_ITEM_ACTION"
Rails.logger.info("Ignoring webhook with type: #{payload["type"]}")
render json: {
status: "ignored",
message: "Only QUEUE_ITEM_ACTION type is processed",
},
status: 200
return
end

item = payload["item"]
action = payload["action"]

content_id = item["id"].to_i
context_id = item["contextId"].to_i

if content_id <= 0
Rails.logger.error("Invalid content ID: #{item["id"]}")
render json: { error: "Invalid content ID" }, status: 400
return
end

# Find the content based on content_type
post = Post.unscoped.find_by(id: content_id)
reviewables = post ? Reviewable.where(target: post) : []

unless post || reviewables.any?
Rails.logger.error("Could not find post, topic, or reviewable with id: #{content_id}")
render json: { error: "Content not found for id: #{content_id}" }, status: 404
return
end

case action["key"]
when "discourse:delete"
PostDestroyer.new(@system_user, post).destroy if post
reviewables.each(&:destroy)
when "discourse:hide"
if post
post.hide!(
PostActionType.types[:moderator_action],
Post.hidden_reasons[:moderator_action],
custom_message: action["value"],
)
post.topic.update!(visible: false) if post.post_number == 1
end
reviewables.each(&:destroy)
when "discourse:show"
if post
Rails.logger.debug("LOG:ModerationAPI: showing post: #{post.id}")
post.update!(hidden: false)
post.topic.update!(visible: true) if post.post_number == 1
end
reviewables.each(&:destroy)
else
Rails.logger.warn("Unknown action key: #{action["key"]}")
render json: { error: "Unknown action" }, status: 400
return
end

render json: {
status: "success",
action: action["key"],
content_id: content_id,
topic_id: context_id,
},
status: 200
end

private

def verify_webhook_signature
# Skip verification if no signing secret is configured
return true if SiteSetting.moderation_api_webhook_signing_secret.blank?

signature = request.headers["modapi-signature"].to_s

# Require signature if secret is configured
if signature.blank?
Rails.logger.warn("Webhook signature missing")
render json: { error: "Signature required", time: Time.current }, status: 401
return false
end

raw_body = request.raw_post
expected_signature =
OpenSSL::HMAC.hexdigest(
"sha256",
SiteSetting.moderation_api_webhook_signing_secret,
raw_body,
)

# Verify signatures match using secure comparison
unless ActiveSupport::SecurityUtils.secure_compare(signature.strip, expected_signature.strip)
Rails.logger.warn("Webhook signature verification failed")
render json: { error: "Signature verification failed", time: Time.current }, status: 401
return false
end

true
end
end
end
end
55 changes: 28 additions & 27 deletions lib/discourse_moderation_api/moderation_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,29 @@ def self.analyze_post(post)
Rails.logger.debug("Analyzing post content")

topic_title = nil
if post.post_number == 1
topic_title = post.topic.title
end
topic_title = post.topic.title if post.post_number == 1

analyze_content(
content: post.raw,
author_id: post.user_id.to_s,
context_id: post.topic_id.to_s,
content_id: post.id&.to_s || "pending_#{Time.now.to_i}",
content_url: "#{Discourse.base_url}/t/#{post.topic.slug}/#{post.topic_id}/#{post.post_number}",
content_url:
"#{Discourse.base_url}/t/#{post.topic.slug}/#{post.topic_id}/#{post.post_number}",
topic_title: topic_title,
)
end

private

def self.analyze_content(content:, author_id:, context_id:, content_id:, content_url:, topic_title:)
def self.analyze_content(
content:,
author_id:,
context_id:,
content_id:,
content_url:,
topic_title:
)
Rails.logger.info("Analyzing content with Moderation API")

begin
Expand All @@ -40,39 +46,34 @@ def self.analyze_content(content:, author_id:, context_id:, content_id:, content
"Author ID: #{author_id}, Context ID: #{context_id}, Content ID: #{content_id}",
)

params = { value: {
type: "object",
data: { },
params = {
value: {
type: "object",
data: {
},
},
doNotStore: false,
metadata: { link: content_url },
metadata: {
link: content_url,
},
}

if topic_title.present?
params[:value][:data]["title"] = {
type: "text",
value: topic_title,
}
params[:value][:data]["title"] = { type: "text", value: topic_title }
end

# add this after to have the order nice
params[:value][:data]["post"] = {
type: "text",
value: content,
}
params[:value][:data]["post"] = { type: "text", value: content }

# Add image URLs if present
if content.present?
# Extract image URLs from post content using Discourse's built-in cooked parsing
doc = Nokogiri::HTML5.fragment(PrettyText.cook(content))
image_urls = doc.css('img').map { |img| img['src'] }.compact
image_urls = doc.css("img").map { |img| img["src"] }.compact

image_urls.each_with_index do |url, index|
full_url = url.start_with?('http') ? url : "#{Discourse.base_url}#{url}"
params[:value][:data]["image-#{index + 1}"] = {
type: "image",
value: full_url
}
full_url = url.start_with?("http") ? url : "#{Discourse.base_url}#{url}"
params[:value][:data]["image-#{index + 1}"] = { type: "image", value: full_url }
end
end

Expand All @@ -87,18 +88,18 @@ def self.analyze_content(content:, author_id:, context_id:, content_id:, content

Rails.logger.debug("API Response: #{analysis.inspect}")

return { approved: !analysis.flagged }
{ approved: !analysis.flagged }
rescue ModerationApi::ApiError => e
Rails.logger.error("Moderation API error: #{e.message}")
Rails.logger.error("Response body: #{e.response_body}") if e.respond_to?(:response_body)
Rails.logger.error("Full error: #{e.full_message}")
Rails.logger.error(e.backtrace.join("\n"))
# Return approved by default in case of API errors
return { approved: true }
{ approved: true }
rescue StandardError => e
Rails.logger.error("Unexpected error in moderation service: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
return { approved: true }
{ approved: true }
end
end
end
Expand Down
Loading