diff --git a/CHANGELOG.md b/CHANGELOG.md index d1782572..b75cdbaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Builtin prosody use a working dir provided by Peertube (needs Peertube >= 3.2.0) * Starting with Peertube 3.2.0, builtin prosody save room history on server. So when a user connects, he can get previously send messages. +* Starting with Peertube 3.2.0, builtin prosody also activate mod_muc_moderation, enabling moderators to moderate messages. NB: unfortunately it requires Prosody>=0.11.8, which is not released yet (ability to change overwrite old messages on internal storages). ### Fixes diff --git a/ROADMAP.md b/ROADMAP.md index e4ab2204..4cb4a143 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ This roadmap is given as an indication. It will be updated as we go along accord [ ] | [ ] | Builtin Prosody | Check with yunohost how to integrate. [ ] | [ ] | Documentation | Rewrite documentation for more clarity. Add screenshots. Separate user and admin documentation. [ ] | [ ] | ConverseJS | UI: make custom templates, for a better UI/UX. Autoshow muc participants depending on the chat window width. -[ ] | [ ] | Builtin Prosody | Allow moderators to delete messages (mod_muc_moderation) +[x] | [ ] | Builtin Prosody | Allow moderators to delete messages (mod_muc_moderation). NB: Prosody dont allow it for now on «internal» storage, will be available in next version (0.11.8?). | Not Released yet [ ] | [ ] | ConverseJS | For anonymous user, automatically log in with a random nickname (and allow to change afterward) [ ] | [x] | JS | Modernise code to use new placeholders provided by Peertube 3.2.0 (with or without backward compatibility) [ ] | [x] | Settings | Replace some checkbox by a select (for the webchat mode). Migrate old checkbox values. diff --git a/prosody-modules/mod_muc_moderation/README.markdown b/prosody-modules/mod_muc_moderation/README.markdown new file mode 100644 index 00000000..ef9e1c3a --- /dev/null +++ b/prosody-modules/mod_muc_moderation/README.markdown @@ -0,0 +1,32 @@ +# Introduction + +This module implements [XEP-0425: Message Moderation]. + +# Usage + +Moderation is done via a supporting client and requires a `moderator` +role in the channel / group chat. + +# Configuration + +Example [MUC component][doc:chatrooms] configuration: + +``` {.lua} +VirtualHost "channels.example.com" "muc" +modules_enabled = { + "muc_mam", + "muc_moderation", +} +``` + +# Compatibility + +- Should work with Prosody 0.11.x and later. +- Tested with trunk rev `52c6dfa04dba`. +- Message tombstones requires a compatible storage module implementing + a new message replacement API. + +## Clients + +- Tested with [Converse.js](https://conversejs.org/) + [v6.0.1](https://github.com/conversejs/converse.js/releases/tag/v6.0.1) diff --git a/prosody-modules/mod_muc_moderation/mod_muc_moderation.lua b/prosody-modules/mod_muc_moderation/mod_muc_moderation.lua new file mode 100644 index 00000000..41a57808 --- /dev/null +++ b/prosody-modules/mod_muc_moderation/mod_muc_moderation.lua @@ -0,0 +1,123 @@ +-- mod_muc_moderation +-- +-- Copyright (C) 2015-2020 Kim Alvefur +-- +-- This file is MIT licensed. +-- +-- Implements: XEP-0425: Message Moderation +-- +-- Imports +local dt = require "util.datetime"; +local id = require "util.id"; +local jid = require "util.jid"; +local st = require "util.stanza"; + +-- Plugin dependencies +local mod_muc = module:depends "muc"; + +local muc_util = module:require "muc/util"; +local valid_roles = muc_util.valid_roles; + +local muc_log_archive = module:open_store("muc_log", "archive"); + +if not muc_log_archive.set then + module:log("warn", "Selected archive storage module does not support message replacement, no tombstones will be saved"); +end + +-- Namespaces +local xmlns_fasten = "urn:xmpp:fasten:0"; +local xmlns_moderate = "urn:xmpp:message-moderate:0"; +local xmlns_retract = "urn:xmpp:message-retract:0"; + +-- Discovering support +module:hook("muc-disco#info", function (event) + event.reply:tag("feature", { var = xmlns_moderate }):up(); +end); + +-- Main handling +module:hook("iq-set/bare/" .. xmlns_fasten .. ":apply-to", function (event) + local stanza, origin = event.stanza, event.origin; + + -- Collect info we need + local apply_to = stanza.tags[1]; + local moderate_tag = apply_to:get_child("moderate", xmlns_moderate); + if not moderate_tag then return end -- some other kind of fastening? + + local reason = moderate_tag:get_child_text("reason"); + + local room_jid = stanza.attr.to; + local room_node = jid.split(room_jid); + local room = mod_muc.get_room_from_jid(room_jid); + + local stanza_id = apply_to.attr.id; + + -- Permissions + local actor = stanza.attr.from; + local actor_nick = room:get_occupant_jid(actor); + local affiliation = room:get_affiliation(actor); + local role = room:get_role(actor_nick) or room:get_default_role(affiliation); + if valid_roles[role or "none"] < valid_roles.moderator then + origin.send(st.error_reply(stanza, "auth", "forbidden", "You need a role of at least 'moderator'")); + return true; + end + + -- Original stanza to base tombstone on + local original, err; + if muc_log_archive.get then + original, err = muc_log_archive:get(room_node, stanza_id); + else + -- COMPAT missing :get API + err = "item-not-found"; + for i, item in muc_log_archive:find(room_node, { key = stanza_id, limit = 1 }) do + if i == stanza_id then + original, err = item, nil; + end + end + end + if not original then + if err == "item-not-found" then + origin.send(st.error_reply(stanza, "modify", "item-not-found")); + else + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + end + return true; + end + + -- Replacements + local tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id }) + :tag("moderated", { xmlns = xmlns_moderate, by = actor_nick }) + :tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up(); + + local announcement = st.message({ from = room_jid, type = "groupchat", id = id.medium(), }) + :tag("apply-to", { xmlns = xmlns_fasten, id = stanza_id }) + :tag("moderated", { xmlns = xmlns_moderate, by = actor_nick }) + :tag("retract", { xmlns = xmlns_retract }):up(); + + if reason then + tombstone:text_tag("reason", reason); + announcement:text_tag("reason", reason); + end + + if muc_log_archive.set then + -- Tombstone + local was_replaced = muc_log_archive:set(room_node, stanza_id, tombstone); + if not was_replaced then + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + return true; + end + end + + -- Done, tell people about it + module:log("info", "Message with id '%s' in room %s moderated by %s, reason: %s", stanza_id, room_jid, actor, reason); + room:broadcast_message(announcement); + + origin.send(st.reply(stanza)); + return true; +end); + +module:hook("muc-message-is-historic", function (event) + -- Ensure moderation messages are stored + if event.stanza.attr.from == event.room.jid then + return event.stanza:get_child("apply-to", xmlns_fasten); + end +end, 1); diff --git a/server/lib/prosody/config/content.ts b/server/lib/prosody/config/content.ts index f0c32101..7b18fb6c 100644 --- a/server/lib/prosody/config/content.ts +++ b/server/lib/prosody/config/content.ts @@ -120,6 +120,7 @@ class ProsodyConfigContent { this.global.set('pidfile', this.paths.pid) this.global.set('plugin_paths', [this.paths.modules]) this.global.set('data_path', this.paths.data) + this.global.set('default_storage', 'internal') this.global.set('storage', 'internal') this.global.set('modules_enabled', [ @@ -222,6 +223,9 @@ class ProsodyConfigContent { this.muc.set('log_all_rooms', true) this.muc.set('muc_log_expires_after', duration) this.muc.set('muc_log_cleanup_interval', 4 * 60 * 60) + + // We can also use mod_muc_moderation + this.muc.add('modules_enabled', 'muc_moderation') } setLog (level: ProsodyLogLevel, syslog?: ProsodyLogLevel[]): void {