diff --git a/prosody-modules/mod_pubsub_peertubelivechat/README.md b/prosody-modules/mod_pubsub_peertubelivechat/README.md new file mode 100644 index 00000000..8d4dc723 --- /dev/null +++ b/prosody-modules/mod_pubsub_peertubelivechat/README.md @@ -0,0 +1,19 @@ +# mod_pubsub_peertubelivechat + +This module is a custom module that provide some pubsub services associated to a MUC room. +This module is entended to be used in the peertube-plugin-livechat project. + +For each MUC room, there will be an associated pubsub node. +This node in only accessible by the ROOM moderators. + +This node can contains various objects: + +* task lists +* tasks +* ... (more to come) + +These objects are meant te be shared between moderators. + +This module is part of peertube-plugin-livechat, and is under the same LICENSE. + +The module code is inspired by mod_pep in Prosody source code (MIT/X11 licensed). diff --git a/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua b/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua new file mode 100644 index 00000000..90df954d --- /dev/null +++ b/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua @@ -0,0 +1,288 @@ +-- This module create sort of a MEP equivalent to PEP, but for MUC chatrooms. +-- This idea is described in https://xmpp.org/extensions/xep-0316.html +-- but here there are some differences (the node can only be subscribed by room moderators, ...) + +-- Note: all room moderators will have 'publisher' access: +-- so they can't modify configuration, affiliations or subscriptions. +-- There will be no owner. FIXME: is this ok? will prosody accept? (the XEP-0060 says that there must be an owner). + +local pubsub = require "util.pubsub"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_join = require "util.jid".join; +local cache = require "util.cache"; + +local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; +local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; +local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner"; + +local lib_pubsub = module:require "pubsub"; + +local mod_muc = module:depends"muc"; +local get_room_from_jid = mod_muc.get_room_from_jid; + +local muc_util = module:require "muc/util"; +local valid_roles = muc_util.valid_roles; + +-- room_jid => object passed to module:add_items() +local mep_service_items = {}; + +-- Size of caches with full pubsub service objects +-- We will have one service per MUC room. +local service_cache_size = module:get_option_number("livechat_mep_service_cache_size", 1000); + +-- room_jid => util.pubsub service object +local services = cache.new(service_cache_size, function (room_jid, _) + -- when service is evicted from cache, we must remove the associated item. + local item = mep_service_items[room_jid]; + mep_service_items[room_jid] = nil; + if item then + module:remove_item("livechat-mep-service", item); + end +end):table(); + +-- size of caches with smaller objects +local info_cache_size = module:get_option_number("livechat_mep_info_cache_size", 10000); + +-- room_jid -> recipient -> set of nodes +local recipients = cache.new(info_cache_size):table(); + + +local host = module.host; + +-- store for nodes configuration +local node_config = module:open_store("livechat-mep", "map"); +-- store for nodes content +local known_nodes = module:open_store("livechat-mep"); + +-- maximum number of items in a node: +local max_max_items = module:get_option_number("livechat_mep_max_items", 256); + +local function tonumber_max_items(n) + if n == "max" then + return max_max_items; + end + return tonumber(n); +end + +function is_item_stanza(item) + return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item" and #item.tags == 1; +end + +-- check_node_config: if someone try to change the node configuration, checks the values. +-- TODO: is this necessary? we should not allow config modification. +function check_node_config(node, actor, new_config) + if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then + return false; + end + if new_config["access_model"] ~= "whitelist" then + return false; + end + return true; +end + +-- get the store for a given room nodes. +local function nodestore(room_jid) + -- luacheck: ignore 212/self + local store = {}; + function store:get(node) + local data, err = node_config:get(room_jid, node) + -- data looks like: + -- data = { + -- name = node; + -- config = {}; + -- subscribers = {}; + -- affiliations = {}; + -- }; + return data, err; + end + function store:set(node, data) + return node_config:set(room_jid, node, data); + end + function store:users() -- iterator over all available keys (see https://prosody.im/doc/developers/moduleapi) + return pairs(known_nodes:get(room_jid) or {}); + end + return store; +end + +local function simple_itemstore(room_jid) + local driver = storagemanager.get_driver(module.host, "livechat_mep_data"); + return function (config, node) + local max_items = tonumber_max_items(config["max_items"]); + module:log("debug", "Creating new persistent item store for room %s, node %q", room_jid, node); + local archive = driver:open("livechat_mep_"..node, "archive"); + return lib_pubsub.archive_itemstore(archive, max_items, room_jid, node, false); + end +end + +local function get_broadcaster(room_jid) + local room_bare = jid_join(room_jid, host); + local function simple_broadcast(kind, node, jids, item, _, node_obj) + if node_obj then + if node_obj.config["notify_"..kind] == false then + return; + end + end + if kind == "retract" then + kind = "items"; -- XEP-0060 signals retraction in an container + end + if item then + item = st.clone(item); + item.attr.xmlns = nil; -- Clear the pubsub namespace + if kind == "items" then + if node_obj and node_obj.config.include_payload == false then + item:maptags(function () return nil; end); + end + end + end + + local id = new_id(); + local message = st.message({ from = room_bare, type = "headline", id = id }) + :tag("event", { xmlns = xmlns_pubsub_event }) + :tag(kind, { node = node }); + + if item then + message:add_child(item); + end + + for jid in pairs(jids) do + module:log("debug", "Sending notification to %s from %s for node %s", jid, room_bare, node); + message.attr.to = jid; + module:send(message); + end + end + return simple_broadcast; +end + +local function get_subscriber_filter(room_jid) + return function (jids, node) + local broadcast_to = {}; + for jid, opts in pairs(jids) do + broadcast_to[jid] = opts; + end + + local service_recipients = recipients[room_jid]; + if service_recipients then + local service = services[room_jid]; + for recipient, nodes in pairs(service_recipients) do + if nodes:contains(node) and service:may(node, recipient, "subscribe") then + broadcast_to[recipient] = true; + end + end + end + return broadcast_to; + end +end + +-- Read-only service with no nodes where nobody is allowed anything to act as a +-- fallback for interactions with non-existent rooms +local noroom_service = pubsub.new({ + node_defaults = { + ["max_items"] = 1; + ["persist_items"] = false; + ["access_model"] = "whitelist"; + ["send_last_published_item"] = "never"; + }; + autocreate_on_publish = false; + autocreate_on_subscribe = false; + get_affiliation = function () + return "outcast"; + end; +}); + +function get_mep_service(room_jid) + local room_bare = jid_join(room_jid, host); + local service = services[room_jid]; + if service then + return service; + end + local room = get_room_from_jid(room_jid); + if not room then + return noroom_service; + end + module:log("debug", "Creating pubsub service for room %q", room_jid); + service = pubsub.new({ + livechat_mep_room_jid = room_jid; + node_defaults = { + ["max_items"] = max_max_items; + ["persist_items"] = true; + ["access_model"] = "whitelist"; + ["send_last_published_item"] = "never"; -- never send last item, clients will require all items at connection + }; + max_items = max_max_items; + + autocreate_on_publish = false; + autocreate_on_subscribe = false; + + nodestore = nodestore(room_jid); + itemstore = simple_itemstore(room_jid); + broadcaster = get_broadcaster(room_jid); + subscriber_filter = get_subscriber_filter(room_jid); + itemcheck = is_item_stanza; + get_affiliation = function (jid) + -- First checking if there is an affiliation on the room for this JID. + local actor_jid = jid_bare(jid); + local room_affiliation = room:get_affiliation(actor_jid); + -- if user is banned, don't go any further + if (room_affiliation == "outcast") then + return "outcast"; + end + if (room_affiliation == "owner" or room_affiliation == "admin") then + return "publisher"; -- always publisher! (see notes at the beginning of this file) + end + + -- No permanent room affiliation... Checking role (for users currently connected to the room) + local actor_nick = room:get_occupant_jid(jid); + if (actor_nick ~= nil) then + local role = room:get_role(actor_nick); + if valid_roles[role or "none"] >= valid_roles.moderator then + return "publisher"; -- always publisher! (see notes at the beginning of this file) + end + end + + -- no access! + return "outcast"; + end; + + jid = room_bare; + normalize_jid = jid_bare; + + check_node_config = check_node_config; + }); + services[room_jid] = service; + local item = { service = service, jid = room_bare } + mep_service_items[room_jid] = item; + module:add_item("livechat-mep-service", item); + return service; +end + +function handle_pubsub_iq(event) + local origin, stanza = event.origin, event.stanza; + local service_name = origin.username; + if stanza.attr.to ~= nil then + service_name = jid_split(stanza.attr.to); + end + local service = get_pep_service(service_name); + + return lib_pubsub.handle_pubsub_iq(event, service) +end + +module:hook("iq/bare/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); +module:hook("iq/bare/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); -- FIXME: should not be necessary, as we don't have owners. + +-- Destroying the node when the room is destroyed +-- FIXME: really? as the room will be automatically recreated in some cases... +module:hook("muc-room-destroyed", function(event) + local room = event.room; + local room_jid = room.jid; + local service = services[room_jid]; + if not service then return end + + for node in pairs(service.nodes) do service:delete(node, true); end + + local item = mep_service_items[room_jid]; + mep_service_items[room_jid] = nil; + if item then module:remove_item("livechat-mep-service", item); end + + recipients[room_jid] = nil; +end); diff --git a/server/lib/prosody/config/content.ts b/server/lib/prosody/config/content.ts index ec4c5d09..fac010f9 100644 --- a/server/lib/prosody/config/content.ts +++ b/server/lib/prosody/config/content.ts @@ -208,6 +208,7 @@ class ProsodyConfigContent { this.muc.set('muc_room_default_history_length', 20) this.muc.add('modules_enabled', 'muc_slow_mode') + this.muc.add('modules_enabled', 'pubsub_peertubelivechat') this.muc.add('slow_mode_duration_form_position', 120) }