view plugins/mod_tombstones.lua @ 14212:55a7143a58ec

mod_tombstones: Add shell commands for listing and clearing tombstones
author Matthew Wild <mwild1@gmail.com>
date Fri, 05 Jun 2026 16:24:55 +0100
parents a8ce6f45ba35
children
line wrap: on
line source

-- TODO warn when trying to create an user before the tombstone expires
-- e.g. via telnet or other admin interface
local datetime = require "prosody.util.datetime";
local errors = require "prosody.util.error";
local jid_node = require"prosody.util.jid".node;
local st = require "prosody.util.stanza";

-- Using a map store as key-value store so that removal of all user data
-- does not also remove the tombstone, which would defeat the point
local graveyard = module:open_store(nil, "keyval+");
local graveyard_cache = require "prosody.util.cache".new(module:get_option_integer("tombstone_cache_size", 1024, 1));

local ttl = module:get_option_period("user_tombstone_expiry", nil);
-- Keep tombstones forever by default
--
-- Rationale:
-- There is no way to be completely sure when remote services have
-- forgotten and revoked all memberships.

-- TODO If the user left a JID they moved to, return a gone+redirect error
-- TODO Attempt to deregister from MUCs based on bookmarks
-- TODO Unsubscribe from pubsub services if a notification is received

local function set_tombstone(username)
	local time = os.time();
	local ok, err = graveyard:set_key(nil, username, time);
	if ok then
		graveyard_cache:set(username, time);
	end
	return ok, err;
end

local function clear_tombstone(username)
	local ok, err = graveyard:set_key(nil, username, nil);
	if ok then
		graveyard_cache:set(username, false);
	end
	return ok, err;
end

module:hook_global("user-deleted", function(event)
	if event.host == module.host then
		local ok, err = set_tombstone(event.username);
		if not ok then module:log("error", "Could store tombstone for %s: %s", event.username, err); end
	end
end);

-- Public API
function has_tombstone(username)
	local tombstone;

	-- Check cache
	local cached_result = graveyard_cache:get(username);
	if cached_result == false then
		-- We cached that there is no tombstone for this user
		return false;
	elseif cached_result then
		tombstone = cached_result;
	else
		local stored_result, err = graveyard:get_key(nil, username);
		if not stored_result and not err then
			-- Cache that there is no tombstone for this user
			graveyard_cache:set(username, false);
			return false;
		elseif err then
			-- Failed to check tombstone status
			return nil, err;
		end
		-- We have a tombstone stored, so let's continue with that
		tombstone = stored_result;
	end

	-- Check expiry
	if ttl and tombstone + ttl < os.time() then
		module:log("debug", "Tombstone for %s created at %s has expired", username, datetime.datetime(tombstone));
		graveyard:set_key(nil, username, nil);
		graveyard_cache:set(username, nil); -- clear cache entry (if any)
		return nil;
	end

	-- Cache for the future
	graveyard_cache:set(username, tombstone);

	return tombstone;
end

module:hook("user-registering", function(event)
	local tombstone, err = has_tombstone(event.username);

	if err then
		event.allowed, event.error = errors.coerce(false, err);
		return true;
	elseif not tombstone then
		-- Feel free
		return;
	end

	module:log("debug", "Tombstone for %s created at %s", event.username, datetime.datetime(tombstone));
	event.allowed = false;
	return true;
end);

module:hook("presence/bare", function(event)
	local origin, presence = event.origin, event.stanza;
	local local_username = jid_node(presence.attr.to);
	if not local_username then return; end

	-- We want to undo any left-over presence subscriptions and notify the former
	-- contact that they're gone.
	--
	-- FIXME This leaks that the user once existed. Hard to avoid without keeping
	-- the contact list in some form, which we don't want to do for privacy
	-- reasons.  Bloom filter perhaps?

	local pres_type = presence.attr.type;
	local is_probe = pres_type == "probe";
	local is_normal = pres_type == nil or pres_type == "unavailable";
	if is_probe and has_tombstone(local_username) then
		origin.send(st.error_reply(presence, "cancel", "gone", "User deleted"));
		origin.send(st.presence({ type = "unsubscribed"; to = presence.attr.from; from = presence.attr.to }));
		return true;
	elseif is_normal and has_tombstone(local_username) then
		origin.send(st.error_reply(presence, "cancel", "gone", "User deleted"));
		origin.send(st.presence({ type = "unsubscribe"; to = presence.attr.from; from = presence.attr.to }));
		return true;
	end
end, 1);

--- shell commands
module:add_item("shell-command", {
	section = "user";
	name = "list_deleted";
	desc = "List deleted user accounts (tombstones)";
	args = {
		{ name = "host", type = "string" };
	};
	host_selector = "host";
	handler = function (self, host) --luacheck: ignore 212/host
		local c = 0;
		for username in pairs(graveyard:get(nil)) do
			c = c + 1;
			self.session.print(username);
		end
		return true, ("Showing %d deleted accounts"):format(c);
	end;
});

module:add_item("shell-command", {
	section = "user";
	name = "forget";
	desc = "Forget a deleted user account (tombstone)";
	args = {
		{ name = "jid", type = "string" };
	};
	host_selector = "jid";
	handler = function (self, user_jid) --luacheck: ignore 212/self
		local username = jid_node(user_jid);
		if not has_tombstone(username) then
			if require "core.usermanager".user_exists(username, module.host) then
				return nil, "User account has not been deleted";
			else
				return nil, "User account does not exist";
			end
		end

		local ok, err = clear_tombstone(username);
		if not ok then
			return nil, ("Failed to remove tombstone: %s"):format(err);
		end

		return true, "User account forgotten";
	end;
});