lib/roda/plugins/sinatra_helpers.rb



# frozen-string-literal: true

require 'rack/mime'
begin
  require 'rack/files'
rescue LoadError
  require 'rack/file'
end


#
class Roda
  module RodaPlugins
    # The sinatra_helpers plugin ports most of the helper methods
    # defined in Sinatra::Helpers to Roda, other than those
    # helpers that were already covered by other plugins such
    # as caching and streaming.
    #
    # Unlike Sinatra, the helper methods are added to either
    # the request or response classes instead of directly to
    # the scope of the route block.  However, for consistency
    # with Sinatra, delegate methods are added to the scope
    # of the route block that call the methods on the request
    # or response.  If you do not want to pollute the namespace
    # of the route block, you should load the plugin with the
    # <tt>delegate: false</tt> option:
    #
    #   plugin :sinatra_helpers, delegate: false
    #
    # == Class Methods Added
    #
    # The only class method added by this plugin is +mime_type+,
    # which is a shortcut for retrieving or setting MIME types
    # in Rack's MIME database:
    #
    #   Roda.mime_type 'csv' # => 'text/csv'
    #   Roda.mime_type 'foobar', 'application/foobar' # set
    #
    # == Request Methods Added 
    #
    # In addition to adding the following methods, this changes
    # +redirect+ to use a 303 response status code by default for
    # HTTP 1.1 non-GET requests, and to automatically use
    # absolute URIs if the +:absolute_redirects+ Roda class option
    # is true, and to automatically prefix redirect paths with the
    # script name if the +:prefixed_redirects+ Roda class option is
    # true.
    #
    # When adding delegate methods, a logger method is added to
    # the route block scope that calls the logger method on the request.
    # 
    # === back
    #
    # +back+ is an alias to referrer, so you can do:
    #
    #   redirect back
    #
    # === error
    #
    # +error+ sets the response status code to 500 (or a status code you provide),
    # and halts the request.  It takes an optional body:
    #
    #   error           # 500 response, empty boby
    #   error 501       # 501 response, empty body
    #   error 'b'       # 500 response, 'b' body
    #   error 501, 'b'  # 501 response, 'b' body
    #
    # === not_found
    #
    # +not_found+ sets the response status code to 404 and halts the request.
    # It takes an optional body:
    #
    #   not_found      # 404 response, empty body
    #   not_found 'b'  # 404 response, 'b' body
    #
    # === uri
    #
    # +uri+ by default returns absolute URIs that are prefixed
    # by the script name:
    #
    #   request.script_name # => '/foo'
    #   uri '/bar'          # => 'http://example.org/foo/bar'
    #
    # You can turn of the absolute or script name prefixing if you want:
    #
    #   uri '/bar', false        # => '/foo/bar'
    #   uri '/bar', true, false  # => 'http://example.org/bar'
    #   uri '/bar', false, false # => '/bar'
    #
    # This method is aliased as +url+ and +to+.
    #
    # === send_file
    #
    # This will serve the file with the given path from the file system:
    #
    #   send_file 'path/to/file.txt'
    #
    # Options:
    #
    # :disposition :: Set the Content-Disposition to the given disposition.
    # :filename :: Set the Content-Disposition to attachment (unless :disposition is set),
    #              and set the filename parameter to the value.
    # :last_modified :: Explicitly set the Last-Modified header to the given value, and
    #                   return a not modified response if there has not been modified since
    #                   the previous request.  This option requires the caching plugin.
    # :status :: Override the status for the response.
    # :type :: Set the Content-Type to use for this response.
    #
    # == Response Methods Added
    #
    # === body
    #
    # When called with an argument or block, +body+ sets the body, otherwise
    # it returns the body:
    #
    #   body      # => []
    #   body('b') # set body to 'b'
    #   body{'b'} # set body to 'b', but don't call until body is needed
    #
    # === body=
    #
    # +body+ sets the body to the given value:
    #
    #   response.body = 'v'
    #
    # This method is not delegated to the scope of the route block,
    # call +body+ with an argument to set the value.
    #
    # === status
    #
    # When called with an argument, +status+ sets the status, otherwise
    # it returns the status:
    #
    #   status      # => 200
    #   status(301) # sets status to 301
    #
    # === headers
    #
    # When called with an argument, +headers+ merges the given headers
    # into the current headers, otherwise it returns the headers:
    #
    #   headers['Foo'] = 'Bar'
    #   headers 'Foo' => 'Bar'
    #
    # === mime_type
    #
    # +mime_type+ just calls the Roda class method to get the mime_type.
    #
    # === content_type
    #
    # When called with an argument, +content_type+ sets the Content-Type
    # based on the argument, otherwise it returns the Content-Type.
    #
    #   mime_type             # => 'text/html'
    #   mime_type 'csv'       # set Content-Type to 'text/csv'
    #   mime_type :csv        # set Content-Type to 'text/csv'
    #   mime_type '.csv'      # set Content-Type to 'text/csv'
    #   mime_type 'text/csv'  # set Content-Type to 'text/csv'
    #
    # Options:
    #
    # :charset :: Set the charset for the mime type to the given charset, if the charset is
    #             not already set in the mime type.
    # :default :: Uses the given type if the mime type is not known.  If this option is not
    #             used and the mime type is not known, an exception will be raised.
    #
    # === attachment
    #
    # When called with no filename, +attachment+ just sets the Content-Disposition
    # to attachment.  When called with a filename, this sets the Content-Disposition
    # to attachment with the appropriate filename parameter, and if the filename
    # extension is recognized, this also sets the Content-Type to the appropriate
    # MIME type if not already set.
    #
    #   attachment          # set Content-Disposition to 'attachment'
    #   attachment 'a.csv'  # set Content-Disposition to 'attachment;filename="a.csv"',
    #                       # also set Content-Type to 'text/csv'
    #
    # === status predicates
    #
    # This adds the following predicate methods for checking the status:
    #
    #   informational?  # 100-199
    #   success?        # 200-299
    #   redirect?       # 300-399
    #   client_error?   # 400-499
    #   not_found?      # 404
    #   server_error?   # 500-599
    #
    # If the status has not yet been set for the response, these will
    # return +nil+.
    #
    # == License
    #
    # The implementation was originally taken from Sinatra,
    # which is also released under the MIT License:
    #
    # Copyright (c) 2007, 2008, 2009 Blake Mizerany
    # Copyright (c) 2010, 2011, 2012, 2013, 2014 Konstantin Haase
    # 
    # Permission is hereby granted, free of charge, to any person
    # obtaining a copy of this software and associated documentation
    # files (the "Software"), to deal in the Software without
    # restriction, including without limitation the rights to use,
    # copy, modify, merge, publish, distribute, sublicense, and/or sell
    # copies of the Software, and to permit persons to whom the
    # Software is furnished to do so, subject to the following
    # conditions:
    # 
    # The above copyright notice and this permission notice shall be
    # included in all copies or substantial portions of the Software.
    # 
    # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
    # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
    # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
    # OTHER DEALINGS IN THE SOFTWARE.
    module SinatraHelpers
      UTF8_ENCODING = Encoding.find('UTF-8')
      ISO88591_ENCODING = Encoding.find('ISO-8859-1')
      BINARY_ENCODING = Encoding.find('BINARY')

      RACK_FILES = defined?(Rack::Files) ? Rack::Files : Rack::File

      # Depend on the status_303 plugin.
      def self.load_dependencies(app, _opts = nil)
        app.plugin :status_303
      end

      # Add delegate methods to the route block scope
      # calling request or response methods, unless the
      # :delegate option is false.
      def self.configure(app, opts=OPTS)
        app.send(:include, DelegateMethods) unless opts[:delegate] == false
      end

      # Class used when the response body is set explicitly, instead
      # of using Roda's default body array and response.write to
      # write to it.
      class DelayedBody
        # Save the block that will return the body, it won't be
        # called until the body is needed.
        def initialize(&block)
          @block = block
        end

        # If the body is a String, yield it, otherwise yield each string
        # returned by calling each on the body.
        def each
          v = value
          if v.is_a?(String)
            yield v
          else
            v.each{|s| yield s}
          end
        end

        # Assume that if the body has been set directly that it is
        # never empty.
        def empty?
          false
        end

        # Return the body as a single string, mostly useful during testing.
        def join
          a = []
          each{|s| a << s}
          a.join
        end

        # Calculate the length for the body.
        def length
          length = 0
          each{|s| length += s.bytesize}
          length
        end

        private

        # Cache the body returned by the block.  This way the block won't
        # be called multiple times.
        def value
          @value ||= @block.call
        end
      end

      module RequestMethods
        # Alias for referrer
        def back
          referrer
        end

        # Halt processing and return the error status provided with the given code and
        # optional body.
        # If a single argument is given and it is not an integer, consider it the body
        # and use a 500 status code.
        def error(code=500, body = nil)
          unless code.is_a?(Integer)
            body = code
            code = 500
          end

          response.status = code
          response.body = body if body
          halt
        end

        # Halt processing and return a 404 response with an optional body.
        def not_found(body = nil)
          error(404, body)
        end

        # If the absolute_redirects or :prefixed_redirects roda class options has been set, respect those
        # and update the path.
        def redirect(path=(no_add_script_name = true; default_redirect_path), status=default_redirect_status)
          opts = roda_class.opts
          absolute_redirects = opts[:absolute_redirects]
          prefixed_redirects = no_add_script_name ? false : opts[:prefixed_redirects]
          path = uri(path, absolute_redirects, prefixed_redirects) if absolute_redirects || prefixed_redirects
          super(path, status)
        end

        # Use the contents of the file at +path+ as the response body.  See plugin documentation for options.
        def send_file(path, opts = OPTS)
          res = response
          headers = res.headers
          if opts[:type] || !headers[RodaResponseHeaders::CONTENT_TYPE]
            res.content_type(opts[:type] || ::File.extname(path), :default => 'application/octet-stream')
          end

          disposition = opts[:disposition]
          filename    = opts[:filename]
          if disposition || filename
            disposition ||= 'attachment'
            filename = path if filename.nil?
            res.attachment(filename, disposition)
          end

          if lm = opts[:last_modified]
            last_modified(lm)
          end

          file = RACK_FILES.new nil
          s, h, b = if Rack.release > '2'
            file.serving(self, path)
          else
            file.path = path
            file.serving(@env)
          end

          res.status = opts[:status] || s
          headers.delete(RodaResponseHeaders::CONTENT_LENGTH)
          headers.replace(h.merge!(headers))
          res.body = b

          halt
        rescue Errno::ENOENT
          not_found
        end

        # Generates the absolute URI for a given path in the app.
        # Takes Rack routers and reverse proxies into account.
        def uri(addr = nil, absolute = true, add_script_name = true)
          addr = addr.to_s if addr
          return addr if addr =~ /\A[A-z][A-z0-9\+\.\-]*:/
          uri = if absolute
            h = if @env.has_key?("HTTP_X_FORWARDED_HOST") || port != (ssl? ? 443 : 80)
              host_with_port
            else
              host
            end
            ["http#{'s' if ssl?}://#{h}"]
          else
            ['']
          end
          uri << script_name.to_s if add_script_name
          uri << (addr || path_info)
          File.join(uri)
        end
        alias url uri
        alias to uri
      end

      module ResponseMethods
        # Set or retrieve the response status code.
        def status(value = nil || (return @status))
          @status = value
        end

        # Set or retrieve the response body. When a block is given,
        # evaluation is deferred until the body is needed.
        def body(value = (return @body unless defined?(yield); nil), &block)
          if block
            @body = DelayedBody.new(&block)
          else
            self.body = value
          end
        end

        # Set the body to the given value.
        def body=(body)
          @body = DelayedBody.new{body}
        end

        # If the body is a DelayedBody, set the appropriate length for it.
        def finish
          @length = @body.length if @body.is_a?(DelayedBody) && !@headers[RodaResponseHeaders::CONTENT_LENGTH]
          super
        end

        # Set multiple response headers with Hash, or return the headers if no
        # argument is given.
        def headers(hash = nil || (return @headers))
          @headers.merge!(hash)
        end

        # Look up a media type by file extension in Rack's mime registry.
        def mime_type(type)
          roda_class.mime_type(type)
        end

        # Set the Content-Type of the response body given a media type or file
        # extension.  See plugin documentation for options.
        def content_type(type = nil || (return @headers[RodaResponseHeaders::CONTENT_TYPE]), opts = OPTS)
          unless (mime_type = mime_type(type) || opts[:default])
            raise RodaError, "Unknown media type: #{type}"
          end

          unless opts.empty?
            opts.each do |key, val|
              next if key == :default || (key == :charset && mime_type.include?('charset'))
              val = val.inspect if val =~ /[";,]/
              mime_type += "#{mime_type.include?(';') ? ', ' : ';'}#{key}=#{val}"
            end
          end

          @headers[RodaResponseHeaders::CONTENT_TYPE] = mime_type
        end

        # Set the Content-Disposition to "attachment" with the specified filename,
        # instructing the user agents to prompt to save.
        def attachment(filename = nil, disposition='attachment')
          if filename
            param_filename = File.basename(filename)
            encoding = param_filename.encoding

            needs_encoding = param_filename.gsub!(/[^ 0-9a-zA-Z!\#$&\+\.\^_`\|~]+/, '-')
            params = "; filename=#{param_filename.inspect}"

            if needs_encoding && (encoding == UTF8_ENCODING || encoding == ISO88591_ENCODING)
              # File name contains non attr-char characters from RFC 5987 Section 3.2.1

              encoded_filename = File.basename(filename).force_encoding(BINARY_ENCODING)
              # Similar regexp as above, but treat each byte separately, and encode
              # space characters, since those aren't allowed in attr-char
              encoded_filename.gsub!(/[^0-9a-zA-Z!\#$&\+\.\^_`\|~]/) do |c|
                "%%%X" % c.ord
              end

              encoded_params = "; filename*=#{encoding.to_s}''#{encoded_filename}"
            end

            unless @headers[RodaResponseHeaders::CONTENT_TYPE]
              ext = File.extname(filename)
              unless ext.empty?
                content_type(ext)
              end
            end
          end
          @headers[RodaResponseHeaders::CONTENT_DISPOSITION] = "#{disposition}#{params}#{encoded_params}"
        end

        # Whether or not the status is set to 1xx. Returns nil if status not yet set.
        def informational?
          @status.between?(100, 199) if @status
        end

        # Whether or not the status is set to 2xx. Returns nil if status not yet set.
        def success?
          @status.between?(200, 299) if @status
        end

        # Whether or not the status is set to 3xx. Returns nil if status not yet set.
        def redirect?
          @status.between?(300, 399) if @status
        end

        # Whether or not the status is set to 4xx. Returns nil if status not yet set.
        def client_error?
          @status.between?(400, 499) if @status
        end

        # Whether or not the status is set to 5xx. Returns nil if status not yet set.
        def server_error?
          @status.between?(500, 599) if @status
        end

        # Whether or not the status is set to 404. Returns nil if status not yet set.
        def not_found?
          @status == 404 if @status
        end
      end

      module ClassMethods
        # If a type and value are given, set the value in Rack's MIME registry.
        # If only a type is given, lookup the type in Rack's MIME registry and
        # return it.
        def mime_type(type=nil || (return), value = nil)
          return type.to_s if type.to_s.include?('/')
          type = ".#{type}" unless type.to_s[0] == ?.
          if value
            Rack::Mime::MIME_TYPES[type] = value
          else
            Rack::Mime.mime_type(type, nil)
          end
        end
      end

      module DelegateMethods
        [:logger, :back].each do |meth|
          define_method(meth){@_request.public_send(meth)}
        end
        [:redirect, :uri, :url, :to, :send_file, :error, :not_found].each do |meth|
          define_method(meth){|*v, &block| @_request.public_send(meth, *v, &block)}
        end

        [:informational?, :success?, :redirect?, :client_error?, :server_error?, :not_found?].each do |meth|
          define_method(meth){@_response.public_send(meth)}
        end
        [:status, :body, :headers, :mime_type, :content_type, :attachment].each do |meth|
          define_method(meth){|*v, &block| @_response.public_send(meth, *v, &block)}
        end
      end
    end

    register_plugin(:sinatra_helpers, SinatraHelpers)
  end
end