From d88114cc00c96f7ee930c035b0720dd20eeb1a8c Mon Sep 17 00:00:00 2001 From: Octavia Togami Date: Sun, 20 Sep 2026 00:41:19 -0700 Subject: [PATCH] Add some more advanced image anti-spam This triggers earlier than the base attachment spam, and tracks general similarity rather than some easy-to-fudge metrics --- .../discord/module/ImageSpamTracker.java | 161 ++++++++++ .../discord/module/NoMessageSpam.java | 35 ++- .../module/errorhelper/ErrorHelper.java | 7 +- .../discord/util/AttachmentImages.java | 98 ++++++ .../discord/module/ImageSpamTrackerTest.java | 295 ++++++++++++++++++ 5 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/enginehub/discord/module/ImageSpamTracker.java create mode 100644 src/main/java/org/enginehub/discord/util/AttachmentImages.java create mode 100644 src/test/java/org/enginehub/discord/module/ImageSpamTrackerTest.java diff --git a/src/main/java/org/enginehub/discord/module/ImageSpamTracker.java b/src/main/java/org/enginehub/discord/module/ImageSpamTracker.java new file mode 100644 index 0000000..a3806b1 --- /dev/null +++ b/src/main/java/org/enginehub/discord/module/ImageSpamTracker.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) EngineHub and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.enginehub.discord.module; + +import com.google.common.base.Ticker; +import com.google.common.cache.CacheBuilder; +import com.google.common.math.PairedStatsAccumulator; +import com.google.common.math.StatsAccumulator; +import org.jetbrains.annotations.Nullable; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.nio.DoubleBuffer; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/// Tracks images sent by users and detects if a user is sending the same image repeatedly within a short time frame. +final class ImageSpamTracker { + + /// Tracking window for similar images. + private static final Duration WINDOW = Duration.ofMinutes(1); + /// How many messages with similar images must be sent within the window to trigger a spam detection. + private static final int TRIGGER_MESSAGES = 3; + /// The similarity threshold for two images to be considered similar. + private static final double SIMILARITY_THRESHOLD = 0.999; + /// The size of the fingerprint used to compare images. A larger size will be more accurate but slower. + private static final int FINGERPRINT_SIZE = 32; + /// The maximum number of entries to keep in the tracker. + private static final int MAX_ENTRIES = 16 * 1024; + + private record Entry(long userId, long messageId, DoubleBuffer fingerprint) { + } + + private final Set entries; + + ImageSpamTracker(InstantSource clock) { + this.entries = Collections.newSetFromMap( + CacheBuilder.newBuilder() + .expireAfterWrite(WINDOW.toNanos(), TimeUnit.NANOSECONDS) + .maximumSize(MAX_ENTRIES) + .ticker(new Ticker() { + @Override + public long read() { + Instant now = clock.instant(); + return TimeUnit.SECONDS.toNanos(now.getEpochSecond()) + now.getNano(); + } + }) + .build() + .asMap() + ); + } + + synchronized boolean isSpammedImage(long userId, long messageId, BufferedImage image) { + DoubleBuffer fingerprint = fingerprint(image); + if (fingerprint == null) { + return false; + } + + int repeatedImageMessageCount = getRepeatedImageMessageCount(userId, messageId, fingerprint); + // Add the new entry after counting repeated images to avoid counting the new message as a repeat + entries.add(new Entry(userId, messageId, fingerprint)); + + return repeatedImageMessageCount >= TRIGGER_MESSAGES; + } + + private int getRepeatedImageMessageCount(long userId, long messageId, DoubleBuffer fingerprint) { + // Although we're doing set-like things, the number of messages is small enough that a linear search is faster. + var messageIds = new ArrayList(); + messageIds.add(messageId); + for (Entry entry : entries) { + if (entry.userId() != userId) { + continue; + } + // Avoid spending effort flagging messages that have already been flagged + if (messageIds.contains(entry.messageId())) { + continue; + } + if (similarity(fingerprint, entry.fingerprint()) >= SIMILARITY_THRESHOLD) { + messageIds.add(entry.messageId()); + } + } + return messageIds.size(); + } + + private static double similarity(DoubleBuffer a, DoubleBuffer b) { + var stats = new PairedStatsAccumulator(); + for (int i = 0; i < a.limit(); i++) { + stats.add(a.get(i), b.get(i)); + } + return stats.pearsonsCorrelationCoefficient(); + } + + /// Generate a fingerprint for the given image, which can be used with [#similarity(DoubleBuffer, DoubleBuffer)] to + /// determine if two images are similar. Returns `null` if the image is a solid color. + /// + /// @param source the image to fingerprint + /// @return a fingerprint of the image, or `null` if not fingerprintable + private static @Nullable DoubleBuffer fingerprint(BufferedImage source) { + BufferedImage scaled = scaleAndCompositeOverWhite(source); + + double[] cells = new double[FINGERPRINT_SIZE * FINGERPRINT_SIZE]; + var stats = new StatsAccumulator(); + int count = 0; + for (int i : scaled.getRGB(0, 0, FINGERPRINT_SIZE, FINGERPRINT_SIZE, null, 0, FINGERPRINT_SIZE)) { + double luma = getLuma(i); + cells[count++] = luma; + stats.add(luma); + } + return stats.populationVariance() == 0 ? null : DoubleBuffer.wrap(cells).asReadOnlyBuffer(); + } + + private static BufferedImage scaleAndCompositeOverWhite(BufferedImage source) { + var scaled = new BufferedImage(FINGERPRINT_SIZE, FINGERPRINT_SIZE, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = scaled.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + // Set white and fill rather than use bgColor overloads of `drawImage` because it does the bgColor prior to + // scaling, which is very slow. + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, FINGERPRINT_SIZE, FINGERPRINT_SIZE); + graphics.drawImage(source, 0, 0, FINGERPRINT_SIZE, FINGERPRINT_SIZE, null); + graphics.dispose(); + return scaled; + } + + /// {@return the luma of the color} + /// + /// @param rgb the color in RGB format + private static double getLuma(int rgb) { + double red = (rgb >> 16) & 0xFF; + double green = (rgb >> 8) & 0xFF; + double blue = rgb & 0xFF; + return 0.299 * red + 0.587 * green + 0.114 * blue; + } +} diff --git a/src/main/java/org/enginehub/discord/module/NoMessageSpam.java b/src/main/java/org/enginehub/discord/module/NoMessageSpam.java index 84d195e..b9d2f47 100644 --- a/src/main/java/org/enginehub/discord/module/NoMessageSpam.java +++ b/src/main/java/org/enginehub/discord/module/NoMessageSpam.java @@ -27,6 +27,7 @@ import com.google.common.cache.LoadingCache; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; @@ -35,10 +36,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.enginehub.discord.EngineHubBot; +import org.enginehub.discord.util.AttachmentImages; import org.enginehub.discord.util.PermissionRole; import org.enginehub.discord.util.PunishmentUtil; import org.jetbrains.annotations.NotNull; +import java.time.InstantSource; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -61,18 +64,18 @@ private record CacheKey(long userId, int messageHash, int attachmentsHash) { } private final LoadingCache messageCounts = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.MINUTES) .build(CacheLoader.from(() -> new AtomicInteger(0))); + private final ImageSpamTracker imageSpamTracker = new ImageSpamTracker(InstantSource.system()); private final HashSet hasPingedBefore = new HashSet<>(); private volatile Set guildsForPunish; @Override public void onMessageReceived(@Nonnull MessageReceivedEvent event) { - // Don't check for people who are trusted not to spam - if (EngineHubBot.isAuthorised(event.getMember(), PermissionRole.TRUSTED)) { + if (isSpamExempt(event)) { return; } if (event.getChannel() instanceof GuildChannel) { - if (checkForGeneralSpam(event)) { + if (checkForGeneralSpam(event) || checkForImageSpam(event)) { // Skip further checks if they were already banned return; } @@ -81,6 +84,11 @@ public void onMessageReceived(@Nonnull MessageReceivedEvent event) { checkForAtEveryone(event); } + private boolean isSpamExempt(@NotNull MessageReceivedEvent event) { + return event.getAuthor().isBot() || event.getAuthor().isSystem() || event.isWebhookMessage() + || EngineHubBot.isAuthorised(event.getMember(), PermissionRole.TRUSTED); + } + private void checkForAtEveryone(@NotNull MessageReceivedEvent event) { if (!(event.getMessage().getMentions().mentionsEveryone() || event.getMessage().getContentRaw().contains("@everyone"))) { return; @@ -125,6 +133,27 @@ private boolean checkForGeneralSpam(@NotNull MessageReceivedEvent event) { return false; } + private boolean checkForImageSpam(@NotNull MessageReceivedEvent event) { + long userId = event.getAuthor().getIdLong(); + long messageId = event.getMessageIdLong(); + for (Message.Attachment attachment : event.getMessage().getAttachments()) { + if (!attachment.isImage()) { + continue; + } + + try { + if (imageSpamTracker.isSpammedImage(userId, messageId, AttachmentImages.fetch(attachment))) { + PunishmentUtil.banUser(event.getGuild(), event.getAuthor(), "Image spam", true); + return true; + } + } catch (Exception e) { + LOGGER.warn("Failed to check image", e); + } + } + + return false; + } + // TODO: Consider extracting out? private interface Punishment { void enact(Guild guild, Member member, String reason); diff --git a/src/main/java/org/enginehub/discord/module/errorhelper/ErrorHelper.java b/src/main/java/org/enginehub/discord/module/errorhelper/ErrorHelper.java index b132ba5..146187c 100644 --- a/src/main/java/org/enginehub/discord/module/errorhelper/ErrorHelper.java +++ b/src/main/java/org/enginehub/discord/module/errorhelper/ErrorHelper.java @@ -46,13 +46,13 @@ import org.enginehub.discord.module.errorhelper.resolver.MCLogsResolver; import org.enginehub.discord.module.errorhelper.resolver.RawSubdirectoryUrlResolver; import org.enginehub.discord.module.errorhelper.resolver.RawSubdomainUrlResolver; +import org.enginehub.discord.util.AttachmentImages; import org.enginehub.discord.util.HttpUtil; import org.enginehub.discord.util.PasteUtil; import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -69,7 +69,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; -import javax.imageio.ImageIO; public class ErrorHelper extends ListenerAdapter implements Module { @@ -109,8 +108,8 @@ public void scanMessage(Message message, User author, MessageChannel channel) { continue; } - try (InputStream is = attachment.getProxy().download().get()) { - BufferedImage image = ImageIO.read(is); + try { + BufferedImage image = AttachmentImages.fetch(attachment); messageText.append(tesseract.doOCR(image)); if (EngineHubBot.isBotOwner(author) && message.getChannel() instanceof PrivateChannel) { // If it's a bot developer, send OCR debug text. diff --git a/src/main/java/org/enginehub/discord/util/AttachmentImages.java b/src/main/java/org/enginehub/discord/util/AttachmentImages.java new file mode 100644 index 0000000..e2d624e --- /dev/null +++ b/src/main/java/org/enginehub/discord/util/AttachmentImages.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) EngineHub and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.enginehub.discord.util; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; +import net.dv8tion.jda.api.entities.Message; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import javax.imageio.ImageIO; + +/// Fetches images attached to Discord messages and caches them in memory. This is used to avoid repeatedly downloading +/// the same image when various modules process the same message. +/// +/// We might want to consider changing message events to instead carry an object that can hold the reference, so we're +/// not reliant on GC to clean up the cache. +public final class AttachmentImages { + + // We probably don't need more than this many pixels, and it bounds how large an image can be. + private static final int MAX_SIDE = 4096; + + private static final Cache IMAGES = CacheBuilder.newBuilder() + .weakKeys() + .build(); + + public static BufferedImage fetch(Message.Attachment attachment) throws IOException { + try { + return IMAGES.get(attachment, () -> load(attachment)); + } catch (ExecutionException | UncheckedExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException ioException) { + throw ioException; + } + throw new RuntimeException("Unexpected error while loading image: " + attachment.getFileName(), cause); + } + } + + private static BufferedImage load(Message.Attachment attachment) throws IOException { + Request request = new Request.Builder().url(getPngUrl(attachment)).build(); + try (Response response = HttpUtil.getClient().newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException( + "HTTP " + response.code() + " while downloading image: " + attachment.getFileName() + ); + } + BufferedImage image = ImageIO.read(response.body().byteStream()); + if (image == null) { + throw new IOException("Unreadable image: " + attachment.getFileName()); + } + return image; + } + } + + private static HttpUrl getPngUrl(Message.Attachment attachment) { + HttpUrl.Builder url = HttpUrl.get(attachment.getProxyUrl()).newBuilder() + .setQueryParameter("format", "png"); + int largestSide = Math.max(attachment.getWidth(), attachment.getHeight()); + if (largestSide > MAX_SIDE) { + double scale = (double) MAX_SIDE / largestSide; + url.setQueryParameter("width", String.valueOf(scaleSide(attachment.getWidth(), scale))); + url.setQueryParameter("height", String.valueOf(scaleSide(attachment.getHeight(), scale))); + } + return url.build(); + } + + private static int scaleSide(int side, double scale) { + return Math.max(1, (int) Math.round(side * scale)); + } + + private AttachmentImages() { + } +} diff --git a/src/test/java/org/enginehub/discord/module/ImageSpamTrackerTest.java b/src/test/java/org/enginehub/discord/module/ImageSpamTrackerTest.java new file mode 100644 index 0000000..5b4446d --- /dev/null +++ b/src/test/java/org/enginehub/discord/module/ImageSpamTrackerTest.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) EngineHub and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.enginehub.discord.module; + +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RadialGradientPaint; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.util.Random; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.MemoryCacheImageOutputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ImageSpamTrackerTest { + + private static final long USER = 1; + private static final long OTHER_USER = 2; + + private static final class FakeClock implements InstantSource { + private Instant now = Instant.EPOCH; + + @Override + public Instant instant() { + return now; + } + + void advance(Duration duration) { + now = now.plus(duration); + } + } + + private final FakeClock clock = new FakeClock(); + private final ImageSpamTracker tracker = new ImageSpamTracker(clock); + + private static BufferedImage createPhotoLikeImage(long seed, int size) { + var random = new Random(seed); + var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + // draw on a random background color to ensure some difference between images + graphics.setColor(new Color(random.nextInt(0x1000000))); + graphics.fillRect(0, 0, size, size); + // emulate some of the features of a photo using blobs and gradients + for (int i = 0; i < 12; i++) { + float radius = size * (0.15f + random.nextFloat() * 0.35f); + float x = random.nextFloat() * size; + float y = random.nextFloat() * size; + // random color again for the blobs + var color = new Color(random.nextInt(0x1000000)); + var clear = new Color(color.getRed(), color.getGreen(), color.getBlue(), 0); + // paint a fading blob that doesn't have a color gradient + graphics.setPaint(new RadialGradientPaint(x, y, radius, new float[] { 0, 1 }, new Color[] { color, clear })); + graphics.fillRect(0, 0, size, size); + } + graphics.dispose(); + return image; + } + + private static BufferedImage createConsoleLikeImage(long seed) { + var random = new Random(seed); + var image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + // dark background to emulate a console window + graphics.setColor(new Color(0x1E1F22)); + graphics.fillRect(0, 0, 256, 256); + // draw some rows that somewhat emulate text + graphics.setColor(new Color(0xDBDEE1)); + for (int y = 8; y < 248; y += 10) { + if (random.nextInt(4) != 0) { + graphics.fillRect(8, y, 20 + random.nextInt(220), 3); + } + } + graphics.dispose(); + return image; + } + + private static BufferedImage createSolidImage(Color color) { + var image = new BufferedImage(128, 128, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(color); + graphics.fillRect(0, 0, 128, 128); + graphics.dispose(); + return image; + } + + private static BufferedImage createNoisyImage(BufferedImage source, long seed) { + var random = new Random(seed); + var image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < source.getHeight(); y++) { + for (int x = 0; x < source.getWidth(); x++) { + int rgb = source.getRGB(x, y); + int result = 0; + for (int shift = 0; shift <= 16; shift += 8) { + int channel = ((rgb >> shift) & 0xFF) + random.nextInt(7) - 3; + result |= Math.clamp(channel, 0, 255) << shift; + } + image.setRGB(x, y, result); + } + } + return image; + } + + private static BufferedImage scale(BufferedImage source, int size) { + var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.drawImage(source, 0, 0, size, size, null); + graphics.dispose(); + return image; + } + + private static BufferedImage applyJpegCompression(BufferedImage source, float quality) throws IOException { + var bytes = new ByteArrayOutputStream(); + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpeg").next(); + try (var output = new MemoryCacheImageOutputStream(bytes)) { + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality); + writer.setOutput(output); + writer.write(null, new IIOImage(source, null, null), param); + } finally { + writer.dispose(); + } + return ImageIO.read(new ByteArrayInputStream(bytes.toByteArray())); + } + + private static BufferedImage cropped(BufferedImage source) { + int border = source.getWidth() / 20; + return source.getSubimage(border, border, source.getWidth() - border * 2, source.getHeight() - border * 2); + } + + private static BufferedImage withTransparentCenter(BufferedImage source) { + int size = source.getWidth(); + int radius = size / 3; + var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + int dx = x - size / 2; + int dy = y - size / 2; + int alpha = dx * dx + dy * dy < radius * radius ? 0 : 0xFF; + image.setRGB(x, y, (alpha << 24) | (source.getRGB(x, y) & 0xFFFFFF)); + } + } + return image; + } + + private static BufferedImage compositeOnWhite(BufferedImage source) { + var image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.drawImage(source, 0, 0, Color.WHITE, null); + graphics.dispose(); + return image; + } + + @Test + public void testNoisyCopies() { + BufferedImage image = createPhotoLikeImage(1, 64); + + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(USER, 2, createNoisyImage(image, 10))); + assertTrue(tracker.isSpammedImage(USER, 3, createNoisyImage(image, 11))); + } + + @Test + public void testReencodedCopies() throws IOException { + BufferedImage image = createPhotoLikeImage(1, 256); + + assertFalse(tracker.isSpammedImage(USER, 1, scale(image, 64))); + assertFalse(tracker.isSpammedImage(USER, 2, scale(applyJpegCompression(image, 0.4f), 64))); + assertTrue(tracker.isSpammedImage(USER, 3, scale(applyJpegCompression(scale(image, 154), 0.4f), 64))); + } + + @Test + public void testDifferentResolutions() { + BufferedImage image = createPhotoLikeImage(1, 256); + + assertFalse(tracker.isSpammedImage(USER, 1, scale(image, 64))); + assertFalse(tracker.isSpammedImage(USER, 2, scale(image, 64))); + assertTrue(tracker.isSpammedImage(USER, 3, image)); + } + + @Test + public void testTransparentCopies() { + BufferedImage image = withTransparentCenter(createPhotoLikeImage(1, 64)); + + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(USER, 2, compositeOnWhite(image))); + assertTrue(tracker.isSpammedImage(USER, 3, image)); + } + + @Test + public void testDifferentImages() { + assertFalse(tracker.isSpammedImage(USER, 1, createPhotoLikeImage(1, 64))); + assertFalse(tracker.isSpammedImage(USER, 2, createPhotoLikeImage(2, 64))); + assertFalse(tracker.isSpammedImage(USER, 3, createPhotoLikeImage(3, 64))); + } + + @Test + public void testDifferentConsoleScreenshots() { + assertFalse(tracker.isSpammedImage(USER, 1, createConsoleLikeImage(1))); + assertFalse(tracker.isSpammedImage(USER, 2, createConsoleLikeImage(2))); + assertFalse(tracker.isSpammedImage(USER, 3, createConsoleLikeImage(3))); + } + + @Test + public void testOnlyTracksWithinOneMinute() { + BufferedImage image = createPhotoLikeImage(1, 64); + + assertFalse(tracker.isSpammedImage(USER, 1, image)); + clock.advance(Duration.ofSeconds(30)); + assertFalse(tracker.isSpammedImage(USER, 2, image)); + clock.advance(Duration.ofSeconds(40)); + assertFalse(tracker.isSpammedImage(USER, 3, image)); + } + + @Test + public void testMessagesAreCountedNotImages() { + BufferedImage image = createPhotoLikeImage(1, 64); + + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(USER, 2, image)); + assertFalse(tracker.isSpammedImage(USER, 2, image)); + assertTrue(tracker.isSpammedImage(USER, 3, image)); + } + + @Test + public void testImagesAreTrackedPerUser() { + BufferedImage image = createPhotoLikeImage(1, 64); + + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(OTHER_USER, 2, image)); + assertFalse(tracker.isSpammedImage(USER, 3, image)); + assertFalse(tracker.isSpammedImage(OTHER_USER, 4, image)); + } + + /// This is a known limitation of our approach, we do not try to detect cropping. + @Test + public void testCroppedCopyIsNotSimilar() { + BufferedImage image = createPhotoLikeImage(1, 256); + + assertFalse(tracker.isSpammedImage(USER, 1, image)); + assertFalse(tracker.isSpammedImage(USER, 2, image)); + assertFalse(tracker.isSpammedImage(USER, 3, cropped(image))); + } + + /// This is a known limitation of our approach, we cannot detect solid color images as spam, due to the nature of + /// the similarity detection algorithm. + @Test + public void testSolidColor() { + assertFalse(tracker.isSpammedImage(USER, 1, createSolidImage(Color.WHITE))); + assertFalse(tracker.isSpammedImage(USER, 2, createSolidImage(Color.WHITE))); + assertFalse(tracker.isSpammedImage(USER, 3, createSolidImage(Color.WHITE))); + + BufferedImage image = createPhotoLikeImage(1, 64); + assertFalse(tracker.isSpammedImage(USER, 4, image)); + assertFalse(tracker.isSpammedImage(USER, 5, image)); + assertTrue(tracker.isSpammedImage(USER, 6, image)); + } +}