# frozen_string_literal: true require 'time' require_relative 'constants' require_relative 'utils' require_relative 'head' require_relative 'mime' require_relative 'files' module Rack # Rack::Directory serves entries below the +root+ given, according to the # path info of the Rack request. If a directory is found, the file's contents # will be presented in an html based index. If a file is found, the env will # be passed to the specified +app+. # # If +app+ is not specified, a Rack::Files of the same +root+ will be used. class Directory DIR_FILE = "%s%s%s%s\n" DIR_PAGE_HEADER = <<-PAGE %s

%s


PAGE DIR_PAGE_FOOTER = <<-PAGE
Name Size Type Last Modified

PAGE # Body class for directory entries, showing an index page with links # to each file. class DirectoryBody < Struct.new(:root, :path, :files) # Yield strings for each part of the directory entry def each show_path = Utils.escape_html(path.sub(/^#{root}/, '')) yield(DIR_PAGE_HEADER % [ show_path, show_path ]) unless path.chomp('/') == root yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) end Dir.foreach(path) do |basename| next if basename.start_with?('.') next unless f = files.call(basename) yield(DIR_FILE % DIR_FILE_escape(f)) end yield(DIR_PAGE_FOOTER) end private # Escape each element in the array of html strings. def DIR_FILE_escape(htmls) htmls.map { |e| Utils.escape_html(e) } end end # The root of the directory hierarchy. Only requests for files and # directories inside of the root directory are supported. attr_reader :root # Set the root directory and application for serving files. def initialize(root, app = nil) @root = ::File.expand_path(root) @app = app || Files.new(@root) @head = Head.new(method(:get)) end def call(env) # strip body if this is a HEAD call @head.call env end # Internals of request handling. Similar to call but does # not remove body for HEAD requests. def get(env) script_name = env[SCRIPT_NAME] path_info = Utils.unescape_path(env[PATH_INFO]) if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) client_error_response else path = ::File.join(@root, path_info) list_path(env, path, path_info, script_name) end end # Rack response to use for requests with invalid paths, or nil if path is valid. def check_bad_request(path_info) return if Utils.valid_path?(path_info) body = "Bad Request\n" [400, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => body.bytesize.to_s, "x-cascade" => "pass" }, [body]] end # Rack response to use for requests with paths outside the root, or nil if path is inside the root. def check_forbidden(path_info) return unless path_info.include? ".." return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) body = "Forbidden\n" [403, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => body.bytesize.to_s, "x-cascade" => "pass" }, [body]] end # Rack response to use for directories under the root. def list_directory(path_info, path, script_name) url_head = (script_name.split('/') + path_info.split('/')).map do |part| Utils.escape_path part end # Globbing not safe as path could contain glob metacharacters body = DirectoryBody.new(@root, path, ->(basename) do stat = stat(::File.join(path, basename)) next unless stat url = ::File.join(*url_head + [Utils.escape_path(basename)]) mtime = stat.mtime.httpdate if stat.directory? type = 'directory' size = '-' url << '/' if basename == '..' basename = 'Parent Directory' else basename << '/' end else type = Mime.mime_type(::File.extname(basename)) size = filesize_format(stat.size) end [ url, basename, size, type, mtime ] end) [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] end # File::Stat for the given path, but return nil for missing/bad entries. def stat(path) ::File.stat(path) rescue Errno::ENOENT, Errno::ELOOP return nil end # Rack response to use for files and directories under the root. # Unreadable and non-file, non-directory entries will get a 404 response. def list_path(env, path, path_info, script_name) if (stat = stat(path)) && stat.readable? return @app.call(env) if stat.file? return list_directory(path_info, path, script_name) if stat.directory? end entity_not_found(path_info) end # Rack response to use for unreadable and non-file, non-directory entries. def entity_not_found(path_info) body = "Entity not found: #{path_info}\n" [404, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => body.bytesize.to_s, "x-cascade" => "pass" }, [body]] end # Stolen from Ramaze FILESIZE_FORMAT = [ ['%.1fT', 1 << 40], ['%.1fG', 1 << 30], ['%.1fM', 1 << 20], ['%.1fK', 1 << 10], ] # Provide human readable file sizes def filesize_format(int) FILESIZE_FORMAT.each do |format, size| return format % (int.to_f / size) if int >= size end "#{int}B" end end end