require "option_parser" require "http/server" require "ecr/macros" require "json" require "secure_random" require "./guff/*" private macro define_model_set_getters(hash) {% for name, klass in hash %} def {{ name.id }} : {{ klass.id }} (@cache[{{ name }}] ||= {{ klass.id }}.new(@context)) as {{ klass.id }} end {% end %} end private macro include_api_modules(modules) {% for mod in modules.resolve %} include {{ mod.id }} {% end %} end private macro api_method_dispatch(modules) case namespace {% for mod in modules.resolve %} {% mod_name = mod.resolve.name.gsub(/^.*:(.*)API$/, "\\1").downcase %} when {{ mod_name.stringify }} case method {% for mod_method in mod.resolve.methods %} {% method_name = mod_method.name.gsub(/^do_([^_]+)_/, "") %} when {{ method_name.stringify }} {{ mod_method.name.id }}(params) {% end %} else raise "unknown method: #{namespace}/#{method}" end {% end %} else raise "unknown namespace: #{namespace}" end end module Guff class Config property :mode, :env, :host, :port, :data_dir, :system_dir DEFAULTS = { mode: "help", env: "production", host: "127.0.0.1", port: "8989", data_dir: "./data", system_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 @system_dir = (ENV["GUFF_SYSTEM_DIR"]? || DEFAULTS[:system_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 system_dir=(dir : String) raise "missing system dir: \"#{dir}\"" unless Dir.exists?(dir) @system_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( "-S DIR", "--system-dir DIR", "Guff system directory (defaults to \"#{DEFAULTS[:system_dir]}\")" ) do |arg| r.system_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 module Models abstract class Model def initialize(@context : Context) end end class UserModel < Model def login(user : String, pass : String) : String? if @context.development? if user == "test" && pass == "test" "0" else nil end else # TODO: handle login nil end end def has_role?(user_id : String?, roles : Array(String)) raise "empty role list" unless roles.size > 0 if user_id && user_id.size > 0 if @context.development? user_id == "0" else # TODO: add role query end else # empty user id false end end def add_user( name : String? = nil, email : String? = nil, password : String? = nil, groups : Array(String)? = nil, active : Bool? = nil, ) : Int64 # TODO create user user_id = 0_i64 # set user attributes set_user( user_id: user_id, email: email, password: password, groups: groups, active: active, ) # return user id user_id end def set_user( user_id : Int64, name : String? = nil, email : String? = nil, password : String? = nil, groups : Array(String)? = nil, active : Bool? = nil, ) end def get_users end end class SessionModel < Model def initialize(context : Context) super(context) @sessions = {} of String => String end def load(id : String) : String? @sessions[id]? end def save(id : String, data : String) if @sessions.has_key?(id) @sessions[id] = data true else false end end def delete(id : String?) @sessions.delete(id) if id false end def create(data : String) : String # generate id r = SecureRandom.hex(32) # save session @sessions[r] = data # return session id r end end class CSRFModel < Model getter :minutes def initialize(context : Context) super(context) @cache = {} of String => Int64 # expire form after 5 minutes # TODO: make this configurable @minutes = 5 end def create_token remove_expired_tokens # generate and cache new token r = SecureRandom.hex(16) @cache[r] = Time.now.epoch + 60 * @minutes # return token r end def use_token(id : String) remove_expired_tokens if @cache.has_key?(id) # remove token, return success @cache.delete(id) true else # return failure false end end private def remove_expired_tokens now = Time.now.epoch # FIXME: limit the size of the cache # to prevent insane memory use # remove expired entries @cache.delete_if do |key, val| val < now end end end end class ModelSet def initialize(@context : Context) @cache = {} of Symbol => Models::Model end define_model_set_getters({ user: Models::UserModel, session: Models::SessionModel, csrf: Models::CSRFModel, }) end class Session < Hash(String, String) getter :session_id # session cookie name # FIXME: does this belong here? COOKIE = "guff_session" def initialize(@context : Context) super() @session_id = nil end def load(id : String) begin # clear existing session clear # load session values JSON.parse(@context.models.session.load(id).not_nil!).each do |key, val| self[key.as_s] = val.as_s end # save session id @session_id = id # return success true rescue err STDERR.puts "session load failed: #{err}" # invalid session id, return failure false end end def create(hash : Hash(String, String)) : String clear merge!(hash) @session_id = @context.models.session.create(hash.to_json) end def save if valid? @context.models.session.save(@session_id, to_json) # return success true else # no session, return failure false end end def clear super @session_id = nil end def delete : String? r = @session_id if valid? @context.models.session.delete(r) clear end r end def valid? @session_id != nil end end class Context getter :config def initialize(@config : Config) end def models @models ||= ModelSet.new(self) end def session @session ||= Session.new(self) end def user_id session["user_id"]? end def has_role?(roles : Array(String)) models.user.has_role?(user_id, roles) end def development? @is_development ||= (@config.env == "development") as Bool end end module APIs module PostAPI def do_post_get_posts(params : HTTP::Params) { "asdf": "foo" } end end module UserAPI def do_user_add_user(params : HTTP::Params) user_id = @context.models.user.add_user( name: params["name"]?, email: params["email"], password: params["password"]?, active: params["active"]? ? (params["active"] == "t") : nil, # groups: params["groups"]? ? JSON.parse(params["groups"]) : nil, ) { "user_id": user_id } end end end module Views abstract class View def initialize(@context : Context) end def h(s : String) : String HTML.escape(s) end end class TabView < View def initialize( context : Context, @prefix : String, @tab : Hash(Symbol, String) ) super(context) @id = h("%s-tab-%s" % [@prefix, @tab[:id]]) as String @target = h("%s-pane-%s" % [@prefix, @tab[:id]]) as String end private def v(id : Symbol) : String raise "unknown id: #{id}" unless @tab.has_key?(id) h(@tab[id]) end ECR.def_to_s("src/views/tab.ecr") end abstract class HTMLView < View TEMPLATES = { script: "", style: "", } private def assets(key : Symbol, paths : Array(String)) String.build do |io| paths.each do |path| io << TEMPLATES[key] % [h(path)] end end end def scripts(paths : Array(String)) assets(:script, paths) end def styles(paths : Array(String)) assets(:style, paths) end def tabs(rows : Array(Hash(Symbol, String))) String.build do |io| rows.each do |row| TabView.new(@context, "admin", row).to_s(io) end end end end class AdminPageView < HTMLView TITLE = "Guff Admin" TABS = [{ id: "home", css: "active", icon: "fa-home", name: "Home", text: "View home tab.", }, { id: "posts", css: "", icon: "fa-cubes", name: "Posts", text: "Manage blog, pages, and projects.", }, { id: "files", css: "", icon: "fa-files-o", name: "Files", text: "Manage files.", }, { id: "users", css: "", icon: "fa-users", name: "Users", text: "Manage users and permissions.", }, { id: "settings", css: "", icon: "fa-cog", name: "Settings", text: "Configure site settings.", }] def tabs super(TABS) end ECR.def_to_s("src/views/admin-page.ecr") end class LoginPageView < HTMLView def initialize(context : Context, @error : String? = nil) super(context) end def get_csrf_token @context.models.csrf.create_token end ECR.def_to_s("src/views/login-page.ecr") end class LogoutPageView < HTMLView ECR.def_to_s("src/views/logout-page.ecr") end end module Handlers abstract class Handler < HTTP::Handler def initialize(@context : Context) super() end protected def valid_origin_headers?(headers : HTTP::Headers) # FIXME: need to compare these against something rather than # just making sure that they are there %w{origin referer}.any? do |key| headers[key]? && headers[key].size > 0 end end end abstract class AuthenticatedHandler < Handler def initialize(context : Context, @roles : Array(String)) super(context) end def call(context : HTTP::Server::Context) if @context.has_role?(@roles) authenticated_call(context) else call_next(context) end end abstract def authenticated_call(context : HTTP::Server::Context) end class SessionHandler < Handler def call(context : HTTP::Server::Context) # clear session @context.session.clear if cookie = context.request.cookies[Guff::Session::COOKIE]? # load session @context.session.load(cookie.value) end call_next(context) end end class APIHandler < Handler PATH_RE = %r{^/guff/api/(?[\w_-]+)/(?[\w_-]+)$} API_MODULES = [ APIs::PostAPI, APIs::UserAPI, ] include_api_modules(API_MODULES) def call(context : HTTP::Server::Context) if context.request.method == "POST" || (@context.development? && context.request.method == "GET") if md = PATH_RE.match(context.request.path.not_nil!) namespace, method = %w{namespace method}.map { |k| md[k] } params = HTTP::Params.parse(context.request.body || "") code, data = begin { 200, api_method_dispatch(API_MODULES) } rescue err STDERR.puts "ERROR: #{err}" { 400, { "error": err.to_s } } end # send json response context.response.status_code = code context.response.content_type = "application/json; charset=utf-8" data.to_json(context.response) else call_next(context) end else call_next(context) end end end class AssetsHandler < Handler def initialize(context : Context) super(context) @etags = {} of String => String end def call(context : HTTP::Server::Context) req_path = context.request.path.not_nil! if matching_request?(context.request.method, req_path) && valid_origin_headers?(context.request.headers) # 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.headers["x-frame-options"] = "SAMEORIGIN" 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.system_dir, "assets", 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.html$} def authenticated_call(context : HTTP::Server::Context) if context.request.path.not_nil!.match(PATH_RE) context.response.headers["x-frame-options"] = "SAMEORIGIN" 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.html$} VALID_METHODS = %w{GET POST} 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" reply(context.response) when "POST" begin # check for valid origin or referer header unless valid_origin_headers?(context.request.headers) raise "missing origin and referer headers" end # create session session_id = @context.session.create({ "user_id": login(context.request.body), }) # add cookie context.response.cookies << HTTP::Cookie.new( name: Guff::Session::COOKIE, value: session_id as String, http_only: true, # TODO # expires: # secure: ) # redirect to admin panel context.response.headers["location"] = "/guff/admin.html" context.response.status_code = 302 rescue err # log error STDERR.puts "login failed: #{err}" reply(context.response, "invalid login") end else raise "invalid HTTP method" end else call_next(context) end end private def reply( response : HTTP::Server::Response, error : String? = nil ) response.headers["x-frame-options"] = "SAMEORIGIN" response.content_type = "text/html; charset=utf-8" response.status_code = 200 Views::LoginPageView.new(@context, error).to_s(response) end private def login(body : String?) : String # check body raise "empty request body" if body.nil? || body.size == 0 # parse request parameters params = HTTP::Params.parse(body.not_nil!) # check login parameters raise "missing login parameters" unless %w{ username password csrf_token }.all? do |key| params.has_key?(key) && params[key].size > 0 end # check csrf token unless @context.models.csrf.use_token(params["csrf_token"]) raise "invalid csrf token" end # try login user_id = @context.models.user.login( params["username"], params["password"] ) # check user id raise "invalid credentials" unless user_id # return user id user_id end end class LogoutPageHandler < Handler PATH_RE = %r{^/guff/logout.html$} def call(context : HTTP::Server::Context) if context.request.method == "GET" && PATH_RE.match(context.request.path.not_nil!) && valid_origin_headers?(context.request.headers) # delete session @context.session.delete # clear cookie context.response.cookies << HTTP::Cookie.new( name: Guff::Session::COOKIE, value: "", expires: Time.epoch(0), http_only: true, ) # build remaining headers context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 # draw page Views::LogoutPageView.new(@context).to_s(context.response) 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: :api, }, { dev: false, id: :assets, }, { dev: false, id: :admin, }, { dev: false, id: :login, }, { dev: false, id: :logout, }] def self.get(context : Context) : Array(HTTP::Handler) HANDLERS.select { |row| !(row[:dev] as Bool) || context.development? }.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 :api APIHandler.new(context) when :assets AssetsHandler.new(context) when :admin AdminPageHandler.new(context) when :login LoginPageHandler.new(context) when :logout LogoutPageHandler.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 { "system": @config.system_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 # parse command-line arguments 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)