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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +337 -0
- data/Rakefile +20 -0
- data/SPEC.md +532 -0
- data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
- data/app/assets/stylesheets/context_drop/application.css +2 -0
- data/app/controllers/context_drop/application_controller.rb +114 -0
- data/app/controllers/context_drop/attachments_controller.rb +31 -0
- data/app/controllers/context_drop/configs_controller.rb +20 -0
- data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
- data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
- data/app/controllers/context_drop/health_controller.rb +9 -0
- data/app/controllers/context_drop/mcp_controller.rb +67 -0
- data/app/helpers/context_drop/widget_helper.rb +62 -0
- data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
- data/app/javascript/context_drop/api-client.ts +87 -0
- data/app/javascript/context_drop/element-picker.ts +1004 -0
- data/app/javascript/context_drop/icons.ts +35 -0
- data/app/javascript/context_drop/index.ts +11 -0
- data/app/javascript/context_drop/sanitizer.ts +454 -0
- data/app/javascript/context_drop/screenshot.ts +214 -0
- data/app/javascript/context_drop/styles.ts +690 -0
- data/app/javascript/context_drop/types.ts +110 -0
- data/app/javascript/context_drop/widget.ts +1120 -0
- data/app/models/context_drop/application_record.rb +7 -0
- data/app/models/context_drop/attachment.rb +30 -0
- data/app/models/context_drop/element_reference.rb +47 -0
- data/app/models/context_drop/feedback.rb +94 -0
- data/app/services/context_drop/metadata_sanitizer.rb +215 -0
- data/app/services/context_drop/upload_validator.rb +217 -0
- data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
- data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
- data/app/views/layouts/context_drop/application.html.erb +215 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
- data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
- data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
- data/lib/context_drop/base64_util.rb +15 -0
- data/lib/context_drop/configuration.rb +155 -0
- data/lib/context_drop/engine.rb +32 -0
- data/lib/context_drop/mcp/dispatcher.rb +197 -0
- data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
- data/lib/context_drop/mcp/server.rb +25 -0
- data/lib/context_drop/mcp/stdio_runner.rb +62 -0
- data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
- data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
- data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
- data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
- data/lib/context_drop/version.rb +5 -0
- data/lib/context_drop.rb +32 -0
- data/lib/generators/context_drop/install/install_generator.rb +52 -0
- data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
- data/lib/tasks/context_drop_tasks.rake +14 -0
- metadata +246 -0
|
@@ -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,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
|