nodebb-plugin-admin-chats 3.4.3 → 3.5.1
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/README.md +12 -7
- package/lib/access.js +89 -0
- package/lib/history.js +144 -0
- package/lib/hooks.js +310 -0
- package/lib/lock.js +145 -0
- package/lib/overrides.js +186 -0
- package/lib/routes.js +432 -0
- package/library.js +5 -1482
- package/package.json +1 -1
- package/plugin.json +6 -6
- package/static/lib/live-updates.js +42 -0
- package/static/lib/lock-toggle.js +57 -0
- package/static/lib/profile-link.js +98 -0
- package/static/lib/switch-chat.js +234 -0
- package/templates/partials/chats/message-window.tpl +60 -0
- package/templates/partials/chats/message.tpl +78 -0
- package/templates/partials/chats/options.tpl +111 -0
- package/templates/partials/chats/recent_room.tpl +60 -0
- package/.github/workflows/npm-publish.yml +0 -62
- package/.vscode/settings.json +0 -3
- package/client.js +0 -920
package/lib/lock.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = nodebb.require('./src/database');
|
|
4
|
+
const User = nodebb.require('./src/user');
|
|
5
|
+
const { canManageAdminChats } = require('./access');
|
|
6
|
+
|
|
7
|
+
// Marker type on the synthetic system message this plugin injects when a
|
|
8
|
+
// room is locked — mirrors how core tags its own system messages
|
|
9
|
+
// ('user-join', 'user-leave', ...) instead of sniffing message content.
|
|
10
|
+
const LOCK_MESSAGE_TYPE = 'admin-chat-lock';
|
|
11
|
+
|
|
12
|
+
async function roomExists(roomId) {
|
|
13
|
+
const roomData = await db.getObject(`chat:room:${roomId}`);
|
|
14
|
+
return !!roomData;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function lockRoom(roomId, actingUid) {
|
|
18
|
+
await db.setObject(`chat:room:${roomId}:adminLock`, {
|
|
19
|
+
isLocked: 1,
|
|
20
|
+
lockedBy: actingUid,
|
|
21
|
+
lockedAt: Date.now(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return await getRoomLockData(roomId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function unlockRoom(roomId, actingUid) {
|
|
28
|
+
await db.setObject(`chat:room:${roomId}:adminLock`, {
|
|
29
|
+
isLocked: 0,
|
|
30
|
+
lockedBy: actingUid,
|
|
31
|
+
lockedAt: 0,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return await getRoomLockData(roomId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function isRoomLocked(roomId) {
|
|
38
|
+
const field = await db.getObjectField(`chat:room:${roomId}:adminLock`, 'isLocked');
|
|
39
|
+
return field === '1' || field === 1 || field === true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getRoomLockData(roomId) {
|
|
43
|
+
const data = await db.getObject(`chat:room:${roomId}:adminLock`) || {};
|
|
44
|
+
return {
|
|
45
|
+
isLocked: data.isLocked === '1' || data.isLocked === 1 || data.isLocked === true,
|
|
46
|
+
lockedBy: parseInt(data.lockedBy, 10) || 0,
|
|
47
|
+
lockedAt: parseInt(data.lockedAt, 10) || 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function canNonAdminModifyLockedRoom(uid, roomId) {
|
|
52
|
+
if (!uid || !roomId) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (await User.isAdministrator(uid)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (await canManageAdminChats(uid)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return await isRoomLocked(roomId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isAdminLockMessage(msg) {
|
|
68
|
+
return !!(msg && msg.type === LOCK_MESSAGE_TYPE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stripAdminLockMessages(messages) {
|
|
72
|
+
const list = Array.isArray(messages) ? messages.slice() : [];
|
|
73
|
+
return list.filter(msg => !isAdminLockMessage(msg));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ensureFirstVisibleMessageHeader(messages) {
|
|
77
|
+
const list = Array.isArray(messages) ? messages.slice() : [];
|
|
78
|
+
const firstUserMessageIndex = list.findIndex(msg => msg && !msg.system);
|
|
79
|
+
|
|
80
|
+
if (firstUserMessageIndex !== -1) {
|
|
81
|
+
list[firstUserMessageIndex].newSet = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Marks every message rendered while the room is locked with `adminChatLocked`
|
|
88
|
+
// so partials/chats/message.tpl can hide edit/delete/restore for non-managers
|
|
89
|
+
// without a client-side visibility pass.
|
|
90
|
+
function markMessagesLocked(messages, isLockedForViewer) {
|
|
91
|
+
if (!isLockedForViewer) {
|
|
92
|
+
return messages;
|
|
93
|
+
}
|
|
94
|
+
(messages || []).forEach((msg) => {
|
|
95
|
+
if (msg && !msg.system) {
|
|
96
|
+
msg.adminChatLocked = true;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return messages;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildRoomMessagesWithLockNotice(messages, lockData) {
|
|
103
|
+
const cleanedMessages = stripAdminLockMessages(messages);
|
|
104
|
+
if (!lockData || !lockData.isLocked || !lockData.lockedAt) {
|
|
105
|
+
return cleanedMessages;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cleanedMessages.push({
|
|
109
|
+
// Raw translation token, resolved per-viewer at render time by
|
|
110
|
+
// partials/chats/system-message.tpl's {{tx(messages.content)}} —
|
|
111
|
+
// the same pattern core uses for its own system messages
|
|
112
|
+
// (src/messaging/create.js: Messaging.addSystemMessage).
|
|
113
|
+
content: '[[admin-chats:lock.banner]]',
|
|
114
|
+
type: LOCK_MESSAGE_TYPE,
|
|
115
|
+
fromuid: lockData.lockedBy,
|
|
116
|
+
uid: lockData.lockedBy,
|
|
117
|
+
timestamp: lockData.lockedAt,
|
|
118
|
+
datetime: lockData.lockedAt,
|
|
119
|
+
system: true,
|
|
120
|
+
newSet: true,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
cleanedMessages.sort((left, right) => {
|
|
124
|
+
const leftTime = parseInt(left && (left.timestamp || left.datetime), 10) || 0;
|
|
125
|
+
const rightTime = parseInt(right && (right.timestamp || right.datetime), 10) || 0;
|
|
126
|
+
return leftTime - rightTime;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return cleanedMessages;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
LOCK_MESSAGE_TYPE,
|
|
134
|
+
roomExists,
|
|
135
|
+
lockRoom,
|
|
136
|
+
unlockRoom,
|
|
137
|
+
isRoomLocked,
|
|
138
|
+
getRoomLockData,
|
|
139
|
+
canNonAdminModifyLockedRoom,
|
|
140
|
+
isAdminLockMessage,
|
|
141
|
+
stripAdminLockMessages,
|
|
142
|
+
ensureFirstVisibleMessageHeader,
|
|
143
|
+
markMessagesLocked,
|
|
144
|
+
buildRoomMessagesWithLockNotice,
|
|
145
|
+
};
|
package/lib/overrides.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Monkey-patches on core modules. NodeBB 4.14 has no hook for "room is
|
|
4
|
+
// locked" style reply/edit/delete restriction or for intercepting chat
|
|
5
|
+
// room-switch routing, so wrapping these functions is still the only way to
|
|
6
|
+
// enforce admin-chat locks and custom admin routing.
|
|
7
|
+
|
|
8
|
+
const User = nodebb.require('./src/user');
|
|
9
|
+
const Messaging = nodebb.require('./src/messaging');
|
|
10
|
+
const ChatsAPI = nodebb.require('./src/api/chats');
|
|
11
|
+
const db = nodebb.require('./src/database');
|
|
12
|
+
const {
|
|
13
|
+
canManageAdminChats, canAccessAdminChats, guessRoomIdUidFromArgs, isUserMemberOfRoom,
|
|
14
|
+
ADMIN_CHATS_PRIVILEGE, ADMIN_CHATS_MANAGE_PRIVILEGE,
|
|
15
|
+
} = require('./access');
|
|
16
|
+
const { isRoomLocked } = require('./lock');
|
|
17
|
+
const { editHistoryKey } = require('./history');
|
|
18
|
+
const privileges = nodebb.require('./src/privileges');
|
|
19
|
+
|
|
20
|
+
// NodeBB translates `[[namespace:key]]` error messages itself (both in
|
|
21
|
+
// app.alertError on the client and anywhere else the message surfaces) —
|
|
22
|
+
// no need to resolve the string server-side for the acting user's language.
|
|
23
|
+
const LOCKED_ACTION_ERROR = '[[admin-chats:errors.lockedAction]]';
|
|
24
|
+
|
|
25
|
+
async function getMessageRoomId(messageId) {
|
|
26
|
+
const roomId = await db.getObjectField(`message:${messageId}`, 'roomId');
|
|
27
|
+
return parseInt(roomId, 10) || 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function overrideCoreChatRedirect(controllers, renderAdminChatsPage) {
|
|
31
|
+
const chatsController = controllers && controllers.accounts && controllers.accounts.chats;
|
|
32
|
+
if (!chatsController || typeof chatsController.redirectToChat !== 'function' || chatsController.redirectToChat._adminChatsWrapped) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const originalRedirectToChat = chatsController.redirectToChat;
|
|
37
|
+
const wrappedRedirectToChat = async function (req, res, next) {
|
|
38
|
+
if (req && req.uid && await canAccessAdminChats(req.uid)) {
|
|
39
|
+
return await renderAdminChatsPage(req, res, next);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return await originalRedirectToChat.call(this, req, res, next);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
wrappedRedirectToChat._adminChatsWrapped = true;
|
|
46
|
+
chatsController.redirectToChat = wrappedRedirectToChat;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function overrideChatsApi() {
|
|
50
|
+
if (!ChatsAPI || typeof ChatsAPI.kick !== 'function' || ChatsAPI.kick._adminChatLockWrapped) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const originalKick = ChatsAPI.kick;
|
|
55
|
+
const wrappedKick = async function (caller, data) {
|
|
56
|
+
const isSelfKick = caller && data && data.roomId && Array.isArray(data.uids) &&
|
|
57
|
+
data.uids.length === 1 && parseInt(data.uids[0], 10) === parseInt(caller.uid, 10);
|
|
58
|
+
|
|
59
|
+
if (isSelfKick && !await User.isAdministrator(caller.uid) && !await canManageAdminChats(caller.uid) &&
|
|
60
|
+
await isRoomLocked(data.roomId)) {
|
|
61
|
+
throw new Error(LOCKED_ACTION_ERROR);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return await originalKick.call(this, caller, data);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
wrappedKick._adminChatLockWrapped = true;
|
|
68
|
+
ChatsAPI.kick = wrappedKick;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function overrideMessagingFunctions() {
|
|
72
|
+
const originalCanEdit = Messaging.canEdit;
|
|
73
|
+
const originalCanDelete = Messaging.canDelete;
|
|
74
|
+
const originalCanViewMessage = Messaging.canViewMessage;
|
|
75
|
+
const originalCanReply = Messaging.canReply;
|
|
76
|
+
|
|
77
|
+
if (typeof Messaging.editMessage === 'function' && !Messaging.editMessage._adminChatsHistoryWrapped) {
|
|
78
|
+
const originalEditMessage = Messaging.editMessage;
|
|
79
|
+
|
|
80
|
+
const wrappedEditMessage = async function (uid, mid, roomId, content) {
|
|
81
|
+
let previous = null;
|
|
82
|
+
try {
|
|
83
|
+
const [oldContent, fields] = await Promise.all([
|
|
84
|
+
Messaging.getMessageField(mid, 'content'),
|
|
85
|
+
Messaging.getMessageFields(mid, ['fromuid', 'timestamp', 'edited']),
|
|
86
|
+
]);
|
|
87
|
+
// Only snapshot when the content actually changes, mirroring core's
|
|
88
|
+
// no-op short-circuit so history stays in step with real edits.
|
|
89
|
+
if (typeof oldContent === 'string' && oldContent !== content) {
|
|
90
|
+
previous = {
|
|
91
|
+
content: oldContent,
|
|
92
|
+
fromuid: parseInt(fields && fields.fromuid, 10) || 0,
|
|
93
|
+
editedByUid: parseInt(uid, 10) || 0,
|
|
94
|
+
replacedAt: Date.now(),
|
|
95
|
+
previousEdited: parseInt(fields && fields.edited, 10) ||
|
|
96
|
+
parseInt(fields && fields.timestamp, 10) || 0,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
previous = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await originalEditMessage.call(this, uid, mid, roomId, content);
|
|
104
|
+
|
|
105
|
+
if (previous) {
|
|
106
|
+
try {
|
|
107
|
+
await db.listAppend(editHistoryKey(mid), JSON.stringify(previous));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// History is best-effort; never break an edit because of it.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
wrappedEditMessage._adminChatsHistoryWrapped = true;
|
|
117
|
+
Messaging.editMessage = wrappedEditMessage;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
Messaging.canEdit = async function (messageId, uid) {
|
|
121
|
+
if (await User.isAdministrator(uid) || await canManageAdminChats(uid)) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const roomId = await getMessageRoomId(messageId);
|
|
126
|
+
if (roomId && await isRoomLocked(roomId)) {
|
|
127
|
+
throw new Error(LOCKED_ACTION_ERROR);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return await originalCanEdit(messageId, uid);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
Messaging.canDelete = async function (messageId, uid) {
|
|
134
|
+
if (await User.isAdministrator(uid) || await canManageAdminChats(uid)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const roomId = await getMessageRoomId(messageId);
|
|
139
|
+
if (roomId && await isRoomLocked(roomId)) {
|
|
140
|
+
throw new Error(LOCKED_ACTION_ERROR);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return await originalCanDelete(messageId, uid);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
Messaging.canViewMessage = async function (mids, roomId, uid) {
|
|
147
|
+
if (await canAccessAdminChats(uid)) {
|
|
148
|
+
return Array.isArray(mids) ? mids.map(() => true) : true;
|
|
149
|
+
}
|
|
150
|
+
return await originalCanViewMessage(mids, roomId, uid);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (typeof originalCanReply === 'function') {
|
|
154
|
+
Messaging.canReply = async function (...args) {
|
|
155
|
+
const guessed = guessRoomIdUidFromArgs(args);
|
|
156
|
+
const [isAdmin, canView, canManage] = await Promise.all([
|
|
157
|
+
User.isAdministrator(guessed.uid),
|
|
158
|
+
privileges.global.can(ADMIN_CHATS_PRIVILEGE, guessed.uid),
|
|
159
|
+
privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, guessed.uid),
|
|
160
|
+
]);
|
|
161
|
+
const isManager = isAdmin || canManage;
|
|
162
|
+
const hasAccess = isAdmin || canView || canManage;
|
|
163
|
+
|
|
164
|
+
if (isManager) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (guessed.roomId && hasAccess) {
|
|
168
|
+
const isMember = await isUserMemberOfRoom(guessed.uid, guessed.roomId);
|
|
169
|
+
if (!isMember) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (guessed.roomId && await isRoomLocked(guessed.roomId)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return await originalCanReply.apply(this, args);
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
getMessageRoomId,
|
|
183
|
+
overrideCoreChatRedirect,
|
|
184
|
+
overrideChatsApi,
|
|
185
|
+
overrideMessagingFunctions,
|
|
186
|
+
};
|