diff --git a/app/controllers/discourse_moderation_api/webhooks_controller.rb b/app/controllers/discourse_moderation_api/webhooks_controller.rb index 97c972e..183ad61 100644 --- a/app/controllers/discourse_moderation_api/webhooks_controller.rb +++ b/app/controllers/discourse_moderation_api/webhooks_controller.rb @@ -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 \ No newline at end of file + end +end diff --git a/lib/discourse_moderation_api/moderation_service.rb b/lib/discourse_moderation_api/moderation_service.rb index bda23c0..11e6a98 100644 --- a/lib/discourse_moderation_api/moderation_service.rb +++ b/lib/discourse_moderation_api/moderation_service.rb @@ -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 @@ -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 @@ -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 diff --git a/plugin.rb b/plugin.rb index 557a889..8cbd7cb 100644 --- a/plugin.rb +++ b/plugin.rb @@ -11,7 +11,7 @@ # Declare dependencies required by moderation_api gem # These are needed because Discourse installs gems with --ignore-dependencies gem "multipart-post", "2.4.1", { require: false } -gem "faraday-multipart", "1.1.1", { require: false } +gem "faraday-multipart", "1.2.0", { require: false } gem "marcel", "1.0.4", { require: false } gem "moderation_api", "1.2.2", { require: false } @@ -25,16 +25,17 @@ module ::DiscourseModerationApi PLUGIN_NAME = "discourse-moderation-api" def self.system_user - @system_user ||= begin - user = User.find_by(username: "moderation_api_bot") - if user && user.id > 0 - Rails.logger.info("Found existing Moderation API bot user with ID: #{user.id}") - user - else - Rails.logger.info("Creating new Moderation API bot user") - create_system_user + @system_user ||= + begin + user = User.find_by(username: "moderation_api_bot") + if user && user.id > 0 + Rails.logger.info("Found existing Moderation API bot user with ID: #{user.id}") + user + else + Rails.logger.info("Creating new Moderation API bot user") + create_system_user + end end - end end def self.create_system_user @@ -67,19 +68,21 @@ def self.create_system_user avatar = UserAvatar.import_url_for_user(avatar_url, created_user) end end - + # Verify the user was created successfully with a valid ID if user.id <= 0 Rails.logger.error("Failed to create Moderation API bot user with valid ID: #{user.id}") raise "Failed to create Moderation API bot user with valid ID" end - + Rails.logger.info("Successfully created Moderation API bot user with ID: #{user.id}") user end def self.should_moderate?(post) - Rails.logger.debug("LOG:ModerationAPI: Checking if post #{post&.id} should be moderated. #{post.inspect}") + Rails.logger.debug( + "LOG:ModerationAPI: Checking if post #{post&.id} should be moderated. #{post.inspect}", + ) if post.blank? || !SiteSetting.moderation_api_enabled Rails.logger.debug("LOG:ModerationAPI: Skipping - post is blank or moderation not enabled") @@ -88,7 +91,9 @@ def self.should_moderate?(post) # Skip if post already has errors if post.errors.present? - Rails.logger.debug("LOG:ModerationAPI: Skipping - post has errors: #{post.errors.full_messages}") + Rails.logger.debug( + "LOG:ModerationAPI: Skipping - post has errors: #{post.errors.full_messages}", + ) return false end @@ -97,7 +102,7 @@ def self.should_moderate?(post) Rails.logger.debug("LOG:ModerationAPI: Skipping - system message or no user") return false end - + # don't check trashed topics if !post.topic || post.topic.trashed? Rails.logger.debug("LOG:ModerationAPI: Skipping - topic is trashed or missing") @@ -129,7 +134,7 @@ def self.should_moderate?(post) end Rails.logger.debug("LOG:ModerationAPI: Post #{post.id} will be moderated") - return true + true end def self.handle_moderation_result(post, analysis) @@ -138,10 +143,10 @@ def self.handle_moderation_result(post, analysis) case SiteSetting.moderation_api_flagging_behavior when "Block post" post.errors.add(:base, SiteSetting.moderation_api_block_message) - return false + false when "Queue for review" queue_post_for_review(post) - return false + false when "Flag post" PostActionCreator.create( system_user, @@ -149,9 +154,9 @@ def self.handle_moderation_result(post, analysis) :inappropriate, message: "Flagged by Moderation API", ) - return false + false when "Nothing" - return nil + nil end end @@ -159,9 +164,7 @@ def self.queue_post_for_review(post) # Hide the post so no one sees it until a moderator checks post.update!(hidden: true) # if first post in topic, hide the topic - if post.post_number == 1 - post.topic.update!(visible: false) - end + post.topic.update!(visible: false) if post.post_number == 1 # Actually delete the post because it comes in again when the reviewable is approved # PostDestroyer.new(system_user, post).destroy @@ -197,7 +200,8 @@ def self.queue_post_for_review(post) # Check before creation only if we're in blocking mode on(:before_create_post) do |post, params| - if pre_create_behaviours.include?(SiteSetting.moderation_api_flagging_behavior) && DiscourseModerationApi.should_moderate?(post) + if pre_create_behaviours.include?(SiteSetting.moderation_api_flagging_behavior) && + DiscourseModerationApi.should_moderate?(post) analysis = DiscourseModerationApi::ModerationService.analyze_post(post) DiscourseModerationApi.handle_moderation_result(post, analysis) end @@ -205,7 +209,8 @@ def self.queue_post_for_review(post) # For non-blocking moderation, analyze after the post is created on(:post_created) do |post| - if post_create_behaviours.include?(SiteSetting.moderation_api_flagging_behavior) && DiscourseModerationApi.should_moderate?(post) + if post_create_behaviours.include?(SiteSetting.moderation_api_flagging_behavior) && + DiscourseModerationApi.should_moderate?(post) analysis = DiscourseModerationApi::ModerationService.analyze_post(post) DiscourseModerationApi.handle_moderation_result(post, analysis) end @@ -219,12 +224,8 @@ def self.queue_post_for_review(post) end end - # Add webhook handler + # Add webhook handler Discourse::Application.routes.append do post "/moderation-api/webhook" => "discourse_moderation_api/webhooks#receive" end - - end - -