diff --git a/src/automod.js b/src/automod.js index 36370931..57362efa 100644 --- a/src/automod.js +++ b/src/automod.js @@ -142,6 +142,9 @@ const extractUrls = rules.extractUrls; // Content check // ══════════════════════════════════════════════════════════════════════ +// The image hosts the GIF picker's services send from (server.js fetchGifs). +const GIF_HOSTS = ['giphy.com', 'tenor.com', 'klipy.com']; + // ctx: { userId, isAdmin, effectiveLevel, createdAt, surface, markdown } // markdown: the text is a chat message, so bare addresses inside code do not count. // surface is one of 'message' | 'edit' | 'dm' | 'profile' | 'channel'. @@ -195,8 +198,13 @@ function checkText(text, ctx = {}) { // Independent of the allowlist and deliberately blunt. The register -> // post-link -> get-banned -> re-register loop is the pattern this exists // to break, and it does not care which domain was used. + // GIFs from the GIF picker are links too, to the GIF services' own image + // hosts. A new member trying the picker was struck, then muted (the wait is + // for links people paste, not for GIFs), so a message whose only links are + // those is let through. The domain rules below still apply to it. + const gifOnly = links.every(l => GIF_HOSTS.some(h => l.host === h || String(l.host).endsWith('.' + h))); const minHours = parseInt(s.automod_link_min_account_hours, 10); - if (Number.isFinite(minHours) && minHours > 0 && ctx.createdAt) { + if (!gifOnly && Number.isFinite(minHours) && minHours > 0 && ctx.createdAt) { const ageMs = Date.now() - new Date(String(ctx.createdAt).replace(' ', 'T') + 'Z').getTime(); if (Number.isFinite(ageMs) && ageMs < minHours * 3600 * 1000) { const hoursLeft = Math.max(1, Math.ceil((minHours * 3600 * 1000 - ageMs) / 3600000)); diff --git a/src/database.js b/src/database.js index 4e3de590..97bce4a6 100644 --- a/src/database.js +++ b/src/database.js @@ -589,7 +589,7 @@ function initDatabase() { const starter = [ 'youtube.com', 'youtu.be', 'twitch.tv', 'x.com', 'twitter.com', 'bsky.app', 'reddit.com', 'github.com', 'gitlab.com', 'stackoverflow.com', 'wikipedia.org', - 'imgur.com', 'giphy.com', 'tenor.com', 'spotify.com', 'soundcloud.com', + 'imgur.com', 'giphy.com', 'tenor.com', 'klipy.com', 'spotify.com', 'soundcloud.com', 'steamcommunity.com', 'steampowered.com', 'last.fm', 'archive.org', 'haven-app.com' ]; @@ -617,6 +617,19 @@ function initDatabase() { } catch (err) { console.error('automod: adding haven-app.com to the allowlist failed:', err.message); } + // KLIPY became a GIF picker choice after the allowlist was seeded, so its + // GIFs were blocked on allowlist servers. Added once, the same way. + try { + const added = db.prepare("SELECT value FROM server_settings WHERE key = 'automod_klipy_v4270'").get(); + if (!added || added.value !== 'true') { + db.prepare( + "INSERT OR IGNORE INTO automod_domains (domain, mode, include_subdomains, note) VALUES ('klipy.com', 'allow', 1, 'Seeded default')" + ).run(); + db.prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('automod_klipy_v4270', 'true')").run(); + } + } catch (err) { + console.error('automod: adding klipy.com to the allowlist failed:', err.message); + } // ── Migration: turn the safe protections on, once (v3.43.0) ── // The seeds above are INSERT OR IGNORE, so they only reach brand-new diff --git a/test/automodGifNewAccount.integration.test.js b/test/automodGifNewAccount.integration.test.js new file mode 100644 index 00000000..1ba979ed --- /dev/null +++ b/test/automodGifNewAccount.integration.test.js @@ -0,0 +1,44 @@ +'use strict'; + +/** + * The new-account link wait is for links people paste. A GIF sent from the + * GIF picker is a link to the GIF service's image host, and a new member who + * tried the picker used to be struck and then muted for it. + * + * node --test test/automodGifNewAccount.integration.test.js + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const { createHarness, wait } = require('./socketServerHarness'); + +const h = createHarness(3505, 'automod-gif'); + +test.before(() => h.start()); +test.after(() => h.stop()); + +test('a new account can send GIFs but not other links', async () => { + const admin = await h.register('admin'); + const bob = await h.register('bob'); + const A = await h.connect(admin.token); + const B = await h.connect(bob.token); + + A.emit('update-server-setting', { key: 'automod_enabled', value: 'true' }); + A.emit('update-server-setting', { key: 'automod_link_min_account_hours', value: '24' }); + const made = h.next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'chat')); + A.emit('create-channel', { name: 'chat' }); + const chat = (await made).find((c) => c.name === 'chat'); + B.emit('join-channel', { code: chat.code }); + await wait(300); + B.emit('enter-channel', { code: chat.code }); + await wait(200); + + const gif = 'https://media4.giphy.com/media/abc123/giphy.gif'; + const got = h.next(B, 'new-message', (d) => d && d.message && d.message.content === gif); + B.emit('send-message', { code: chat.code, content: gif }); + assert.ok(await got, 'the GIF went through'); + + const refused = h.next(B, 'new-message', (d) => d && d.message && d.message.content.includes('example.org'), 1500); + B.emit('send-message', { code: chat.code, content: 'look https://example.org/page' }); + assert.strictEqual(await refused, null, 'a pasted link still waits'); +});