# frozen_string_literal: true require "active_support" # Pass between different controller flows via stashed redirects # # Stash a redirect to execute a controller flow within another and return to the original flow later. module ActionController::StashedRedirects extend ActiveSupport::Concern autoload :VERSION, "action_controller/stashed_redirects/version" # Allow a general `rescue ActionController::StashedRedirects::Error`. Error = Module.new class MissingRedirectError < StandardError include Error attr_reader :purpose def initialize(purpose) super "can't extract a stashed redirect_url to redirect_to" @purpose = purpose end end class_methods do # Adds a `before_action` to stash a redirect in a given `on:` action. # # stash_redirect_for :sign_in, on: :new # stash_redirect_for :sign_in, on: %i[ new edit ] # stash_redirect_for :sign_in, on: :new, url: -> { update_post_path(@post) } def stash_redirect_for(purpose, on:, url: DEFAULT_URL) before_action(-> { stash_redirect_for(purpose, url: url) }, only: on) end end private # Stashes a redirect URL in the `session` under the given +purpose+. # # An explicit +redirect_url+ can be passed in `from:`, otherwise the redirect URL is # derived from `params[:redirect_url]` then falling back to `request.referer` on GET requests. # # stash_redirect_for :sign_in # stash_redirect_for :sign_in, from: url_from(params[:redirect_url]) || root_url # stash_redirect_for :sign_in, from: :param # Only derive the redirect URL from `params[:redirect_url]`. # stash_redirect_for :sign_in, from: :referer # Only derive the redirect URL from `request.referer`. def stash_redirect_for(purpose, url: DEFAULT_URL) if url = derive_stash_redirect_url_from(url) session[KEY_GENERATOR.(purpose)] = url else raise ArgumentError, "missing a redirect_url to stash, pass one via from: or via a redirect_url URL param" end end # Finds and deletes the redirect stashed in `session` under the given +purpose+, then redirects. # # redirect_from_stashed :sign_in # # Raises if no stashed redirect is found under the given +purpose+. # # Relies on +redirect_to+'s open redirect protection, see it's documentation for more. def redirect_from_stashed(purpose) redirect_to stashed_redirect_url_for(purpose) end # Deletes and returns the redirect stashed in the `session` under the given +purpose+ if any. # # discard_stashed_redirect_for :sign_in # => the sign_in redirect URL or nil. def discard_stashed_redirect_for(purpose) session.delete(KEY_GENERATOR.(purpose)) end def stashed_redirect_url_for(purpose) url_from(discard_stashed_redirect_for(purpose)) or raise MissingRedirectError, purpose end def derive_stash_redirect_url_from(url) case url when DEFAULT_URL then redirect_url when String then url_from url when Symbol, Proc then url_from instance_exec(self, &url) end end # Syntactic sugar for redirecting to `redirect_url` with a fallback. # # redirect_forward_or_to root_url # => redirect_to redirect_url || root_url def redirect_forward_or_to(fallback_url) redirect_to redirect_url || fallback_url end # Looks up a redirect URL from `params[:redirect_url]` using # Rails' `url_from` as the protection mechanism to ensure it's a valid internal redirect. # See the `url_from` docs: https://api.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-url_from # # You probably want to use `redirect_forward_or_to`. def redirect_url = url_from(params[:redirect_url]) DEFAULT_URL = Object.new KEY_GENERATOR = ->(purpose) { "__url_stash_#{purpose}" } private_constant :KEY_GENERATOR end ActiveSupport.on_load(:action_controller) { include ActionController::StashedRedirects }