#!/usr/bin/ruby -w
# $Id: httpd,v 1.11 2004/09/14 07:21:39 bubi Exp $
#
# httpd daemon for local use, supports CGI
# http://www.xs4all.nl/~hipster
#
# Copyright (C) 2000-2004  Michel van de Ven <hipster@xs4all.nl>
# Copyright (C) 2004  Patric Mueller <bhaak@gmx.net>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

# Changes from 1.6.1.6
# - removed Cache code
# - slightly changed appearance of directory listing
# - searches for index.html also in directories
# - quits now also on SIGQUIT 
# - added VERBOSE_LOG
# - 404 gets logged
# - bugfix: crashed when cgi-file didn't exists
# - bugfix: Connection by peer error(e.g. by nmap) let httpd crash (14.09.2004)
#
# ToDo:
# - bug: http://domain/directory//page.html confuses relative links
# - improve directory listing to make it look more like apache 
# - change CONSTANTS to @global_variables and enable config file
# - implement HEAD

require "socket"
require "thread"

REVISION = 1.8

###########################################################################
# modify this to suit your setup and needs
#

# "0.0.0.0" to serve everybody
HOST = "localhost"
PORT = 8080

# number of simultaneous handler threads
THREADS = 4

# root of the html tree
DOCROOT = File.expand_path("~/lib/html")

# root of the cgi scripts, relative to DOCROOT
CGIROOT = "/cgi"

# location of the logfile; nil = don't log anything
LOGFILE = File.expand_path("~/lib/html/httpd.log")

# max number of log rotations; if <= 0: don't rotate
# This value is ignored if LOGFILE is nil.
LOGROTATIONS = 8

# if true, log all http headers
VERBOSE_LOG = true

# if true, detach process from terminal
START_AS_DAEMON = true

# change to userids
USER_ID  = 1000
GROUP_ID = 100

# XXX load configuration from file, temporarily removed
#load(File.expand_path("~/src/ruby/httpd/httpd.rc"))

###########################################################################
class MimeMap
  def initialize
    # XXX see also end of header parsing
    # could also be text/plain or text/html
    @map = Hash.new("*/*")
    File.foreach("/etc/mime.types") { |line|
      next if line =~ /^#|^\s*$/
      type, extlist = line.chomp.split(/\t+/)
      if extlist
        extlist.split(/ /).each { |ext| @map[ext] = type }
      end
    }
  end

  def typeof ext
    @map[ext]
  end
end

###########################################################################
class Log
  if LOGFILE.nil?
    def Log.open(stream = nil) end
    def Log.puts(txt) end
    def Log.close() end
  else
    def Log.open stream = LOGFILE
      unless stream.is_a?(IO)
        if LOGROTATIONS > 0
          if test(?f, LOGFILE)
            (LOGROTATIONS - 1).downto(0) { |n|
              target = "#{LOGFILE}.#{n}"
              if test(?f, target)
                File.rename(target, "#{LOGFILE}.#{n + 1}")
              end
            }
            culprit = "#{LOGFILE}.#{LOGROTATIONS}"
            if test(?f, culprit)
              File.delete(culprit)
            end
            File.rename(LOGFILE, "#{LOGFILE}.0")
          end
        end
      end

      begin
        @io = (stream.is_a? IO) ? stream : File.open(stream, "a")
      rescue
        $stderr.puts "httpd: cannot open stream for writing; logging to stderr"
        @io = $stderr
      end
    end

    def Log.close
      @io.close
    end

    def Log.puts txt
      @io.print txt.chomp + "\n"
      @io.flush
    end
  end
end

###########################################################################
class Request
  attr_reader :socket, :header, :method, :file, :mtype, :content, :fatal_error

  def initialize socket, mimemap
    @socket = socket
    @header = @method = @file = @content = @mtype = ""
    @fatal_error = false;
    contentlength = 0

    Log.puts "" if VERBOSE_LOG
    begin
      # output from address and time
      if VERBOSE_LOG
        Log.puts "[" + @socket.peeraddr[2..3].join(", ") + "]"
        Log.puts Time.new.to_s
      end

      while (line = @socket.gets) != nil
        break if line == "\r\n" or line == "\n"
        @header << line
        Log.puts line if VERBOSE_LOG
        case line
        when /^(GET|POST) (.*) HTTP\/\d+\.\d+/
          # XXX delay this to the handlers, so they can indicate failures?
          if not VERBOSE_LOG then
            Log.puts "[" + @socket.peeraddr[3] + "] " + line.chomp("\r\n")
          end
          @method = $1
          @file = $2          
        when /^content-length: (\d+)/i
          contentlength = $1.to_i
        end
      end
      if contentlength != 0
        @content = @socket.read contentlength
      end

      # default to index.html for root and directory requests
      @file = @file + "/index.html" if test(?f, DOCROOT + @file + "/index.html")

      # deduce mimetype from filename suffix
      if @file =~ /\.([a-z]+)$/
        @mtype = mimemap.typeof($1)
      else
        @mtype = "text/plain"
      end
    rescue Exception
      @fatal_error = true;
      Log.puts $!.to_s + "("+$!.class.to_s+")"
    end
  end
end

