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