webhookdb 1.2.2 → 1.3.1

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/admin-dist/assets/index-6aebf805.js +264 -0
  3. data/admin-dist/favicon.ico +0 -0
  4. data/admin-dist/index.html +130 -0
  5. data/admin-dist/manifest.json +15 -0
  6. data/data/messages/replicators/url-recorder.liquid +20 -0
  7. data/data/messages/templates/errors/signalwire_send_sms.email.liquid +31 -0
  8. data/data/messages/web/install-customer-login.liquid +6 -5
  9. data/data/messages/web/install-error.liquid +1 -1
  10. data/data/messages/web/install-forbidden.liquid +25 -0
  11. data/data/messages/web/install-org-chooser.liquid +40 -0
  12. data/data/messages/web/install-success.liquid +2 -1
  13. data/data/messages/web/install.liquid +2 -1
  14. data/data/messages/web/partials/head.liquid +2 -0
  15. data/data/messages/web/styles.liquid +24 -0
  16. data/db/migrations/041_views.rb +20 -0
  17. data/db/migrations/042_sint_lock.rb +10 -0
  18. data/db/migrations/043_text_search.rb +28 -0
  19. data/db/migrations/044_oauth_session_token_cache.rb +21 -0
  20. data/integration/auth_spec.rb +2 -2
  21. data/lib/sequel/plugins/text_searchable.rb +165 -0
  22. data/lib/sequel/text_searchable.rb +42 -0
  23. data/lib/webhookdb/admin_api/auth.rb +24 -3
  24. data/lib/webhookdb/admin_api/data_provider.rb +196 -0
  25. data/lib/webhookdb/admin_api/entities.rb +143 -28
  26. data/lib/webhookdb/admin_api.rb +0 -2
  27. data/lib/webhookdb/api/auth.rb +5 -6
  28. data/lib/webhookdb/api/db.rb +31 -6
  29. data/lib/webhookdb/api/entities.rb +7 -1
  30. data/lib/webhookdb/api/helpers.rb +6 -25
  31. data/lib/webhookdb/api/install.rb +204 -79
  32. data/lib/webhookdb/api/organizations.rb +14 -12
  33. data/lib/webhookdb/api/saved_queries.rb +9 -3
  34. data/lib/webhookdb/api/saved_views.rb +99 -0
  35. data/lib/webhookdb/api/service_integrations.rb +15 -9
  36. data/lib/webhookdb/api/subscriptions.rb +3 -1
  37. data/lib/webhookdb/api/sync_targets.rb +9 -7
  38. data/lib/webhookdb/api/system.rb +1 -0
  39. data/lib/webhookdb/api/webhook_subscriptions.rb +3 -1
  40. data/lib/webhookdb/apps.rb +30 -7
  41. data/lib/webhookdb/async/audit_logger.rb +2 -0
  42. data/lib/webhookdb/async.rb +5 -0
  43. data/lib/webhookdb/backfill_job/service_integration_lock.rb +22 -0
  44. data/lib/webhookdb/backfill_job.rb +9 -0
  45. data/lib/webhookdb/customer.rb +5 -0
  46. data/lib/webhookdb/database_document.rb +1 -1
  47. data/lib/webhookdb/db_adapter/default_sql.rb +1 -1
  48. data/lib/webhookdb/db_adapter.rb +20 -4
  49. data/lib/webhookdb/fixtures/message_bodies.rb +34 -0
  50. data/lib/webhookdb/fixtures/organizations.rb +5 -0
  51. data/lib/webhookdb/fixtures/roles.rb +14 -0
  52. data/lib/webhookdb/fixtures/saved_views.rb +25 -0
  53. data/lib/webhookdb/fixtures/webhook_subscription_deliveries.rb +18 -0
  54. data/lib/webhookdb/http.rb +8 -2
  55. data/lib/webhookdb/icalendar.rb +3 -0
  56. data/lib/webhookdb/idempotency.rb +69 -22
  57. data/lib/webhookdb/increase.rb +69 -21
  58. data/lib/webhookdb/intercom.rb +10 -3
  59. data/lib/webhookdb/jobs/backfill.rb +3 -1
  60. data/lib/webhookdb/jobs/emailer.rb +0 -1
  61. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +19 -0
  62. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +1 -1
  63. data/lib/webhookdb/jobs/icalendar_sync.rb +1 -1
  64. data/lib/webhookdb/jobs/increase_event_handler.rb +20 -0
  65. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -1
  66. data/lib/webhookdb/jobs/sync_target_run_sync.rb +3 -1
  67. data/lib/webhookdb/message/body.rb +6 -4
  68. data/lib/webhookdb/message/delivery.rb +2 -0
  69. data/lib/webhookdb/messages/error_icalendar_fetch.rb +1 -2
  70. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +48 -0
  71. data/lib/webhookdb/oauth/fake_provider.rb +44 -0
  72. data/lib/webhookdb/oauth/front_provider.rb +1 -2
  73. data/lib/webhookdb/oauth/increase_provider.rb +80 -0
  74. data/lib/webhookdb/oauth/intercom_provider.rb +3 -11
  75. data/lib/webhookdb/oauth/session.rb +20 -0
  76. data/lib/webhookdb/oauth.rb +7 -21
  77. data/lib/webhookdb/organization/alerting.rb +2 -0
  78. data/lib/webhookdb/organization/database_migration.rb +3 -0
  79. data/lib/webhookdb/organization.rb +37 -6
  80. data/lib/webhookdb/organization_membership.rb +14 -7
  81. data/lib/webhookdb/postgres.rb +2 -0
  82. data/lib/webhookdb/replicator/base.rb +1 -0
  83. data/lib/webhookdb/replicator/docgen.rb +9 -1
  84. data/lib/webhookdb/replicator/fake.rb +2 -3
  85. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +49 -14
  86. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +97 -17
  87. data/lib/webhookdb/replicator/icalendar_event_v1.rb +104 -2
  88. data/lib/webhookdb/replicator/increase_account_number_v1.rb +6 -43
  89. data/lib/webhookdb/replicator/increase_account_transfer_v1.rb +7 -24
  90. data/lib/webhookdb/replicator/increase_account_v1.rb +7 -31
  91. data/lib/webhookdb/replicator/increase_ach_transfer_v1.rb +5 -43
  92. data/lib/webhookdb/replicator/increase_app_v1.rb +78 -0
  93. data/lib/webhookdb/replicator/increase_check_transfer_v1.rb +23 -29
  94. data/lib/webhookdb/replicator/increase_event_v1.rb +41 -0
  95. data/lib/webhookdb/replicator/increase_limit_v1.rb +9 -34
  96. data/lib/webhookdb/replicator/increase_transaction_v1.rb +5 -30
  97. data/lib/webhookdb/replicator/increase_v1_mixin.rb +58 -78
  98. data/lib/webhookdb/replicator/increase_wire_transfer_v1.rb +5 -24
  99. data/lib/webhookdb/replicator/intercom_contact_v1.rb +51 -4
  100. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +42 -6
  101. data/lib/webhookdb/replicator/intercom_marketplace_root_v1.rb +2 -13
  102. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +20 -16
  103. data/lib/webhookdb/replicator/oauth_refresh_access_token_mixin.rb +1 -1
  104. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  105. data/lib/webhookdb/replicator/transistor_episode_v1.rb +17 -0
  106. data/lib/webhookdb/replicator/url_recorder_v1.rb +137 -0
  107. data/lib/webhookdb/replicator/webhook_request.rb +4 -0
  108. data/lib/webhookdb/replicator.rb +8 -0
  109. data/lib/webhookdb/role.rb +5 -2
  110. data/lib/webhookdb/saved_query.rb +23 -0
  111. data/lib/webhookdb/saved_view.rb +73 -0
  112. data/lib/webhookdb/sentry.rb +2 -0
  113. data/lib/webhookdb/service/entities.rb +0 -4
  114. data/lib/webhookdb/service/helpers.rb +5 -0
  115. data/lib/webhookdb/service/middleware.rb +9 -0
  116. data/lib/webhookdb/service/types.rb +10 -8
  117. data/lib/webhookdb/service/validators.rb +1 -2
  118. data/lib/webhookdb/service/view_api.rb +1 -1
  119. data/lib/webhookdb/service_integration.rb +17 -15
  120. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +8 -8
  121. data/lib/webhookdb/spec_helpers/whdb.rb +3 -2
  122. data/lib/webhookdb/subscription.rb +2 -0
  123. data/lib/webhookdb/sync_target.rb +10 -2
  124. data/lib/webhookdb/tasks/message.rb +3 -1
  125. data/lib/webhookdb/version.rb +1 -1
  126. data/lib/webhookdb/webhook_subscription/delivery.rb +2 -0
  127. data/lib/webhookdb/webhook_subscription.rb +2 -0
  128. metadata +57 -9
  129. data/lib/webhookdb/admin_api/customers.rb +0 -63
  130. data/lib/webhookdb/admin_api/message_deliveries.rb +0 -61
  131. data/lib/webhookdb/admin_api/roles.rb +0 -15
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Webhookdb::Replicator::UrlRecorderV1 < Webhookdb::Replicator::Base
4
+ include Appydays::Loggable
5
+
6
+ # @return [Webhookdb::Replicator::Descriptor]
7
+ def self.descriptor
8
+ return Webhookdb::Replicator::Descriptor.new(
9
+ name: "url_recorder_v1",
10
+ ctor: ->(sint) { self.new(sint) },
11
+ feature_roles: [],
12
+ resource_name_singular: "URL Recorder",
13
+ supports_webhooks: true,
14
+ supports_backfill: false,
15
+ description: "Record any visit to the webhook URL for later inspection. " \
16
+ "Useful for recording scans, like visiting QR Code. After the webhook, " \
17
+ "visitors can be redirected, or shown a Markdown page.",
18
+ )
19
+ end
20
+
21
+ def _remote_key_column = Webhookdb::Replicator::Column.new(:unique_id, BIGINT)
22
+
23
+ def requires_sequence? = true
24
+
25
+ def _denormalized_columns
26
+ col = Webhookdb::Replicator::Column
27
+ return [
28
+ col.new(:inserted_at, TIMESTAMP, index: true),
29
+ col.new(:request_method, TEXT),
30
+ col.new(:path, TEXT),
31
+ col.new(:full_url, TEXT),
32
+ col.new(:user_agent, TEXT),
33
+ col.new(:ip, TEXT),
34
+ col.new(:content_type, TEXT),
35
+ col.new(:parsed_query, OBJECT),
36
+ col.new(:parsed_body, OBJECT),
37
+ col.new(:raw_body, TEXT),
38
+ ]
39
+ end
40
+
41
+ def _timestamp_column_name = :inserted_at
42
+ def _resource_and_event(_request) = [{}, nil]
43
+
44
+ def _update_where_expr = self.qualified_table_sequel_identifier[:inserted_at] < Sequel[:excluded][:inserted_at]
45
+
46
+ def _prepare_for_insert(_resource, event, request, enrichment)
47
+ # rr = Rack::Request.new
48
+ rr = request.rack_request
49
+ raise Webhookdb::InvalidPrecondition, "#{request} must have rack_request set" if rr.nil?
50
+ r = {
51
+ "unique_id" => self.service_integration.sequence_nextval,
52
+ "inserted_at" => Time.now,
53
+ "request_method" => rr.request_method,
54
+ "path" => rr.path,
55
+ "parsed_query" => rr.GET,
56
+ "raw_query" => rr.query_string,
57
+ "full_url" => rr.url,
58
+ "user_agent" => rr.user_agent,
59
+ "ip" => rr.ip,
60
+ "content_type" => rr.content_type,
61
+ "raw_body" => nil,
62
+ "parsed_body" => nil,
63
+ }
64
+ if !request.body.is_a?(String)
65
+ # If we were able to parse the request body (usually means it's JSON), store it.
66
+ r["parsed_body"] = request.body
67
+ elsif rr.POST.present?
68
+ # If Rack was able to parse the request body (usually means it's form encoded), store it.
69
+ r["parsed_body"] = rr.POST
70
+ else
71
+ # Store the raw body if nothing can parse it.
72
+ r["raw_body"] = request.body
73
+ end
74
+ return super(r, event, request, enrichment)
75
+ end
76
+
77
+ def _resource_to_data(*) = {}
78
+
79
+ def _webhook_response(_request) = self.redirect? ? self._redirect_response : self._page_response
80
+
81
+ def process_webhooks_synchronously? = true
82
+
83
+ def synchronous_processing_response_body(*)
84
+ resp = self.redirect? ? self._redirect_response : self._page_response
85
+ return resp.body
86
+ end
87
+
88
+ def redirect? = self.service_integration.api_url =~ %r{^https?://}
89
+
90
+ def _redirect_response
91
+ headers = {"Location" => self.service_integration.api_url, "Content-Type" => "text/plain"}
92
+ return Webhookdb::WebhookResponse.new(status: 302, headers:, body: "")
93
+ end
94
+
95
+ def _page_response
96
+ headers = {"Content-Type" => "text/html; charset=UTF-8"}
97
+ content = self.service_integration.api_url
98
+ content_is_doc = content.start_with?("<!DOCTYPE") || content.starts_with?("<html")
99
+ body = if content_is_doc
100
+ content
101
+ else
102
+ tmpl_file = File.open(Webhookdb::DATA_DIR + "messages/replicators/url-recorder.liquid")
103
+ liquid_tmpl = Liquid::Template.parse(tmpl_file.read)
104
+ liquid_tmpl.render!({"content" => content})
105
+ end
106
+ return Webhookdb::WebhookResponse.new(status: 200, headers:, body:)
107
+ end
108
+
109
+ def calculate_webhook_state_machine
110
+ step = Webhookdb::Replicator::StateMachineStep.new
111
+ if self.service_integration.api_url.blank?
112
+ step.output = %(After users visit the WebhookDB endpoint,
113
+ they can either be redirected to a location of your own,
114
+ or we'll render an HTML page to show them.
115
+
116
+ To use a redirect, input a URL starting with 'https://'.
117
+
118
+ To render a page, paste in the HTML:
119
+
120
+ - If the text starts with an `html` tag, it will be used as-is
121
+ for the page's HTML, so you can use your own styles.
122
+ - Otherwise we assume the content is a relatively simple message,
123
+ and it's rendered with basic WebhookDB styles.)
124
+ return step.prompting("URL, HTML, or text").api_url(self.service_integration)
125
+ end
126
+ step.output = %(
127
+ All set! Every visit to
128
+ #{self.webhook_endpoint}
129
+ will be recorded.
130
+
131
+ If you want to modify what users see after the visit is recorded,
132
+ run `webhookdb integration reset #{self.descriptor.name}.
133
+
134
+ #{self._query_help_output})
135
+ return step.completed
136
+ end
137
+ end
@@ -2,4 +2,8 @@
2
2
 
3
3
  class Webhookdb::Replicator::WebhookRequest < Webhookdb::TypedStruct
4
4
  attr_accessor :body, :headers, :path, :method
5
+ # @!attribute rack_request
6
+ # When a webhook is processed synchronously, this will be set to the Rack::Request.
7
+ # Normal (async) webhook processing does not have this available.
8
+ attr_accessor :rack_request
5
9
  end
@@ -70,6 +70,12 @@ class Webhookdb::Replicator
70
70
  # for example in case 'backfill' is called but not supported.
71
71
  attr_reader :documentation_url
72
72
 
73
+ # If this integration uses /v1/install to set up,
74
+ # or some other link like a marketplace URL,
75
+ # provide it here. Note that you can add a redirect to Webhookdb::Apps::REDIRECTS
76
+ # to provide a pretty, arbitrary URL.
77
+ attr_reader :install_url
78
+
73
79
  # Markdown description of this replicator.
74
80
  attr_reader :description
75
81
 
@@ -94,6 +100,7 @@ class Webhookdb::Replicator
94
100
  description: nil,
95
101
  enterprise: false,
96
102
  documentation_url: nil,
103
+ install_url: nil,
97
104
  documentable: nil
98
105
  )
99
106
  raise ArgumentError, "must support one or both of webhooks and backfill" unless
@@ -107,6 +114,7 @@ class Webhookdb::Replicator
107
114
  dependency_descriptor:,
108
115
  documentation_url:,
109
116
  api_docs_url:,
117
+ install_url:,
110
118
  enterprise:
111
119
  )
112
120
  @ctor = ctor.is_a?(Class) ? ctor.method(:new) : ctor
@@ -3,6 +3,8 @@
3
3
  require "webhookdb/postgres/model"
4
4
 
5
5
  class Webhookdb::Role < Webhookdb::Postgres::Model(:roles)
6
+ plugin :text_searchable, terms: [:name]
7
+
6
8
  # n.b. Because of the uniqueness constraint on "name", there is only one "admin" role. Its meaning
7
9
  # depends on the context: if the customer has this role, they are an admin; if the org membership has
8
10
  # this role, the customer is an org admin.
@@ -30,8 +32,9 @@ end
30
32
  # Table: roles
31
33
  # ---------------------------------------------------------------------------------------------------------------------------
32
34
  # Columns:
33
- # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
34
- # name | text | NOT NULL
35
+ # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
36
+ # name | text | NOT NULL
37
+ # text_search | tsvector |
35
38
  # Indexes:
36
39
  # roles_pkey | PRIMARY KEY btree (id)
37
40
  # roles_name_key | UNIQUE btree (name)
@@ -2,6 +2,7 @@
2
2
 
3
3
  class Webhookdb::SavedQuery < Webhookdb::Postgres::Model(:saved_queries)
4
4
  plugin :timestamps
5
+ plugin :text_searchable, terms: [:organization, :created_by]
5
6
 
6
7
  CLI_EDITABLE_FIELDS = ["description", "sql", "public"].freeze
7
8
  INFO_FIELDS = {
@@ -26,3 +27,25 @@ class Webhookdb::SavedQuery < Webhookdb::Postgres::Model(:saved_queries)
26
27
 
27
28
  def run_url = "#{Webhookdb.api_url}/v1/saved_queries/#{self.opaque_id}/run"
28
29
  end
30
+
31
+ # Table: saved_queries
32
+ # ------------------------------------------------------------------------------------------------------
33
+ # Columns:
34
+ # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
35
+ # created_at | timestamp with time zone | NOT NULL DEFAULT now()
36
+ # updated_at | timestamp with time zone |
37
+ # organization_id | integer | NOT NULL
38
+ # created_by_id | integer |
39
+ # opaque_id | text | NOT NULL
40
+ # description | text | NOT NULL
41
+ # sql | text | NOT NULL
42
+ # public | boolean | NOT NULL DEFAULT false
43
+ # text_search | tsvector |
44
+ # Indexes:
45
+ # saved_queries_pkey | PRIMARY KEY btree (id)
46
+ # saved_queries_opaque_id_key | UNIQUE btree (opaque_id)
47
+ # saved_queries_organization_id_index | btree (organization_id)
48
+ # Foreign key constraints:
49
+ # saved_queries_created_by_id_fkey | (created_by_id) REFERENCES customers(id) ON DELETE SET NULL
50
+ # saved_queries_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
51
+ # ------------------------------------------------------------------------------------------------------
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Webhookdb::SavedView < Webhookdb::Postgres::Model(:saved_views)
4
+ plugin :timestamps
5
+ plugin :text_searchable, terms: [:organization, :created_by]
6
+
7
+ DOCS_URL = "https://docs.webhookdb.com/docs/integrating/saved-views.html"
8
+
9
+ class InvalidQuery < Webhookdb::InvalidInput; end
10
+
11
+ many_to_one :organization, class: "Webhookdb::Organization"
12
+ many_to_one :created_by, class: "Webhookdb::Customer"
13
+
14
+ def self.feature_role
15
+ return Webhookdb.cached_get("saved-view-feature-role") do
16
+ Webhookdb::Role.find_or_create_or_find(name: "saved_views")
17
+ end
18
+ end
19
+
20
+ def self.create_or_replace(organization:, sql:, name:, **kw)
21
+ Webhookdb::DBAdapter.validate_identifier!(name, type: "view")
22
+ self.db.transaction do
23
+ sv = self.find_or_create_or_find(organization:, name:) do |new|
24
+ new.sql = sql
25
+ end
26
+ sv.update(sql:, **kw)
27
+
28
+ # Verify that the underlying query is readonly, by running it as a readonly user.
29
+ if (_, errmsg = organization.execute_readonly_query_with_help(sql)) && errmsg.present?
30
+ raise InvalidQuery, errmsg
31
+ end
32
+
33
+ # Create the view now that we've asserted it's readonly
34
+ qname = Webhookdb::DBAdapter::PG.new.escape_identifier(name)
35
+ organization.admin_connection do |conn|
36
+ conn << "CREATE OR REPLACE VIEW #{qname} AS (#{sql})"
37
+ end
38
+ return sv
39
+ end
40
+ end
41
+
42
+ def before_destroy
43
+ raise Webhookdb::InvariantViolation, "#{self.inspect} name became invalid somehow" unless
44
+ Webhookdb::DBAdapter.valid_identifier?(self.name)
45
+ if self.organization.admin_connection_url_raw.present?
46
+ qname = Webhookdb::DBAdapter::PG.new.escape_identifier(self.name)
47
+ self.organization.admin_connection do |conn|
48
+ conn << "DROP VIEW IF EXISTS #{qname}"
49
+ end
50
+ end
51
+ super
52
+ end
53
+ end
54
+
55
+ # Table: saved_views
56
+ # ----------------------------------------------------------------------------------------------------
57
+ # Columns:
58
+ # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
59
+ # created_at | timestamp with time zone | NOT NULL DEFAULT now()
60
+ # updated_at | timestamp with time zone |
61
+ # organization_id | integer | NOT NULL
62
+ # name | text | NOT NULL
63
+ # sql | text | NOT NULL
64
+ # created_by_id | integer |
65
+ # text_search | tsvector |
66
+ # Indexes:
67
+ # saved_views_pkey | PRIMARY KEY btree (id)
68
+ # saved_views_organization_id_name_key | UNIQUE btree (organization_id, name)
69
+ # saved_views_organization_id_index | btree (organization_id)
70
+ # Foreign key constraints:
71
+ # saved_views_created_by_id_fkey | (created_by_id) REFERENCES customers(id) ON DELETE SET NULL
72
+ # saved_views_organization_id_fkey | (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
73
+ # ----------------------------------------------------------------------------------------------------
@@ -12,6 +12,7 @@ module Webhookdb::Sentry
12
12
 
13
13
  configurable(:sentry) do
14
14
  setting :dsn, ""
15
+ setting :log_level, :warn
15
16
 
16
17
  # Apply the current configuration to Sentry.
17
18
  # See https://docs.sentry.io/clients/ruby/config/ for more info.
@@ -22,6 +23,7 @@ module Webhookdb::Sentry
22
23
  Sentry.init do |config|
23
24
  config.dsn = dsn
24
25
  config.logger = self.logger
26
+ config.logger.level = self.log_level
25
27
  end
26
28
  else
27
29
  Sentry.instance_variable_set(:@main_hub, nil)
@@ -54,10 +54,6 @@ module Webhookdb::Service::Entities
54
54
  end
55
55
  end
56
56
  end
57
-
58
- expose :message do |_instance, options|
59
- options[:message] || ""
60
- end
61
57
  end
62
58
 
63
59
  class Image < Base
@@ -142,6 +142,11 @@ module Webhookdb::Service::Helpers
142
142
  merror!(403, message, code: "permission_check")
143
143
  end
144
144
 
145
+ def check_feature_access!(org, role)
146
+ return if org.feature_roles.include?(role)
147
+ permission_error!("This feature is not enabled for your organization.")
148
+ end
149
+
145
150
  # Raise a 400 error for unstructured validation.
146
151
  # @param errors [Array<String>,String] Error messages, like 'password is invalid'.
147
152
  # @param message [String] If not given, build it from the errors list.
@@ -38,6 +38,14 @@ module Webhookdb::Service::Middleware
38
38
  credentials: false,
39
39
  expose: "*"
40
40
  end
41
+ allow do
42
+ origins("*")
43
+ resource "/v1/db/run_sql",
44
+ headers: :any,
45
+ methods: [:get, :post],
46
+ credentials: false,
47
+ expose: "*"
48
+ end
41
49
  end
42
50
  end
43
51
 
@@ -45,6 +53,7 @@ module Webhookdb::Service::Middleware
45
53
  builder.use(Rack::ContentLength)
46
54
  builder.use(Rack::Chunked)
47
55
  builder.use(Sentry::Rack::CaptureExceptions)
56
+ builder.use(Rack::Deflater)
48
57
  end
49
58
 
50
59
  def self.add_dev_middleware(builder)
@@ -7,18 +7,20 @@ module Webhookdb::Service::Types
7
7
  ctx.const_set(:NormalizedEmail, NormalizedEmail)
8
8
  ctx.const_set(:NormalizedPhone, NormalizedPhone)
9
9
  ctx.const_set(:CommaSepArray, CommaSepArray)
10
+ ctx.const_set(:TrimmedString, TrimmedString)
10
11
  end
11
12
 
12
- class NormalizedEmail
13
- def self.parse(value)
14
- return value.downcase.strip
15
- end
13
+ class NormalizedEmail < String
14
+ def self.parse(value) = self.new(value.downcase.strip)
16
15
  end
17
16
 
18
- class NormalizedPhone
19
- def self.parse(value)
20
- return Webhookdb::PhoneNumber::US.normalize(value)
21
- end
17
+ class NormalizedPhone < String
18
+ def self.parse(value) = self.new(Webhookdb::PhoneNumber::US.normalize(value))
19
+ end
20
+
21
+ class TrimmedString < String
22
+ def self.parse(value) = self.new(value.strip)
23
+ def self.map(arr) = arr.map { |a| self.new(a) }
22
24
  end
23
25
 
24
26
  class CommaSepArray
@@ -20,8 +20,7 @@ module Webhookdb::Service::Validators
20
20
  def validate_param!(attr_name, params)
21
21
  val = params[attr_name]
22
22
  return if val.blank? && @allow_blank
23
- re = Webhookdb::DBAdapter::VALID_IDENTIFIER
24
- return if re.match?(val)
23
+ return if Webhookdb::DBAdapter.valid_identifier?(val)
25
24
  raise Grape::Exceptions::Validation.new(
26
25
  params: [@scope.full_name(attr_name)],
27
26
  message: "is not a valid database identifier for WebhookDB. " +
@@ -23,7 +23,7 @@ module Webhookdb::Service::ViewApi
23
23
  |
24
24
  tmpl_file = File.open(Webhookdb::DATA_DIR + data_rel_path)
25
25
  liquid_tmpl = Liquid::Template.parse(tmpl_file.read)
26
- rendered = liquid_tmpl.render!(vars.stringify_keys, registers: {})
26
+ rendered = liquid_tmpl.render!(vars.deep_stringify_keys, registers: {})
27
27
  _endpoint.content_type content_type
28
28
  if serialize_view_params
29
29
  _endpoint.cookies[:whdbviewparams] = {path: data_rel_path, vars:, content_type:}.to_json
@@ -20,6 +20,7 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
20
20
  }.freeze
21
21
 
22
22
  plugin :timestamps
23
+ plugin :text_searchable, terms: [:service_name, :table_name, :organization]
23
24
  plugin :column_encryption do |enc|
24
25
  enc.column :data_encryption_secret
25
26
  enc.column :webhook_secret
@@ -145,10 +146,12 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
145
146
  def destroy_self_and_all_dependents
146
147
  self.dependents.each(&:destroy_self_and_all_dependents)
147
148
 
148
- begin
149
- self.replicator.admin_dataset(timeout: :fast) { |ds| ds.db << "DROP TABLE #{self.table_name}" }
150
- rescue Sequel::DatabaseError => e
151
- raise unless e.wrapped_exception.is_a?(PG::UndefinedTable)
149
+ if self.organization.admin_connection_url.present?
150
+ begin
151
+ self.replicator.admin_dataset(timeout: :fast) { |ds| ds.db << "DROP TABLE #{self.table_name}" }
152
+ rescue Sequel::DatabaseError => e
153
+ raise e unless e.wrapped_exception.is_a?(PG::UndefinedTable)
154
+ end
152
155
  end
153
156
  self.destroy
154
157
  end
@@ -222,11 +225,7 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
222
225
 
223
226
  def rename_table(to:)
224
227
  Webhookdb::Organization::DatabaseMigration.guard_ongoing!(self.organization)
225
- unless Webhookdb::DBAdapter::VALID_IDENTIFIER.match?(to)
226
- msg = "Sorry, this is not a valid table name. " + Webhookdb::DBAdapter::INVALID_IDENTIFIER_MESSAGE
227
- msg += " And we see you what you did there ;)" if to.include?(";") && to.downcase.include?("drop")
228
- raise TableRenameError, msg
229
- end
228
+ Webhookdb::DBAdapter.validate_identifier!(to, type: "table")
230
229
  self.db.transaction do
231
230
  begin
232
231
  self.organization.admin_connection { |db| db << "ALTER TABLE #{self.table_name} RENAME TO #{to}" }
@@ -333,7 +332,7 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
333
332
  end
334
333
 
335
334
  # Table: service_integrations
336
- # -----------------------------------------------------------------------------------------------------------------------------------------------------------
335
+ # ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
337
336
  # Columns:
338
337
  # id | integer | PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
339
338
  # created_at | timestamp with time zone | NOT NULL DEFAULT now()
@@ -350,6 +349,8 @@ end
350
349
  # depends_on_id | integer |
351
350
  # data_encryption_secret | text |
352
351
  # skip_webhook_verification | boolean | NOT NULL DEFAULT false
352
+ # webhookdb_api_key | text |
353
+ # text_search | tsvector |
353
354
  # Indexes:
354
355
  # service_integrations_pkey | PRIMARY KEY btree (id)
355
356
  # service_integrations_opaque_id_key | UNIQUE btree (opaque_id)
@@ -358,8 +359,9 @@ end
358
359
  # service_integrations_depends_on_id_fkey | (depends_on_id) REFERENCES service_integrations(id) ON DELETE RESTRICT
359
360
  # service_integrations_organization_id_fkey | (organization_id) REFERENCES organizations(id)
360
361
  # Referenced By:
361
- # backfill_jobs | backfill_jobs_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id) ON DELETE CASCADE
362
- # service_integrations | service_integrations_depends_on_id_fkey | (depends_on_id) REFERENCES service_integrations(id) ON DELETE RESTRICT
363
- # sync_targets | sync_targets_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id) ON DELETE CASCADE
364
- # webhook_subscriptions | webhook_subscriptions_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id)
365
- # -----------------------------------------------------------------------------------------------------------------------------------------------------------
362
+ # backfill_jobs | backfill_jobs_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id) ON DELETE CASCADE
363
+ # backfill_job_service_integration_locks | backfill_job_service_integration_lo_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id) ON DELETE CASCADE
364
+ # service_integrations | service_integrations_depends_on_id_fkey | (depends_on_id) REFERENCES service_integrations(id) ON DELETE RESTRICT
365
+ # sync_targets | sync_targets_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id) ON DELETE CASCADE
366
+ # webhook_subscriptions | webhook_subscriptions_service_integration_id_fkey | (service_integration_id) REFERENCES service_integrations(id)
367
+ # ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
@@ -45,7 +45,7 @@ RSpec.shared_examples "a replicator" do |name|
45
45
  if expected_row
46
46
  expect(ds.first).to match(expected_row)
47
47
  else
48
- expect(ds.first[:data]).to eq(expected_data)
48
+ expect(ds.first[:data].to_h).to eq(expected_data)
49
49
  end
50
50
  end
51
51
  end
@@ -59,7 +59,7 @@ RSpec.shared_examples "a replicator" do |name|
59
59
  if expected_row
60
60
  expect(ds.first).to match(expected_row)
61
61
  else
62
- expect(ds.first[:data]).to eq(expected_data)
62
+ expect(ds.first[:data].to_h).to eq(expected_data)
63
63
  end
64
64
  # this is how a fully qualified table is represented (schema->table, table->column)
65
65
  expect(ds.opts[:from].first).to have_attributes(table: "xyz", column: svc.service_integration.table_name.to_sym)
@@ -277,7 +277,7 @@ RSpec.shared_examples "a replicator that prevents overwriting new data with old"
277
277
  if expected_old_row
278
278
  expect(ds.first).to match(expected_old_row)
279
279
  else
280
- expect(ds.first[:data]).to eq(expected_old_data)
280
+ expect(ds.first[:data].to_h).to eq(expected_old_data)
281
281
  end
282
282
 
283
283
  upsert_webhook(svc, body: new_body)
@@ -285,7 +285,7 @@ RSpec.shared_examples "a replicator that prevents overwriting new data with old"
285
285
  if expected_new_row
286
286
  expect(ds.first).to match(expected_new_row)
287
287
  else
288
- expect(ds.first[:data]).to eq(expected_new_data)
288
+ expect(ds.first[:data].to_h).to eq(expected_new_data)
289
289
  end
290
290
  end
291
291
  end
@@ -299,7 +299,7 @@ RSpec.shared_examples "a replicator that prevents overwriting new data with old"
299
299
  if expected_new_row
300
300
  expect(ds.first).to match(expected_new_row)
301
301
  else
302
- expect(ds.first[:data]).to eq(expected_new_data)
302
+ expect(ds.first[:data].to_h).to eq(expected_new_data)
303
303
  end
304
304
 
305
305
  upsert_webhook(svc, body: old_body)
@@ -307,7 +307,7 @@ RSpec.shared_examples "a replicator that prevents overwriting new data with old"
307
307
  if expected_new_row
308
308
  expect(ds.first).to match(expected_new_row)
309
309
  else
310
- expect(ds.first[:data]).to eq(expected_new_data)
310
+ expect(ds.first[:data].to_h).to eq(expected_new_data)
311
311
  end
312
312
  end
313
313
  end
@@ -475,7 +475,7 @@ RSpec.shared_examples "a replicator that deals with resources and wrapped events
475
475
  upsert_webhook(svc, body: resource_json, headers: resource_headers)
476
476
  svc.readonly_dataset do |ds|
477
477
  expect(ds.all).to have_length(1)
478
- expect(ds.first[:data]).to eq(resource_json)
478
+ expect(ds.first[:data].to_h).to eq(resource_json)
479
479
  end
480
480
  end
481
481
 
@@ -484,7 +484,7 @@ RSpec.shared_examples "a replicator that deals with resources and wrapped events
484
484
  upsert_webhook(svc, body: resource_in_envelope_json, headers: resource_in_envelope_headers)
485
485
  svc.readonly_dataset do |ds|
486
486
  expect(ds.all).to have_length(1)
487
- expect(ds.first[:data]).to eq(resource_json)
487
+ expect(ds.first[:data].to_h).to eq(resource_json)
488
488
  end
489
489
  end
490
490
  end
@@ -104,13 +104,14 @@ module Webhookdb::SpecHelpers::Whdb
104
104
  this.let(:request_method) { nil }
105
105
  this.let(:request_body) { nil }
106
106
  this.let(:request_headers) { nil }
107
+ this.let(:rack_request) { nil }
107
108
  this.let(:webhook_request) do
108
109
  Webhookdb::Replicator::WebhookRequest.new(
109
- body: request_body, method: request_method, path: request_path, headers: request_headers,
110
+ body: request_body, method: request_method, path: request_path, headers: request_headers, rack_request:,
110
111
  )
111
112
  end
112
113
  this.define_method(:upsert_webhook) do |svc, **kw|
113
- params = {body: request_body, headers: request_headers, method: request_method, path: request_path}
114
+ params = {body: request_body, headers: request_headers, method: request_method, path: request_path, rack_request:}
114
115
  params.merge!(**kw)
115
116
  svc.upsert_webhook(Webhookdb::Replicator::WebhookRequest.new(**params))
116
117
  end
@@ -23,6 +23,7 @@ class Webhookdb::Subscription < Webhookdb::Postgres::Model(:subscriptions)
23
23
 
24
24
  plugin :timestamps
25
25
  plugin :soft_deletes
26
+ plugin :text_searchable, terms: [:organization]
26
27
 
27
28
  configurable(:subscription) do
28
29
  setting :billing_enabled, false
@@ -196,6 +197,7 @@ end
196
197
  # stripe_id | text | NOT NULL
197
198
  # stripe_customer_id | text | NOT NULL DEFAULT ''::text
198
199
  # stripe_json | jsonb | DEFAULT '{}'::jsonb
200
+ # text_search | tsvector |
199
201
  # Indexes:
200
202
  # subscriptions_pkey | PRIMARY KEY btree (id)
201
203
  # subscriptions_stripe_id_key | UNIQUE btree (stripe_id)
@@ -74,6 +74,7 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
74
74
  end
75
75
 
76
76
  plugin :timestamps
77
+ plugin :text_searchable, terms: [:service_integration, :created_by]
77
78
  plugin :column_encryption do |enc|
78
79
  enc.column :connection_url
79
80
  end
@@ -327,7 +328,8 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
327
328
  # it wasn't in the last sync; however that is likely not a big problem
328
329
  # since clients need to handle updates in any case.
329
330
  def dataset_to_sync
330
- @replicator.readonly_dataset do |ds|
331
+ # Use admin dataset, since the client could be using all their readonly conns.
332
+ @replicator.admin_dataset do |ds|
331
333
  # Find rows updated before we started
332
334
  tscond = (@timestamp_expr <= @now)
333
335
  # Find rows updated after the last sync was run
@@ -364,7 +366,12 @@ class Webhookdb::SyncTarget < Webhookdb::Postgres::Model(:sync_targets)
364
366
  # This is handled well so no need to re-raise.
365
367
  # We already committed the last page that was successful,
366
368
  # so we can just stop syncing at this point to try again later.
367
- self.sync_target.logger.warn("sync_target_http_error", error: e)
369
+
370
+ # Don't spam our logs with downstream errors
371
+ idem_key = "sync_target_http_error-#{self.sync_target.id}-#{e.class.name}"
372
+ Webhookdb::Idempotency.every(1.hour).in_memory.under_key(idem_key) do
373
+ self.sync_target.logger.warn("sync_target_http_error", error: e)
374
+ end
368
375
  end
369
376
 
370
377
  def _flush_http_chunk(chunk)
@@ -480,6 +487,7 @@ end
480
487
  # last_synced_at | timestamp with time zone |
481
488
  # last_applied_schema | text | NOT NULL DEFAULT ''::text
482
489
  # page_size | integer | NOT NULL
490
+ # text_search | tsvector |
483
491
  # Indexes:
484
492
  # sync_targets_pkey | PRIMARY KEY btree (id)
485
493
  # sync_targets_opaque_id_key | UNIQUE btree (opaque_id)
@@ -28,7 +28,9 @@ module Webhookdb::Tasks
28
28
  SemanticLogger.add_appender(io: feedback_io)
29
29
 
30
30
  commit = Webhookdb::RACK_ENV != "test"
31
- delivery = Webhookdb::Message::Delivery.preview(template_class_name.classify, commit:)
31
+ clsname = template_class_name.classify
32
+ (clsname += "s") if template_class_name.end_with?("s") && !clsname.end_with?("s")
33
+ delivery = Webhookdb::Message::Delivery.preview(clsname, commit:)
32
34
  feedback_io << "*** Created MessageDelivery: #{delivery.values}\n\n"
33
35
  feedback_io << delivery.body_with_mediatype!("text/plain")&.content
34
36
  feedback_io << "\n\n"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webhookdb
4
- VERSION = "1.2.2"
4
+ VERSION = "1.3.1"
5
5
  end