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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -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 &amp; 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>