Changeset

6147:6ba0489e4828

mod_storage_metronome_readonly: New module for migrating off Metronome
author Link Mauve <linkmauve@linkmauve.fr>
date Fri, 10 Jan 2025 23:17:09 +0100
parents 6146:dde2803f7678
children 6148:c90aab23fb9b
files mod_storage_metronome_readonly/README.markdown mod_storage_metronome_readonly/mod_storage_metronome_readonly.lua
diffstat 2 files changed, 380 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_storage_metronome_readonly/README.markdown	Fri Jan 10 23:17:09 2025 +0100
@@ -0,0 +1,48 @@
+---
+labels:
+- 'Type-Storage'
+- 'Stage-Alpha'
+summary: Metronome Read-only Storage Module
+...
+
+Introduction
+============
+
+This is a storage backend using Metronome Lua storage.
+
+This module only works in read-only, and was made to be used by [mod\_migrate]
+to migrate from Metronome’s storage.
+
+So far it has only been tested migrating to sqlite, because
+mod\_storage\_internal relies on the same `data_path` variable as this module,
+and thus would overwrite the files we just read.
+
+I’ve also only tested it on a dump from a Metronome configured by Yunohost, so
+using LDAP and such for user accounts, I don’t yet know how to migrate from
+different Metronome account storages.
+
+Configuration
+=============
+
+Copy the module to the prosody modules/plugins directory.
+
+In Prosody's configuration file, set:
+
+    storage = "metronome_readonly"
+    data_path = "/var/lib/metronome"
+
+To run the actual migration, run this command:
+
+    ./prosodyctl mod_migrate kl.netlib.re roster,vcard,private,cloud_notify,pep,pep-archive,offline-archive,archive-archive sql
+
+It will create a file in `/var/lib/metronome/prosody.sqlite`, after which you
+can change your configuration file to point to it, or alternatively you can
+perform a second migration to the internal storage if you prefer that.
+
+Compatibility
+=============
+
+  ------------------------ --------
+  trunk (as of 2025-01-10) Works
+  0.12                     Untested
+  ------------------------ --------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_storage_metronome_readonly/mod_storage_metronome_readonly.lua	Fri Jan 10 23:17:09 2025 +0100
@@ -0,0 +1,332 @@
+local datamanager = require "prosody.core.storagemanager".olddm;
+local datetime = require "prosody.util.datetime";
+local st = require "prosody.util.stanza";
+local now = require "prosody.util.time".now;
+local id = require "prosody.util.id".medium;
+local set = require "prosody.util.set";
+local envloadfile = require"prosody.util.envload".envloadfile;
+
+local host = module.host;
+
+local archive_item_limit = module:get_option_integer("storage_archive_item_limit", 10000, 0);
+
+-- Metronome doesn’t store the item publish time, so fallback to the migration time.
+local when = math.floor(now());
+
+local function encode (s)
+        return s and (s:gsub("%W", function (c) return string.format("%%%02x", c:byte()); end));
+end
+
+local driver = {};
+
+function driver:open(store, typ)
+	local mt = self[typ or "keyval"]
+	if not mt then
+		return nil, "unsupported-store";
+	end
+	return setmetatable({ store = store, type = typ }, mt);
+end
+
+function driver:stores(username) -- luacheck: ignore 212/self
+	if username == true then
+		local nodes = set.new();
+		for user in datamanager.users(host, "pep") do
+			local data = datamanager.load(user, host, "pep");
+
+			for _, node in ipairs(data["nodes"]) do
+				nodes:add("pep_" .. node);
+			end
+		end
+		return function()
+			for node in nodes do
+				nodes:remove(node);
+				return node;
+			end
+		end;
+	end
+end
+
+function driver:purge(user) -- luacheck: ignore 212/self
+	return nil, "unsupported-store";
+end
+
+local keyval = { };
+driver.keyval = { __index = keyval };
+
+function keyval:get(user)
+	if self.store == "pep" then
+		local data = datamanager.load(user, host, self.store);
+		local nodes = data["nodes"];
+		local result = {};
+
+		local pep_base_path = datamanager.getpath(user, host, self.store):sub(1, -5);
+
+		for _, node in ipairs(nodes) do
+			local path = ("%s/%s.dat"):format(pep_base_path, encode(node));
+			local data = envloadfile(path, {});
+			if not data then
+				log("error", "Failed to load metronome storage");
+				return nil, "Error reading storage";
+			end
+			local success, data = pcall(data);
+			if not success then
+				log("error", "Unable to load metronome storage");
+				return nil, "Error reading storage";
+			end
+			local new_node = {};
+			new_node["name"] = node;
+			new_node["subscribers"] = data["subscribers"];
+			new_node["affiliations"] = data["affiliations"];
+			new_node["config"] = data["config"];
+			result[node] = new_node;
+		end
+		return result;
+	elseif self.store == "cloud_notify" then
+		local data = datamanager.load(user, host, "push");
+		local result = {};
+		for jid, data in pairs(data) do
+			local secret = data["secret"];
+			for node in pairs(data["nodes"]) do
+				-- TODO: Does Metronome store more info than that?
+				local options;
+				if secret then
+					options = st.preserialize(st.stanza("x", { xmlns = "jabber:x:data", type = "submit" })
+						:tag("field", { var = "FORM_TYPE" })
+							:text_tag("value", "http://jabber.org/protocol/pubsub#publish-options")
+						:up()
+						:tag("field", { var = "secret" })
+							:text_tag("value", secret));
+				end
+				result[jid.."<"..node] = {
+					jid = jid,
+					node = node,
+					options = options,
+				};
+			end
+		end
+		return result;
+	elseif self.store == "roster" then
+		return datamanager.load(user, host, self.store);
+	elseif self.store == "vcard" then
+		return datamanager.load(user, host, self.store);
+	elseif self.store == "private" then
+		return datamanager.load(user, host, self.store);
+
+	-- After that, handle MUC specific stuff, not tested yet whatsoever.
+	elseif self.store == "persistent" then
+		return datamanager.load(user, host, self.store);
+	elseif self.store == "config" then
+		return datamanager.load(user, host, self.store);
+	elseif self.store == "vcard_muc" then
+		local data = datamanager.load(user, host, "room_icons");
+		return data and data["photo"];
+	else
+		return nil, "unsupported-store";
+	end
+end
+
+function keyval:set(user, data)
+	return nil, "unsupported-store";
+end
+
+function keyval:users()
+	local store;
+	if self.store == "vcard_muc" then
+		store = "room_icons";
+	elseif self.store == "cloud_notify" then
+		store = "push";
+	else
+		store = self.store;
+	end
+	return datamanager.users(host, store, self.type);
+end
+
+local archive = {};
+driver.archive = { __index = archive };
+
+archive.caps = {
+	total = true;
+	quota = archive_item_limit;
+	full_id_range = true;
+	ids = true;
+};
+
+function archive:append(username, key, value, when, with)
+	return nil, "unsupported-store";
+end
+
+function archive:find(username, query)
+	if self.store == "archive" then
+		local jid = username.."@"..host;
+		local data = datamanager.load(username, host, "archiving");
+		local iter = ipairs(data["logs"]);
+		local i = 0;
+		local message;
+		return function()
+			i, message = iter(data["logs"], i);
+			if not message then
+				return;
+			end
+
+			local with;
+			local bare_to = message["bare_to"];
+			local bare_from = message["bare_from"];
+			if jid == bare_to then
+				-- received
+				with = bare_from;
+			else
+				-- sent
+				with = bare_to;
+			end
+
+			local to = message["to"];
+			local from = message["from"];
+			local id = message["id"];
+			local type = message["type"];
+
+			local key = message["uid"];
+			local when = message["timestamp"];
+			local item = st.message({ to = to, from = from, id = id, type = type }, message["body"]);
+			if message["tags"] then
+				for _, tag in ipairs(message["tags"]) do
+					setmetatable(tag, st.stanza_mt);
+					item:add_direct_child(tag);
+				end
+			end
+			if message["marker"] then
+				item:tag(message["marker"], { xmlns = "urn:xmpp:chat-markers:0", id = message["marker_id"] });
+			end
+			return key, item, when, with;
+		end;
+
+	elseif self.store:sub(1, 4) == "pep_" then
+		local node = self.store:sub(5);
+
+		local pep_base_path = datamanager.getpath(username, host, "pep"):sub(1, -5);
+		local path = ("%s/%s.dat"):format(pep_base_path, encode(node));
+		local data = envloadfile(path, {});
+		if not data then
+			log("debug", "Failed to load metronome storage");
+			return {};
+		end
+		local success, data = pcall(data);
+		if not success then
+			log("error", "Unable to load metronome storage");
+			return nil, "Error reading storage";
+		end
+
+		local iter = pairs(data["data"]);
+		local key = nil;
+		local payload;
+		return function()
+			key, payload = iter(data["data"], key);
+			if not key then
+				return;
+			end
+			local item = st.deserialize(payload[1]);
+			local with = data["data_author"][key];
+			return key, item, when, with;
+		end;
+	elseif self.store == "offline" then
+		-- This is mostly copy/pasted from mod_storage_internal.
+		local list, err = datamanager.list_open(username, host, self.store);
+		if not list then
+			if err then
+				return list, err;
+			end
+			return function()
+			end;
+		end
+
+		local i = 0;
+		local iter = function()
+			i = i + 1;
+			return list[i];
+		end
+
+		return function()
+			local item = iter();
+			print(item)
+			if item == nil then
+				if list.close then
+					list:close();
+				end
+				return
+			end
+			print(0)
+			local key = id();
+			local when = item.attr and datetime.parse(item.attr.stamp);
+			local with = "";
+			print(1)
+			item.key, item.when, item.with = nil, nil, nil;
+			item.attr.stamp = nil;
+			-- COMPAT Stored data may still contain legacy XEP-0091 timestamp
+			item.attr.stamp_legacy = nil;
+			item = st.deserialize(item);
+			print(key, when, with, item)
+			return key, item, when, with;
+		end
+	else
+		return nil, "unsupported-store";
+	end
+end
+
+function archive:get(username, wanted_key)
+	local iter, err = self:find(username, { key = wanted_key })
+	if not iter then return iter, err; end
+	for key, stanza, when, with in iter do
+		if key == wanted_key then
+			return stanza, when, with;
+		end
+	end
+	return nil, "item-not-found";
+end
+
+function archive:set(username, key, new_value, new_when, new_with)
+	return nil, "unsupported-store";
+end
+
+function archive:dates(username)
+	return nil, "unsupported-store";
+end
+
+function archive:summary(username, query)
+	return nil, "unsupported-store";
+end
+
+function archive:users()
+	if self.store == "archive" then
+		return datamanager.users(host, "archiving");
+	elseif self.store:sub(1, 4) == "pep_" then
+		local wanted_node = self.store:sub(5);
+		local iter, tbl = datamanager.users(host, "pep");
+		return function()
+			while true do
+				local user = iter(tbl);
+				if not user then
+					return;
+				end
+				local data = datamanager.load(user, host, "pep");
+				for _, node in ipairs(data["nodes"]) do
+					if node == wanted_node then
+						return user;
+					end
+				end
+			end
+		end;
+	elseif self.store == "offline" then
+		return datamanager.users(host, self.store, "list");
+	else
+		return nil, "unsupported-store";
+	end
+end
+
+function archive:trim(username, to_when)
+	return nil, "unsupported-store";
+end
+
+function archive:delete(username, query)
+	return nil, "unsupported-store";
+end
+
+module:provides("storage", driver);