-- Copyright (C) 2009 Thilo Cestonaro
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--
module:set_global();
local prosody = prosody;
local tabSort = table.sort;
local tonumber = _G.tonumber;
local tostring = _G.tostring;
local strchar = string.char;
local strformat = string.format;
local splitJid = require "util.jid".split;
local config_get = require "core.configmanager".get;
local httpserver = require "net.httpserver";
local urlencode = require "net.http".urlencode;
local urldecode = require "net.http".urldecode;
local datamanager = require "util.datamanager";
local data_load, data_getpath = datamanager.load, datamanager.getpath;
local datastore = "muc_log";
local urlBase = "muc_log";
local muc_hosts = {};
local config = nil;
local tostring = _G.tostring;
local tonumber = _G.tonumber;
local os_date, os_time = os.date, os.time;
local str_format = string.format;
local io_open = io.open;
local themesParent = (module.path and module.path:gsub("[/\\][^/\\]*$", "") or (prosody.paths.plugins or "./plugins") .. "/muc_log_http") .. "/themes";
local lom = require "lxp.lom";
--[[ LuaFileSystem
* URL: http://www.keplerproject.org/luafilesystem/index.html
* Install: luarocks install luafilesystem
* ]]
local lfs = require "lfs";
--[[
* Default templates for the html output.
]]--
local html = {};
local theme;
local function checkDatastorePathExists(node, host, today, create)
create = create or false;
local path = data_getpath(node, host, datastore, "dat", true);
path = path:gsub("/[^/]*$", "");
-- check existance
local attributes, err = lfs.attributes(path);
if attributes == nil or attributes.mode ~= "directory" then
module:log("warn", "muc_log folder isn't a folder: %s", path);
return false;
end
attributes, err = lfs.attributes(path .. "/" .. today);
if attributes == nil then
if create then
return lfs.mkdir(path .. "/" .. today);
else
return false;
end
elseif attributes.mode == "directory" then
return true;
end
return false;
end
function createDoc(body)
if body then
body = body:gsub("%%", "%%%%");
return html.doc:gsub("###BODY_STUFF###", body);
end
end
function urlunescape (escapedUrl)
escapedUrl = escapedUrl:gsub("+", " ")
escapedUrl = escapedUrl:gsub("%%(%x%x)", function(h) return strchar(tonumber(h,16)) end)
escapedUrl = escapedUrl:gsub("\r\n", "\n")
return escapedUrl
end
local function htmlEscape(t)
if t then
t = t:gsub("<", "<");
t = t:gsub(">", ">");
t = t:gsub("(http://[%a%d@%.:/&%?=%-_#%%~]+)", function(h)
h = urlunescape(h)
return "" .. h .. "";
end);
t = t:gsub("\n", "
");
t = t:gsub("%%", "%%%%");
else
t = "";
end
return t;
end
function splitUrl(url)
local tmp = url:sub(string.len("/muc_log/") + 1);
local day = nil;
local room = nil;
local component = nil;
local at = nil;
local slash = nil;
local slash2 = nil;
slash = tmp:find("/");
if slash then
component = tmp:sub(1, slash - 1);
if tmp:len() > slash then
room = tmp:sub(slash + 1);
slash = room:find("/");
if slash then
tmp = room;
room = tmp:sub(1, slash - 1);
if tmp:len() > slash then
day = tmp:sub(slash + 1);
slash = day:find("/");
if slash then
day = day:sub(1, slash - 1);
end
end
end
end
end
return room, component, day;
end
local function generateComponentListSiteContent()
local components = "";
for component,muc_host in pairs(muc_hosts or {}) do
components = components .. html.components.bit:gsub("###COMPONENT###", component);
end
if components ~= "" then
return html.components.body:gsub("###COMPONENTS_STUFF###", components);
end
end
local function generateRoomListSiteContent(component)
local rooms = "";
if prosody.hosts[component] and prosody.hosts[component].muc ~= nil then
for jid, room in pairs(prosody.hosts[component].muc.rooms) do
local node = splitJid(jid);
if not room._data.hidden and node then
rooms = rooms .. html.rooms.bit:gsub("###ROOM###", node):gsub("###COMPONENT###", component);
end
end
if rooms ~= "" then
return html.rooms.body:gsub("###ROOMS_STUFF###", rooms):gsub("###COMPONENT###", component);
end
end
end
-- Calendar stuff
local function getDaysForMonth(month, year)
local daysCount = 30;
local leapyear = false;
if year%4 == 0 and year%100 == 0 then
if year%400 == 0 then
leapyear = true;
else
leapyear = false; -- turn of the century but not a leapyear
end
elseif year%4 == 0 then
leapyear = true;
end
if month == 2 and leapyear then
daysCount = 29;
elseif month == 2 and not leapyear then
daysCount = 28;
elseif month < 8 and month%2 == 1 or
month >= 8 and month%2 == 0
then
daysCount = 31;
end
return daysCount;
end
local function createMonth(month, year, dayCallback)
local htmlStr = html.month.header;
local days = getDaysForMonth(month, year);
local time = os_time{year=year, month=month, day=1};
local dow = tostring(os_date("%a", time))
local title = tostring(os_date("%B", time));
local weekDays = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
local weekDay = 0;
local weeks = 1;
local logAvailableForMinimumOneDay = false;
local weekDaysHtml = "";
for _, tmp in ipairs(weekDays) do
weekDaysHtml = weekDaysHtml .. html.month.weekDay:gsub("###DAY###", tmp) .. "\n";
end
htmlStr = htmlStr:gsub("###TITLE###", title):gsub("###WEEKDAYS###", weekDaysHtml);
for i = 1, 31 do
weekDay = weekDay + 1;
if weekDay == 1 then htmlStr = htmlStr .. "
\n"; end
if i == 1 then
for _, tmp in ipairs(weekDays) do
if dow ~= tmp then
htmlStr = htmlStr .. html.month.emptyDay .. "\n";
weekDay = weekDay + 1;
else
break;
end
end
end
if i < days + 1 then
local tmp = tostring(i);
if dayCallback ~= nil and dayCallback.callback ~= nil then
tmp = dayCallback.callback(dayCallback.path, i, month, year, dayCallback.room, dayCallback.webPath);
end
if tmp == nil then
tmp = tostring(i);
else
logAvailableForMinimumOneDay = true;
end
htmlStr = htmlStr .. html.month.day:gsub("###DAY###", tmp) .. "\n";
end
if i >= days then
break;
end
if weekDay == 7 then
weekDay = 0;
weeks = weeks + 1;
htmlStr = htmlStr .. "
\n";
end
end
if weekDay + 1 < 8 or weeks < 6 then
weekDay = weekDay + 1;
if weekDay > 7 then
weekDay = 1;
end
if weekDay == 1 then
weeks = weeks + 1;
end
for y = weeks, 6 do
if weekDay == 1 then
htmlStr = htmlStr .. "\n";
end
for i = weekDay, 7 do
htmlStr = htmlStr .. html.month.emptyDay .. "\n";
end
weekDay = 1
htmlStr = htmlStr .. "
\n";
end
end
htmlStr = htmlStr .. html.month.footer;
if logAvailableForMinimumOneDay then
return htmlStr;
end
end
local function createYear(year, dayCallback)
local year = year;
local tmp;
if tonumber(year) <= 99 then
year = year + 2000;
end
local htmlStr = "";
for i=1, 12 do
tmp = createMonth(i, year, dayCallback);
if tmp then
htmlStr = htmlStr .. "\n" .. tmp .. "
\n";
end
end
if htmlStr ~= "" then
return "" .. html.year.title:gsub("###YEAR###", tostring(year)) .. htmlStr .. "
\n";
end
return "";
end
local function perDayCallback(path, day, month, year, room, webPath)
local webPath = webPath or ""
local year = year;
if year > 2000 then
year = year - 2000;
end
local bareDay = str_format("%.02d%.02d%.02d", year, month, day);
room = urlencode(room);
local attributes, err = lfs.attributes(path.."/"..bareDay.."/"..room..".dat")
if attributes ~= nil and attributes.mode == "file" then
local s = html.days.bit;
s = s:gsub("###BARE_DAY###", webPath .. bareDay);
s = s:gsub("###DAY###", day);
return s;
end
return;
end
local function generateDayListSiteContentByRoom(bareRoomJid)
local days = "";
local arrDays = {};
local tmp;
local node, host, resource = splitJid(bareRoomJid);
local path = data_getpath(node, host, datastore);
local room = nil;
local nextRoom = "";
local previousRoom = "";
local rooms = "";
local attributes = nil;
local since = "";
local to = "";
local topic = "";
path = path:gsub("/[^/]*$", "");
attributes = lfs.attributes(path);
if muc_hosts ~= nil and muc_hosts[host] and prosody.hosts[host] ~= nil and prosody.hosts[host].muc ~= nil and prosody.hosts[host].muc.rooms[bareRoomJid] ~= nil then
local found = 0;
for jid, room in pairs(prosody.hosts[host].muc.rooms) do
local node = splitJid(jid)
if not room._data.hidden and node then
if found == 0 then
previousRoom = node
elseif found == 1 then
nextRoom = node
found = -1
end
if jid == bareRoomJid then
found = 1
end
rooms = rooms .. html.days.rooms.bit:gsub("###ROOM###", node);
end
end
room = prosody.hosts[host].muc.rooms[bareRoomJid];
if room._data.hidden then
room = nil
end
end
if attributes ~= nil and room ~= nil then
local first = 1;
local alreadyDoneYears = {};
local temptime = {day=0, month=0, year=0};
topic = room._data.subject or "(no subject)"
if topic:len() > 135 then
topic = topic:sub(1, topic:find(" ", 120)) .. " ..."
end
for folder in lfs.dir(path) do
local year, month, day = folder:match("^(%d%d)(%d%d)(%d%d)");
if year ~= nil and alreadyDoneYears[year] == nil then
temptime.day = tonumber(day)
temptime.month = tonumber(month)
temptime.year = 2000 + tonumber(year)
if first == 1 then
to = tostring(os_date("%B %Y", os_time(temptime)))
first = 0
end
since = tostring(os_date("%B %Y", os_time(temptime)))
module:log("debug", "creating overview for: " .. tostring(since))
days = createYear(year, {callback=perDayCallback, path=path, room=node}) .. days;
alreadyDoneYears[year] = true;
end
end
end
if days ~= "" then
tmp = html.days.body:gsub("###DAYS_STUFF###", days);
tmp = tmp:gsub("###PREVIOUS_ROOM###", previousRoom == "" and node or previousRoom);
tmp = tmp:gsub("###NEXT_ROOM###", nextRoom == "" and node or nextRoom);
tmp = tmp:gsub("###ROOMS###", rooms);
tmp = tmp:gsub("###ROOMTOPIC###", topic);
tmp = tmp:gsub("###SINCE###", since);
tmp = tmp:gsub("###TO###", to);
return tmp:gsub("###JID###", bareRoomJid);
end
end
local function parseIqStanza(stanza, timeStuff, nick)
local text = nil;
local victim = nil;
if(stanza.attr.type == "set") then
for _,tag in ipairs(stanza) do
if tag.tag == "query" then
for _,item in ipairs(tag) do
if item.tag == "item" and item.attr.nick ~= nil and item.attr.role == 'none' then
victim = item.attr.nick;
for _,reason in ipairs(item) do
if reason.tag == "reason" then
text = reason[1];
break;
end
end
break;
end
end
break;
end
end
if victim ~= nil then
if text ~= nil then
text = html.day.reason:gsub("###REASON###", htmlEscape(text));
else
text = "";
end
return html.day.kick:gsub("###TIME_STUFF###", timeStuff):gsub("###VICTIM###", victim):gsub("###REASON_STUFF###", text);
end
end
return;
end
local function parsePresenceStanza(stanza, timeStuff, nick)
local ret = "";
local showJoin = "block"
if config and not config.showJoin then
showJoin = "none";
end
if stanza.attr.type == nil then
local showStatus = "block"
if config and not config.showStatus then
showStatus = "none";
end
local show, status = nil, "";
local alreadyJoined = false;
for _, tag in ipairs(stanza) do
if tag.tag == "alreadyJoined" then
alreadyJoined = true;
elseif tag.tag == "show" then
show = tag[1];
elseif tag.tag == "status" and tag[1] ~= nil then
status = tag[1];
end
end
if alreadyJoined == true then
if show == nil then
show = "online";
end
ret = html.day.presence.statusChange:gsub("###TIME_STUFF###", timeStuff);
if status ~= "" then
status = html.day.presence.statusText:gsub("###STATUS###", htmlEscape(status));
end
ret = ret:gsub("###SHOW###", show):gsub("###NICK###", nick):gsub("###SHOWHIDE###", showStatus):gsub("###STATUS_STUFF###", status);
else
ret = html.day.presence.join:gsub("###TIME_STUFF###", timeStuff):gsub("###SHOWHIDE###", showJoin):gsub("###NICK###", nick);
end
elseif stanza.attr.type ~= nil and stanza.attr.type == "unavailable" then
ret = html.day.presence.leave:gsub("###TIME_STUFF###", timeStuff):gsub("###SHOWHIDE###", showJoin):gsub("###NICK###", nick);
end
return ret;
end
local function parseMessageStanza(stanza, timeStuff, nick)
local body, title, ret = nil, nil, "";
for _,tag in ipairs(stanza) do
if tag.tag == "body" then
body = tag[1];
if nick ~= nil then
break;
end
elseif tag.tag == "nick" and nick == nil then
nick = htmlEscape(tag[1]);
if body ~= nil or title ~= nil then
break;
end
elseif tag.tag == "subject" then
title = tag[1];
if nick ~= nil then
break;
end
end
end
if nick ~= nil and body ~= nil then
body = htmlEscape(body);
local me = body:find("^/me");
local template = "";
if not me then
template = html.day.message;
else
template = html.day.messageMe;
body = body:gsub("^/me ", "");
end
ret = template:gsub("###TIME_STUFF###", timeStuff):gsub("###NICK###", nick):gsub("###MSG###", body);
elseif nick ~= nil and title ~= nil then
title = htmlEscape(title);
ret = html.day.titleChange:gsub("###TIME_STUFF###", timeStuff):gsub("###NICK###", nick):gsub("###TITLE###", title);
end
return ret;
end
local function incrementDay(bare_day)
local year, month, day = bare_day:match("^(%d%d)(%d%d)(%d%d)");
local leapyear = false;
module:log("debug", tostring(day).."/"..tostring(month).."/"..tostring(year))
day = tonumber(day);
month = tonumber(month);
year = tonumber(year);
if year%4 == 0 and year%100 == 0 then
if year%400 == 0 then
leapyear = true;
else
leapyear = false; -- turn of the century but not a leapyear
end
elseif year%4 == 0 then
leapyear = true;
end
if (month == 2 and leapyear and day + 1 > 29) or
(month == 2 and not leapyear and day + 1 > 28) or
(month < 8 and month%2 == 1 and day + 1 > 31) or
(month < 8 and month%2 == 0 and day + 1 > 30) or
(month >= 8 and month%2 == 0 and day + 1 > 31) or
(month >= 8 and month%2 == 1 and day + 1 > 30)
then
if month + 1 > 12 then
year = year + 1;
month = 1;
day = 1;
else
month = month + 1;
day = 1;
end
else
day = day + 1;
end
return strformat("%.02d%.02d%.02d", year, month, day);
end
local function findNextDay(bareRoomJid, bare_day)
local node, host, resource = splitJid(bareRoomJid);
local day = incrementDay(bare_day);
local max_trys = 7;
module:log("debug", day);
while(not checkDatastorePathExists(node, host, day, false)) do
max_trys = max_trys - 1;
if max_trys == 0 then
break;
end
day = incrementDay(day);
end
if max_trys == 0 then
return nil;
else
return day;
end
end
local function decrementDay(bare_day)
local year, month, day = bare_day:match("^(%d%d)(%d%d)(%d%d)");
local leapyear = false;
module:log("debug", tostring(day).."/"..tostring(month).."/"..tostring(year))
day = tonumber(day);
month = tonumber(month);
year = tonumber(year);
if year%4 == 0 and year%100 == 0 then
if year%400 == 0 then
leapyear = true;
else
leapyear = false; -- turn of the century but not a leapyear
end
elseif year%4 == 0 then
leapyear = true;
end
if day - 1 == 0 then
if month - 1 == 0 then
year = year - 1;
month = 12;
day = 31;
else
month = month - 1;
if (month == 2 and leapyear) then day = 29
elseif (month == 2 and not leapyear) then day = 28
elseif (month < 8 and month%2 == 1) or (month >= 8 and month%2 == 0) then day = 31
else day = 30
end
end
else
day = day - 1;
end
return strformat("%.02d%.02d%.02d", year, month, day);
end
local function findPreviousDay(bareRoomJid, bare_day)
local node, host, resource = splitJid(bareRoomJid);
local day = decrementDay(bare_day);
local max_trys = 7;
module:log("debug", day);
while(not checkDatastorePathExists(node, host, day, false)) do
max_trys = max_trys - 1;
if max_trys == 0 then
break;
end
day = decrementDay(day);
end
if max_trys == 0 then
return nil;
else
return day;
end
end
local function parseDay(bareRoomJid, roomSubject, bare_day)
local ret = "";
local year;
local month;
local day;
local tmp;
local node, host, resource = splitJid(bareRoomJid);
local year, month, day = bare_day:match("^(%d%d)(%d%d)(%d%d)");
local previousDay = findPreviousDay(bareRoomJid, bare_day);
local nextDay = findNextDay(bareRoomJid, bare_day);
local temptime = {day=0, month=0, year=0};
local path = data_getpath(node, host, datastore);
path = path:gsub("/[^/]*$", "");
local calendar = ""
if tonumber(year) <= 99 then
year = year + 2000;
end
temptime.day = tonumber(day)
temptime.month = tonumber(month)
temptime.year = tonumber(year)
calendar = createMonth(temptime.month, temptime.year, {callback=perDayCallback, path=path, room=node, webPath="../"}) or ""
if bare_day ~= nil then
local data = data_load(node, host, datastore .. "/" .. bare_day);
if data ~= nil then
for i=1, #data, 1 do
local stanza = lom.parse(data[i]);
if stanza ~= nil and stanza.attr ~= nil and stanza.attr.time ~= nil then
local timeStuff = html.day.time:gsub("###TIME###", stanza.attr.time):gsub("###UTC###", stanza.attr.utc or stanza.attr.time);
if stanza[1] ~= nil then
local nick;
local tmp;
-- grep nick from "from" resource
if stanza[1].attr.from ~= nil then -- presence and messages
nick = htmlEscape(stanza[1].attr.from:match("/(.+)$"));
elseif stanza[1].attr.to ~= nil then -- iq
nick = htmlEscape(stanza[1].attr.to:match("/(.+)$"));
end
if stanza[1].tag == "presence" and nick ~= nil then
tmp = parsePresenceStanza(stanza[1], timeStuff, nick);
elseif stanza[1].tag == "message" then
tmp = parseMessageStanza(stanza[1], timeStuff, nick);
elseif stanza[1].tag == "iq" then
tmp = parseIqStanza(stanza[1], timeStuff, nick);
else
module:log("info", "unknown stanza subtag in log found. room: %s; day: %s", bareRoomJid, year .. "/" .. month .. "/" .. day);
end
if tmp ~= nil then
ret = ret .. tmp
tmp = nil;
end
end
end
end
end
if ret ~= "" then
if nextDay then
nextDay = html.day.dayLink:gsub("###DAY###", nextDay):gsub("###TEXT###", ">")
end
if previousDay then
previousDay = html.day.dayLink:gsub("###DAY###", previousDay):gsub("###TEXT###", "<");
end
ret = ret:gsub("%%", "%%%%");
tmp = html.day.body:gsub("###DAY_STUFF###", ret):gsub("###JID###", bareRoomJid);
tmp = tmp:gsub("###CALENDAR###", calendar);
tmp = tmp:gsub("###DATE###", tostring(os_date("%A, %B %d, %Y", os_time(temptime))));
tmp = tmp:gsub("###TITLE_STUFF###", html.day.title:gsub("###TITLE###", roomSubject));
tmp = tmp:gsub("###STATUS_CHECKED###", config.showStatus and "checked='checked'" or "");
tmp = tmp:gsub("###JOIN_CHECKED###", config.showJoin and "checked='checked'" or "");
tmp = tmp:gsub("###NEXT_LINK###", nextDay or "");
tmp = tmp:gsub("###PREVIOUS_LINK###", previousDay or "");
return tmp;
end
end
end
function handle_request(method, body, request)
local node, host, day = splitUrl(request.url.path);
node = urldecode(node);
if muc_hosts ~= nil and html.doc ~= nil then
if node ~= nil and host ~= nil then
local bare = node .. "@" .. host;
if prosody.hosts[host] ~= nil and prosody.hosts[host].muc ~= nil then
if prosody.hosts[host].muc.rooms[bare] ~= nil then
local room = prosody.hosts[host].muc.rooms[bare];
if day == nil then
return createDoc(generateDayListSiteContentByRoom(bare));
else
local subject = ""
if room._data ~= nil and room._data.subject ~= nil then
subject = room._data.subject;
end
return createDoc(parseDay(bare, subject, day));
end
else
return createDoc(generateRoomListSiteContent(host));
end
else
return createDoc(generateComponentListSiteContent());
end
elseif host ~= nil then
return createDoc(generateRoomListSiteContent(host));
else
return createDoc(generateComponentListSiteContent());
end
end
return;
end
-- Compatibility: Lua-5.1
function split(str, pat)
local t = {} -- NOTE: use {n = 0} in Lua-5.0
local fpat = "(.-)" .. pat
local last_end = 1
local s, e, cap = str:find(fpat, 1)
while s do
if s ~= 1 or cap ~= "" then
table.insert(t,cap)
end
last_end = e+1
s, e, cap = str:find(fpat, last_end)
end
if last_end <= #str then
cap = str:sub(last_end)
table.insert(t, cap)
end
return t
end
local function assign(arr, content)
local tmp = html;
local idx = nil;
for _,i in ipairs(arr) do
if idx ~= nil then
if tmp[idx] == nil then
tmp[idx] = {};
end
tmp = tmp[idx];
end
idx = i;
end
tmp[idx] = content;
end
local function readFile(filepath)
local f = assert(io_open(filepath, "r"));
local t = f:read("*all");
f:close()
return t;
end
local function loadTheme(path)
for file in lfs.dir(path) do
if file ~= "." and file ~= ".." then
module:log("debug", "opening theme file: " .. file);
local tmp = split(file:gsub("\.html$", ""), "_");
local content = readFile(path .. "/" .. file);
assign(tmp, content);
end
end
return true;
end
function module.load()
config = config_get("*", "core", "muc_log_http") or {};
if config.showStatus == nil then
config.showStatus = true;
end
if config.showJoin == nil then
config.showJoin = true;
end
theme = config.theme or "prosody";
local themePath = themesParent .. "/" .. tostring(theme);
local attributes, err = lfs.attributes(themePath);
if attributes == nil or attributes.mode ~= "directory" then
module:log("error", "Theme folder of theme \"".. tostring(theme) .. "\" isn't existing. expected Path: " .. themePath);
return false;
end
-- module:log("debug", (require "util.serialization").serialize(html));
if(not loadTheme(themePath)) then
module:log("error", "Theme \"".. tostring(theme) .. "\" is missing something.");
return false;
end
-- module:log("debug", (require "util.serialization").serialize(html));
httpserver.new_from_config({ config.http_port or true }, handle_request, { base = urlBase, ssl = false, port = 5290 });
for jid, host in pairs(prosody.hosts) do
if host.muc then
local enabledModules = config_get(jid, "core", "modules_enabled");
if enabledModules then
for _,mod in ipairs(enabledModules) do
if(mod == "muc_log") then
module:log("debug", "component: %s", tostring(jid));
muc_hosts[jid] = true;
break;
end
end
end
end
end
module:log("debug", "loaded mod_muc_log_http");
end
function module.unload()
muc_hosts = nil;
module:log("debug", "unloaded mod_muc_log_http");
end
module:hook("component-activated", function(component, config)
if config.core and config.core.modules_enabled then
for _,mod in ipairs(config.core.modules_enabled) do
if(mod == "muc_log") then
module:log("debug", "component: %s", tostring(component));
muc_hosts[component] = true;
break;
end
end
end
end);