pg_reports 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +222 -93
  7. data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
  8. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
  9. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
  10. data/app/views/pg_reports/dashboard/index.html.erb +346 -238
  11. data/app/views/pg_reports/dashboard/show.html.erb +1 -1
  12. data/bin/pg_reports +2 -2
  13. data/config/brakeman.ignore +10 -56
  14. data/config/locales/en.yml +45 -11
  15. data/config/locales/ru.yml +44 -11
  16. data/config/locales/uk.yml +44 -11
  17. data/config/routes.rb +2 -0
  18. data/lib/pg_reports/access_policy.rb +39 -0
  19. data/lib/pg_reports/compatibility.rb +10 -2
  20. data/lib/pg_reports/configuration.rb +33 -2
  21. data/lib/pg_reports/connection/error_translator.rb +21 -0
  22. data/lib/pg_reports/connection/target.rb +43 -10
  23. data/lib/pg_reports/csv_cell.rb +20 -0
  24. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  25. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  26. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  27. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  28. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  29. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  30. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  31. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  32. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  34. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  35. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  36. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  37. data/lib/pg_reports/drop_index_migration.rb +56 -0
  38. data/lib/pg_reports/engine.rb +4 -3
  39. data/lib/pg_reports/executor.rb +46 -9
  40. data/lib/pg_reports/modules/queries.rb +1 -0
  41. data/lib/pg_reports/query_monitor.rb +478 -192
  42. data/lib/pg_reports/rate_limiter.rb +63 -0
  43. data/lib/pg_reports/report.rb +1 -1
  44. data/lib/pg_reports/report_definition.rb +11 -1
  45. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  46. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  47. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  48. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  49. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  50. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  51. data/lib/pg_reports/sql_redactor.rb +32 -0
  52. data/lib/pg_reports/standalone.rb +83 -20
  53. data/lib/pg_reports/version.rb +1 -1
  54. data/lib/pg_reports.rb +7 -2
  55. metadata +18 -9
@@ -4,6 +4,38 @@ module PgReports
4
4
  class DashboardController < ActionController::Base
5
5
  layout "pg_reports/application"
6
6
 
7
+ INDEX_LOOKUP = <<~SQL
8
+ #{Executor::QUERY_TAG}
9
+ SELECT schemaname, tablename, indexname, indexdef
10
+ FROM pg_indexes
11
+ WHERE indexname = $1 AND ($2::text = '' OR schemaname = $2)
12
+ SQL
13
+
14
+ MAX_RESULT_ROWS = 100
15
+ # Pages that draw the database selector, the only thing that shows a size
16
+ DATABASE_SELECTOR_ACTIONS = %w[index show].freeze
17
+ RATE_LIMITED_ACTIONS = %i[
18
+ explain_analyze execute_query run_query migration_preview create_migration
19
+ reset_statistics enable_pg_stat_statements send_to_telegram
20
+ start_query_monitoring load_query_history
21
+ ].freeze
22
+ # PostgreSQL's own limit. A `$99999999` in captured text must not allocate.
23
+ MAX_BIND_PARAMETERS = 65_535
24
+ TYPED_PLACEHOLDER = /\b(interval|timestamptz|timestamp|date|time)\s+\$(\d+)/i
25
+ # What a read-only transaction does not stop: these work outside the database's
26
+ # own data, leave something behind on a pooled connection, or run SQL held in a
27
+ # string, which would put the name of anything else here out of reach of a check
28
+ # that only reads the text. This narrows what a mistake can do; it is not a
29
+ # sandbox.
30
+ ADMIN_FUNCTIONS = /\b(?:
31
+ pg_read_file | pg_read_binary_file | pg_ls_\w+ | pg_stat_file | pg_file_\w+ |
32
+ pg_terminate_backend | pg_cancel_backend | pg_reload_conf | pg_rotate_logfile |
33
+ pg_stat_reset\w* | pg_stat_statements_reset | pg_advisory_\w+ | pg_switch_wal |
34
+ pg_promote | pg_wal_replay_\w+ | pg_create_\w+ | pg_drop_\w+ | pg_logical_\w+ |
35
+ pg_replication_\w+ | lo_import | lo_export | dblink\w* |
36
+ query_to_xml\w* | ts_stat | ts_rewrite
37
+ )\b/x
38
+
7
39
  # CSRF protection. ActionController::Base does NOT enforce this by default;
