diff options
| author | rebecca <ubq323@ubq323.website> | 2026-09-17 20:54:44 +0100 |
|---|---|---|
| committer | rebecca <ubq323@ubq323.website> | 2026-09-17 20:54:44 +0100 |
| commit | 6c5d5fec129a29c6250b094f46881cfa5aaadcc7 (patch) | |
| tree | b66161fccfa91180e937f0689763f2124a4af97b | |
| parent | 7f60b7c4e8d99f97a0a24b60bd4c4fd15697a4bb (diff) | |
web: import from m1msq 15b348f
| -rw-r--r-- | web/app.lua | 19 | ||||
| -rw-r--r-- | web/db.lua | 62 | ||||
| -rw-r--r-- | web/fail.lua | 1 | ||||
| -rw-r--r-- | web/main.lua | 58 | ||||
| -rw-r--r-- | web/mime.lua | 9 | ||||
| -rw-r--r-- | web/redirect.lua | 1 | ||||
| -rw-r--r-- | web/router.lua | 20 | ||||
| -rw-r--r-- | web/server.lua | 114 | ||||
| -rw-r--r-- | web/session.lua | 59 |
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} |
