view mod_http_muc_log/mod_http_muc_log.lua @ 1581:9f6cd252d233

mod_http_muc_log: Revamp template system
author Kim Alvefur <zash@zash.se>
date Tue, 02 Dec 2014 15:12:01 +0100
parents 63571115302f
children 8e282eb0c70c
line wrap: on
line source

local st = require "util.stanza";
local datetime = require"util.datetime";
local jid_split = require"util.jid".split;
local nodeprep = require"util.encodings".stringprep.nodeprep;
local uuid = require"util.uuid".generate;
local it = require"util.iterators";
local gettime = require"socket".gettime;
local url = require"socket.url";
local xml_escape = st.xml_escape;
local t_concat = table.concat;

local archive = module:open_store("muc_log", "archive");

-- Support both old and new MUC code
local mod_muc = module:depends"muc";
local rooms = rawget(mod_muc, "rooms");
local each_room = rawget(mod_muc, "each_room") or function() return it.values(rooms); end;
local new_muc = not rooms;
if new_muc then
	rooms = module:shared"muc/rooms";
end
local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or
	function (jid)
		return rooms[jid];
	end

local function get_room(name)
	local jid = name .. '@' .. module.host;
	return get_room_from_jid(jid);
end

module:depends"http";

local function render(template, values)
	--[[ DOC
	{name} is substituted for values["name"] and is XML escaped
	{name!} is substituted without XML escaping
	{name?} is optional and is replaced with an empty string if no value exists
	{name# sub-template } renders a sub-template using an array of values
	]]
	return (template:gsub("%b{}", function (block)
		local name, opt, e = block:sub(2, -2):match("([%a_][%w_]*)(%p?)()");
		local value = values[name];
		if opt == '#' then
			if not value or not value[1] then return ""; end
			local out, subtpl = {}, block:sub(e+1, -2);
			for i=1, #value do
				out[i] = render(subtpl, value[i]);
			end
			return t_concat(out);
		end
		if value ~= nil  then
			if type(value) ~= "string" then
				value = tostring(value);
			end
			if opt ~= '!' then
				return xml_escape(value);
			end
			return value;
		elseif opt == '?' then
			return block:sub(e+1, -2);
		end
	end));
end

-- TODO Move template into a file
local template = [=[
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:link,:visited{text-decoration:none;color:#2e3436;text-decoration:none;}
:link:hover,:visited:hover{color:#3465a4;}
body{background-color:#eeeeec;margin:1ex 0;padding-bottom:3em;font-family:Arial,Helvetica,sans-serif;}
ul,ol{padding:0;}
li{list-style:none;}
hr{visibility:hidden;clear:both;}
br{clear:both;}
header,footer{margin:1ex 1em;}
footer{font-size:smaller;color:#babdb6;}
nav{font-size:large;margin:1ex 1ex;clear:both;line-height:1.5em;}
footer nav .up{display:none;}
@media screen and (min-width: 460px) {
nav {font-size:x-large;margin:1ex 1em;}
}
nav a{padding: 1ex;}
nav .up{font-size:smaller;display:block;clear:both;}
nav .prev{float:left;}
nav .next{float:right;}
nav .next::after{content:" →";}
nav .prev::before{content:"← ";}
nav :empty::after,nav :empty::before{content:""}
.content{background-color:white;padding:1em;list-style-position:inside;}
.time{float:right;font-size:small;opacity:0.2;}
li:hover .time{opacity:1;}
.description{font-size:smaller;}
.body{white-space:pre-line;}
.body::before,.body::after{content:"";}
.presence .verb{font-style:normal;color:#30c030;}
.unavailable .verb{color:#c03030;}
</style>
</head>
<body>
<header>
<h1 title="xmpp:{jid?}">{title}</h1>
<nav>{links#
<a class="{rel?}" href="{href}" rel="{rel?}">{text}</a>}
</nav>
</header>
<hr>
<div class="content">
<nav>
<dl class="room-list">
{rooms#
<dt class="name"><a href="{href}">{name}</a></dt>
<dd class="description">{description?}</dd>}
</dl>
<ul class="dates">{dates#
<li><a href="{date}">{date}</a></li>}
</ul>
</nav>
<ol class="chat-logs">{lines#
<li class="{st_name} {st_type?}" id="{key}">
<a class="time" href="#{key}"><time datetime="{datetime}">{time}</time></a>
<b class="nick">{nick}</b>
<em class="verb">{verb?}</em>
<q class="body">{body?}</q>
</li>}
</ol>
</div>
<hr>
<footer>
<nav>{links#
<a class="{rel?}" href="{href}" rel="{rel?}">{text}</a>}
</nav>
<br>
<div class="powered-by">Prosody</div>
</footer>
<script>
/*
 * Local timestamps
 */
(function () {
	var timeTags = document.getElementsByTagName("time");
	var i = 0, tag, date;
	while(timeTags[i]) {
		tag = timeTags[i++];
		if(date = tag.getAttribute("datetime")) {
			date = new Date(date);
			tag.textContent = date.toLocaleTimeString();
			tag.setAttribute("title", date.toString());
		}
	}
})();
</script>
</body>
</html>
]=];

local base_url = module:http_url() .. '/';
local get_link do
	local link, path = { path = '/' }, { "", "", is_directory = true };
	function get_link(room, date)
		path[1], path[2] = room, date;
		path.is_directory = not date;
		link.path = url.build_path(path);
		return url.build(link);
	end
end

local function public_room(room)
	if type(room) == "string" then
		room = get_room(room);
	end
	return (room
		and not (room.get_hidden or room.is_hidden)(room)
		and not (room.get_members_only or room.is_members_only)(room)
		and room._data.logging == true);
end

-- FIXME Invent some more efficient API for this
local function dates_page(event, path)
	local request, response = event.request, event.response;

	local room = nodeprep(path:match("^(.*)/$"));
	if not room or not public_room(room) then return end

	local dates, i = {}, 1;
	module:log("debug", "Find all dates with messages");
	local next_day;
	repeat
		local iter = archive:find(room, {
			["start"] = next_day;
			limit = 1;
			with = "message<groupchat";
		})
		if not iter then break end
		next_day = nil;
		for key, message, when in iter do
			next_day = datetime.date(when);
			dates[i], i = {
				date = next_day;
			}, i + 1;
			next_day = datetime.parse(next_day .. "T23:59:59Z") + 1;
			break;
		end
	until not next_day;

	response.headers.content_type = "text/html; charset=utf-8";
	return render(template, {
		title = get_room(room):get_name();
		jid = get_room(room).jid;
		dates = dates;
		links = {
			{ href = "../", rel = "up", text = "Back to room list" },
		};
	});
end

local function logs_page(event, path)
	local request, response = event.request, event.response;

	local room, date = path:match("^(.-)/(%d%d%d%d%-%d%d%-%d%d)$");
	room = nodeprep(room);
	if not room then
		return dates_page(event, path);
	end
	if not public_room(room) then return end

	local logs, i = {}, 1;
	local iter, err = archive:find(room, {
		["start"] = datetime.parse(date.."T00:00:00Z");
		["end"]   = datetime.parse(date.."T23:59:59Z");
		-- with = "message<groupchat";
	});
	if not iter then return 500; end

	local first, last;
	local verb, subject, body;
	for key, item, when in iter do
		body = item:get_child_text("body");
		subject = item:get_child_text("subject");
		verb = nil;
		if subject then
			verb, body = "set the topic to", subject;
		elseif body and body:sub(1,4) == "/me " then
			verb, body = body:sub(5), nil;
		elseif item.name == "presence" then
			verb = item.attr.type == "unavailable" and "has left" or "has joined";
		end
		if body or verb then
			logs[i], i = {
				key = key;
				datetime = datetime.datetime(when);
				time = datetime.time(when);
				verb = verb;
				body = body;
				nick = select(3, jid_split(item.attr.from));
				st_name = item.name;
				st_type = item.attr.type;
			}, i + 1;
		end
		first = first or key;
		last = key;
	end
	if i == 1 then return end -- No items

	local next_when = "";
	local prev_when = "";

	module:log("debug", "Find next date with messages");
	for key, message, when in archive:find(room, {
		after = last;
		limit = 1;
	}) do
		next_when = datetime.date(when);
		module:log("debug", "Next message: %s", datetime.datetime(when));
	end

	module:log("debug", "Find prev date with messages");
	for key, message, when in archive:find(room, {
		before = first;
		limit = 1;
		reverse = true;
	}) do
		prev_when = datetime.date(when);
		module:log("debug", "Previous message: %s", datetime.datetime(when));
	end

	response.headers.content_type = "text/html; charset=utf-8";
	return render(template, {
		title = ("%s - %s"):format(get_room(room):get_name(), date);
		jid = get_room(room).jid;
		lines = logs;
		links = {
			{ href = "./", rel = "up", text = "Back to date list" },
			{ href = prev_when, rel = "prev", text = prev_when},
			{ href = next_when, rel = "next", text = next_when},
		};
	});
end

local function list_rooms(event)
	local request, response = event.request, event.response;
	local room_list, i = {}, 1;
	for room in each_room() do
		if public_room(room) then
			room_list[i], i = {
				href = get_link(jid_split(room.jid), nil);
				name = room:get_name();
				description = room:get_description();
			}, i + 1;
		end
	end

	response.headers.content_type = "text/html; charset=utf-8";
	return render(template, {
		title = module:get_option_string("name", "Prosody Chatrooms");
		jid = module.host;
		rooms = room_list;
	});
end

local cache = setmetatable({}, {__mode = 'v'});

local function with_cache(f)
	return function (event, path)
		local request, response = event.request, event.response;
		local ckey = path or "";
		local cached = cache[ckey];

		if cached then
			local etag = cached.etag;
			local if_none_match = request.headers.if_none_match;
			if etag == if_none_match then
				module:log("debug", "Client cache hit");
				return 304;
			end
			module:log("debug", "Server cache hit");
			response.headers.etag = etag;
			response.headers.content_type = "text/html; charset=utf-8";
			return cached[1];
		end

		local start = gettime();
		local render = f(event, path);
		module:log("debug", "Rendering took %dms", math.floor( (gettime() - start) * 1000 + 0.5));

		if type(render) == "string" then
			local etag = uuid();
			cached = { render, etag = etag, date = datetime.date() };
			response.headers.etag = etag;
			cache[ckey] = cached;
		end

		response.headers.content_type = "text/html; charset=utf-8";
		return render;
	end
end

-- How is cache invalidation a hard problem? ;)
module:hook("muc-broadcast-message", function (event)
	local room = event.room;
	local room_name = jid_split(room.jid);
	local today = datetime.date();
	cache[get_link(room_name)] = nil;
	cache[get_link(room_name, today)] = nil;
end);

module:provides("http", {
	route = {
		["GET /"] = list_rooms;
		["GET /*"] = with_cache(logs_page);
	};
});