nodebb-plugin-admin-chats 3.5.0 → 3.6.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.
- package/README.md +12 -7
- package/lib/access.js +89 -0
- package/lib/history.js +110 -0
- package/lib/hooks.js +310 -0
- package/lib/lock.js +145 -0
- package/lib/overrides.js +186 -0
- package/lib/routes.js +469 -0
- package/library.js +5 -1501
- package/package.json +1 -1
- package/plugin.json +7 -6
- package/public/languages/ar/admin-chats.json +11 -1
- package/public/languages/de/admin-chats.json +11 -1
- package/public/languages/de-CH/admin-chats.json +11 -1
- package/public/languages/el/admin-chats.json +11 -1
- package/public/languages/en/admin-chats.json +11 -1
- package/public/languages/en-GB/admin-chats.json +11 -1
- package/public/languages/es/admin-chats.json +11 -1
- package/public/languages/fr/admin-chats.json +11 -1
- package/public/languages/fr-CH/admin-chats.json +11 -1
- package/public/languages/he/admin-chats.json +11 -1
- package/public/languages/hi/admin-chats.json +11 -1
- package/public/languages/it/admin-chats.json +11 -1
- package/public/languages/it-CH/admin-chats.json +11 -1
- package/public/languages/ja/admin-chats.json +11 -1
- package/public/languages/nl/admin-chats.json +11 -1
- package/public/languages/pl/admin-chats.json +11 -1
- package/public/languages/pt-BR/admin-chats.json +11 -1
- package/public/languages/rm-CH/admin-chats.json +11 -1
- package/public/languages/ru/admin-chats.json +11 -1
- package/public/languages/uk/admin-chats.json +11 -1
- package/public/languages/zh-CN/admin-chats.json +11 -1
- package/public/languages/zh-TW/admin-chats.json +11 -1
- package/static/lib/live-updates.js +42 -0
- package/static/lib/lock-toggle.js +57 -0
- package/static/lib/message-history.js +221 -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 +83 -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 -917
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
|
+
};
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const User = nodebb.require('./src/user');
|
|
4
|
+
const Messaging = nodebb.require('./src/messaging');
|
|
5
|
+
const db = nodebb.require('./src/database');
|
|
6
|
+
const meta = nodebb.require('./src/meta');
|
|
7
|
+
const privileges = nodebb.require('./src/privileges');
|
|
8
|
+
const helpers = nodebb.require('./src/controllers/helpers');
|
|
9
|
+
const sockets = nodebb.require('./src/socket.io');
|
|
10
|
+
|
|
11
|
+
const { canAccessAdminChats, canManageAdminChats } = require('./access');
|
|
12
|
+
const { getMessageEditHistory, deleteRevision } = require('./history');
|
|
13
|
+
const { roomExists, lockRoom, unlockRoom, getRoomLockData } = require('./lock');
|
|
14
|
+
|
|
15
|
+
const ADMIN_CHAT_PAGE_SIZE = 30;
|
|
16
|
+
const ADMIN_CHAT_SCAN_SIZE = 100;
|
|
17
|
+
|
|
18
|
+
async function assertAdminChatsAccess(req, res) {
|
|
19
|
+
if (!req.uid || !await canAccessAdminChats(req.uid)) {
|
|
20
|
+
helpers.notAllowed(req, res);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function renderAdminChatsPage(req, res, next) {
|
|
28
|
+
if (meta.config.disableChat) {
|
|
29
|
+
return next();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!await assertAdminChatsAccess(req, res)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const payload = await buildAdminChatsPayload(req);
|
|
37
|
+
if (req.params.roomId && !payload.roomId) {
|
|
38
|
+
return next();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
res.render('chats', payload);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function buildAdminChatsPayload(req, options = {}) {
|
|
45
|
+
// Like core (controllers/accounts/chats.js): when the client only switches
|
|
46
|
+
// rooms (?switch=1 on the JSON endpoint), skip rebuilding the room list —
|
|
47
|
+
// scanning every chat room on each switch made room-opening slow on mobile
|
|
48
|
+
const isSwitch = !!options.isSwitch;
|
|
49
|
+
const userslug = await User.getUserField(req.uid, 'userslug');
|
|
50
|
+
|
|
51
|
+
const payload = {
|
|
52
|
+
title: '[[pages:chats]]',
|
|
53
|
+
uid: req.uid,
|
|
54
|
+
userslug,
|
|
55
|
+
adminAllChats: true,
|
|
56
|
+
bodyClasses: ['page-user-chats'],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (!isSwitch) {
|
|
60
|
+
const [recentChats, publicRooms, privateRoomCount] = await Promise.all([
|
|
61
|
+
getAdminRecentChats(req.uid, 0, ADMIN_CHAT_PAGE_SIZE),
|
|
62
|
+
Messaging.getPublicRooms(req.uid, req.uid),
|
|
63
|
+
getPrivateRoomCount(),
|
|
64
|
+
]);
|
|
65
|
+
payload.rooms = recentChats.rooms;
|
|
66
|
+
payload.nextStart = recentChats.nextStart;
|
|
67
|
+
payload.publicRooms = publicRooms || [];
|
|
68
|
+
payload.privateRoomCount = privateRoomCount;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const roomId = parseInt(req.params.roomId, 10) || 0;
|
|
72
|
+
if (!roomId) {
|
|
73
|
+
return payload;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const roomPayload = await buildAdminChatRoomPayload(req.uid, roomId, req.params.index);
|
|
77
|
+
if (!roomPayload) {
|
|
78
|
+
return payload;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...payload,
|
|
83
|
+
...roomPayload,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function buildAdminChatRoomPayload(uid, roomId, indexParam) {
|
|
88
|
+
let start = 0;
|
|
89
|
+
let scrollToIndex = null;
|
|
90
|
+
|
|
91
|
+
if (indexParam) {
|
|
92
|
+
const msgCount = await db.getObjectField(`chat:room:${roomId}`, 'messageCount');
|
|
93
|
+
start = Math.max(0, parseInt(msgCount, 10) - parseInt(indexParam, 10) - 49);
|
|
94
|
+
scrollToIndex = Math.min(msgCount, Math.max(0, parseInt(indexParam, 10) || 1));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const room = await Messaging.loadRoom(uid, { uid, roomId, start });
|
|
98
|
+
if (!room) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Root fix for "message stays unread": core only clears unread state from the
|
|
103
|
+
// client when the room's nav element carries the `unread` class, which the
|
|
104
|
+
// admin list never sets. Mark the room read for the viewer on every load.
|
|
105
|
+
try {
|
|
106
|
+
await Messaging.markRead(uid, roomId);
|
|
107
|
+
await Messaging.pushUnreadCount(uid);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// never let read-state bookkeeping break room rendering
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const [canViewInfo, canUploadImage, canUploadFile] = await privileges.global.can([
|
|
113
|
+
'view:users:info', 'upload:post:image', 'upload:post:file',
|
|
114
|
+
], uid);
|
|
115
|
+
|
|
116
|
+
room.title = room.roomName || room.usernames || '[[pages:chats]]';
|
|
117
|
+
room.bodyClasses = ['page-user-chats', 'chat-loaded'];
|
|
118
|
+
room.canViewInfo = canViewInfo;
|
|
119
|
+
room.canUpload = (canUploadImage || canUploadFile) && (meta.config.maximumFileSize > 0 || room.isAdmin);
|
|
120
|
+
room.scrollToIndex = scrollToIndex;
|
|
121
|
+
|
|
122
|
+
return room;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function getPrivateRoomCount() {
|
|
126
|
+
const [totalCount, publicCount] = await Promise.all([
|
|
127
|
+
db.sortedSetCard('chat:rooms'),
|
|
128
|
+
db.sortedSetCard('chat:rooms:public'),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
return Math.max(0, (parseInt(totalCount, 10) || 0) - (parseInt(publicCount, 10) || 0));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function getAdminRecentChats(uid, start, limit) {
|
|
135
|
+
let cursor = Math.max(0, parseInt(start, 10) || 0);
|
|
136
|
+
const roomPairs = [];
|
|
137
|
+
|
|
138
|
+
while (roomPairs.length < limit) {
|
|
139
|
+
const roomIds = await db.getSortedSetRevRange('chat:rooms', cursor, cursor + ADMIN_CHAT_SCAN_SIZE - 1);
|
|
140
|
+
if (!roomIds.length) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rooms = await Messaging.getRoomsData(roomIds);
|
|
145
|
+
rooms.forEach((room, index) => {
|
|
146
|
+
if (room && !room.public && roomPairs.length < limit) {
|
|
147
|
+
roomPairs.push({ roomId: roomIds[index], room });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
cursor += roomIds.length;
|
|
152
|
+
if (roomIds.length < ADMIN_CHAT_SCAN_SIZE) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const rooms = roomPairs.map(item => item.room);
|
|
158
|
+
const roomIds = roomPairs.map(item => item.roomId);
|
|
159
|
+
|
|
160
|
+
await enrichAdminRecentRooms(uid, roomIds, rooms);
|
|
161
|
+
|
|
162
|
+
return { rooms, nextStart: cursor };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function enrichAdminRecentRooms(uid, roomIds, rooms) {
|
|
166
|
+
if (!roomIds.length) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const roomUsers = await Promise.all(roomIds.map(roomId => Messaging.getUidsInRoom(roomId, 0, -1)));
|
|
171
|
+
const uniqueUids = [...new Set(roomUsers.flat().filter(Boolean))];
|
|
172
|
+
const userMap = new Map();
|
|
173
|
+
|
|
174
|
+
if (uniqueUids.length) {
|
|
175
|
+
const users = await User.getUsersFields(uniqueUids, [
|
|
176
|
+
'uid', 'username', 'userslug', 'displayname', 'picture', 'status', 'lastonline',
|
|
177
|
+
]);
|
|
178
|
+
uniqueUids.forEach((memberUid, index) => {
|
|
179
|
+
const userData = users[index];
|
|
180
|
+
if (userData) {
|
|
181
|
+
userData.status = User.getStatus(userData);
|
|
182
|
+
userMap.set(String(memberUid), userData);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const teasers = await Promise.all(roomIds.map(roomId => getAdminRoomTeaser(roomId)));
|
|
188
|
+
|
|
189
|
+
rooms.forEach((room, index) => {
|
|
190
|
+
if (!room) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
room.users = (roomUsers[index] || [])
|
|
195
|
+
.map(memberUid => userMap.get(String(memberUid)))
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
room.groupChat = room.userCount > 2;
|
|
198
|
+
room.unread = false;
|
|
199
|
+
room.teaser = teasers[index];
|
|
200
|
+
room.lastUser = room.users[0];
|
|
201
|
+
room.usernames = Messaging.generateUsernames(room, uid);
|
|
202
|
+
room.participantsLabel = buildParticipantsLabel(room.users);
|
|
203
|
+
room.icon = Messaging.getRoomIcon(room);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildParticipantsLabel(users) {
|
|
208
|
+
const list = Array.isArray(users) ? users.filter(Boolean) : [];
|
|
209
|
+
if (!list.length) {
|
|
210
|
+
return '';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const names = list.map(user => user.displayname || user.username).filter(Boolean);
|
|
214
|
+
if (names.length <= 5) {
|
|
215
|
+
return names.join(', ');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return `${names.slice(0, 5).join(', ')} +${names.length - 5}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getAdminRoomTeaser(roomId) {
|
|
222
|
+
const mids = await db.getSortedSetRevRange(`chat:room:${roomId}:mids`, 0, 19);
|
|
223
|
+
if (!mids.length) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const teaser = (await Messaging.getMessagesFields(mids, ['fromuid', 'content', 'timestamp', 'deleted', 'system']))
|
|
228
|
+
.find(message => message && !message.deleted && !message.system && message.fromuid);
|
|
229
|
+
|
|
230
|
+
if (!teaser) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const teaserUser = await User.getUserFields(teaser.fromuid, [
|
|
235
|
+
'uid', 'username', 'userslug', 'displayname', 'picture', 'status', 'lastonline',
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
if (teaserUser) {
|
|
239
|
+
teaser.user = teaserUser;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
teaser.content = String(teaser.content || '').replace(/<[^>]*>/g, '').trim();
|
|
243
|
+
teaser.roomId = roomId;
|
|
244
|
+
|
|
245
|
+
return teaser;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function registerRoutes(app, router, middleware) {
|
|
249
|
+
if (!router) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const routeMiddleware = [];
|
|
254
|
+
if (middleware && typeof middleware.ensureLoggedIn === 'function') {
|
|
255
|
+
routeMiddleware.push(middleware.ensureLoggedIn);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const pageMiddlewares = [
|
|
259
|
+
middleware.autoLocale,
|
|
260
|
+
middleware.applyBlacklist,
|
|
261
|
+
middleware.authenticateRequest,
|
|
262
|
+
middleware.redirectToHomeIfBanned,
|
|
263
|
+
middleware.maintenanceMode,
|
|
264
|
+
middleware.registrationComplete,
|
|
265
|
+
middleware.pluginHooks,
|
|
266
|
+
...routeMiddleware,
|
|
267
|
+
middleware.pageView,
|
|
268
|
+
].filter(Boolean);
|
|
269
|
+
|
|
270
|
+
const pageController = async (req, res, next) => {
|
|
271
|
+
try {
|
|
272
|
+
// Users without admin-chats access get core's behaviour: redirect to
|
|
273
|
+
// their own chats page (helpers.redirect also handles API/ajaxify
|
|
274
|
+
// requests via X-Redirect, so navigation works in one hop)
|
|
275
|
+
if (req.uid && !await canAccessAdminChats(req.uid)) {
|
|
276
|
+
const userSlug = await User.getUserField(req.uid, 'userslug');
|
|
277
|
+
if (userSlug) {
|
|
278
|
+
const suffix = req.params.roomId ? `/${req.params.roomId}${req.params.index ? `/${req.params.index}` : ''}` : '';
|
|
279
|
+
return helpers.redirect(res, `/user/${userSlug}/chats${suffix}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await renderAdminChatsPage(req, res, next);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
next(err);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const pageRouter = app || router;
|
|
290
|
+
pageRouter.get('/chats/:roomId?/:index?', middleware.busyCheck, pageMiddlewares, middleware.buildHeader, pageController);
|
|
291
|
+
// Also own the page-API route. Without it, ajaxify entry into /chats/<roomId>
|
|
292
|
+
// (e.g. picking a room from the mobile chat dropdown) hit core's redirect
|
|
293
|
+
// route and bounced through an extra hop before the room finally rendered —
|
|
294
|
+
// showing the chat list in between. Registered on the core router before
|
|
295
|
+
// core's own routes, and covered by middleware.prepareAPI (mounted earlier),
|
|
296
|
+
// so res.render answers with ajaxify JSON in a single round-trip.
|
|
297
|
+
router.get('/api/chats/:roomId?/:index?', pageMiddlewares, pageController);
|
|
298
|
+
|
|
299
|
+
router.get('/api/admin-chats', ...routeMiddleware, async (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
if (!await assertAdminChatsAccess(req, res)) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const start = Math.max(0, parseInt(req.query.start, 10) || 0);
|
|
306
|
+
const data = await getAdminRecentChats(req.uid, start, ADMIN_CHAT_PAGE_SIZE);
|
|
307
|
+
res.json(data);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
router.get('/api/admin-chats/page/:roomId?/:index?', ...routeMiddleware, async (req, res) => {
|
|
314
|
+
try {
|
|
315
|
+
if (!await assertAdminChatsAccess(req, res)) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const payload = await buildAdminChatsPayload(req, { isSwitch: parseInt(req.query.switch, 10) === 1 });
|
|
320
|
+
res.json(payload);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
router.post('/api/admin-chats/:roomId/lock', ...routeMiddleware, async (req, res) => {
|
|
327
|
+
try {
|
|
328
|
+
const actingUid = req.uid;
|
|
329
|
+
const roomId = parseInt(req.params.roomId, 10);
|
|
330
|
+
|
|
331
|
+
if (!actingUid || Number.isNaN(roomId)) {
|
|
332
|
+
return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid room id' } });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!await User.isAdministrator(actingUid) && !await canManageAdminChats(actingUid)) {
|
|
336
|
+
return res.status(403).json({ status: { code: 'forbidden', message: 'Admin or manage privilege required' } });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!await roomExists(roomId)) {
|
|
340
|
+
return res.status(404).json({ status: { code: 'not-found', message: 'Room not found' } });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const shouldLock = req.body && typeof req.body.locked === 'boolean' ? req.body.locked : true;
|
|
344
|
+
const lockData = shouldLock ? await lockRoom(roomId, actingUid) : await unlockRoom(roomId, actingUid);
|
|
345
|
+
|
|
346
|
+
// Push the new lock state to anyone with that room open right now —
|
|
347
|
+
// same broadcast pattern core uses for messages (src/messaging/notifications.js),
|
|
348
|
+
// so an open window updates live instead of relying on client-side polling.
|
|
349
|
+
sockets.in(`chat_room_${roomId}`).emit('event:admin-chats.roomLockChanged', { roomId, lockData });
|
|
350
|
+
|
|
351
|
+
return res.json({ status: { code: 'ok', message: 'Room updated' }, roomId, lockData });
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
router.get('/api/admin-chats/:roomId/lock', ...routeMiddleware, async (req, res) => {
|
|
358
|
+
try {
|
|
359
|
+
const roomId = parseInt(req.params.roomId, 10);
|
|
360
|
+
|
|
361
|
+
if (Number.isNaN(roomId)) {
|
|
362
|
+
return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid room id' } });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!await roomExists(roomId)) {
|
|
366
|
+
return res.status(404).json({ status: { code: 'not-found', message: 'Room not found' } });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const lockData = await getRoomLockData(roomId);
|
|
370
|
+
return res.json({ status: { code: 'ok', message: 'Lock data retrieved' }, roomId, lockData });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// --- Chat edit-history (message options menu) -------------------------
|
|
377
|
+
// Surfaced as an "Edit history" item in each message's options dropdown
|
|
378
|
+
// (templates/partials/chats/message.tpl + static/lib/message-history.js),
|
|
379
|
+
// mirroring core's post diffs modal. Both routes require the manage
|
|
380
|
+
// privilege — view-only admin-chats access is not enough.
|
|
381
|
+
// GET /api/admin-chats/history/:mid -> JSON
|
|
382
|
+
// DELETE /api/admin-chats/history/:mid/:index -> delete one revision
|
|
383
|
+
router.get('/api/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
if (!req.uid || !await canManageAdminChats(req.uid)) {
|
|
386
|
+
return res.status(403).json({ status: { code: 'forbidden', message: 'Manage privilege required' } });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const mid = parseInt(req.params.mid, 10);
|
|
390
|
+
if (Number.isNaN(mid)) {
|
|
391
|
+
return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid message id' } });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const history = await getMessageEditHistory(mid);
|
|
395
|
+
return res.json({ status: { code: 'ok', message: 'Edit history retrieved' }, ...history });
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Restore a revision — like core post diffs (src/posts/diffs.js Diffs.restore),
|
|
402
|
+
// restoring is just an edit that sets the message content back to the
|
|
403
|
+
// revision's content. Going through Messaging.editMessage means the
|
|
404
|
+
// replaced current version is snapshotted into history (lib/overrides.js)
|
|
405
|
+
// and open chat windows update live via core's edit broadcast.
|
|
406
|
+
router.put('/api/admin-chats/history/:mid/:index', ...routeMiddleware, async (req, res) => {
|
|
407
|
+
try {
|
|
408
|
+
if (!req.uid || !await canManageAdminChats(req.uid)) {
|
|
409
|
+
return res.status(403).json({ status: { code: 'forbidden', message: 'Manage privilege required' } });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const mid = parseInt(req.params.mid, 10);
|
|
413
|
+
const index = parseInt(req.params.index, 10);
|
|
414
|
+
if (Number.isNaN(mid) || Number.isNaN(index) || index < 0) {
|
|
415
|
+
return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid message id or revision index' } });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const history = await getMessageEditHistory(mid);
|
|
419
|
+
if (!history.exists) {
|
|
420
|
+
return res.status(404).json({ status: { code: 'not-found', message: 'Message not found' } });
|
|
421
|
+
}
|
|
422
|
+
if (index >= history.revisions.length) {
|
|
423
|
+
return res.status(404).json({ status: { code: 'not-found', message: 'Revision not found' } });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await Messaging.editMessage(req.uid, mid, history.roomId, history.revisions[index].content);
|
|
427
|
+
|
|
428
|
+
const fresh = await getMessageEditHistory(mid);
|
|
429
|
+
return res.json({ status: { code: 'ok', message: 'Revision restored' }, ...fresh });
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
router.delete('/api/admin-chats/history/:mid/:index', ...routeMiddleware, async (req, res) => {
|
|
436
|
+
try {
|
|
437
|
+
if (!req.uid || !await canManageAdminChats(req.uid)) {
|
|
438
|
+
return res.status(403).json({ status: { code: 'forbidden', message: 'Manage privilege required' } });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const mid = parseInt(req.params.mid, 10);
|
|
442
|
+
const index = parseInt(req.params.index, 10);
|
|
443
|
+
if (Number.isNaN(mid) || Number.isNaN(index) || index < 0) {
|
|
444
|
+
return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid message id or revision index' } });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const deleted = await deleteRevision(mid, index);
|
|
448
|
+
if (!deleted) {
|
|
449
|
+
return res.status(404).json({ status: { code: 'not-found', message: 'Revision not found' } });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const history = await getMessageEditHistory(mid);
|
|
453
|
+
return res.json({ status: { code: 'ok', message: 'Revision deleted' }, ...history });
|
|
454
|
+
} catch (err) {
|
|
455
|
+
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
module.exports = {
|
|
461
|
+
ADMIN_CHAT_PAGE_SIZE,
|
|
462
|
+
assertAdminChatsAccess,
|
|
463
|
+
renderAdminChatsPage,
|
|
464
|
+
buildAdminChatsPayload,
|
|
465
|
+
buildAdminChatRoomPayload,
|
|
466
|
+
getAdminRecentChats,
|
|
467
|
+
buildParticipantsLabel,
|
|
468
|
+
registerRoutes,
|
|
469
|
+
};
|