src/derails/action_controller/metal/allow_browser.rb
# frozen_string_literal: true

# :markup: markdown

module ActionController # :nodoc:
  module AllowBrowser
    extend ActiveSupport::Concern

    module ClassMethods
      # Specify the browser versions that will be allowed to access all actions (or
      # some, as limited by `only:` or `except:`). Only browsers matched in the hash
      # or named set passed to `versions:` will be blocked if they're below the
      # versions specified. This means that all other browsers, as well as agents that
      # aren't reporting a user-agent header, will be allowed access.
      #
      # A browser that's blocked will by default be served the file in
      # public/406-unsupported-browser.html with an HTTP status code of "406 Not
      # Acceptable".
      #
      # In addition to specifically named browser versions, you can also pass
      # `:modern` as the set to restrict support to browsers natively supporting webp
      # images, web push, badges, import maps, CSS nesting, and CSS :has. This
      # includes Safari 17.2+, Chrome 120+, Firefox 121+, Opera 106+.
      #
      # You can use https://caniuse.com to check for browser versions supporting the
      # features you use.
      #
      # You can use `ActiveSupport::Notifications` to subscribe to events of browsers
      # being blocked using the `browser_block.action_controller` event name.
      #
      # Examples:
      #
      #     class ApplicationController < ActionController::Base
      #       # Allow only browsers natively supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has
      #       allow_browser versions: :modern
      #     end
      #
      #     class ApplicationController < ActionController::Base
      #       # Allow only browsers natively supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has
      #       allow_browser versions: :modern, block: :handle_outdated_browser
      #
      #       private
      #         def handle_outdated_browser
      #           render file: Rails.root.join("public/custom-error.html"), status: :not_acceptable
      #         end
      #     end
      #
      #     class ApplicationController < ActionController::Base
      #       # All versions of Chrome and Opera will be allowed, but no versions of "internet explorer" (ie). Safari needs to be 16.4+ and Firefox 121+.
      #       allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
      #     end
      #
      #     class MessagesController < ApplicationController
      #       # In addition to the browsers blocked by ApplicationController, also block Opera below 104 and Chrome below 119 for the show action.
      #       allow_browser versions: { opera: 104, chrome: 119 }, only: :show
      #     end
      def allow_browser(versions:, block: -> { render file: Rails.root.join("public/406-unsupported-browser.html"), layout: false, status: :not_acceptable }, **options)
        before_action -> { allow_browser(versions: versions, block: block) }, **options
      end
    end

    private
      def allow_browser(versions:, block:)
        require "useragent"

        if BrowserBlocker.new(request, versions: versions).blocked?
          ActiveSupport::Notifications.instrument("browser_block.action_controller", request: request, versions: versions) do
            block.is_a?(Symbol) ? send(block) : instance_exec(&block)
          end
        end
      end

      class BrowserBlocker # :nodoc:
        SETS = {
          modern: { safari: 17.2, chrome: 120, firefox: 121, opera: 106, ie: false }
        }

        attr_reader :request, :versions

        def initialize(request, versions:)
          @request, @versions = request, versions
        end

        def blocked?
          user_agent_version_reported? && unsupported_browser?
        end

        private
          def parsed_user_agent
            @parsed_user_agent ||= UserAgent.parse(request.user_agent)
          end

          def user_agent_version_reported?
            request.user_agent.present? && parsed_user_agent.version.to_s.present?
          end

          def unsupported_browser?
            version_guarded_browser? && version_below_minimum_required? && !bot?
          end

          def version_guarded_browser?
            minimum_browser_version_for_browser != nil
          end

          def bot?
            parsed_user_agent.bot?
          end

          def version_below_minimum_required?
            if minimum_browser_version_for_browser
              parsed_user_agent.version < UserAgent::Version.new(minimum_browser_version_for_browser.to_s)
            else
              true
            end
          end

          def minimum_browser_version_for_browser
            expanded_versions[normalized_browser_name]
          end

          def expanded_versions
            @expanded_versions ||= (SETS[versions] || versions).with_indifferent_access
          end

          def normalized_browser_name
            case name = parsed_user_agent.browser.downcase
            when "internet explorer" then "ie"
            else name
            end
          end
      end
  end
end
