openvibers/openvibe.community Commits

Signed-out pastes that advertise a link are kept unlisted (a secret slug, never listed or indexed); the answer says why (#120)

OP OpenVibers committed 22 days ago · fc4ab1afe6 · 3 files, +110 −2

Changes

ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex e5e965c..df99808 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -19,6 +19,7 @@ const crypto = require('crypto');
const events = require('../events');
const store = require('./store');
const looks = require('./no-looks');
++const spam = require('./spam');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
@@@ -406,7 +407,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
const metadata = serviceMetadata(v, body);
-- const visibility = visibilityOf(body.visibility, !!v.subject);
++ let visibility = visibilityOf(body.visibility, !!v.subject);
++ // Link spam from someone signed out is kept but unlisted (./spam.js); people with an account and AI pastes are
++ // never checked.
++ const spammy = !v.subject && v.origin !== 'ai' && visibility === 'public' ? spam.linkSpam({ title: body.title, content }) : null;
++ if (spammy && spammy.unlist) visibility = 'unlisted';
const row = await store.insertPaste(db, {
slug: await slugFor(v, body, visibility, truthy(body.burn_after_read)),
owner_subject: v.subject || null,
@@@ -423,7 +428,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
...aiFields(v, body),
});
-- return await created(row, { paste: await shapeOne(row, v) });
++ return await created(row, { paste: await shapeOne(row, v), ...(spammy && spammy.unlist ? { unlisted_reason: spammy.reason, notice: spam.NOTICE } : {}) });
},
/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
ddiff --git a/server/pastes/spam.js b/server/pastes/spam.js
nnew file mode 100644
iindex 0000000..b28eaab
---- /dev/null
++++ b/server/pastes/spam.js
@@@ -0,0 +1,30 @@
++'use strict';
++/**
++ * Link spam from signed-out posters. A paste without an account that would be public and looks like an advert for a
++ * link is kept, but unlisted: it gets a secret slug, so anyone with the link still opens it, and it is never listed,
++ * searchable or indexed. A signed-in person's paste and an AI paste are never checked.
++ *
++ * 2026-10-22: the one spam paste so far was 367 characters with one link, recruiting for an "18+ creator opportunity".
++ * So a paste is unlisted when, title and text together, it has
++ * - three or more links, or
++ * - a link and advertising words (PROMO), or
++ * - a link and under SHORT characters of text besides it (a paste that is mostly its link).
++ * Code, logs and notes with a link or two in a longer text pass. Nothing is refused and nothing is hidden from its
++ * poster: the answer says why, and signing in publishes it.
++ */
++const LINK_RE = /\bhttps?:\/\/[^\s<>"')\]]+|\bwww\.[a-z0-9-]+\.[a-z]{2,}[^\s<>"')\]]*/gi;
++const PROMO = /\b(?:18\+|onlyfans|fansly|casino|betting|sportsbook|viagra|cialis|escort|webcam model|cam (?:model|girl)s?|creator opportunit(?:y|ies)|work from home|make money|earn \$?\d|passive income|crypto signals?|forex|payday loan|buy followers|followers? for sale|seo services?|backlinks?|telegram @|whatsapp \+?\d)/i;
++const SHORT = 600;
++const NOTICE = 'Signed-out pastes that advertise a link are unlisted: anyone with this link can open it, but it is not listed or searchable. Sign in to publish a paste.';
++
++/** → { unlist, reason, links } for a would-be public paste from someone signed out. */
++function linkSpam({ title = '', content = '' } = {}) {
++ const text = `${title || ''}\n${content || ''}`;
++ const links = text.match(LINK_RE) || [];
++ if (!links.length) return { unlist: false, reason: null, links: 0 };
++ const rest = links.reduce((s, l) => s.replace(l, ' '), text).replace(/\s+/g, ' ').trim();
++ const reason = links.length >= 3 ? 'links' : PROMO.test(text) ? 'promotion' : rest.length < SHORT ? 'mostly_link' : null;
++ return { unlist: Boolean(reason), reason, links: links.length };
++}
++
++module.exports = { linkSpam, NOTICE, SHORT };
ddiff --git a/test/paste-link-spam.test.js b/test/paste-link-spam.test.js
nnew file mode 100644
iindex 0000000..30e1e25
---- /dev/null
++++ b/test/paste-link-spam.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * Link spam from signed-out posters (server/pastes/spam.js): a would-be public paste with no account that advertises a
++ * link is kept but unlisted (a secret slug, never listed or indexed), and the answer says why; code or notes with a
++ * link in longer text, a signed-in person's paste and an AI paste are never touched.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { linkSpam } = require('../server/pastes/spam');
++
++const LONG_NOTES = `${'The build failed at the link step; the log below shows the missing symbol and the flags used. '.repeat(8)}\nSee https://github.com/example/repo/issues/12 for the same error.`;
++
++(async () => {
++ await check('the rule: three links, a link with advertising words, or a paste that is mostly its link', () => {
++ // 2026-10-22's spam paste, in shape: a short recruitment post with one link.
++ assert.deepStrictEqual(linkSpam({ title: 'Studio creator opportunity | Leeds | 18+', content: 'Paid weekly, apply now: https://example.test/apply' }), { unlist: true, reason: 'promotion', links: 1 });
++ assert.strictEqual(linkSpam({ content: 'a https://a.test b https://b.test c https://c.test' }).reason, 'links');
++ assert.strictEqual(linkSpam({ content: 'look www.example.test' }).reason, 'mostly_link');
++ assert.deepStrictEqual(linkSpam({ content: LONG_NOTES }), { unlist: false, reason: null, links: 1 }, 'notes with one link in a longer text');
++ assert.deepStrictEqual(linkSpam({ content: 'console.log(1)' }), { unlist: false, reason: null, links: 0 });
++ });
++
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 } });
++ const net = t.network;
++ const ana = net.addUser({ network_user_id: 21, username: 'ana', display_name: 'Ana' });
++ const anaJwt = net.sign({ id: 21, subject_id: ana.subject_id, username: 'ana', display_name: 'Ana', role: 'user' });
++ const post = (json, { cookie, token, ip = '198.51.100.7' } = {}) => {
++ const headers = { 'content-type': 'application/json', 'x-forwarded-for': ip };
++ if (token) headers.authorization = `Bearer ${token}`;
++ return t.get('/api/pastes', { method: 'POST', headers, body: JSON.stringify(json), cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ const SPAM = { title: 'Studio creator opportunity | 18+', content: 'Paid weekly, apply now: https://example.test/apply', visibility: 'public' };
++
++ await check('signed out, an advert for a link is kept unlisted with a secret slug, and the answer says why', async () => {
++ const r = await post(SPAM);
++ assert.strictEqual(r.status, 201, r.text);
++ const out = r.json();
++ assert.strictEqual(out.paste.visibility, 'unlisted');
++ assert.strictEqual(out.unlisted_reason, 'promotion');
++ assert.match(out.notice, /Sign in to publish/);
++ assert.ok(out.slug.length >= 16, 'a secret slug, not a guessable adjective-noun one');
++ const list = await t.get('/api/pastes?limit=50');
++ assert.ok(!list.text.includes('creator opportunity'), 'not in the public list');
++ const page = await t.get(`/p/${out.slug}`);
++ assert.strictEqual(page.status, 200, 'still opens by its link');
++ });
++
++ await check('signed out, notes with a link in longer text stay public; signed in, the same advert stays public', async () => {
++ const notes = await post({ title: 'Build log', content: LONG_NOTES });
++ assert.strictEqual(notes.json().paste.visibility, 'public');
++ assert.strictEqual(notes.json().notice, undefined);
++ const mine = await post(SPAM, { cookie: anaJwt });
++ assert.strictEqual(mine.json().paste.visibility, 'public', 'a person with an account is answerable for their paste');
++ });
++
++ await check('a signed-out paste a service forwards is checked; an AI paste never is', async () => {
++ const r = await post({ ...SPAM, content: 'Moment: https://openvibe.live/@x and https://openvibe.video/v/1 and https://openvibe.video/c/2' }, {
++ token: net.signService({ cap: ['community.paste.create'] }), ip: '127.0.0.1',
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ // A service without X-OV-Origin: ai is a signed-out person's paste forwarded (Live's /api/pastes): checked.
++ assert.strictEqual(r.json().paste.visibility, 'unlisted');
++ const ai = await t.get('/api/pastes', {
++ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${net.signService({ cap: ['community.paste.create'] })}`, 'x-ov-origin': 'ai' },
++ body: JSON.stringify({ title: 'From the stream', content: 'Clip: https://openvibe.video/c/2 https://openvibe.video/c/3 https://openvibe.video/c/4' }),
++ });
++ assert.strictEqual(ai.status, 201, ai.text);
++ assert.strictEqual(ai.json().paste.visibility, 'public');
++ });
++
++ await t.close();
++ done();
++})();