-
Notifications
You must be signed in to change notification settings - Fork 52
Expand file tree
/
Copy pathindex.js
More file actions
471 lines (422 loc) · 19.7 KB
/
Copy pathindex.js
File metadata and controls
471 lines (422 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
'use strict';
const {template} = require('ep_plugin_helpers');
const AttributePool = require('ep_etherpad-lite/static/js/AttributePool').default || require('ep_etherpad-lite/static/js/AttributePool');
const Changeset = require('ep_etherpad-lite/static/js/Changeset').default || require('ep_etherpad-lite/static/js/Changeset');
const eejs = require('ep_etherpad-lite/node/eejs');
const settings = require('ep_etherpad-lite/node/utils/Settings');
const {Formidable} = require('formidable');
const commentManager = require('./commentManager');
const apiUtils = require('./apiUtils');
const padMessageHandler = require('ep_etherpad-lite/node/handler/PadMessageHandler');
const readOnlyManager = require('ep_etherpad-lite/node/db/ReadOnlyManager').default || require('ep_etherpad-lite/node/db/ReadOnlyManager');
const padManager = require('ep_etherpad-lite/node/db/PadManager');
const authorManager = require('ep_etherpad-lite/node/db/AuthorManager').default || require('ep_etherpad-lite/node/db/AuthorManager');
// Resolve the authoritative authorId for a /comment socket connection from the
// HttpOnly author-token cookie on its handshake — the same cookie core uses to
// identify the author. The cookie is never exposed to the page, so a client
// cannot spoof another user's authorId (#222). Returns null when it can't be
// resolved (e.g. no token cookie), in which case authorship checks fail closed.
// Mirrors core's PadMessageHandler cookie parsing (the socket.io handshake does
// not run cookie-parser, so read the Cookie header directly).
const authorIdForSocket = async (socket) => {
try {
const cookiePrefix = (settings.cookie && settings.cookie.prefix) || '';
const cookieHeader =
(socket && socket.request && socket.request.headers && socket.request.headers.cookie) || '';
const match = cookieHeader.split(/;\s*/).find(
(c) => c.split('=')[0] === `${cookiePrefix}token`);
if (!match) return null;
let token;
try {
token = decodeURIComponent(match.split('=').slice(1).join('='));
} catch (err) {
if (err instanceof URIError) return null; // malformed cookie -> treat as absent
throw err;
}
if (!token) return null;
const getAuthorId = authorManager.getAuthorId
? (t) => authorManager.getAuthorId(t, {})
: (t) => authorManager.getAuthor4Token(t); // older cores
return await getAuthorId(token);
} catch (err) {
return null;
}
};
// Exported for tests (verifies author identity derives from the token cookie).
exports.authorIdForSocket = authorIdForSocket;
// Comment char-ranges per line for a given revision's atext. The timeslider on
// older Etherpad cores can't run the plugin's client hooks, so it never paints
// the `comment` class; this lets the client reconstruct those ranges and render
// comments read-only there (issue #33). Returns {commentId: [{line, start, end}]}.
const commentLocationsFromAText = (atext, apool) => {
const text = atext.text;
const out = {};
let charIdx = 0;
let line = 0;
let col = 0;
const opIter = Changeset.opIterator(atext.attribs);
while (opIter.hasNext()) {
const op = opIter.next();
let commentId = null;
Changeset.eachAttribNumber(op.attribs, (n) => {
if (apool.getAttribKey(n) === 'comment') commentId = apool.getAttribValue(n);
});
for (let i = 0; i < op.chars; i++) {
const ch = text[charIdx++];
if (ch === '\n') { line++; col = 0; continue; }
if (commentId) {
const ranges = out[commentId] || (out[commentId] = []);
const last = ranges[ranges.length - 1];
if (last && last.line === line && last.end === col) last.end = col + 1;
else ranges.push({line, start: col, end: col + 1});
}
col++;
}
}
return out;
};
const {padToggle} = require('ep_plugin_helpers/pad-toggle-server');
const {toggle} = require('ep_plugin_helpers/settings-toggle');
// Parallel User Settings + Pad Wide Settings checkboxes for comment-pane
// visibility. Helper owns the storage, broadcast, enforce, and i18n wiring.
const commentsToggle = padToggle({
pluginName: 'ep_comments_page',
settingId: 'comments',
l10nId: 'ep_comments_page.show_comments',
defaultLabel: 'Show Comments',
defaultEnabled: true,
});
// #12/#5: the all-comments overview is a checkbox in the user Settings pane
// (not a toolbar icon), built with the ep_plugin_helpers `toggle` helper —
// cookie-persisted, default off. The client shows/hides the panel from it.
const overviewToggle = toggle({
pluginName: 'ep_comments_page',
settingId: 'comments-overview',
templatePath: 'ep_comments_page/templates/commentsOverviewSetting.ejs',
defaultEnabled: false,
});
exports.loadSettings = commentsToggle.loadSettings;
// Compose both settings checkboxes (Show Comments + Show all comments) into the
// single eejsBlock_mySettings hook.
exports.eejsBlock_mySettings = (hookName, args, cb) =>
commentsToggle.eejsBlock_mySettings(hookName, args, () =>
overviewToggle.eejsBlock_mySettings(hookName, args, cb));
exports.eejsBlock_padSettings = commentsToggle.eejsBlock_padSettings;
let io;
exports.exportEtherpadAdditionalContent = (hookName, context, callback) => callback(['comments']);
exports.padRemove = async (hookName, context) => {
await Promise.all([
commentManager.deleteCommentReplies(context.pad.id),
commentManager.deleteComments(context.pad.id),
]);
};
exports.padCopy = async (hookName, context) => {
await Promise.all([
commentManager.copyComments(context.originalPad.id, context.destinationID),
commentManager.copyCommentReplies(context.originalPad.id, context.destinationID),
]);
};
exports.handleMessageSecurity = async (hookName, ctx) => {
// ctx.client was renamed to ctx.socket in newer versions of Etherpad. Fall back to ctx.client in
// case this plugin is installed on an older version of Etherpad.
const {message, socket = ctx.client} = ctx;
const {type: mtype, data: {type: dtype, apool, changeset} = {}} = message;
if (mtype !== 'COLLABROOM') return;
if (dtype !== 'USER_CHANGES') return;
// Nothing needs to be done if the user already has write access.
if (!padMessageHandler.sessioninfos[socket.id].readonly) return;
// Read-only commenting is opt-in (#8). When it's off (the default), fall
// through without granting permission so core's normal read-only enforcement
// rejects the change.
if (!(settings.ep_comments_page && settings.ep_comments_page.allowReadonlyComments)) return;
const pool = new AttributePool().fromJsonable(apool);
const cs = Changeset.unpack(changeset);
const opIter = Changeset.opIterator(cs.ops);
while (opIter.hasNext()) {
const op = opIter.next();
// Only operations that manipulate the 'comment' attribute on existing text are allowed.
if (op.opcode !== '=') return;
const forbiddenAttrib = new Error();
try {
Changeset.eachAttribNumber(op.attribs, (n) => {
// Use an exception to break out of the iteration early.
if (pool.getAttribKey(n) !== 'comment') throw forbiddenAttrib;
});
} catch (err) {
if (err !== forbiddenAttrib) throw err;
return;
}
}
return true;
};
exports.socketio = (hookName, args, cb) => {
io = args.io.of('/comment');
io.on('connection', (socket) => {
const handler = (fn) => (...args) => {
const respond = args.pop();
(async () => await fn(...args))().then(
(val) => respond(null, val),
(err) => respond({name: err.name, message: err.message}));
};
// Join the rooms
socket.on('getComments', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
// Put read-only and read-write users in the same socket.io "room" so that they can see each
// other's updates.
socket.join(padId);
return await commentManager.getComments(padId);
}));
socket.on('getCommentReplies', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
return await commentManager.getCommentReplies(padId);
}));
// Where each comment's text sits at a given revision (for the timeslider).
socket.on('getCommentLocations', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
const pad = await padManager.getPad(padId);
const head = pad.getHeadRevisionNumber();
let rev = Number(data.rev);
if (!Number.isInteger(rev) || rev < 0 || rev > head) rev = head;
const atext = await pad.getInternalRevisionAText(rev);
return {rev, locations: commentLocationsFromAText(atext, pad.pool)};
}));
// On add events
socket.on('addComment', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
const content = data.comment;
// Stamp the authoritative author server-side so a comment can't be created
// labelled as someone else (#222). Fall back to the supplied value when no
// token is resolvable (e.g. API/test contexts without the cookie).
const resolvedAuthor = await authorIdForSocket(socket);
if (content && resolvedAuthor) content.author = resolvedAuthor;
const [commentId, comment] = await commentManager.addComment(padId, content);
if (commentId != null && comment != null) {
socket.broadcast.to(padId).emit('pushAddComment', commentId, comment);
return [commentId, comment];
}
}));
socket.on('deleteComment', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
// Authorize against the server-resolved author, never the client-supplied
// authorId (which is spoofable) (#222).
const authorId = await authorIdForSocket(socket);
await commentManager.deleteComment(padId, data.commentId, authorId);
socket.broadcast.to(padId).emit('commentDeleted', data.commentId);
}));
socket.on('revertChange', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
// Broadcast to all other users that this change was accepted.
// Note that commentId here can either be the commentId or replyId..
await commentManager.changeAcceptedState(padId, data.commentId, false);
socket.broadcast.to(padId).emit('changeReverted', data.commentId);
}));
socket.on('acceptChange', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
// Broadcast to all other users that this change was accepted.
// Note that commentId here can either be the commentId or replyId..
await commentManager.changeAcceptedState(padId, data.commentId, true);
socket.broadcast.to(padId).emit('changeAccepted', data.commentId);
}));
socket.on('bulkAddComment', handler(async (padId, data) => {
padId = (await readOnlyManager.getIds(padId)).padId;
const [commentIds, comments] = await commentManager.bulkAddComments(padId, data);
socket.broadcast.to(padId).emit('pushAddCommentInBulk');
// {c-123:data, c-124:data}
return Object.fromEntries(commentIds.map((id, i) => [id, comments[i]]));
}));
socket.on('bulkAddCommentReplies', handler(async (padId, data) => {
padId = (await readOnlyManager.getIds(padId)).padId;
const [repliesId, replies] = await commentManager.bulkAddCommentReplies(padId, data);
socket.broadcast.to(padId).emit('pushAddCommentReply', repliesId, replies);
return repliesId.map((id, i) => [id, replies[i]]);
}));
socket.on('updateCommentText', handler(async (data) => {
const {commentId, commentText} = data;
const {padId} = await readOnlyManager.getIds(data.padId);
// Authorize against the server-resolved author, never the client-supplied
// authorId (which is spoofable) (#222).
const authorId = await authorIdForSocket(socket);
await commentManager.changeCommentText(padId, commentId, commentText, authorId);
socket.broadcast.to(padId).emit('textCommentUpdated', commentId, commentText);
}));
socket.on('addCommentReply', handler(async (data) => {
const {padId} = await readOnlyManager.getIds(data.padId);
// Stamp the authoritative author server-side (#222); fall back to the
// supplied value when no token is resolvable (API/test contexts).
const resolvedAuthor = await authorIdForSocket(socket);
if (data && resolvedAuthor) data.author = resolvedAuthor;
const [replyId, reply] = await commentManager.addCommentReply(padId, data);
reply.replyId = replyId;
socket.broadcast.to(padId).emit('pushAddCommentReply', replyId, reply);
return [replyId, reply];
}));
});
return cb();
};
exports.eejsBlock_dd_insert =
template('ep_comments_page/templates/menuButtons.ejs');
exports.padInitToolbar = (hookName, args, cb) => {
const toolbar = args.toolbar;
const button = toolbar.button({
command: 'addComment',
localizationId: 'ep_comments_page.add_comment.title',
// `acl-write` lets Etherpad core hide the button on read-only pads
// (`.readonly .acl-write { display: none }`) — see issue #204.
class: 'buttonicon buttonicon-comment-medical acl-write',
});
toolbar.registerButton('addComment', button);
return cb();
};
// Skip the default toolbar button when the admin placed `addComment` in a
// custom toolbar layout. Uses the ep_plugin_helpers template() helper.
exports.eejsBlock_editbarMenuLeft = template('ep_comments_page/templates/commentBarButtons.ejs', {
skip: () => JSON.stringify(settings.toolbar).indexOf('addComment') > -1,
});
exports.eejsBlock_scripts = (hookName, args, cb) => {
args.content += eejs.require('ep_comments_page/templates/comments.html');
args.content += eejs.require('ep_comments_page/templates/commentIcons.html');
return cb();
};
exports.eejsBlock_styles =
template('ep_comments_page/templates/styles.html');
// Read-only comments in the timeslider (issue #33). Injected as plain scripts
// rather than a client hook because older timeslider bundles can't load plugin
// hooks. socket.io's served client is loaded first so the script has a global
// `io`. Relative paths resolve from /p/<pad>/timeslider to the site root.
exports.eejsBlock_timesliderScripts = (hookName, args, cb) => {
args.content +=
'<script src="../../socket.io/socket.io.js"></script>' +
'<script src="../../static/plugins/ep_comments_page/static/js/timeslider.js"></script>';
return cb();
};
exports.clientVars = async (hook, context) => {
const displayCommentAsIcon =
settings.ep_comments_page ? settings.ep_comments_page.displayCommentAsIcon : false;
const highlightSelectedText =
settings.ep_comments_page ? settings.ep_comments_page.highlightSelectedText : false;
// #95: the floating add-comment button is on unless an admin disables it.
const floatingCommentButton = !(settings.ep_comments_page &&
settings.ep_comments_page.floatingCommentButton === false);
// #6: author-colour accent is on unless an admin disables it.
const showAuthorColor = !(settings.ep_comments_page &&
settings.ep_comments_page.showAuthorColor === false);
// #8: read-only viewers may comment only when an admin opts in (default off).
const allowReadonlyComments =
!!(settings.ep_comments_page && settings.ep_comments_page.allowReadonlyComments);
// Merge in the padToggle helper's clientVars block so the client-side
// helper can read padWideSupported/initialPadEnabled/etc.
const helperVars = await commentsToggle.clientVars(hook, context);
return Object.assign(
{displayCommentAsIcon, highlightSelectedText, floatingCommentButton, showAuthorColor,
allowReadonlyComments},
helperVars);
};
exports.expressCreateServer = (hookName, args, callback) => {
args.app.get('/p/:pad{/:rev}/comments', async (req, res) => {
if (!await apiUtils.validateAuth(req, res)) return;
// sanitize pad id before continuing
const padIdReceived = (await readOnlyManager.getIds(apiUtils.sanitizePadId(req))).padId;
let data;
try {
data = await commentManager.getComments(padIdReceived);
} catch (err) {
console.error(err.stack ? err.stack : err.toString());
res.json({code: 2, message: 'internal error', data: null});
return;
}
if (data == null) return;
res.json({code: 0, data});
});
// Helper that returns request fields from either req.body (when Etherpad's
// express body-parser middleware has already parsed JSON or urlencoded) or
// by parsing the raw body with Formidable (multipart/form-data uploads).
// Formidable v3 returns array values; flatten them so callers can use
// fields.data without indexing.
const parseRequestFields = async (req) => {
if (req.body && Object.keys(req.body).length > 0) return req.body;
const raw = await new Promise((resolve, reject) => {
new Formidable().parse(req, (err, fields) => err ? reject(err) : resolve(fields));
});
const flat = {};
for (const [k, v] of Object.entries(raw || {})) {
flat[k] = Array.isArray(v) ? v[0] : v;
}
return flat;
};
args.app.post('/p/:pad{/:rev}/comments', async (req, res) => {
if (!await apiUtils.validateAuth(req, res)) return;
const fields = await parseRequestFields(req);
// check required fields from comment data
if (!apiUtils.validateRequiredFields(fields, ['data'], res)) return;
// sanitize pad id before continuing
const padIdReceived = (await readOnlyManager.getIds(apiUtils.sanitizePadId(req))).padId;
// create data to hold comment information:
let data;
try {
data = JSON.parse(fields.data);
} catch (err) {
res.json({code: 1, message: 'data must be a JSON', data: null});
return;
}
let commentIds, comments;
try {
[commentIds, comments] = await commentManager.bulkAddComments(padIdReceived, data);
} catch (err) {
console.error(err.stack ? err.stack : err.toString());
res.json({code: 2, message: 'internal error', data: null});
return;
}
if (commentIds == null) return;
for (let i = 0; i < commentIds.length; i++) {
io.to(padIdReceived).emit('pushAddComment', commentIds[i], comments[i]);
}
res.json({code: 0, commentIds});
});
args.app.get('/p/:pad{/:rev}/commentReplies', async (req, res) => {
if (!await apiUtils.validateAuth(req, res)) return;
// sanitize pad id before continuing
const padIdReceived = (await readOnlyManager.getIds(apiUtils.sanitizePadId(req))).padId;
// call the route with the pad id sanitized
let data;
try {
data = await commentManager.getCommentReplies(padIdReceived);
} catch (err) {
console.error(err.stack ? err.stack : err.toString());
res.json({code: 2, message: 'internal error', data: null});
return;
}
if (data == null) return;
res.json({code: 0, data});
});
args.app.post('/p/:pad{/:rev}/commentReplies', async (req, res) => {
if (!await apiUtils.validateAuth(req, res)) return;
const fields = await parseRequestFields(req);
// check required fields from comment data
if (!apiUtils.validateRequiredFields(fields, ['data'], res)) return;
// sanitize pad id before continuing
const padIdReceived = (await readOnlyManager.getIds(apiUtils.sanitizePadId(req))).padId;
// create data to hold comment reply information:
let data;
try {
data = JSON.parse(fields.data);
} catch (err) {
res.json({code: 1, message: 'data must be a JSON', data: null});
return;
}
let replyIds, replies;
try {
[replyIds, replies] = await commentManager.bulkAddCommentReplies(padIdReceived, data);
} catch (err) {
console.error(err.stack ? err.stack : err.toString());
res.json({code: 2, message: 'internal error', data: null});
return;
}
if (replyIds == null) return;
for (let i = 0; i < replyIds.length; i++) {
replies[i].replyId = replyIds[i];
io.to(padIdReceived).emit('pushAddCommentReply', replyIds[i], replies[i]);
}
res.json({code: 0, replyIds});
});
return callback();
};