@arnaudw38/nodebb-plugin-spam-be-gone 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [1.0.3] - 2026-02-25
6
+
7
+ ### Fixed
8
+ - Fixed Cloudflare Turnstile failure on login retry without page reload.
9
+ - Reset Turnstile widget automatically after a failed login attempt (Turnstile tokens are single-use).
10
+ - Improved login flow reliability when users retry authentication on the same page.
11
+
12
+ ### Improved
13
+ - Added safer Turnstile widget state handling on the login form.
14
+ - Added callbacks handling for expired/timeout token states.
15
+ - Better UX during repeated login attempts without manual refresh.
16
+ - Removed deprecated `stopforumspam` npm dependency and replaced it with direct StopForumSpam API requests using native `fetch` (eliminates `q` / `node-domexception` install warnings).
17
+
18
+ ## [1.0.2] - 2026-02-25
19
+
20
+ ### UI
21
+ - Replaced visible 'Turnstile' label with a more user-friendly label:
22
+ - French: `Vérification de sécurité`
23
+
24
+ ## [1.0.1] - 2026-02-25
25
+
26
+ ### Changed
27
+ - Refactored plugin for NodeBB 4.x compatibility.
28
+ - Removed legacy reCAPTCHA and hCaptcha integrations.
29
+ - Added Cloudflare Turnstile support (register + optional login protection).
30
+ - Simplified package/tooling for a minimal runtime-focused plugin setup.
31
+
32
+ ### Docs
33
+ - Rewrote README in English (Turnstile-only, no images).
34
+ - Added npm-ready/publish-ready package metadata and documentation.
package/library.js CHANGED
@@ -4,7 +4,6 @@ const util = require('util');
4
4
  const https = require('https');
5
5
  const querystring = require('querystring');
6
6
  const Honeypot = require('project-honeypot');
7
- const stopforumspam = require('stopforumspam');
8
7
 
9
8
  const winston = require.main.require('winston');
10
9
  const nconf = require.main.require('nconf');
