src/derails/action_dispatch/http/permissions_policy.rb
# frozen_string_literal: true

# :markup: markdown

require "active_support/core_ext/object/deep_dup"

module ActionDispatch # :nodoc:
  # # Action Dispatch PermissionsPolicy
  #
  # Configures the HTTP
  # [Feature-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy)
  # response header to specify which browser features the current
  # document and its iframes can use.
  #
  # Example global policy:
  #
  #     Rails.application.config.permissions_policy do |policy|
  #       policy.camera      :none
  #       policy.gyroscope   :none
  #       policy.microphone  :none
  #       policy.usb         :none
  #       policy.fullscreen  :self
  #       policy.payment     :self, "https://secure.example.com"
  #     end
  #
  # The Feature-Policy header has been renamed to Permissions-Policy. The
  # Permissions-Policy requires a different implementation and isn't yet supported
  # by all browsers. To avoid having to rename this middleware in the future we
  # use the new name for the middleware but keep the old header name and
  # implementation for now.
  class PermissionsPolicy
    class Middleware
      def initialize(app)
        @app = app
      end

      def call(env)
        _, headers, _ = response = @app.call(env)

        return response if policy_present?(headers)

        request = ActionDispatch::Request.new(env)

        if policy = request.permissions_policy
          headers[ActionDispatch::Constants::FEATURE_POLICY] = policy.build(request.controller_instance)
        end

        if policy_empty?(policy)
          headers.delete(ActionDispatch::Constants::FEATURE_POLICY)
        end

        response
      end

      private
        def policy_present?(headers)
          headers[ActionDispatch::Constants::FEATURE_POLICY]
        end

        def policy_empty?(policy)
          policy&.directives&.empty?
        end
    end

    module Request
      POLICY = "action_dispatch.permissions_policy"

      def permissions_policy
        get_header(POLICY)
      end

      def permissions_policy=(policy)
        set_header(POLICY, policy)
      end
    end

    MAPPINGS = {
      self: "'self'",
      none: "'none'",
    }.freeze

    # List of available permissions can be found at
    # https://github.com/w3c/webappsec-permissions-policy/blob/main/features.md#policy-controlled-features
    DIRECTIVES = {
      accelerometer:        "accelerometer",
      ambient_light_sensor: "ambient-light-sensor",
      autoplay:             "autoplay",
      camera:               "camera",
      display_capture:      "display-capture",
      encrypted_media:      "encrypted-media",
      fullscreen:           "fullscreen",
      geolocation:          "geolocation",
      gyroscope:            "gyroscope",
      hid:                  "hid",
      idle_detection:       "idle-detection",
      keyboard_map:         "keyboard-map",
      magnetometer:         "magnetometer",
      microphone:           "microphone",
      midi:                 "midi",
      payment:              "payment",
      picture_in_picture:   "picture-in-picture",
      screen_wake_lock:     "screen-wake-lock",
      serial:               "serial",
      sync_xhr:             "sync-xhr",
      usb:                  "usb",
      web_share:            "web-share",
    }.freeze

    private_constant :MAPPINGS, :DIRECTIVES

    attr_reader :directives

    def initialize
      @directives = {}
      yield self if block_given?
    end

    def initialize_copy(other)
      @directives = other.directives.deep_dup
    end

    DIRECTIVES.each do |name, directive|
      define_method(name) do |*sources|
        if sources.first
          @directives[directive] = apply_mappings(sources)
        else
          @directives.delete(directive)
        end
      end
    end

    def build(context = nil)
      build_directives(context).compact.join("; ")
    end

    private
      def apply_mappings(sources)
        sources.map do |source|
          case source
          when Symbol
            apply_mapping(source)
          when String, Proc
            source
          else
            raise ArgumentError, "Invalid HTTP permissions policy source: #{source.inspect}"
          end
        end
      end

      def apply_mapping(source)
        MAPPINGS.fetch(source) do
          raise ArgumentError, "Unknown HTTP permissions policy source mapping: #{source.inspect}"
        end
      end

      def build_directives(context)
        @directives.map do |directive, sources|
          if sources.is_a?(Array)
            "#{directive} #{build_directive(sources, context).join(' ')}"
          elsif sources
            directive
          else
            nil
          end
        end
      end

      def build_directive(sources, context)
        sources.map { |source| resolve_source(source, context) }
      end

      def resolve_source(source, context)
        case source
        when String
          source
        when Symbol
          source.to_s
        when Proc
          if context.nil?
            raise RuntimeError, "Missing context for the dynamic permissions policy source: #{source.inspect}"
          else
            context.instance_exec(&source)
          end
        else
          raise RuntimeError, "Unexpected permissions policy source: #{source.inspect}"
        end
      end
  end

  ActiveSupport.on_load(:action_dispatch_request) do
    include ActionDispatch::PermissionsPolicy::Request
  end
end
