src/http.lua

Thu, 22 Jun 2023 21:27:34 +0100

author
Matthew Wild <mwild1@gmail.com>
date
Thu, 22 Jun 2023 21:27:34 +0100
changeset 12
dfa7cb60647e
parent 0
6279a7d40ae7
child 16
68a0c983bf49
permissions
-rw-r--r--

http: Handler logic improvements (affects auth, config, templates)

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 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, default_tpl, path_prefix_len, check_auth)
	return function (event)
		log("debug", "Check auth...");
		local request = event.request;
		local authed, handler_override = check_auth(request, config);
		if authed and not request.authenticated then
			request.authenticated = authed;
		end
		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 h = handler_override or f;

		local p, custom_tpl;
		if web.is_response(h) then
			p = h;
		else
			p, custom_tpl = (handler_override or f)(event, sub_path);
		end

		-- Process response (may be promises)
		return promise.join(function (resp, tpl)
			if type(resp) == "table" and not web.is_response(resp) 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 tpl then
					if authed or event.needs_csrf then check_csrf(event, resp); end
					resp.authenticated = event.request.authenticated;
					resp.config = config;
					resp = render(tpl, resp);
					if not headers.content_type then
						headers.content_type = "text/html; charset=utf-8";
					end
				else
					return resp;
				end
			end
			return resp;
		end, p, custom_tpl or default_tpl):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, check_auth)
	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, check_auth);
		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, check_auth);
			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.templates = templates;
	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);

	_G.CONFIG = config;
	local check_auth = require "app.auth".check_auth;
	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, nil, check_auth);

	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;
	set_auth_cookie = set_auth_cookie;
	get_auth_cookie = get_auth_cookie;
}

mercurial