# 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