From 52487c2dac216944543a823ac0f921471b685d60 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Mon, 4 Nov 2013 05:33:31 +0000 Subject: extras: add autoindex module Unlike Rack::Directory, this this also avoids tables and CSS for preformatted HTML. This is meant to resemble nginx autoindex and index functionality (combined). --- extras/autoindex.rb | 151 ++++++++++++++++++++++++++++++++++++++++++ test/test_extras_autoindex.rb | 53 +++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 extras/autoindex.rb create mode 100644 test/test_extras_autoindex.rb diff --git a/extras/autoindex.rb b/extras/autoindex.rb new file mode 100644 index 0000000..b868a5c --- /dev/null +++ b/extras/autoindex.rb @@ -0,0 +1,151 @@ +# -*- 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) + + # 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.message, env) + ensure + dir.close if dir + end + + def r(code, msg = nil, env = nil) + if env && logger = env["rack.logger"] + logger.warn("#{env['REQUEST_METHOD']} #{env['PATH_INFO']} " \ + "#{code} #{msg.inspect}") + 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 diff --git a/test/test_extras_autoindex.rb b/test/test_extras_autoindex.rb new file mode 100644 index 0000000..ed0e1d5 --- /dev/null +++ b/test/test_extras_autoindex.rb @@ -0,0 +1,53 @@ +# Copyright (C) 2013, Eric Wong and all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'server_helper' +require 'zlib' +require 'time' + +class TestExtrasAutoindex < Testcase + ENV["N"].to_i > 1 and parallelize_me! + include ServerHelper + + def setup + @tmpdir = Dir.mktmpdir + server_helper_setup + end + + def teardown + server_helper_teardown + FileUtils.rm_rf @tmpdir + end + + def test_autoindex + err, cfg, host, port = @err, Yahns::Config.new, @srv.addr[3], @srv.addr[1] + tmpdir = @tmpdir + pid = mkserver(cfg) do + $LOAD_PATH.unshift "#{Dir.pwd}/extras" + require 'try_gzip_static' + require 'autoindex' + cfg.instance_eval do + app(:rack, Autoindex.new(TryGzipStatic.new(tmpdir))) do + listen "#{host}:#{port}" + end + stderr_path err.path + end + end + + Net::HTTP.start(host, port) do |http| + res = http.request(Net::HTTP::Get.new("/")) + assert_equal 200, res.code.to_i + File.open("#@tmpdir/foo", "w").close + res = http.request(Net::HTTP::Get.new("/")) + assert_equal 200, res.code.to_i + assert_match %r{foo}, res.body + Dir.mkdir "#@tmpdir/bar" + + res = http.request(Net::HTTP::Get.new("/")) + assert_equal 200, res.code.to_i + assert_match %r{foo}, res.body + assert_match %r{bar/}, res.body + end + ensure + quit_wait pid + end +end -- cgit v1.2.3-24-ge0c7