lib/roda/plugins/public.rb



# frozen-string-literal: true

require 'uri'

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

#
class Roda
  module RodaPlugins
    # The public plugin adds a +r.public+ routing method to serve static files
    # from a directory.
    #
    # The public plugin recognizes the application's :root option, and defaults to
    # using the +public+ subfolder of the application's +:root+ option.  If the application's
    # :root option is not set, it defaults to the +public+ folder in the working
    # directory.  Additionally, if a relative path is provided as the +:root+
    # option to the plugin, it will be considered relative to the application's
    # +:root+ option.
    #
    # Examples:
    #
    #   # Use public folder as location of files
    #   plugin :public
    #
    #   # Use /path/to/app/static as location of files
    #   opts[:root] = '/path/to/app'
    #   plugin :public, root: 'static'
    #
    #   # Assuming public is the location of files
    #   route do
    #     # Make GET /images/foo.png look for public/images/foo.png 
    #     r.public
    #
    #     # Make GET /static/images/foo.png look for public/images/foo.png
    #     r.on(:static) do
    #       r.public
    #     end
    #   end
    module Public
      SPLIT = Regexp.union(*[File::SEPARATOR, File::ALT_SEPARATOR].compact)
      RACK_FILES = defined?(Rack::Files) ? Rack::Files : Rack::File
      ENCODING_MAP = {:zstd=>'zstd', :brotli=>'br', :gzip=>'gzip'}.freeze
      ENCODING_EXTENSIONS = {'br'=>'.br', 'gzip'=>'.gz', 'zstd'=>'.zst'}.freeze

      # :nocov:
      PARSER = defined?(::URI::RFC2396_PARSER) ? ::URI::RFC2396_PARSER : ::URI::DEFAULT_PARSER
      MATCH_METHOD = RUBY_VERSION >= '2.4' ? :match? : :match
      # :nocov:

      # Use options given to setup a Rack::File instance for serving files. Options:
      # :brotli :: Whether to serve already brotli-compressed files with a .br extension
      #            for clients supporting "br" transfer encoding.
      # :default_mime :: The default mime type to use if the mime type is not recognized.
      # :encodings :: An enumerable of pairs to handle accepted encodings.  The first
      #               element of the pair is the accepted encoding name (e.g. 'gzip'),
      #               and the second element of the pair is the file extension (e.g.
      #               '.gz'). This allows configuration of the order in which encodings 
      #               are tried, to prefer brotli to zstd for example, or to support
      #               encodings other than zstd, brotli, and gzip. This takes
      #               precedence over the :brotli, :gzip, and :zstd options if given.
      # :gzip :: Whether to serve already gzipped files with a .gz extension for clients
      #          supporting "gzip" transfer encoding.
      # :headers :: A hash of headers to use for statically served files
      # :root :: Use this option for the root of the public directory (default: "public")
      # :zstd :: Whether to serve already zstd-compressed files with a .zst extension
      #          for clients supporting "zstd" transfer encoding.
      def self.configure(app, opts={})
        if opts[:root]
          app.opts[:public_root] = app.expand_path(opts[:root])
        elsif !app.opts[:public_root]
          app.opts[:public_root] = app.expand_path("public")
        end
        app.opts[:public_server] = RACK_FILES.new(app.opts[:public_root], opts[:headers]||{}, opts[:default_mime] || 'text/plain')

        unless encodings = opts[:encodings]
          if ENCODING_MAP.any?{|k,| opts.has_key?(k)}
            encodings = ENCODING_MAP.map{|k, v| [v, ENCODING_EXTENSIONS[v]] if opts[k]}.compact
          end
        end
        encodings = (encodings || app.opts[:public_encodings] || EMPTY_ARRAY).map(&:dup).freeze
        encodings.each do |a|
          a << /\b#{a[0]}\b/
        end
        encodings.each(&:freeze)
        app.opts[:public_encodings] = encodings
      end

      module RequestMethods
        # Serve files from the public directory if the file exists and this is a GET request.
        def public
          public_serve_with(roda_class.opts[:public_server])
        end

        private

        # Return an array of segments for the given path, handling ..
        # and . components
        def public_path_segments(path)
          segments = []
            
          path.split(SPLIT).each do |seg|
            next if seg.empty? || seg == '.'
            seg == '..' ? segments.pop : segments << seg
          end
            
          segments
        end

        # Return whether the given path is a readable regular file.
        def public_file_readable?(path)
          ::File.file?(path) && ::File.readable?(path)
        rescue SystemCallError
          # :nocov:
          false
          # :nocov:
        end

        def public_serve_with(server)
          return unless is_get?
          path = PARSER.unescape(real_remaining_path)
          return if path.include?("\0")

          roda_opts = roda_class.opts
          path = ::File.join(server.root, *public_path_segments(path))

          if accept_encoding = env['HTTP_ACCEPT_ENCODING']
            roda_opts[:public_encodings].each do |enc, ext, regexp|
              if regexp.send(MATCH_METHOD, accept_encoding)
                public_serve_compressed(server, path, ext, enc)
              end
            end
          end

          if public_file_readable?(path)
            s, h, b = public_serve(server, path)
            headers = response.headers
            headers.replace(h)
            halt [s, headers, b]
          end
        end

        # Serve the compressed file if it exists.  This should only
        # be called if the client will accept the related encoding.
        def public_serve_compressed(server, path, suffix, encoding)
          compressed_path = path + suffix

          if public_file_readable?(compressed_path)
            s, h, b = public_serve(server, compressed_path)
            headers = response.headers
            headers.replace(h)

            unless s == 304
              headers[RodaResponseHeaders::CONTENT_TYPE] = ::Rack::Mime.mime_type(::File.extname(path), 'text/plain')
              headers[RodaResponseHeaders::CONTENT_ENCODING] = encoding
            end

            halt [s, headers, b]
          end
        end

        if ::Rack.release > '2'
          # Serve the given path using the given Rack::Files server.
          def public_serve(server, path)
            server.serving(self, path)
          end
        else
          def public_serve(server, path)
            server = server.dup
            server.path = path
            server.serving(env)
          end
        end
      end
    end

    register_plugin(:public, Public)
  end
end