Skip to content

Commit 7332c22

Browse files
committed
feat: multi-profile picture support
1 parent 3426bf4 commit 7332c22

File tree

6 files changed

+389
-9
lines changed

6 files changed

+389
-9
lines changed

config/passport.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,21 @@ async function handleAuthLogin(req, accessToken, refreshToken, providerName, par
9898
user[providerName] = providerProfile.id;
9999
user.profile.name = user.profile.name || providerProfile.name;
100100
user.profile.gender = user.profile.gender || providerProfile.gender;
101-
user.profile.picture = user.profile.picture || providerProfile.picture;
101+
102+
if (providerProfile.picture) {
103+
if (!user.profile.pictures || user.profile.pictureSource === undefined) {
104+
// legacy account (pre-multi-picture support)
105+
user.profile.pictures = new Map();
106+
user.profile.picture = providerProfile.picture;
107+
user.profile.pictureSource = providerName;
108+
}
109+
user.profile.pictures.set(providerName, providerProfile.picture);
110+
if (user.profile.pictureSource === 'gravatar') {
111+
user.profile.picture = providerProfile.picture;
112+
user.profile.pictureSource = providerName;
113+
}
114+
}
115+
102116
user.profile.location = user.profile.location || providerProfile.location;
103117
user.profile.website = user.profile.website || providerProfile.website;
104118
user.profile.email = user.profile.email || providerProfile.email;
@@ -131,7 +145,14 @@ async function handleAuthLogin(req, accessToken, refreshToken, providerName, par
131145
}
132146
user.profile.name = providerProfile.name;
133147
user.profile.gender = providerProfile.gender;
134-
user.profile.picture = providerProfile.picture;
148+
149+
if (providerProfile.picture) {
150+
user.profile.pictures = new Map();
151+
user.profile.pictures.set(providerName, providerProfile.picture);
152+
user.profile.picture = providerProfile.picture;
153+
user.profile.pictureSource = providerName;
154+
}
155+
135156
user.profile.location = providerProfile.location;
136157
user.profile.website = providerProfile.website;
137158
user.profile.email = providerProfile.email;

controllers/user.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,19 @@ exports.postUpdateProfile = async (req, res, next) => {
310310
user.profile.gender = req.body.gender || '';
311311
user.profile.location = req.body.location || '';
312312
user.profile.website = req.body.website || '';
313+
314+
// Handle picture source selection
315+
if (typeof req.body.pictureSource === 'string') {
316+
const newProfilePictureSource = req.body.pictureSource.trim();
317+
if (newProfilePictureSource && user.profile.pictures && user.profile.pictures.has(newProfilePictureSource)) {
318+
user.profile.pictureSource = newProfilePictureSource;
319+
user.profile.picture = user.profile.pictures.get(newProfilePictureSource);
320+
} else {
321+
req.flash('errors', { msg: 'Invalid profile picture change request.' });
322+
return res.redirect('/account');
323+
}
324+
}
325+
313326
await user.save();
314327
req.flash('success', { msg: 'Profile information has been updated.' });
315328
res.redirect('/account');
@@ -383,6 +396,32 @@ exports.getOauthUnlink = async (req, res, next) => {
383396
const user = await User.findById(req.user.id);
384397
user[provider.toLowerCase()] = undefined;
385398
const tokensWithoutProviderToUnlink = user.tokens.filter((token) => token.kind !== provider.toLowerCase());
399+
400+
// Remove provider's picture entry
401+
if (user.profile.pictures && user.profile.pictures.has(provider.toLowerCase())) {
402+
user.profile.pictures.delete(provider.toLowerCase());
403+
404+
// If current picture source was the unlinked provider, select fallback
405+
if (user.profile.pictureSource === provider.toLowerCase()) {
406+
let fallbackSource = null;
407+
408+
// Priority order: gravatar -> any remaining provider -> undefined
409+
if (user.profile.pictures.has('gravatar')) {
410+
fallbackSource = 'gravatar';
411+
} else if (user.profile.pictures.size > 0) {
412+
fallbackSource = user.profile.pictures.keys().next().value;
413+
}
414+
415+
if (fallbackSource) {
416+
user.profile.pictureSource = fallbackSource;
417+
user.profile.picture = user.profile.pictures.get(fallbackSource);
418+
} else {
419+
user.profile.pictureSource = undefined;
420+
user.profile.picture = undefined;
421+
}
422+
}
423+
}
424+
386425
// Some auth providers do not provide an email address in the user profile.
387426
// As a result, we need to verify that unlinking the provider is safe by ensuring
388427
// that another login method exists.

models/User.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ const userSchema = new mongoose.Schema(
5656
location: String,
5757
website: String,
5858
picture: String,
59+
pictureSource: String,
60+
61+
pictures: {
62+
type: Map,
63+
of: String,
64+
},
5965
},
6066
},
6167
{ timestamps: true },
@@ -135,6 +141,34 @@ userSchema.methods.gravatar = function gravatarUrl(size) {
135141
return `https://gravatar.com/avatar/${sha256}?s=${size}&d=retro`;
136142
};
137143

144+
userSchema.pre('save', function updateGravatarOnEmailChange() {
145+
if (!this.isModified('email')) return;
146+
if (!this.profile.pictures) {
147+
this.profile.pictures = new Map();
148+
}
149+
if (!this.profile.pictureSource) {
150+
this.profile.pictureSource = 'gravatar';
151+
}
152+
const url = this.gravatar();
153+
this.profile.pictures.set('gravatar', url);
154+
if (this.profile.pictureSource === 'gravatar') {
155+
this.profile.picture = url;
156+
}
157+
});
158+
159+
userSchema.methods.noMultiPictureUpgrade = function noMultiPictureUpgrade() {
160+
if (!this.profile.pictures) {
161+
this.profile.pictures = new Map();
162+
}
163+
if (!this.profile.pictureSource) {
164+
this.profile.pictureSource = 'gravatar';
165+
}
166+
const url = this.gravatar();
167+
this.profile.pictures.set('gravatar', url);
168+
if (this.profile.pictureSource === 'gravatar') {
169+
this.profile.picture = url;
170+
}
171+
};
138172
// Helper methods for creating hashed IP addresses
139173
// This is used to prevent CSRF attacks by ensuring that the token is valid for
140174
// the IP address it was generated from

test/models.test.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,138 @@ describe('User Model', () => {
564564
const url = user.gravatar(200);
565565
expect(url).to.include('00000000000000000000000000000000');
566566
});
567+
568+
it('Scenario 1: Gravatar generation when email is present - after save', async () => {
569+
const user = new User({
570+
email: 'test@gmail.com',
571+
password: 'password123',
572+
});
573+
574+
await user.save();
575+
576+
const sha256 = '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674';
577+
578+
expect(user.profile.pictures).to.be.instanceOf(Map);
579+
expect(user.profile.pictures.get('gravatar')).to.include(sha256);
580+
expect(user.profile.pictureSource).to.equal('gravatar');
581+
expect(user.profile.picture).to.include(sha256);
582+
});
583+
584+
it('Scenario 2: Gravatar update on email change', async () => {
585+
const user = new User({
586+
email: 'user1@example.com',
587+
password: 'password123',
588+
});
589+
590+
await user.save();
591+
592+
const originalGravatar = user.profile.pictures.get('gravatar');
593+
expect(user.profile.picture).to.equal(originalGravatar);
594+
595+
// Change email
596+
user.email = 'user2@example.com';
597+
await user.save();
598+
599+
const newGravatar = user.profile.pictures.get('gravatar');
600+
expect(newGravatar).to.not.equal(originalGravatar);
601+
expect(user.profile.picture).to.equal(newGravatar);
602+
});
603+
604+
it('Scenario 3: noMultiPictureUpgrade behavior', () => {
605+
const user = new User({
606+
email: 'test@example.com',
607+
password: 'password123',
608+
});
609+
610+
user.noMultiPictureUpgrade();
611+
612+
expect(user.profile.pictures).to.be.instanceOf(Map);
613+
expect(user.profile.pictureSource).to.equal('gravatar');
614+
expect(user.profile.pictures.get('gravatar')).to.include(user.gravatar());
615+
expect(user.profile.picture).to.equal(user.gravatar());
616+
});
617+
618+
it('Scenario 4: Preserve non-gravatar pictureSource', async () => {
619+
const user = new User({
620+
email: 'test@example.com',
621+
password: 'password123',
622+
profile: {
623+
pictureSource: 'facebook',
624+
picture: 'https://facebook/pic.jpg',
625+
},
626+
});
627+
628+
await user.save();
629+
630+
expect(user.profile.pictures.get('gravatar')).to.include(user.gravatar());
631+
expect(user.profile.picture).to.equal('https://facebook/pic.jpg');
632+
expect(user.profile.pictureSource).to.equal('facebook');
633+
});
634+
635+
it('Scenario 5: Preserve non-gravatar pictureSource - noMultiPictureUpgrade', () => {
636+
const user = new User({
637+
email: 'test@example.com',
638+
password: 'password123',
639+
profile: {
640+
pictureSource: 'github',
641+
picture: 'https://github/pic.jpg',
642+
},
643+
});
644+
645+
user.noMultiPictureUpgrade();
646+
647+
expect(user.profile.pictures.get('gravatar')).to.include(user.gravatar());
648+
expect(user.profile.picture).to.equal('https://github/pic.jpg');
649+
expect(user.profile.pictureSource).to.equal('github');
650+
});
651+
652+
it('Scenario 6: Legacy account upgrade path', () => {
653+
const user = new User({
654+
email: 'legacy@example.com',
655+
password: 'password123',
656+
profile: {
657+
picture: 'old-picture.jpg',
658+
// pictureSource and pictures are undefined
659+
},
660+
});
661+
662+
user.noMultiPictureUpgrade();
663+
664+
expect(user.profile.pictures).to.be.instanceOf(Map);
665+
expect(user.profile.pictures.get('gravatar')).to.include(user.gravatar());
666+
expect(user.profile.pictureSource).to.equal('gravatar');
667+
expect(user.profile.picture).to.equal(user.gravatar());
668+
});
669+
670+
it('Scenario 7: Map persistence', async () => {
671+
const user = new User({
672+
email: 'maptest@example.com',
673+
password: 'password123',
674+
});
675+
676+
await user.save();
677+
678+
const reloaded = await User.findById(user._id);
679+
680+
expect(reloaded.profile.pictures).to.be.instanceOf(Map);
681+
expect(reloaded.profile.pictures.get('gravatar')).to.include(user.gravatar());
682+
});
683+
684+
it('Scenario 8: No duplicate gravatar entries', async () => {
685+
const user = new User({
686+
email: 'noduplicate@example.com',
687+
password: 'password123',
688+
});
689+
690+
await user.save();
691+
const initialSize = user.profile.pictures.size;
692+
693+
// Save again without changing email
694+
await user.save();
695+
696+
expect(user.profile.pictures.size).to.equal(initialSize);
697+
expect(user.profile.pictures.get('gravatar')).to.include(user.gravatar());
698+
});
567699
});
568700

569701
describe('Token Cleanup on Save', () => {

0 commit comments

Comments
 (0)