require "option_parser" require "http/server" require "ecr/macros" require "json" require "yaml" require "secure_random" require "crypto/bcrypt" require "sqlite3" require "./guff/*" private macro define_lazy_getters(hash) {% for name, klass in hash %} def {{ name.id }} : {{ klass.id }} (@cached_{{ name.id }} ||= {{ klass.id }}.new(@context)) 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 db_path File.join(@data_dir, "guff.db") 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 ISO8601 = ::Time::Format.new("%Y-%m-%dT%H:%M:%SZ") 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 Password def self.create(password : String) : String Crypto::Bcrypt::Password.create(password).to_s end def self.test(hash : String, password : String) : Bool Crypto::Bcrypt::Password.new(hash) == password end def self.random_password SecureRandom.base64(6 + rand(6)).strip.gsub(/\=+$/, "") end end class PagedResultSet def initialize( @page : Int32, @num_rows : Int64, @limit : Int32, @rows : Array(Hash(String, String)) ) end def to_json(io) { "meta": { "page": @page, "num_rows": @num_rows, "num_pages": (1.0 * @num_rows / @limit).ceil }, "rows": @rows }.to_json(io) end end module Models abstract class Model def initialize(@context : Context) end end class PostModel < Model SQL = { add: " INSERT INTO posts(site_id, created_by, state_id) VALUES (?, ?, (SELECT state_id FROM states WHERE state = 'draft')) ", fts_add: " INSERT INTO posts_fts(rowid, name, slug, body) SELECT post_id, name, slug, body FROM posts WHERE post_id = ? ", set: " UPDATE posts SET %s WHERE post_id = ? ", fts_set: " UPDATE posts_fts SET %s WHERE rowid = ? ", count_posts: " SELECT COUNT(*) FROM posts a JOIN states b ON (b.state_id = a.state_id) JOIN sites c ON (c.site_id = a.site_id) JOIN users d ON (d.user_id = a.created_by) LEFT JOIN blogs x ON (x.post_id = a.post_id) LEFT JOIN pages y ON (y.post_id = a.post_id) LEFT JOIN projects z ON (z.post_id = a.post_id) WHERE %s ", get_posts: " SELECT a.post_id, a.site_id, c.name AS site_name, b.state, d.user_id, d.name AS user_name, d.email AS user_email, a.created_at, date(a.created_at) AS created_at_text, datetime(a.created_at) AS created_at_text_full, a.posted_at, date(a.posted_at) AS posted_at_text, datetime(a.posted_at) AS posted_at_text_full, a.expires_at, date(a.expires_at) AS expires_at_text, datetime(a.expires_at) AS expires_at_text_full, a.slug, a.slug_lock, a.name, (CASE WHEN x.post_id IS NOT NULL THEN 'blog' WHEN y.post_id IS NOT NULL THEN 'page' WHEN z.post_id IS NOT NULL THEN 'project' END) as post_type, (CASE WHEN b.state = 'public' THEN CASE WHEN x.post_id IS NOT NULL THEN strftime('/%%Y/%%m/%%d/', a.posted_at) || a.slug || '.html' WHEN y.post_id IS NOT NULL THEN '/' || a.slug || '.html' WHEN z.post_id IS NOT NULL THEN '/' || a.slug || '/' END ELSE NULL END) AS post_url FROM posts a JOIN states b ON (b.state_id = a.state_id) JOIN sites c ON (c.site_id = a.site_id) JOIN users d ON (d.user_id = a.created_by) LEFT JOIN blogs x ON (x.post_id = a.post_id) LEFT JOIN pages y ON (y.post_id = a.post_id) LEFT JOIN projects z ON (z.post_id = a.post_id) WHERE c.is_active AND %s ORDER BY COALESCE(a.posted_at, a.created_at) DESC LIMIT ? OFFSET ? ", } def add( site_id : Int64, user_id : Int64, ) : Int64 db = @context.dbs.rw post_id = -1_i64 db.transaction do # add entry db.query(SQL[:add], [site_id.to_s, user_id.to_s]) post_id = db.last_insert_row_id.to_i64 # populate fts index db.query(SQL[:fts_add], [post_id.to_s]) end # return post_id post_id end def set( post_id : Int64, site_id : Int64? = nil, state : String? = nil, have_posted_at : Bool = false, posted_at : Time? = nil, have_expires_at : Bool = false, expires_at : Time? = nil, slug : String? = nil, slug_lock : Bool? = nil, name : String? = nil, body : String? = nil, ) sets = [] of String args = [] of String fts_sets = [] of String fts_args = [] of String if site_id sets << "site_id = ?" args << site_id.to_s end if state sets << "state_id = (SELECT state_id FROM states WHERE state = ?)" args << state if state == "public" && !have_posted_at # set posted_at by default sets << "posted_at = COALESCE(posted_at, CURRENT_TIMESTAMP)" end end if have_posted_at if posted_at sets << "posted_at = ?" args << ISO8601.format(posted_at) else sets << "posted_at = NULL" end end if have_expires_at if expires_at sets << "expires_at = ?" args << ISO8601.format(expires_at) else sets << "expires_at = NULL" end end if slug sets << "slug = ?" args << slug fts_sets << "slug = ?" fts_args << slug end unless slug_lock.nil? sets << "slug_lock = ?" args << (slug_lock ? "1" : "0") end if name sets << "name = ?" args << name fts_sets << "name = ?" fts_args << name end if body sets << "body = ?" args << body fts_sets << "body = ?" fts_args << body.gsub(/<.+?>/, " ") end if sets.size > 0 # update posts args << post_id.to_s @context.dbs.rw.query(SQL[:set] % sets.join(","), args) if fts_sets.size > 0 # update posts fts fts_args << post_id.to_s @context.dbs.rw.query(SQL[:fts_set] % fts_sets.join(","), fts_args) end end end LIMIT = 50 def get_posts( site_id : Int64? = nil, user_id : Int64? = nil, type : String? = nil, state : String? = nil, q : String? = nil, page : Int32 = 1, ) filters = %w{1} args = [] of String # check page raise "invalid page: #{page}" unless page > 0 if site_id # add site filter filters << "a.site_id = ?" args << site_id.to_s end if user_id # add user filter filters << "a.created_by = ?" args << user_id.to_s end if type # add type filter filters << case type when "blog" "x.post_id IS NOT NULL" when "page" "y.post_id IS NOT NULL" when "project" "z.post_id IS NOT NULL" when "all" # allow "all" "1" else raise "unknown post type: #{type}" end end if state && state != "default" # add state filter filters << "b.state = ?" args << state else # default state filter filters << "b.state IN ('draft', 'public')" end if q && q.match(/\S+/) # add search filter filters << "a.post_id IN ( SELECT rowid FROM posts_fts WHERE posts_fts MATCH ? )" args << q end # build where clause filter_sql = filters.join(" AND ") # count matching rows num_posts = (@context.dbs.ro.one(SQL[:count_posts] % [ filter_sql ], args) || "0").to_i64 # build result rows = [] of Hash(String, String) if num_posts > 0 # get matching rows @context.dbs.ro.all(SQL[:get_posts] % [ filter_sql ], args.concat([ LIMIT.to_s, ((page - 1) * LIMIT).to_s, ])) do |row| # append row to result rows << row.reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end end # return results PagedResultSet.new( page: page, num_rows: num_posts, limit: LIMIT, rows: rows, ) end end class PageModel < Model SQL = { get_id: " SELECT b.post_id FROM sites a JOIN posts b ON (b.site_id = a.site_id) JOIN pages c ON (c.post_id = b.post_id) JOIN states d ON (d.state_id = b.state_id) WHERE a.site_id = ? AND b.slug = ? AND d.state = 'public' ORDER BY b.created_at DESC LIMIT 1 ", add: " INSERT INTO pages(post_id, layout_id) VALUES (?, (SELECT layout_id FROM layouts WHERE layout = 'default')) ", set: " UPDATE pages SET layout_id = (SELECT layout_id FROM layouts WHERE layout = ?) WHERE post_id = ? ", get: " SELECT a.post_id, a.site_id, c.state, a.posted_at, a.expires_at, a.slug, a.slug_lock, a.name, a.body, d.layout FROM posts a JOIN pages b ON (b.post_id = a.post_id) JOIN states c ON (c.state_id = a.state_id) JOIN layouts d ON (d.layout_id = b.layout_id) WHERE a.post_id = ? ", } def get_id( site_id : Int64, slug : String ) : Int64? r = @context.dbs.ro.one(SQL[:get_id], [site_id.to_s, slug]) r ? r.to_i64 : nil end def add( site_id : Int64, user_id : Int64, ) : Int64 db = @context.dbs.rw post_id = -1_i64 db.transaction do post_id = @context.models.post.add(site_id, user_id) db.query(SQL[:add], [post_id.to_s]) end post_id end def set( post_id : Int64, site_id : Int64? = nil, state : String? = nil, have_posted_at : Bool = false, posted_at : Time? = nil, have_expires_at : Bool = false, expires_at : Time? = nil, slug : String? = nil, slug_lock : Bool? = nil, name : String? = nil, body : String? = nil, layout : String? = nil, ) db = @context.dbs.rw db.transaction do @context.models.post.set( post_id: post_id, site_id: site_id, state: state, have_posted_at: have_posted_at, posted_at: posted_at, have_expires_at: have_expires_at, expires_at: expires_at, slug: slug, slug_lock: slug_lock, name: name, body: body, ) if layout db.query(SQL[:set], [layout, post_id.to_s]) end end end def get(post_id : Int64) @context.dbs.ro.row(SQL[:get], [post_id.to_s]).not_nil! end end class ProjectModel < Model SQL = { get_id: " SELECT b.post_id FROM sites a JOIN posts b ON (b.site_id = a.site_id) JOIN projects c ON (c.post_id = b.post_id) JOIN states d ON (d.state_id = b.state_id) WHERE a.site_id = ? AND b.slug = ? AND d.state = 'public' ORDER BY b.created_at DESC LIMIT 1 ", add: " INSERT INTO projects(post_id) VALUES (?) ", set: " UPDATE projects SET repo_url = ? WHERE post_id = ? ", get: " SELECT a.post_id, a.site_id, c.state, a.posted_at, a.expires_at, a.slug, a.slug_lock, a.name, a.body, b.repo_url FROM posts a JOIN projects b ON (b.post_id = a.post_id) JOIN states c ON (c.state_id = a.state_id) WHERE a.post_id = ? ", } def get_id( site_id : Int64, slug : String ) : Int64? r = @context.dbs.ro.one(SQL[:get_id], [site_id.to_s, slug]) r ? r.to_i64 : nil end def add( site_id : Int64, user_id : Int64, ) : Int64 db = @context.dbs.rw post_id = -1_i64 db.transaction do post_id = @context.models.post.add(site_id, user_id) db.query(SQL[:add], [post_id.to_s]) end post_id end def set( post_id : Int64, site_id : Int64? = nil, state : String? = nil, have_posted_at : Bool = false, posted_at : Time? = nil, have_expires_at : Bool = false, expires_at : Time? = nil, slug : String? = nil, slug_lock : Bool? = nil, name : String? = nil, body : String? = nil, repo_url : String? = nil, ) db = @context.dbs.rw db.transaction do @context.models.post.set( post_id: post_id, site_id: site_id, state: state, have_posted_at: have_posted_at, posted_at: posted_at, have_expires_at: have_expires_at, expires_at: expires_at, slug: slug, slug_lock: slug_lock, name: name, body: body, ) if repo_url db.query(SQL[:set], [repo_url, post_id.to_s]) end end end def get(post_id : Int64) @context.dbs.ro.row(SQL[:get], [post_id.to_s]).not_nil! end end class BlogModel < Model SQL = { get_ids: " SELECT b.post_id FROM sites a JOIN posts b ON (b.site_id = a.site_id) JOIN blogs c ON (c.post_id = b.post_id) JOIN states d ON (d.state_id = b.state_id) WHERE a.site_id = ? AND %s AND d.state = 'public' -- TODO: handle posted_at and expired_at ORDER BY COALESCE(b.posted_at, b.created_at) DESC LIMIT ? OFFSET ? ", add: " INSERT INTO blogs(post_id) VALUES (?) ", get: " SELECT a.post_id, a.site_id, c.state, a.posted_at, a.expires_at, a.slug, a.slug_lock, a.name, a.body FROM posts a JOIN blogs b ON (b.post_id = a.post_id) JOIN states c ON (c.state_id = a.state_id) WHERE a.post_id = ? ", } def get_id( site_id : Int64, year : Int32, month : Int32, day : Int32, slug : String ) : Int64? ids = get_ids(site_id, year, month, day, slug) (ids.size > 0) ? ids.first : nil end # TODO: make this configurable LIMIT = 50 def get_ids( site_id : Int64, year : Int32? = nil, month : Int32? = nil, day : Int32? = nil, slug : String? = nil, page : Int32? = nil ) : Array(Int64) sql = ["1"] args = [site_id.to_s] # STDERR.puts "DEBUG: site_id = #{site_id}, year = #{year}, month = #{month}, day = #{day}, slug = \"#{slug}\"" if year # add year filter sql << "strftime('%Y', b.posted_at) + 0 = ? + 0" args << year.to_s if month # add month filter sql << "strftime('%m', b.posted_at) + 0 = ? + 0" args << month.to_s if day # add day filter sql << "strftime('%d', b.posted_at) + 0 = ? + 0" args << day.to_s end end end if slug # add slug filter sql << "b.slug = ?" args << slug end page ||= 1 raise "invalid page: #{page}" if page < 1 args << LIMIT.to_s args << ((page - 1) * LIMIT).to_s # STDERR.puts "DEBUG: args = #{args.to_json}" # exec query, build result r = [] of Int64 @context.dbs.ro.all(SQL[:get_ids] % sql.join(" AND "), args) do |row| p row r << row["post_id"] as Int64 end # return results r end def add( site_id : Int64, user_id : Int64, ) : Int64 db = @context.dbs.rw post_id = -1_i64 db.transaction do post_id = @context.models.post.add(site_id, user_id) db.query(SQL[:add], [post_id.to_s]) end post_id end def set( post_id : Int64, site_id : Int64? = nil, state : String? = nil, have_posted_at : Bool = false, posted_at : Time? = nil, have_expires_at : Bool = false, expires_at : Time? = nil, slug : String? = nil, slug_lock : Bool? = nil, name : String? = nil, body : String? = nil, repo_url : String? = nil, ) @context.models.post.set( post_id: post_id, site_id: site_id, state: state, have_posted_at: have_posted_at, posted_at: posted_at, have_expires_at: have_expires_at, expires_at: expires_at, slug: slug, slug_lock: slug_lock, name: name, body: body, ) end def get(post_id : Int64) @context.dbs.ro.row(SQL[:get], [post_id.to_s]).not_nil! end end class UserModel < Model SQL = { login: " SELECT user_id, password FROM users WHERE is_active AND email = ? ", has_role: " SELECT 1 FROM users WHERE is_active AND user_id = ? AND role_id IN (SELECT role_id FROM roles WHERE role IN (%s)) ", add: " INSERT INTO users(role_id, name, email, password, is_active) VALUES ((SELECT role_id FROM roles where role = ?), ?, ?, ?, ?) ", set: " UPDATE users SET %s WHERE user_id = ? ", get_users: " SELECT a.user_id, a.name, a.email, a.is_active, b.role, b.name AS role_name FROM users a JOIN roles b ON (b.role_id = a.role_id) ORDER BY LOWER(a.name) ", get: " SELECT a.user_id, a.name, a.email, a.is_active, b.role, b.name AS role_name FROM users a JOIN roles b ON (b.role_id = a.role_id) WHERE a.user_id = ? ", } def login( email : String, password : String ) : Int64? r = nil if row = @context.dbs.ro.row(SQL[:login], [email]) if Password.test(row["password"] as String, password) # given email and password matches active user r = row["user_id"] as Int64 end end # return result r end def has_role?(user_id : Int64, roles : Array(String)) raise "empty role list" unless roles.size > 0 !!@context.dbs.ro.one(SQL[:has_role] % [ (["?"] * roles.size).join(",") ], [user_id.to_s].concat(roles)) end def add( name : String, email : String, password : String, role : String, active : Bool, ) : Int64 @context.dbs.rw.query(SQL[:add], [ role, name, email, Password.create(password), active ? "1" : "0", ]) @context.dbs.rw.last_insert_row_id.to_i64 end def set( user_id : Int64, name : String? = nil, email : String? = nil, password : String? = nil, role : String? = nil, active : Bool? = nil, ) sets = [] of String args = [] of String if name sets << "name = ?" args << name end if email sets << "email = ?" args << email end if role sets << "role_id = (SELECT role_id FROM roles WHERE role = ?)" args << role end if password sets << "password = ?" args << Password.create(password) end if active != nil sets << "is_active = ?" args << (active ? "1" : "0") end if sets.size > 0 args << user_id.to_s @context.dbs.rw.query(SQL[:set] % sets.join(", "), args) end end def get_users rows = [] of Hash(String, String) @context.dbs.ro.all(SQL[:get_users]) do |row| # append row to result rows << row.reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end rows end def get(user_id : Int64) row = @context.dbs.ro.row(SQL[:get], [user_id.to_s]) raise "unknown user: #{user_id}" unless row row.reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end end # TODO: handle session expiration class SessionModel < Model SQL = { load: " SELECT data FROM sessions WHERE id = ? -- TODO: -- AND strftime('%s', created_at, '1 week') > strftime('%s') -- AND strftime('%s', updated_at, '2 hours') > strftime('%s') ", save: " UPDATE sessions SET updated_at = CURRENT_TIMESTAMP, data = ? WHERE id = ? ", delete: " DELETE FROM sessions WHERE id = ? ", create: " INSERT INTO sessions(id, data) VALUES (?, ?) ", } def load(id : String) : String? @context.dbs.ro.one(SQL[:load], [id]) end def save(id : String, data : String) @context.dbs.rw.query(SQL[:save], [data, id]) nil end def delete(id : String?) @context.dbs.rw.query(SQL[:delete], [id]) if id nil end def create(data : String) : String # generate id r = SecureRandom.hex(32) # save session @context.dbs.rw.query(SQL[:create], [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 class StateModel < Model SQL = { get_states: " SELECT state_id, state, name, icon FROM states ORDER BY sort ", } def get_states rows = [] of Hash(String, String) @context.dbs.ro.all(SQL[:get_states]) do |row| rows << row.reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end rows end end class SiteModel < Model SQL = { get_id: " SELECT site_id FROM ( SELECT a.site_id FROM site_domains a JOIN sites b ON (b.site_id = a.site_id) WHERE b.is_active AND a.domain = $1 UNION ALL SELECT site_id FROM sites WHERE is_active AND is_default ) a LIMIT 1 ", get_default_id: " SELECT site_id FROM sites WHERE is_active AND is_default ", get_sites: " SELECT site_id, name, is_active, is_default FROM sites ORDER BY LOWER(name) ", } def get_id(host : String?) : Int64? r = @context.dbs.ro.one(SQL[:get_id], [host || ""]) r ? r.to_i64 : nil end def get_default_id : Int64 @context.dbs.ro.one(SQL[:get_default_id]).not_nil!.to_i64 end def get_sites rows = [] of Hash(String, String) @context.dbs.ro.all(SQL[:get_sites]) do |row| rows << row.reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end rows end end class RoleModel < Model SQL = { get_roles: " SELECT role, name FROM roles ORDER BY sort ", } def get_roles : Array(Hash(String, String)) r = [] of Hash(String, String) @context.dbs.ro.all(SQL[:get_roles]) do |row| r << { "role" => row["role"] as String, "name" => row["name"] as String, } end r end end end class ModelSet def initialize(@context : Context) end define_lazy_getters({ user: Models::UserModel, session: Models::SessionModel, csrf: Models::CSRFModel, post: Models::PostModel, page: Models::PageModel, project: Models::ProjectModel, blog: Models::BlogModel, site: Models::SiteModel, role: Models::RoleModel, state: Models::StateModel, }) 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.not_nil!, 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 DatabasePair getter :ro, :rw def initialize( path : String, debug : Bool = false ) @ro = Database.new( path: path, read_only: true, debug: debug ) @rw = Database.new( path: path, read_only: false, debug: debug ) end end class Context getter :config, :dbs def initialize(@config : Config) @dbs = DatabasePair.new(@config.db_path, development?) end def models @models ||= ModelSet.new(self) end def session @session ||= Session.new(self) end def user_id : Int64? session["user_id"]? ? session["user_id"].to_i64 : nil end def has_role?(roles : Array(String)) id = user_id id ? models.user.has_role?(id, roles) : false end def development? @is_development ||= (@config.env == "development") as Bool end end module APIs module PostAPI def do_post_get_posts(params : HTTP::Params) @context.models.post.get_posts( site_id: (params["site_id"]? && params["site_id"] != "all") ? params["site_id"].to_i64 : nil, user_id: (params["user_id"]? && params["user_id"] != "all") ? params["user_id"].to_i64 : nil, state: params["state"]?, type: params["type"]?, q: params["q"]?, page: params["page"].to_i32, ) end end module PageAPI def do_page_add(params : HTTP::Params) post_id = @context.models.page.add( site_id: params["site_id"]? ? params["site_id"].to_i64 : @context.models.site.get_default_id, user_id: @context.user_id.not_nil!, ) { "post_id": post_id } end def do_page_set(params : HTTP::Params) @context.models.page.set( post_id: params["post_id"].to_i64, site_id: params["site_id"]? ? params["site_id"].to_i64 : nil, state: params["state"]?, have_posted_at: !!params["posted_at"]?, posted_at: params["posted_at"]? ? ISO8601.parse(params["posted_at"]) : nil, have_expires_at: !!params["expires_at"]?, expires_at: params["expires_at"]? ? ISO8601.parse(params["expires_at"]) : nil, slug: params["slug"]?, slug_lock: params["slug_lock"]? ? (params["slug_lock"] == "t") : nil, name: params["name"]?, body: params["body"]?, layout: params["layout"]?, ) nil end def do_page_get(params : HTTP::Params) @context.models.page.get( post_id: params["post_id"].to_i64 ).reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end end module ProjectAPI def do_project_add(params : HTTP::Params) post_id = @context.models.project.add( site_id: params["site_id"]? ? params["site_id"].to_i64 : @context.models.site.get_default_id, user_id: @context.user_id.not_nil!, ) { "post_id": post_id } end def do_project_set(params : HTTP::Params) @context.models.project.set( post_id: params["post_id"].to_i64, site_id: params["site_id"]? ? params["site_id"].to_i64 : nil, state: params["state"]?, have_posted_at: !!params["posted_at"]?, posted_at: params["posted_at"]? ? ISO8601.parse(params["posted_at"]) : nil, have_expires_at: !!params["expires_at"]?, expires_at: params["expires_at"]? ? ISO8601.parse(params["expires_at"]) : nil, slug: params["slug"]?, slug_lock: params["slug_lock"]? ? (params["slug_lock"] == "t") : nil, name: params["name"]?, body: params["body"]?, repo_url: params["repo_url"]?, ) nil end def do_project_get(params : HTTP::Params) @context.models.project.get( post_id: params["post_id"].to_i64 ).reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end end module BlogAPI def do_blog_add(params : HTTP::Params) post_id = @context.models.blog.add( site_id: params["site_id"]? ? params["site_id"].to_i64 : @context.models.site.get_default_id, user_id: @context.user_id.not_nil!, ) { "post_id": post_id } end def do_blog_set(params : HTTP::Params) @context.models.blog.set( post_id: params["post_id"].to_i64, site_id: params["site_id"]? ? params["site_id"].to_i64 : nil, state: params["state"]?, have_posted_at: !!params["posted_at"]?, posted_at: params["posted_at"]? ? ISO8601.parse(params["posted_at"]) : nil, have_expires_at: !!params["expires_at"]?, expires_at: params["expires_at"]? ? ISO8601.parse(params["expires_at"]) : nil, slug: params["slug"]?, slug_lock: params["slug_lock"]? ? (params["slug_lock"] == "t") : nil, name: params["name"]?, body: params["body"]?, ) nil end def do_blog_get(params : HTTP::Params) @context.models.blog.get( post_id: params["post_id"].to_i64 ).reduce({} of String => String) do |r, kv| r[kv[0]] = kv[1].to_s r end end end module UserAPI def do_user_add(params : HTTP::Params) user_id = @context.models.user.add( name: params["name"], email: params["email"], password: params["password"], active: (params["active"] == "t"), role: params["role"], ) { "user_id": user_id } end def do_user_get(params : HTTP::Params) @context.models.user.get(params["user_id"].to_i64) end def do_user_set(params : HTTP::Params) @context.models.user.set( user_id: params["user_id"].to_i64, name: params["name"]?, email: params["email"]?, password: (params["password"]? && params["password"].size > 0) ? params["password"] : nil, active: params["active"]? ? (params["active"] == "t") : nil, role: params["role"]?, ) nil end def do_user_get_users(params : HTTP::Params) @context.models.user.get_users end end module SiteAPI def do_site_get_sites(params : HTTP::Params) @context.models.site.get_sites 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 module Dropdown module Icon ICON_TEMPLATE = "" def self.icon(id : String?) if id && id.size > 0 ICON_TEMPLATE % [HTML.escape(id.not_nil!)] else "" end end end class ItemView < View def initialize( context : Context, @active : Bool, @item : Hash(Symbol, String) ) super(context) end private def v(id : Symbol) h(@item[id]) end private def li_css @active ? "class='active'" : "" end ECR.def_to_s("src/views/dropdown/item.ecr") end class MenuView < View def initialize( context : Context, @id : String, @name : String, @text : String, @css : String, @icon : String, @default : String, @items : Array(Hash(Symbol, String)) ) super(context) @default_name = @items.reduce("") do |r, row| (row[:id]? == @default) ? row[:name] : r end as String end private def items String.build do |io| @items.each do |item| io << ItemView.new( context: @context, active: @default == item[:id]?, item: item ).to_s end end end ECR.def_to_s("src/views/dropdown/menu.ecr") end 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(prefix : String, rows : Array(Hash(Symbol, String))) String.build do |io| rows.each do |row| TabView.new(@context, prefix, row).to_s(io) end end end def dropdown( id : String, name : String, text : String, icon : String, css : String, default : String, items : Array(Hash(Symbol, String)) ) Dropdown::MenuView.new( context: @context, id: id, name: name, text: text, icon: icon, css: css, default: default, items: items ).to_s end end class AdminPageView < HTMLView TITLE = "Guff Admin" TABS = { "admin" => [{ :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 => "settings", :css => "", :icon => "fa-cogs", :name => "Settings", :text => "Configure settings.", }], "settings" => [{ :id => "general", :css => "active", :icon => "fa-cog", :name => "General", :text => "Manage general settings.", }, { :id => "backups", :css => "", :icon => "fa-archive", :name => "Backups", :text => "Manage backups.", }, { :id => "import", :css => "", :icon => "fa-upload", :name => "Import / Export", :text => "Import and export posts.", }, { :id => "sites", :css => "", :icon => "fa-sitemap", :name => "Sites", :text => "Manage sites and domains.", }, { :id => "themes", :css => "", :icon => "fa-eye", :name => "Themes", :text => "Manage themes.", }, { :id => "users", :css => "", :icon => "fa-users", :name => "Users", :text => "Manage users and permissions.", }], } TEMPLATES = { :option => " ", :new_post_button => " Create ", :state_button => " %s ", } def tabs(id : String) super(id, TABS[id]) end private def new_post_button TEMPLATES[:new_post_button] end private def role_options @role_options ||= String.build do |io| @context.models.role.get_roles.each do |row| io << TEMPLATES[:option] % %w{role name}.map { |key| h(row[key]) } end end end private def state_buttons @state_buttons ||= String.build do |io| @context.models.state.get_states.each do |row| io << TEMPLATES[:state_button] % [ h(row["name"]), h(row["state"]), h(row["icon"]), h(row["name"]) ] end end end private def authors_menu_items @context.models.user.get_users.map do |row| { :id => row["user_id"], :name => row["name"], :text => "Show author \"%s\"." % [row["name"]], } end end private def sites_menu_items @context.models.site.get_sites.map do |row| { :id => row["site_id"], :name => row["name"], :text => "Show site \"%s\"." % [row["name"]], } end end private def states_menu_items @context.models.state.get_states.map do |row| { :id => row["state"], :name => row["name"], :icon => row["icon"], :text => "Show state \"%s\"." % [row["name"]], } end 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 class BlogListView < HTMLView ECR.def_to_s("src/views/blog-list.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 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 = 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| 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 context.response << "blog list: ids = #{ids.join(", ")}" 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"]?) 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: md["page"]? ? md["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)