require 'socket' require 'thread' require 'time' class Symbol def to_s @_to_s || (@_to_s = id2name) end end # String extension methods. class String # Encodes a normal string to a URI string. def uri_escape gsub(/([^ a-zA-Z0-9_.-]+)/n) {'%'+$1.unpack('H2'*$1.size). join('%').upcase}.tr(' ', '+') end # Decodes a URI string to a normal string. def uri_unescape tr('+', ' ').gsub(/((?:%[0-9a-fA-F]{2})+)/n){[$1.delete('%')].pack('H*')} end # Concatenates a path def /(o) to_s + '/' + o.to_s end end module ServerSide class Request module Const LineBreak = "\r\n".freeze RequestRegexp = /([A-Za-z]+)\s(\/.*)\sHTTP\/(.+)\r/.freeze HeaderRegexp = /([^:]+):\s?(.*)\r\n/.freeze ContentLength = 'Content-Length'.freeze Version_1_1 = '1.1'.freeze Connection = 'Connection'.freeze Close = 'close'.freeze QueryRegexp = /([^\?]+)(\?(.*))?/.freeze end def initialize(conn) @conn = conn @thread = Thread.new {process} @thread[:time] = Time.now @thread[:stage] = 'normal' end def process while true break unless parse_request respond break unless @persistent end rescue => e puts e.message puts e.backtrace.first ensure @conn.close end def parse_request return nil unless @conn.gets =~ Const::RequestRegexp @method, @query, @version = $1.downcase.to_sym, $2, $3 @query =~ Const::QueryRegexp @path = $1 @parameters = $3 ? parse_parameters($3) : {} @headers = {} while (line = @conn.gets) break if line.nil? || (line == Const::LineBreak) if line =~ Const::HeaderRegexp @headers[$1] = $2 end end @persistent = (@version == Const::Version_1_1) && (@headers[Const::Connection] != Const::Close) @headers end module Const Ampersand = '&'.freeze ParameterRegexp = /(.+)=(.*)/.freeze EqualSign = '='.freeze end def parse_parameters(query) query.split(Const::Ampersand).inject({}) do |m, i| if i =~ Const::ParameterRegexp m[$1.to_sym] = $2.uri_unescape end m end end module Const Status = "HTTP/1.1 %d\r\nDate: %s\r\nConnection: close\r\nContent-Type: %s\r\nContent-Length: %d\r\n%s\r\n".freeze StatusStream = "HTTP/1.1 %d\r\nDate: %s\r\nConnection: close\r\nContent-Type: %s\r\n%s\r\n".freeze StatusPersist = "HTTP/1.1 %d\r\nDate: %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n%s\r\n".freeze Header = "%s: %s\r\n".freeze Empty = ''.freeze end def send_response(status, content_type, body = nil, content_length = nil, headers = nil) h = headers ? headers.inject("") {|m, kv| m << (Const::Header % kv)} : Const::Empty content_length = body.length if content_length.nil? && body @persistent = false if content_length.nil? date = Time.now.httpdate if @persistent @conn << (Const::StatusPersist % [status, date, content_type, content_length, h]) elsif body @conn << (Const::Status % [status, date, content_type, content_length, h]) else @conn << (Frozen::StatusStream % [status, date, content_type, h]) end (@conn << body if body) rescue nil end module Const HTML = '

%s %s

%s

'.freeze TextHtml = 'text/html'.freeze ETag = 'ETag'.freeze ETagFormat = '%x:%x:%x'.inspect.freeze CacheControl = 'Cache-Control'.freeze StaticAge = (86400 * 30).freeze StaticMaxAge = "max-age=#{StaticAge}".freeze IfNoneMatch = 'If-None-Match'.freeze TextPlain = 'text/plain'.freeze NotModifiedStatus = "HTTP/1.1 304 Not Modified\r\nConnection: close\r\nContent-Length: 0\r\nETag: %s\r\nCache-Control: max-age=#{StaticAge}\r\n\r\n".freeze NotModifiedStatusPersist = "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\nETag: %s\r\nCache-Control: max-age=#{StaticAge}\r\n\r\n".freeze end @@mime_types = Hash.new {|h, k| Const::TextPlain} @@mime_types.merge!({ '.html'.freeze => 'text/html'.freeze, '.css'.freeze => 'text/css'.freeze, '.js'.freeze => 'text/javascript'.freeze, '.gif'.freeze => 'image/gif'.freeze, '.jpg'.freeze => 'image/jpeg'.freeze, '.jpeg'.freeze => 'image/jpeg'.freeze, '.png'.freeze => 'image/png'.freeze }) module Const TestRegexp = /^\/test/.freeze end def respond if @path =~ Const::TestRegexp send_response(200, Const::TextHtml, Const::HTML % [@method, @path, ENV.inspect]) else serve_file(@path) end end @@static_files = {} def serve_file(path) fn = '.'/path if File.file?(fn) stat = File.stat(fn) etag = Const::ETagFormat % [stat.mtime.to_i, stat.size, stat.ino] unless etag == @headers[Const::IfNoneMatch] if @@static_files[fn] && (@@static_files[fn][0] == etag) content = @@static_files[fn][1] else content = IO.read(fn).freeze @@static_files[fn] = [etag.freeze, content] end send_response(200, @@mime_types[File.extname(fn)], content, stat.size, { Const::ETag => etag, Const::CacheControl => Const::StaticMaxAge }) else if @persistent @conn << Const::NotModifiedStatusPersist % etag else @conn << Const::NotModifiedStatus % etag end # send_response(304, Const::Empty, Const::Empty, 0, { # Const::ETag => etag, # Const::CacheControl => Const::StaticMaxAge # }) end else send_response(404, Const::TextHtml, 'File not found.') end rescue => e puts e.message puts e.backtrace.first send_response(404, Const::TextHtml, 'Error reading file.') end end class Server attr_accessor :thread def initialize(host, port, request_class) @request_class = request_class @server = TCPServer.new(host, port) loop {@request_class.new(@server.accept)} end end end trap ("TERM") {exit} ServerSide::Server.new('0.0.0.0', 4401, ServerSide::Request)