From adfd34e506cdeac42226c26c47a906b6c5a592b4 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 28 May 2026 19:20:25 +0100 Subject: [PATCH] Rate limit heartbeat exports --- app/controllers/my/heartbeats_controller.rb | 24 +++++++++++++++++++ .../Users/Settings/ImportsExports.svelte | 3 ++- app/jobs/heartbeat_export_job.rb | 8 +++++++ .../my/heartbeats_controller_test.rb | 22 +++++++++++++++++ test/system/heartbeat_export_test.rb | 24 +++++++++++++++++-- 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/app/controllers/my/heartbeats_controller.rb b/app/controllers/my/heartbeats_controller.rb index da18a825f..43cae94be 100644 --- a/app/controllers/my/heartbeats_controller.rb +++ b/app/controllers/my/heartbeats_controller.rb @@ -1,5 +1,7 @@ module My class HeartbeatsController < ApplicationController + EXPORT_COOLDOWN = 10.minutes + before_action :ensure_current_user before_action :ensure_no_ban, only: [ :export ] @@ -9,10 +11,14 @@ def export end if params[:all_data] == "true" + return if export_rate_limited? + HeartbeatExportJob.perform_later(current_user.id, all_data: true) else date_range = export_date_range_from_params return if date_range.nil? + return if export_rate_limited? + HeartbeatExportJob.perform_later( current_user.id, all_data: false, @@ -58,5 +64,23 @@ def parse_iso8601_date(value:, default_value:) redirect_to my_settings_imports_exports_path, alert: "Invalid date format. Please use YYYY-MM-DD." nil end + + def export_rate_limited? + return false unless recent_export_requested? + + redirect_to my_settings_imports_exports_path, alert: "Export requests are limited to once every 10 minutes." + true + end + + def recent_export_requested? + GoodJob::Job + .where(job_class: "HeartbeatExportJob") + .where("created_at >= ?", EXPORT_COOLDOWN.ago) + .where( + "serialized_params -> 'arguments' -> 0 = to_jsonb(?::bigint)", + current_user.id + ) + .exists? + end end end diff --git a/app/javascript/pages/Users/Settings/ImportsExports.svelte b/app/javascript/pages/Users/Settings/ImportsExports.svelte index 7b4b7ad3b..a3e0fa4dd 100644 --- a/app/javascript/pages/Users/Settings/ImportsExports.svelte +++ b/app/javascript/pages/Users/Settings/ImportsExports.svelte @@ -338,7 +338,8 @@

- Exports are generated in the background and emailed to you. + Exports are generated in the background and emailed to you. You can + request one export every 10 minutes.

diff --git a/app/jobs/heartbeat_export_job.rb b/app/jobs/heartbeat_export_job.rb index b81fd17a1..1cda02615 100644 --- a/app/jobs/heartbeat_export_job.rb +++ b/app/jobs/heartbeat_export_job.rb @@ -3,6 +3,14 @@ class HeartbeatExportJob < ApplicationJob queue_as :default + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + total_limit: 1, + key: -> { "heartbeat_export_job_#{arguments.first}" }, + drop: true + ) + HEARTBEAT_EXPORT_FIELDS = %i[ id entity type category project language editor operating_system machine branch user_agent is_write line_additions line_deletions lineno lines diff --git a/test/controllers/my/heartbeats_controller_test.rb b/test/controllers/my/heartbeats_controller_test.rb index 27d2209d7..40d9fc18f 100644 --- a/test/controllers/my/heartbeats_controller_test.rb +++ b/test/controllers/my/heartbeats_controller_test.rb @@ -1,6 +1,10 @@ require "test_helper" class My::HeartbeatsControllerTest < ActionDispatch::IntegrationTest + setup do + GoodJob::Job.delete_all + end + test "export rejects banned users" do user = User.create!(trust_level: :red) user.email_addresses.create!(email: "banned-export@example.com", source: :signing_in) @@ -44,4 +48,22 @@ class My::HeartbeatsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to my_settings_imports_exports_path assert_equal "Start date must be on or before end date.", flash[:alert] end + + test "export rate limits repeated requests" do + user = User.create! + user.email_addresses.create!(email: "rate-limited-export@example.com", source: :signing_in) + sign_in_as(user) + + assert_difference -> { GoodJob::Job.where(job_class: "HeartbeatExportJob").count }, +1 do + post export_my_heartbeats_path, params: { all_data: "true" } + end + + assert_no_difference -> { GoodJob::Job.where(job_class: "HeartbeatExportJob").count } do + post export_my_heartbeats_path, params: { all_data: "true" } + end + + assert_response :redirect + assert_redirected_to my_settings_imports_exports_path + assert_equal "Export requests are limited to once every 10 minutes.", flash[:alert] + end end diff --git a/test/system/heartbeat_export_test.rb b/test/system/heartbeat_export_test.rb index f8b8e5901..fe140ffb3 100644 --- a/test/system/heartbeat_export_test.rb +++ b/test/system/heartbeat_export_test.rb @@ -12,7 +12,7 @@ class HeartbeatExportTest < ApplicationSystemTestCase test "clicking export all heartbeats enqueues job and shows notice" do visit my_settings_imports_exports_path - assert_text "Export all heartbeats" + wait_for_export_controls assert_difference -> { export_job_count }, 1 do click_on "Export all heartbeats" @@ -27,7 +27,7 @@ class HeartbeatExportTest < ApplicationSystemTestCase test "submitting export date range enqueues job and shows notice" do visit my_settings_imports_exports_path - assert_text "Export all heartbeats" # wait till it's loaded + wait_for_export_controls start_date = 7.days.ago.to_date.iso8601 end_date = Date.current.iso8601 @@ -46,6 +46,22 @@ class HeartbeatExportTest < ApplicationSystemTestCase ) end + test "repeated export requests are rate limited" do + visit my_settings_imports_exports_path + + wait_for_export_controls + + assert_difference -> { export_job_count }, 1 do + click_on "Export all heartbeats" + assert_text "Your export is being prepared and will be emailed to you" + end + + assert_no_difference -> { export_job_count } do + click_on "Export all heartbeats" + assert_text "Export requests are limited to once every 10 minutes." + end + end + test "export is not available for restricted users" do @user.update!(trust_level: :red) visit my_settings_imports_exports_path @@ -110,4 +126,8 @@ def set_date_input(field_name, value) input.dispatchEvent(new Event("change", { bubbles: true })); JS end + + def wait_for_export_controls + assert_button "Export all heartbeats", wait: 15 + end end