Skip to content

Commit

Permalink
OF-2954: New feature: Spam Reporting
Browse files Browse the repository at this point in the history
This commit provides a basic implementation of XEP-0377: Spam Reporting

The changes include:
- persistent storage of spam reports
- an event listening mechanism
- an optional notification of admins
  • Loading branch information
guusdk committed Jan 14, 2025
1 parent 9ff9b79 commit abbd110
Show file tree
Hide file tree
Showing 14 changed files with 901 additions and 31 deletions.
12 changes: 11 additions & 1 deletion distribution/src/database/openfire_hsqldb.sql
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,16 @@ CREATE TABLE ofPubsubDefaultConf (
CONSTRAINT ofPubsubDefaultConf_pk PRIMARY KEY (serviceID, leaf)
);

CREATE TABLE ofSpamReport (
reporter VARCHAR(1024) NOT NULL,
reported VARCHAR(1024) NOT NULL,
reason VARCHAR(255) NOT NULL,
created BIGINT NOT NULL,
raw LONGVARCHAR NOT NULL
);
CREATE INDEX ofSpamReport_created_reporter_id ON ofSpamReport (created, reporter);
CREATE INDEX ofSpamReport_created_reported_id ON ofSpamReport (created, reported);

// Finally, insert default table values.

INSERT INTO ofID (idType, id) VALUES (18, 1);
Expand All @@ -389,7 +399,7 @@ INSERT INTO ofID (idType, id) VALUES (23, 1);
INSERT INTO ofID (idType, id) VALUES (26, 2);
INSERT INTO ofID (idType, id) VALUES (27, 1);

INSERT INTO ofVersion (name, version) VALUES ('openfire', 36);
INSERT INTO ofVersion (name, version) VALUES ('openfire', 37);

