diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/disco/DiscoItemsProvider.java b/xmppserver/src/main/java/org/jivesoftware/openfire/disco/DiscoItemsProvider.java index e3a412a493..e3ef60df6e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/disco/DiscoItemsProvider.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/disco/DiscoItemsProvider.java @@ -16,6 +16,7 @@ package org.jivesoftware.openfire.disco; +import org.dom4j.Element; import org.xmpp.packet.JID; import java.util.Iterator; @@ -46,4 +47,18 @@ public interface DiscoItemsProvider { */ Iterator getItems( String name, String node, JID senderJID ); + /** + * Returns disco items, optionally using the full query element (for XEP-0462 type filtering, XEP-0499 metadata, etc.). + * The default implementation ignores the query element and delegates to {@link #getItems(String, String, JID)}. + * + * @param name the recipient JID's name. + * @param node the requested disco node. + * @param senderJID the XMPPAddress of user that sent the disco items request. + * @param query the full disco#items query element. + * @return an Iterator (of DiscoItem) with the target entity's items or null if none. + */ + default Iterator getItems(String name, String node, JID senderJID, Element query) { + return getItems(name, node, senderJID); + } + } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/disco/IQDiscoItemsHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/disco/IQDiscoItemsHandler.java index ebff336547..42b92c536a 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/disco/IQDiscoItemsHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/disco/IQDiscoItemsHandler.java @@ -159,8 +159,10 @@ public IQ handleIQ(IQ packet) { Element iq = packet.getChildElement(); String node = iq.attributeValue("node"); - // Check if we have items associated with the requested name and node - Iterator itemsItr = itemsProvider.getItems(name, node, packet.getFrom()); + // Check if we have items associated with the requested name and node. Pass a defensive copy of the + // query element: providers may be third-party implementations, and mutating the live element would + // leak into the reply built below (and into anything else holding packet.getChildElement()). + Iterator itemsItr = itemsProvider.getItems(name, node, packet.getFrom(), iq.createCopy()); if (itemsItr != null) { reply.setChildElement(iq.createCopy()); Element queryElement = reply.getChildElement(); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtension.java b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtension.java new file mode 100644 index 0000000000..6dce114d04 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtension.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.muc; + +import org.xmpp.forms.DataForm; + +import java.util.Collection; +import java.util.Set; + +/** + * Allows plugins to contribute additional fields to MUC room configuration forms and room disco#info. + */ +public interface MUCRoomConfigExtension { + + /** + * Adds extension-specific fields to the room owner configuration form. + * + * @param form the configuration form being built. + * @param room the room being configured. + */ + default void contributeConfigForm(DataForm form, MUCRoom room) { + } + + /** + * Populates extension-specific fields in the configuration form with current room values. + * + * @param form the configuration form being populated. + * @param room the room being configured. + */ + default void populateConfigForm(DataForm form, MUCRoom room) { + } + + /** + * Processes extension-specific fields from a submitted configuration form. + * + * @param completedForm the submitted configuration form. + * @param room the room being configured. + */ + default void processConfigSubmit(DataForm completedForm, MUCRoom room) { + } + + /** + * Contributes extension-specific disco#info features for a room. Invoked while building the room's + * disco#info feature list. + * + * @param features mutable collection of disco features to augment. + * @param room the room being described. + */ + default void contributeRoomDiscoFeatures(Collection features, MUCRoom room) { + } + + /** + * Contributes extension-specific disco#info extended data forms for a room. Invoked while building the + * room's extended disco#info (XEP-0128) forms; the set already contains the room's {@code muc#roominfo} + * form, which an extension may augment or supplement with additional forms. + * + * @param forms mutable set of extended data forms to augment. + * @param room the room being described. + */ + default void contributeRoomDiscoForms(Set forms, MUCRoom room) { + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtensionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtensionManager.java new file mode 100644 index 0000000000..5edfaebd46 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtensionManager.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.muc; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmpp.forms.DataForm; + +import java.util.Collection; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * Registry for {@link MUCRoomConfigExtension} implementations contributed by plugins. + */ +public final class MUCRoomConfigExtensionManager { + + private static final Logger Log = LoggerFactory.getLogger(MUCRoomConfigExtensionManager.class); + + private static final MUCRoomConfigExtensionManager INSTANCE = new MUCRoomConfigExtensionManager(); + + private final CopyOnWriteArrayList extensions = new CopyOnWriteArrayList<>(); + + private MUCRoomConfigExtensionManager() { + } + + public static MUCRoomConfigExtensionManager getInstance() { + return INSTANCE; + } + + public void register(MUCRoomConfigExtension extension) { + if (extension != null) { + extensions.addIfAbsent(extension); + } + } + + public void unregister(MUCRoomConfigExtension extension) { + extensions.remove(extension); + } + + public void contributeConfigForm(DataForm form, MUCRoom room) { + forEachExtension("contributing to the config form", room, extension -> extension.contributeConfigForm(form, room)); + } + + public void populateConfigForm(DataForm form, MUCRoom room) { + forEachExtension("populating the config form", room, extension -> extension.populateConfigForm(form, room)); + } + + public void processConfigSubmit(DataForm completedForm, MUCRoom room) { + forEachExtension("processing the config submission", room, extension -> extension.processConfigSubmit(completedForm, room)); + } + + public void contributeRoomDiscoFeatures(Collection features, MUCRoom room) { + forEachExtension("contributing disco#info features", room, extension -> extension.contributeRoomDiscoFeatures(features, room)); + } + + public void contributeRoomDiscoForms(Set forms, MUCRoom room) { + forEachExtension("contributing disco#info forms", room, extension -> extension.contributeRoomDiscoForms(forms, room)); + } + + /** + * Applies an operation to every registered extension, isolating failures: an exception thrown by one + * (third-party) extension is logged and does not prevent the remaining extensions from running, nor + * does it break the room configuration / disco handling that invoked this manager. + */ + private void forEachExtension(final String action, final MUCRoom room, final Consumer operation) { + for (final MUCRoomConfigExtension extension : extensions) { + try { + operation.accept(extension); + } catch (final RuntimeException e) { + Log.warn("MUC room config extension {} threw an exception while {} for room {}", + extension, action, room == null ? null : room.getName(), e); + } + } + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/IQOwnerHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/IQOwnerHandler.java index ac95d9ac51..7f88e67468 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/IQOwnerHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/IQOwnerHandler.java @@ -419,6 +419,8 @@ else if ( passwordChanged ) room.setRegistrationEnabled( parseFirstValueAsBoolean( field, true ) ); } + MUCRoomConfigExtensionManager.getInstance().processConfigSubmit(completedForm, room); + // Update the modification date to reflect the last time when the room's configuration // was modified room.setModificationDate(new Date()); @@ -622,6 +624,8 @@ private Element generateProbeResult(Locale preferredLocale) { LocaleUtils.getLocalizedString("muc.form.conf.owner_roomowners", preferredLocale), Type.jid_multi); + MUCRoomConfigExtensionManager.getInstance().contributeConfigForm(configurationForm, room); + // Add room-specific data values to the form. synchronized (room) { FormField field = configurationForm.getField("muc#roomconfig_roomname"); @@ -741,6 +745,8 @@ private Element generateProbeResult(Locale preferredLocale) { field.addValue(jid.toString()); } } + + MUCRoomConfigExtensionManager.getInstance().populateConfigForm(configurationForm, room); } final Element element = DocumentHelper.createElement(QName.get("query", diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/MultiUserChatServiceImpl.java b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/MultiUserChatServiceImpl.java index 4226690b0d..10c5fa9e16 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/MultiUserChatServiceImpl.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/muc/spi/MultiUserChatServiceImpl.java @@ -3129,6 +3129,7 @@ else if (name != null && node == null) { } features.add( "urn:xmpp:sid:0" ); features.add( "urn:xmpp:occupant-id:0" ); + MUCRoomConfigExtensionManager.getInstance().contributeRoomDiscoFeatures(features, room); } } return features.iterator(); @@ -3197,6 +3198,7 @@ public Set getExtendedInfos(String name, String node, JID senderJID) { final Set dataForms = new HashSet<>(); dataForms.add(dataForm); + MUCRoomConfigExtensionManager.getInstance().contributeRoomDiscoForms(dataForms, room); return dataForms; } } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtensionManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtensionManagerTest.java new file mode 100644 index 0000000000..7e1f1c91e4 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/muc/MUCRoomConfigExtensionManagerTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2026 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.muc; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.xmpp.forms.DataForm; +import org.xmpp.forms.FormField; + +import java.util.HashSet; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class MUCRoomConfigExtensionManagerTest { + + private DataForm processedForm; + private MUCRoom processedRoom; + + @AfterEach + void cleanup() { + MUCRoomConfigExtensionManager.getInstance().unregister(testExtension); + MUCRoomConfigExtensionManager.getInstance().unregister(throwingExtension); + } + + private final MUCRoomConfigExtension throwingExtension = new MUCRoomConfigExtension() { + @Override + public void contributeConfigForm(final DataForm form, final MUCRoom room) { + throw new RuntimeException("boom"); + } + }; + + private final MUCRoomConfigExtension testExtension = new MUCRoomConfigExtension() { + @Override + public void contributeConfigForm(final DataForm form, final MUCRoom room) { + form.addField("test#field", "Test", FormField.Type.text_single); + } + + @Override + public void populateConfigForm(final DataForm form, final MUCRoom room) { + form.getField("test#field").addValue("value"); + } + + @Override + public void processConfigSubmit(final DataForm completedForm, final MUCRoom room) { + processedForm = completedForm; + processedRoom = room; + } + + @Override + public void contributeRoomDiscoFeatures(final java.util.Collection features, final MUCRoom room) { + features.add("urn:xmpp:test:0"); + } + + @Override + public void contributeRoomDiscoForms(final java.util.Set forms, final MUCRoom room) { + final DataForm form = new DataForm(DataForm.Type.result); + form.addField("FORM_TYPE", null, FormField.Type.hidden).addValue("urn:xmpp:test:0"); + forms.add(form); + } + }; + + @Test + void registeredExtensionContributesConfigForm() { + MUCRoomConfigExtensionManager.getInstance().register(testExtension); + final DataForm form = new DataForm(DataForm.Type.form); + final MUCRoom room = mock(MUCRoom.class); + MUCRoomConfigExtensionManager.getInstance().contributeConfigForm(form, room); + assertNotNull(form.getField("test#field")); + + MUCRoomConfigExtensionManager.getInstance().populateConfigForm(form, room); + assertEquals("value", form.getField("test#field").getFirstValue()); + + MUCRoomConfigExtensionManager.getInstance().processConfigSubmit(form, room); + assertSame(form, processedForm); + assertSame(room, processedRoom); + } + + @Test + void registeredExtensionContributesDisco() { + MUCRoomConfigExtensionManager.getInstance().register(testExtension); + final var room = mock(MUCRoom.class); + final var features = new HashSet(); + final var forms = new HashSet(); + MUCRoomConfigExtensionManager.getInstance().contributeRoomDiscoFeatures(features, room); + MUCRoomConfigExtensionManager.getInstance().contributeRoomDiscoForms(forms, room); + assertTrue(features.contains("urn:xmpp:test:0")); + // Assert the specific form this extension contributed, rather than mere non-emptiness, so the test + // does not depend on what else may be registered on the process-wide singleton. + assertTrue(forms.stream().anyMatch(f -> { + final FormField formType = f.getField("FORM_TYPE"); + return formType != null && "urn:xmpp:test:0".equals(formType.getFirstValue()); + })); + } + + @Test + void throwingExtensionIsIsolated() { + // A throwing extension must neither propagate nor prevent other extensions from running. + MUCRoomConfigExtensionManager.getInstance().register(throwingExtension); + MUCRoomConfigExtensionManager.getInstance().register(testExtension); + final DataForm form = new DataForm(DataForm.Type.form); + final MUCRoom room = mock(MUCRoom.class); + assertDoesNotThrow(() -> MUCRoomConfigExtensionManager.getInstance().contributeConfigForm(form, room)); + assertNotNull(form.getField("test#field")); + } +}