#!/usr/local/bin/ruby

##
# @file      mpmws.rb
# @author    Mitch Richling <https://www.mitchr.me/>
# @Copyright Copyright 2006 by Mitch Richling.  All rights reserved.
# @Revision  $Revision: 1.11 $ 
# @SCMdate   $Date: 2011/06/23 19:46:05 $
# @brief     Simple web server with Ruby.@EOL
# @Keywords  ruby web server webrick
# @Std       Ruby 1.8
#
#            INTRODUCTION
#
#               This little ruby program implements a simple web server providing browsable directory index
#               lists, HTML index support (index.html), and CGI support.  While not as sophisticated as web
#               servers like Apache, this web server is more than adequate for testing things like CGI scripts
#               client side AJAX-style code.
#
#               HTML index files are supported, and will be delivered if the URL points to a directory containing
#               an appropriately named index file ("index.html", "index.htm", "index.cgi", or
#               "index.rhtml). Apache-like directory indexes are generated for directories not containing an HTML
#               index file. Files ending with .cgi will be executed as CGI scripts.  A small, but normalcy
#               sufficient Mime-Type map is provided as well. The IP address to bind to, the port to listen on,
#               and the directory for the web server document root may be provided as command line arguments.
#
#            IMPLEMENTATION HISTORY
#
#               This script was born from a need to test different web sites on one stand alone laptop in such
#               a way that each site was isolated from the rest on a different web sever.  Testing CGI scripts
#               and providing an endpoint for XML queries in client side AJAX code drove the need for a
#               server, and eliminated the possibility of testing with a static filesystem.  This script provides
#               the HTTP server required without the complexity associated with normal HTTP servers.
#
#            USAGE
#
#               USE: mpmws.rb [OPTIONS] [[ADDRESS:]PORT] [DIRECTORY]
#
#                  First options (arguments begining with '-') are poped off and processed:
#                       -s      -- Turn on SSL if ruby >= 1.8.6. (Note: use https in the URL)
#                       -p=file -- Specify a password file.  Turns on -a as well.  If you leave off the name
#                                  and equals sign, then ~/.mpmpw.txt is used.
#                                  Each line of the file looks like "user_name:SHA1_hash_of_passwd" or
#                                  "user_name:clear_password".  Clear passwords are recognized as not being 40
#                                  hex digits. :) One can generate a simple file (user: mitch, passwd: mitch1)
#                                  like this:
#                                     echo -n 'mitch:' > pw.txt; echo 'mitch1' | openssl sha1 >> pw.txt
#                       -up=u:p -- Specify a user name and password on the command line.  Turns on -a as well.
#                                  This option may be present multiple times -- each time a new user &
#                                  password will be added to the list, or will update password for an existing
#                                  user. The -p & -up options are processed in the order they appear on the
#                                  command line. The value given to this option is processed just like a
#                                  single line of a password file given to -p
#
#                  If only one argument remains and it is an existing directory, then it is assumed to be the
#                  DIRECTORY argument.  Otherwise a single argument will be interpreted as the
#                  [[ADDRESS:]PORT] argument.  If no arguments are given, or
#                  some are missing, then the defaults are used.  The ADDRESS defaults to 'localhost', the
#                  PORT defaults to '8080', and the DIRECTORY defaults to the current working directory ('./').
#     
#               With the defaults, most UNIX browsers will attache to the web server with
#               "http://localhost:8080/".  Some dumb browsers try to do direct DNS lookups on hosts in URLs
#               and will need this URL: "http://127.0.0.1:8080/".  Note, if the host is not configured
#               properly, then 'localhost' may not resolve to '127.0.0.1'.
#
#            EXIT CODES
#
#                 * 1  Too many command line arguments given!
#                 * 2  Given path doesn't exist!
#                 * 3  Given path exists, but is not a directory!
#                 * 4  Given path is a directory, but is not a executable!
#                 * 5  Given path is a directory, but is not a readable!
#                 * 6  Only root (uid=0) may open ports below 1024!
#                 * 7  The password file was bad
#
#            TODO (not prioritized)
#
#                 * Add the ability to have multiple, different directories served up
#                 * Add command line options to add Mime-Types (point to a mimetype file for example)
#                 * Add command line options to change the HTML index file names
#                 * Add command line options to create a real 'cgi-bin' directory in which all file are executed
#                   as CGI scripts not just the ones with a .cgi extension.
#                 * Add help command line option
#                 * Redo the argument processing.
#
#            BUGS
#
#                 * This script is so simple that not many bugs can exist.  Still, I'm sure we have some. :) 

require 'webrick'
include WEBrick
require 'cgi'
require 'digest/sha1'
require 'etc' 

if (RUBY_VERSION >= '1.8.6') then
require 'webrick/https'
end

