mod_http_muc_log/mod_http_muc_log.lua

Fri, 08 Dec 2017 21:14:10 +0100

local mt = require"util.multitable";
local datetime = require"util.datetime";
local jid_split = require"util.jid".split;
local nodeprep = require"util.encodings".stringprep.nodeprep;
local it = require"util.iterators";
local url = require"socket.url";
local os_time, os_date = os.time, os.date;
local render = require"util.interpolation".new("%b{}", require"util.stanza".xml_escape);

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 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() .. '/'; -- TODO: Generate links in a smart way
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) -- : boolean
	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))
end

local function sort_Y(a,b) return a.year > b.year end
local function sort_m(a,b) return a.n > b.n end

-- Time zone hack?
local t_diff = os_time(os_date("*t")) - os_time(os_date("!*t"));
local function time(t)
	return os_time(t) + t_diff;
end
local function date_floor(t)
	return t - t % 86400;
end

-- Fetch one item
local function find_once(room, query, retval)
	if query then query.limit = 1; else query = { limit = 1 }; end
	local iter, err = archive:find(room, query);
	if not iter then return iter, err; end
	if retval then
		return select(retval, iter());
	end
	return iter();
end

local lazy = module:get_option_boolean(module.name .. "_lazy_calendar", true);

-- Produce the calendar view
local function years_page(event, path)
	local response = event.response;

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

	-- Collect each date that has messages
	-- convert it to a year / month / day tree
	local date_list = archive.dates and archive:dates(room);
	local dates = mt.new();
	if date_list then
		for _, date in ipairs(date_list) do
			local when = datetime.parse(date.."T00:00:00Z");
			local t = os_date("!*t", when);
			dates:set(t.year, t.month, t.day, when);
		end
	elseif lazy then
		-- Lazy with many false positives
		local first_day = find_once(room, nil, 3);
		local last_day = find_once(room, { reverse = true }, 3);
		if first_day and last_day then
			first_day = date_floor(first_day);
			last_day = date_floor(last_day);
			for when = first_day, last_day, 86400 do
				local t = os_date("!*t", when);
				dates:set(t.year, t.month, t.day, when);
			end
		else
			return; -- 404
		end
	else
		-- Collect date the hard way
		module:log("debug", "Find all dates with messages");
		local next_day;
		repeat
			local when = find_once(room, { start = next_day; }, 3);
			if not when then break; end
			local t = os_date("!*t", when);
			dates:set(t.year, t.month, t.day, when );
			next_day = date_floor(when) + 86400;
		until not next_day;
	end

	local years = {};

	-- Wrangle Y/m/d tree into year / month / week / day tree for calendar view
	for current_year, months_t in pairs(dates.data) do
		local t = { year = current_year, month = 1, day = 1 };
		local months = { };
		local year = { year = current_year, months = months };
		years[#years+1] = year;
		for current_month, days_t in pairs(months_t) do
			t.day = 1;
			t.month = current_month;
			local tmp = os_date("!*t", time(t));
			local days = {};
			local week = { days = days }
			local weeks = { week };
			local month = { year = year.year, month = os_date("!%B", time(t)), n = current_month, weeks = weeks };
			months[#months+1] = month;
			local current_day = 1;
			for _=1, (tmp.wday+5)%7 do
				days[current_day], current_day = {}, current_day+1;
			end
			for i = 1, 31 do
				t.day = i;
				tmp = os_date("!*t", time(t));
				if tmp.month ~= current_month then break end
				if i > 1 and tmp.wday == 2 then
					days = {};
					weeks[#weeks+1] = { days = days };
					current_day = 1;
				end
				days[current_day] = {
					wday = tmp.wday, day = i, href = days_t[i] and datetime.date(days_t[i])
				};
				current_day = current_day+1;
			end
		end
		table.sort(year, sort_m);
	end
	table.sort(years, sort_Y);

	-- Phew, all wrangled, all that's left is rendering it with the template

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

-- Produce the chat log view
local function logs_page(event, path)
	local response = event.response;

	-- FIXME In the year, 105105, if MUC is still alive,
	-- if Prosody can survive... Enjoy this Y10k bug
	local room, date = path:match("^(.-)/(%d%d%d%d%-%d%d%-%d%d)$");
	room = nodeprep(room);
	if not room then
		return years_page(event, path);
	end
	if not public_room(room) then return end
	local day_start = datetime.parse(date.."T00:00:00Z");

	local logs, i = {}, 1;
	local iter, err = archive:find(room, {
		["start"] = day_start;
		["end"]   = day_start + 86399;
	});
	if not iter then
		module:log("warn", "Could not search archive: %s", err or "no error");
		return 500;
	end

	local first, last;
	for key, item, when in iter do
		local body = item:get_child_text("body");
		local subject = item:get_child_text("subject");
		local 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 and not lazy then return end -- No items

	local next_when, prev_when = "", "";
	local date_list = archive.dates and archive:dates(room);
	if date_list then
		for j = 1, #date_list do
			if date_list[j] == date then
				next_when = date_list[j+1] or "";
				prev_when = date_list[j-1] or "";
				break;
			end
		end
	elseif lazy then
		next_when = datetime.date(day_start + 86400);
		prev_when = datetime.date(day_start - 86400);
	elseif first and last then

		module:log("debug", "Find next date with messages");
		next_when = find_once(room, { after = last }, 3);
		if next_when then
			next_when = datetime.date(next_when);
			module:log("debug", "Next message: %s", next_when);
		end

		module:log("debug", "Find prev date with messages");
		prev_when = find_once(room, { before = first, reverse = true }, 3);
		if prev_when then
			prev_when = datetime.date(prev_when);
			module:log("debug", "Previous message: %s", prev_when);
		end
	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 = "Calendar" },
			{ href = prev_when, rel = "prev", text = prev_when},
			{ href = next_when, rel = "next", text = next_when},
		};
	});
end

local function list_rooms(event)
	local response = 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

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

mercurial