Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions PROFILE_PICTURES_FEATURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Profile Picture Management Feature

This implementation adds the ability for users to manage their profile pictures from multiple social login sources.

## Features Implemented

### 1. Profile Picture Array
- Added `profilePictures` array to User model to store multiple profile pictures
- Each picture includes:
- `source`: The provider (gravatar, facebook, google, github, etc.)
- `url`: The picture URL
- `isSelected`: Boolean indicating if this is the currently selected picture

### 2. Automatic Picture Collection
- When users link social accounts, their profile pictures are automatically added to the array
- Gravatar is always available as a fallback option
- The first linked account's picture becomes the default selection

### 3. Profile Picture Selection Interface
- Users can view all available profile pictures in their account settings
- Radio button interface allows selecting which picture to use
- Pictures are displayed as thumbnails with the source provider name

### 4. Account Unlinking Integration
- When users unlink a social account, the corresponding profile picture is removed
- If the removed picture was selected, the system automatically selects another available picture
- Gravatar is used as the ultimate fallback

## Files Modified

### Models
- `models/User.js`: Added profilePictures schema and helper methods

### Controllers
- `controllers/user.js`: Added profile picture selection handler and updated account view

### Views
- `views/account/profile.pug`: Added profile picture selection interface

### Configuration
- `config/passport.js`: Updated all OAuth strategies to use new picture management
- `app.js`: Added route for profile picture updates

## API Endpoints

### POST /account/picture
Updates the selected profile picture for the authenticated user.

**Parameters:**
- `pictureSource`: The source of the picture to select (gravatar, facebook, google, etc.)

**Response:**
- Redirects to `/account` with success/error flash message

## User Model Methods

### addProfilePicture(source, url)
Adds or updates a profile picture from a specific source.

### selectProfilePicture(source)
Selects a profile picture as the active one.

### removeProfilePicture(source)
Removes a profile picture from a specific source and handles fallback selection.

## Usage Example

```javascript
// Add a profile picture
user.addProfilePicture('facebook', 'https://graph.facebook.com/123/picture');

// Select a different picture
user.selectProfilePicture('google');

// Remove a picture
user.removeProfilePicture('facebook');

await user.save();
```

## Migration Notes

- Existing users will automatically get gravatar added to their profile pictures array via middleware
- The current `profile.picture` field is maintained for backward compatibility
- For existing deployments, run the migration script: `node scripts/migrate-profile-pictures.js`
- No database schema migration is required as the new fields have default values

## Security Considerations

- Profile picture URLs are validated to come from trusted OAuth providers
- CSRF protection is maintained for the picture selection form
- User authentication is required for all profile picture operations
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ I also tried to make it as **generic** and **reusable** as possible to cover mos
- Support for a range of foundational and embedding models (DeepSeek, Llama, Mistral, Sentence Transformers, etc.) via LangChain, Together.AI, and Hugging Face
- **API Examples**
- **Backoffice:** Lob (USPS Mail), Paypal, Quickbooks, Stripe, Twilio (text messaging)
- **Data, Media & Entertainment:** Alpha Vantage (stocks and finance info) with ChartJS, Github, Foursquare, Last.fm, New York Times, PubChem (chemical information), Trakt.tv (movies/TV), Twitch, Tumblr (OAuth 1.0a example), Web Scraping
- **Data, Media & Entertainment:** Alpha Vantage (stocks and finance info) with ChartJS, Github, Foursquare, Last.fm, New York Times, PubChem (chemical information), Trakt.tv (movies/TV), Twitch, Tumblr (OAuth 1.0a example), Web Scraping, Wikipedia
- **Maps and Location:** Google Maps, HERE Maps
- **Productivity:** Google Drive, Google Sheets

Expand Down Expand Up @@ -459,6 +459,19 @@ The OpenAI moderation API for checking harmful inputs is free to use as long as

<hr>

<img src="https://upload.wikimedia.org/wikipedia/commons/8/80/Wikipedia-logo-v2.svg" height="75">

**Wikipedia API** uses the public REST API which doesn't require any API keys or authentication. The Wikipedia API provides access to Wikipedia content and metadata.

- **No setup required** - The Wikipedia REST API is completely free and open
- **API Documentation**: Visit <a href="https://en.wikipedia.org/api/rest_v1/" target="_blank">Wikipedia REST API</a> for full documentation
- **Rate Limits**: Please be respectful of Wikipedia's servers and don't make excessive requests
- **Example endpoints used**:
- Search: `https://en.wikipedia.org/api/rest_v1/page/search/{query}`
- Page summary: `https://en.wikipedia.org/api/rest_v1/page/summary/{title}`

<hr>

## Web Analytics

