diff --git a/PROFILE_PICTURES_FEATURE.md b/PROFILE_PICTURES_FEATURE.md
new file mode 100644
index 0000000..ec8d018
--- /dev/null
+++ b/PROFILE_PICTURES_FEATURE.md
@@ -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
\ No newline at end of file
diff --git a/README.md b/README.md
index 51528c8..7494cf3 100644
--- a/README.md
+++ b/README.md
@@ -71,7 +71,7 @@ I also tried to make it as **generic** and **reusable** as possible to cover mos
- Login
- **Local Authentication** Sign in with Email and Password, Passwordless
- - **OAuth 2.0 Authentication:** Sign in with Google, Facebook, X (Twitter), Twitch, Github, Discord
+ - **OAuth 2.0 Authentication:** Sign in with Google, Facebook, X (Twitter), Twitch, Github, Discord, Microsoft
- **OpenID Connect:** Sign in with LinkedIn
- **User Profile and Account Management**
- Gravatar
@@ -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
@@ -156,7 +156,7 @@ _What to get and configure:_
- reCAPTCHA
- For contact form submission, but you can skip it during your development
- OAuth for social logins (Sign in with / Login with)
- - Depending on your application need, obtain keys from Google, Facebook, X (Twitter), LinkedIn, Twitch, GitHub. You don't have to obtain valid keys for any provider that you don't need. Just remove the buttons and links in the login and account pug views before your demo.
+ - Depending on your application need, obtain keys from Google, Facebook, X (Twitter), LinkedIn, Twitch, GitHub, Microsoft. You don't have to obtain valid keys for any provider that you don't need. Just remove the buttons and links in the login and account pug views before your demo.
- API keys for service providers that you need in the API Examples if you are planning to use them.
- MongoDB Atlas
@@ -326,6 +326,25 @@ Obtain SMTP credentials from a provider for transactional emails. Set the SMTP_U
+
+
+- Go to Azure Portal
+- Sign in with your Microsoft account or create a new one
+- Navigate to **Azure Active Directory** > **App registrations**
+- Click **+ New registration**
+- Enter your application name
+- For **Supported account types**, select **Accounts in any organizational directory and personal Microsoft accounts**
+- For **Redirect URI**, select **Web** and enter your BASE_URL value followed by `/auth/microsoft/callback` (i.e. `http://localhost:8080/auth/microsoft/callback`)
+- Click **Register**
+- Copy the **Application (client) ID** and paste it into your `.env` file as `MICROSOFT_CLIENT_ID`
+- Click on **Certificates & secrets** in the left sidebar
+- Under **Client secrets**, click **+ New client secret**
+- Add a description and select an expiration period
+- Click **Add** and copy the **Value** (not the Secret ID) immediately and paste it into your `.env` file as `MICROSOFT_CLIENT_SECRET`
+- **Important:** The client secret value will only be shown once, so make sure to copy it immediately
+
+
+
- Visit Lob Dashboard
@@ -459,6 +478,19 @@ The OpenAI moderation API for checking harmful inputs is free to use as long as
+
+
+**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 Wikipedia REST API 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}`
+
+
+
## 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.
diff --git a/app.js b/app.js
index 97150d8..b9f397f 100644
--- a/app.js
+++ b/app.js
@@ -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);
@@ -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);
/**
@@ -303,6 +305,10 @@ app.get('/auth/discord', passport.authenticate('discord'));
app.get('/auth/discord/callback', passport.authenticate('discord', { failureRedirect: '/auth/failure' }), (req, res) => {
res.redirect(req.session.returnTo || '/');
});
+app.get('/auth/microsoft', passport.authenticate('microsoft'));
+app.get('/auth/microsoft/callback', passport.authenticate('microsoft', { failureRedirect: '/auth/failure' }), (req, res) => {
+ res.redirect(req.session.returnTo || '/');
+});
/**
* OAuth authorization routes. (API examples)
diff --git a/config/passport.js b/config/passport.js
index e72b044..b594eb7 100644
--- a/config/passport.js
+++ b/config/passport.js
@@ -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);
@@ -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) {
@@ -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);
@@ -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) {
@@ -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);
@@ -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) {
@@ -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);
@@ -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) {
@@ -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);
@@ -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) {
@@ -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);
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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);
@@ -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) {
@@ -846,6 +874,79 @@ const discordStrategyConfig = new OAuth2Strategy(
passport.use('discord', discordStrategyConfig);
refresh.use('discord', discordStrategyConfig);
+/**
+ * Sign in with Microsoft using OAuth2Strategy.
+ */
+const microsoftStrategyConfig = new OAuth2Strategy(
+ {
+ authorizationURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+ tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
+ clientID: process.env.MICROSOFT_CLIENT_ID,
+ clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
+ callbackURL: `${process.env.BASE_URL}/auth/microsoft/callback`,
+ scope: ['openid', 'profile', 'email'].join(' '),
+ state: generateState(),
+ passReqToCallback: true,
+ },
+ async (req, accessToken, refreshToken, params, profile, done) => {
+ try {
+ // Fetch Microsoft profile using accessToken
+ const response = await fetch('https://graph.microsoft.com/v1.0/me', {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ if (!response.ok) {
+ return done(new Error('Failed to fetch Microsoft profile'));
+ }
+ const microsoftProfile = await response.json();
+ if (req.user) {
+ const existingUser = await User.findOne({ microsoft: { $eq: microsoftProfile.id } });
+ if (existingUser && existingUser.id !== req.user.id) {
+ req.flash('errors', {
+ msg: 'There is another account in our system linked to your Microsoft account. Please delete the duplicate account before linking Microsoft to your current account.',
+ });
+ if (req.session) req.session.returnTo = undefined;
+ return done(null, req.user);
+ }
+ const user = await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'microsoft');
+ user.microsoft = microsoftProfile.id;
+ user.profile.name = user.profile.name || microsoftProfile.displayName;
+ await user.save();
+ req.flash('info', { msg: 'Microsoft account has been linked.' });
+ return done(null, user);
+ }
+ const existingUser = await User.findOne({ microsoft: { $eq: microsoftProfile.id } });
+ if (existingUser) {
+ return done(null, existingUser);
+ }
+ const emailFromProvider = microsoftProfile.mail || microsoftProfile.userPrincipalName;
+ const normalizedEmail = emailFromProvider ? validator.normalizeEmail(emailFromProvider, { gmail_remove_dots: false }) : undefined;
+ const existingEmailUser = await User.findOne({
+ email: { $eq: normalizedEmail },
+ });
+ if (existingEmailUser) {
+ req.flash('errors', {
+ msg: `Unable to sign in with Microsoft at this time. If you have an existing account in our system, please sign in by email and link your account to Microsoft in your user profile settings.`,
+ });
+ return done(null, false);
+ }
+ const user = new User();
+ user.email = normalizedEmail;
+ user.microsoft = microsoftProfile.id;
+ req.user = user;
+ await saveOAuth2UserTokens(req, accessToken, refreshToken, params.expires_in, null, 'microsoft');
+ user.profile.name = microsoftProfile.displayName;
+ await user.save();
+ return done(null, user);
+ } catch (err) {
+ return done(err);
+ }
+ },
+);
+passport.use('microsoft', microsoftStrategyConfig);
+refresh.use('microsoft', microsoftStrategyConfig);
+
/**
* Login Required middleware.
*/
diff --git a/controllers/ai.js b/controllers/ai.js
index b2e02e0..d936486 100644
--- a/controllers/ai.js
+++ b/controllers/ai.js
@@ -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
@@ -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
diff --git a/controllers/api.js b/controllers/api.js
index 02c6f46..520ec69 100644
--- a/controllers/api.js
+++ b/controllers/api.js
@@ -4,11 +4,16 @@ const cheerio = require('cheerio');
const { LastFmNode } = require('lastfm');
const multer = require('multer');
const { OAuth } = require('oauth');
-// Disable eslint rule for @octakit/rest until the following github issue is resolved
-// github npm package bug: https://github.com/octokit/rest.js/issues/446
-// eslint-disable-next-line import/no-unresolved
-const { Octokit } = require('@octokit/rest');
-const stripe = require('stripe')(process.env.STRIPE_SKEY);
+// Using dynamic import for @octokit/rest due to ESM compatibility
+let OctokitClass;
+const getOctokit = async () => {
+ if (!OctokitClass) {
+ const { Octokit } = await import('@octokit/rest');
+ OctokitClass = Octokit;
+ }
+ return OctokitClass;
+};
+const stripe = process.env.STRIPE_SKEY ? require('stripe')(process.env.STRIPE_SKEY) : null;
const twilioClient = require('twilio')(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
const googledrive = require('@googleapis/drive');
const googlesheets = require('@googleapis/sheets');
@@ -168,6 +173,7 @@ exports.getGithub = async (req, res, next) => {
let userInfo;
let userRepos;
let userEvents;
+ const Octokit = await getOctokit();
if (githubToken) {
github = new Octokit({
auth: req.user.tokens.find((token) => token.kind === 'github').accessToken,
@@ -481,6 +487,10 @@ exports.getStripe = (req, res) => {
* Make a payment.
*/
exports.postStripe = (req, res) => {
+ if (!stripe) {
+ req.flash('errors', { msg: 'Stripe API key not configured.' });
+ return res.redirect('/api/stripe');
+ }
const { stripeToken, stripeEmail } = req.body;
stripe.charges.create(
{
@@ -1444,6 +1454,40 @@ exports.getTrakt = async (req, res, next) => {
}
};
+/**
+ * GET /api/wikipedia
+ * Wikipedia API example.
+ */
+exports.getWikipedia = async (req, res, next) => {
+ const searchTerm = req.query.search || 'Node.js';
+
+ try {
+ // Search for articles
+ const searchResponse = await fetch(`https://en.wikipedia.org/api/rest_v1/page/search/${encodeURIComponent(searchTerm)}?limit=5`);
+ if (!searchResponse.ok) throw new Error('Wikipedia search failed');
+ const searchResults = await searchResponse.json();
+
+ // Get summary for the first result if available
+ let summary = null;
+ if (searchResults.pages && searchResults.pages.length > 0) {
+ const firstResult = searchResults.pages[0];
+ const summaryResponse = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(firstResult.key)}`);
+ if (summaryResponse.ok) {
+ summary = await summaryResponse.json();
+ }
+ }
+
+ res.render('api/wikipedia', {
+ title: 'Wikipedia API',
+ searchTerm,
+ searchResults: searchResults.pages || [],
+ summary
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
/**
* GET /api/pubchem
* PubChem API example - Chemical information for Aspirin.
diff --git a/controllers/user.js b/controllers/user.js
index a0fd98d..b3c9e18 100644
--- a/controllers/user.js
+++ b/controllers/user.js
@@ -328,6 +328,28 @@ exports.postUpdateProfile = async (req, res, next) => {
}
};
+/**
+ * POST /account/picture
+ * Update selected profile picture.
+ */
+exports.postUpdatePicture = async (req, res, next) => {
+ try {
+ const user = await User.findById(req.user.id);
+ const { pictureSource } = req.body;
+
+ if (user.selectProfilePicture(pictureSource)) {
+ await user.save();
+ req.flash('success', { msg: 'Profile picture has been updated.' });
+ } else {
+ req.flash('errors', { msg: 'Invalid profile picture selection.' });
+ }
+
+ res.redirect('/account');
+ } catch (err) {
+ next(err);
+ }
+};
+
/**
* POST /account/password
* Update current password.
@@ -393,6 +415,10 @@ exports.getOauthUnlink = async (req, res, next) => {
return res.redirect('/account');
}
user.tokens = tokensWithoutProviderToUnlink;
+
+ // Remove profile picture from the unlinked provider
+ user.removeProfilePicture(provider.toLowerCase());
+
await user.save();
req.flash('info', {
msg: `${provider.charAt(0).toUpperCase() + provider.slice(1).toLowerCase()} account has been unlinked.`,
diff --git a/models/User.js b/models/User.js
index 468d61a..2a045b8 100644
--- a/models/User.js
+++ b/models/User.js
@@ -25,6 +25,7 @@ const userSchema = new mongoose.Schema(
github: String,
google: String,
linkedin: String,
+ microsoft: String,
quickbooks: String,
steam: String,
trakt: String,
@@ -41,6 +42,12 @@ const userSchema = new mongoose.Schema(
website: String,
picture: String,
},
+
+ profilePictures: [{
+ source: { type: String, required: true }, // 'gravatar', 'facebook', 'google', etc.
+ url: { type: String, required: true },
+ isSelected: { type: Boolean, default: false }
+ }],
},
{ timestamps: true },
);
@@ -63,6 +70,28 @@ userSchema.virtual('isLoginExpired').get(function checkLoginTokenExpiration() {
return Date.now() > this.loginExpires;
});
+// Middleware to ensure gravatar is always available
+userSchema.pre('save', function ensureGravatar(next) {
+ // Ensure gravatar is always available as an option
+ const hasGravatar = this.profilePictures.some(pic => pic.source === 'gravatar');
+
+ if (!hasGravatar) {
+ const gravatarUrl = this.gravatar();
+ this.profilePictures.push({
+ source: 'gravatar',
+ url: gravatarUrl,
+ isSelected: this.profilePictures.length === 0 || !this.profile.picture
+ });
+
+ // If no picture is set, use gravatar
+ if (!this.profile.picture) {
+ this.profile.picture = gravatarUrl;
+ }
+ }
+
+ next();
+});
+
// Middleware to clear expired tokens on save
userSchema.pre('save', function clearExpiredTokens(next) {
const now = Date.now();
@@ -135,6 +164,50 @@ userSchema.statics.generateToken = function generateToken() {
return crypto.randomBytes(32).toString('hex');
};
+// Helper method for managing profile pictures
+userSchema.methods.addProfilePicture = function addProfilePicture(source, url) {
+ // Remove existing picture from same source
+ this.profilePictures = this.profilePictures.filter(pic => pic.source !== source);
+
+ // Add new picture
+ this.profilePictures.push({ source, url, isSelected: false });
+
+ // If this is the first picture or no picture is selected, make it the selected one
+ if (this.profilePictures.length === 1 || !this.profilePictures.some(pic => pic.isSelected)) {
+ this.profilePictures[this.profilePictures.length - 1].isSelected = true;
+ this.profile.picture = url;
+ }
+};
+
+userSchema.methods.selectProfilePicture = function selectProfilePicture(source) {
+ // Deselect all pictures
+ this.profilePictures.forEach(pic => { pic.isSelected = false; });
+
+ // Select the specified picture
+ const selectedPic = this.profilePictures.find(pic => pic.source === source);
+ if (selectedPic) {
+ selectedPic.isSelected = true;
+ this.profile.picture = selectedPic.url;
+ return true;
+ }
+ return false;
+};
+
+userSchema.methods.removeProfilePicture = function removeProfilePicture(source) {
+ const wasSelected = this.profilePictures.find(pic => pic.source === source && pic.isSelected);
+ this.profilePictures = this.profilePictures.filter(pic => pic.source !== source);
+
+ // If the removed picture was selected, select another one or fallback to gravatar
+ if (wasSelected) {
+ if (this.profilePictures.length > 0) {
+ this.profilePictures[0].isSelected = true;
+ this.profile.picture = this.profilePictures[0].url;
+ } else {
+ this.profile.picture = this.gravatar();
+ }
+ }
+};
+
// Helper methods for token verification
userSchema.methods.verifyTokenAndIp = function verifyTokenAndIp(token, ip, tokenType) {
const hashedIp = this.constructor.hashIP(ip);
diff --git a/package-lock.json b/package-lock.json
index 1e77b4d..3b6b313 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -95,6 +95,7 @@
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.27.3.tgz",
"integrity": "sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -166,6 +167,7 @@
"resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.6.0.tgz",
"integrity": "sha512-83iXP5D7xMm8Wyn66TUaUrgoByCmAJuoMoZQI3sGg3JAiMlTfnCIMqyVBoNSaItaPIkaCnrsj6LiusmXV2X9YA==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -489,7 +491,6 @@
"resolved": "https://registry.npmjs.org/@huggingface/inference/-/inference-4.11.0.tgz",
"integrity": "sha512-pgtNIYt0jkDBq+sbOX8un3W3NulRrRBkSJEo6Rvze9rjvXT5k71XeSm7QsG2bz6jOcJ1KOqoNDPPSSXT4dw34Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@huggingface/jinja": "^0.5.1",
"@huggingface/tasks": "^0.19.49"
@@ -1179,7 +1180,6 @@
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.78.tgz",
"integrity": "sha512-Nn0x9erQlK3zgtRU1Z8NUjLuyW0gzdclMsvLQ6wwLeDqV91pE+YKl6uQb+L2NUDs4F0N7c2Zncgz46HxrvPzuA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
@@ -1845,7 +1845,6 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.5.tgz",
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2336,7 +2335,6 @@
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"playwright": "1.56.0"
},
@@ -2352,7 +2350,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -2501,7 +2498,8 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
@@ -2518,6 +2516,7 @@
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/ms": "*"
}
@@ -2554,7 +2553,8 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/node": {
"version": "18.19.129",
@@ -2570,6 +2570,7 @@
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
@@ -2585,7 +2586,8 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/uuid": {
"version": "10.0.0",
@@ -2628,6 +2630,7 @@
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"event-target-shim": "^5.0.0"
},
@@ -2668,7 +2671,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2700,6 +2702,7 @@
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"humanize-ms": "^1.2.1"
},
@@ -2954,7 +2957,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -3196,6 +3198,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
@@ -3413,7 +3416,6 @@
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz",
"integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
@@ -4114,7 +4116,6 @@
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -4391,7 +4392,6 @@
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4688,6 +4688,7 @@
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -4703,6 +4704,7 @@
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.8.x"
}
@@ -4799,7 +4801,6 @@
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
@@ -4989,6 +4990,7 @@
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
@@ -5205,13 +5207,15 @@
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/formdata-node": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz",
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 18"
}
@@ -5370,6 +5374,7 @@
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"optional": true,
+ "peer": true,
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
@@ -5384,6 +5389,7 @@
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"debug": "4"
},
@@ -5397,6 +5403,7 @@
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
"license": "Apache-2.0",
"optional": true,
+ "peer": true,
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^5.0.0",
@@ -5413,6 +5420,7 @@
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
@@ -5665,7 +5673,6 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
@@ -5876,6 +5883,7 @@
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "^2.0.0"
}
@@ -5953,7 +5961,8 @@
"url": "https://feross.org/support"
}
],
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/ignore": {
"version": "5.3.2",
@@ -5961,7 +5970,6 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 4"
}
@@ -6406,6 +6414,7 @@
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"optional": true,
+ "peer": true,
"engines": {
"node": ">=8"
},
@@ -6551,7 +6560,8 @@
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
@@ -7403,7 +7413,6 @@
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
@@ -8449,6 +8458,7 @@
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
},
@@ -8576,7 +8586,6 @@
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"playwright-core": "1.56.0"
},
@@ -8628,7 +8637,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8644,6 +8652,7 @@
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6.0"
}
@@ -8705,6 +8714,7 @@
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
@@ -8864,7 +8874,8 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -8964,6 +8975,7 @@
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"readable-stream": "^4.7.0"
},
@@ -8980,6 +8992,7 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
@@ -9061,7 +9074,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/resolve": {
"version": "1.22.10",
@@ -9107,6 +9121,7 @@
"resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-2.6.0.tgz",
"integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.7.0"
},
@@ -9827,6 +9842,7 @@
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
@@ -10002,6 +10018,7 @@
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
@@ -10019,6 +10036,7 @@
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
@@ -10034,6 +10052,7 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 4.0.0"
}
@@ -10375,6 +10394,7 @@
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@@ -10789,6 +10809,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -10973,7 +10994,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/public/css/main.scss b/public/css/main.scss
index 53057d4..c9c99fe 100644
--- a/public/css/main.scss
+++ b/public/css/main.scss
@@ -61,6 +61,17 @@
}
}
+.btn-microsoft {
+ background-color: #00a1f1;
+ color: #fff !important;
+
+ &:hover,
+ &:active {
+ background-color: #0078d4;
+ color: #fff !important;
+ }
+}
+
// Add a space after fontawesome icons
.iconpadding {
padding-right: 6px !important;
diff --git a/scripts/migrate-profile-pictures.js b/scripts/migrate-profile-pictures.js
new file mode 100644
index 0000000..eafbeab
--- /dev/null
+++ b/scripts/migrate-profile-pictures.js
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+
+/**
+ * Migration script to add gravatar profile pictures to existing users
+ * Run this script after deploying the profile picture feature
+ */
+
+const mongoose = require('mongoose');
+const User = require('../models/User');
+
+async function migrateProfilePictures() {
+ try {
+ // Connect to MongoDB
+ await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/hackathon-starter');
+ console.log('Connected to MongoDB');
+
+ // Find all users without profilePictures array or with empty array
+ const users = await User.find({
+ $or: [
+ { profilePictures: { $exists: false } },
+ { profilePictures: { $size: 0 } }
+ ]
+ });
+
+ console.log(`Found ${users.length} users to migrate`);
+
+ let migratedCount = 0;
+ for (const user of users) {
+ try {
+ // Initialize profilePictures array if it doesn't exist
+ if (!user.profilePictures) {
+ user.profilePictures = [];
+ }
+
+ // Add gravatar if not present
+ const hasGravatar = user.profilePictures.some(pic => pic.source === 'gravatar');
+ if (!hasGravatar) {
+ const gravatarUrl = user.gravatar();
+ user.profilePictures.push({
+ source: 'gravatar',
+ url: gravatarUrl,
+ isSelected: true
+ });
+
+ // Set profile picture if not set
+ if (!user.profile.picture) {
+ user.profile.picture = gravatarUrl;
+ }
+
+ await user.save();
+ migratedCount++;
+ console.log(`Migrated user: ${user.email}`);
+ }
+ } catch (error) {
+ console.error(`Error migrating user ${user.email}:`, error.message);
+ }
+ }
+
+ console.log(`Migration completed. ${migratedCount} users migrated.`);
+ } catch (error) {
+ console.error('Migration failed:', error);
+ process.exit(1);
+ } finally {
+ await mongoose.disconnect();
+ console.log('Disconnected from MongoDB');
+ }
+}
+
+// Run the migration
+if (require.main === module) {
+ migrateProfilePictures();
+}
+
+module.exports = migrateProfilePictures;
\ No newline at end of file
diff --git a/views/account/login.pug b/views/account/login.pug
index 5645fd8..68b5bfe 100644
--- a/views/account/login.pug
+++ b/views/account/login.pug
@@ -50,6 +50,9 @@ block content
a.btn.btn-block.btn-discord.btn-social(href='/auth/discord')
i.fab.fa-discord
| Sign in with Discord
+ a.btn.btn-block.btn-microsoft.btn-social(href='/auth/microsoft')
+ i.fab.fa-microsoft
+ | Sign in with Microsoft
script.
document.getElementById('loginByEmailLink').addEventListener('change', function () {
diff --git a/views/account/profile.pug b/views/account/profile.pug
index 8f568e0..83ca885 100644
--- a/views/account/profile.pug
+++ b/views/account/profile.pug
@@ -48,6 +48,20 @@ block content
label.col-md-3.col-form-label.font-weight-bold.text-right Profile Picture
.col-sm-4.mb-2
img.profile(src=user.profile.picture ? user.profile.picture : user.gravatar(), width='100', height='100')
+ if user.profilePictures && user.profilePictures.length > 1
+ .mt-3
+ p.small Change your profile picture:
+ form(action='/account/picture', method='POST')
+ input(type='hidden', name='_csrf', value=_csrf)
+ .row
+ each picture in user.profilePictures
+ .col-md-6.mb-2
+ .form-check
+ input.form-check-input(type='radio', name='pictureSource', value=picture.source, checked=picture.isSelected, id=`pic-${picture.source}`)
+ label.form-check-label(for=`pic-${picture.source}`)
+ img.me-2(src=picture.url, width='40', height='40', style='border-radius: 50%;')
+ = picture.source.charAt(0).toUpperCase() + picture.source.slice(1)
+ button.btn.btn-sm.btn-outline-primary.mt-2(type='submit') Update Picture
.form-group
.offset-sm-3.col-md-7.pl-2
button.btn.btn.btn-primary(type='submit')
@@ -126,6 +140,11 @@ block content
p.mb-1: a.text-danger(href='/account/unlink/linkedin') Unlink your LinkedIn account
else
p.mb-1: a(href='/auth/linkedin') Link your LinkedIn account
+ .offset-sm-3.col-md-7.pl-2
+ if user.microsoft
+ p.mb-1: a.text-danger(href='/account/unlink/microsoft') Unlink your Microsoft account
+ else
+ p.mb-1: a(href='/auth/microsoft') Link your Microsoft account
.offset-sm-3.col-md-7.pl-2
if user.quickbooks
p.mb-1: a.text-danger(href='/account/unlink/quickbooks') Unlink your QuickBooks account
diff --git a/views/api/index.pug b/views/api/index.pug
index fcaa666..8fb42fc 100644
--- a/views/api/index.pug
+++ b/views/api/index.pug
@@ -131,6 +131,12 @@ block content
.card-body
img(src='https://i.imgur.com/Adtl9qg.png', height=40, style='padding: 0px 10px 0px 0px')
| trakt.tv
+ .col-md-4
+ a(href='/api/wikipedia', style='color: #000')
+ .card.mb-3(style='background-color: #f8f9fa')
+ .card-body
+ img(src='https://upload.wikimedia.org/wikipedia/commons/8/80/Wikipedia-logo-v2.svg', height=40, style='padding: 0px 10px 0px 0px')
+ | Wikipedia
.col-md-4
a(href='/api/pubchem', style='color: #fff')
.card.text-white.mb-3(style='background-color: rgba(128, 200, 255, 1)')
diff --git a/views/api/wikipedia.pug b/views/api/wikipedia.pug
new file mode 100644
index 0000000..5414311
--- /dev/null
+++ b/views/api/wikipedia.pug
@@ -0,0 +1,47 @@
+extends ../layout
+
+block content
+ .pb-2.mt-2.mb-4.border-bottom
+ h3 Wikipedia API
+ p.lead Search and retrieve information from Wikipedia
+
+ .row
+ .col-md-12
+ form(method='GET', action='/api/wikipedia')
+ .input-group.mb-3
+ input.form-control(type='text', name='search', placeholder='Search Wikipedia...', value=searchTerm)
+ button.btn.btn-primary(type='submit') Search
+
+ if summary
+ .row
+ .col-md-12
+ .card.mb-4
+ .card-header
+ h4= summary.title
+ .card-body
+ .row
+ if summary.thumbnail
+ .col-md-3
+ img.img-fluid.rounded(src=summary.thumbnail.source, alt=summary.title)
+ .col-md-9
+ p= summary.extract
+ if summary.content_urls && summary.content_urls.desktop
+ a.btn.btn-outline-primary(href=summary.content_urls.desktop.page, target='_blank') Read Full Article
+
+ if searchResults && searchResults.length > 0
+ .row
+ .col-md-12
+ h4 Search Results
+ .list-group
+ each result in searchResults
+ .list-group-item
+ .d-flex.w-100.justify-content-between
+ h5.mb-1= result.title
+ small= result.key
+ p.mb-1= result.excerpt
+ if result.thumbnail
+ img.img-thumbnail(src=result.thumbnail.url, alt=result.title, style='max-width: 100px; height: auto;')
+ else if searchTerm
+ .row
+ .col-md-12
+ .alert.alert-info No results found for "#{searchTerm}"
\ No newline at end of file