nodebb-plugin-admin-chats 3.2.0 → 3.4.0

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.
@@ -0,0 +1,62 @@
1
+ name: Publish to npm on version bump
2
+
3
+ # Runs on every push to the default branch. If the version in package.json is
4
+ # not yet on the npm registry, it publishes the package. Bump the version in a
5
+ # commit and this workflow does the rest. Requires an NPM_TOKEN repository (or
6
+ # organization) secret; without it the publish step is skipped, not failed.
7
+
8
+ on:
9
+ push:
10
+ branches: [main, master]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: npm-publish-${{ github.ref }}
17
+ cancel-in-progress: false
18
+
19
+ jobs:
20
+ publish:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - name: Checkout
24
+ uses: actions/checkout@v4
25
+
26
+ - name: Setup Node.js
27
+ uses: actions/setup-node@v4
28
+ with:
29
+ node-version: 20
30
+ registry-url: https://registry.npmjs.org
31
+
32
+ - name: Determine if this version needs publishing
33
+ id: check
34
+ run: |
35
+ NAME=$(node -p "require('./package.json').name")
36
+ VERSION=$(node -p "require('./package.json').version")
37
+ echo "Package: $NAME@$VERSION"
38
+ if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
39
+ echo "Version already on npm - nothing to do."
40
+ echo "publish=false" >> "$GITHUB_OUTPUT"
41
+ else
42
+ echo "Version not on npm - will publish."
43
+ echo "publish=true" >> "$GITHUB_OUTPUT"
44
+ fi
45
+
46
+ - name: Verify NPM_TOKEN is configured
47
+ id: token
48
+ env:
49
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
50
+ run: |
51
+ if [ -n "$NPM_TOKEN" ]; then
52
+ echo "set=true" >> "$GITHUB_OUTPUT"
53
+ else
54
+ echo "::warning::NPM_TOKEN secret is not set - skipping publish."
55
+ echo "set=false" >> "$GITHUB_OUTPUT"
56
+ fi
57
+
58
+ - name: Publish to npm
59
+ if: steps.check.outputs.publish == 'true' && steps.token.outputs.set == 'true'
60
+ run: npm publish --access public
61
+ env:
62
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/library.js CHANGED
@@ -7,6 +7,7 @@ const db = require.main.require('./src/database');
7
7
  const meta = require.main.require('./src/meta');
8
8
  const privileges = require.main.require('./src/privileges');
9
9
  const helpers = require.main.require('./src/controllers/helpers');
10
+ const validator = require.main.require('validator');
10
11
  const fs = require('fs');
11
12
  const path = require('path');
12
13
 
@@ -16,6 +17,15 @@ const LOCKED_MESSAGE_TEXT_KEY = 'lock.banner';
16
17
  const ADMIN_CHATS_PRIVILEGE = 'admin-chats:view';
17
18
  const ADMIN_CHATS_MANAGE_PRIVILEGE = 'admin-chats:manage';
18
19
 
20
+ // Per-message edit-history storage. Each edit appends the *previous* (pre-edit)
21
+ // version as a JSON string, oldest first. Deliberately kept out of every payload,
22
+ // hook and template the plugin exposes — the only way to read it back is the
23
+ // hidden routes registered in registerRoutes(), whose paths are not linked
24
+ // anywhere in the UI. Access is still gated by admin-chats privileges.
25
+ function editHistoryKey(mid) {
26
+ return `message:${mid}:editHistory`;
27
+ }
28
+
19
29
  // Load translations from language files
20
30
  const translations = {};