###########################################################################
class Httpd
  HTTP_200 = "HTTP/1.0 200 OK\n"
  HTTP_404 = "HTTP/1.0 404 Not found\n"

  def initialize
    Log.open
    @hostname = HOST
    @port = PORT
    @mimemap = MimeMap.new
    @socket = TCPserver.new(@hostname, @port)
    change_ids
    @rqueue = Queue.new # request queue, handler threads query this
    @nrequest = 0
    @cgi_mutex = Mutex.new
    ["SIGTERM", "SIGHUP", "SIGINT", "SIGQUIT"].each { |sig|
      trap sig, lambda { shutdown }
    }
  end

  def listen
    Log.puts "-" * 79 + "\nstart @ " + Time.now.to_s +
        "\nhttpd/#{REVISION}; " +
                  "pid #{$$} with #{THREADS} threads listening on #{@hostname}:#{@port}"
    # start handler threads
    Thread.abort_on_exception = true # XXX debugging only?
    THREADS.times { Thread.new { handler } }
    # main accept loop
    loop do
      begin
        @rqueue.push @socket.accept
        @nrequest += 1
      rescue
        Log.puts "listener: socket exception: " + $!
      end
    end
  end

  private

  def handler
    cgiroot_regex = Regexp.new("^#{CGIROOT}")
    loop do
      socket = @rqueue.pop
      req = Request.new socket, @mimemap

      if not req.fatal_error then
        if cgiroot_regex.match(req.file)
          handle_cgi req
        else
          if test(?f, DOCROOT + req.file)
            handle_file req
          elsif test(?d, DOCROOT + req.file)
            handle_dir req
          else
            handle_error req
          end
        end
      end
      socket.close
    end
  end

  def handle_cgi req
    script, param = req.file.split("?")
    if File.exists?("#{DOCROOT}#{script}") && File.stat("#{DOCROOT}#{script}").executable?
      begin
        @cgi_mutex.lock
        ENV["SERVER_SOFTWARE"] = "Ruby-httpd/#{REVISION}"
        ENV["REMOTE_HOST"] = ENV["HOSTNAME"]
        ENV["REQUEST_METHOD"] = req.method
        ENV["SCRIPT_NAME"] = script
        ENV["QUERY_STRING"] = param
        ENV["CONTENT_LENGTH"] = req.content.length != 0 ? req.content.length.to_s : nil
        cgi = IO.popen("#{DOCROOT}#{script}", "r+")
        if req.content.length != 0
          cgi << req.content
          #cgi.flush # commented out, did hang with some shell scripts
        end
        result = cgi.read
      rescue
        result = header("text/html") +
                 "<h1>Error executing script '#{script}'</h1>"
      ensure
        cgi.close
        @cgi_mutex.unlock
      end
      emit req.socket, HTTP_200 + result
    else
      handle_error req
    end
  end

  def handle_dir req
    emit req.socket, HTTP_200 + header("text/html") +
                               "<html>\n<head>\n<title>\nIndex of #{req.file}\n</title>\n</head>\n<body>\n<h1>Index of #{req.file}</h1>\n"
    up = File.expand_path(req.file + "/..", DOCROOT)
    emit req.socket,
         %!<pre><hr /> <a href='#{up}'>Parent Directory</a>\n!
    begin
      dir = Dir.open(DOCROOT + req.file)
      dir.sort.each { |entry|
        next if entry == "." or entry == ".."
        link = entry
        link += "/" if test(?d, DOCROOT + req.file + "/" + link)
        emit req.socket, %! <a href="#{req.file}/#{entry}">#{link}</a>\n!
      }
    ensure
      dir.close
    end
    emit req.socket, "</pre><hr>"
    emit req.socket, "<i>Ruby-httpd #{REVISION} at #@hostname:#@port</i></body></html>"
  end

  def handle_file req
    # read file and send it to client
    content = File.open(DOCROOT + req.file).read
    emit req.socket, HTTP_200 + header(req.mtype)
    emit req.socket, content
  end

  def handle_error req
    emit req.socket, HTTP_404 + header("text/html") +
                               "<h1>404 Not Found</h1>\n" +
                                    "Request header: <pre>#{req.header}</pre>" +
                               "<hr><i>Ruby-httpd #{REVISION} at #@hostname:#@port</i>"
    Log.puts "404 not found: " + req.file + "\n\n"
    # XXX OR
    #     if req.mtype =~ /^text\//
    #       emit req.socket, "HTTP/1.0 404 Not found\n" +
    #         "Content-type: text/html\n\n" +
    #         "<h1>Object not found</h1>\n" +
    #         "Request header: <pre>#{req.header}</pre>"
    #     else
    #       emit req.socket, "HTTP/1.0 404 Not found\n"
    #     end
  end

  def header mimetype
    "Content-type: #{mimetype}\n\n"
    # XXX "Content-type: #{mimetype}\nConnection: close\n\n"
  end

  def emit socket, data
    begin
      socket.write data
    rescue
      Log.puts "emit: socket exception: " + $!
    end 
  end

  def change_ids
    Process.uid  = USER_ID
    Process.gid  = GROUP_ID
    Process.euid = USER_ID
    Process.egid = GROUP_ID
  end

  def shutdown
    @socket.close
    Log.puts "#{@nrequest} requests handled"
    Log.puts "shutdown @ #{Time.now.to_s}"
    Log.close
    exit
  end
end

###########################################################################
# TODO move this into httpd. Provide both a background and foreground func.
def daemon
  if fork == nil
    Process.setsid # create new session, disconnect from tty
    [$stdin, $stdout, $stderr].each { |stream| stream.reopen "/dev/null" }
    Httpd.new.listen
  end
end

# XXX debug stuff
if false
  LOGFILE = $stderr
  Httpd.new.listen
else
  daemon
end
