view mod_http_muc_log/mod_http_muc_log.lua @ 5401:c8d04ac200fc

mod_http_oauth2: Reject loopback URIs as client_uri This really should be a proper website with info, https://localhost is not good enough. Ideally we'd validate that it's got proper DNS and is actually reachable, but triggering HTTP or even DNS lookups seems like it would carry abuse potential that would best to avoid.
author Kim Alvefur <zash@zash.se>
date Tue, 02 May 2023 16:20:55 +0200
parents 7bce75e74f86
children df483d9056f5
line wrap: on
line source

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("<h1>mod_{module} could not read the template</h1>\
		<p>Tried to open <b>{filename}</b></p>\
		<pre>{error}</pre>",
			{ 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 hide_presence(request)
	if not presence_logged then
		return false;
	end
	if request.url.query then
		local data = httplib.formdecode(request.url.query);
		if data then
			return data.p == "h"
		end
	end
	return false;
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);
		hide_presence = hide_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;

	local room, date = path:match("^([^/]+)/([^/]*)/?$");
	if not room then
		response.headers.location = url.build({ path = path .. "/" });
		return 303;
	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"]  = hide_presence(request) and "message<groupchat" or nil;
	});
	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; -- { string : integer }
					if not react_map then
						react_map = {};
						logs[n].reactions = react_map;
					end
					for reaction_tag in reactions:childtags("reaction") do
						-- FIXME This doesn't replace previous reactions by the same user
						-- on the same message.
						local reaction_text = reaction_tag:get_text() or "�";
						react_map[reaction_text] = (react_map[reaction_text] or 0) + 1;
					end
					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

	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);
		hide_presence = hide_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;
		hide_presence = hide_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.
	};
});