view mod_mam/mod_mam.lua @ 735:c1b0f0c33c6a

mod_archive: Fix hour offset in stored message date os.date expect a timestamp in local time, that is subject to daylight saving. But since we pass an UTC timestamp to os.date one hour is (wrongly) added in the summer. The only sensible thing is to call the os.date only once with the ! parametter. And then parsing this sting to get the utc_timestamp. Calling os.date with an UTC timestamp is not possible, and calling os.date twice without timestamp could give different results.
author Olivier Goffart <ogoffart@woboq.com>
date Wed, 04 Jul 2012 13:49:57 +0200
parents 53afd87f3612
children 3c37445f26ac
line wrap: on
line source

-- XEP-0313: Message Archive Management for Prosody
-- Copyright (C) 2011-2012 Kim Alvefur
--
-- This file is MIT/X11 licensed.

local xmlns_mam     = "urn:xmpp:mam:tmp";
local xmlns_delay   = "urn:xmpp:delay";
local xmlns_forward = "urn:xmpp:forward:0";

local st = require "util.stanza";
local rsm = module:require "rsm";
local jid_bare = require "util.jid".bare;
local jid_split = require "util.jid".split;
local host = module.host;

local dm_load = require "util.datamanager".load;
local dm_store = require "util.datamanager".store;
local dm_list_load = require "util.datamanager".list_load;
local dm_list_append = require "util.datamanager".list_append;
local rm_load_roster = require "core.rostermanager".load_roster;

local tostring = tostring;
local time_now = os.time;
local m_min = math.min;
local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse;
local uuid = require "util.uuid".generate;
local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
local global_default_policy = module:get_option("default_archive_policy", false);
-- TODO Should be possible to enforce it too


-- For translating preference names from string to boolean and back
local default_attrs = {
	always = true, [true] = "always",
	never = false, [false] = "never",
	roster = "roster",
}

do
	local prefs_format = {
		[false] = "roster",
		-- default ::= true | false | "roster"
		-- true = always, false = never, nil = global default
		["romeo@montague.net"] = true, -- always
		["montague@montague.net"] = false, -- newer
	};
end

local archive_store = "archive2";
local prefs_store = archive_store .. "_prefs";
local function get_prefs(user)
	return dm_load(user, host, prefs_store) or
		{ [false] = global_default_policy };
end
local function set_prefs(user, prefs)
	return dm_store(user, host, prefs_store, prefs);
end


-- Handle prefs.
module:hook("iq/self/"..xmlns_mam..":prefs", function(event)
	local origin, stanza = event.origin, event.stanza;
	local user = origin.username;
	if stanza.attr.type == "get" then
		local prefs = get_prefs(user);
		local default = prefs[false];
		default = default ~= nil and default_attrs[default] or global_default_policy;
		local reply = st.reply(stanza):tag("prefs", { xmlns = xmlns_mam, default = default })
		local always = st.stanza("always");
		local never = st.stanza("never");
		for k,v in pairs(prefs) do
			if k then
				(v and always or never):tag("jid"):text(k):up();
			end
		end
		reply:add_child(always):add_child(never);
		origin.send(reply);
		return true
	else -- type == "set"
		local prefs = {};
		local new_prefs = stanza:get_child("prefs", xmlns_mam);
		local new_default = new_prefs.attr.default;
		if new_default then
			prefs[false] = default_attrs[new_default];
		end

		local always = new_prefs:get_child("always");
		if always then
			for rule in always:childtags("jid") do
				local jid = rule:get_text();
				prefs[jid] = true;
			end
		end

		local never = new_prefs:get_child("never");
		if never then
			for rule in never:childtags("jid") do
				local jid = rule:get_text();
				prefs[jid] = false;
			end
		end

		local ok, err = set_prefs(user, prefs);
		if not ok then
			origin.send(st.error_reply(stanza, "cancel", "internal-server-error", "Error storing preferences: "..tostring(err)));
		else
			origin.send(st.reply(stanza));
		end
		return true
	end
end);

