require "http/server" require "ecr/macros" require "json" require "yaml" require "secure_random" require "sqlite3" module Guff VERSION = "0.1.0" end 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 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 BlogListItemView < HTMLView def initialize(context : Context, @post_id : Int64) super(context) end ECR.def_to_s("src/views/blog/list-item.ecr") end # # TODO: add y/m/d/page # class BlogListView < HTMLView TITLE = "Blog List" def initialize(context : Context, @post_ids : Array(Int64)) super(context) end def posts String.build do |io| @post_ids.each do |id| BlogListItemView.new(@context, id).to_s(io) end end end 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/(?