Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine. Co-authored-by: Cursor <cursoragent@cursor.com>
85 lines
2.8 KiB
Ruby
85 lines
2.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Analytics
|
|
class SnapshotIngest
|
|
MAX_BYTES = 1_800_000
|
|
MIN_REFRESH = 12.hours
|
|
RATE_LIMIT_PER_HOUR = 20
|
|
|
|
Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true)
|
|
|
|
def initialize(path:, device:, width:, height:, image:, remote_ip:)
|
|
@path = path
|
|
@device = device.to_s
|
|
@width = width
|
|
@height = height
|
|
@image = image
|
|
@remote_ip = remote_ip.to_s.presence || "unknown"
|
|
end
|
|
|
|
def call
|
|
return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited?
|
|
|
|
page_path = PathNormalizer.normalize(@path)
|
|
return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path)
|
|
return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device)
|
|
return Result.new(ok: false, skipped: true, error: "image") unless valid_image?
|
|
|
|
width = Integer(@width)
|
|
height = Integer(@height)
|
|
return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000
|
|
|
|
snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device)
|
|
if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached?
|
|
return Result.new(ok: true, skipped: true)
|
|
end
|
|
|
|
snap.width = width
|
|
snap.height = height
|
|
snap.captured_at = Time.current
|
|
snap.save!
|
|
snap.image.purge if snap.image.attached?
|
|
snap.image.attach(
|
|
io: image_io,
|
|
filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg",
|
|
content_type: "image/jpeg"
|
|
)
|
|
|
|
Result.new(ok: true, skipped: false)
|
|
rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e
|
|
Result.new(ok: false, skipped: true, error: e.class.name)
|
|
end
|
|
|
|
private
|
|
|
|
def rate_limited?
|
|
key = "analytics:snapshot:#{@remote_ip}"
|
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
|
count = redis.incr(key)
|
|
redis.expire(key, 3600) if count == 1
|
|
count > RATE_LIMIT_PER_HOUR
|
|
rescue StandardError => e
|
|
Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}")
|
|
false
|
|
end
|
|
|
|
def valid_image?
|
|
return false if @image.blank?
|
|
return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read)
|
|
return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES
|
|
|
|
content_type = @image.content_type.to_s if @image.respond_to?(:content_type)
|
|
content_type.blank? || content_type.start_with?("image/")
|
|
end
|
|
|
|
def image_io
|
|
if @image.respond_to?(:tempfile)
|
|
@image.tempfile.rewind
|
|
@image.tempfile
|
|
else
|
|
StringIO.new(@image.read)
|
|
end
|
|
end
|
|
end
|
|
end
|