# HG changeset patch # User Matthew Wild # Date 1615292216 0 # Node ID 6279a7d40ae79483e36fc2a2c64cf63962fc1f74 Initial commit diff -r 000000000000 -r 6279a7d40ae7 Dockerfile --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Dockerfile Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,62 @@ +#################################### +FROM debian:buster-slim as build + +MAINTAINER Matthew Wild + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + lua5.2 \ + liblua5.2-dev \ + libidn11-dev \ + libssl-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp/build + +ADD https://hg.prosody.im/trunk/archive/tip.tar.gz ./prosody.tar.gz + +RUN tar --strip-components=1 -xzf prosody.tar.gz \ + && ./configure && make + +ADD src/web/ util/ + +############################ +FROM debian:buster-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + tini \ + lua5.2 \ + lua-cjson \ + lua-expat \ + lua-filesystem \ + lua-sec \ + lua-socket \ + libidn11 \ + lua-dbi-postgresql \ + lua-scrypt \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /tmp/build/util /usr/local/lib/lua-web-app/util +COPY --from=build /tmp/build/net /usr/local/lib/lua-web-app/net + +ENV LUA_WEB_APP_FRAMEWORK /usr/local/lib/lua-web-app + +ADD src/ /usr/local/lib/lua-web-app + +WORKDIR /opt + +ADD default-app/html ./html +ADD default-app/app ./app + +ADD config.dist.lua /etc/app/config.lua + +VOLUME /var/lib/app + +ENV LISTEN_INTERFACE * +ENV LISTEN_PORT 8007 +EXPOSE 8007 + +ENTRYPOINT ["/usr/bin/tini"] +CMD ["/usr/bin/lua5.2", "/usr/local/lib/lua-web-app/main.lua", "/etc/app/config.lua"] diff -r 000000000000 -r 6279a7d40ae7 config.dist.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/config.dist.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,11 @@ + +loglevel = "info" +debug = false + +data_path = ENV("APP_DATA") or "data"; + +base_url = "http://localhost:8007/" + +listen_port = ENV("LISTEN_PORT") or 8007 +listen_interface = ENV("LISTEN_INTERFACE") or "127.0.0.1" + diff -r 000000000000 -r 6279a7d40ae7 default-app/app/auth.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/default-app/app/auth.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,7 @@ +local function check_auth() + return nil; +end + +return { + check_auth = check_auth; +} diff -r 000000000000 -r 6279a7d40ae7 default-app/app/routes.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/default-app/app/routes.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,11 @@ +local http = require "net.http"; + +local routes = {}; + +function routes.get_() + return { + greeting = "Hello world!"; + }; +end + +return routes; diff -r 000000000000 -r 6279a7d40ae7 default-app/html/error.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/default-app/html/error.html Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,20 @@ + + + + + Error {code} + + +

Error {code}

+

{long}

+

{text}

+ Error instance: {id} + {internal_error& +

Debug info

+

The error leading up to this was:

+
+      {internal_error}
+    
+ } + + diff -r 000000000000 -r 6279a7d40ae7 default-app/html/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/default-app/html/index.html Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,6 @@ + + + +

{greeting}

