File

mod_http_muc_log/mod_http_muc_log.lua @ 1594:620cc035ae1e

mod_admin_message: New IM-based administration console This module provides a console over XMPP. All the commands of the mod_admin_telnet module are available to Prosody admins from an XMPP client.
author Mikael Berthe <mikael@lilotux.net>
date Sun, 25 Jan 2015 19:52:39 +0100
parent 1582:8e282eb0c70c
child 1605:c8a51d1bc96d
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

local template = "Could not load template"
do
	local template_file = module:get_option_string(module.name .. "_template", module.name .. ".html");
	template_file = assert(module:load_resource(template_file));
	template = template_file:read("*a");
	template_file:close();
end

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);
	};
});