-
Notifications
You must be signed in to change notification settings - Fork 26
/
Copy pathaccounts-meld-server.js
641 lines (604 loc) · 19.7 KB
/
accounts-meld-server.js
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
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
/* global
AccountsEmailsField: false,
AccountsMeld: true,
checkForMelds: true,
MeldActions: true,
updateOrCreateUserFromExternalService: true
*/
'use strict';
// ----------------------------------------
// Collection to keep meld action documents
// ----------------------------------------
// Each document is composed as follow:
// {
// dst_user_id: user_id associated to the account which should survive
// src_user_id: user_id associated to the account to be deleted
// meld: one of ["ask", yes", "not_now", "never", "melding", "done"]
// used to track the status of the meld action.
// src_info: { a bit of information about the source account
// emails: src_user.registered_emails (see accounts-emails-field package)
// services: array of registered services' name, but 'resume'
// }
// dst_info: { a bit of information about the destination account
// emails: dst_user.registered_emails (see accounts-emails-field package)
// services: array of registered services' name, but 'resume'
// }
// }
//
//
// Server - Client interaction flow:
//
// 1) a meld action is created: 'meld': 'ask'
// 2) the client is prompted with a question for which answer allowed values are
// - 'yes' -> requires to perform the meld action
// - 'not_now' -> requires to as again at the next login
// - 'never' -> requires not to meld and not to bother again...
//
// 3a) client updates the meld action an sets 'meld': 'yes'
// 3aa) server sets 'meld': 'melding'
// (so that client can visualize something...)
// 3ab) in case the meld action cannot be performed because of the same service
// appearing inside both accounts but with different ids the server sets
// 'meld': 'ask'
// ...the hope is the user can remove one of the two conflitting services
// and then ask again to meld.
// should be probably very rare, but SOMETHING BETTER SHOULD BE DONE!
// 3ac) when the meld action is completed the server sets 'meld': 'done'
// 3ad) the client should visualize something and then set 'meld': 'ok'
//
// 3b) client updates the meld action an sets 'meld': 'not_now'
// 3ba) at the next login the server changes 'meld': 'not_now' --> 'meld': 'ask'
//
// 3c) client updates the meld action an sets 'meld': 'never'
// 3ca) at the next login the server sees the mels action with 'meld': 'never'
// and does nothing...
//
MeldActions = new Meteor.Collection("meldActions");
// Allow client-side modification of a meld action only
// to catch the user answer after having proposed a meld
// and to delete the document of a completed meld action.
MeldActions.allow({
update: function(userId, doc, fieldNames, modifier) {
// Only the destination user can modify a document
if (userId !== doc.dst_user_id) {
return false;
}
// ...and only the field meld can be modified
if (fieldNames.length > 1 || fieldNames[0] !== "meld") {
return false;
}
// ...and only if meld is 'ask' or 'melding'
if (!_.contains(['ask', 'melding'], doc.meld)) {
return false;
}
// ...when meld is "ask" only ["yes", "not_now", "never"] are allowed
if (doc.meld === "ask") {
var allowedModifiers = [{
'$set': {
meld: 'yes'
}
}, {
'$set': {
meld: 'not_now'
}
}, {
'$set': {
meld: 'never'
}
}];
var notAllowed = _.every(allowedModifiers, function(mod) {
return !_.isEqual(mod, modifier);
});
if (notAllowed) {
return false;
}
}
// ...when meld is "melding" only answer "ok" is allowed
if (doc.meld === "melding") {
if (!_.isEqual(modifier, {
'$set': {
meld: 'ok'
}
})) {
return false;
}
}
// ...only in case all the above conditions are satisfied:
return true;
},
remove: function(userId, doc) {
// no removals unless the meld action is completed!
return doc.meld === "done";
}
});
// Publish meld action registered for the current user
// ...except those marked with "ok", yes", "not_now", "never"
// which are not meant to be displayed client-side.
Meteor.publish("pendingMeldActions", function() {
return MeldActions.find({
dst_user_id: this.userId,
meld: {
$nin: ["not_now", "never", "ok", "yes"]
}
});
});
// Observe the changes of meld actions to respond to
// client-side user interactions:
// - remove unnecessary data when a meld action is marked
// as to be never performed
// - actually proceed to meld accounts when the client-side
// answer is "yes"
MeldActions.find().observeChanges({
changed: function(id, fields) {
if (fields.meld === "never") {
// Remove unnecessary data from the document
MeldActions.update(id, {
$unset: {
src_info: "",
dst_info: ""
}
});
} else if (fields.meld === "yes") {
// Proceed with actual melding of the two accounts...
AccountsMeld.executeMeldAction(id);
}
}
});
// ------------------
// AccountsMeld class
// ------------------
var AM = function() {};
// Configuration pattern to be checked with check
AM.prototype.CONFIG_PAT = {
askBeforeMeld: Match.Optional(Boolean),
checkForConflictingServices: Match.Optional(Boolean),
meldUserCallback: Match.Optional(Match.Where(_.isFunction)),
meldDBCallback: Match.Optional(Match.Where(_.isFunction)),
serviceAddedCallback: Match.Optional(Match.Where(_.isFunction))
};
// Current configuration values
AM.prototype._config = {
// Flags telling whether to ask the user before melding any two accounts
askBeforeMeld: false,
// Flags telling whether to check for conflicting services before melding
checkForConflictingServices: false,
// Reference to the callback to meld user objects
meldUserCallback: null,
// Reference to the callback to meld collections' objects
meldDBCallback: null,
// Reference to the callback to update user profile when a service is added
serviceAddedCallback: null
};
AM.prototype._meldUsersObject = function(srcUser, dstUser) {
// Checks whether a callback for melding users' object was specified
var meldUserCallback = this.getConfig('meldUserCallback');
// ...in case it was, uses the requested one
if (meldUserCallback) {
var meldedUser = meldUserCallback(srcUser, dstUser);
meldedUser = _.omit(
meldedUser,
'_id', 'services', 'emails', 'registered_emails'
);
_.each(meldedUser, function(value, key) {
dstUser[key] = value;
});
}
// ...otherwise perfors some default fusion
else {
// 'createdAt' field: keep the oldest between the two
if (srcUser.createdAt < dstUser.createdAt) {
dstUser.createdAt = srcUser.createdAt;
}
// 'profile' field
var profile = {};
_.defaults(profile, dstUser.profile || {});
_.defaults(profile, srcUser.profile || {});
if (!_.isEmpty(profile)) {
dstUser.profile = profile;
}
}
// 'services' field (at this point we know some check was already done...)
// adds services appearing inside the src user which
// do not appear inside the destination user (but for 'resume')
// TODO: check whether we need to re-encrypt data using
// 'pinEncryptedFieldsToUser'. See
// meteor/packages/accounts-base/accounts_server.js#L1136
var newServices = {};
var srcServices = _.omit(srcUser.services, _.keys(dstUser.services));
// NOTE: it is mandatory to skip also 'resume' data in order to prevent the
// current login action to be interrupted in case the srcUser actually
// has a different and outdated 'resume' data.
srcServices = _.omit(srcUser.services, "resume");
_.each(_.keys(srcServices), function(serviceName) {
newServices['services.' + serviceName] = srcServices[serviceName];
dstUser.services[serviceName] = srcServices[serviceName];
});
// TODO: check there are no overlapping services which have different ids!!!
// 'emails' field: fuses the two emails fields, giving precedence to
// verified ones...
var srcEmails = srcUser.emails || [];
var dstEmails = dstUser.emails || [];
// creates an object with addresses as keys and verification status as values
var emails = {};
_.each(_.flatten([srcEmails, dstEmails]), function(email) {
emails[email.address] = emails[email.address] || email.verified;
});
// transforms emails back to
// [{address: addr1, verified: bool}, {address: addr2, verified: bool}, ...]
dstUser.emails = _.map(emails, function(verified, address) {
return {
address: address,
verified: verified
};
});
if (!dstUser.emails.length) {
delete dstUser.emails;
}
// updates the registered_emails field
AccountsEmailsField.updateEmails({
user: dstUser
});
// Removes the old user
Meteor.users.remove(srcUser._id);
// Updates the current user
Meteor.users.update(dstUser._id, {
$set: _.omit(dstUser, "_id", "services")
});
Meteor.users.update(dstUser._id, {
$set: newServices
});
};
AM.prototype.getConfig = function(paramName) {
return this._config[paramName];
};
AM.prototype.configure = function(config) {
check(config, this.CONFIG_PAT);
// Update the current configuration
this._config = _.defaults(config, this._config);
};
AM.prototype.createMeldAction = function(srcUser, dstUser) {
MeldActions.insert({
src_user_id: srcUser._id,
dst_user_id: dstUser._id,
meld: "ask",
src_info: {
emails: srcUser.registered_emails,
services: _.without(_.keys(srcUser.services), "resume")
},
dst_info: {
emails: dstUser.registered_emails,
services: _.without(_.keys(dstUser.services), "resume")
}
});
};
AM.prototype.executeMeldAction = function(id) {
// Retrieve the meld action document
var meldAction = MeldActions.findOne(id);
// Marks the meld action as "melding"
MeldActions.update(meldAction._id, {
$set: {
meld: "melding"
}
});
// Retrieve the source account
var srcUser = Meteor.users.findOne(meldAction.src_user_id);
// Retrieve the destination account
var dstUser = Meteor.users.findOne(meldAction.dst_user_id);
// Actually melds the two accounts
var meldResult = this.meldAccounts(srcUser, dstUser);
if (meldResult) {
// Marks the meld action as "done"
MeldActions.update(meldAction._id, {
$set: {
meld: "done"
}
});
// Possibly removes old meld actions registered for the same two
// accounts but for the opposite direction
var invMeldAction = MeldActions.findOne({
src_user_id: meldAction.dst_user_id,
dst_user_id: meldAction.src_user_id,
});
if (invMeldAction) {
MeldActions.remove(invMeldAction._id);
}
} else {
// XXX TODO: For now this seems the only thing to be improved in a near
// future. Some error status and better client communication of
// the problem should be put in place...
MeldActions.update(meldAction._id, {
$set: {
meld: "not_now"
}
});
}
};
AM.prototype.meldAccounts = function(srcUser, dstUser) {
//checks there are no overlapping services which have different ids!!!
var canMeld = true;
// Checks for conflicting services before proceeding with actual melding
if (this.getConfig('checkForConflictingServices')) {
if (!!srcUser.services && !!dstUser.services) {
_.each(_.keys(srcUser.services), function(serviceName) {
if (serviceName !== "resume" && !!dstUser.services[serviceName]) {
if (serviceName === "password") {
var sameService = _.isEqual(
srcUser.services[serviceName],
dstUser.services[serviceName]
);
if (!sameService) {
canMeld = false;
}
} else {
var srcService = srcUser.services[serviceName];
var dstService = dstUser.services[serviceName];
if (!!srcService.id &&
!!dstService.id &&
srcService.id !== dstService.id
) {
canMeld = false;
}
}
}
});
}
}
if (!canMeld) {
return false;
}
// Melds users'object
this._meldUsersObject(srcUser, dstUser);
// Check whether a callback for DB document migration was specified
var meldDBCallback = this.getConfig('meldDBCallback');
if (meldDBCallback) {
meldDBCallback(srcUser._id, dstUser._id);
}
return true;
};
AccountsMeld = new AM();
// ------------------------------------------------
// Callback functions to be registered with 'hooks'
// ------------------------------------------------
checkForMelds = function(dstUser) {
// Updates all possibly pending meld actions...
MeldActions.update({
dst_user_id: dstUser._id,
meld: "not_now"
}, {
$set: {
meld: "ask"
}
}, {
multi: true
});
// Picks up verified email addresses and creates a list like
// [
// {$elemMatch: {"address": addr1, "verified": true}},
// {$elemMatch: {"address": addr2, "verified": true}},
// ...
// ]
var queryEmails = _.chain(dstUser.registered_emails)
.filter(function(email) {
return email.verified;
})
.map(function(email) {
return {
"registered_emails": {
$elemMatch: email
}
};
})
.value();
// In case there is at least one registered address
if (queryEmails.length) {
// Finds users with at least one registered email address matching the
// above list
if (queryEmails.length > 1) {
queryEmails = {
$or: queryEmails
};
} else {
queryEmails = queryEmails[0];
}
// Excludes current user...
queryEmails._id = {
$ne: dstUser._id
};
var users = Meteor.users.find(queryEmails);
users.forEach(function(user) {
if (AccountsMeld.getConfig('askBeforeMeld')) {
// Checks if there is already a document about this meld action
var meldAction = MeldActions.findOne({
src_user_id: user._id,
dst_user_id: dstUser._id
});
if (meldAction) {
// If the last time the answer was "Not now", ask again...
if (meldAction.meld === "not_now") {
MeldActions.update(meldAction._id, {
$set: {
meld: "ask"
}
});
}
} else {
// Creates a new meld action
AccountsMeld.createMeldAction(user, dstUser);
}
} else {
// Directly melds the two accounts
AccountsMeld.meldAccounts(user, dstUser);
}
});
}
};
var createServiceSelector = function(serviceName, serviceData) {
// Selector construction copied from
// accounts-base/accounts_server.js Lines 1114-1131
var selector = {};
var serviceIdKey = "services." + serviceName + ".id";
// XXX Temporary special case for Twitter. (Issue #629)
// The serviceData.id will be a string representation of an integer.
// We want it to match either a stored string or int representation.
// This is to cater to earlier versions of Meteor storing twitter
// user IDs in number form, and recent versions storing them as strings.
// This can be removed once migration technology is in place, and twitter
// users stored with integer IDs have been migrated to string IDs.
if (serviceName === "twitter" && !isNaN(serviceData.id)) {
selector.$or = [{}, {}];
selector.$or[0][serviceIdKey] = serviceData.id;
selector.$or[1][serviceIdKey] = parseInt(serviceData.id, 10);
} else {
selector[serviceIdKey] = serviceData.id;
}
return selector;
};
var origUpdateOrCreateUserFromExternalService =
Accounts.updateOrCreateUserFromExternalService;
updateOrCreateUserFromExternalService = function(serviceName, serviceData, options) {
var
currentUser = Meteor.user(),
selector,
setAttr,
serviceIdKey,
user;
if (currentUser) {
// The user was already logged in with a different account
// Checks if the service is already registered with this same account
if (!currentUser.services[serviceName]) {
// It may be that the same service is already used with a different
// account. Checks if there is already an account with this service
// Creates a selector for the current service
selector = createServiceSelector(serviceName, serviceData);
// Look for a user with the appropriate service user id.
user = Meteor.users.findOne(selector);
if (!user) {
// This service is being used for the first time!
// Simply add the service to the current user, and that's it!
setAttr = {};
serviceIdKey = "services." + serviceName + ".id";
setAttr[serviceIdKey] = serviceData.id;
// This is just to fake updateOrCreateUserFromExternalService so to have
// it attach the new service to the existing user instead of creating a
// new one
Meteor.users.update({
_id: currentUser._id
}, {
$set: setAttr
});
// Now calls original updateOrCreateUserFromExternalService
origUpdateOrCreateUserFromExternalService.apply(this, arguments);
// Reloads updated currentUser
currentUser = Meteor.users.findOne(currentUser._id);
// Updates the registered_emails field
AccountsEmailsField.updateEmails({
user: currentUser
});
// Checks whether a callback for user update after a new service is
// added was specified
var serviceAddedCbk = AccountsMeld.getConfig('serviceAddedCallback');
if (serviceAddedCbk) {
serviceAddedCbk(currentUser._id, serviceName);
}
// Cancels the login to save some data exchange with the client
// currentUser will remain logged in
return {
type: serviceName,
error: new Meteor.Error(
Accounts.LoginCancelledError.numericError,
"Service correctly added to the current user, no need to proceed!"
)
};
} else {
// This service was already registered for "user"
if (AccountsMeld.getConfig('askBeforeMeld')) {
// Checks if there is already a document about this meld action
var meldAction = MeldActions.findOne({
src_user_id: user._id,
dst_user_id: currentUser._id
});
if (meldAction) {
// If the last time the answer was "Not now", ask again...
if (meldAction.meld === "not_now") {
MeldActions.update(meldAction._id, {
$set: {
meld: "ask"
}
});
}
} else {
// Creates a new meld action
AccountsMeld.createMeldAction(user, currentUser);
}
// Cancels the login to keep currentUser logged in...
return {
type: serviceName,
error: new Meteor.Error(
Accounts.LoginCancelledError.numericError,
"Another account registered with the same service was found!"
)
};
} else {
// Directly melds the two accounts
AccountsMeld.meldAccounts(user, currentUser);
// Cancels the login
return {
type: serviceName,
error: new Meteor.Error(
Accounts.LoginCancelledError.numericError,
"Another account registered with the same service was found, " +
"and melded with the current one!"
)
};
}
}
}
} else {
// The user is logging in now...
// Only In case automatic melding is set
if (!AccountsMeld.getConfig('askBeforeMeld')) {
// Creates a selector for the current service
selector = createServiceSelector(serviceName, serviceData);
// Look for a user with the appropriate service user id.
user = Meteor.users.findOne(selector);
if (!user) {
// This service is being used for the first time!
// Extracts the email address associated with the current service
var serviceEmails = AccountsEmailsField.getEmailsFromService(
serviceName, serviceData
).filter(function(serviceEmail) {
return serviceEmail.verified;
});
// In case it is a verified email...
if (serviceEmails.length) {
// ...checks whether the email address used with the service is
// already associated with an existing account.
selector = {
$or: serviceEmails.map(function(serviceEmail) {
return {
"registered_emails": {$elemMatch: serviceEmail}
};
})
};
var otherUser = Meteor.users.findOne(selector);
if (otherUser) {
// Simply add the service to 'user', and that's it!
setAttr = {};
serviceIdKey = "services." + serviceName + ".id";
setAttr[serviceIdKey] = serviceData.id;
// This is just to fake updateOrCreateUserFromExternalService so to
// have it attach the new service to the existing user instead of
// creating a new one
Meteor.users.update({
_id: otherUser._id
}, {
$set: setAttr
});
}
}
}
}
}
// Let the user in!
return origUpdateOrCreateUserFromExternalService.apply(this, arguments);
};