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,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ContextDrop
|
|
4
|
+
class Attachment < ApplicationRecord
|
|
5
|
+
self.table_name = "context_drop_attachments"
|
|
6
|
+
|
|
7
|
+
ATTACHMENT_TYPES = %w[image screenshot file].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :feedback,
|
|
10
|
+
class_name: "ContextDrop::Feedback",
|
|
11
|
+
inverse_of: :attachments
|
|
12
|
+
|
|
13
|
+
belongs_to :element_reference,
|
|
14
|
+
class_name: "ContextDrop::ElementReference",
|
|
15
|
+
optional: true,
|
|
16
|
+
inverse_of: :attachments
|
|
17
|
+
|
|
18
|
+
has_one_attached :file if respond_to?(:has_one_attached)
|
|
19
|
+
|
|
20
|
+
validates :attachment_type, presence: true, inclusion: { in: ATTACHMENT_TYPES }
|
|
21
|
+
|
|
22
|
+
before_validation :normalize_metadata
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def normalize_metadata
|
|
27
|
+
self.metadata = {} unless metadata.is_a?(Hash)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ContextDrop
|
|
4
|
+
class ElementReference < ApplicationRecord
|
|
5
|
+
self.table_name = "context_drop_element_references"
|
|
6
|
+
|
|
7
|
+
belongs_to :feedback,
|
|
8
|
+
class_name: "ContextDrop::Feedback",
|
|
9
|
+
inverse_of: :element_references
|
|
10
|
+
|
|
11
|
+
has_many :attachments,
|
|
12
|
+
class_name: "ContextDrop::Attachment",
|
|
13
|
+
foreign_key: :element_reference_id,
|
|
14
|
+
dependent: :nullify,
|
|
15
|
+
inverse_of: :element_reference
|
|
16
|
+
|
|
17
|
+
validates :tag_name, presence: true
|
|
18
|
+
validates :selector, presence: true
|
|
19
|
+
validate :validate_bounding_rect
|
|
20
|
+
|
|
21
|
+
before_validation :normalize_fields
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_fields
|
|
26
|
+
self.tag_name = tag_name.to_s.strip.downcase if tag_name.present?
|
|
27
|
+
self.attributes_snapshot = {} unless attributes_snapshot.is_a?(Hash)
|
|
28
|
+
self.bounding_rect = {} unless bounding_rect.is_a?(Hash)
|
|
29
|
+
self.viewport = {} unless viewport.is_a?(Hash)
|
|
30
|
+
self.metadata = {} unless metadata.is_a?(Hash)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_bounding_rect
|
|
34
|
+
unless bounding_rect.is_a?(Hash) && bounding_rect.any?
|
|
35
|
+
errors.add(:bounding_rect, "must be a non-empty coordinate hash")
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
required_keys = %w[x y width height]
|
|
40
|
+
stringified_keys = bounding_rect.keys.map(&:to_s)
|
|
41
|
+
missing = required_keys - stringified_keys
|
|
42
|
+
return if missing.empty?
|
|
43
|
+
|
|
44
|
+
errors.add(:bounding_rect, "is missing required keys: #{missing.join(', ')}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ContextDrop
|
|
4
|
+
class Feedback < ApplicationRecord
|
|
5
|
+
self.table_name = "context_drop_feedbacks"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[new reviewed resolved dismissed].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :submitter, polymorphic: true, optional: true
|
|
10
|
+
|
|
11
|
+
has_many :attachments,
|
|
12
|
+
class_name: "ContextDrop::Attachment",
|
|
13
|
+
foreign_key: :feedback_id,
|
|
14
|
+
inverse_of: :feedback,
|
|
15
|
+
dependent: :destroy
|
|
16
|
+
|
|
17
|
+
has_many :element_references,
|
|
18
|
+
class_name: "ContextDrop::ElementReference",
|
|
19
|
+
foreign_key: :feedback_id,
|
|
20
|
+
inverse_of: :feedback,
|
|
21
|
+
dependent: :destroy
|
|
22
|
+
|
|
23
|
+
scope :new_records, -> { where(status: "new") }
|
|
24
|
+
scope :reviewed, -> { where(status: "reviewed") }
|
|
25
|
+
scope :resolved, -> { where(status: "resolved") }
|
|
26
|
+
scope :dismissed, -> { where(status: "dismissed") }
|
|
27
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
28
|
+
|
|
29
|
+
validates :page_url, presence: true
|
|
30
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
31
|
+
validate :ensure_content_or_context_present
|
|
32
|
+
validate :ensure_collection_limits
|
|
33
|
+
|
|
34
|
+
before_validation :normalize_defaults
|
|
35
|
+
|
|
36
|
+
def mark_reviewed!
|
|
37
|
+
update!(status: "reviewed")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mark_resolved!
|
|
41
|
+
update!(status: "resolved")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mark_dismissed!
|
|
45
|
+
update!(status: "dismissed")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def submitter_label
|
|
49
|
+
return "Anonymous" if submitter_type.blank? && submitter_id.blank?
|
|
50
|
+
|
|
51
|
+
if submitter
|
|
52
|
+
if submitter.respond_to?(:email) && submitter.email.present?
|
|
53
|
+
"#{submitter_type}##{submitter_id} (#{submitter.email})"
|
|
54
|
+
elsif submitter.respond_to?(:name) && submitter.name.present?
|
|
55
|
+
"#{submitter_type}##{submitter_id} (#{submitter.name})"
|
|
56
|
+
else
|
|
57
|
+
"#{submitter_type}##{submitter_id}"
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
"#{submitter_type}##{submitter_id}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def normalize_defaults
|
|
67
|
+
self.status = "new" if status.blank?
|
|
68
|
+
self.metadata = {} unless metadata.is_a?(Hash)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ensure_content_or_context_present
|
|
72
|
+
has_message = message.to_s.strip != ""
|
|
73
|
+
has_attachments = attachments.reject(&:marked_for_destruction?).any?
|
|
74
|
+
has_elements = element_references.reject(&:marked_for_destruction?).any?
|
|
75
|
+
|
|
76
|
+
return if has_message || has_attachments || has_elements
|
|
77
|
+
|
|
78
|
+
errors.add(:base, "Feedback must include a message, an attachment, or a selected DOM element")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_collection_limits
|
|
82
|
+
max_att = ContextDrop.config.max_attachments.to_i
|
|
83
|
+
max_el = ContextDrop.config.max_element_references.to_i
|
|
84
|
+
|
|
85
|
+
if attachments.reject(&:marked_for_destruction?).size > max_att
|
|
86
|
+
errors.add(:attachments, "exceeds maximum allowed (#{max_att})")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return unless element_references.reject(&:marked_for_destruction?).size > max_el
|
|
90
|
+
|
|
91
|
+
errors.add(:element_references, "exceeds maximum allowed (#{max_el})")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ContextDrop
|
|
6
|
+
class MetadataSanitizer
|
|
7
|
+
ALLOWED_ATTRIBUTE_KEYS = %w[
|
|
8
|
+
id
|
|
9
|
+
class
|
|
10
|
+
role
|
|
11
|
+
name
|
|
12
|
+
type
|
|
13
|
+
href
|
|
14
|
+
title
|
|
15
|
+
placeholder
|
|
16
|
+
alt
|
|
17
|
+
data-testid
|
|
18
|
+
data-test
|
|
19
|
+
data-cy
|
|
20
|
+
data-qa
|
|
21
|
+
data-turbo-frame
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
FORBIDDEN_METADATA_KEYS = %w[
|
|
25
|
+
outerhtml
|
|
26
|
+
innerhtml
|
|
27
|
+
value
|
|
28
|
+
defaultvalue
|
|
29
|
+
password
|
|
30
|
+
secret
|
|
31
|
+
token
|
|
32
|
+
authorization
|
|
33
|
+
cookie
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
MAX_TEXT_LENGTH = 500
|
|
37
|
+
MAX_SELECTOR_LENGTH = 500
|
|
38
|
+
MAX_DOM_PATH_LENGTH = 1000
|
|
39
|
+
MAX_ATTR_VALUE_LENGTH = 300
|
|
40
|
+
|
|
41
|
+
class SensitiveElementError < ContextDrop::Error; end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def sanitize_element_reference(raw_params)
|
|
45
|
+
params = stringify_hash(raw_params)
|
|
46
|
+
tag_name = params["tag_name"].to_s.strip.downcase
|
|
47
|
+
raw_attrs = stringify_hash(params["attributes_snapshot"] || params["attributes"] || {})
|
|
48
|
+
|
|
49
|
+
if sensitive_element?(tag_name, raw_attrs, params["selector"].to_s)
|
|
50
|
+
raise SensitiveElementError, "Cannot attach excluded or sensitive DOM element"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
client_ref: truncate_str(params["client_ref"], 128),
|
|
55
|
+
tag_name: tag_name,
|
|
56
|
+
selector: truncate_str(params["selector"], MAX_SELECTOR_LENGTH).to_s,
|
|
57
|
+
dom_path: truncate_str(params["dom_path"], MAX_DOM_PATH_LENGTH),
|
|
58
|
+
text_content: sanitize_text_content(tag_name, params["text_content"]),
|
|
59
|
+
attributes_snapshot: sanitize_attributes(raw_attrs),
|
|
60
|
+
bounding_rect: sanitize_bounding_rect(params["bounding_rect"]),
|
|
61
|
+
viewport: sanitize_viewport(params["viewport"]),
|
|
62
|
+
metadata: sanitize_generic_hash(params["metadata"]),
|
|
63
|
+
captured_at: parse_timestamp(params["captured_at"])
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sanitize_feedback_metadata(raw_metadata)
|
|
68
|
+
sanitize_generic_hash(raw_metadata)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sanitize_attachment_metadata(raw_metadata)
|
|
72
|
+
sanitize_generic_hash(raw_metadata)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sensitive_element?(tag_name, attrs, selector = "")
|
|
76
|
+
input_type = attrs["type"].to_s.strip.downcase
|
|
77
|
+
return true if tag_name == "input" && %w[password hidden].include?(input_type)
|
|
78
|
+
return true if attrs.key?("data-feedback-ignore") || attrs.key?("data-feedback-sensitive")
|
|
79
|
+
|
|
80
|
+
autocomplete = attrs["autocomplete"].to_s.strip.downcase
|
|
81
|
+
return true if autocomplete.start_with?("cc-")
|
|
82
|
+
|
|
83
|
+
sel_down = selector.to_s.downcase
|
|
84
|
+
return true if sel_down.include?("data-feedback-ignore") || sel_down.include?("data-feedback-sensitive")
|
|
85
|
+
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def sanitize_attributes(raw_attrs)
|
|
90
|
+
attrs = stringify_hash(raw_attrs)
|
|
91
|
+
sanitized = {}
|
|
92
|
+
|
|
93
|
+
attrs.each do |key, value|
|
|
94
|
+
normalized_key = key.to_s.strip.downcase
|
|
95
|
+
next if normalized_key.empty?
|
|
96
|
+
next if FORBIDDEN_METADATA_KEYS.include?(normalized_key)
|
|
97
|
+
next if normalized_key.start_with?("on")
|
|
98
|
+
|
|
99
|
+
allowed = ALLOWED_ATTRIBUTE_KEYS.include?(normalized_key) ||
|
|
100
|
+
(normalized_key.start_with?("aria-") && normalized_key != "aria-hidden")
|
|
101
|
+
next unless allowed
|
|
102
|
+
|
|
103
|
+
str_val = value.to_s.strip
|
|
104
|
+
if normalized_key == "href" && str_val.downcase.start_with?("javascript:", "data:")
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sanitized[normalized_key] = truncate_str(str_val, MAX_ATTR_VALUE_LENGTH)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sanitized
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def sanitize_bounding_rect(raw_rect)
|
|
115
|
+
rect = stringify_hash(raw_rect)
|
|
116
|
+
return {} if rect.empty?
|
|
117
|
+
|
|
118
|
+
result = {}
|
|
119
|
+
%w[x y width height].each do |coord|
|
|
120
|
+
next unless rect.key?(coord) && !rect[coord].nil?
|
|
121
|
+
|
|
122
|
+
num = Float(rect[coord])
|
|
123
|
+
result[coord] = num.finite? ? num.round(2) : 0.0
|
|
124
|
+
rescue ArgumentError, TypeError
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sanitize_viewport(raw_viewport)
|
|
131
|
+
vp = stringify_hash(raw_viewport)
|
|
132
|
+
return {} if vp.empty?
|
|
133
|
+
|
|
134
|
+
result = {}
|
|
135
|
+
%w[width height].each do |dim|
|
|
136
|
+
next unless vp.key?(dim) && !vp[dim].nil?
|
|
137
|
+
|
|
138
|
+
num = Integer(Float(vp[dim]))
|
|
139
|
+
result[dim] = num.positive? ? num : 0
|
|
140
|
+
rescue ArgumentError, TypeError
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def sanitize_generic_hash(input, depth = 0)
|
|
147
|
+
return {} if depth > 4 || !input.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
cleaned = {}
|
|
150
|
+
input.each do |k, v|
|
|
151
|
+
key_str = k.to_s.strip
|
|
152
|
+
next if key_str.empty?
|
|
153
|
+
next if FORBIDDEN_METADATA_KEYS.include?(key_str.downcase)
|
|
154
|
+
|
|
155
|
+
cleaned[key_str] = case v
|
|
156
|
+
when Hash
|
|
157
|
+
sanitize_generic_hash(v, depth + 1)
|
|
158
|
+
when Array
|
|
159
|
+
v.first(25).map { |item| item.is_a?(Hash) ? sanitize_generic_hash(item, depth + 1) : sanitize_scalar(item) }
|
|
160
|
+
else
|
|
161
|
+
sanitize_scalar(v)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
cleaned
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def sanitize_text_content(tag_name, text)
|
|
170
|
+
return nil if text.nil?
|
|
171
|
+
return nil if %w[input textarea select password].include?(tag_name)
|
|
172
|
+
|
|
173
|
+
normalized = text.to_s.gsub(/\s+/, " ").strip
|
|
174
|
+
return nil if normalized.empty?
|
|
175
|
+
|
|
176
|
+
truncate_str(normalized, MAX_TEXT_LENGTH)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def sanitize_scalar(val)
|
|
180
|
+
case val
|
|
181
|
+
when Numeric, TrueClass, FalseClass, NilClass
|
|
182
|
+
val
|
|
183
|
+
else
|
|
184
|
+
truncate_str(val.to_s, MAX_TEXT_LENGTH)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def truncate_str(value, max_len)
|
|
189
|
+
return nil if value.nil?
|
|
190
|
+
|
|
191
|
+
str = value.to_s
|
|
192
|
+
str.length > max_len ? str[0, max_len] : str
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_timestamp(val)
|
|
196
|
+
return Time.now.utc if val.nil? || val.to_s.strip.empty?
|
|
197
|
+
return val if val.is_a?(Time)
|
|
198
|
+
|
|
199
|
+
Time.iso8601(val.to_s)
|
|
200
|
+
rescue ArgumentError
|
|
201
|
+
Time.now.utc
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def stringify_hash(obj)
|
|
205
|
+
if obj.respond_to?(:to_unsafe_h)
|
|
206
|
+
obj.to_unsafe_h.transform_keys(&:to_s)
|
|
207
|
+
elsif obj.is_a?(Hash)
|
|
208
|
+
obj.transform_keys(&:to_s)
|
|
209
|
+
else
|
|
210
|
+
{}
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
begin
|
|
5
|
+
require "marcel"
|
|
6
|
+
rescue LoadError
|
|
7
|
+
# Fallback to built-in magic byte detection if Marcel is not loaded
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ContextDrop
|
|
11
|
+
class UploadValidator
|
|
12
|
+
class ValidationError < ContextDrop::Error
|
|
13
|
+
attr_reader :http_status
|
|
14
|
+
|
|
15
|
+
def initialize(message, http_status: :unprocessable_entity)
|
|
16
|
+
super(message)
|
|
17
|
+
@http_status = http_status
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
MIME_EXTENSIONS = {
|
|
22
|
+
"image/png" => %w[.png],
|
|
23
|
+
"image/jpeg" => %w[.jpg .jpeg],
|
|
24
|
+
"image/webp" => %w[.webp],
|
|
25
|
+
"image/gif" => %w[.gif]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
|
29
|
+
JPEG_SIGNATURE = "\xFF\xD8\xFF".b.freeze
|
|
30
|
+
GIF87_SIGNATURE = "GIF87a".b.freeze
|
|
31
|
+
GIF89_SIGNATURE = "GIF89a".b.freeze
|
|
32
|
+
RIFF_SIGNATURE = "RIFF".b.freeze
|
|
33
|
+
WEBP_SIGNATURE = "WEBP".b.freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Validates an uploaded file (ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile, or Hash/IO)
|
|
37
|
+
# Returns a normalized Hash `{ io:, filename:, content_type:, byte_size:, dimensions: [w, h] }`
|
|
38
|
+
def validate!(upload)
|
|
39
|
+
io, original_filename = extract_io_and_filename(upload)
|
|
40
|
+
raise ValidationError, "Missing file upload" if io.nil?
|
|
41
|
+
|
|
42
|
+
io.rewind if io.respond_to?(:rewind)
|
|
43
|
+
raw_bytes = io.read.to_s.b
|
|
44
|
+
io.rewind if io.respond_to?(:rewind)
|
|
45
|
+
|
|
46
|
+
byte_size = raw_bytes.bytesize
|
|
47
|
+
raise ValidationError, "Uploaded file cannot be empty" if byte_size.zero?
|
|
48
|
+
|
|
49
|
+
max_size = ContextDrop.config.max_attachment_size.to_i
|
|
50
|
+
if max_size.positive? && byte_size > max_size
|
|
51
|
+
raise ValidationError.new(
|
|
52
|
+
"File size (#{byte_size} bytes) exceeds maximum allowed size (#{max_size} bytes)",
|
|
53
|
+
http_status: :unprocessable_entity
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
detected_mime = detect_magic_mime_type(raw_bytes)
|
|
58
|
+
allowed_types = Array(ContextDrop.config.allowed_content_types)
|
|
59
|
+
|
|
60
|
+
if detected_mime.nil? || !allowed_types.include?(detected_mime)
|
|
61
|
+
raise ValidationError, "Unsupported or invalid file content type (detected: #{detected_mime || 'unknown'})"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sanitized_filename = sanitize_filename(original_filename, detected_mime)
|
|
65
|
+
validate_extension!(sanitized_filename, detected_mime)
|
|
66
|
+
|
|
67
|
+
dimensions = extract_image_dimensions(raw_bytes, detected_mime)
|
|
68
|
+
validate_dimensions!(dimensions)
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
io: StringIO.new(raw_bytes),
|
|
72
|
+
filename: sanitized_filename,
|
|
73
|
+
content_type: detected_mime,
|
|
74
|
+
byte_size: byte_size,
|
|
75
|
+
dimensions: dimensions
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def detect_magic_mime_type(bytes)
|
|
80
|
+
header = bytes[0, 32].to_s.b
|
|
81
|
+
magic_mime = if header.start_with?(PNG_SIGNATURE)
|
|
82
|
+
"image/png"
|
|
83
|
+
elsif header.start_with?(JPEG_SIGNATURE)
|
|
84
|
+
"image/jpeg"
|
|
85
|
+
elsif header.start_with?(GIF87_SIGNATURE) || header.start_with?(GIF89_SIGNATURE)
|
|
86
|
+
"image/gif"
|
|
87
|
+
elsif header.bytesize >= 12 && header[0, 4] == RIFF_SIGNATURE && header[8, 4] == WEBP_SIGNATURE
|
|
88
|
+
"image/webp"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return magic_mime if magic_mime
|
|
92
|
+
|
|
93
|
+
if defined?(Marcel::MimeType)
|
|
94
|
+
marcel_type = Marcel::MimeType.for(StringIO.new(bytes))
|
|
95
|
+
return marcel_type if MIME_EXTENSIONS.key?(marcel_type)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_image_dimensions(bytes, mime_type)
|
|
102
|
+
case mime_type
|
|
103
|
+
when "image/png"
|
|
104
|
+
return nil if bytes.bytesize < 24
|
|
105
|
+
|
|
106
|
+
bytes[16, 8].unpack("NN")
|
|
107
|
+
when "image/gif"
|
|
108
|
+
return nil if bytes.bytesize < 10
|
|
109
|
+
|
|
110
|
+
bytes[6, 4].unpack("vv")
|
|
111
|
+
when "image/webp"
|
|
112
|
+
extract_webp_dimensions(bytes)
|
|
113
|
+
when "image/jpeg"
|
|
114
|
+
extract_jpeg_dimensions(bytes)
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def sanitize_filename(filename, detected_mime)
|
|
121
|
+
default_ext = MIME_EXTENSIONS.fetch(detected_mime, [".bin"]).first
|
|
122
|
+
return "attachment#{default_ext}" if filename.nil? || filename.to_s.strip.empty?
|
|
123
|
+
|
|
124
|
+
base = File.basename(filename.to_s.strip).gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
125
|
+
base = "attachment#{default_ext}" if base.empty? || base.start_with?(".")
|
|
126
|
+
base
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def extract_io_and_filename(upload)
|
|
132
|
+
if upload.respond_to?(:tempfile) && upload.respond_to?(:original_filename)
|
|
133
|
+
[upload.tempfile, upload.original_filename]
|
|
134
|
+
elsif upload.is_a?(Hash)
|
|
135
|
+
io = upload[:io] || upload["io"] || upload[:tempfile] || upload["tempfile"]
|
|
136
|
+
fname = upload[:filename] || upload["filename"] || upload[:original_filename] || upload["original_filename"]
|
|
137
|
+
[io, fname]
|
|
138
|
+
elsif upload.respond_to?(:read)
|
|
139
|
+
fname = upload.respond_to?(:original_filename) ? upload.original_filename : "screenshot.png"
|
|
140
|
+
[upload, fname]
|
|
141
|
+
else
|
|
142
|
+
[nil, nil]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_extension!(filename, detected_mime)
|
|
147
|
+
ext = File.extname(filename).downcase
|
|
148
|
+
allowed_exts = MIME_EXTENSIONS[detected_mime] || []
|
|
149
|
+
return if ext.empty? || allowed_exts.include?(ext)
|
|
150
|
+
|
|
151
|
+
raise ValidationError, "File extension '#{ext}' does not match detected content type '#{detected_mime}'"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_dimensions!(dimensions)
|
|
155
|
+
return if dimensions.nil?
|
|
156
|
+
|
|
157
|
+
max_dims = Array(ContextDrop.config.max_image_dimensions)
|
|
158
|
+
return unless max_dims.size == 2
|
|
159
|
+
|
|
160
|
+
width, height = dimensions
|
|
161
|
+
max_w, max_h = max_dims.map(&:to_i)
|
|
162
|
+
|
|
163
|
+
if width <= 0 || height <= 0
|
|
164
|
+
raise ValidationError, "Invalid image dimensions (#{width}x#{height})"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return unless (max_w.positive? && width > max_w) || (max_h.positive? && height > max_h)
|
|
168
|
+
|
|
169
|
+
raise ValidationError, "Image dimensions (#{width}x#{height}) exceed maximum allowed (#{max_w}x#{max_h})"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def extract_webp_dimensions(bytes)
|
|
173
|
+
return nil if bytes.bytesize < 30
|
|
174
|
+
|
|
175
|
+
chunk_type = bytes[12, 4]
|
|
176
|
+
case chunk_type
|
|
177
|
+
when "VP8 ".b
|
|
178
|
+
w, h = bytes[26, 4].unpack("vv")
|
|
179
|
+
[w & 0x3FFF, h & 0x3FFF]
|
|
180
|
+
when "VP8L".b
|
|
181
|
+
bits = bytes[21, 4].unpack1("V")
|
|
182
|
+
w = (bits & 0x3FFF) + 1
|
|
183
|
+
h = ((bits >> 14) & 0x3FFF) + 1
|
|
184
|
+
[w, h]
|
|
185
|
+
when "VP8X".b
|
|
186
|
+
w = (bytes[24, 3] + "\x00".b).unpack1("V") + 1
|
|
187
|
+
h = (bytes[27, 3] + "\x00".b).unpack1("V") + 1
|
|
188
|
+
[w, h]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def extract_jpeg_dimensions(bytes)
|
|
193
|
+
idx = 2
|
|
194
|
+
len = bytes.bytesize
|
|
195
|
+
while idx + 9 < len
|
|
196
|
+
if bytes.getbyte(idx) != 0xFF
|
|
197
|
+
idx += 1
|
|
198
|
+
next
|
|
199
|
+
end
|
|
200
|
+
marker = bytes.getbyte(idx + 1)
|
|
201
|
+
idx += 2
|
|
202
|
+
next if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7)
|
|
203
|
+
|
|
204
|
+
seg_len = bytes[idx, 2].unpack1("n")
|
|
205
|
+
break if seg_len.nil? || seg_len < 2
|
|
206
|
+
|
|
207
|
+
if [0xC0, 0xC1, 0xC2, 0xC3].include?(marker)
|
|
208
|
+
height, width = bytes[idx + 3, 4].unpack("nn")
|
|
209
|
+
return [width, height]
|
|
210
|
+
end
|
|
211
|
+
idx += seg_len
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<div class="cd-tabs" role="tablist" aria-label="Filter feedback by status">
|
|
2
|
+
<a href="<%= dashboard_feedbacks_path(q: @search_query.presence, page_url: @page_url_filter.presence) %>"
|
|
3
|
+
class="cd-tab <%= 'active' if @current_status.blank? %>">
|
|
4
|
+
All (<%= @status_counts["all"] %>)
|
|
5
|
+
</a>
|
|
6
|
+
<% ContextDrop::Feedback::STATUSES.each do |status_name| %>
|
|
7
|
+
<a href="<%= dashboard_feedbacks_path(status: status_name, q: @search_query.presence, page_url: @page_url_filter.presence) %>"
|
|
8
|
+
class="cd-tab <%= 'active' if @current_status == status_name %>">
|
|
9
|
+
<%= status_name.capitalize %> (<%= @status_counts[status_name] || 0 %>)
|
|
10
|
+
</a>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="cd-card">
|
|
15
|
+
<form method="get" action="<%= dashboard_feedbacks_path %>" class="cd-filter-bar">
|
|
16
|
+
<% if @current_status.present? %>
|
|
17
|
+
<input type="hidden" name="status" value="<%= @current_status %>">
|
|
18
|
+
<% end %>
|
|
19
|
+
<input type="search"
|
|
20
|
+
name="q"
|
|
21
|
+
value="<%= @search_query %>"
|
|
22
|
+
placeholder="Search message, selector, or DOM text..."
|
|
23
|
+
class="cd-input"
|
|
24
|
+
aria-label="Search feedback">
|
|
25
|
+
<input type="text"
|
|
26
|
+
name="page_url"
|
|
27
|
+
value="<%= @page_url_filter %>"
|
|
28
|
+
placeholder="Filter by page URL (e.g. /checkout)"
|
|
29
|
+
class="cd-input"
|
|
30
|
+
aria-label="Filter by page URL">
|
|
31
|
+
<button type="submit" class="cd-btn cd-btn-primary">Filter</button>
|
|
32
|
+
<% if @search_query.present? || @page_url_filter.present? || @current_status.present? %>
|
|
33
|
+
<a href="<%= dashboard_feedbacks_path %>" class="cd-btn">Reset</a>
|
|
34
|
+
<% end %>
|
|
35
|
+
</form>
|
|
36
|
+
|
|
37
|
+
<% if @feedbacks.empty? %>
|
|
38
|
+
<p style="color: var(--cd-muted); padding: 1.5rem 0; text-align: center; margin: 0;">
|
|
39
|
+
No feedback records found matching the current filters.
|
|
40
|
+
</p>
|
|
41
|
+
<% else %>
|
|
42
|
+
<table class="cd-table">
|
|
43
|
+
<thead>
|
|
44
|
+
<tr>
|
|
45
|
+
<th>ID</th>
|
|
46
|
+
<th>Status</th>
|
|
47
|
+
<th>Message & Context</th>
|
|
48
|
+
<th>Page URL</th>
|
|
49
|
+
<th>Submitter</th>
|
|
50
|
+
<th>Created</th>
|
|
51
|
+
<th>Actions</th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
<% @feedbacks.each do |fb| %>
|
|
56
|
+
<tr id="feedback-row-<%= fb.id %>">
|
|
57
|
+
<td><a href="<%= dashboard_feedback_path(fb) %>" class="cd-mono">#<%= fb.id %></a></td>
|
|
58
|
+
<td>
|
|
59
|
+
<span class="cd-badge cd-badge-<%= fb.status %>"><%= fb.status %></span>
|
|
60
|
+
</td>
|
|
61
|
+
<td>
|
|
62
|
+
<div style="font-weight: 500; margin-bottom: 0.25rem;">
|
|
63
|
+
<%= fb.message.present? ? truncate(fb.message, length: 120) : "(No text message)" %>
|
|
64
|
+
</div>
|
|
65
|
+
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; font-size: 0.75rem; color: var(--cd-muted);">
|
|
66
|
+
<span>Attachments: <strong><%= fb.attachments.size %></strong></span>
|
|
67
|
+
<span>Elements: <strong><%= fb.element_references.size %></strong></span>
|
|
68
|
+
<% fb.element_references.first(2).each do |el| %>
|
|
69
|
+
<code class="cd-mono"><%= el.selector %></code>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
</td>
|
|
73
|
+
<td><code class="cd-mono"><%= fb.page_url %></code></td>
|
|
74
|
+
<td><%= fb.submitter_label %></td>
|
|
75
|
+
<td style="white-space: nowrap; color: var(--cd-muted);">
|
|
76
|
+
<%= fb.created_at&.strftime("%Y-%m-%d %H:%M UTC") %>
|
|
77
|
+
</td>
|
|
78
|
+
<td>
|
|
79
|
+
<a href="<%= dashboard_feedback_path(fb) %>" class="cd-btn">Inspect</a>
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
<% end %>
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|