Initial commit

Tue, 09 Mar 2021 12:16:56 +0000

author
Matthew Wild <mwild1@gmail.com>
date
Tue, 09 Mar 2021 12:16:56 +0000
changeset 0
6279a7d40ae7
child 1
ab05314b412c

Initial commit

Dockerfile file | annotate | diff | comparison | revisions
config.dist.lua file | annotate | diff | comparison | revisions
default-app/app/auth.lua file | annotate | diff | comparison | revisions
default-app/app/routes.lua file | annotate | diff | comparison | revisions
default-app/html/error.html file | annotate | diff | comparison | revisions
default-app/html/index.html file | annotate | diff | comparison | revisions
src/http.lua file | annotate | diff | comparison | revisions
src/main.lua file | annotate | diff | comparison | revisions
src/notify/email.lua file | annotate | diff | comparison | revisions
src/render.lua file | annotate | diff | comparison | revisions
src/templates.lua file | annotate | diff | comparison | revisions
src/web/html.lua file | annotate | diff | comparison | revisions
src/web/usercookie.lua file | annotate | diff | comparison | revisions
src/web/web.lua file | annotate | diff | comparison | revisions
--- /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 <mwild1@gmail.com>
+
+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"]
--- /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"
+
--- /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;
+}
--- /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;
--- /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 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>Error {code}</title>
+  </head>
+  <body>
+    <h1>Error {code}</h1>
+    <h2>{long}</h2>
+    <p>{text}</p>
+    <code>Error instance: {id}</code>
+    {internal_error&
+    <h3>Debug info</h3>
+    <p>The error leading up to this was:</p>
+    <pre>
+      {internal_error}
+    </pre>
+    }
+  </body>
+</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 @@
+<!DOCTYPE html>
+<html>
+<body>
+<p>{greeting}</p>
+</body>
+</html>
--- /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;
+}
--- /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
+
+
--- /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;
+};
--- /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;
+};
--- /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;
--- /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 = { ["'"] = "&apos;", ["\""] = "&quot;", ["<"] = "&lt;", [">"] = "&gt;", ["&"] = "&amp;" };
+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+)([^>]*)>(.-)</%1>"), function (tag, attrs, content)
+		tag = tag:lower();
+		if tags[tag] then
+			return ("<%s>%s</%s>"):format(tag, content, tag);
+		end
+	end);
+end
+
+local function html2md(text)
+	return text:gsub("<(%a+)([^>]*)>(.-)</%1>", 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<a rel=\"nofollow\" href=\"%s\">%s</a>%s"):format(p, url, url, extra);
+	end);
+end
+
+return {
+	escape = html_escape;
+	html2md = html2md;
+	formatted = formatted;
+	linkify = linkify;
+	unescape = html_unescape;
+};
--- /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;
+};
--- /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;
+};

mercurial