@@ -100,9 +99,6 @@ Plugin.load = async function (params) {
100
99
  if (!settings.akismetMinReputationHam) {
101
100
  settings.akismetMinReputationHam = 10;
102
101
  }
103
- if (settings.stopforumspamApiKey) {
104
- stopforumspam.Key(settings.stopforumspamApiKey);
105
- }
106
102
 
107
103
  pluginSettings = settings;
108
104
 
@@ -137,7 +133,7 @@ Plugin.report = async function (req, res, next) {
137
133
  if (isAdmin) {
138
134
  return res.status(403).send({ message: '[[spam-be-gone:cant-report-admin]]' });
139
135
  }
140
- await stopforumspam.submit({ ip: ips[0], email: fields.email, username: fields.username }, `Manual submission from user: ${req.uid} to user: ${fields.uid} via ${pluginData.id}`);
136
+ await stopForumSpamSubmit({ ip: ips[0], email: fields.email, username: fields.username }, `Manual submission from user: ${req.uid} to user: ${fields.uid} via ${pluginData.id}`);
141
137
  res.status(200).json({ message: '[[spam-be-gone:user-reported]]' });
142
138
  } catch (err) {
143
139
  winston.error(`[plugins/${pluginData.nbbId}][report-error] ${err.message}`);
@@ -152,7 +148,7 @@ Plugin.reportFromQueue = async (req, res) => {
152
148
  }
153
149
  const submitData = { ip: data.ip, email: data.email, username: data.username };
154
150
  try {
155
- await stopforumspam.submit(submitData, `Manual submission from user: ${req.uid} to user: ${data.username} via ${pluginData.id}`);
151
+ await stopForumSpamSubmit(submitData, `Manual submission from user: ${req.uid} to user: ${data.username} via ${pluginData.id}`);
156
152
  res.status(200).json({ message: '[[spam-be-gone:user-reported]]' });
157
153
  } catch (err) {
158
154
  winston.error(`[plugins/${pluginData.nbbId}][report-error] ${err.message}\n${JSON.stringify(submitData, null, 4)}`);
@@ -283,7 +279,7 @@ Plugin.getRegistrationQueue = async function (data) {
283
279
  async function augmentWitSpamData(user) {
284
280
  try {
285
281
  user.ip = user.ip.replace('::ffff:', '');
286
- let body = await stopforumspam.isSpammer({ ip: user.ip, email: user.email, username: user.username, f: 'json' });
282
+ let body = await stopForumSpamLookup({ ip: user.ip, email: user.email, username: user.username });
287
283
  if (!body) {
288
284
  body = { success: 1, username: { frequency: 0, appears: 0 }, email: { frequency: 0, appears: 0 }, ip: { frequency: 0, appears: 0, asn: null } };
289
285
  }
@@ -396,6 +392,71 @@ Plugin._turnstileCheck = async function (req) {
396
392
  });
397
393
  };
398
394
 
395
+
396
+ async function stopForumSpamLookup({ ip, email, username }) {
397
+ const params = new URLSearchParams();
398
+ params.set('f', 'json');
399
+ if (ip) params.set('ip', ip);
400
+ if (email) params.set('email', email);
401
+ if (username) params.set('username', username);
402
+
403
+ const res = await fetch(`https://api.stopforumspam.org/api?${params.toString()}`, {
404
+ headers: {
405
+ accept: 'application/json',
406
+ 'user-agent': pluginData.id,
407
+ },
408
+ });
409
+
410
+ if (!res.ok) {
411
+ throw new Error(`StopForumSpam lookup failed (${res.status})`);
412
+ }
413
+
414
+ return await res.json();
415
+ }
416
+
417
+ async function stopForumSpamSubmit({ ip, email, username }, evidence) {
418
+ if (!pluginSettings.stopforumspamApiKey) {
419
+ throw new Error('[[spam-be-gone:sfs-api-key-not-set]]');
420
+ }
421
+
422
+ const body = new URLSearchParams();
423
+ body.set('api_key', pluginSettings.stopforumspamApiKey);
424
+ body.set('api', 'json');
425
+ if (ip) body.set('ip_addr', ip);
426
+ if (email) body.set('email', email);
427
+ if (username) body.set('username', username);
428
+ if (evidence) body.set('evidence', evidence);
429
+
430
+ const res = await fetch('https://www.stopforumspam.com/add.php', {
431
+ method: 'POST',
432
+ headers: {
433
+ 'content-type': 'application/x-www-form-urlencoded',
434
+ accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
435
+ 'user-agent': pluginData.id,
436
+ },
437
+ body: body.toString(),
438
+ });
439
+
440
+ const text = await res.text();
441
+ if (!res.ok) {
442
+ throw new Error(`StopForumSpam submit failed (${res.status})`);
443
+ }
444
+
445
+ try {
446
+ const parsed = JSON.parse(text);
447
+ if (parsed.success === 0 || parsed.error) {
448
+ throw new Error(parsed.error || 'StopForumSpam submit failed');
449
+ }
450
+ return parsed;
451
+ } catch (err) {
452
+ // Some SFS responses can be plain text; consider HTTP 200 success as accepted.
453
+ if (/error/i.test(text)) {
454
+ throw err instanceof Error ? err : new Error('StopForumSpam submit failed');
455
+ }
456
+ return { success: 1, raw: text };
457
+ }
458
+ }
459
+
399
460
  Plugin.admin = {
400
461
  menu: function (custom_header, callback) {
401
462
  custom_header.plugins.push({ route: `/plugins/${pluginData.nbbId}`, icon: pluginData.faIcon, name: pluginData.name });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arnaudw38/nodebb-plugin-spam-be-gone",
3
- "version": "1.0.1",
4
- "description": "Anti-spam plugin for NodeBB 4.x using Akismet, StopForumSpam, ProjectHoneyPot, and Cloudflare Turnstile (Turnstile-only fork)",
3
+ "version": "1.0.3",
4
+ "description": "Anti-spam plugin for NodeBB 4.x using Akismet, StopForumSpam API, ProjectHoneyPot, and Cloudflare Turnstile (Turnstile-only fork)",
5
5
  "main": "library.js",
6
6
  "scripts": {},
7
7
  "repository": {
@@ -33,8 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "async": "^3.2.0",
36
- "project-honeypot": "~0.0.0",
37
- "stopforumspam": "^1.3.8"
36
+ "project-honeypot": "~0.0.0"
38
37
  },
39
38
  "nbbpm": {
40
39
  "compatibility": "^4.0.0"
@@ -50,7 +49,8 @@
50
49
  "upgrades/",
51
50
  "plugin.json",
52
51
  "README.md",
53
- "LICENSE"
52
+ "LICENSE",
53
+ "CHANGELOG.md"
54
54
  ],
55
55
  "publishConfig": {
56
56
  "access": "public"
package/plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "id": "nodebb-plugin-spam-be-gone",
3
3
  "name": "Spam Be Gone",
4
4
  "description": "Anti-spam using Akismet.com, StopForumSpam.com, ProjectHoneyPot.org and Cloudflare Turnstile",
5
- "url": "https://github.com/aworobel/nodebb-plugin-spam-be-gone",
5
+ "url": "https://github.com/akhoury/nodebb-plugin-spam-be-gone",
6
6
  "scss": [
7
7
  "public/scss/styles.scss"
8
8
  ],
@@ -5,6 +5,10 @@
5
5
  $(function () {
6
6
  var pluginName = 'spam-be-gone';
7
7
  var turnstileScriptUrl = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
8
+ var turnstileState = {
9
+ widgets: {},
10
+ loginBindingsAttached: false,
11
+ };
8
12
 
9
13
  function getTurnstileArgs() {
10
14
  return ajaxify.data && ajaxify.data.turnstileArgs;
@@ -25,6 +29,67 @@ $(function () {
25
29
  });
26
30
  }
27
31
 
32
+ function getCurrentTurnstileResponse() {
33
+ var input = document.querySelector('input[name="cf-turnstile-response"]');
34
+ return input && input.value;
35
+ }
36
+
37
+ function resetTurnstileWidget(targetId) {
38
+ if (typeof turnstile === 'undefined') {
39
+ return;
40
+ }
41
+ var widgetId = turnstileState.widgets[targetId];
42
+ if (widgetId === undefined || widgetId === null) {
43
+ return;
44
+ }
45
+ try {
46
+ turnstile.reset(widgetId);
47
+ } catch (err) {
48
+ // Ignore reset errors when the widget is already destroyed by navigation.
49
+ }
50
+ }
51
+
52
+ function scheduleLoginTurnstileReset(targetId) {
53
+ // Turnstile tokens are single-use. On failed login, NodeBB keeps the user on the
54
+ // same page, so we refresh the widget shortly after submit to allow a retry.
55
+ [1200, 3000].forEach(function (delay) {
56
+ window.setTimeout(function () {
57
+ if (ajaxify.data && ajaxify.data.template && ajaxify.data.template.name && ajaxify.data.template.name !== 'login') {
58
+ return;
59
+ }
60
+ if (!document.getElementById(targetId)) {
61
+ return;
62
+ }
63
+ if (getCurrentTurnstileResponse()) {
64
+ resetTurnstileWidget(targetId);
65
+ }
66
+ }, delay);
67
+ });
68
+ }
69
+
70
+ function bindLoginRetryReset(targetId) {
71
+ if (turnstileState.loginBindingsAttached) {
72
+ return;
73
+ }
74
+ turnstileState.loginBindingsAttached = true;
75
+
76
+ $(document)
77
+ .off('submit.spamBeGoneTurnstileLogin')
78
+ .on('submit.spamBeGoneTurnstileLogin', 'form[action="/login"], form[data-action="login"], #login', function () {
79
+ scheduleLoginTurnstileReset(targetId);
80
+ })
81
+ .off('click.spamBeGoneTurnstileLogin')
82
+ .on('click.spamBeGoneTurnstileLogin', '[component="login/submit"]', function () {
83
+ scheduleLoginTurnstileReset(targetId);
84
+ })
85
+ .off('input.spamBeGoneTurnstileLogin change.spamBeGoneTurnstileLogin')
86
+ .on('input.spamBeGoneTurnstileLogin change.spamBeGoneTurnstileLogin', 'input[name="username"], input[name="password"]', function () {
87
+ if (document.getElementById(targetId) && getCurrentTurnstileResponse()) {
88
+ resetTurnstileWidget(targetId);
89
+ }
90
+ });
91
+ }
92
+
28
93
  function renderTurnstileIfNeeded(isLoginPage) {
29
94
  var args = getTurnstileArgs();
30
95
  if (!args || (isLoginPage && !args.addLoginTurnstile)) {
@@ -37,10 +102,18 @@ $(function () {
37
102
  return;
38
103
  }
39
104
  var target = document.getElementById(args.targetId);
40
- if (!target || target.dataset.turnstileRendered === '1') {
105
+ if (!target) {
106
+ return;
107
+ }
108
+
109
+ if (target.dataset.turnstileRendered === '1') {
110
+ if (isLoginPage) {
111
+ bindLoginRetryReset(args.targetId);
112
+ }
41
113
  return;
42
114
  }
43
- turnstile.render('#' + args.targetId, {
115
+
116
+ var widgetId = turnstile.render('#' + args.targetId, {
44
117
  sitekey: args.siteKey,
45
118
  theme: args.theme || 'auto',
46
119
  size: args.size || 'normal',
@@ -55,8 +128,18 @@ $(function () {
55
128
  'error-callback': function () {
56
129
  require(['alerts'], function (alerts) { alerts.error('[[spam-be-gone:captcha-not-verified]]'); });
57
130
  },
131
+ 'expired-callback': function () {
132
+ resetTurnstileWidget(args.targetId);
133
+ },
134
+ 'timeout-callback': function () {
135
+ resetTurnstileWidget(args.targetId);
136
+ },
58
137
  });
59
138
  target.dataset.turnstileRendered = '1';
139
+ turnstileState.widgets[args.targetId] = widgetId;
140
+ if (isLoginPage) {
141
+ bindLoginRetryReset(args.targetId);
142
+ }
60
143
  })
61
144
  .catch(function () {
62
145
  require(['alerts'], function (alerts) { alerts.error('Failed to load Cloudflare Turnstile'); });