fs - simple file server

fs.cr at [87b77ab4de]
Login

fs.cr at [87b77ab4de]

File src/fs.cr artifact ff1867bb3f part of check-in 87b77ab4de


# fs - Crystal implementation of a simple static file server

require "uri"
require "http"
require "option_parser"
require "yaml"
require "logger"

# Class for reading configuration information from fs.yml.

class Config
  # Required settings
  getter host : String # 127.0.0.1 for localhost, 0.0.0.0 for all hosts
  getter port : Int32
  getter root : String

  # Optional settings
  getter sslport : (Int32|Nil)
  getter key : (String|Nil)
  getter cert : (String|Nil)
  getter log : (String|Nil)
  getter loglevel : (String|Nil)

  def initialize(config_file : String)
    yaml = File.open(config_file) {|file| YAML.parse(file) }

    # host, port, and root are required.
    @host = yaml["host"].as_s
    @port = yaml["port"].as_i
    @root = yaml["root"].as_s

    # sslport, key, and cert are optional.
    if yaml["sslport"]?
      @sslport = yaml["sslport"].as_i
      @key = yaml["key"].as_s
      @cert = yaml["cert"].as_s
    end

    # log and loglevel are optional.
    if yaml["log"]?
      @log = yaml["log"].as_s
    end
    if yaml["loglevel"]?
      @loglevel = yaml["loglevel"].as_s
    end
  end
end

# Logger class that reads the log level and log filename from fs.yml

module MyLog
  extend self

  @@log = uninitialized Logger

  def configure(config : Config)
    levels = {
      "DEBUG"   => Logger::DEBUG,
      "ERROR"   => Logger::ERROR,
      "FATAL"   => Logger::FATAL,
      "INFO"    => Logger::INFO,
      "UNKNOWN" => Logger::UNKNOWN,
      "WARN"    => Logger::WARN
    }

    filename = config.log || ""
    loglevel = config.loglevel || "DEBUG"
    if filename.size > 0
      file = File.open(filename, "a+")
      @@log = Logger.new(file)
    else
      @@log = Logger.new(STDOUT)
    end
    @@log.level = levels[loglevel.upcase]
  end

  delegate debug, to: @@log
  delegate error, to: @@log
  delegate fatal, to: @@log
  delegate info, to: @@log
  delegate unknown, to: @@log
  delegate warn, to: @@log

  def close
    @log.close
  end
end

# Simple HTTP server for static files.

class Server
  def initialize(config : Config)
    @config = config
    @root = config.root
    @server = uninitialized HTTP::Server
  end

  def process_request(context : HTTP::Server::Context)
    request = context.request
    request_path = request.path
    method = request.method

    MyLog.debug "process_request: got path #{request_path}, method #{method}"

    path = File.join(@root, request_path)
    if Dir.exists?(path)
      path = File.join(path, "index.html")
    end
    if File.exists?(path)
      content_type = `file -b --mime-type #{path}`.strip
      context.response.content_type = content_type
      MyLog.info "Serving #{path}, content type #{content_type}"
      File.open(path) { |file| IO.copy(file, context.response) }
    else
      MyLog.error "No such file #{path}"
      context.response.respond_with_status(404, "No such file #{request_path}")
    end
  end

  def start
    @server = HTTP::Server.new([
      HTTP::ErrorHandler.new,
      HTTP::LogHandler.new,
      HTTP::StaticFileHandler.new(@root, directory_listing: false)
      ]) do |context|
        process_request(context)
      end

    if @server
      address = @server.bind_tcp @config.host, @config.port
      puts "Listening on http://#{address}"

      # If SSL is specified, set that up.  If you have Apache
      # available, it's probably better to NOT let fs handle
      # SSL, but let Apache handle it and put fs behind a
      # reverse proxy.
      if @config.sslport
	ssl_context = OpenSSL::SSL::Context::Server.new
	ssl_context.certificate_chain = @config.cert || ""
	ssl_context.private_key = @config.key || ""
	@server.bind_tls "0.0.0.0", @config.sslport || 0, ssl_context
	puts "Listening on SSL port #{@config.sslport}"
      end
      @server.listen
    end
  end

  def stop
    MyLog.debug "Server::stop"
    if @server
      @server.close
    end
  end
end

def doit
  banner = <<-BANNER
fs [options]
BANNER

  config_file = "./fs.yml"

  OptionParser.parse do |parser|
    parser.banner = banner
    parser.on("-c FILENAME", "--config=FILENAME",
              "Specifies the config filename") { |name| config_file = name }
  end

  # Read config file.
  puts "Using config file " + config_file
  config = Config.new(config_file);

  # Set up logging.
  MyLog.configure(config)

  # Start the server.
  server = Server.new(config)
  server.start
  server.stop
end

doit