// Entry for admin user
INSERT INTO ofUser (username, plainPassword, name, email, creationDate, modificationDate)
Expand Down
7 changes: 7 additions & 0 deletions documentation/openfire.doap
Original file line number Diff line number Diff line change
Expand Up @@ -458,5 +458,12 @@
<xmpp:note xml:lang='en'>Implements most features, except for much of the Destination Address Selection as defined in RFC 6724, Section 6.</xmpp:note>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0377.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.3.1</xmpp:version>
</xmpp:SupportedXep>
</implements>
</Project>
</rdf:RDF>
2 changes: 2 additions & 0 deletions documentation/protocol-support.html
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,8 @@ <h2>List of other XEPs Supported</h2>
<td><a href="https://www.xmpp.org/extensions/xep-0289.html">XEP-0289</a>: Federated MUC for Constrained Environments</td>
</tr><tr>
<td><a href="https://www.xmpp.org/extensions/xep-0321.html">XEP-0321</a>: Remote Roster Management [<a href="#fn15">15</a>]</td>
</tr><tr>
<td><a href="https://www.xmpp.org/extensions/xep-0337.html">XEP-0337</a>: Spam Reporting</td>
</tr><tr>
<td><a href="https://www.xmpp.org/extensions/xep-0359.html">XEP-0359</a>: Unique and Stable Stanza IDs</td>
</tr><tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public class SchemaManager {
/**
* Current Openfire database schema version.
*/
private static final int DATABASE_VERSION = 36;
private static final int DATABASE_VERSION = 37;

/**
* Checks the Openfire database schema to ensure that it's installed and up to date.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import org.jivesoftware.openfire.privacy.PrivacyListManager;
import org.jivesoftware.openfire.privacy.PrivacyListProvider;
import org.jivesoftware.openfire.session.ClientSession;
import org.jivesoftware.openfire.spamreporting.SpamReport;
import org.jivesoftware.openfire.spamreporting.SpamReportManager;
import org.jivesoftware.openfire.user.User;
import org.jivesoftware.openfire.user.UserManager;
import org.jivesoftware.openfire.user.UserNotFoundException;
Expand All @@ -39,6 +41,7 @@
import org.xmpp.packet.PacketError;
import org.xmpp.packet.Presence;

import java.time.Instant;
import java.util.*;

/**
Expand Down Expand Up @@ -69,7 +72,7 @@ public IQHandlerInfo getInfo()
@Override
public Iterator<String> getFeatures()
{
return Collections.singletonList( NAMESPACE ).iterator();
return List.of(NAMESPACE, SpamReport.NAMESPACE).iterator();
}

@Override
Expand Down Expand Up @@ -130,13 +133,18 @@ else if ( iq.getType().equals( IQ.Type.set ) && "block".equals( iq.getChildEleme
}

final List<JID> toBlocks = new ArrayList<>();
final Set<SpamReport> reports = new HashSet<>();
final Instant now = Instant.now();
for ( final Element item : items )
{
toBlocks.add( new JID( item.attributeValue( "jid" ) ) );
final JID offender = new JID(item.attributeValue( "jid" ));
reports.addAll(SpamReport.allFromChildren(now, requester.asBareJID(), offender, item));
toBlocks.add(offender);
}

addToBlockList( user, toBlocks );
pushBlocklistUpdates( user, toBlocks );
SpamReportManager.getInstance().process(reports);

return IQ.createResultIQ( iq );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.jivesoftware.openfire.handler.IQPingHandler;
import org.jivesoftware.openfire.muc.*;
import org.jivesoftware.openfire.muc.cluster.SyncLocalOccupantsAndSendJoinPresenceTask;
import org.jivesoftware.openfire.stanzaid.StanzaID;
import org.jivesoftware.openfire.stanzaid.StanzaIDUtil;
import org.jivesoftware.openfire.user.UserAlreadyExistsException;
import org.jivesoftware.openfire.user.UserManager;
Expand Down Expand Up @@ -2992,7 +2993,7 @@ else if (name != null && node == null) {
if ( IQMUCvCardHandler.PROPERTY_ENABLED.getValue() ) {
features.add( IQMUCvCardHandler.NAMESPACE );
}
features.add( "urn:xmpp:sid:0" );
features.add(StanzaID.NAMESPACE);
}
}
return features.iterator();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* Copyright (C) 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.
* 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.spamreporting;

import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.DocumentHelper;
import org.jivesoftware.database.DbConnectionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.packet.JID;

import javax.annotation.Nonnull;
import java.sql.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

public class DefaultSpamReportProvider implements SpamReportProvider
{
private static final Logger Log = LoggerFactory.getLogger(DefaultSpamReportProvider.class);

private static final String STORE_REPORT = "INSERT INTO ofSpamReport (reporter, reported, reason, created, raw) VALUES (?,?,?,?,?)";
private static final String GET_REPORTS_SINCE = "SELECT (reporter, reported, created, raw) FROM ofSpamReport WHERE created >= ? ORDER BY created ASC, reporter, reported, reason";
private static final String GET_REPORTS_BY = "SELECT (reporter, reported, created, raw) FROM ofSpamReport WHERE reporter = ? ORDER BY created ASC, reported, reason";
private static final String GET_REPORTS_ABOUT = "SELECT (reporter, reported, created, raw) FROM ofSpamReport WHERE reported = ? ORDER BY created ASC, reporter, reason";

@Override
public void store(@Nonnull final SpamReport spamReport)
{
Log.trace("Storing spam report: {}", spamReport);

Connection con = null;
PreparedStatement pstmt = null;
try {
con = DbConnectionManager.getConnection();
pstmt = con.prepareStatement(STORE_REPORT);
pstmt.setString(1, spamReport.getReportingAddress().toString());
pstmt.setString(2, spamReport.getReportedAddress().toString());
pstmt.setString(3, spamReport.getReason());
pstmt.setLong(4, spamReport.getTimestamp().toEpochMilli());
DbConnectionManager.setLargeTextField(pstmt, 5, spamReport.getReportElement().asXML());

pstmt.executeUpdate();
} catch (SQLException e) {
Log.error("A database error prevented successful storage of a spam report: {}", spamReport, e);
} finally {
DbConnectionManager.closeConnection(pstmt, con);
}
}

@Nonnull
@Override
public List<SpamReport> getSpamReportsSince(@Nonnull final Instant created)
{
Log.trace("Retrieving spam reports since {}", created);

Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// This retrieves _all_ table content, and operates on the entire result set. For large data sets, scalability
// issues are mitigated by depending on auto-commit being disabled (for postgres). Getting a 'transaction'
// connection will ensure this (if supported). MSSQL differentiates between client-cursored and
// server-cursored result sets. For server-cursored result sets, the fetch buffer and scroll window are the
// same size (as opposed to fetch buffer containing all the rows). To hint that a server-cursored result set
// is desired, it should be configured to be 'forward only' as well as 'read only'.
con = DbConnectionManager.getTransactionConnection();
pstmt = con.prepareStatement(GET_REPORTS_SINCE, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);

pstmt.setLong(1, created.toEpochMilli());

pstmt.setFetchSize(250);
pstmt.setFetchDirection(ResultSet.FETCH_FORWARD);

rs = pstmt.executeQuery();

return parse(rs);
} catch (SQLException e) {
Log.error("A database error prevented successful retrieval of a spam reports (since: {})", created, e);
} finally {
DbConnectionManager.closeConnection(rs, pstmt, con);
}
return Collections.emptyList();
}

@Nonnull
@Override
public List<SpamReport> getSpamReportsByReporter(@Nonnull JID reporter)
{
Log.trace("Retrieving spam reports by {}", reporter);

Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// This retrieves _all_ table content, and operates on the entire result set. For large data sets, scalability
// issues are mitigated by depending on auto-commit being disabled (for postgres). Getting a 'transaction'
// connection will ensure this (if supported). MSSQL differentiates between client-cursored and
// server-cursored result sets. For server-cursored result sets, the fetch buffer and scroll window are the
// same size (as opposed to fetch buffer containing all the rows). To hint that a server-cursored result set
// is desired, it should be configured to be 'forward only' as well as 'read only'.
con = DbConnectionManager.getTransactionConnection();
pstmt = con.prepareStatement(GET_REPORTS_BY, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);

pstmt.setString(1, reporter.toString());

pstmt.setFetchSize(250);
pstmt.setFetchDirection(ResultSet.FETCH_FORWARD);

rs = pstmt.executeQuery();

return parse(rs);
} catch (SQLException e) {
Log.error("A database error prevented successful retrieval of a spam reports (by: {})", reporter, e);
} finally {
DbConnectionManager.closeConnection(rs, pstmt, con);
}
return Collections.emptyList();
}

@Nonnull
@Override
public List<SpamReport> getSpamReportsByReported(@Nonnull JID reported)
{
Log.trace("Retrieving spam reports about {}", reported);

Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// This retrieves _all_ table content, and operates on the entire result set. For large data sets, scalability
// issues are mitigated by depending on auto-commit being disabled (for postgres). Getting a 'transaction'
// connection will ensure this (if supported). MSSQL differentiates between client-cursored and
// server-cursored result sets. For server-cursored result sets, the fetch buffer and scroll window are the
// same size (as opposed to fetch buffer containing all the rows). To hint that a server-cursored result set
// is desired, it should be configured to be 'forward only' as well as 'read only'.
con = DbConnectionManager.getTransactionConnection();
pstmt = con.prepareStatement(GET_REPORTS_ABOUT, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);

pstmt.setString(1, reported.toString());

pstmt.setFetchSize(250);
pstmt.setFetchDirection(ResultSet.FETCH_FORWARD);

rs = pstmt.executeQuery();

return parse(rs);
} catch (SQLException e) {
Log.error("A database error prevented successful retrieval of a spam reports (about: {})", reported, e);
} finally {
DbConnectionManager.closeConnection(rs, pstmt, con);
}
return Collections.emptyList();
}

protected static List<SpamReport> parse(@Nonnull final ResultSet rs) throws SQLException
{
final List<SpamReport> result = new LinkedList<>();

long progress = 0;
Instant lastProgressReport = Instant.now();
while (rs.next())
{
final String reporter = rs.getString("reporter");
final String reported = rs.getString("reported");
final long reportcreated = rs.getLong("created");
final String raw = DbConnectionManager.getLargeTextField(rs, 4);

try {
if (raw == null) {
Log.warn("Unable to parse raw data from the database (record created: {}) as spam report: raw data was missing", reportcreated);
} else {
final Document document = DocumentHelper.parseText(raw);
final SpamReport spamReport = SpamReport.parse(Instant.ofEpochMilli(reportcreated), new JID(reporter), new JID(reported), document.getRootElement());
result.add(spamReport);
}
} catch (DocumentException e) {
Log.warn("Unable to parse raw data from the database (record created: {}) as spam report: {} ", reportcreated, raw, e);
}

// When there are _many_ rows to be processed, log an occasional progress indicator, to let admins know that things are still churning.
++progress;
if (lastProgressReport.isBefore(Instant.now().minus(10, ChronoUnit.SECONDS)) ) {
Log.debug( "... processed {} spam reports so far.", progress);
lastProgressReport = Instant.now();
}
}

return result;
}
}
Loading

0 comments on commit abbd110

Please sign in to comment.