summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.lua19
-rw-r--r--web/db.lua62
-rw-r--r--web/fail.lua1
-rw-r--r--web/main.lua58
-rw-r--r--web/mime.lua9
-rw-r--r--web/redirect.lua1
-rw-r--r--web/router.lua20
-rw-r--r--web/server.lua114
-rw-r--r--web/session.lua59
9 files changed, 343 insertions, 0 deletions
diff --git a/web/app.lua b/web/app.lua
new file mode 100644
index 0000000..9a7e24b
--- /dev/null
+++ b/web/app.lua
@@ -0,0 +1,19 @@
+local class = require'r.class'
+
+local App = class()
+function App.make(cls, fields)
+ fields.hooks = fields.hooks or {
+ pre_start = {},
+ pre_req = {},
+ post_req = {}}
+ return setmetatable(fields,cls) end
+function App.add_hook(self,name,fn)
+ local lst = self.hooks[name]
+ assert(lst, 'unknown hook name')
+ local ix = name:match"^post" and 1 or #lst+1
+ table.insert(lst, ix, fn) end
+function App.run_hooks(self,name,...)
+ local lst = self.hooks[name]
+ assert(lst,'unknown hook name')
+ for i,f in ipairs(lst) do f(...) end end
+return App
diff --git a/web/db.lua b/web/db.lua
new file mode 100644
index 0000000..5b616c2
--- /dev/null
+++ b/web/db.lua
@@ -0,0 +1,62 @@
+local dbi = require'DBI'
+local cqueues = require'cqueues'
+
+local function db_do(db, sql, ...)
+ -- print('>>',sql,...)
+ local stmt = assert(db:prepare(sql))
+ ::tryagain::
+ local ok, err = stmt:execute(...)
+ if not ok and err:match"locked" then
+ print'still waiting'
+ cqueues.sleep(1)
+ goto tryagain
+ end
+ assert(ok, err)
+ return stmt
+end
+
+local extra_methods = {}
+function extra_methods.fetch_one(db, sql, ...)
+ local stmt = db_do(db, sql, ...)
+ local row = stmt:fetch(true)
+ stmt:close()
+ return row
+end
+function extra_methods.fetch(db, sql, ...)
+ local stmt = db_do(db, sql, ...)
+ local rows, i = {}, 1
+ for row in stmt:rows(true) do
+ rows[i] = row
+ i = i + 1
+ end
+ stmt:close()
+ return rows
+end
+function extra_methods.exec(db, sql, ...)
+ local stmt = db_do(db, sql, ...)
+ stmt:close()
+end
+
+local function get_conn()
+ local c = assert(dbi.Connect('SQLite3','database.db'))
+ -- fuck it
+ local mt = getmetatable(c)
+ for k,v in pairs(extra_methods) do mt.__index[k] = v end
+ mt.__close = function(x) x:close() end
+ c:exec('pragma foreign_keys=ON;')
+ return c
+end
+
+local function apply_migrations(migrations)
+ local conn <close> = get_conn()
+ local ver = conn:fetch_one"pragma user_version".user_version
+ for i = ver+1, #migrations do
+ print("applying migration #"..i)
+ conn:exec(migrations[i])
+ end
+ conn:exec("pragma user_version="..#migrations)
+ assert(conn:commit())
+end
+
+
+return {conn=get_conn,apply_migrations=apply_migrations}
diff --git a/web/fail.lua b/web/fail.lua
new file mode 100644
index 0000000..0201cc9
--- /dev/null
+++ b/web/fail.lua
@@ -0,0 +1 @@
+return function(status) error({fail=true,status=status,tb=debug.traceback(nil,2)},2) end
diff --git a/web/main.lua b/web/main.lua
new file mode 100644
index 0000000..0368063
--- /dev/null
+++ b/web/main.lua
@@ -0,0 +1,58 @@
+local cqueues = require 'cqueues'
+local cq_notify = require 'cqueues.notify'
+local posix_dirent = require 'posix.dirent'
+
+local server = require 'lib.server'
+local app = require 'app'
+local db = require 'lib.db'
+
+local pprint = require'pprint'
+
+-- should happen on code reload too probably
+app:run_hooks('pre_start')
+
+print'NEW CQUEUE'
+local cq = cqueues.new()
+server.run(cq, app)
+
+local function reload(modname)
+ local oldmod = require(modname)
+ assert(type(oldmod) == "table","reloading only works on modules that return tables")
+ package.loaded[modname] = nil
+ local newmod = require(modname)
+ package.loaded[modname]=oldmod
+ assert(type(newmod) == "table","new module doesn't seem to be a table, cancelling")
+ print('','r',modname,oldmod.version,newmod.version)
+ for k in pairs(oldmod) do oldmod[k] = nil end
+ for k,v in pairs(newmod) do oldmod[k] = v end
+end
+
+local function reloader()
+ print('NEW RELOADER')
+ local path = '.'
+ local function modname(filename) return filename:match"^([a-z_]+)%.lua$" end
+
+ local notifier = cq_notify.opendir(path)
+
+ local seen = {}
+ local function add(filename)
+ if not seen[filename] then seen[filename]=true print('adding',filename) notifier:add(filename) end
+ end
+
+ for filename in posix_dirent.files(path) do
+ local m = modname(filename)
+ if m and m ~= 'main' then add(filename) end
+ end
+
+ for changes, filename in notifier:changes() do
+ pprint('changes',changes,filename)
+ local m = modname(filename)
+ if m then
+ print('!!! reloading ['..m..']')
+ print(pcall(reload, m))
+ end
+ end
+end
+cq:wrap(reloader)
+
+assert(cq:loop())
diff --git a/web/mime.lua b/web/mime.lua
new file mode 100644
index 0000000..5aa49c5
--- /dev/null
+++ b/web/mime.lua
@@ -0,0 +1,9 @@
+local m = {}
+
+m.exts = {
+ js = 'application/javascript',
+ html = 'text/html',
+ css = 'text/css',
+}
+
+return m
diff --git a/web/redirect.lua b/web/redirect.lua
new file mode 100644
index 0000000..8c056b5
--- /dev/null
+++ b/web/redirect.lua
@@ -0,0 +1 @@
+return function (path) return '', {location=path}, 303 end
diff --git a/web/router.lua b/web/router.lua
new file mode 100644
index 0000000..09be12a
--- /dev/null
+++ b/web/router.lua
@@ -0,0 +1,20 @@
+local fail = require'lib.fail'
+local class = require'r.class'
+local pprint = require'pprint'
+local Router = class()
+function Router.make(cls) return setmetatable({routes={}},cls) end
+function Router._add(self, patt,israw,view)
+ table.insert(self.routes,{patt=patt,israw=israw,view=view}) end
+function Router.path(self, path, view) self:_add(path,true,view) end
+function Router.patt(self, patt, view) self:_add(patt,false,view) end
+-- todo: recursion
+function Router.__call(self, req) for _,r in ipairs(self.routes) do
+ local s = { req.path:find(r.patt,1,r.israw) }
+ if s[1] then return r.view(req, table.unpack(s,3)) end end
+ fail(404)
+end
+
+return Router
+
+-- view protocol:
+-- view: callable(req) -> (body,headers,status)
diff --git a/web/server.lua b/web/server.lua
new file mode 100644
index 0000000..a0cc505
--- /dev/null
+++ b/web/server.lua
@@ -0,0 +1,114 @@
+local cqueues = require 'cqueues'
+local cq_condition = require'cqueues.condition'
+local http_server = require 'http.server'
+local http_headers = require 'http.headers'
+local http_util = require 'http.util'
+local http_cookie = require 'http.cookie'
+local pprint=require'pprint'
+
+local function make_req(app, server, stream)
+ local req = {server=server, stream=stream, app=app}
+ req.headers = assert(req.stream:get_headers())
+ req.method = req.headers:get":method"
+
+ local path = req.headers:get":path"
+ local sel, qs = path:match"^(.-)%?(.*)$"
+ if sel then path = sel end
+ req.path, req.qs = path, qs
+ req.args = {}
+ if req.qs then
+ for k,v in http_util.query_args(req.qs) do req.args[k] = v end
+ end
+
+ req.cookies = assert(http_cookie.parse_cookies(req.headers))
+
+ if req.method == 'POST' then
+ req.form = {}
+ local content_type = req.headers:get'content-type'
+ local is_form = content_type:match'^application/x%-www%-form%-urlencoded'
+ if is_form then
+ local body = stream:get_body_as_string()
+ -- i'm like 96% sure this is correct
+ body = body:gsub('+',' ')
+ for k,v in http_util.query_args(body) do
+ req.form[k] = v
+ end
+ end
+ end
+
+ req.app:run_hooks('pre_req',req)
+ return req
+end
+
+-- take return values of view function, construct and send http response
+local function make_resp(req, body, header_dict, status_code)
+ if body == false then return end -- assume view sent its own response
+
+ local rheaders
+ if rawequal(getmetatable(header_dict), http_headers.mt) then
+ rheaders = header_dict
+ else
+ rheaders = http_headers.new()
+ for k,v in pairs(header_dict or {}) do
+ rheaders:append(k:gsub("_","-"), v)
+ end
+ if not rheaders:has':status' then
+ rheaders:append(':status',tostring(status_code or 200)) end
+ if not rheaders:has'content-type' then
+ rheaders:append('content-type','text/html') end
+ end
+
+ req.app:run_hooks('post_req',req,body,rheaders,status_code)
+
+ assert(req.stream:write_headers(rheaders, false))
+
+ if type(body) == 'string' then
+ assert(req.stream:write_body_from_string(body))
+ elseif io.type(body) == 'file' then
+ assert(req.stream:write_body_from_file(body))
+ body:close()
+ elseif type(body) == 'table' and getmetatable(body).__tostring then
+ assert(req.stream:write_body_from_string(tostring(body)))
+ else
+ error('unsupported response type: '..type(body))
+ end -- todo maybe: chunk iterators, if i need them
+end
+
+local function handle_request(app, server, stream)
+ local req = make_req(app, server, stream)
+ print('handling',app.version,req.method,req.path)
+ local ok, body, headers, status = xpcall(app.view, debug.traceback, req)
+ if not ok then
+ if type(body)=='table' and body.fail then
+ status,headers = body.status, nil
+ body = '<h1>'..status..' page</h1><pre>'..body.tb..'</pre>'
+ else
+ status, headers = 500, nil
+ body = '<h1>500 internal server explosion</h1><pre>'..body..'</pre>'
+ end
+ end
+ return make_resp(req, body, headers, status)
+end
+
+local function onerror(server, ctx, op, err, errno)
+ local msg = op .. " on " .. tostring(ctx) .. " failed"
+ if err then msg = msg.. ": " .. tostring(err) end
+ io.stderr:write(msg, "\n")
+end
+
+local function run(cq, app)
+ local server = assert(http_server.listen {
+ cq = cq, host = '0.0.0.0', port = 8082, onerror = onerror,
+ onstream = function(server, stream)
+ local ok, err = xpcall(handle_request, debug.traceback, app, server, stream)
+ if not ok then error(err, 0) end
+ end
+ })
+ assert(server:listen())
+ local _,addr,port = server:localname()
+ print(("listening on http://%s:%s"):format(addr,port))
+end
+
+return {
+ run = run,
+}
diff --git a/web/session.lua b/web/session.lua
new file mode 100644
index 0000000..3c6c297
--- /dev/null
+++ b/web/session.lua
@@ -0,0 +1,59 @@
+local base64 = require'base64'
+local hmac = require'openssl.hmac'
+local pprint=require'pprint'
+local json = require'dkjson'
+local http_cookie = require'http.cookie'
+local unpack=unpack or table.unpack
+
+local t_enc = base64.makeencoder('-','_')
+local t_dec = base64.makedecoder('-','_')
+local function enc(s) return base64.encode(s,t_enc):gsub('=','') end
+local function dec(s) return base64.decode(s..("="):rep((4-#s)%4),t_dec) end
+
+local function encode(app, fields)
+ local header = enc(json.encode{alg="HS256",typ="JWT"})
+ fields.exp = os.time()+app.session_ttl
+ local payload = enc(json.encode(fields))
+ local front = header..'.'..payload
+ local h = hmac.new(app.secret_key,'sha256')
+ local sig = enc(h:final(front))
+ return front..'.'..sig
+end
+
+local function _decode(app, token)
+ local parts = {}
+ for s in token:gmatch("[^%.]+") do table.insert(parts,s) end
+ assert(#parts==3) local front=parts[1]..'.'..parts[2]
+ local dparts = {} for i,v in ipairs(parts) do dparts[i]=dec(v) end
+ local header,payload,sig = unpack(dparts)
+ header=assert(json.decode(header)) payload=assert(json.decode(payload))
+ assert(header.typ=='JWT' and header.alg=='HS256','bad header')
+ assert(type(payload.exp)=='number' and payload.exp > os.time(),'expired')
+ local nsig = hmac.new(app.secret_key,'sha256'):final(front)
+ assert(nsig==sig,'bad signature')
+ return payload
+end
+local function decode(...) return pcall(_decode,...) end
+
+local function pre_req(req)
+ if req.cookies.SESSION then
+ local ok, val = decode(req.app,req.cookies.SESSION)
+ if ok then req.session = val return
+ else print('\trejected session: '..val) end
+ end
+ req.session = setmetatable({},{__jsontype='object'})
+end
+local function post_req(req, body, headers, status)
+ if req.session then headers:append('set-cookie',
+ http_cookie.bake('SESSION',encode(req.app,req.session),
+ os.time()+req.app.session_ttl, nil, '/'))
+end end
+
+local function install(app)
+ assert(app.session_ttl, 'missing app.session_ttl')
+ assert(app.secret_key, 'missing app.secret_key')
+ app:add_hook('pre_req',pre_req)
+ app:add_hook('post_req',post_req)
+end
+
+return {install=install}