# -*- encoding: binary -*- # Copyright (C) 2013, Eric Wong and all contributors # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) require 'time' require 'rack/utils' require 'rack/request' # this is middleware meant to behave like "index" and "autoindex" in nginx # No CSS or JS to avoid potential security bugs # Only basic pre-formatted HTML, not even tables, should look good in lynx # all bikeshedding here :> class Autoindex FN = %{%s} TFMT = "%Y-%m-%d %H:%M" def initialize(app, index = %w(index.html)) app.respond_to?(:root) or raise ArgumentError, "wrapped app #{app.inspect} does not respond to :root" @app = app @root = app.root @index = index end def redirect_slash(env) req = Rack::Request.new(env) location = "#{req.url}/" body = "Redirecting to #{location}\n" [ 302, { "Content-Type" => "text/plain", "Location" => location, "Content-Length" => body.size.to_s }, [ body ] ] end def call(env) case env["REQUEST_METHOD"] when "GET", "HEAD" # try to serve the static file, first status, headers, body = res = @app.call(env) return res if status.to_i != 404 path_info = env["PATH_INFO"] path_info_ue = Rack::Utils.unescape(path_info, Encoding::BINARY) # reject requests to go up a level (browser takes care of it) path_info_ue =~ /\.\./ and return r(403) # cleanup the path path_info_ue.squeeze!('/') # will raise ENOENT/ENOTDIR pfx = "#@root#{path_info_ue}" dir = Dir.open(pfx) return redirect_slash(env) unless path_info =~ %r{/\z} # try index.html and friends tryenv = env.dup @index.each do |base| tryenv["PATH_INFO"] = "#{path_info}#{base}" status, headers, body = res = @app.call(tryenv) return res if status.to_i != 404 end # generate the index, show directories first dirs = [] files = [] dir.each do |base| case base when "." next end begin st = File.stat("#{pfx}#{base}") rescue next end url = Rack::Utils.escape_html(Rack::Utils.escape(base)) name = Rack::Utils.escape_html(base) if st.directory? name << "/" url << "/" end entry = sprintf(FN, url, name) pad = 52 - name.size entry << (" " * pad) if pad > 0 entry << st.mtime.strftime(TFMT) entry << sprintf("% 8s", human_size(st)) (st.directory? ? dirs : files) << [ name, entry ] end dirs.sort! { |(a,_),(b)| a <=> b }.map! { |(_,ent)| ent } files.sort! { |(a,_),(b)| a <=> b }.map! { |(_,ent)| ent } path_info_html = path_info_ue.split(%r{/}, -1).map! do |part| Rack::Utils.escape_html(part) end.join("/") body = "Index of #{path_info_html}" \ "

Index of #{path_info_html}


\n" \
             "#{dirs.concat(files).join("\n")}" \
             "

\n" h = { "Content-Type" => "text/html", "Content-Length" => body.size.to_s } [ 200, h, [ body ] ] else r(405) end rescue Errno::ENOENT, Errno::ENOTDIR # from Dir.open r(404) rescue => e r(500, e, env) ensure dir.close if dir end def r(code, msg = nil, env = nil) if env && exc && logger = env["rack.logger"] msg = exc.message msg = msg.dump if /[[:cntrl:]]/ =~ msg # prevent code injection logger.warn("#{env['REQUEST_METHOD']} #{env['PATH_INFO']} " \ "#{code} #{msg}") exc.backtrace.each { |line| logger.warn(line) } end if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(code) [ code, {}, [] ] else h = { 'Content-Type' => 'text/plain', 'Content-Length' => '0' } [ code, h, [] ] end end def human_size(st) if st.file? size = st.size suffix = "" %w(K M G T).each do |s| break if size < 1024 size /= 1024.0 if size <= 1024 suffix = s break end end "#{size.round}#{suffix}" else "-" end end end