1 #
2 # filehandler.rb -- FileHandler Module
3 #
4 # Author: IPR -- Internet Programming with Ruby -- writers
5 # Copyright (c) 2001 TAKAHASHI Masayoshi, GOTOU Yuuzou
6 # Copyright (c) 2003 Internet Programming with Ruby writers. All rights
7 # reserved.
8 #
9 # $IPR: filehandler.rb,v 1.44 2003/06/07 01:34:51 gotoyuzo Exp $
10
11 require 'thread'
12 require 'time'
13
14 require 'webrick/htmlutils'
15 require 'webrick/httputils'
16 require 'webrick/httpstatus'
17
18 module WEBrick
19 module HTTPServlet
20
21 class DefaultFileHandler < AbstractServlet
22 def initialize(server, local_path)
23 super
24 @local_path = local_path
25 end
26
27 def do_GET(req, res)
28 st = File::stat(@local_path)
29 mtime = st.mtime
30 res['etag'] = sprintf("%x-%x-%x", st.ino, st.size, st.mtime.to_i)
31
32 if not_modified?(req, res, mtime, res['etag'])
33 res.body = ''
34 raise HTTPStatus::NotModified
35 elsif req['range']
36 make_partial_content(req, res, @local_path, st.size)
37 raise HTTPStatus::PartialContent
38 else
39 mtype = HTTPUtils::mime_type(@local_path, @config[:MimeTypes])
40 res['content-type'] = mtype
41 res['content-length'] = st.size
42 res['last-modified'] = mtime.httpdate
43 res.body = open(@local_path, "rb")
44 end
45 end
46
47 def not_modified?(req, res, mtime, etag)
48 if ir = req['if-range']
49 begin
50 if Time.httpdate(ir) >= mtime
51 return true
52 end
53 rescue
54 if HTTPUtils::split_header_value(ir).member?(res['etag'])
55 return true
56 end
57 end
58 end
59
60 if (ims = req['if-modified-since']) && Time.parse(ims) >= mtime
61 return true
62 end
63
64 if (inm = req['if-none-match']) &&
65 HTTPUtils::split_header_value(inm).member?(res['etag'])
66 return true
67 end
68
69 return false
70 end
71
72 def make_partial_content(req, res, filename, filesize)
73 mtype = HTTPUtils::mime_type(filename, @config[:MimeTypes])
74 unless ranges = HTTPUtils::parse_range_header(req['range'])
75 raise HTTPStatus::BadRequest,
76 "Unrecognized range-spec: \"#{req['range']}\""
77 end
78 open(filename, "rb"){|io|
79 if ranges.size > 1
80 time = Time.now
81 boundary = "#{time.sec}_#{time.usec}_#{Process::pid}"
82 body = ''
83 ranges.each{|range|
84 first, last = prepare_range(range, filesize)
85 next if first < 0
86 io.pos = first
87 content = io.read(last-first+1)
88 body << "--" << boundary << CRLF
89 body << "Content-Type: #{mtype}" << CRLF
90 body << "Content-Range: bytes #{first}-#{last}/#{filesize}" << CRLF
91 body << CRLF
92 body << content
93 body << CRLF
94 }
95 raise HTTPStatus::RequestRangeNotSatisfiable if body.empty?
96 body << "--" << boundary << "--" << CRLF
97 res["content-type"] = "multipart/byteranges; boundary=#{boundary}"
98 res.body = body
99 elsif range = ranges[0]
100 first, last = prepare_range(range, filesize)
101 raise HTTPStatus::RequestRangeNotSatisfiable if first < 0
102 if last == filesize - 1
103 content = io.dup
104 content.pos = first
105 else
106 io.pos = first
107 content = io.read(last-first+1)
108 end
109 res['content-type'] = mtype
110 res['content-range'] = "bytes #{first}-#{last}/#{filesize}"
111 res['content-length'] = last - first + 1
112 res.body = content
113 else
114 raise HTTPStatus::BadRequest
115 end
116 }
117 end
118
119 def prepare_range(range, filesize)
120 first = range.first < 0 ? filesize + range.first : range.first
121 return -1, -1 if first < 0 || first >= filesize
122 last = range.last < 0 ? filesize + range.last : range.last
123 last = filesize - 1 if last >= filesize
124 return first, last
125 end
126 end
127
128 class FileHandler < AbstractServlet
129 HandlerTable = Hash.new
130
131 def self.add_handler(suffix, handler)
132 HandlerTable[suffix] = handler
133 end
134
135 def self.remove_handler(suffix)
136 HandlerTable.delete(suffix)
137 end
138
139 def initialize(server, root, options={}, default=Config::FileHandler)
140 @config = server.config
141 @logger = @config[:Logger]
142 @root = File.expand_path(root)
143 if options == true || options == false
144 options = { :FancyIndexing => options }
145 end
146 @options = default.dup.update(options)
147 end
148
149 def service(req, res)
150 # if this class is mounted on "/" and /~username is requested.
151 # we're going to override path informations before invoking service.
152 if defined?(Etc) && @options[:UserDir] && req.script_name.empty?
153 if %r|^(/~([^/]+))| =~ req.path_info
154 script_name, user = $1, $2
155 path_info = $'
156 begin
157 passwd = Etc::getpwnam(user)
158 @root = File::join(passwd.dir, @options[:UserDir])
159 req.script_name = script_name
160 req.path_info = path_info
161 rescue
162 @logger.debug "#{self.class}#do_GET: getpwnam(#{user}) failed"
163 end
164 end
165 end
166 prevent_directory_traversal(req, res)
167 super(req, res)
168 end
169
170 def do_GET(req, res)
171 unless exec_handler(req, res)
172 set_dir_list(req, res)
173 end
174 end
175
176 def do_POST(req, res)
177 unless exec_handler(req, res)
178 raise HTTPStatus::NotFound, "`#{req.path}' not found."
179 end
180 end
181
182 def do_OPTIONS(req, res)
183 unless exec_handler(req, res)
184 super(req, res)
185 end
186 end
187
188 # ToDo
189 # RFC2518: HTTP Extensions for Distributed Authoring -- WEBDAV
190 #
191 # PROPFIND PROPPATCH MKCOL DELETE PUT COPY MOVE
192 # LOCK UNLOCK
193
194 # RFC3253: Versioning Extensions to WebDAV
195 # (Web Distributed Authoring and Versioning)
196 #
197 # VERSION-CONTROL REPORT CHECKOUT CHECK_IN UNCHECKOUT
198 # MKWORKSPACE UPDATE LABEL MERGE ACTIVITY
199
200 private
201
202 def trailing_pathsep?(path)
203 # check for trailing path separator:
204 # File.dirname("/aaaa/bbbb/") #=> "/aaaa")
205 # File.dirname("/aaaa/bbbb/x") #=> "/aaaa/bbbb")
206 # File.dirname("/aaaa/bbbb") #=> "/aaaa")
207 # File.dirname("/aaaa/bbbbx") #=> "/aaaa")
208 return File.dirname(path) != File.dirname(path+"x")
209 end
210
211 def prevent_directory_traversal(req, res)
212 # Preventing directory traversal on Windows platforms;
213 # Backslashes (0x5c) in path_info are not interpreted as special
214 # character in URI notation. So the value of path_info should be
215 # normalize before accessing to the filesystem.
216
217 if trailing_pathsep?(req.path_info)
218 # File.expand_path removes the trailing path separator.
219 # Adding a character is a workaround to save it.
220 # File.expand_path("/aaa/") #=> "/aaa"
221 # File.expand_path("/aaa/" + "x") #=> "/aaa/x"
222 expanded = File.expand_path(req.path_info + "x")
223 expanded.chop! # remove trailing "x"
224 else
225 expanded = File.expand_path(req.path_info)
226 end
227 req.path_info = expanded
228 end
229
230 def exec_handler(req, res)
231 raise HTTPStatus::NotFound, "`#{req.path}' not found" unless @root
232 if set_filename(req, res)
233 handler = get_handler(req, res)
234 call_callback(:HandlerCallback, req, res)
235 h = handler.get_instance(@config, res.filename)
236 h.service(req, res)
237 return true
238 end
239 call_callback(:HandlerCallback, req, res)
240 return false
241 end
242
243 def get_handler(req, res)
244 suffix1 = (/\.(\w+)\z/ =~ res.filename) && $1.downcase
245 if /\.(\w+)\.([\w\-]+)\z/ =~ res.filename
246 if @options[:AcceptableLanguages].include?($2.downcase)
247 suffix2 = $1.downcase
248 end
249 end
250 handler_table = @options[:HandlerTable]
251 return handler_table[suffix1] || handler_table[suffix2] ||
252 HandlerTable[suffix1] || HandlerTable[suffix2] ||
253 DefaultFileHandler
254 end
255
256 def set_filename(req, res)
257 res.filename = @root.dup
258 path_info = req.path_info.scan(%r|/[^/]*|)
259
260 path_info.unshift("") # dummy for checking @root dir
261 while base = path_info.first
262 break if base == "/"
263 break unless File.directory?(File.expand_path(res.filename + base))
264 shift_path_info(req, res, path_info)
265 call_callback(:DirectoryCallback, req, res)
266 end
267
268 if base = path_info.first
269 if base == "/"
270 if file = search_index_file(req, res)
271 shift_path_info(req, res, path_info, file)
272 call_callback(:FileCallback, req, res)
273 return true
274 end
275 shift_path_info(req, res, path_info)
276 elsif file = search_file(req, res, base)
277 shift_path_info(req, res, path_info, file)
278 call_callback(:FileCallback, req, res)
279 return true
280 else
281 raise HTTPStatus::NotFound, "`#{req.path}' not found."
282 end
283 end
284
285 return false
286 end
287
288 def check_filename(req, res, name)
289 if nondisclosure_name?(name) || windows_ambiguous_name?(name)
290 @logger.warn("the request refers nondisclosure name `#{name}'.")
291 raise HTTPStatus::NotFound, "`#{req.path}' not found."
292 end
293 end
294
295 def shift_path_info(req, res, path_info, base=nil)
296 tmp = path_info.shift
297 base = base || tmp
298 req.path_info = path_info.join
299 req.script_name << base
300 res.filename = File.expand_path(res.filename + base)
301 check_filename(req, res, File.basename(res.filename))
302 end
303
304 def search_index_file(req, res)
305 @config[:DirectoryIndex].each{|index|
306 if file = search_file(req, res, "/"+index)
307 return file
308 end
309 }
310 return nil
311 end
312
313 def search_file(req, res, basename)
314 langs = @options[:AcceptableLanguages]
315 path = res.filename + basename
316 if File.file?(path)
317 return basename
318 elsif langs.size > 0
319 req.accept_language.each{|lang|
320 path_with_lang = path + ".#{lang}"
321 if langs.member?(lang) && File.file?(path_with_lang)
322 return basename + ".#{lang}"
323 end
324 }
325 (langs - req.accept_language).each{|lang|
326 path_with_lang = path + ".#{lang}"
327 if File.file?(path_with_lang)
328 return basename + ".#{lang}"
329 end
330 }
331 end
332 return nil
333 end
334
335 def call_callback(callback_name, req, res)
336 if cb = @options[callback_name]
337 cb.call(req, res)
338 end
339 end
340
341 def windows_ambiguous_name?(name)
342 return true if /[. ]+\z/ =~ name
343 return true if /::\$DATA\z/ =~ name
344 return false
345 end
346
347 def nondisclosure_name?(name)
348 @options[:NondisclosureName].each{|pattern|
349 if File.fnmatch(pattern, name, File::FNM_CASEFOLD)
350 return true
351 end
352 }
353 return false
354 end
355
356 def set_dir_list(req, res)
357 redirect_to_directory_uri(req, res)
358 unless @options[:FancyIndexing]
359 raise HTTPStatus::Forbidden, "no access permission to `#{req.path}'"
360 end
361 local_path = res.filename
362 list = Dir::entries(local_path).collect{|name|
363 next if name == "." || name == ".."
364 next if nondisclosure_name?(name)
365 next if windows_ambiguous_name?(name)
366 st = (File::stat(File.join(local_path, name)) rescue nil)
367 if st.nil?
368 [ name, nil, -1 ]
369 elsif st.directory?
370 [ name + "/", st.mtime, -1 ]
371 else
372 [ name, st.mtime, st.size ]
373 end
374 }
375 list.compact!
376
377 if d0 = req.query["N"]; idx = 0
378 elsif d0 = req.query["M"]; idx = 1
379 elsif d0 = req.query["S"]; idx = 2
380 else d0 = "A" ; idx = 0
381 end
382 d1 = (d0 == "A") ? "D" : "A"
383
384 if d0 == "A"
385 list.sort!{|a,b| a[idx] <=> b[idx] }
386 else
387 list.sort!{|a,b| b[idx] <=> a[idx] }
388 end
389
390 res['content-type'] = "text/html"
391
392 res.body = <<-_end_of_html_
393 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
394 <HTML>
395 <HEAD><TITLE>Index of #{HTMLUtils::escape(req.path)}</TITLE></HEAD>
396 <BODY>
397 <H1>Index of #{HTMLUtils::escape(req.path)}</H1>
398 _end_of_html_
399
400 res.body << "<PRE>\n"
401 res.body << " <A HREF=\"?N=#{d1}\">Name</A> "
402 res.body << "<A HREF=\"?M=#{d1}\">Last modified</A> "
403 res.body << "<A HREF=\"?S=#{d1}\">Size</A>\n"
404 res.body << "<HR>\n"
405
406 list.unshift [ "..", File::mtime(local_path+"/.."), -1 ]
407 list.each{ |name, time, size|
408 if name == ".."
409 dname = "Parent Directory"
410 elsif name.size > 25
411 dname = name.sub(/^(.{23})(.*)/){ $1 + ".." }
412 else
413 dname = name
414 end
415 s = " <A HREF=\"#{HTTPUtils::escape(name)}\">#{dname}</A>"
416 s << " " * (30 - dname.size)
417 s << (time ? time.strftime("%Y/%m/%d %H:%M ") : " " * 22)
418 s << (size >= 0 ? size.to_s : "-") << "\n"
419 res.body << s
420 }
421 res.body << "</PRE><HR>"
422
423 res.body << <<-_end_of_html_
424 <ADDRESS>
425 #{HTMLUtils::escape(@config[:ServerSoftware])}<BR>
426 at #{req.host}:#{req.port}
427 </ADDRESS>
428 </BODY>
429 </HTML>
430 _end_of_html_
431 end
432
433 end
434 end
435 end