# fs - Crystal implementation of a simple static file server
require "uri"
require "http"
require "option_parser"
require "yaml"
require "logger"
require "mime"
# 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
# This gets called if HTTP::StaticFileHandler can't find a file.
# The most common case is a directory path, in which case
# we look for index.html in that directory.
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 = MIME.from_filename(path)
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