webhookdb 1.2.2 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
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.0"
5
5
  end