mod_muc_moderation.lua 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. -- mod_muc_moderation
  2. --
  3. -- Copyright (C) 2015-2021 Kim Alvefur
  4. --
  5. -- This file is MIT licensed.
  6. --
  7. -- Implements: XEP-0425: Message Moderation
  8. --
  9. -- Imports
  10. local dt = require "util.datetime";
  11. local id = require "util.id";
  12. local jid = require "util.jid";
  13. local st = require "util.stanza";
  14. -- Plugin dependencies
  15. local mod_muc = module:depends "muc";
  16. local muc_util = module:require "muc/util";
  17. local valid_roles = muc_util.valid_roles;
  18. local muc_log_archive = module:open_store("muc_log", "archive");
  19. if not muc_log_archive.set then
  20. module:log("warn", "Selected archive storage module does not support message replacement, no tombstones will be saved");
  21. end
  22. -- Namespaces
  23. local xmlns_fasten = "urn:xmpp:fasten:0";
  24. local xmlns_moderate = "urn:xmpp:message-moderate:0";
  25. local xmlns_moderate_1 = "urn:xmpp:message-moderate:1";
  26. local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
  27. local xmlns_retract = "urn:xmpp:message-retract:0";
  28. local xmlns_retract_1 = "urn:xmpp:message-retract:1";
  29. -- Discovering support
  30. module:hook("muc-disco#info", function (event)
  31. event.reply:tag("feature", { var = xmlns_moderate }):up();
  32. event.reply:tag("feature", { var = xmlns_moderate_1 }):up();
  33. end);
  34. -- TODO error registry, requires Prosody 0.12+
  35. -- moderate : function (string, string, string, boolean, string) : boolean, enum, enum, string
  36. local function moderate(actor, room_jid, stanza_id, retract, reason)
  37. local room_node = jid.split(room_jid);
  38. local room = mod_muc.get_room_from_jid(room_jid);
  39. -- Permissions is based on role, which is a property of a current occupant,
  40. -- so check if the actor is an occupant, otherwise if they have a reserved
  41. -- nickname that can be used to retrieve the role.
  42. local actor_nick = room:get_occupant_jid(actor);
  43. if not actor_nick then
  44. local reserved_nickname = room:get_affiliation_data(jid.bare(actor), "reserved_nickname");
  45. if reserved_nickname then
  46. actor_nick = room.jid .. "/" .. reserved_nickname;
  47. end
  48. end
  49. -- Retrieve their current role, iff they are in the room, otherwise what they
  50. -- would have based on affiliation.
  51. local affiliation = room:get_affiliation(actor);
  52. local role = room:get_role(actor_nick) or room:get_default_role(affiliation);
  53. if valid_roles[role or "none"] < valid_roles.moderator then
  54. return false, "auth", "forbidden", "You need a role of at least 'moderator'";
  55. end
  56. -- Original stanza to base tombstone on
  57. local original, err;
  58. if muc_log_archive.get then
  59. original, err = muc_log_archive:get(room_node, stanza_id);
  60. else
  61. -- COMPAT missing :get API
  62. err = "item-not-found";
  63. for i, item in muc_log_archive:find(room_node, { key = stanza_id, limit = 1 }) do
  64. if i == stanza_id then
  65. original, err = item, nil;
  66. end
  67. end
  68. end
  69. if not original then
  70. if err == "item-not-found" then
  71. return false, "modify", "item-not-found";
  72. else
  73. return false, "wait", "internal-server-error";
  74. end
  75. end
  76. local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick);
  77. local announcement = st.message({ from = room_jid, type = "groupchat", id = id.medium(), })
  78. :tag("apply-to", { xmlns = xmlns_fasten, id = stanza_id })
  79. :tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
  80. if room.get_occupant_id then
  81. -- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here
  82. announcement:add_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }));
  83. end
  84. if retract then
  85. announcement:tag("retract", { xmlns = xmlns_retract }):up();
  86. end
  87. if reason then
  88. announcement:text_tag("reason", reason);
  89. end
  90. local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id);
  91. if room.get_occupant_id and moderated_occupant_id then
  92. announcement:add_direct_child(moderated_occupant_id);
  93. end
  94. -- XEP 0425 v0.3.0
  95. announcement:reset();
  96. if retract then
  97. announcement:tag("retract", { xmlns = xmlns_retract_1; id = stanza_id })
  98. :tag("moderated", { xmlns = xmlns_moderate_1 })
  99. :tag("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) });
  100. if reason then
  101. announcement:up():up():text_tag("reason", reason);
  102. end
  103. end
  104. local tombstone = nil;
  105. if muc_log_archive.set and retract then
  106. tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id })
  107. :tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
  108. :tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up();
  109. if reason then
  110. tombstone:text_tag("reason", reason);
  111. end
  112. if room.get_occupant_id then
  113. if actor_occupant then
  114. tombstone:add_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }));
  115. end
  116. if moderated_occupant_id then
  117. -- Copy occupant id from moderated message
  118. tombstone:add_direct_child(moderated_occupant_id);
  119. end
  120. end
  121. tombstone:reset();
  122. end
  123. -- fire an event, that can be used to cancel the moderation, or modify stanzas.
  124. local event = {
  125. room = room;
  126. announcement = announcement;
  127. tombstone = tombstone;
  128. stanza_id = stanza_id;
  129. retract = retract;
  130. reason = reason;
  131. actor = actor;
  132. actor_nick = actor_nick;
  133. };
  134. if module:fire_event("muc-moderate-message", event) then
  135. -- TODO: allow to change the error message?
  136. return false, "wait", "internal-server-error";
  137. end
  138. if tombstone then
  139. local was_replaced = muc_log_archive:set(room_node, stanza_id, tombstone);
  140. if not was_replaced then
  141. return false, "wait", "internal-server-error";
  142. end
  143. end
  144. -- Done, tell people about it
  145. module:log("info", "Message with id '%s' in room %s moderated by %s, reason: %s", stanza_id, room_jid, actor, reason);
  146. room:broadcast_message(announcement);
  147. return true;
  148. end
  149. -- Main handling
  150. module:hook("iq-set/bare/" .. xmlns_fasten .. ":apply-to", function (event)
  151. local stanza, origin = event.stanza, event.origin;
  152. local actor = stanza.attr.from;
  153. local room_jid = stanza.attr.to;
  154. -- Collect info we need
  155. local apply_to = stanza.tags[1];
  156. local moderate_tag = apply_to:get_child("moderate", xmlns_moderate);
  157. if not moderate_tag then return end -- some other kind of fastening?
  158. local reason = moderate_tag:get_child_text("reason");
  159. local retract = moderate_tag:get_child("retract", xmlns_retract);
  160. local stanza_id = apply_to.attr.id;
  161. local ok, error_type, error_condition, error_text = moderate(actor, room_jid, stanza_id, retract, reason);
  162. if not ok then
  163. origin.send(st.error_reply(stanza, error_type, error_condition, error_text));
  164. return true;
  165. end
  166. origin.send(st.reply(stanza));
  167. return true;
  168. end);
  169. module:hook("iq-set/bare/" .. xmlns_moderate_1 .. ":moderate", function (event)
  170. local stanza, origin = event.stanza, event.origin;
  171. local actor = stanza.attr.from;
  172. local room_jid = stanza.attr.to;
  173. local moderate_tag = stanza:get_child("moderate", xmlns_moderate_1)
  174. local retract_tag = moderate_tag:get_child("retract", xmlns_retract_1)
  175. if not retract_tag then return end -- other kind of moderation?
  176. local reason = moderate_tag:get_child_text("reason");
  177. local stanza_id = moderate_tag.attr.id
  178. local ok, error_type, error_condition, error_text = moderate(
  179. actor,
  180. room_jid,
  181. stanza_id,
  182. retract_tag,
  183. reason
  184. );
  185. if not ok then
  186. origin.send(st.error_reply(stanza, error_type, error_condition, error_text));
  187. return true;
  188. end
  189. origin.send(st.reply(stanza));
  190. return true;
  191. end);
  192. module:hook("muc-message-is-historic", function (event)
  193. -- Ensure moderation messages are stored
  194. if event.stanza.attr.from == event.room.jid then
  195. return event.stanza:get_child("apply-to", xmlns_fasten);
  196. end
  197. end, 1);