require "http/server" require "ecr/macros" require "json" require "yaml" require "secure_random" require "sqlite3" module Guff VERSION = "20160715" end require "./guff/**" 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 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 protected def get_site_id(host : String?) : Int64? @context.models.site.get_id(host) 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 StubHandler < Handler def call(context : HTTP::Server::Context) STDERR.puts "%s %s" % [ context.request.method, context.request.path.not_nil! ] call_next(context) end 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) if @context.session.valid? @context.session.save end end end class APIHandler < Handler PATH_RE = %r{^/guff/api/(?[\w_-]+)/(?[\w_-]+)$} API_MODULES = [ APIs::PostAPI, APIs::UserAPI, APIs::PageAPI, APIs::ProjectAPI, APIs::BlogAPI, APIs::SiteAPI, ] 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] } # get query parameteres params = if (context.request.method == "GET") context.request.query_params else HTTP::Params.parse(context.request.body || "") end 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 = AssetMimeType.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| STDERR.puts "sending #{abs_path}" IO.copy(fh, context.response) STDERR.puts "done sending #{abs_path}" 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).to_s, }) # 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?) # 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.not_nil! 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 class PageHandler < Handler PATH_RE = %r{^/(?[^/]+)\.html$} def call(context : HTTP::Server::Context) if post_id = get_post_id(context.request) # TODO: render page context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 context.response << "page: #{post_id}" else # unknown page call_next(context) end end private def get_post_id(request : HTTP::Request) : Int64? r = nil if request.method == "GET" if md = PATH_RE.match(request.path.not_nil!) if site_id = get_site_id(request.headers["host"]?) r = @context.models.page.get_id( site_id: site_id, slug: md["slug"], ) end end end # return result r end end class ProjectHandler < Handler PATH_RE = %r{^/(?[^/]+)/?$} def call(context : HTTP::Server::Context) if post_id = get_post_id(context.request) path = context.request.path.not_nil! if /\/$/.match(path) # TODO: render page context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 context.response << "project: #{post_id}" else # redirect to project context.response.headers["location"] = path + "/" context.response.status_code = 302 end else # unknown page call_next(context) end end private def get_post_id(request : HTTP::Request) : Int64? r = nil if request.method == "GET" if md = PATH_RE.match(request.path.not_nil!) if site_id = get_site_id(request.headers["host"]?) r = @context.models.project.get_id( site_id: site_id, slug: md["slug"], ) end end end # return result r end end class BlogPostHandler < Handler PATH_RE = %r{^ (/blog)? /(?\d{4}) /(?\d{2}) /(?\d{2}) /(?[^/]+)\.html $}x def call(context : HTTP::Server::Context) if id = get_id(context.request) # TODO: render page context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 context.response << "blog post id: #{id}" else # unknown page call_next(context) end end private def get_id(request : HTTP::Request) : Int64? r = nil if request.method == "GET" if md = PATH_RE.match(request.path.not_nil!) if site_id = get_site_id(request.headers["host"]?) r = @context.models.blog.get_id( site_id: site_id, year: md["year"].to_i, month: md["month"].to_i, day: md["day"].to_i, slug: md["slug"], ) end end end # return result r end end class BlogListHandler < Handler # TODO: make index page configurable PATH_RE = %r{^/ (blog/?)? ( (?\d{4})/ ( (?\d{2})/ ((?\d{2})/)? )? )? $}x def call(context : HTTP::Server::Context) if ids = get_ids(context.request) # TODO: render page context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 Views::Blog::ListView.new(@context, ids).to_s(context.response) else # unknown page call_next(context) end end private def get_ids(request : HTTP::Request) : Array(Int64)? r = nil if request.method == "GET" if md = PATH_RE.match(request.path.not_nil!) if site_id = get_site_id(request.headers["host"]?) # get request parameters params = request.query_params r = @context.models.blog.get_ids( site_id: site_id, year: md["year"]? ? md["year"].to_i : nil, month: md["month"]? ? md["month"].to_i : nil, day: md["day"]? ? md["day"].to_i : nil, page: params["page"]? ? params["page"].to_i : nil, ) end end end # return result r end end HANDLERS = [{ :dev => true, :id => :stub, }, { :dev => true, :id => :error, }, { :dev => false, :id => :log, }, { :dev => false, :id => :deflate, }, { :dev => false, :id => :assets, }, { :dev => false, :id => :page, }, { :dev => false, :id => :project, }, { :dev => false, :id => :blog_post, }, { :dev => false, :id => :blog_list, }, { :dev => false, :id => :login, }, { :dev => false, :id => :session, }, { :dev => false, :id => :api, }, { :dev => false, :id => :admin, }, { :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 :stub StubHandler.new(context) 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) when :page PageHandler.new(context) when :blog_post BlogPostHandler.new(context) when :blog_list BlogListHandler.new(context) when :project ProjectHandler.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 class Data YAML.mapping({ init_sql: Array(String), add_user: String, test_posts: Array(String), }) def self.load(system_dir : String) : Data self.from_yaml(File.read(File.join(system_dir, "init.yaml"))) end end def initialize(config : Config) super(config) # read init data @data = Data.load(@config.system_dir) end def run STDERR.puts "Initializing data directory" Dir.mkdir(@config.data_dir) unless Dir.exists?(@config.data_dir) Guff::Database.new(@config.db_path) do |db| @data.init_sql.each do |sql| db.query(sql) end # gen random password and add admin user # TODO: move these to init.yaml password = Password.random_password add_user(db, "Admin", "admin@admin", password) add_user(db, "Test", "test@test", "test") add_test_posts(db) STDERR.puts "admin user: admin@admin, password: #{password}" end end private def add_user( db : Database, name : String, email : String, password : String ) : Int64 db.query(@data.add_user, [ name, email, Password.create(password), "admin", ]) db.last_insert_row_id.to_i64 end private def add_test_posts(db) # STDERR.puts "DEBUG: adding test data" @data.test_posts.each do |sql| db.query(sql) end end end class RunAction < Action def run STDERR.puts "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)