require "option_parser" require "http/server" require "ecr/macros" require "./guff/*" module Guff class Config property :mode, :env, :host, :port, :data_dir, :assets_dir DEFAULTS = { mode: "help", env: "production", host: "127.0.0.1", port: "8989", data_dir: "./data", assets_dir: "/usr/local/share/guff", } def initialize @mode = DEFAULTS[:mode] as String @env = (ENV["GUFF_ENVIRONMENT"]? || DEFAULTS[:env]) as String @host = (ENV["GUFF_HOST"]? || DEFAULTS[:host]) as String @port = (ENV["GUFF_PORT"]? || DEFAULTS[:port]) as String @data_dir = (ENV["GUFF_DATA_DIR"]? || DEFAULTS[:data_dir]) as String @assets_dir = (ENV["GUFF_ASSETS_DIR"]? || DEFAULTS[:assets_dir]) as String end VALID_MODES = %w{init run help} def mode=(mode : String) raise "unknown mode: \"#{mode}\"" unless VALID_MODES.includes?(mode) @mode = mode end VALID_ENVS = %w{development production} def env=(env : String) raise "unknown environment: \"#{env}\"" unless VALID_ENVS.includes?(env) @env = env end def port=(port : String) val = port.to_i raise "invalid port: #{port}" unless val > 0 && val < 65535 @port = port end def assets_dir=(dir : String) raise "missing assets dir: \"#{dir}\"" unless Dir.exists?(dir) @assets_dir = dir end def self.parse( app : String, args : Array(String) ) : Config r = Config.new raise "missing mode" unless args.size > 0 # get mode r.mode = case mode = args.shift when "-h", "--help" "help" else mode end # parse arguments p = OptionParser.parse(args) do |p| p.banner = "Usage: #{app} [mode] " p.separator p.separator("Run Options:") p.on( "-H HOST", "--host HOST", "TCP host (defaults to \"#{DEFAULTS[:host]}\")" ) do |arg| r.host = arg end p.on( "-p PORT", "--port PORT", "TCP port (defaults to \"#{DEFAULTS[:port]}\")" ) do |arg| r.port = arg end p.separator p.separator("Directory Options:") p.on( "-D DIR", "--data-dir DIR", "Data directory (defaults to \"#{DEFAULTS[:data_dir]}\")" ) do |arg| r.data_dir = arg end p.on( "-A DIR", "--assets-dir DIR", "Guff assets directory (defaults to \"#{DEFAULTS[:assets_dir]}\")" ) do |arg| r.assets_dir = arg end p.separator p.separator("Development Options:") p.on( "-E ENV", "--environment ENV", "Environment (defaults to \"#{DEFAULTS[:env]})\"" ) do |arg| r.env = arg end p.separator p.separator("Other Options:") p.on("-h", "--help", "Print usage.") do r.mode = "help" end end case r.mode when "init" # shortcut for -D parameter r.data_dir = args.shift if args.size > 0 when "help" # print help puts p end # return config r end end module MimeType TYPES = { ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".png": "image/png", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".otf": "application/vnd.ms-opentype", ".eot": "application/vnd.ms-fontobject", ".svg": "image/svg+xml", ".ttf": "application/x-font-ttf", ".woff": "application/font-woff", ".woff2": "application/font-woff", } def self.from_path(path : String) : String TYPES[File.extname(path)]? || "application/octet-stream" end end class Context property :config #, :models def initialize(@config : Config) # @models = nil end end module Views abstract class View def initialize(@context : Context) end def h(s : String) : String HTML.escape(s) end TEMPLATES = { script: "", style: "", } private def assets(key : Symbol, paths : Array(String)) paths.map { |path| TEMPLATES[key] % [h(path)] }.join end def scripts(paths : Array(String)) assets(:script, paths) end def styles(paths : Array(String)) assets(:style, paths) end end class AdminPageView < View ECR.def_to_s("src/views/admin-page.ecr") end class LoginPageView < View ECR.def_to_s("src/views/login-page.ecr") end end module Handlers abstract class Handler < HTTP::Handler def initialize(@context : Context) super() end end abstract class AuthenticatedHandler < Handler def initialize(context : Context, roles : Array(String)) super(context) @roles = roles end def call(context : HTTP::Server::Context) role = context.request.headers["x-guff-role"]? if role && @roles.includes?(role) authenticated_call(context) else call_next(context) end end abstract def authenticated_call(context : HTTP::Server::Context) end class SessionHandler < Guff::Handlers::Handler def call(context : HTTP::Server::Context) if @context.config.env == "development" # TODO: add handler support context.request.headers["x-guff-user-id"] = "0" context.request.headers["x-guff-role"] = "admin" else context.request.headers["x-guff-user-id"] = "" context.request.headers["x-guff-role"] = "" end call_next(context) end end class AssetsHandler < AuthenticatedHandler def initialize(context : Context) super(context, %w{admin editor}) @etags = {} of String => String end def authenticated_call(context : HTTP::Server::Context) req_path = context.request.path.not_nil! if matching_request?(context.request.method, req_path) # get expanded path to file if abs_path = expand_path(req_path) # get file digest etag = get_file_etag(abs_path) # check for cache header if context.request.headers["if-none-match"]? == etag # cached, send 304 not modified context.response.status_code = 304 else # not cached, set code and send headers context.response.status_code = 200 context.response.content_type = MimeType.from_path(abs_path) context.response.content_length = File.size(abs_path) context.response.headers["etag"] = etag if context.request.method == "GET" # send body for GET requests File.open(abs_path) do |fh| IO.copy(fh, context.response) end end end else # expanded path does not exist call_next(context) end else # not a matching request call_next(context) end end VALID_METHODS = %w{GET HEAD} PATH_RE = %r{^/guff/assets/} private def matching_request?(method, path) VALID_METHODS.includes?(method) && PATH_RE.match(path) end private def expand_path(req_path : String) : String? # unescape path, check for nil byte path = URI.unescape(req_path) return nil if path.includes?('\0') # build absolute path r = File.join( @context.config.assets_dir, File.expand_path(path.gsub(PATH_RE, ""), "/") ) # return path if file exists, or nil otherwise File.file?(r) ? r : nil end private def get_file_etag(path : String) : String # FIXME: rather than a hash this should be an HMAC @etags[path] ||= OpenSSL::Digest.new("SHA1").file(path).hexdigest end end class AdminPageHandler < AuthenticatedHandler def initialize(context : Context) super(context, %w{admin editor}) end PATH_RE = %r{^/guff/admin/$} def authenticated_call(context : HTTP::Server::Context) if context.request.path.not_nil!.match(PATH_RE) context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 Views::AdminPageView.new(@context).to_s(context.response) else call_next(context) end end end class LoginPageHandler < Handler PATH_RE = %r{^/guff/login/$} VALID_METHODS = %w{GET HEAD} def call(context : HTTP::Server::Context) if VALID_METHODS.includes?(context.request.method) && PATH_RE.match(context.request.path.not_nil!) case context.request.method when "GET" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 Views::LoginPageView.new(@context).to_s(context.response) when "POST" # TODO: try login else raise "invalid HTTP method" end else call_next(context) end end end HANDLERS = [{ dev: true, id: :error, }, { dev: false, id: :log, }, { dev: false, id: :deflate, }, { dev: false, id: :session, }, { dev: false, id: :assets, }, { dev: false, id: :admin, }, { dev: false, id: :login, }] def self.get(context : Context) : Array(HTTP::Handler) is_dev = (context.config.env == "development") HANDLERS.select { |row| !(row[:dev] as Bool) || is_dev }.map { |row| make_handler(row[:id] as Symbol, context) } end def self.make_handler( handler_id : Symbol, context : Context ) : HTTP::Handler case handler_id when :error HTTP::ErrorHandler.new when :log HTTP::LogHandler.new when :deflate HTTP::DeflateHandler.new when :session SessionHandler.new(context) when :assets AssetsHandler.new(context) when :admin AdminPageHandler.new(context) when :login LoginPageHandler.new(context) else raise "unknown handler id: #{handler_id}" end end end module CLI module Actions abstract class Action def self.run(config : Config) new(config).run end def initialize(@config : Config) end abstract def run end class InitAction < Action def run STDERR.puts "TODO: building directory" end end class RunAction < Action def run STDERR.puts "TODO: running web server" check_dirs # create context context = Context.new(@config) STDERR.puts "listening on %s:%s" % [@config.host, @config.port] # run server HTTP::Server.new( @config.host, @config.port.to_i, Handlers.get(context) ).listen end private def check_dirs { "assets": @config.assets_dir, "data": @config.data_dir, }.each do |name, dir| unless Dir.exists?(dir) raise "missing #{name} directory: \"#{dir}\"" end end end end end def self.run(app : String, args : Array(String)) begin begin config = Config.parse(app, args) rescue err raise "#{err}. Use --help for usage" end case config.mode when "init" Actions::InitAction.run(config) when "run" Actions::RunAction.run(config) when "help" # do nothing else # never reached raise "unknown mode: #{config.mode}" end rescue err STDERR.puts "ERROR: #{err}." exit -1 end end end end Guff::CLI.run($0, ARGV)