lib/rodauth.rb



# frozen-string-literal: true

require 'securerandom'

module Rodauth
  def self.load_dependencies(app, opts={})
    json_opt = opts.fetch(:json, app.opts[:rodauth_json])
    if json_opt
      app.plugin :json
      app.plugin :json_parser
    end

    unless json_opt == :only
      require 'tilt/string'
      app.plugin :render

      case opts.fetch(:csrf, app.opts[:rodauth_csrf])
      when false
        # nothing
      when :rack_csrf
        # :nocov:
        app.plugin :csrf
        # :nocov:
      else
        app.plugin :route_csrf
      end

      app.plugin :flash unless opts[:flash] == false
      app.plugin :h
    end
  end

  def self.configure(app, opts={}, &block)
    json_opt = app.opts[:rodauth_json] = opts.fetch(:json, app.opts[:rodauth_json])
    csrf = app.opts[:rodauth_csrf] = opts.fetch(:csrf, app.opts[:rodauth_csrf])
    app.opts[:rodauth_route_csrf] = case csrf
    when false, :rack_csrf
      false
    else
      json_opt != :only
    end
    auth_class = (app.opts[:rodauths] ||= {})[opts[:name]] ||= Class.new(Auth)
    if !auth_class.roda_class
      auth_class.roda_class = app
    elsif auth_class.roda_class != app
      auth_class = app.opts[:rodauths][opts[:name]] = Class.new(auth_class)
      auth_class.roda_class = app
    end
    auth_class.configure(&block)
  end

  FEATURES = {}

  class FeatureConfiguration < Module
    def def_configuration_methods(feature)
      private_methods = feature.private_instance_methods.map(&:to_sym)
      priv = proc{|m| private_methods.include?(m)}
      feature.auth_methods.each{|m| def_auth_method(m, priv[m])}
      feature.auth_value_methods.each{|m| def_auth_value_method(m, priv[m])}
      feature.auth_private_methods.each{|m| def_auth_private_method(m)}
    end

    private

    def def_auth_method(meth, priv)
      define_method(meth) do |&block|
        @auth.send(:define_method, meth, &block)
        @auth.send(:private, meth) if priv
      end
    end

    def def_auth_private_method(meth)
      umeth = :"_#{meth}"
      define_method(meth) do |&block|
        @auth.send(:define_method, umeth, &block)
        @auth.send(:private, umeth)
      end
    end

    def def_auth_value_method(meth, priv)
      define_method(meth) do |v=nil, &block|
        block ||= proc{v}
        @auth.send(:define_method, meth, &block)
        @auth.send(:private, meth) if priv
      end
    end
  end

  class Feature < Module
    [:auth, :auth_value, :auth_private].each do |meth|
      name = :"#{meth}_methods"
      define_method(name) do |*v|
        iv = :"@#{name}"
        existing = instance_variable_get(iv) || []
        if v.empty?
          existing
        else
          instance_variable_set(iv, existing + v)
        end
      end
    end

    attr_accessor :feature_name
    attr_accessor :dependencies
    attr_accessor :routes
    attr_accessor :configuration

    def route(name=feature_name, default=name.to_s.tr('_', '-'), &block)
      route_meth = :"#{name}_route"
      auth_value_method route_meth, default

      define_method(:"#{name}_path"){|opts={}| route_path(send(route_meth), opts)}
      define_method(:"#{name}_url"){|opts={}| route_url(send(route_meth), opts)}

      handle_meth = :"handle_#{name}"
      internal_handle_meth = :"_#{handle_meth}"
      before route_meth
      define_method(internal_handle_meth, &block)

      define_method(handle_meth) do
        request.is send(route_meth) do
          check_csrf if check_csrf?
          before_rodauth
          send(internal_handle_meth, request)
        end
      end

      routes << handle_meth
    end

    def self.define(name, constant=nil, &block)
      feature = new
      feature.dependencies = []
      feature.routes = []
      feature.feature_name = name
      configuration = feature.configuration = FeatureConfiguration.new
      feature.module_eval(&block)
      configuration.def_configuration_methods(feature)

      # :nocov:
      if constant
      # :nocov:
        Rodauth.const_set(constant, feature)
        Rodauth::FeatureConfiguration.const_set(constant, configuration)
      end

      FEATURES[name] = feature
    end

    def configuration_module_eval(&block)
      configuration.module_eval(&block)
    end

    if RUBY_VERSION >= '2.5'
      DEPRECATED_ARGS = [{:uplevel=>1}]
    else
      # :nocov:
      DEPRECATED_ARGS = []
      # :nocov:
    end
    def def_deprecated_alias(new, old)
      configuration_module_eval do
        define_method(old) do |*a, &block|
          warn("Deprecated #{old} method used during configuration, switch to using #{new}", *DEPRECATED_ARGS)
          send(new, *a, &block)
        end
      end
      define_method(old) do
        warn("Deprecated #{old} method called at runtime, switch to using #{new}", *DEPRECATED_ARGS)
        send(new)
      end
    end

    DEFAULT_REDIRECT_BLOCK = proc{default_redirect}
    def redirect(name=feature_name, &block)
      meth = :"#{name}_redirect"
      block ||= DEFAULT_REDIRECT_BLOCK
      define_method(meth, &block)
      auth_value_methods meth
    end

    def view(page, title, name=feature_name)
      meth = :"#{name}_view"
      title_meth = :"#{name}_page_title"
      translatable_method(title_meth, title)
      define_method(meth) do
        view(page, send(title_meth))
      end
      auth_methods meth
    end

    def loaded_templates(v)
      define_method(:loaded_templates) do
        super().concat(v)
      end
      private :loaded_templates
    end

    def depends(*deps)
      dependencies.concat(deps)
    end

    %w'after before'.each do |hook|
      define_method(hook) do |name=feature_name|
        meth = "#{hook}_#{name}"
        class_eval("def #{meth}; super if defined?(super); _#{meth}; hook_action(:#{hook}, :#{name}); nil end", __FILE__, __LINE__)
        class_eval("def _#{meth}; nil end", __FILE__, __LINE__)
        private meth, :"_#{meth}"
        auth_private_methods(meth)
      end
    end

    def additional_form_tags(name=feature_name)
      auth_value_method(:"#{name}_additional_form_tags", nil)
    end

    def session_key(meth, value)
      define_method(meth){convert_session_key(value)}
      auth_value_methods(meth)
    end

    def auth_value_method(meth, value)
      define_method(meth){value}
      auth_value_methods(meth)
    end

    def translatable_method(meth, value)
      define_method(meth){translate(meth, value)}
      auth_value_methods(meth)
    end

    def auth_cached_method(meth, iv=:"@#{meth}")
      umeth = :"_#{meth}"
      define_method(meth) do
        if instance_variable_defined?(iv)
          instance_variable_get(iv)
        else
          instance_variable_set(iv, send(umeth))
        end
      end
      auth_private_methods(meth)
    end

    [:notice_flash, :error_flash, :button].each do |meth|
      define_method(meth) do |v, name=feature_name|
        translatable_method(:"#{name}_#{meth}", v)
      end
    end
  end

  class Auth
    class << self
      attr_accessor :roda_class
      attr_reader :features
      attr_reader :routes
      attr_accessor :route_hash
    end

    def self.inherited(subclass)
      super
      subclass.instance_exec do
        @features = []
        @routes = []
        @route_hash = {}
      end
    end

    def self.configure(&block)
      Configuration.new(self, &block)
    end

    def self.freeze
      @features.freeze
      @routes.freeze
      @route_hash.freeze
      super
    end
  end

  class Configuration
    attr_reader :auth

    def initialize(auth, &block)
      @auth = auth
      load_feature(:base)
      instance_exec(&block)
      auth.allocate.post_configure
    end

    def enable(*features)
      new_features = features - @auth.features
      new_features.each{|f| load_feature(f)}
      @auth.features.concat(new_features)
    end

    private

    def load_feature(feature_name)
      require "rodauth/features/#{feature_name}"
      feature = FEATURES[feature_name]
      enable(*feature.dependencies)
      extend feature.configuration

      @auth.routes.concat(feature.routes)
      @auth.send(:include, feature)
    end
  end

  module InstanceMethods
    def rodauth(name=nil)
      if name
        (@_rodauths ||= {})[name] ||= self.class.rodauth(name).new(self)
      else
        @_rodauth ||= self.class.rodauth.new(self)
      end
    end
  end

  module ClassMethods
    def rodauth(name=nil)
      opts[:rodauths][name]
    end

    def precompile_rodauth_templates
      instance = allocate
      rodauth = instance.rodauth

      view_opts = rodauth.send(:loaded_templates).map do |page|
        rodauth.send(:_view_opts, page)
      end
      view_opts << rodauth.send(:button_opts, '', {})

      view_opts.each do |opts|
        instance.send(:retrieve_template, opts).send(:compiled_method, opts[:locals].keys.sort_by(&:to_s))
      end

      nil
    end

    def freeze
      opts[:rodauths].each_value(&:freeze)
      opts[:rodauths].freeze
      super
    end
  end

  module RequestMethods
    def rodauth(name=nil)
      scope.rodauth(name).route!
    end
  end
end