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 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 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/(?[\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::BlogListView.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)