context_drop 0.1.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 +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,2 @@
1
+ /* ContextDrop Engine Dashboard Stylesheet */
2
+ /* Styles are also embedded in layouts/context_drop/application.html.erb for zero-config asset setups */
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ContextDrop
6
+ class ApplicationController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+
9
+ before_action :ensure_context_drop_enabled!
10
+
11
+ rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_csrf_token
12
+ rescue_from ContextDrop::UploadValidator::ValidationError, with: :handle_upload_validation_error
13
+ rescue_from ContextDrop::MetadataSanitizer::SensitiveElementError, with: :handle_sensitive_element_error
14
+
15
+ class << self
16
+ def rate_limit_store
17
+ @rate_limit_store ||= {}
18
+ end
19
+
20
+ def rate_limit_mutex
21
+ @rate_limit_mutex ||= Mutex.new
22
+ end
23
+
24
+ def reset_rate_limits!
25
+ rate_limit_mutex.synchronize { rate_limit_store.clear }
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def ensure_context_drop_enabled!
32
+ return if ContextDrop.enabled?
33
+
34
+ respond_to do |format|
35
+ format.json { render json: { error: "Not Found" }, status: :not_found }
36
+ format.any { head :not_found }
37
+ end
38
+ end
39
+
40
+ def current_context_drop_submitter
41
+ return @current_context_drop_submitter if defined?(@current_context_drop_submitter)
42
+
43
+ @current_context_drop_submitter = ContextDrop.config.resolve_submitter(self)
44
+ end
45
+
46
+ def enforce_submission_authorization!
47
+ submitter = current_context_drop_submitter
48
+ if !ContextDrop.config.allow_anonymous && submitter.nil?
49
+ render json: { error: "Authentication required to submit feedback" }, status: :unauthorized
50
+ return
51
+ end
52
+
53
+ return if ContextDrop.config.submission_authorized?(self)
54
+
55
+ render json: { error: "Forbidden" }, status: :forbidden
56
+ end
57
+
58
+ def enforce_rate_limit!
59
+ limit_cfg = ContextDrop.config.rate_limit
60
+ return unless limit_cfg.is_a?(Hash)
61
+
62
+ max_requests = (limit_cfg[:max_requests] || limit_cfg["max_requests"]).to_i
63
+ period_seconds = (limit_cfg[:period] || limit_cfg["period"]).to_i
64
+ return if max_requests <= 0 || period_seconds <= 0
65
+
66
+ client_identifier = request.remote_ip.presence || "unknown"
67
+ bucket = (Time.now.to_i / period_seconds)
68
+ cache_key = "context_drop:rate_limit:#{client_identifier}:#{bucket}"
69
+
70
+ count = increment_rate_limit_counter(cache_key, period_seconds)
71
+ return if count <= max_requests
72
+
73
+ render json: { error: "Rate limit exceeded. Please try again later." }, status: :too_many_requests
74
+ end
75
+
76
+ def increment_rate_limit_counter(cache_key, period_seconds)
77
+ if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache && !Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
78
+ current = Rails.cache.read(cache_key).to_i + 1
79
+ Rails.cache.write(cache_key, current, expires_in: period_seconds)
80
+ current
81
+ else
82
+ self.class.rate_limit_mutex.synchronize do
83
+ store = self.class.rate_limit_store
84
+ now = Time.now.to_i
85
+ store.delete_if { |_k, v| v[:expires_at] <= now }
86
+ entry = store[cache_key] ||= { count: 0, expires_at: now + period_seconds }
87
+ entry[:count] += 1
88
+ entry[:count]
89
+ end
90
+ end
91
+ end
92
+
93
+ def hashed_session_identifier
94
+ raw_id = session.id.to_s if respond_to?(:session) && session.respond_to?(:id) && session.id
95
+ return nil if raw_id.nil? || raw_id.empty?
96
+
97
+ Digest::SHA256.hexdigest(raw_id)[0, 32]
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ def handle_invalid_csrf_token
103
+ render json: { error: "Invalid or missing CSRF authenticity token" }, status: :unprocessable_entity
104
+ end
105
+
106
+ def handle_upload_validation_error(exception)
107
+ render json: { error: exception.message }, status: exception.http_status
108
+ end
109
+
110
+ def handle_sensitive_element_error(exception)
111
+ render json: { error: exception.message }, status: :unprocessable_entity
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class AttachmentsController < ApplicationController
5
+ before_action :enforce_submission_authorization!
6
+ before_action :enforce_rate_limit!
7
+
8
+ def create
9
+ validated = ContextDrop::UploadValidator.validate!(params[:file])
10
+
11
+ blob_args = {
12
+ io: validated[:io],
13
+ filename: validated[:filename],
14
+ content_type: validated[:content_type]
15
+ }
16
+ if ContextDrop.config.active_storage_service
17
+ blob_args[:service_name] = ContextDrop.config.active_storage_service
18
+ end
19
+
20
+ blob = ActiveStorage::Blob.create_and_upload!(**blob_args)
21
+
22
+ render json: {
23
+ signed_blob_id: blob.signed_id,
24
+ filename: validated[:filename],
25
+ content_type: validated[:content_type],
26
+ byte_size: validated[:byte_size],
27
+ dimensions: validated[:dimensions]
28
+ }, status: :created
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class ConfigsController < ApplicationController
5
+ before_action :enforce_submission_authorization!, only: [:show]
6
+
7
+ def show
8
+ render json: ContextDrop.config.public_widget_config, status: :ok
9
+ end
10
+
11
+ def widget_bundle
12
+ bundle_path = ContextDrop::Engine.root.join("app/assets/javascripts/context_drop/context_drop.js")
13
+ if File.exist?(bundle_path)
14
+ send_file bundle_path, type: "application/javascript", disposition: "inline"
15
+ else
16
+ head :not_found
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ module Dashboard
5
+ class FeedbacksController < ApplicationController
6
+ layout "context_drop/application"
7
+
8
+ before_action :enforce_dashboard_authorization!
9
+
10
+ PER_PAGE = 25
11
+
12
+ def index
13
+ @status_counts = {
14
+ "all" => ContextDrop::Feedback.count,
15
+ "new" => ContextDrop::Feedback.new_records.count,
16
+ "reviewed" => ContextDrop::Feedback.reviewed.count,
17
+ "resolved" => ContextDrop::Feedback.resolved.count,
18
+ "dismissed" => ContextDrop::Feedback.dismissed.count
19
+ }
20
+
21
+ scope = ContextDrop::Feedback.includes(:submitter, :element_references, :attachments).recent
22
+
23
+ @current_status = params[:status].to_s.strip
24
+ if ContextDrop::Feedback::STATUSES.include?(@current_status)
25
+ scope = scope.where(status: @current_status)
26
+ else
27
+ @current_status = ""
28
+ end
29
+
30
+ @page_url_filter = params[:page_url].to_s.strip
31
+ if @page_url_filter.present?
32
+ scope = scope.where("context_drop_feedbacks.page_url LIKE ?", "%#{sanitize_sql_like(@page_url_filter)}%")
33
+ end
34
+
35
+ @search_query = (params[:q] || params[:query]).to_s.strip
36
+ if @search_query.present?
37
+ pattern = "%#{sanitize_sql_like(@search_query)}%"
38
+ scope = scope.left_outer_joins(:element_references).where(
39
+ "context_drop_feedbacks.message LIKE :q OR " \
40
+ "context_drop_element_references.selector LIKE :q OR " \
41
+ "context_drop_element_references.dom_path LIKE :q OR " \
42
+ "context_drop_element_references.text_content LIKE :q",
43
+ q: pattern
44
+ ).distinct
45
+ end
46
+
47
+ @total_filtered = scope.count
48
+ @page = [params[:page].to_i, 1].max
49
+ @total_pages = [(@total_filtered.to_f / PER_PAGE).ceil, 1].max
50
+ @feedbacks = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
51
+ end
52
+
53
+ def show
54
+ @feedback = ContextDrop::Feedback
55
+ .includes(:submitter, :element_references, :attachments)
56
+ .find(params[:id])
57
+ end
58
+
59
+ def update
60
+ @feedback = ContextDrop::Feedback.find(params[:id])
61
+ next_status = (params[:status] || params.dig(:feedback, :status)).to_s.strip
62
+
63
+ unless ContextDrop::Feedback::STATUSES.include?(next_status)
64
+ respond_to do |format|
65
+ format.json { render json: { error: "Invalid status '#{next_status}'" }, status: :unprocessable_entity }
66
+ format.html do
67
+ redirect_to dashboard_feedback_path(@feedback), alert: "Invalid status '#{next_status}'."
68
+ end
69
+ end
70
+ return
71
+ end
72
+
73
+ @feedback.update!(status: next_status)
74
+
75
+ respond_to do |format|
76
+ format.json do
77
+ render json: {
78
+ id: @feedback.id,
79
+ status: @feedback.status,
80
+ updated_at: @feedback.updated_at.iso8601
81
+ }, status: :ok
82
+ end
83
+ format.html do
84
+ redirect_to dashboard_feedback_path(@feedback), notice: "Feedback ##{@feedback.id} marked as #{@feedback.status}."
85
+ end
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def enforce_dashboard_authorization!
92
+ return if ContextDrop.config.dashboard_authorized?(self)
93
+
94
+ respond_to do |format|
95
+ format.json { render json: { error: "Forbidden" }, status: :forbidden }
96
+ format.any { render plain: "403 Forbidden — Not authorized to view ContextDrop Dashboard", status: :forbidden }
97
+ end
98
+ end
99
+
100
+ def sanitize_sql_like(str)
101
+ ActiveRecord::Base.sanitize_sql_like(str.to_s)
102
+ rescue StandardError
103
+ str.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class FeedbacksController < ApplicationController
5
+ before_action :enforce_submission_authorization!
6
+ before_action :enforce_rate_limit!
7
+
8
+ def create
9
+ payload = extract_feedback_payload
10
+ raw_elements = Array(payload["element_references"])
11
+ raw_attachments = Array(payload["attachments"])
12
+
13
+ if raw_attachments.size > ContextDrop.config.max_attachments.to_i
14
+ render json: {
15
+ error: "Attachments count (#{raw_attachments.size}) exceeds maximum allowed (#{ContextDrop.config.max_attachments})"
16
+ }, status: :unprocessable_entity
17
+ return
18
+ end
19
+
20
+ if raw_elements.size > ContextDrop.config.max_element_references.to_i
21
+ render json: {
22
+ error: "Element references count (#{raw_elements.size}) exceeds maximum allowed (#{ContextDrop.config.max_element_references})"
23
+ }, status: :unprocessable_entity
24
+ return
25
+ end
26
+
27
+ feedback = nil
28
+
29
+ ContextDrop::Feedback.transaction do
30
+ feedback = ContextDrop::Feedback.new(
31
+ submitter: current_context_drop_submitter,
32
+ session_id: hashed_session_identifier,
33
+ message: payload["message"].to_s.strip.presence,
34
+ page_url: (payload["page_url"].presence || request.referer).to_s.strip,
35
+ user_agent: (payload["user_agent"].presence || request.user_agent).to_s.strip.presence,
36
+ metadata: ContextDrop::MetadataSanitizer.sanitize_feedback_metadata(payload["metadata"]),
37
+ status: "new"
38
+ )
39
+
40
+ element_refs_by_client_ref = {}
41
+ raw_elements.each_with_index do |raw_el, idx|
42
+ sanitized_el = ContextDrop::MetadataSanitizer.sanitize_element_reference(raw_el)
43
+ el_record = feedback.element_references.build(sanitized_el)
44
+ client_key = sanitized_el[:client_ref].presence || idx.to_s
45
+ element_refs_by_client_ref[client_key] = el_record
46
+ end
47
+
48
+ pending_file_attachments = []
49
+ raw_attachments.each do |raw_att|
50
+ att_hash = stringify_hash(raw_att)
51
+ att_type = (att_hash["attachment_type"] || att_hash["type"] || "image").to_s.strip.downcase
52
+ att_meta = ContextDrop::MetadataSanitizer.sanitize_attachment_metadata(att_hash["metadata"])
53
+
54
+ linked_ref_key = (
55
+ att_hash["element_reference_ref"] ||
56
+ att_hash["element_attachment_ref"] ||
57
+ att_hash["client_ref"] ||
58
+ att_meta["element_attachment_ref"] ||
59
+ att_meta["client_ref"]
60
+ ).to_s.strip.presence
61
+
62
+ linked_el = linked_ref_key ? element_refs_by_client_ref[linked_ref_key] : nil
63
+ file_source = resolve_attachment_file_source(att_hash)
64
+
65
+ if file_source[:dimensions] && att_meta["image_dimensions"].nil?
66
+ att_meta["image_dimensions"] = {
67
+ "width" => file_source[:dimensions][0],
68
+ "height" => file_source[:dimensions][1]
69
+ }
70
+ end
71
+
72
+ att_record = feedback.attachments.build(
73
+ attachment_type: att_type,
74
+ element_reference: linked_el,
75
+ metadata: att_meta
76
+ )
77
+ pending_file_attachments << [att_record, linked_el, file_source]
78
+ end
79
+
80
+ unless feedback.valid?
81
+ render json: { error: feedback.errors.full_messages.join(", "), errors: feedback.errors.full_messages },
82
+ status: :unprocessable_entity
83
+ raise ActiveRecord::Rollback
84
+ end
85
+
86
+ feedback.save!
87
+
88
+ pending_file_attachments.each do |att_record, linked_el, file_source|
89
+ if linked_el && att_record.element_reference_id.nil?
90
+ att_record.update_column(:element_reference_id, linked_el.id)
91
+ end
92
+ attach_binary_to_record!(att_record, file_source)
93
+ end
94
+ end
95
+
96
+ return if performed?
97
+
98
+ invoke_on_feedback_created(feedback)
99
+
100
+ render json: {
101
+ id: feedback.id,
102
+ status: feedback.status,
103
+ attachments_count: feedback.attachments.size,
104
+ element_references_count: feedback.element_references.size,
105
+ created_at: feedback.created_at.iso8601
106
+ }, status: :created
107
+ end
108
+
109
+ private
110
+
111
+ def extract_feedback_payload
112
+ raw = params[:feedback].is_a?(ActionController::Parameters) || params[:feedback].is_a?(Hash) ? params[:feedback] : params
113
+ hash = stringify_hash(raw)
114
+
115
+ %w[metadata element_references attachments].each do |key|
116
+ next unless hash[key].is_a?(String)
117
+
118
+ begin
119
+ hash[key] = JSON.parse(hash[key])
120
+ rescue JSON::ParserError
121
+ # Leave as-is if not valid JSON string
122
+ end
123
+ end
124
+
125
+ hash
126
+ end
127
+
128
+ def resolve_attachment_file_source(att_hash)
129
+ if att_hash["signed_blob_id"].present?
130
+ { type: :signed_blob, signed_blob_id: att_hash["signed_blob_id"].to_s }
131
+ elsif att_hash["file"].present?
132
+ validated = ContextDrop::UploadValidator.validate!(att_hash["file"])
133
+ { type: :validated_io, validated: validated, dimensions: validated[:dimensions] }
134
+ elsif att_hash["data_url"].present? || att_hash["data"].present?
135
+ raw_data = (att_hash["data_url"] || att_hash["data"]).to_s
136
+ decoded = decode_base64_image(raw_data, att_hash["filename"])
137
+ validated = ContextDrop::UploadValidator.validate!(decoded)
138
+ { type: :validated_io, validated: validated, dimensions: validated[:dimensions] }
139
+ else
140
+ { type: :none }
141
+ end
142
+ end
143
+
144
+ def attach_binary_to_record!(att_record, file_source)
145
+ return unless att_record.respond_to?(:file)
146
+
147
+ case file_source[:type]
148
+ when :signed_blob
149
+ blob = ActiveStorage::Blob.find_signed!(file_source[:signed_blob_id])
150
+ validate_existing_blob!(blob)
151
+ att_record.file.attach(blob)
152
+ when :validated_io
153
+ val = file_source[:validated]
154
+ att_record.file.attach(
155
+ io: val[:io],
156
+ filename: val[:filename],
157
+ content_type: val[:content_type]
158
+ )
159
+ end
160
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
161
+ raise ContextDrop::UploadValidator::ValidationError, "Invalid or expired signed_blob_id"
162
+ end
163
+
164
+ def validate_existing_blob!(blob)
165
+ max_size = ContextDrop.config.max_attachment_size.to_i
166
+ if max_size.positive? && blob.byte_size > max_size
167
+ raise ContextDrop::UploadValidator::ValidationError, "Attached blob exceeds maximum allowed size"
168
+ end
169
+
170
+ allowed_types = Array(ContextDrop.config.allowed_content_types)
171
+ return if allowed_types.include?(blob.content_type)
172
+
173
+ raise ContextDrop::UploadValidator::ValidationError, "Attached blob has disallowed content type"
174
+ end
175
+
176
+ def decode_base64_image(raw_data, custom_filename = nil)
177
+ b64_payload = if raw_data.include?(";base64,")
178
+ raw_data.split(";base64,", 2).last
179
+ else
180
+ raw_data
181
+ end
182
+ bytes = ContextDrop::Base64Util.strict_decode64(b64_payload.strip)
183
+ {
184
+ io: StringIO.new(bytes),
185
+ filename: custom_filename.presence || "screenshot.png"
186
+ }
187
+ rescue ArgumentError
188
+ raise ContextDrop::UploadValidator::ValidationError, "Invalid base64 attachment data"
189
+ end
190
+
191
+ def invoke_on_feedback_created(feedback)
192
+ callback = ContextDrop.config.on_feedback_created
193
+ callback.call(feedback) if callback.respond_to?(:call)
194
+ rescue StandardError => e
195
+ Rails.logger.error("[ContextDrop] on_feedback_created callback error: #{e.class}: #{e.message}") if defined?(Rails.logger) && Rails.logger
196
+ end
197
+
198
+ def stringify_hash(obj)
199
+ if obj.respond_to?(:to_unsafe_h)
200
+ obj.to_unsafe_h.transform_keys(&:to_s)
201
+ elsif obj.is_a?(Hash)
202
+ obj.transform_keys(&:to_s)
203
+ else
204
+ {}
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class HealthController < ApplicationController
5
+ def show
6
+ render json: { status: "ok", version: ContextDrop::VERSION }, status: :ok
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ContextDrop
6
+ class McpController < ApplicationController
7
+ skip_before_action :verify_authenticity_token
8
+ skip_before_action :ensure_context_drop_enabled!
9
+
10
+ before_action :ensure_mcp_enabled!
11
+ before_action :authenticate_mcp_request!
12
+
13
+ def create
14
+ raw_body = request.raw_post.to_s
15
+ payload = JSON.parse(raw_body)
16
+ response = ContextDrop::MCP::Dispatcher.call(payload)
17
+
18
+ if response.nil?
19
+ head :accepted
20
+ else
21
+ render json: response, status: :ok
22
+ end
23
+ rescue JSON::ParserError => e
24
+ render json: {
25
+ jsonrpc: "2.0",
26
+ id: nil,
27
+ error: {
28
+ code: -32_700,
29
+ message: "Parse error: #{e.message}"
30
+ }
31
+ }, status: :ok
32
+ end
33
+
34
+ private
35
+
36
+ def ensure_mcp_enabled!
37
+ return if ContextDrop.config.mcp_enabled
38
+
39
+ render json: { error: "Not Found" }, status: :not_found
40
+ end
41
+
42
+ def authenticate_mcp_request!
43
+ if ContextDrop.config.authorize_mcp.respond_to?(:call)
44
+ return if ContextDrop.config.authorize_mcp.call(self)
45
+
46
+ render json: { error: "Unauthorized MCP request" }, status: :unauthorized
47
+ return
48
+ end
49
+
50
+ expected_key = ContextDrop.config.mcp_api_key.to_s
51
+ if expected_key.strip.empty?
52
+ render json: { error: "Unauthorized: MCP API key is not configured" }, status: :unauthorized
53
+ return
54
+ end
55
+
56
+ auth_header = request.headers["Authorization"].to_s
57
+ provided_token = auth_header.start_with?("Bearer ") ? auth_header.sub(/\ABearer\s+/i, "").strip : ""
58
+
59
+ if provided_token.bytesize == expected_key.bytesize &&
60
+ ActiveSupport::SecurityUtils.secure_compare(provided_token, expected_key)
61
+ return
62
+ end
63
+
64
+ render json: { error: "Unauthorized: Invalid or missing Bearer token" }, status: :unauthorized
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "erb"
5
+
6
+ module ContextDrop
7
+ module WidgetHelper
8
+ # Renders the `<context-drop-widget>` custom element and script tag when ContextDrop is enabled
9
+ # and the current visitor is authorized to submit feedback.
10
+ #
11
+ # Supports per-page option overrides (`position:`, `placeholder:`, `metadata:`, `endpoint:`).
12
+ def context_drop_widget(**options)
13
+ cfg = ContextDrop.config
14
+ return nil unless cfg.enabled
15
+
16
+ controller_instance = respond_to?(:controller) ? controller : nil
17
+ return nil unless cfg.submission_authorized?(controller_instance)
18
+
19
+ widget_config = cfg.public_widget_config(options)
20
+ mount_path = resolve_context_drop_mount_path(options[:endpoint])
21
+ custom_metadata = options[:metadata].is_a?(Hash) ? options[:metadata] : {}
22
+
23
+ attrs = {
24
+ "data-endpoint" => mount_path,
25
+ "data-position" => widget_config[:position],
26
+ "data-placeholder" => widget_config[:placeholder],
27
+ "data-config" => JSON.generate(widget_config),
28
+ "data-metadata" => JSON.generate(custom_metadata)
29
+ }
30
+
31
+ attr_string = attrs.map { |k, v| %(#{k}="#{ERB::Util.html_escape(v)}") }.join(" ")
32
+ widget_html = "<context-drop-widget #{attr_string}></context-drop-widget>"
33
+
34
+ script_html = if respond_to?(:javascript_include_tag)
35
+ begin
36
+ javascript_include_tag("context_drop/context_drop", defer: true)
37
+ rescue StandardError
38
+ %(<script src="#{ERB::Util.html_escape(mount_path)}/assets/context_drop.js" defer="defer"></script>)
39
+ end
40
+ else
41
+ %(<script src="#{ERB::Util.html_escape(mount_path)}/assets/context_drop.js" defer="defer"></script>)
42
+ end
43
+
44
+ output = "#{widget_html}\n#{script_html}"
45
+ output.respond_to?(:html_safe) ? output.html_safe : output
46
+ end
47
+
48
+ private
49
+
50
+ def resolve_context_drop_mount_path(explicit_endpoint = nil)
51
+ return explicit_endpoint.to_s.chomp("/") if explicit_endpoint && !explicit_endpoint.to_s.strip.empty?
52
+
53
+ if respond_to?(:context_drop) && context_drop.respond_to?(:root_path)
54
+ context_drop.root_path.to_s.chomp("/")
55
+ else
56
+ "/context_drop"
57
+ end
58
+ rescue StandardError
59
+ "/context_drop"
60
+ end
61
+ end
62
+ end