21
31
  function loadTranslations() {
@@ -640,6 +650,51 @@ function overrideMessagingFunctions() {
640
650
  const originalCanViewMessage = Messaging.canViewMessage;
641
651
  const originalCanReply = Messaging.canReply;
642
652
 
653
+ if (typeof Messaging.editMessage === 'function' && !Messaging.editMessage._adminChatsHistoryWrapped) {
654
+ const originalEditMessage = Messaging.editMessage;
655
+
656
+ const wrappedEditMessage = async function (uid, mid, roomId, content) {
657
+ let previous = null;
658
+ try {
659
+ const [oldContent, fields] = await Promise.all([
660
+ Messaging.getMessageField(mid, 'content'),
661
+ Messaging.getMessageFields(mid, ['fromuid', 'timestamp', 'edited']),
662
+ ]);
663
+ // Only snapshot when the content actually changes, mirroring core's
664
+ // no-op short-circuit so history stays in step with real edits.
665
+ if (typeof oldContent === 'string' && oldContent !== content) {
666
+ previous = {
667
+ content: oldContent,
668
+ fromuid: parseInt(fields && fields.fromuid, 10) || 0,
669
+ editedByUid: parseInt(uid, 10) || 0,
670
+ // When this version stopped being current (i.e. now).
671
+ replacedAt: Date.now(),
672
+ // The edit stamp this version carried before being replaced.
673
+ previousEdited: parseInt(fields && fields.edited, 10) ||
674
+ parseInt(fields && fields.timestamp, 10) || 0,
675
+ };
676
+ }
677
+ } catch (err) {
678
+ previous = null;
679
+ }
680
+
681
+ const result = await originalEditMessage.call(this, uid, mid, roomId, content);
682
+
683
+ if (previous) {
684
+ try {
685
+ await db.listAppend(editHistoryKey(mid), JSON.stringify(previous));
686
+ } catch (err) {
687
+ // History is best-effort; never break an edit because of it.
688
+ }
689
+ }
690
+
691
+ return result;
692
+ };
693
+
694
+ wrappedEditMessage._adminChatsHistoryWrapped = true;
695
+ Messaging.editMessage = wrappedEditMessage;
696
+ }
697
+
643
698
  Messaging.canEdit = async function (messageId, uid) {
644
699
  if (await User.isAdministrator(uid)) {
645
700
  return true;
@@ -853,6 +908,185 @@ function registerRoutes(app, router, middleware) {
853
908
  return res.status(500).json({ status: { code: 'error', message: err.message } });
854
909
  }
855
910
  });
911
+
912
+ // --- Hidden chat edit-history viewer ---------------------------------
913
+ // These two routes are intentionally NOT referenced from any template,
914
+ // client script, menu or API payload. They do not appear in the admin
915
+ // chats UI, so a "regular" administrator who has not read this source
916
+ // will never encounter them. They are still protected by
917
+ // assertAdminChatsAccess(), so only privileged accounts can read data
918
+ // even if the URL is discovered. Paths:
919
+ // GET /api/admin-chats/history/:mid -> JSON
920
+ // GET /admin-chats/history/:mid -> standalone HTML page
921
+ router.get('/api/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
922
+ try {
923
+ if (!await assertAdminChatsAccess(req, res)) {
924
+ return;
925
+ }
926
+
927
+ const mid = parseInt(req.params.mid, 10);
928
+ if (Number.isNaN(mid)) {
929
+ return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid message id' } });
930
+ }
931
+
932
+ const history = await getMessageEditHistory(mid);
933
+ return res.json({
934
+ status: { code: 'ok', message: 'Edit history retrieved' },
935
+ ...history,
936
+ });
937
+ } catch (err) {
938
+ return res.status(500).json({ status: { code: 'error', message: err.message } });
939
+ }
940
+ });
941
+
942
+ router.get('/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
943
+ try {
944
+ if (!await assertAdminChatsAccess(req, res)) {
945
+ return;
946
+ }
947
+
948
+ const mid = parseInt(req.params.mid, 10);
949
+ if (Number.isNaN(mid)) {
950
+ return res.status(400).type('html').send('<h1>400</h1><p>Invalid message id</p>');
951
+ }
952
+
953
+ const history = await getMessageEditHistory(mid);
954
+ return res.type('html').send(renderMessageHistoryHtml(history));
955
+ } catch (err) {
956
+ return res.status(500).type('html').send(`<h1>500</h1><pre>${validator.escape(String(err.message))}</pre>`);
957
+ }
958
+ });
959
+ }
960
+
961
+ async function getMessageEditHistory(mid) {
962
+ const [exists, current, rawHistory] = await Promise.all([
963
+ Messaging.messageExists(mid),
964
+ Messaging.getMessageFields(mid, ['content', 'fromuid', 'timestamp', 'edited', 'roomId', 'deleted']),
965
+ db.getListRange(editHistoryKey(mid), 0, -1),
966
+ ]);
967
+
968
+ const revisions = (rawHistory || []).map((item) => {
969
+ try {
970
+ return JSON.parse(item);
971
+ } catch (err) {
972
+ return null;
973
+ }
974
+ }).filter(Boolean);
975
+
976
+ // Resolve usernames for the author and every editor in one lookup.
977
+ const uidSet = new Set();
978
+ const authorUid = parseInt(current && current.fromuid, 10) || 0;
979
+ if (authorUid) {
980
+ uidSet.add(authorUid);
981
+ }
982
+ revisions.forEach((rev) => {
983
+ if (rev.editedByUid) {
984
+ uidSet.add(parseInt(rev.editedByUid, 10) || 0);
985
+ }
986
+ if (rev.fromuid) {
987
+ uidSet.add(parseInt(rev.fromuid, 10) || 0);
988
+ }
989
+ });
990
+ uidSet.delete(0);
991
+
992
+ const uids = [...uidSet];
993
+ const userMap = new Map();
994
+ if (uids.length) {
995
+ const users = await User.getUsersFields(uids, ['uid', 'username', 'userslug']);
996
+ uids.forEach((uid, index) => {
997
+ if (users[index]) {
998
+ userMap.set(uid, users[index]);
999
+ }
1000
+ });
1001
+ }
1002
+
1003
+ const decorate = (uid) => {
1004
+ const numeric = parseInt(uid, 10) || 0;
1005
+ const user = userMap.get(numeric);
1006
+ return {
1007
+ uid: numeric,
1008
+ username: user ? user.username : (numeric ? `uid ${numeric}` : '—'),
1009
+ };
1010
+ };
1011
+
1012
+ return {
1013
+ mid,
1014
+ exists: !!exists,
1015
+ roomId: parseInt(current && current.roomId, 10) || 0,
1016
+ author: decorate(authorUid),
1017
+ current: {
1018
+ content: current ? String(current.content || '') : '',
1019
+ edited: parseInt(current && current.edited, 10) || 0,
1020
+ timestamp: parseInt(current && current.timestamp, 10) || 0,
1021
+ deleted: parseInt(current && current.deleted, 10) === 1,
1022
+ },
1023
+ revisionCount: revisions.length,
1024
+ revisions: revisions.map((rev) => ({
1025
+ content: String(rev.content || ''),
1026
+ editor: decorate(rev.editedByUid),
1027
+ replacedAt: parseInt(rev.replacedAt, 10) || 0,
1028
+ previousEdited: parseInt(rev.previousEdited, 10) || 0,
1029
+ })),
1030
+ };
1031
+ }
1032
+
1033
+ function renderMessageHistoryHtml(history) {
1034
+ const esc = (value) => validator.escape(String(value == null ? '' : value));
1035
+ const fmt = (ts) => {
1036
+ const numeric = parseInt(ts, 10) || 0;
1037
+ return numeric ? new Date(numeric).toISOString().replace('T', ' ').replace(/\..+$/, ' UTC') : '—';
1038
+ };
1039
+
1040
+ if (!history.exists) {
1041
+ return `<!doctype html><html lang="he" dir="rtl"><meta charset="utf-8"><title>היסטוריית עריכות</title><body style="font-family:system-ui,Arial,sans-serif;padding:2rem;">
1042
+ <h1>הודעה ${esc(history.mid)} לא נמצאה</h1></body></html>`;
1043
+ }
1044
+
1045
+ // Oldest version first (revisions are stored oldest→newest), then the
1046
+ // current live version last, so the page reads top-to-bottom in time order.
1047
+ const timeline = history.revisions.map((rev, index) => `
1048
+ <li class="rev">
1049
+ <div class="meta">גרסה ${index + 1} · הוחלפה ב-${esc(fmt(rev.replacedAt))} · עורך: ${esc(rev.editor.username)}</div>
1050
+ <pre class="content">${esc(rev.content)}</pre>
1051
+ </li>`).join('');
1052
+
1053
+ const currentBlock = `
1054
+ <li class="rev current">
1055
+ <div class="meta">גרסה נוכחית${history.current.edited ? ` · נערכה לאחרונה ${esc(fmt(history.current.edited))}` : ''}${history.current.deleted ? ' · מחוקה' : ''}</div>
1056
+ <pre class="content">${esc(history.current.content)}</pre>
1057
+ </li>`;
1058
+
1059
+ return `<!doctype html>
1060
+ <html lang="he" dir="rtl">
1061
+ <head>
1062
+ <meta charset="utf-8">
1063
+ <meta name="robots" content="noindex,nofollow">
1064
+ <title>היסטוריית עריכות · הודעה ${esc(history.mid)}</title>
1065
+ <style>
1066
+ body { font-family: system-ui, Segoe UI, Arial, sans-serif; margin: 0; padding: 2rem; background: #f5f6f8; color: #1c1e21; }
1067
+ h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
1068
+ .sub { color: #65676b; font-size: .875rem; margin-bottom: 1.5rem; }
1069
+ ul { list-style: none; margin: 0; padding: 0; max-width: 780px; }
1070
+ .rev { background: #fff; border: 1px solid #dcdfe3; border-radius: 8px; padding: .75rem 1rem; margin-bottom: 1rem; }
1071
+ .rev.current { border-color: #2d6cdf; box-shadow: 0 0 0 1px #2d6cdf33; }
1072
+ .meta { font-size: .8rem; color: #65676b; margin-bottom: .5rem; }
1073
+ .content { white-space: pre-wrap; word-break: break-word; margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
1074
+ @media (prefers-color-scheme: dark) {
1075
+ body { background: #18191a; color: #e4e6eb; }
1076
+ .rev { background: #242526; border-color: #3a3b3c; }
1077
+ .meta, .sub { color: #b0b3b8; }
1078
+ }
1079
+ </style>
1080
+ </head>
1081
+ <body>
1082
+ <h1>היסטוריית עריכות · הודעה ${esc(history.mid)}</h1>
1083
+ <div class="sub">מחבר: ${esc(history.author.username)} · חדר: ${esc(history.roomId)} · ${esc(history.revisionCount)} עריכות קודמות</div>
1084
+ <ul>
1085
+ ${history.revisionCount ? timeline : '<li class="rev"><div class="meta">אין עריכות קודמות מתועדות</div></li>'}
1086
+ ${currentBlock}
1087
+ </ul>
1088
+ </body>
1089
+ </html>`;
856
1090
  }
857
1091
 
858
1092
  async function renderAdminChatsPage(req, res, next) {
@@ -1248,4 +1482,3 @@ module.exports = plugin;
1248
1482
 
1249
1483
 
1250
1484
 
1251
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-admin-chats",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "A plugin that allows the administrator to view and manage user chats.",
5
5
  "main": "library.js",
6
6
  "nbbpm": {