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 url = require"socket.url";
local os_time, os_date = os.time, os.date;
local httplib = require "util.http";
local render_funcs = {};
local render = require"util.interpolation".new("%b{}", require"util.stanza".xml_escape, render_funcs);
local archive = module:open_store("muc_log", "archive");
-- Prosody 0.11+ MUC API
local mod_muc = module:depends"muc";
local each_room = mod_muc.each_room;
local get_room_from_jid = mod_muc.get_room_from_jid;
local function get_room(name)
local jid = name .. '@' .. module.host;
return get_room_from_jid(jid);
end
local use_oob = module:get_option_boolean(module.name .. "_show_images", false);
module:depends"http";
local template;
do
local template_filename = module:get_option_string(module.name .. "_template", "res/" .. module.name .. ".html");
local template_file, err = module:load_resource(template_filename);
if template_file then
template, err = template_file:read("*a");
template_file:close();
end
if not template then
module:log("error", "Error loading template: %s", err);
template = render("
mod_{module} could not read the template
\
Tried to open {filename}
\
{error}
",
{ module = module.name, filename = template_filename, error = err });
end
end
local resources = module:get_option_path(module.name .. "_resources", "static");
-- 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 get_absolute_link(room, date)
local link = url.parse(module:http_url());
local path = url.parse_path(link.path);
if room then
table.insert(path, room);
if date then
table.insert(path, date)
path.is_directory = false;
else
path.is_directory = true;
end
end
link.path = url.build_path(path)
return url.build(link)
end
-- Whether room can be joined by anyone
local function open_room(room) -- : boolean
if type(room) == "string" then
room = get_room(room);
-- assumed to be a room object otherwise
end
if not room then
return nil;
end
if (room.get_members_only or room.is_members_only)(room) then
return false;
end
if room:get_password() then
return false;
end
return true;
end
-- Can be set to "latest"
local default_view = module:get_option_string(module.name .. "_default_view", nil);
module:hook("muc-disco#info", function (event)
local room = event.room;
if open_room(room) then
table.insert(event.form, { name = "muc#roominfo_logs", type="text-single" });
event.formdata["muc#roominfo_logs"] = get_absolute_link(jid_split(event.room.jid), default_view);
end
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);
local presence_logged = module:get_option_boolean("muc_log_presences", false);
local function show_presence(request) --> boolean|nil
-- boolean -> yes or no
-- nil -> dunno
if not presence_logged then
-- No presence stored, skip
return nil;
end
if request.url.query then
local data = httplib.formdecode(request.url.query);
if type(data) == "table" then
if data.p == "s" or data.p == "h" then
return data.p == "s";
end
end
end
end
local function presence_with(request)
local show = show_presence(request);
if show == true then
return nil; -- no filter, everything
elseif show == false or show == nil then
-- only messages
return "message ?p=[sh]
local show = show_presence(request);
if show == true then
return { p = "s" }
elseif show == false then
return { p = "h" }
else
return nil;
end
end
local function get_dates(room) --> { integer, ... }
local date_list = archive.dates and archive:dates(room);
if date_list then
for i = 1, #date_list do
date_list[i] = datetime.parse(date_list[i].."T00:00:00Z");
end
return date_list;
end
if lazy then
-- Lazy with many false positives
date_list = {};
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
table.insert(date_list, when);
end
else
return; -- 404
end
return date_list;
end
-- Collect date the hard way
module:log("debug", "Find all dates with messages");
date_list = {};
local next_day;
repeat
local when = find_once(room, { start = next_day; }, 3);
if not when then break; end
table.insert(date_list, when);
next_day = date_floor(when) + 86400;
until not next_day;
return date_list;
end
function render_funcs.calendarize(date_list)
-- convert array of timestamps to a year / month / day tree
local dates = mt.new();
for _, when in ipairs(date_list) do
local t = os_date("!*t", when);
dates:set(t.year, t.month, t.day, when);
end
-- Wrangle Y/m/d tree into year / month / week / day tree for calendar view
local years = {};
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(months, sort_m);
end
table.sort(years, sort_Y);
return years;
end
-- Produce the calendar view
local function years_page(event, path)
local request, response = event.request, event.response;
local room = nodeprep(path:match("^(.*)/$"));
local is_open = open_room(room);
if is_open == nil then
return -- implicit 404
elseif is_open == false then
return 403;
end
local date_list = get_dates(room);
if not date_list then
return; -- 404
end
-- Phew, all wrangled, all that's left is rendering it with the template
response.headers.content_type = "text/html; charset=utf-8";
local room_obj = get_room(room);
return render(template, {
static = "../@static";
room = room_obj._data;
jid = room_obj.jid;
jid_node = jid_split(room_obj.jid);
q = presence_query(request);
show_presence = show_presence(request);
presence_available = presence_logged;
dates = date_list;
links = {
{ href = "../", rel = "up", text = "Room list" },
{ href = "latest", rel = "last", text = "Latest" },
};
});
end
-- Produce the chat log view
local function logs_page(event, path)
local request, response = event.request, event.response;
-- /room --> 303 /room/
-- /room/ --> calendar view
-- /room/yyyy-mm-dd --> logs view
-- /room/yyyy-mm-dd/* --> 404
local room, date = path:match("^([^/]+)/([^/]*)$");
if not room and not path:find"/" then
response.headers.location = url.build({ path = path .. "/" });
return 303;
elseif not room then
return 404;
end
room = nodeprep(room);
if not room then
return 400;
elseif date == "" then
return years_page(event, path);
end
local is_open = open_room(room);
if is_open == nil then
return -- implicit 404
elseif is_open == false then
return 403;
end
if date == "latest" then
local last_day = find_once(room, { reverse = true }, 3);
response.headers.location = url.build({ path = datetime.date(last_day), query = request.url.query });
return 303;
end
local day_start = datetime.parse(date.."T00:00:00Z");
if not day_start then
module:log("debug", "Invalid date format: %q", date);
return 400;
end
local logs, i = {}, 1;
local iter, err = archive:find(room, {
["start"] = day_start;
["end"] = day_start + 86399;
["with"] = presence_with(request);
});
if not iter then
module:log("warn", "Could not search archive: %s", err or "no error");
return 500;
end
local first, last;
for archive_id, item, when in iter do
local body_tag = item:get_child("body");
local body = body_tag and body_tag:get_text();
local subject = item:get_child_text("subject");
local verb = nil;
local lang = body_tag and body_tag.attr["xml:lang"] or item.attr["xml:lang"];
-- XEP-0359: Unique and Stable Stanza IDs
local message_id = item:find("{urn:xmpp:sid:0}origin-id@id") or item.attr.id;
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
-- TODO Distinguish between join and presence update
verb = item.attr.type == "unavailable" and "has left" or "has joined";
lang = "en";
end
local nick = select(3, jid_split(item.attr.from));
local occupant_id = item:find("{urn:xmpp:occupant-id:0}occupant-id@id") or nick;
-- XEP-0066: Out of Band Data
local oob = use_oob and item:get_child("x", "jabber:x:oob");
-- XEP-0425: Message Moderation
local moderated = item:get_child("moderated", "urn:xmpp:message-moderate:0");
if moderated then
local actor = moderated.attr.by;
if actor then actor = select(3, jid_split(actor)); end
verb = "removed by " .. (actor or "moderator");
body = moderated:get_child_text("reason") or "";
end
local moderation = item:find("{urn:xmpp:fasten:0}apply-to/{urn:xmpp:message-moderate:0}moderated");
if moderation then
nick = nick or "a moderator";
verb = "removed a message";
body = moderation:get_child_text("reason") or "";
end
-- XEP-0308: Last Message Correction
local edit = item:find("{urn:xmpp:message-correct:0}replace/@id");
if edit then
local found = false;
for n = i-1, 1, -1 do
if logs[n].message_id == edit and occupant_id == logs[n].occupant_id then
found = true;
logs[n].edited = archive_id;
edit = logs[n].archive_id;
break;
end
end
if not found then
-- Ignore unresolved edit.
edit = nil;
end
end
-- XEP-0444: Message Reactions
local reactions = item:get_child("reactions", "urn:xmpp:reactions:0");
if reactions then
-- COMPAT Movim uses an @to attribute instead of the correct @id
local target_id = reactions.attr.id or reactions.attr.to;
for n = i - 1, 1, -1 do
if logs[n].archive_id == target_id then
local react_map = logs[n].reactions; -- [occupant_id][emoji]boolean
if not react_map then
react_map = {};
logs[n].reactions = react_map;
end
local reacts = {};
for reaction_tag in reactions:childtags("reaction") do
local reaction_text = reaction_tag:get_text() or "�";
reacts[reaction_text] = true;
end
react_map[occupant_id] = reacts;
break
end
end
end
-- XEP-0461: Message Replies
local reply = item:find("{urn:xmpp:reply:0}reply@id");
if body or verb or oob then
local line = {
message_id = message_id;
archive_id = archive_id;
occupant_id = occupant_id;
datetime = datetime.datetime(when);
time = datetime.time(when);
verb = verb;
body = body;
lang = lang;
nick = nick;
st_name = item.name;
st_type = item.attr.type;
edit = edit;
reply = reply;
-- COMPAT
key = archive_id;
};
if oob then
line.oob = {
url = oob:get_child_text("url");
desc = oob:get_child_text("desc");
}
end
logs[i], i = line, i + 1;
end
first = first or archive_id;
last = archive_id;
end
if i == 1 and not lazy then return end -- No items
-- collapse reactions[occupant-id][reaction]boolean into reactions[reaction]integer
for n = 1, #logs do
local reactions = logs[n].reactions;
if reactions then
local collated = {};
for _, reacts in pairs(reactions) do
for reaction_text in pairs(reacts) do
collated[reaction_text] = (collated[reaction_text] or 0) + 1;
end
end
logs[n].reactions = collated;
end
end
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
local links = {
{ href = "../", rel = "up", text = "Room list" },
{ href = "./", rel = "up", text = "Calendar" },
};
if prev_when ~= "" then
table.insert(links, { href = prev_when, rel = "prev", text = prev_when});
end
if next_when ~= "" then
table.insert(links, { href = next_when, rel = "next", text = next_when});
end
response.headers.content_type = "text/html; charset=utf-8";
local room_obj = get_room(room);
return render(template, {
static = "../@static";
date = date;
room = room_obj._data;
jid = room_obj.jid;
jid_node = jid_split(room_obj.jid);
q = presence_query(request);
show_presence = show_presence(request);
presence_available = presence_logged;
lang = room_obj.get_language and room_obj:get_language();
lines = logs;
links = links;
dates = {}; -- COMPAT util.interpolation {nil|func#...} bug
});
end
local room_weights = setmetatable(module:get_option_array(module.name.."_list_order", {}):reverse(), nil);
for i = #room_weights, 1, -1 do
local room_jid = room_weights[i];
room_weights[i] = nil;
room_weights[room_jid] = i;
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 not (room.get_hidden or room.is_hidden)(room) then
local localpart = jid_split(room.jid);
room_list[i], i = {
jid = room.jid;
localpart = localpart;
href = get_link(localpart, default_view);
name = room:get_name() or localpart;
lang = room.get_language and room:get_language();
description = room:get_description();
priority = room_weights[ room.jid ] or 0;
}, i + 1;
end
end
table.sort(room_list, function (a, b)
if a.priority ~= b.priority then return a.priority > b.priority; end
if a.description ~= nil and b.description == nil then
return true;
elseif a.description == nil and b.description ~= nil then
return false;
end
return a.jid < b.jid;
end);
response.headers.content_type = "text/html; charset=utf-8";
return render(template, {
static = "./@static";
title = module:get_option_string("name", "Prosody Chatrooms");
jid = module.host;
q = presence_query(request);
show_presence = show_presence(request);
presence_available = presence_logged;
rooms = room_list;
dates = {}; -- COMPAT util.interpolation {nil|func#...} bug
});
end
local serve_static
do
if prosody.process_type == "prosody" then
-- Prosody >= 0.12
local http_files = require "net.http.files";
serve = http_files.serve;
else
-- Prosody <= 0.11
serve = module:depends "http_files".serve;
end
local mime_map = module:shared("/*/http_files/mime").types or { css = "text/css"; js = "application/javascript" };
serve_static = serve({ path = resources; mime_map = mime_map });
end
module:provides("http", {
title = module:get_option_string("name", "Chatroom logs");
route = {
["GET /"] = list_rooms;
["GET /*"] = logs_page;
-- mod_http only supports one wildcard so logs_page will dispatch to years_page if the path contains no date
-- thus:
-- GET /room --> years_page (via logs_page)
-- GET /room/yyyy-mm-dd --> logs_page (for real)
["GET /@static/*"] = serve_static;
-- There are not many ASCII characters that are safe to use in URLs but not
-- valid in JID localparts, '@' seemed the only option.
};
});