8
40
  # we opt in explicitly because every state-changing endpoint here
9
41
  # (switch_*, execute_query, create_migration, reset_statistics, telegram
@@ -11,21 +43,23 @@ module PgReports
11
43
  # ships authenticity_token in forms and X-CSRF-Token in XHR.
12
44
  protect_from_forgery with: :exception
13
45
 
14
- before_action :authenticate_dashboard!, if: -> { PgReports.config.dashboard_auth.present? }
46
+ before_action :authenticate_dashboard!
47
+ around_action :within_selected_locale
15
48
  before_action :set_categories
16
49
  before_action :resolve_database_selection
17
50
  around_action :within_selected_database
18
- before_action :block_query_monitor_in_standalone, only: %i[
51
+ before_action :require_query_monitor, only: %i[
19
52
  start_query_monitoring stop_query_monitoring query_monitor_status
20
53
  query_monitor_feed load_query_history download_query_monitor
21
54
  ]
22
- before_action :enforce_rate_limit!, only: %i[
23
- explain_analyze execute_query run_query create_migration
24
- ]
55
+ before_action :enforce_rate_limit!, only: RATE_LIMITED_ACTIONS
56
+ before_action :require_migration_creation, only: %i[migration_preview create_migration]
25
57
 
26
58
  helper_method :category_disabled_reason, :category_disabled?
59
+ helper_method :dashboard_locales, :selected_locale
27
60
 
28
61
  def index
62
+ Compatibility.check_postgresql_once!
29
63
  @pg_stat_status = pg_stat_status
30
64
  @current_database = PgReports.system.current_database
31
65
  end
@@ -48,6 +82,21 @@ module PgReports
48
82
  # POST /switch_target
49
83
  # Persists the chosen target in session, clears the database choice (each
50
84
  # target has its own list of databases), and redirects back.
85
+ # POST /switch_locale
86
+ # Persists the chosen language in session; #within_selected_locale applies it
87
+ # to this and every later request.
88
+ def switch_locale
89
+ requested = params[:locale].to_s
90
+
91
+ if requested.empty?
92
+ session.delete(:pg_reports_locale)
93
+ elsif dashboard_locales.any? { |locale| locale.to_s == requested }
94
+ session[:pg_reports_locale] = requested
95
+ end
96
+
97
+ redirect_back fallback_location: root_path
98
+ end
99
+
51
100
  def switch_target
52
101
  requested = params[:target].to_s
53
102
 
@@ -137,11 +186,8 @@ module PgReports
137
186
 
138
187
  # Load filter parameters from YAML
139
188
  @report_filters = load_report_filters(@category, @report_key)
140
-
141
- @report = execute_report(@category, @report_key)
142
189
  rescue => e
143
190
  @error = report_error_message(e)
144
- @report = nil
145
191
  end
146
192
 
147
193
  def run
@@ -270,11 +316,9 @@ module PgReports
270
316
  return
271
317
  end
272
318
 
273
- # Substitute parameters if provided
274
- final_query = substitute_params(query, query_params)
319
+ binds = query_binds(query, query_params)
275
320
 
276
- # Check for remaining unsubstituted parameters
277
- if final_query.match?(/\$\d+/)
321
+ if binds.nil?
278
322
  render json: {
279
323
  success: false,
280
324
  error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
@@ -283,9 +327,9 @@ module PgReports
283
327
  end
284
328
 
285
329
  explain_output = nil
286
- with_statement_timeout do
287
- result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
288
- explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
330
+ in_read_only_transaction do
331
+ rows = select_rows("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{cast_typed_placeholders(query)}", binds)
332
+ explain_output = rows.map { |r| r["QUERY PLAN"] }.join("\n")
289
333
  end
290
334
 
291
335
  # Analyze the EXPLAIN output
@@ -300,7 +344,7 @@ module PgReports
300
344
  problems: analysis[:problems],
301
345
  summary: analysis[:summary]
302
346
  }
303
- rescue ActiveRecord::QueryCanceled
347
+ rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
304
348
  render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
305
349
  rescue => e
306
350
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -337,11 +381,9 @@ module PgReports
337
381
  return
338
382
  end
339
383
 
340
- # Substitute parameters if provided
341
- final_query = substitute_params(query, query_params)
384
+ binds = query_binds(query, query_params)
342
385
 
343
- # Check for remaining unsubstituted parameters
344
- if final_query.match?(/\$\d+/)
386
+ if binds.nil?
345
387
  render json: {
346
388
  success: false,
347
389
  error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
@@ -349,42 +391,8 @@ module PgReports
349
391
  return
350
392
  end
351
393
 
352
- # Execute with LIMIT to prevent huge result sets
353
- limited_query = add_limit_if_missing(final_query, 100)
354
-
355
- rows = columns = nil
356
- total_count = 0
357
- truncated = false
358
- execution_time = nil
359
-
360
- with_statement_timeout do
361
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
362
- result = ActiveRecord::Base.connection.execute(limited_query)
363
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
364
- execution_time = ((end_time - start_time) * 1000).round(2)
365
-
366
- rows = result.to_a
367
- columns = rows.first&.keys || []
368
- total_count = rows.size
369
-
370
- # Check if we need to get total count
371
- if rows.size >= 100
372
- count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
373
- total_count = count_result.first["count"].to_i
374
- truncated = total_count > 100
375
- end
376
- end
377
-
378
- render json: {
379
- success: true,
380
- columns: columns,
381
- rows: rows,
382
- count: rows.size,
383
- total_count: total_count,
384
- truncated: truncated,
385
- execution_time: execution_time
386
- }
387
- rescue ActiveRecord::QueryCanceled
394
+ render json: run_limited_select(cast_typed_placeholders(query), binds)
395
+ rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
388
396
  render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
389
397
  rescue => e
390
398
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -419,78 +427,41 @@ module PgReports
419
427
  return
420
428
  end
421
429
 
422
- limited_query = add_limit_if_missing(raw_query, 100)
423
-
424
- rows = columns = nil
425
- total_count = 0
426
- truncated = false
427
- execution_time = nil
428
-
429
- with_statement_timeout do
430
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
431
- result = ActiveRecord::Base.connection.execute(limited_query)
432
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
433
- execution_time = ((end_time - start_time) * 1000).round(2)
434
-
435
- rows = result.to_a
436
- columns = rows.first&.keys || []
437
- total_count = rows.size
438
-
439
- if rows.size >= 100
440
- count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{raw_query}) AS count_query")
441
- total_count = count_result.first["count"].to_i
442
- truncated = total_count > 100
443
- end
444
- end
445
-
446
- render json: {
447
- success: true,
448
- columns: columns,
449
- rows: rows,
450
- count: rows.size,
451
- total_count: total_count,
452
- truncated: truncated,
453
- execution_time: execution_time
454
- }
455
- rescue ActiveRecord::QueryCanceled
430
+ render json: run_limited_select(raw_query)
431
+ rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
456
432
  render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
457
433
  rescue => e
458
434
  render json: {success: false, error: e.message}, status: :unprocessable_entity
459
435
  end
460
436
 
461
- def create_migration
462
- unless PgReports.config.allow_migration_creation
463
- render json: {
464
- success: false,
465
- error: I18n.t("pg_reports.ui.errors.migration_dev_only")
466
- }, status: :forbidden
467
- return
468
- end
469
-
470
- file_name = params[:file_name]
471
- code = params[:code]
437
+ # POST /migration_preview
438
+ def migration_preview
439
+ migration = drop_index_migration or return
472
440
 
473
- if file_name.blank? || code.blank?
474
- render json: {success: false, error: I18n.t("pg_reports.ui.errors.filename_code_required")}, status: :unprocessable_entity
475
- return
476
- end
441
+ render json: {success: true, code: migration.code, file_name: migration.file_name(migration_timestamp)}
442
+ rescue => e
443
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
444
+ end
477
445
 
478
- # Sanitize file name
479
- safe_file_name = file_name.gsub(/[^a-z0-9_.]/, "")
480
- unless safe_file_name.match?(/\A\d{14}_\w+\.rb\z/)
481
- render json: {success: false, error: I18n.t("pg_reports.ui.errors.invalid_filename_format")}, status: :unprocessable_entity
482
- return
483
- end
446
+ # POST /create_migration
447
+ # Takes the index to drop, never code: the file is built here from what the
448
+ # database reports, because `rails db:migrate` will execute it.
449
+ def create_migration
450
+ migration = drop_index_migration or return
484
451
 
485
- # Find migrations directory
486
452
  migrations_path = Rails.root.join("db", "migrate")
487
453
  unless migrations_path.exist?
488
454
  render json: {success: false, error: I18n.t("pg_reports.ui.errors.migrations_dir_not_found")}, status: :unprocessable_entity
489
455
  return
490
456
  end
491
457
 
492
- file_path = migrations_path.join(safe_file_name)
493
- File.write(file_path, code)
458
+ if migrations_path.glob("*_#{migration.name}.rb").any?
459
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_exists")}, status: :unprocessable_entity
460
+ return
461
+ end
462
+
463
+ file_path = migrations_path.join(migration.file_name(migration_timestamp))
464
+ File.write(file_path, migration.code, mode: "wx")
494
465
 
495
466
  render json: {success: true, file_path: file_path.to_s, message: I18n.t("pg_reports.ui.success.migration_created")}
496
467
  rescue => e
@@ -498,11 +469,7 @@ module PgReports
498
469
  end
499
470
 
500
471
  def start_query_monitoring
501
- monitor = PgReports::QueryMonitor.instance
502
- Rails.logger.info("PgReports: start_query_monitoring called. Instance: #{monitor.object_id}")
503
-
504
- result = monitor.start
505
- Rails.logger.info("PgReports: start result: #{result.inspect}")
472
+ result = PgReports::QueryMonitor.instance.start(source: params[:source])
506
473
 
507
474
  if result[:success]
508
475
  render json: result
@@ -510,7 +477,6 @@ module PgReports
510
477
  render json: result, status: :unprocessable_entity
511
478
  end
512
479
  rescue => e
513
- Rails.logger.error("PgReports: start_query_monitoring error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
514
480
  render json: {success: false, error: e.message}, status: :unprocessable_entity
515
481
  end
516
482
 
@@ -529,39 +495,29 @@ module PgReports
529
495
  end
530
496
 
531
497
  def query_monitor_status
532
- monitor = PgReports::QueryMonitor.instance
533
- status = monitor.status
534
-
535
- render json: {
536
- success: true,
537
- enabled: status[:enabled],
538
- session_id: status[:session_id],
539
- query_count: status[:query_count],
540
- history_available: status[:history_available]
541
- }
498
+ render json: PgReports::QueryMonitor.instance.status.merge(success: true)
542
499
  rescue => e
543
500
  render json: {success: false, error: e.message}, status: :unprocessable_entity
544
501
  end
545
502
 
503
+ # A session that has ended is an ordinary state here, not an error: it can be
504
+ # stopped from another tab or run out its time, and the viewer keeps the rows.
546
505
  def query_monitor_feed
547
506
  monitor = PgReports::QueryMonitor.instance
548
507
 
549
- unless monitor.enabled
550
- Rails.logger.warn("PgReports: query_monitor_feed called but monitoring not active. Instance: #{monitor.object_id}, enabled: #{monitor.enabled}, session_id: #{monitor.session_id}")
551
- render json: {success: false, message: "Monitoring not active"}
552
- return
553
- end
554
-
555
- limit = params[:limit]&.to_i || 50
556
- session_id = params[:session_id]
557
-
558
- queries = monitor.queries(limit: limit, session_id: session_id)
508
+ since = params[:since].to_i
509
+ queries = monitor.queries(
510
+ limit: params[:limit]&.to_i || 50,
511
+ session_id: params[:session_id],
512
+ since: since
513
+ )
559
514
 
560
- render json: {
515
+ render json: monitor.status.merge(
561
516
  success: true,
562
517
  queries: queries,
518
+ last_seq: queries.last ? queries.last[:seq] : since,
563
519
  timestamp: Time.current.to_i
564
- }
520
+ )
565
521
  rescue => e
566
522
  render json: {success: false, error: e.message}, status: :unprocessable_entity
567
523
  end
@@ -604,7 +560,7 @@ module PgReports
604
560
  type: "text/csv; charset=utf-8",
605
561
  disposition: "attachment"
606
562
  when "json"
607
- send_data queries.to_json,
563
+ send_data JSON.generate(queries),
608
564
  filename: "#{filename}.json",
609
565
  type: "application/json; charset=utf-8",
610
566
  disposition: "attachment"
@@ -622,13 +578,48 @@ module PgReports
622
578
  private
623
579
 
624
580
  def authenticate_dashboard!
625
- instance_exec(&PgReports.config.dashboard_auth)
581
+ auth = PgReports.config.dashboard_auth
582
+ return if auth == AccessPolicy::HOST_MANAGED
583
+ return instance_exec(&auth) if auth.present?
584
+ return if AccessPolicy.open_access_allowed?(request)
585
+
586
+ render plain: AccessPolicy::DASHBOARD_DENIED, status: :forbidden
626
587
  end
627
588
 
628
589
  def set_categories
629
590
  @categories = Dashboard::ReportsRegistry.all
630
591
  end
631
592
 
593
+ # I18n.locale is global to the process, so assigning it outright would leak
594
+ # the dashboard's language into the host application's own rendering (and
595
+ # into whatever request runs next on this thread). with_locale restores the
596
+ # previous value once the action and its view are done.
597
+ def within_selected_locale(&block)
598
+ I18n.with_locale(selected_locale, &block)
599
+ end
600
+
601
+ def selected_locale
602
+ @selected_locale ||= begin
603
+ stored = session[:pg_reports_locale].to_s
604
+ match = dashboard_locales.find { |locale| locale.to_s == stored }
605
+ match || I18n.locale
606
+ end
607
+ end
608
+
609
+ # Only locales that actually carry pg_reports translations. A host app can
610
+ # have dozens of its own locales while translating none of this dashboard,
611
+ # and offering those would just switch the UI to fallback English. Picked by
612
+ # probing one key rather than by listing the shipped files, so a host app
613
+ # that supplies its own pg_reports translations is offered too.
614
+ def dashboard_locales
615
+ @dashboard_locales ||= begin
616
+ translated = I18n.available_locales.select do |locale|
617
+ I18n.exists?("pg_reports.ui.branding.title", locale)
618
+ end
619
+ translated.presence || [I18n.default_locale]
620
+ end
621
+ end
622
+
632
623
  # Resolves which target/database every action should run against, based on:
633
624
  # 1. session[:pg_reports_target] (set by #switch_target)
634
625
  # 2. session[:pg_reports_database] (set by #switch_database)
@@ -659,7 +650,10 @@ module PgReports
659
650
 
660
651
  begin
661
652
  registry.with_context(target: @selected_target) do
662
- @available_databases = registry.fetch(@selected_target).list_databases(current: @target_default_database)
653
+ @available_databases = registry.fetch(@selected_target).list_databases(
654
+ current: @target_default_database,
655
+ sizes: DATABASE_SELECTOR_ACTIONS.include?(action_name)
656
+ )
663
657
  end
664
658
  rescue => e
665
659
  @database_error = PgReports::Connection::ErrorTranslator.translate(e)
@@ -742,7 +736,11 @@ module PgReports
742
736
  else raise ArgumentError, "Unknown category: #{category}"
743
737
  end
744
738
 
745
- unless mod.respond_to?(report_key)
739
+ # The report name comes from the URL and the dispatch below is public_send,
740
+ # so the registry decides what can run. respond_to? would also admit
741
+ # reset_statistics!, enable_pg_stat_statements! and Module#freeze, reachable
742
+ # by a plain GET that CSRF protection does not cover.
743
+ unless Dashboard::ReportsRegistry.find(category, report_key)
746
744
  raise ArgumentError, "Unknown report: #{report_key}"
747
745
  end
748
746
 
@@ -828,100 +826,140 @@ module PgReports
828
826
  end
829
827
  end
830
828
 
831
- # SQL Query Monitor taps ActiveSupport::Notifications in the host
832
- # application's process. In standalone mode there is no host app — pg_reports
833
- # is the only process running, so there's nothing meaningful to observe.
834
829
  # Blocks the API even if a client calls it directly (the UI panel is also
835
- # hidden in standalone, see dashboard/index view).
836
- def block_query_monitor_in_standalone
837
- return unless PgReports.config.standalone
830
+ # hidden when the monitor is unavailable, see dashboard/index view).
831
+ def require_query_monitor
832
+ return if PgReports.config.query_monitor_available?
838
833
 
839
- render json: {
840
- success: false,
841
- error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
842
- }, status: :forbidden
834
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_monitor_disabled")}, status: :forbidden
843
835
  end
844
836
 
845
- # Soft per-IP throttle for the dashboard's privileged raw-query and
846
- # migration endpoints. This is not a hardened distributed rate limiter —
847
- # just a best-effort guard against a single client hammering these
848
- # expensive/privileged actions, backed by Rails.cache (works with or
849
- # without a shared cache backend across processes). Fails open if the
850
- # cache is unavailable, consistent with the rest of the dashboard (see
851
- # #resolve_database_selection, #retrieve_query_by_hash).
837
+ # Per-IP throttle for what is expensive, privileged or leaves the building. See
838
+ # RateLimiter for what it can and cannot promise.
852
839
  def enforce_rate_limit!
853
840
  limit = PgReports.config.raw_query_rate_limit
854
841
  return if limit.nil?
855
842
 
856
- window = PgReports.config.raw_query_rate_limit_window_seconds
857
- key = "pg_reports:rate_limit:#{request.remote_ip}:#{params[:action]}"
858
-
859
- count = begin
860
- current = (Rails.cache.read(key) || 0) + 1
861
- Rails.cache.write(key, current, expires_in: window)
862
- current
863
- rescue => e
864
- Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
865
- return
866
- end
867
-
868
- return if count <= limit
843
+ retry_after = RateLimiter.instance.retry_after(
844
+ "#{request.remote_ip}:#{params[:action]}",
845
+ limit: limit,
846
+ window: PgReports.config.raw_query_rate_limit_window_seconds
847
+ )
848
+ return unless retry_after
869
849
 
850
+ response.set_header("Retry-After", retry_after.to_s)
870
851
  render json: {
871
852
  success: false,
872
853
  error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
873
854
  }, status: :too_many_requests
874
855
  end
875
856
 
876
- def on_primary_default_database?
877
- return true if @target_default_database.nil?
857
+ def require_migration_creation
858
+ return if PgReports.config.allow_migration_creation
878
859
 
879
- @selected_target == PgReports.connection_registry.default_name &&
880
- @selected_database == @target_default_database
860
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_dev_only")}, status: :forbidden
881
861
  end
882
862
 
883
- def substitute_params(query, params_hash)
884
- result = query.dup
863
+ # The migration for the index named in the request, or nil once a response has
864
+ # been rendered. A migration runs on the application's own database, so one
865
+ # built from an index elsewhere would drop nothing, or the wrong index.
866
+ def drop_index_migration
867
+ unless on_primary_default_database?
868
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_primary_only")}, status: :unprocessable_entity
869
+ return
870
+ end
885
871
 
886
- # Sort by param number descending to replace $10 before $1
887
- params_hash.keys.map(&:to_i).sort.reverse_each do |num|
888
- value = params_hash[num.to_s] || params_hash[num]
889
- next if value.nil? || value.to_s.empty?
872
+ rows = select_rows(INDEX_LOOKUP, [params[:index_name].to_s, params[:schema].to_s])
890
873
 
891
- # Quote the value appropriately
892
- quoted_value = quote_param_value(value)
893
- result = result.gsub("$#{num}", quoted_value)
874
+ # More than one means the same name in two schemas, and no way to tell which
875
+ unless rows.one?
876
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.index_not_found")}, status: :unprocessable_entity
877
+ return
894
878
  end
895
879
 
896
- result
880
+ DropIndexMigration.new(rows.first, version: ActiveRecord::Migration.current_version)
897
881
  end
898
882
 
899
- def quote_param_value(value)
900
- str = value.to_s
883
+ def migration_timestamp
884
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
885
+ end
901
886
 
902
- # Check if it looks like NULL
903
- if str.downcase == "null"
904
- "NULL"
905
- # Check if it looks like a boolean
906
- elsif str.downcase.in?(["true", "false"])
907
- str.downcase
908
- else
909
- # Quote as string by default - PostgreSQL will handle type casting
910
- # This ensures compatibility with both text and numeric columns
911
- "'#{str.gsub("'", "''")}'"
887
+ def on_primary_default_database?
888
+ return true if @target_default_database.nil?
889
+
890
+ @selected_target == PgReports.connection_registry.default_name &&
891
+ @selected_database == @target_default_database
892
+ end
893
+
894
+ # Values for the query's $n placeholders, in order, or nil if one is missing.
895
+ # They travel as bind parameters and are never written into the SQL: the text
896
+ # of a captured query can hold a `$1` anywhere, including inside a string
897
+ # literal, where a substituted and quoted value would end that literal.
898
+ def query_binds(query, params_hash)
899
+ numbers = query.scan(/\$(\d+)/).flatten.map(&:to_i).uniq
900
+ return [] if numbers.empty?
901
+ return nil if numbers.max > MAX_BIND_PARAMETERS
902
+
903
+ binds = Array.new(numbers.max)
904
+ numbers.each do |number|
905
+ value = params_hash[number.to_s].to_s
906
+ return nil if value.empty?
907
+
908
+ binds[number - 1] = (value.downcase == "null") ? nil : value
912
909
  end
910
+ binds
913
911
  end
914
912
 
915
- def add_limit_if_missing(query, limit)
916
- # Simple check - if query doesn't end with LIMIT clause, add one
917
- normalized = query.strip.gsub(/\s+/, " ").downcase
913
+ # pg_stat_statements reduces `interval '1 day'` to `interval $1`, which is only
914
+ # valid for a literal. The cast form means the same and accepts a parameter.
915
+ def cast_typed_placeholders(query)
916
+ query.gsub(TYPED_PLACEHOLDER) { "$#{$2}::#{$1}" }
917
+ end
918
918
 
919
- if normalized.match?(/\blimit\s+\d+\s*(?:offset\s+\d+\s*)?\z/i)
920
- # Already has LIMIT
921
- query
922
- else
923
- "#{query} LIMIT #{limit}"
919
+ # The extended protocol refuses more than one statement, which is what keeps a
920
+ # query to the single statement that was validated. connection.execute uses
921
+ # the simple protocol, where text after a `;` would simply run.
922
+ def select_rows(sql, binds = [])
923
+ PgReports.config.connection.raw_connection.exec_params(sql, binds).to_a
924
+ end
925
+
926
+ def run_limited_select(query, binds = [])
927
+ rows = nil
928
+ total_count = 0
929
+ truncated = false
930
+ execution_time = nil
931
+
932
+ in_read_only_transaction do
933
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
934
+ rows = select_rows("SELECT * FROM (#{wrapped(query)}) AS pg_reports_limited LIMIT #{MAX_RESULT_ROWS}", binds)
935
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
936
+ execution_time = ((end_time - start_time) * 1000).round(2)
937
+
938
+ total_count = rows.size
939
+
940
+ if rows.size >= MAX_RESULT_ROWS
941
+ count = select_rows("SELECT COUNT(*) FROM (#{wrapped(query)}) AS count_query", binds)
942
+ total_count = count.first["count"].to_i
943
+ truncated = total_count > MAX_RESULT_ROWS
944
+ end
924
945
  end
946
+
947
+ {
948
+ success: true,
949
+ columns: rows.first&.keys || [],
950
+ rows: rows,
951
+ count: rows.size,
952
+ total_count: total_count,
953
+ truncated: truncated,
954
+ execution_time: execution_time
955
+ }
956
+ end
957
+
958
+ # On lines of its own, so that a query ending in a -- comment cannot comment out
959
+ # the closing parenthesis. As a subquery, the row limit holds whatever LIMIT the
960
+ # query itself asks for, and nothing has to be read out of its text.
961
+ def wrapped(query)
962
+ "\n#{query}\n"
925
963
  end
926
964
 
927
965
  # Generate a secure hash for a query and store it in cache
@@ -989,18 +1027,36 @@ module PgReports
989
1027
  raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
990
1028
  end
991
1029
  end
1030
+
1031
+ if (function = normalized[ADMIN_FUNCTIONS])
1032
+ raise SecurityError, "Administrative function not allowed: #{function}"
1033
+ end
1034
+
1035
+ # U&"pg\005fls_dir" names a function without ever spelling it
1036
+ if normalized.include?('u&"')
1037
+ raise SecurityError, "Unicode-escaped identifiers are not allowed"
1038
+ end
992
1039
  end
993
1040
 
994
- # Bounds how long a single raw-query execution (Execute Query / EXPLAIN
995
- # ANALYZE / SQL Console) can run, so a runaway query can't hang the
996
- # connection indefinitely. SET LOCAL only takes effect inside a
997
- # transaction and reverts automatically at its end (commit or rollback) —
998
- # safe here since every caller only ever runs SELECTs.
999
- def with_statement_timeout
1000
- ActiveRecord::Base.transaction do
1041
+ # The envelope of every raw query (Execute Query / EXPLAIN ANALYZE / SQL
1042
+ # Console), on the connection for the selected target and database, which is
1043
+ # the one #select_rows uses: settings only bind the query on that connection.
1044
+ #
1045
+ # READ ONLY makes PostgreSQL refuse writes that text checks cannot see, such as
1046
+ # SELECT INTO or a volatile function. SET LOCAL bounds the run time.
1047
+ #
1048
+ # It always ends in ROLLBACK. Nothing here has anything to commit, and
1049
+ # set_config(..., false) from a SELECT survives a COMMIT: the connection goes
1050
+ # back to the pool, possibly the host application's, with that setting on it.
1051
+ def in_read_only_transaction
1052
+ connection = PgReports.config.connection
1053
+
1054
+ connection.transaction do
1055
+ connection.execute("SET TRANSACTION READ ONLY")
1001
1056
  timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
1002
- ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
1057
+ connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
1003
1058
  yield
1059
+ raise ActiveRecord::Rollback
1004
1060
  end
1005
1061
  end
1006
1062
 
@@ -1021,9 +1077,9 @@ module PgReports
1021
1077
  csv << [
1022
1078
  query[:timestamp],
1023
1079
  query[:duration_ms],
1024
- query[:name],
1025
- query[:sql],
1026
- query.dig(:source_location, :file),
1080
+ CsvCell.safe(query[:name]),
1081
+ CsvCell.safe(query[:sql]),
1082
+ CsvCell.safe(query.dig(:source_location, :file)),
1027
1083
  query.dig(:source_location, :line)
1028
1084
  ]
1029
1085
  end