#!/usr/bin/ruby STOREDIR = '/var/spool/denbun' PROTO = 'https' SSLPORT = 443 # ruby 1.9 compatibility unless ''.respond_to?(:bytesize) class String alias :bytesize :size end end class Time def http utc.strftime('%a, %d %b %Y %H:%M:%S GMT') end end class MyCGI def initialize @method = ENV['REQUEST_METHOD'] @path = ENV['PATH_INFO'] @ctype = ENV['CONTENT_TYPE'] @ruser = ENV['REMOTE_USER'].to_s.gsub(/[^-\w]/, '_') @server = ENV['SERVER_NAME'] @port = ENV['SERVER_PORT'] @script = ENV['SCRIPT_NAME'] @md5 = ENV['HTTP_CONTENT_MD5'] @cenc = ENV['HTTP_CONTENT_ENCODING'] @body = nil @param = nil end attr_reader :method, :path, :ruser, :server, :port, :script def body return @body if @body @body = $stdin.read if @md5 begin require 'digest/md5' dg = [Digest::MD5.digest(@body)].pack('m').strip if @md5 != dg raise "409 Conflict\tMD5 mismatch #{dg} #{@md5}" end rescue LoadError # do nothing end end case @cenc when nil, /^identity$/ # do nothing when /^(x-)?gzip$/ begin require 'zlib' require 'stringio' rescue Exception raise "415 Unsupported Media Type\tcontent-encoding=gzip, zlib missing" end StringIO.new(@body.dup) {|sio| @body = Zlib::GzipReader.wrap(sio).read } else raise "415 Unsupported Media Type\tcontent-encoding #{@cenc} unsupported" end return @body end def parse_header str hdr = {} buf = [] for line in str.split(/\r?\n/) case line when /^$/ then when /^\s/ then name = buf.shift.to_s.downcase hdr[name] = yield(name, buf.join) buf = [] when /^(\S+):/ then name = buf.shift.to_s.downcase hdr[name] = yield(name, buf.join) buf = [] buf.push $1 buf.push $' else buf.push '(error)' buf.push line end end name = buf.shift.to_s.downcase hdr[name] = yield(name, buf.join) hdr end def param return @param if @param h = {} case @ctype.to_s.strip when /^multipart\/form-data\s*;\s*boundary\s*=\s*("([^"]*)"|(\S*))/ boundary = "\r\n--" + ($2 or $3).to_s body.sub(/^/, "\r\n").split(boundary).each {|part| next if part.empty? next if /^--/ === part ph, pb = part.split(/\r?\n\r?\n/, 2) hx = parse_header(ph) {|n, s| case n when /^content-(type|disposition)$/ then mt = {} s.split(/\s*;\s*/).map{|f| case f when /=/ then k, v = $`, $' v = $1 if /^"([^"]*)"$/ === v mt[k] = v else mt[''] = f.strip end } mt else s end } cd = hx['content-disposition'] next unless cd case hx['content-transfer-encoding'].to_s.strip when /^(7bit|8bit|binary)$/ then # do nothing when /^base64$/ then pb = pb.unpack('m').first when /^quoted-printable$/ then pb = pb.unpack('M').first end hx[''] = pb h[cd['name']] = hx } when /multipart\/form-data/ raise "415 Unsupported Media Type\tmultipart requires boundary" else raise "415 Unsupported Media Type\tUnknown content type '#{@ctype}'" end @param = h end end class Response def initialize @code = '200 Ok' @type = 'text/plain' @body = '' @header = {} end attr_writer :code attr_writer :type attr_writer :body def []=(name, val) @header[name] = val end def output(fp) fp.write("Status: #{@code}\n") for name, val in @header fp.write("#{name}: #{val}\r\n") end fp.write("Content-Type: #{@type}\n") if @type fp.write("Content-Length: #{@body.bytesize}\n") fp.write("\n") fp.write(@body) end end class App def initialize @resp = Response.new @c = MyCGI.new end def authcheck unless SSLPORT == @c.port.to_i @resp['location'] = make_url(@c.path, SSLPORT) raise "301 Moved Permanently\tUse SSL/TLS!" end case @c.ruser when /^-?$/ raise "401 Unauthorized\tREMOTE_USER='#{@c.ruser}'" end end def list Dir.open(STOREDIR) {|dp| for file in dp next if /^\./ === file next unless file.index(@c.ruser) == 0 next unless /^[-+.\w]+$/ === file yield file.sub(/^[^.]+\./, '') end } File.stat(STOREDIR).mtime end def retr basename begin fnam = File.join(STOREDIR, bnam = "#{@c.ruser}.#{basename}") [File.read(fnam), File.stat(fnam).mtime] rescue Errno::ENOENT raise "404 Not Found\tfile #{bnam} not found" rescue Errno::EACCES raise "403 Forbidden\tpermission denied for #{bnam}" end end def store data, filename begin fnam = File.join(STOREDIR, bnam = "#{@c.ruser}.#{filename}") tfnam = File.join(STOREDIR, ".#{@c.ruser}.#{filename}.tmp") File.open(tfnam, 'w') {|fp| fp.write data } overwriting = File.exist?(fnam) File.rename(tfnam, fnam) return overwriting rescue Errno::ENOENT => e unless File.directory?(STOREDIR) raise "404 Not Found\tmkdir #{STOREDIR} please" end raise e rescue Errno::EACCES raise "403 Forbidden\tpermission denied for #{bnam} - check #{STOREDIR}" end end def rest_basename raise "403 Forbidden\tpath_info missing" unless @c.path basename = File.basename(@c.path) case basename when /^\/?$/ then raise "403 Forbidden\tpath_info empty" end basename end def make_url basename, port = nil a = [ PROTO, '://', @c.server ] port = @c.port if port.nil? case [ PROTO, ':', port ].join when /^http:80$/, /^https:443$/ then # do nothing else a << [':', port] end a << [@c.script, '/', basename] a.join end def parse_put basename = rest_basename if store(@c.body, basename) @resp.code = "204 No Content" @resp.type = nil else @resp.code = "201 Created" @resp['location'] = url = make_url(basename) @resp.type = 'text/plain' @resp.body = "#{url} created" end end def parse_post upld = @c.param['uploaded'] raise "403 Forbidden\tuploaded file missing" unless upld basename = File.basename(upld['content-disposition']['filename'].to_s) raise "403 Forbidden\tfilename missing" if basename.empty? store(upld[''], basename) @resp.code = "201 Created" @resp['location'] = url = make_url(basename) @resp.type = 'text/plain' @resp.body = "#{url} created\n" end def parse_get bname = rest_basename case bname when /^index\.html$/ then buf = [ '', "listing for user #{@c.ruser}", '' @resp.body = buf.join @resp['last-update'] = mtime.http @resp.type = 'application/xhtml+xml' else body, mtime = retr(bname) @resp.body = body @resp['last-modified'] = mtime.http @resp.type = 'application/octet-stream' end @resp.code = '200 Ok' end def main authcheck case @c.method when /^PUT$/ then parse_put when /^POST$/ then parse_post when /^GET$/ then parse_get else raise "501 Unimplemented\tMethod '#{@c.method}' not implemented" end end def run begin main rescue Exception => e msg = e.message @resp.code = '500 Internal Server Error' if /^(\d+ [^\t]+)\t/ === msg @resp.code = $1 msg = $' end @resp.body = "#{msg} (#{e.class.to_s})\n" end @resp.output($stdout) end end App.new.run