-- Handle archive queries
module:hook("iq/self/"..xmlns_mam..":query", function(event)
	local origin, stanza = event.origin, event.stanza;
	local query = stanza.tags[1];
	if stanza.attr.type == "get" then
		local qid = query.attr.queryid;

		-- Search query parameters
		local qwith = query:get_child_text("with");
		local qstart = query:get_child_text("start");
		local qend = query:get_child_text("end");
		local qset = rsm.get(query);
		module:log("debug", "Archive query, id %s with %s from %s until %s)",
			tostring(qid), qwith or "anyone", qstart or "the dawn of time", qend or "now");

		qstart, qend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend))

		-- Load all the data!
		local data, err = dm_list_load(origin.username, origin.host, archive_store);
		if not data then
			if (not err) then
				module:log("debug", "The archive was empty.");
				origin.send(st.reply(stanza));
			else
				origin.send(st.error_reply(stanza, "cancel", "internal-server-error", "Error loading archive: "..tostring(err)));
			end
			return true
		end

		-- RSM stuff
		local qmax = m_min(qset and qset.max or default_max_items, max_max_items);
		local qset_matches = not (qset and qset.after);
		local first, last, index;
		local n = 0;
		local start = qset and qset.index or 1;

		module:log("debug", "Loaded %d items, about to filter", #data);
		for i=start,#data do
			local item = data[i];
			local when, with, with_bare = item.when, item.with, item.with_bare;
			local id = item.id;
			--module:log("debug", "id is %s", id);

			-- RSM pre-send-checking
			if qset then
				if qset.before == id then
					module:log("debug", "End of matching range found");
					qset_matches = false;
					break;
				end
			end

			--module:log("debug", "message with %s at %s", with, when or "???");
			-- Apply query filter
			if (not qwith or ((qwith == with) or (qwith == with_bare)))
					and (not qstart or when >= qstart)
					and (not qend or when <= qend)
					and (not qset or qset_matches) then
				local fwd_st = st.message{ to = origin.full_jid }
					:tag("result", { xmlns = xmlns_mam, queryid = qid, id = id }):up()
					:tag("forwarded", { xmlns = xmlns_forward })
						:tag("delay", { xmlns = xmlns_delay, stamp = timestamp(when) }):up();
				local orig_stanza = st.deserialize(item.stanza);
				orig_stanza.attr.xmlns = "jabber:client";
				fwd_st:add_child(orig_stanza);
				origin.send(fwd_st);
				if not first then
					index = i;
					first = id;
				end
				last = id;
				n = n + 1;
			elseif (qend and when > qend) then
				module:log("debug", "We have passed into messages more recent than requested");
				break -- We have passed into messages more recent than requested
			end

			-- RSM post-send-checking
			if qset then
				if qset.after == id then
					module:log("debug", "Start of matching range found");
					qset_matches = true;
				end
			end
			if n >= qmax then
				module:log("debug", "Max number of items matched");
				break
			end
		end
		-- That's all folks!
		module:log("debug", "Archive query %s completed", tostring(qid));

		local reply = st.reply(stanza);
		if last then
			-- This is a bit redundant, isn't it?
			reply:query(xmlns_mam):add_child(rsm.generate{last = last});
		end
		origin.send(reply);
		return true
	end
end);

local function has_in_roster(user, who)
	local roster = rm_load_roster(user, host);
	module:log("debug", "%s has %s in roster? %s", user, who, roster[who] and "yes" or "no");
	return roster and roster[who];
end

local function shall_store(user, who)
	-- TODO Cache this?
	local prefs = get_prefs(user);
	local rule = prefs[who];
	module:log("debug", "%s's rule for %s is %s", user, who, tostring(rule))
	if rule ~= nil then
		return rule;
	else -- Below could be done by a metatable
		local default = prefs[false];
		module:log("debug", "%s's default rule is %s", user, tostring(default))
		if default == nil then
			default = global_default_policy;
			module:log("debug", "Using global default rule, %s", tostring(default))
		end
		if default == "roster" then
			return has_in_roster(user, who);
		end
		return default;
	end
end

-- Handle messages
local function message_handler(event, c2s)
	local origin, stanza = event.origin, event.stanza;
	local orig_type = stanza.attr.type or "normal";
	local orig_to = stanza.attr.to;
	local orig_from = stanza.attr.from;

	if not orig_from and c2s then
		orig_from = origin.full_jid;
	end
	orig_to = orig_to or orig_from; -- Weird corner cases

	-- Don't store messages of these types
	if orig_type == "error"
	or orig_type == "headline"
	or orig_type == "groupchat"
	or not stanza:get_child("body") then
		return;
		-- TODO Maybe headlines should be configurable?
		-- TODO Write a mod_mam_muc for groupchat messages.
	end

	local store_user, store_host = jid_split(c2s and orig_from or orig_to);
	local target_jid = c2s and orig_to or orig_from;
	local target_bare = jid_bare(target_jid);

	if shall_store(store_user, target_bare) then
		module:log("debug", "Archiving stanza: %s", stanza:top_tag());

		local id = uuid();
		local when = time_now();
		-- And stash it
		local ok, err = dm_list_append(store_user, store_host, archive_store, {
			-- WARNING This format may change.
			id = id,
			when = when,
			with = target_jid,
			with_bare = target_bare, -- Optimization, to avoid loads of jid_bare() calls when filtering.
			stanza = st.preserialize(stanza)
		});
		--[[ This was dropped from the spec
		if ok then 
			stanza:tag("archived", { xmlns = xmlns_mam, by = host, id = id }):up();
		end
		--]]
	else
		module:log("debug", "Not archiving stanza: %s", stanza:top_tag());
	end
end

local function c2s_message_handler(event)
	return message_handler(event, true);
end

-- Stanzas sent by local clients
module:hook("pre-message/bare", c2s_message_handler, 2);
module:hook("pre-message/full", c2s_message_handler, 2);
-- Stanszas to local clients
module:hook("message/bare", message_handler, 2);
module:hook("message/full", message_handler, 2);

module:add_feature(xmlns_mam);