view mod_muc_log_http/muc_log_http/mod_muc_log_http.lua @ 116:f68a781efe81

mod_muc_log_http: support escaped urls
author Thilo Cestonaro <thilo@cestona.ro>
date Thu, 14 Jan 2010 12:54:44 +0100
parents 5e657a305c88
children ecf05eb2d833
line wrap: on
line source

-- Copyright (C) 2009 Thilo Cestonaro
-- 
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--

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 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 = (CFG_PLUGINDIR 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 = "default";

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
		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)
	t = t:gsub("<", "&lt;");
	t = t:gsub(">", "&gt;");
	t = t:gsub("(http://[%a%d@%.:/&%?=%-_#%%]+)", function(h)
		h = urlunescape(h)
		return "<a href='" .. h .. "'>" .. h .. "</a>";
	end);
	t = t:gsub("\n", "<br />");
	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 .. "<tr>\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("<span style='color:#DDDDDD'>"..tostring(i).."</span>");
            if dayCallback ~= nil and dayCallback.callback ~= nil then
                tmp = dayCallback.callback(dayCallback.path, i, month, year);
            end
			if tmp == nil then
            	tmp = tostring("<span style='color:#DDDDDD'>"..tostring(i).."</span>");
			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 .. "</tr>\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 .. "<tr>\n";
            end
            for i = weekDay, 7 do
                htmlStr = htmlStr .. html.month.emptyDay .. "\n";
            end
            weekDay = 1
            htmlStr = htmlStr .. "</tr>\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 = "<div name='yearDiv' style='padding: 40px; text-align: center;'>" .. html.year.title:gsub("###YEAR###", tostring(year));
    for i=1, 12 do
		tmp = createMonth(i, year, dayCallback);
		if tmp then
        	htmlStr = htmlStr .. "<div style='float: left; padding: 5px;'>\n" .. tmp .. "</div>\n";
		end
    end
	return htmlStr .. "</div><br style='clear:both;'/> \n";
end

local function perDayCallback(path, day, month, year)
	local year = year;
	if year > 2000 then
		year = year - 2000;
	end
	local bareDay = str_format("%.02d%.02d%.02d", year, month, day);
	local attributes, err = lfs.attributes(path.."/"..bareDay)
	if attributes ~= nil and attributes.mode == "directory" then
		local s = html.days.bit;
		s = s:gsub("###BARE_DAY###", 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 attributes = nil;
	
	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
		room = prosody.hosts[host].muc.rooms[bareRoomJid];
		if room._data.hidden then
			room = nil
		end
	end
	if attributes ~= nil and room ~= nil then
		local alreadyDoneYears = {};
		for file in lfs.dir(path) do
			local year, month, day = file:match("^(%d%d)(%d%d)(%d%d)");
			if year ~= nil and alreadyDoneYears[year] == nil then
				days = days .. createYear(year, {callback=perDayCallback, path=path});
				alreadyDoneYears[year] = true;
			end
		end
	end
	
	if days ~= "" then
		tmp = html.days.body:gsub("###DAYS_STUFF###", days);
		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 tostring(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" 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;
		else
			month = month + 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)");
	module:log("debug", tostring(day).."/"..tostring(month).."/"..tostring(year))
	day = tonumber(day);
	month = tonumber(month);
	year = tonumber(year);
	
	if day - 1 == 0 then
		if month - 1 == 0 then
			year = year - 1;
		else
			month = month - 1;
		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);
	
	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);
					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###", "next day &gt;&gt;")
			end
			if previousDay then
				previousDay = html.day.dayLink:gsub("###DAY###", previousDay):gsub("###TEXT###", "&lt;&lt; previous day");
			end
			tmp = html.day.body:gsub("###DAY_STUFF###", ret):gsub("###JID###", bareRoomJid);
			tmp = tmp:gsub("###YEAR###", year):gsub("###MONTH###", month):gsub("###DAY###", day);
			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);
	
	if muc_hosts ~= 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)
	local iter = lfs.dir(path);
    for file in iter 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 "default";
	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:add_event_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);