This project supports integrating web analytics tools such as Google Analytics 4 and Facebook Pixel, along with Open Graph metadata for social sharing. Below are instructions to help you set up these features in your application.
Expand Down
2 changes: 2 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ app.get('/account/verify', passportConfig.isAuthenticated, userController.getVer
app.get('/account/verify/:token', passportConfig.isAuthenticated, userController.getVerifyEmailToken);
app.get('/account', passportConfig.isAuthenticated, userController.getAccount);
app.post('/account/profile', passportConfig.isAuthenticated, userController.postUpdateProfile);
app.post('/account/picture', passportConfig.isAuthenticated, userController.postUpdatePicture);
app.post('/account/password', passportConfig.isAuthenticated, userController.postUpdatePassword);
app.post('/account/delete', passportConfig.isAuthenticated, userController.postDeleteAccount);
app.post('/account/logout-everywhere', passportConfig.isAuthenticated, userController.postLogoutEverywhere);
Expand Down Expand Up @@ -232,6 +233,7 @@ app.get('/api/chart', apiController.getChart);
app.get('/api/google/sheets', passportConfig.isAuthenticated, passportConfig.isAuthorized, apiController.getGoogleSheets);
app.get('/api/quickbooks', passportConfig.isAuthenticated, passportConfig.isAuthorized, apiController.getQuickbooks);
app.get('/api/trakt', apiController.getTrakt);
app.get('/api/wikipedia', apiController.getWikipedia);
app.get('/api/pubchem', apiController.getPubChem);

/**
Expand Down
60 changes: 44 additions & 16 deletions config/passport.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ passport.use(
user.facebook = profile.id;
user.profile.name = user.profile.name || `${profile.name.givenName} ${profile.name.familyName}`;
user.profile.gender = user.profile.gender || profile._json.gender;
user.profile.picture = user.profile.picture || `https://graph.facebook.com/${profile.id}/picture?type=large`;
user.addProfilePicture('facebook', `https://graph.facebook.com/${profile.id}/picture?type=large`);
await user.save();
req.flash('info', { msg: 'Facebook account has been linked.' });
return done(null, user);
Expand Down Expand Up @@ -204,8 +204,8 @@ passport.use(
await saveOAuth2UserTokens(req, accessToken, null, params.expires_in, null, 'facebook');
user.profile.name = `${profile.name.givenName} ${profile.name.familyName}`;
user.profile.gender = profile._json.gender;
user.profile.picture = `https://graph.facebook.com/${profile.id}/picture?type=large`;
user.profile.location = profile._json.location ? profile._json.location.name : '';
user.addProfilePicture('facebook', `https://graph.facebook.com/${profile.id}/picture?type=large`);
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -245,9 +245,11 @@ passport.use(
const user = await saveOAuth2UserTokens(req, accessToken, null, null, null, 'github');
user.github = profile.id;
user.profile.name = user.profile.name || profile.displayName;
user.profile.picture = user.profile.picture || profile._json.avatar_url;
user.profile.location = user.profile.location || profile._json.location;
user.profile.website = user.profile.website || profile._json.blog;
if (profile._json.avatar_url) {
user.addProfilePicture('github', profile._json.avatar_url);
}
await user.save();
req.flash('info', { msg: 'GitHub account has been linked.' });
return done(null, user);
Expand Down Expand Up @@ -282,9 +284,11 @@ passport.use(
req.user = user;
await saveOAuth2UserTokens(req, accessToken, null, null, null, 'github');
user.profile.name = profile.displayName;
user.profile.picture = profile._json.avatar_url;
user.profile.location = profile._json.location;
user.profile.website = profile._json.blog;
if (profile._json.avatar_url) {
user.addProfilePicture('github', profile._json.avatar_url);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -322,7 +326,9 @@ passport.use(
user.tokens.push({ kind: 'x', accessToken, tokenSecret });
user.profile.name = user.profile.name || profile.displayName;
user.profile.location = user.profile.location || profile._json.location;
user.profile.picture = user.profile.picture || profile._json.profile_image_url_https;
if (profile._json.profile_image_url_https) {
user.addProfilePicture('x', profile._json.profile_image_url_https);
}
await user.save();
req.flash('info', { msg: 'X account has been linked.' });
return done(null, user);
Expand All @@ -340,7 +346,9 @@ passport.use(
user.tokens.push({ kind: 'x', accessToken, tokenSecret });
user.profile.name = profile.displayName;
user.profile.location = profile._json.location;
user.profile.picture = profile._json.profile_image_url_https;
if (profile._json.profile_image_url_https) {
user.addProfilePicture('x', profile._json.profile_image_url_https);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -381,7 +389,9 @@ const googleStrategyConfig = new GoogleStrategy(
user.google = profile.id;
user.profile.name = user.profile.name || profile.displayName;
user.profile.gender = user.profile.gender || profile._json.gender;
user.profile.picture = user.profile.picture || profile._json.picture;
if (profile._json.picture) {
user.addProfilePicture('google', profile._json.picture);
}
await user.save();
req.flash('info', { msg: 'Google account has been linked.' });
return done(null, user);
Expand All @@ -408,7 +418,9 @@ const googleStrategyConfig = new GoogleStrategy(
await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'google');
user.profile.name = profile.displayName;
user.profile.gender = profile._json.gender;
user.profile.picture = profile._json.picture;
if (profile._json.picture) {
user.addProfilePicture('google', profile._json.picture);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -458,7 +470,9 @@ passport.use(
user.linkedin = profile.id;
user.tokens.push({ kind: 'linkedin', accessToken: null }); // null for now since passport-openidconnect isn't returning it yet; will update when it supports it
user.profile.name = user.profile.name || profile.displayName;
user.profile.picture = user.profile.picture || profile.photos;
if (profile.photos) {
user.addProfilePicture('linkedin', profile.photos);
}
await user.save();
req.flash('info', { msg: 'LinkedIn account has been linked.' });
return done(null, user);
Expand All @@ -483,7 +497,9 @@ passport.use(
user.tokens.push({ kind: 'linkedin', accessToken: null });
user.email = normalizedEmail;
user.profile.name = profile.displayName;
user.profile.picture = profile.photos || '';
if (profile.photos) {
user.addProfilePicture('linkedin', profile.photos);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -521,7 +537,9 @@ const twitchStrategyConfig = new TwitchStrategy(
const user = await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'twitch');
user.twitch = profile.id;
user.profile.name = user.profile.name || profile.displayName;
user.profile.picture = user.profile.picture || profile.profile_image_url;
if (profile.profile_image_url) {
user.addProfilePicture('twitch', profile.profile_image_url);
}
await user.save();
req.flash('info', { msg: 'Twitch account has been linked.' });
return done(null, user);
Expand All @@ -548,7 +566,9 @@ const twitchStrategyConfig = new TwitchStrategy(
await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'twitch');
user.profile.name = profile.display_name;
user.profile.email = profile.email;
user.profile.picture = profile.profile_image_url;
if (profile.profile_image_url) {
user.addProfilePicture('twitch', profile.profile_image_url);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -665,7 +685,9 @@ passport.use(
const data = await response.json();
const profileData = data.response.players[0];
user.profile.name = user.profile.name || profileData.personaname;
user.profile.picture = user.profile.picture || profileData.avatarmedium;
if (profileData.avatarmedium) {
user.addProfilePicture('steam', profileData.avatarmedium);
}
await user.save();
return done(null, user);
} catch (err) {
Expand All @@ -686,7 +708,9 @@ passport.use(
user.email = `${steamId}@steam.com`; // steam does not disclose emails, prevent duplicate keys
user.tokens.push({ kind: 'steam', accessToken: steamId });
user.profile.name = profileData.personaname;
user.profile.picture = profileData.avatarmedium;
if (profileData.avatarmedium) {
user.addProfilePicture('steam', profileData.avatarmedium);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down Expand Up @@ -809,7 +833,9 @@ const discordStrategyConfig = new OAuth2Strategy(
const user = await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'discord');
user.discord = discordProfile.id;
user.profile.name = user.profile.name || discordProfile.username;
user.profile.picture = user.profile.picture || (discordProfile.avatar ? `https://cdn.discordapp.com/avatars/${discordProfile.id}/${discordProfile.avatar}.png` : undefined);
if (discordProfile.avatar) {
user.addProfilePicture('discord', `https://cdn.discordapp.com/avatars/${discordProfile.id}/${discordProfile.avatar}.png`);
}
await user.save();
req.flash('info', { msg: 'Discord account has been linked.' });
return done(null, user);
Expand All @@ -835,7 +861,9 @@ const discordStrategyConfig = new OAuth2Strategy(
req.user = user;
await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'discord');
user.profile.name = discordProfile.username;
user.profile.picture = discordProfile.avatar ? `https://cdn.discordapp.com/avatars/${discordProfile.id}/${discordProfile.avatar}.png` : undefined;
if (discordProfile.avatar) {
user.addProfilePicture('discord', `https://cdn.discordapp.com/avatars/${discordProfile.id}/${discordProfile.avatar}.png`);
}
await user.save();
return done(null, user);
} catch (err) {
Expand Down
13 changes: 10 additions & 3 deletions controllers/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ const { ChatTogetherAI } = require('@langchain/community/chat_models/togetherai'
const { HumanMessage } = require('@langchain/core/messages');
const { CacheBackedEmbeddings } = require('langchain/embeddings/cache_backed');
const { MongoClient } = require('mongodb');
// eslint-disable-next-line import/extensions
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.mjs');
// Using dynamic import for pdfjs-dist due to ESM compatibility
let pdfjsLib;
const getPdfjsLib = async () => {
if (!pdfjsLib) {
pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
}
return pdfjsLib;
};

/**
* GET /ai
Expand Down Expand Up @@ -243,8 +249,9 @@ exports.postRagIngest = async (req, res) => {

// Process the PDF file
try {
const pdfjs = await getPdfjsLib();
const loader = new PDFLoader(filePath, {
pdfjs: () => Promise.resolve(pdfjsLib),
pdfjs: () => Promise.resolve(pdfjs),
});
const docs = await loader.load();
// Split the document into chunks
Expand Down
Loading