############################################################################################################################################
# Get command line arguments
dir          = './'
srvAndPort   = nil
doSSL        = false
osUsrAndPass = Array.new
while (ARGV[0] =~ /^-/) do
  curOpt = ARGV.shift
  if (/^-+s/i =~ curOpt) then
    if (RUBY_VERSION >= '1.8.6') then
      doSSL = true
    else
      STDOUT.puts("WARNING(mpmws): SSL (HTTPS) won't work with Ruby pre 1.8.6 -- ignoreing -s option")
    end
  elsif (tmp = curOpt.match(/^-+up=(.+)$/i)) then
    upv = tmp[1]
    if (RUBY_VERSION >= '1.8.6') then
      if (tmp = upv.match(/^([^:]+):(.+)$/)) then
        osUsrAndPass.push( [ tmp[1], tmp[2] ] )
      else
        STDERR.puts("WARNING(mpmws): Value of -up was badly formatted: '#{upv}")
      end
    else
      STDOUT.puts("WARNING(mpmws): HTTPAuth won't work with Ruby pre 1.8.6 -- ignoreing -p option")
    end
  elsif (tmp = curOpt.match(/^-+p(=(.+)){0,1}$/i)) then
    pwFile = tmp[2]
    if (pwFile.nil?) then
      if (ENV['HOME']) then
        pwFile = ENV['HOME'] + '/.mpmpw.txt'
      else
        pwFile = Etc.getpwnam(Etc.getlogin).dir + '/.mpmpw.txt'
      end
    end
    if (RUBY_VERSION >= '1.8.6') then
      open(pwFile) do |file|
        file.each_line do |line|
          if (tmp = line.strip.match(/^([^:]+):(.+)$/)) then
            osUsrAndPass.push( [ tmp[1], tmp[2] ] )
          else
            STDERR.puts("WARNING(mpmws): Password file had a bad line: '#{line}")
          end
        end
      end
    else
      STDOUT.puts("WARNING(mpmws): HTTPAuth won't work with Ruby pre 1.8.6 -- ignoreing -p option")
    end
  end
end
if (ARGV.size == 0) then
  # Nothing to do...
elsif (ARGV.size == 1) then
  if ( FileTest.directory?(ARGV[0])) then
    dir = ARGV[0]
  else
    srvAndPort = ARGV[0]
  end
elsif (ARGV.size == 2) then
  srvAndPort = ARGV[0]
  dir        = ARGV[1]
else
  puts("ERROR(mpmws): Too many command line arguments given!")
  exit(1)
end

############################################################################################################################################
# Figure out the bindAdd and bindPort
bindAdd    = 'localhost'
bindPort   = 8080
if ( !(srvAndPort)) then
  # nothing do do
elsif (/^\d+$/.match(srvAndPort)) then
  bindPort = srvAndPort.to_i
elsif(/^.+:\d+$/.match(srvAndPort)) then
  (bindAdd, bindPort) = srvAndPort.split(/:/)
  bindPort = bindPort.to_i
else
  bindAdd  = srvAndPort
end  

if ((bindAdd == 'localhost') || (bindAdd == 'loopback')) then
  bindAdd = '127.0.0.1'
end
if (bindAdd == 'external') then
  bindAdd = Socket.gethostbyname(Socket.gethostname)[0]
end
if (bindAdd == '*') then
  bindAdd = nil
end

############################################################################################################################################
# Print out what we think we were ask to do
STDOUT.puts("INFO(mpmws): Requested Web Server Information:\n")
STDOUT.puts("INFO(mpmws):   Bind Address: #{bindAdd}\n")
STDOUT.puts("INFO(mpmws):   Bind Port:    #{bindPort}\n")
STDOUT.puts("INFO(mpmws):   Server Root:  #{dir}\n")

############################################################################################################################################
# Make sure we can read the directory
if ( !(FileTest.exist?(dir))) then
  STDERR.puts("ERROR(mpmws):  Given path (#{dir}) doesn't exist!")
  exit(2)
end
if ( !(FileTest.directory?(dir))) then
  STDERR.puts("ERROR(mpmws):  Given path (#{dir}) exists, but is not a directory!")
  exit(3)
end
if ( !(FileTest.executable?(dir))) then
  STDERR.puts("ERROR(mpmws):  Given path (#{dir}) is a directory, but is not a executable!")
  exit(4);
end
if ( !(FileTest.readable?(dir))) then
  STDERR.puts("ERROR(mpmws):  Given path (#{dir}) is a directory, but is not a readable!")
  exit(5);
end

############################################################################################################################################
# Make sure port number is OK
if ( (bindPort < 1024) && (Process.uid != 0) ) then
  STDERR.puts("ERROR(mpmws):  Only root (uid=0) may open ports below 1024!")
  exit(6);
end

############################################################################################################################################
# How we authenticate

thePasswords = Hash.new
if (!(osUsrAndPass.empty?)) then
  osUsrAndPass.each do |u, p|
    if (p =~ /^([a-z0-9]{40}|[A-Z0-9]{40})$/) then
      thePasswords[tmp[1]] = tmp[2]
    else
      thePasswords[tmp[1]] = Digest::SHA1.hexdigest(tmp[2])
      STDERR.puts("WARNING(mpmws): Password didn't look like a SHA1 hash.  Hashed it: '#{u}:HIDDEN'")
    end
  end
end

authenticate = Proc.new do |req, res|
  HTTPAuth.basic_auth(req, res, '') do |user, password|
    thePasswords.empty? || (thePasswords[user] == Digest::SHA1.hexdigest(password || ''))
  end
end

############################################################################################################################################
# Create the server object, trap signals, and start server up.
STDOUT.puts("INFO(mpmws): Starting up web server now...")

aServer = nil
if (doSSL) then
  aServer = HTTPServer.new(:Port => bindPort, :BindAddress => bindAdd,
                           :SSLEnable       => true,
                           :SSLVerifyClient => ::OpenSSL::SSL::VERIFY_NONE,
                           :SSLCertName => [ ["C","US"], ["O","127.0.0.1"], ["CN", "127.0.0.1"] ]
                           )
else
  aServer = HTTPServer.new(:Port => bindPort, :BindAddress => bindAdd)
end

aServer.mount('/', HTTPServlet::FileHandler, dir, :FancyIndexing => true, :HandlerCallback => authenticate)

# Trap server shutdown signals
if (RUBY_PLATFORM =~ /mswin/) then
  ['INT' , 'TERM'].each do |sig|
    trap(sig) { aServer.shutdown }
  end
else
  ['HUP', 'QUIT', 'INT', 'TERM', 'USR1', 'USR2'].each do |sig|
    trap(sig) { aServer.shutdown }
  end
end

aServer.start