1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
| | # -*- encoding: binary -*-
# Copyright (C) 2013-2016 all contributors <yahns-public@yhbt.net>
# License: GPL-3.0+ (https://www.gnu.org/licenses/gpl-3.0.txt)
# frozen_string_literal: true
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 = %{<a href="%s">%s</a>}
TFMT = "%Y-%m-%d %H:%M"
def initialize(app, *args)
app.respond_to?(:root) or raise ArgumentError,
"wrapped app #{app.inspect} does not respond to #root"
@app = app
@root = app.root
@index = case args[0]
when Array then args.shift
when String then Array(args.shift)
else
%w(index.html)
end
@skip_gzip_static = @skip_dotfiles = nil
case args[0]
when Hash
@skip_gzip_static = args[0][:skip_gzip_static]
@skip_dotfiles = args[0][:skip_dotfiles]
when true, false
@skip_gzip_static = args.shift
end
@skip_gzip_static = true if @skip_gzip_static.nil?
@skip_dotfiles = false if @skip_dotfiles.nil?
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, _, 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, _, body = res = @app.call(tryenv)
return res if status.to_i != 404
end
# generate the index, show directories first
dirs = []
files = []
ngz_idx = {} if @skip_gzip_static # used to avoid redundant stat()
dir.each do |base|
case base
when "."
next
when ".."
next if path_info == "/"
when /\A\./
next if @skip_dotfiles
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))
entry = [name, entry]
if st.directory?
dirs << entry
elsif ngz_idx
ngz_idx[name] = entry
else
files << entry
end
end
if ngz_idx
ngz_idx.each do |name, entry|
# n.b: use use dup.sub! to ensure ngz_path is nil
# if .gz is not found
ngz_path = name.dup.sub!(/\.gz\z/, '')
ngz_idx.include?(ngz_path) or files << entry
end
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 = "<html><head><title>Index of #{path_info_html}</title></head>" \
"<body><h1>Index of #{path_info_html}</h1><hr><pre>\n" \
"#{dirs.concat(files).join("\n")}" \
"</pre><hr></body></html>\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, exc = 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
msg = "#{code} #{Rack::Utils::HTTP_STATUS_CODES[code.to_i]}\n"
h = { 'Content-Type' => 'text/plain', 'Content-Length' => msg.size.to_s }
[ code, h, [ msg ] ]
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
|