+ + diff -r 000000000000 -r 6279a7d40ae7 src/http.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/http.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,210 @@ +local http_server = require"net.http.server"; +local files = require "net.http.files"; +local http_codes = require"net.http.codes"; +local promise = require "util.promise"; +local url = require"socket.url"; +local uuid = require"util.uuid"; +local web = require "util.web"; +local render = require "render".render; +local ok, json = pcall(require, "cjson"); +if not ok then json = require"util.json"; end +local errors = require "util.error".init("web", {}); + +local templates; +local config; + +local log = require "util.logger".init("web"); + +local usercookie = require"util.usercookie"; +local secret = uuid.generate(); + +local check_auth = require "app.auth".check_auth; + +local function set_auth_cookie(username, response) + local expires = config.cookie_ttl or 604800; + local cookie = usercookie.generate(username, os.time()+expires, secret); + cookie = "remember=".. cookie .. "; Path="..config.base_path + .."; Max-Age="..tostring(expires).."; HttpOnly"; + return web.set_cookie(response.headers, cookie); +end + +local csrf_token_len = #uuid.generate(); + +local function check_csrf(event, viewdata) + local request, response = event.request, event.response; + web.unpack_cookies(request); + local csrf_token = request.cookies.csrf_token; + log("debug", "csrf_token=%s", tostring(csrf_token)); + if csrf_token and #csrf_token == csrf_token_len then + viewdata.csrf_token = csrf_token; + else + csrf_token = uuid.generate(); + viewdata.csrf_token = csrf_token; + web.set_cookie(response.headers, "csrf_token=" .. csrf_token .. "; Path="..config.base_path.."; HttpOnly"); + end +end + +local function wrap_handler(f, t, path_prefix_len) + return function (event) + log("debug", "Check auth..."); + local authed = check_auth(event.request, config); + log("debug", "Checked, %s", authed); + event.config = config; + local sub_path = nil; + if path_prefix_len then + sub_path = event.request.path:sub(path_prefix_len+2); + end + local p = f(event, sub_path); + return promise.resolve(p):next(function (resp) + if type(resp) == "table" then + local headers = event.response.headers; + local accept = event.request.headers.accept or ""; + web.add_header(headers, "vary", "Accept, Cookie"); + if accept:find"application/json" then + headers.content_type = "application/json"; + return json.encode(resp); + elseif t then + if authed or event.needs_csrf then check_csrf(event, resp); end + resp.authenticated = event.request.authenticated; + resp.config = config; + resp = render(t, resp); + if not headers.content_type then + headers.content_type = "text/html; charset=utf-8"; + end + else + return resp; + end + end + return resp; + end):catch(function (err) + log("error", "Failed inside handler wrapper: %s", json.encode(err)); + return promise.reject(errors.wrap(err)); + end); + end +end + +local function handle_error(error, config) + if not error.response then + -- Top-level error, return text string + if config.debug then + return error.private_message; + end + return "An internal error occurred."; + end + + error.response.headers.content_type = "text/html; charset=utf-8"; + + log("warn", "HTTP error handler triggered (debug: %s): %d %s", not not config.debug, error.code, json.encode(error.error)); + + local err = errors.wrap(error.err); + + local r = render(templates.error or "{text}", { + config = config; + code = err.code or error.code; + long = err.condition or http_codes[error.code]; + text = err.text or "An unexpected error occurred. Please try again later."; + id = err.instance_id; + internal_error = config.debug and error.error and error.error.context.wrapped_error; + }); + return r; +end + +local function redirect(to, code) + code = code or 303; + return function (event) + if event.request.url.query then + event.response.headers.location = to.."?"..event.request.url.query; + else + event.response.headers.location = to; + end + return code; + end +end + +local function size_only(request, data) + request.headers.content_size = #data; + return 200; +end + +local function head_handler(handler) + return function (event) + event.send = size_only; + return handler(event); + end +end + +local function register_handlers(handlers, event_base, path_prefix) + for method_path, handler in pairs(handlers) do + if type(handler) == "table" then + register_handlers(handler, event_base, (path_prefix and (path_prefix.."/") or "")..method_path); + else + local method, path, wildcard = method_path:match"^(%w+)_(.-)(_?)$"; + method = method:upper(); + wildcard = wildcard == "_" and (path == "" and "*" or "/*") or ""; + local template_name = path; + if path == "" then + template_name = "index"; + end + + if path_prefix then + path = path_prefix .. "/" .. path; + end + + if templates[template_name] then + log("debug", "handler for %s %s (wildcard: %s) with template %s", method, path, wildcard, template_name); + else + log("debug", "No template (%s) for %s", template_name, path); + end + + local path_prefix_len; + if wildcard == "/*" then + path_prefix_len = #path+1; + end + + handler = wrap_handler(handler, templates[template_name], path_prefix_len); + local head_event = event_base:format("HEAD", path, wildcard); + http_server.add_handler(head_event, head_handler(handler)); + + local event_name = event_base:format(method, path, wildcard); + http_server.add_handler(event_name, handler); + log("debug", "Handler for %s added with template %s", event_name, template_name); + end + end +end + +local function init(_config, events) + config = _config or {}; + + templates = require "templates".init(config); + + local base_url = url.parse(config.base_url or "http://localhost:8006/"); + local base_path = url.parse_path(config.base_path or base_url.path or "/"); + base_path.is_directory = true; + base_url.path = url.build_path(base_path); + config.base_host = base_url.host; + config.base_path = base_url.path; + config.base_url = url.build(base_url); + local event_base = ("%%s %s%s%%s%%s"):format(base_url.host, base_url.path); + + local handlers = require "app.routes"; + + handlers.get_static_ = files.serve { path = "./static", http_base = base_url.path.."static" }; + + http_server.add_host(base_url.host); + http_server.set_default_host(base_url.host); + http_server.add_handler("http-error", function (error) + return handle_error(error, config) + end); + + register_handlers(handlers, event_base); + + local listen_port = config.listen_port or 8007; + local listen_interface = config.listen_interface or "*"; + assert(http_server.listen_on(listen_port, listen_interface)); + log("info", "Serving web interface on http://localhost:%d/", listen_port); +end + +return { + init = init; + render = render; +} diff -r 000000000000 -r 6279a7d40ae7 src/main.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,83 @@ +local LUA_WEB_APP_FRAMEWORK = os.getenv("LUA_WEB_APP_FRAMEWORK"); + +if LUA_WEB_APP_FRAMEWORK then + package.path = ("%s/?.lua;%s"):format(LUA_WEB_APP_FRAMEWORK, package.path); + package.cpath = ("%s/?.so;%s"):format(LUA_WEB_APP_FRAMEWORK, package.cpath); +end + +local ssl = require "ssl"; +local server = require "net.server_epoll"; +local envload = require"util.envload"; +local logger = require "util.logger"; + +package.loaded["net.server"] = server; -- COMPAT this module from Prosody can't be loaded outside of prosody + +local log = logger.init("main"); + +local config = { + loglevel = "debug"; +}; + +local logfile = io.stderr; + +local function log2file(source, level, message, ...) + logfile:write(os.date("!%Y-%m-%dT%H:%M:%SZ"), "\t", source, "\t", level, "\t", message:format(...), "\n"); +end + +local function init(config_file) + if type(config_file) ~= "table" then + setmetatable(config, { __index = { ENV = os.getenv } }); + local ok, err = envload.envloadfile(config_file or "./config.lua", config); + if ok then ok, err = pcall(ok); end + setmetatable(config, nil); + if not ok then log2file("config", "error", "Parse failed: %s", err or "Unknown error"); end + else + config = config_file; + end + + local events = require"util.events".new(); + + -- Set up logging to stdout + logger.reset(); + local logsink = log2file; + if config.logfile then + if config.logfile == "*stdout" then + logfile = io.stdout; + elseif config.logfile == "*stderr" then + logfile = io.stderr; + else + logfile = assert(io.open(config.logfile, "a")); + end + end + if logfile then + logfile:setvbuf("line"); + for _, level in ipairs{"error", "warn", "info", "debug"} do + logger.add_level_sink(level, logsink); + if config.loglevel == level then break; end + end + end + log("debug", "Logging ready"); + + require "http".init(config, events); + + -- Load optional extensions specified in the config + for _, ext in ipairs(config.extensions or {}) do + require("extensions."..ext).init(config, events); + end +end + +local function run() + server.loop(); +end + +if arg then + init(...); + run(); +else + return { + init = init, + run = run, + }; +end + + diff -r 000000000000 -r 6279a7d40ae7 src/notify/email.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/notify/email.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,46 @@ +local smtp = require"socket.smtp"; +local url = require"socket.url"; +local uuid = require"util.uuid"; + +local function send_email(cfg, to, headers, content) + if type(headers) == "string" then + headers = { + Subject = headers; + From = ('"%s" <%s>'):format(cfg.project.name, cfg.smtp.origin); + }; + end + if not headers["Message-ID"] then + local namespace = url.parse(cfg.base_url or "http://localhost/").host; + headers["Message-ID"] = ("<%s@%s>"):format(uuid.generate(), namespace); + end + headers.To = to; + headers["Content-Type"] = 'text/plain; charset="utf-8"'; + local message = smtp.message{ + headers = headers; + body = content; + }; + + if cfg.smtp.exec then + local pipe = io.popen(cfg.smtp.exec .. + " '"..to:gsub("'", "'\\''").."'", "w"); + + for str in message do + pipe:write(str); + end + + return pipe:close(); + end + + return smtp.send({ + user = cfg.smtp.user; password = cfg.smtp.password; + server = cfg.smtp.server; port = cfg.smtp.port; + domain = cfg.smtp.domain; + + from = cfg.smtp.origin; rcpt = to; + source = message; + }); +end + +return { + send = send_email; +}; diff -r 000000000000 -r 6279a7d40ae7 src/render.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/render.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,18 @@ +local html = require "util.html"; +local json = require "util.json"; + +html.basename = function (str) + return str:match("[^/]+$"); +end + +html.date = function (datetime) + return datetime:sub(1,10); +end + +html.json = json.encode; + +local render = require"util.interpolation".new("%b{}", html.escape, html); + +return { + render = render; +}; diff -r 000000000000 -r 6279a7d40ae7 src/templates.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/templates.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,30 @@ +local lfs = require "lfs"; + +local function readfile(filename) + local fh = assert(io.open(filename)); + local data = fh:read("*a"); + fh:close(); + return data; +end + +local _M = {}; + +local template_base_path = (os.getenv("TEMPLATE_PATH") or ".").."/"; + +function _M.init(config) + local templates = {}; + + local template_path = template_base_path..(config.templates or "html"); + + for filename in lfs.dir(template_path) do + local template_name = filename; + if filename:match("%.html$") then + template_name = filename:gsub("%.html$", ""); + end + templates[template_name] = readfile(template_path.."/"..filename); + end + + return templates; +end + +return _M; diff -r 000000000000 -r 6279a7d40ae7 src/web/html.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/web/html.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,54 @@ + +local s_gsub = string.gsub; + +local html_escape_table = { ["'"] = "'", ["\""] = """, ["<"] = "<", [">"] = ">", ["&"] = "&" }; +local function html_escape(str) return (s_gsub(str, "['&<>\"]", html_escape_table)); end + +local html_unescape_table = {}; for k,v in pairs(html_escape_table) do html_unescape_table[v] = k; end +local function html_unescape(str) return (s_gsub(str, "&%a+;", html_unescape_table)); end + +local tags = { + em = "/"; strong = "*"; small = ""; s = ""; cite = ""; + q = "\""; dfn = ""; code = "`"; var = "`"; samp = "`"; kbd = "`"; + sub = "~"; sup = "^"; i = "/"; b = "*"; u = "_"; mark = ""; +}; + +local function formatted(text) + return html_escape(text):gsub(html_escape("<(%a+)([^>]*)>(.-)"), function (tag, attrs, content) + tag = tag:lower(); + if tags[tag] then + return ("<%s>%s"):format(tag, content, tag); + end + end); +end + +local function html2md(text) + return text:gsub("<(%a+)([^>]*)>(.-)", function (tag, attrs, content) + tag = tags[tag]; + if tag then + return tag .. content .. tag; + end + end); +end + +local par = { ["("] = ")", ["<"] = ">", ["["] = "]", ["{"] = "}", [";"] = "&", }; + +local function linkify(text) + return text:gsub("(%S?)(https?://%S+)", function (p, url) + local extra = ""; + -- Attempt to get rid of trailing parenthesis from found URLs + if par[p] and url:find(par[p], 1, true) then + extra = url:sub(url:find(par[p], 1, true), -1); + url = url:sub(1, url:find(par[p], 1, true) - 1); + end + return ("%s%s%s"):format(p, url, url, extra); + end); +end + +return { + escape = html_escape; + html2md = html2md; + formatted = formatted; + linkify = linkify; + unescape = html_unescape; +}; diff -r 000000000000 -r 6279a7d40ae7 src/web/usercookie.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/web/usercookie.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,45 @@ +local hmac_sha256 = require"util.hashes".hmac_sha256; +local base64_encode = require"util.encodings".base64.encode; +local base64_decode = require"util.encodings".base64.decode; +local datetime = require"util.datetime"; +local t_insert = table.insert; + +local function generate(user, expires, key) + local data = ("%s %s"):format(datetime.date(expires), user); + local signature = hmac_sha256(key, data); + return base64_encode(data .. signature); +end + +local function verify(cookie, key) + if not cookie then + return nil, "no value"; + end + cookie = base64_decode(cookie); + if not cookie then + return nil, "invalid armor"; + end + local data = cookie:sub(1, -33) + if cookie:sub(-32) ~= hmac_sha256(key, data) then + return nil, "invalid signature"; + end + if data < datetime.date() then + return nil, "expired"; + end + return data:sub(12); -- Strip date +end + +local function cookiedecode(s) + local r = {}; + if not s then return r; end + for k, v in s:gmatch("([%w!#$%%&'*+%-.^_`|~]+)=\"?([%w!#-+--/:<-@%]-`_]+)\"?") do + r[k] = v; + t_insert(r, { name = k, value = v }); + end + return r; +end + +return { + decode = cookiedecode; + generate = generate; + verify = verify; +}; diff -r 000000000000 -r 6279a7d40ae7 src/web/web.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/web/web.lua Tue Mar 09 12:16:56 2021 +0000 @@ -0,0 +1,108 @@ +local http_util = require "util.http"; +local usercookie = require"util.usercookie"; +local uuid = require "util.uuid"; + +local function unpack_cookies(request) + if not request.cookies then + request.cookies = usercookie.decode(request.headers.cookie); + end +end + +local post_parsers = { + ["application/x-www-form-urlencoded"] = http_util.formdecode; +}; + +local function parse_body(request) + local content_type = request.headers.content_type; + if not content_type then + --log("warn", "No Content-Type header sent"); + return nil, 400; + end + local post_parser = post_parsers[content_type]; + if not post_parser then + --log("warn", "Don't know how to parse %s", content_type); + return nil, 415; + end + local post_body = post_parser(request.body); + if type(post_body) ~= "table" then + --log("warn", "Could not parse %s %q, got %s", content_type, request.body, type(post_body)); + return nil, 415; + end + return post_body; +end + +local csrf_token_len = #uuid.generate(); + +local function validate_csrf(csrf_token, request) + if request.headers.origin == nil and request.headers.referer == nil then + return true; -- Probably a non-browser request + end + if not (csrf_token and #csrf_token == csrf_token_len) then + return false; + end + unpack_cookies(request); + return request.cookies.csrf_token == csrf_token; +end + +local function parse_body_and_csrf(request) + local post_body, err = parse_body(request); + if not post_body then return nil, err; end + if not validate_csrf(post_body.csrf_token, request) then + --log("warn", "CSRF error (token: '%s', cookie: '%s')", + -- tostring(post_body.csrf_token), tostring(request.headers.cookie)); + return nil, 400; + end + return post_body; +end + +-- Cookies + +local function add_header(headers, header, value) + if headers[header] then + headers[header] = headers[header] .. ", " .. value; + else + headers[header] = value; + end +end + +local function prefix_header(headers, header, value) + if headers[header] then + headers[header] = value .. ", " .. headers[header]; + else + headers[header] = value; + end +end + +local function set_cookie(headers, cookie, opts) + if opts then + local params = {""}; + if opts.path then + table.insert(params, "Path="..opts.path); + end + if opts.ttl then + table.insert(params, ("Max-Age=%d"):format(opts.ttl)); + end + if opts.http_only then + table.insert(params, "HttpOnly"); + end + if opts.secure then + table.insert(params, "Secure"); + end + + if #params > 1 then + cookie = cookie .. table.concat(params, "; "); + end + end + + prefix_header(headers, "set_cookie", cookie); +end + +return { + unpack_cookies = unpack_cookies; + validate_csrf = validate_csrf; + parse_body_and_csrf = parse_body_and_csrf; + parse_body = parse_body; + add_header = add_header; + prefix_header = prefix_header; + set_cookie = set_cookie; +};