From e7c1870fc299afb18cb0eeea6ce855e9ac9aef4e Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Thu, 2 Jan 2025 21:27:24 +0100 Subject: [PATCH] Implement 'clear room history' event Openfire 5.0.0 introduces a 'clear room history' event. In this commit, the monitoring plugin implements that event, by: - deleting associated database content - removing deleted content from the Lucene indices. Fixes #369 Fixes #370 --- changelog.html | 2 + .../plugin/archive/impl/MucIndexer.java | 36 +++++- .../openfire/archive/ArchiveIndexer.java | 28 ++++- .../openfire/archive/ConversationDAO.java | 103 ++++++++++++++++++ .../openfire/archive/ConversationEvent.java | 19 +++- .../openfire/archive/ConversationManager.java | 20 ++++ .../archive/GroupConversationInterceptor.java | 14 +++ 7 files changed, 216 insertions(+), 6 deletions(-) diff --git a/changelog.html b/changelog.html index 8713f20d1..7943f6f41 100644 --- a/changelog.html +++ b/changelog.html @@ -51,6 +51,8 @@

  • [Issue #401] - Fixes: Update Jersey from 2.35 to 2.45
  • [Issue #398] - Fixes: Missing translation for system property
  • [Issue #392] - Fixes: Compatibility issue with Openfire 5.0.0
  • +
  • [Issue #370] - Add option to delete history on room deletion
  • +
  • [Issue #369] - Add option to clear history for a given MUC
  • [Issue #363] - Fixes SQL Server error: An expression of non-boolean type specified in a context where a condition is expected, near 'RowNum'
  • [Issue #357] - Fixes: Error on admin console after user session expired.
  • [Issue #354] - Allow full-text search / indexing to be disabled
  • diff --git a/src/java/com/reucon/openfire/plugin/archive/impl/MucIndexer.java b/src/java/com/reucon/openfire/plugin/archive/impl/MucIndexer.java index e30a36598..75e9efb8c 100644 --- a/src/java/com/reucon/openfire/plugin/archive/impl/MucIndexer.java +++ b/src/java/com/reucon/openfire/plugin/archive/impl/MucIndexer.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 Jive Software, 2024 Ignite Realtime Foundation. All rights reserved. + * Copyright (C) 2008 Jive Software, 2024-2025 Ignite Realtime Foundation. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ import org.apache.lucene.document.*; import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.Term; import org.jivesoftware.database.DbConnectionManager; import org.jivesoftware.openfire.archive.ConversationManager; import org.jivesoftware.openfire.archive.MonitoringConstants; @@ -36,6 +37,8 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; +import java.util.HashSet; +import java.util.Set; /** * Creates and maintains a Lucene index for messages exchanged in multi-user chat. @@ -55,12 +58,27 @@ public class MucIndexer extends LuceneIndexer private ConversationManager conversationManager; + /** + * A collection of rooms that are to be removed from the index during the next update or rebuild operation. + */ + private final Set roomsPendingDeletion = new HashSet<>(); + public MucIndexer( final TaskEngine taskEngine, final ConversationManager conversationManager ) { super(taskEngine, JiveGlobals.getHomePath().resolve(Path.of(MonitoringConstants.NAME, "mucsearch")), "MUCSEARCH", SCHEMA_VERSION); this.conversationManager = conversationManager; } + /** + * Schedules documents that relate to the provided room for deletion during the next update cycle. + * + * @param roomID Room for which documents are to be removed from the index. + */ + public void scheduleForDeletion(final Long roomID) + { + roomsPendingDeletion.add(roomID); + } + @Override protected Instant doUpdateIndex( final IndexWriter writer, final Instant lastModified ) throws IOException { @@ -148,6 +166,11 @@ private Instant indexMUCMessages( IndexWriter writer, Instant since ) continue; } + // Skip rooms that are going to be deleted anyway. + if (roomsPendingDeletion.contains(roomID)) { + continue; + } + // Index message. final Document document = createDocument(roomID, messageID, sender, logTime, body ); writer.addDocument(document); @@ -164,6 +187,15 @@ private Instant indexMUCMessages( IndexWriter writer, Instant since ) } } Log.debug( "... finished the entire result set. Processed {} messages in total.", progress ); + + if (!since.equals( Instant.EPOCH ) && !roomsPendingDeletion.isEmpty()) { + // In case this is an update instead of a rebuild, older documents may still refer to rooms that are deleted. Remove those. + Log.debug( "... removing documents for {} rooms that are pending deletion.", roomsPendingDeletion.size()); + for (long roomID : roomsPendingDeletion) { + writer.deleteDocuments(new Term("roomID", Long.toString(roomID))); + } + } + roomsPendingDeletion.clear(); } catch (SQLException sqle) { Log.error("An exception occurred while trying to fetch all MUC messages from the database to rebuild the Lucene index.", sqle); @@ -179,7 +211,7 @@ private Instant indexMUCMessages( IndexWriter writer, Instant since ) } /** - * Creates a index document for one particular chat message. + * Creates an index document for one particular chat message. * * @param roomID ID of the MUC room in which the message was exchanged. * @param messageID ID of the message that was exchanged. diff --git a/src/java/org/jivesoftware/openfire/archive/ArchiveIndexer.java b/src/java/org/jivesoftware/openfire/archive/ArchiveIndexer.java index 51551ef6c..c57112352 100644 --- a/src/java/org/jivesoftware/openfire/archive/ArchiveIndexer.java +++ b/src/java/org/jivesoftware/openfire/archive/ArchiveIndexer.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 Jive Software, Ignite Realtime Foundation 2024. All rights reserved. + * Copyright (C) 2008 Jive Software, Ignite Realtime Foundation 2024-2025. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,11 @@ public class ArchiveIndexer extends org.jivesoftware.openfire.index.LuceneIndexe */ public static final int SCHEMA_VERSION = 1; + /** + * A collection of conversations that are to be removed from the index during the next update or rebuild operation. + */ + private final Set conversationsPendingDeletion = new HashSet<>(); + /** * Constructs a new archive indexer. * @@ -79,6 +84,16 @@ public void stop() conversationManager = null; } + /** + * Schedules documents that relate to the provided conversations for deletion during the next update cycle. + * + * @param conversations Conversations for which documents are to be removed from the index. + */ + public void scheduleForDeletion(final Set conversations) + { + conversationsPendingDeletion.addAll(conversations); + } + /** * Updates the index with all new conversation data since the last index update. * @@ -104,6 +119,10 @@ protected Instant doUpdateIndex( final IndexWriter writer, Instant lastModified) // Load meta-data for each conversation that needs updating. final SortedMap externalMetaData = extractMetaData(conversationIDs); + // Add those conversations that are scheduled to be deleted. + conversationIDs.addAll(conversationsPendingDeletion); + conversationsPendingDeletion.clear(); + // Delete any conversations found -- they may have already been indexed, but updated since then. Log.debug("... deleting all to-be-updated conversations from the index."); for (long conversationID : conversationIDs) { @@ -137,6 +156,11 @@ public synchronized Instant doRebuildIndex( final IndexWriter writer ) throws IO } final SortedMap conversationMetadata = findAllConversations(); + + // Correct for conversations that are scheduled to be removed. + conversationsPendingDeletion.forEach(conversationMetadata::remove); + conversationsPendingDeletion.clear(); + Log.debug("... identified {} conversations.", conversationMetadata.size()); if (conversationMetadata.isEmpty()) { return Instant.EPOCH; @@ -183,7 +207,7 @@ private SortedMap findAllConversations() } /** - * Finds converstations that are modified after the specified date. + * Finds conversations that are modified after the specified date. * * @param lastModified The date that marks the beginning of the period for which to return conversations. Cannot be null. * @return A list of conversation identifiers (never null, possibly empty). diff --git a/src/java/org/jivesoftware/openfire/archive/ConversationDAO.java b/src/java/org/jivesoftware/openfire/archive/ConversationDAO.java index 22cf64c69..503bb6bf9 100644 --- a/src/java/org/jivesoftware/openfire/archive/ConversationDAO.java +++ b/src/java/org/jivesoftware/openfire/archive/ConversationDAO.java @@ -56,6 +56,12 @@ public class ConversationDAO { private static final String LOAD_MESSAGES = "SELECT fromJID, fromJIDResource, toJID, toJIDResource, sentDate, body, stanza, isPMforJID FROM ofMessageArchive WHERE conversationID=? " + "ORDER BY sentDate"; + private static final String DELETE_ROOM_MESSAGES = "DELETE FROM ofMessageArchive WHERE conversationID IN (SELECT conversationID FROM ofConversation WHERE roomID=?)"; + private static final String DELETE_ROOM_PARTICIPANTS = "DELETE FROM ofConParticipant WHERE conversationID IN (SELECT conversationID FROM ofConversation WHERE roomID=?)"; + private static final String DELETE_ROOM_CONVERSATIONS = "DELETE FROM ofConversation WHERE roomID=?"; + + private static final String CONVERSATIONS_FOR_ROOM = "SELECT DISTINCT conversationID FROM ofConversation WHERE roomID=?"; + private static final Logger Log = LoggerFactory.getLogger(ConversationDAO.class); /** @@ -239,6 +245,103 @@ public static List getMessages(@Nonnull final Conversation conv return messages; } + /** + * Removes all recorded chat history (messages) that is stored in the database. + * + * @param roomID the numeric ID for the room + */ + public static void deleteRoomMessages(final long roomID) + { + Log.debug("Removing messages for room {}", roomID); + Connection con = null; + PreparedStatement pstmt = null; + try { + con = DbConnectionManager.getConnection(); + pstmt = con.prepareStatement(DELETE_ROOM_MESSAGES); + pstmt.setLong(1, roomID); + pstmt.executeUpdate(); + } catch (SQLException e) { + Log.error("A database error occurred while removing messages for room {}", roomID, e); + } finally { + DbConnectionManager.closeConnection(pstmt, con); + } + } + + /** + * Removes all recorded participants (users) that are stored in the database. + * + * @param roomID the numeric ID for the room + */ + public static void deleteRoomParticipants(final long roomID) + { + Log.debug("Removing participants for room {}", roomID); + Connection con = null; + PreparedStatement pstmt = null; + try { + con = DbConnectionManager.getConnection(); + pstmt = con.prepareStatement(DELETE_ROOM_PARTICIPANTS); + pstmt.setLong(1, roomID); + pstmt.executeUpdate(); + } catch (SQLException e) { + Log.error("A database error occurred while removing participants for room {}", roomID, e); + } finally { + DbConnectionManager.closeConnection(pstmt, con); + } + } + + /** + * Removes all recorded conversations that are stored in the database. + * + * @param roomID the numeric ID for the room + * @return IDs of the removed conversations. + */ + public static Set deleteRoomConversations(final long roomID) + { + Log.debug("Removing conversations for room {}", roomID); + + // JDBC does not support returning data from deleted rows. Instead, this first queries for the data, then deletes it. + final Set result = getConversationIDsForRoom(roomID); + + Connection con = null; + PreparedStatement pstmt = null; + try { + con = DbConnectionManager.getConnection(); + pstmt = con.prepareStatement(DELETE_ROOM_CONVERSATIONS); + pstmt.setLong(1, roomID); + pstmt.executeUpdate(); + } catch (SQLException e) { + Log.error("A database error occurred while removing conversations for room {}", roomID, e); + } finally { + DbConnectionManager.closeConnection(pstmt, con); + } + return result; + } + + public static Set getConversationIDsForRoom(final long roomID) + { + Log.debug("Getting conversation IDs for room {}", roomID); + + final Set result = new HashSet<>(); + + Connection con = null; + PreparedStatement pstmt = null; + ResultSet rs = null; + try { + con = DbConnectionManager.getConnection(); + pstmt = con.prepareStatement(CONVERSATIONS_FOR_ROOM); + pstmt.setLong(1, roomID); + rs = pstmt.executeQuery(); + while (rs.next()) { + result.add(rs.getLong(1)); + } + } catch (SQLException e) { + Log.error("A database error occurred while loading conversation IDs for room {}", roomID, e); + } finally { + DbConnectionManager.closeConnection(rs, pstmt, con); + } + return result; + } + private static Conversation loadFromDb(final long conversationID) throws NotFoundException { Connection con = null; PreparedStatement pstmt = null; diff --git a/src/java/org/jivesoftware/openfire/archive/ConversationEvent.java b/src/java/org/jivesoftware/openfire/archive/ConversationEvent.java index 7997288c6..2afcbff9a 100644 --- a/src/java/org/jivesoftware/openfire/archive/ConversationEvent.java +++ b/src/java/org/jivesoftware/openfire/archive/ConversationEvent.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 Jive Software. All rights reserved. + * Copyright (C) 2008 Jive Software, 2025 Ignite Realtime Foundation. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -76,12 +76,15 @@ public void run(ConversationManager conversationManager) { else if (Type.roomDestroyed == type) { conversationManager.roomConversationEnded(roomJID, date); } + else if (Type.roomClearChatHistory == type) { + conversationManager.clearChatHistory(roomID); + } else if (Type.occupantJoined == type) { conversationManager.joinedGroupConversation(roomJID, user, nickname, date); } else if (Type.occupantLeft == type) { conversationManager.leftGroupConversation(roomJID, user, date); - // If there are no more occupants then consider the group conversation over + // If there are no more occupants than consider the group conversation over MUCRoom mucRoom = XMPPServer.getInstance().getMultiUserChatManager().getMultiUserChatService(roomJID).getChatRoom(roomJID.getNode()); if (mucRoom != null && mucRoom.getOccupantsCount() == 0) { conversationManager.roomConversationEnded(roomJID, date); @@ -116,6 +119,14 @@ public static ConversationEvent roomDestroyed(long roomID, JID roomJID, Date dat return event; } + public static ConversationEvent roomClearChatHistory(long roomID, JID roomJID) { + ConversationEvent event = new ConversationEvent(); + event.type = Type.roomClearChatHistory; + event.roomID = roomID; + event.roomJID = roomJID; + return event; + } + public static ConversationEvent occupantJoined(JID roomJID, JID user, String nickname, Date date) { ConversationEvent event = new ConversationEvent(); event.type = Type.occupantJoined; @@ -164,6 +175,10 @@ private enum Type { * Event triggered when a room was destroyed. */ roomDestroyed, + /** + * Event triggered when historic data for a room is removed. + */ + roomClearChatHistory, /** * Event triggered when a new occupant joins a room. */ diff --git a/src/java/org/jivesoftware/openfire/archive/ConversationManager.java b/src/java/org/jivesoftware/openfire/archive/ConversationManager.java index 788005810..6fd0ffb30 100644 --- a/src/java/org/jivesoftware/openfire/archive/ConversationManager.java +++ b/src/java/org/jivesoftware/openfire/archive/ConversationManager.java @@ -923,6 +923,26 @@ private void removeConversation(String key, Conversation conversation, Date date } } + /** + * Removes all recorded data for a particular chat room, including messages, participants and conversations from the + * database, and associated data from the Lucene indices. + * + * @param roomID the numeric ID for the room + */ + public void clearChatHistory(final long roomID) { + ConversationDAO.deleteRoomMessages(roomID); + ConversationDAO.deleteRoomParticipants(roomID); + final Set deletedConversations = ConversationDAO.deleteRoomConversations(roomID); + + final MonitoringPlugin plugin = MonitoringPlugin.getInstance(); + if (!deletedConversations.isEmpty()) { + plugin.getArchiveIndexer().scheduleForDeletion(deletedConversations); + plugin.getArchiveIndexer().updateIndex(); + } + + plugin.getMucIndexer().scheduleForDeletion(roomID); + } + /** * Returns the group conversation taking place in the specified room or null if none. * diff --git a/src/java/org/jivesoftware/openfire/archive/GroupConversationInterceptor.java b/src/java/org/jivesoftware/openfire/archive/GroupConversationInterceptor.java index 1fc733d6c..b7f9ccd36 100644 --- a/src/java/org/jivesoftware/openfire/archive/GroupConversationInterceptor.java +++ b/src/java/org/jivesoftware/openfire/archive/GroupConversationInterceptor.java @@ -83,6 +83,20 @@ public void roomDestroyed(final long roomID, @Nonnull final JID roomJID) { } } + @Override + public void roomClearChatHistory(final long roomID, @Nonnull final JID roomJID) + { + // Process this event in the senior cluster member or local JVM when not in a cluster + if (ClusterManager.isSeniorClusterMember()) { + conversationManager.clearChatHistory(roomID); + } + else { + ConversationEventsQueue eventsQueue = conversationManager.getConversationEventsQueue(); + eventsQueue.addGroupChatEvent(conversationManager.getRoomConversationKey(roomJID), + ConversationEvent.roomClearChatHistory(roomID, roomJID)); + } + } + @Override public void occupantJoined(JID roomJID, JID user, String nickname) { // Process this event in the senior cluster member or local JVM when not in a cluster