25 Commits

Author SHA1 Message Date
Adolfo Reyna
83727957ab fix(auth): return JSON 401 for API sessions and harden cross-site cookies 2026-02-21 21:55:01 -05:00
a8ddae4b1e Merge pull request 'feat: add universal chat room with language-aware translation' (#3) from codex/universal-chat-room into master
Reviewed-on: #3
2026-02-21 04:22:59 +00:00
Adolfo Reyna
77134b6bab feat: add universal chat room with language-aware translation 2026-02-20 23:16:05 -05:00
Adolfo Reyna
d907aeecee Add notifications viewed flag endpoint and persistence 2026-02-20 22:15:13 -05:00
Adolfo Reyna
1ca38ca3b9 Add agent notes file 2026-02-20 22:06:01 -05:00
Adolfo Reyna
e13678ad56 Fix profile update persistence and stale cache 2026-02-20 22:05:43 -05:00
bbc8c36439 Merge pull request 'codex/password-security-plan-comments' (#2) from codex/password-security-plan-comments into master
Reviewed-on: #2
2026-02-21 03:02:44 +00:00
Adolfo Reyna
f3a782a360 chore(auth): remove security plan doc and marker comments 2026-02-20 21:22:47 -05:00
Adolfo Reyna
19d805d322 fix(auth): add account+IP brute-force rate limiting 2026-02-20 21:20:21 -05:00
Adolfo Reyna
469962d03c fix(auth): add single-use token login recovery flow 2026-02-20 20:20:40 -05:00
Adolfo Reyna
c6d9dfd3c1 fix(auth): enforce POST body credentials and generic auth errors 2026-02-20 20:09:29 -05:00
Adolfo Reyna
0baf237548 docs(auth): add password security hardening plan and code markers 2026-02-20 20:07:26 -05:00
822e2bc0d6 Merge pull request 'codex/fix-502-feed-profile' (#1) from codex/fix-502-feed-profile into master
Reviewed-on: #1
2026-02-21 00:46:34 +00:00
Adolfo Reyna
ea864b27d4 Harden local/prod cookie policy and Mongo connection settings 2026-02-20 19:25:21 -05:00
Adolfo Reyna
c136d25974 Harden feed/profile routes against invalid IDs and null profiles 2026-02-20 19:09:15 -05:00
Adolfo Reyna
0b36db9b33 docs: Update Swagger documentation for Profile endpoints
Updated the Swagger documentation for various Profile endpoints to accurately
reflect their return types, including arrays of Profile objects and detailed
schemas for specific responses.
2025-07-17 10:32:22 -04:00
Adolfo Reyna
e4bac717f9 docs: Add API documentation access instructions to README
Updated the README.md file to include instructions on how to access the
interactive API documentation via Swagger UI, including the URL.
2025-07-17 10:00:31 -04:00
Adolfo Reyna
d21736d52c docs: Update Swagger documentation for Post endpoints
Updated the Swagger documentation for various Post endpoints to accurately
reflect their return types, including arrays of Post objects and detailed
schemas for specific responses.
2025-07-17 09:59:50 -04:00
Adolfo Reyna
148ed696b2 feat: Add Swagger API documentation
This commit introduces Swagger API documentation for all endpoints in the
application.

- Installs  and .
- Configures Swagger in  to generate and serve API documentation
  at .
- Adds JSDoc-style Swagger annotations to all routes in  and
  the  directory (, , ,
  , , ).
- Defines a cookie-based security scheme for authenticated routes.

This allows for interactive API documentation and testing via the
endpoint.
2025-07-17 09:52:37 -04:00
Adolfo Reyna
0a48327e93 Improve readme (AI) 2025-07-17 09:37:21 -04:00
Adolfo Reyna
100b0c2a8f Change payments to be accessible wihtout loggin 2025-07-17 09:32:49 -04:00
Adolfo Reyna
64ca9df639 Tags support 2025-07-17 09:32:27 -04:00
Adolfo Reyna
46a2fc5c2b Add endpoint to retrieve posts by tag with validation 2025-02-27 23:45:17 -05:00
Adolfo Reyna
56cb8b4caa Enhance session and profile handling with validation and error handling improvements 2025-02-27 23:12:11 -05:00
Adolfo Reyna
606db78529 Add data to push notifications 2025-02-27 23:11:53 -05:00
25 changed files with 3771 additions and 369 deletions

100
Agent.md Normal file
View File

@@ -0,0 +1,100 @@
# EMI Backend Agent Notes
## What this service is
- Node.js + Express API for EMI social features (profiles, posts, groups/courses, songs, payments, Bible/subsplash integrations).
- Main entrypoint: `index.js`.
- MongoDB Atlas-backed via `MONGO_URL` using `mongodb@3.6.x`.
## Runbook
- Install: `npm install`
- Start: `npm start` (binds to `PORT`, default `3000`)
- Test: `npm test` (single auth test file)
- API docs: `GET /api-docs`
## High-level architecture
- `index.js`: middleware setup, auth routes, route mounting, Swagger, web-push setup.
- `mongoDB.js`: creates shared DB object + collections + utility methods, then extends with:
- `dbTools/profile.js`
- `dbTools/post.js`
- `dbTools/payments.js`
- `dbTools/songs.js`
- `middleware/sessionChecker.js`: cookie/session validation and profile context hydration.
- `routes/*.js`: feature-specific routers.
- `def/*.js`: lightweight constructors for `Profile`, `Post`, `Songs`.
## Auth + session model
- Cookies used:
- `user_sid`
- `session_id`
- `profile_id`
- `sessionChecker` verifies ObjectId format, then checks session in `tokens` collection.
- On missing/invalid session/profile, user is redirected to `/login`.
- Most app routes are protected with `sessionChecker` except:
- `/signup`, `/login`, `/logout`, `/resetPassword`
- `/payments/*`
- `/subsplash/*`
- `/invite/:email`
## Key route surfaces
- `routes/profile.js`:
- Profile CRUD, invites, follow/unfollow, group/course discovery, subscribe/approve/reject flows.
- `routes/post.js`:
- Feed endpoints, tags/media filters, create/edit/delete posts, reactions/comments/bookmarks.
- Merges organic + non-organic posts (news/popular recommendations).
- `routes/payments.js`:
- Stripe payment intent creation + result registration; can toggle subscription timestamp.
- `routes/songs.js`:
- Song CRUD (ownership checks are effectively placeholder).
- `routes/bible.js`:
- Proxies scripture.api.bible endpoints using hardcoded API key in source.
- `routes/subsplash.js`:
- Scrapes Subsplash HTML with cheerio for events/media.
## Data model (collections)
- `users`: auth identity + password hash + optional customer.
- `tokens`: session documents (`uid` points to user).
- `invitation`: invite gating for signup.
- `profiles`: user/group/course/chat profile documents.
- `posts`: feed posts, reactions, comments, bookmarks, tags, non-organic type.
- `payments`: intent and payment result records.
- `songs`: song content metadata and reactions/comments.
## Important operational dependencies
- Mongo connection is required before server starts listening (`index.js` waits for `DB.getDB`).
- Notifications:
- Email via `nodemailer` SMTP (`mail.emmint.com`, env `EMAILPASS`).
- Mobile push via Expo (`expo-server-sdk`).
- Web push VAPID keys (`PUBLIC_VAPID_KEY`, `PRIVATE_VAPID_KEY`, `WEB_PUSH_EMAIL`).
- Analytics via PostHog (`POSTHOG_API_KEY`).
- Stripe via `STRIPE`.
## Environment/cookie/cors behavior
- Cookies configured in `config/cookiesOptions.js`:
- production or `COOKIE_SECURE=true` => `secure: true`, `sameSite: none`
- local HTTP => `secure: false`, `sameSite: lax`
- Allowed CORS origins in `config/corsOptions.js` are explicit list-based.
## Known code risks and maintenance hotspots
- Mixed ESM/CommonJS utility scripts (`AITools.js` uses ESM style while app is CommonJS).
- `routes/bible.js` has duplicate `/books` route and a probable bug in `/books/:bookId` (`bibleId` reference).
- Hardcoded external API key in `routes/bible.js` should be moved to env.
- `routes/songs.js` `songBelongsToUser` always returns true (authorization gap).
- Some endpoints return redirect-to-login for API callers instead of structured 401 JSON.
- Inconsistent error handling/response shapes across routes.
- Legacy driver/runtime tension:
- Dependency is `mongodb@3.6.x`
- `Dockerfile` uses Node 22, but code warns Node 22 is not fully tested; Node 20 LTS is safer.
## Testing state
- Only `test/auth.test.js` exists; no broad coverage for routes/db tools.
- Auth test expects existing seeded user behavior, so reliability depends on DB fixture state.
## Suggested workflow for future changes
- Keep fixes scoped and defensive (null checks + stable JSON).
- For auth/session changes:
- update both `sessionChecker` and `utils/sessionUtils.js`.
- For profile/post behavior:
- confirm DB helper method side effects in `dbTools/*`.
- For production incidents:
- first validate `MONGO_URL` connectivity and cookie security mode alignment.

139
README.md
View File

@@ -2,8 +2,141 @@
This is the code for the backend of the EMI website. This is the code for the backend of the EMI website.
## Getting Started
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
### Prerequisites
What things you need to install the software and how to install them:
```
node.js
npm
mongodb
```
### Installing
A step by step series of examples that tell you how to get a development env running:
1. Clone the repo
2. Install NPM packages
```
npm install
```
3. Create a `.env` file with the necessary environment variables (`PORT`, `MONGO_URL`, `STRIPE`, etc.)
4. Run the server
```
npm start
```
### API Documentation
Once the server is running, you can access the interactive API documentation powered by Swagger UI at:
`http://localhost:3000/api-docs`
This page allows you to view all available endpoints, their parameters, and test them directly from your browser.
## API Endpoints
The API is divided into several sections based on functionality. Most routes under `/user`, `/post`, `/bible`, and `/songs` require authentication via a session cookie.
### Authentication
- `POST /signup`: Creates a new user account.
- `POST /login`: Logs in a user and creates a session.
- `GET /logout`: Logs out the current user.
- `POST /resetPassword`: Sends a password reset link to the user's email.
### General
- `GET /`: Returns basic information about the logged-in user.
- `GET /invite/:email`: Checks if an invitation exists for a given email.
- `POST /changeProfile`: Changes the active profile for the logged-in user.
- `POST /token`: Refreshes the push notification token for a profile.
- `POST /subscribe`: Subscribes a profile to web push notifications.
### Profiles (`/user`)
- `GET /mine`: Get all profiles for the logged-in user.
- `POST /`: Creates a new profile.
- `POST /invite`: Invites a new user by email.
- `GET /invite/:email`: Get invitation details for an email.
- `GET /groups`: Get a list of all groups.
- `GET /groups/following`: Get a list of groups the current profile is following.
- `POST /groups`: Create a new group.
- `GET /courses`: Get a list of all courses.
- `POST /groups/accept`: Accept a request to join a private group.
- `POST /groups/reject`: Reject a request to join a private group.
- `GET /groups/search`: Search for groups.
- `GET /groups/:id`: Get details for a specific group.
- `GET /groups/:id/subscribe`: Subscribe to a group.
- `GET /groups/:id/unsubscribe`: Unsubscribe from a group.
- `GET /search`: Search for profiles.
- `POST /setData`: Set custom data for a profile.
- `POST /myProfile`: Update the current user's profile.
- `GET /:id`: Get a specific profile by ID.
- `DELETE /:id`: Delete a profile.
- `GET /:id/follow`: Follow a profile.
- `GET /:id/unfollow`: Unfollow a profile.
### Posts (`/post`)
- `GET /organic`: Get the organic feed for the current user.
- `GET /`: Get the feed with promotional content.
- `GET /tag/:tag`: Get posts with a specific tag.
- `GET /usr/:id`: Get posts from a specific user.
- `GET /usr/:id/images`: Get all image posts from a user.
- `GET /usr/:id/embedded`: Get all embedded posts from a user.
- `GET /usr/:id/media`: Get all media posts from a user.
- `POST /`: Create a new post.
- `POST /react`: React to a post.
- `POST /unreact`: Remove a reaction from a post.
- `POST /bookmark`: Bookmark a post.
- `POST /unbookmark`: Remove a bookmark from a post.
- `POST /comment`: Add a comment to a post.
- `POST /comment/react`: React to a comment.
- `POST /comment/unreact`: Remove a reaction from a comment.
- `GET /images`: Get all image posts for the current user.
- `GET /embedded`: Get all embedded posts for the current user.
- `GET /media`: Get all media posts for the current user.
- `GET /course/recent`: Get recently watched media from courses.
- `GET /:id`: Get a specific post by ID.
- `DELETE /:id`: Delete a post.
- `POST /:id`: Update a post.
### Payments (`/payments`)
- `POST /create-payment-intent`: Creates a Stripe Payment Intent.
- `POST /intent`: (Alias for /create-payment-intent)
- `POST /register`: Registers a payment after a successful Stripe transaction.
### Songs (`/songs`)
- `GET /`: Get all songs.
- `POST /`: Create a new song.
- `GET /:id`: Get a specific song by ID.
- `DELETE /:id`: Delete a song.
- `POST /:id`: Update a song.
### Bible (`/bible`)
- `GET /`: Get a list of available Bibles.
- `GET /books`: Get the books of a Bible.
- `GET /books/:bookId`: Get details for a specific book.
- `GET /books/:bookId/chapters`: Get the chapters of a book.
- `GET /chapters/:chapterId`: Get the content of a chapter.
- `GET /chapters/:chapterId/verses`: Get the verses of a chapter.
- `GET /search`: Search the Bible.
### Subsplash (`/subsplash`)
- `GET /events/:calendarId`: Get events from a Subsplash calendar.
- `GET /media/:seriesId`: Get media from a Subsplash media series.
### TODO ### TODO
[ ] Define nodes schema - [ ] Define nodes schema
[ ] Implement basic login/registration - [ ] Implement basic login/registration
[ ]

View File

@@ -3,21 +3,55 @@ const { client_logger } = require('../utils/analyticsLogger');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils.js'); const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils.js');
const { cookiesOptions } = require('../config/cookiesOptions'); const { getCookiesOptions } = require('../config/cookiesOptions');
const Notifications = require("../notifications"); const Notifications = require("../notifications");
// Object Definitions // Object Definitions
const Post = require("../def/post.js") const Post = require("../def/post.js")
const Profile = require("../def/profile.js"); const Profile = require("../def/profile.js");
const DUMMY_BCRYPT_HASH = '$2b$10$2zQfAaxK0cN13N7V2Q5hAOL3wxY5E9OQj1YxDCEV4VpWw2X2gYd6C';
const PASSWORD_TOKEN_TTL_MINUTES = parseInt(process.env.PASSWORD_TOKEN_TTL_MINUTES || '20', 10);
const PASSWORD_TOKEN_PATH = process.env.PASSWORD_TOKEN_PATH || '/token-login';
const FRONTEND_URL = (process.env.FRONTEND_URL || 'https://social.emmint.com').replace(/\/+$/, '');
const createPasswordTokenHash = (rawToken) =>
crypto.createHash('sha256').update(rawToken).digest('hex');
const createSessionFromUser = async ({ DB, user, req, res }) => {
const sessionObj = await DB.newSession(user._id);
const cookiesOptions = getCookiesOptions(req);
res.cookie('user_sid', user._id, cookiesOptions);
res.cookie('session_id', sessionObj.insertedId, cookiesOptions);
const latestUpdatedProfile = await DB.latestProfile(user._id);
if (latestUpdatedProfile && latestUpdatedProfile._id) {
res.cookie('profile_id', latestUpdatedProfile._id, cookiesOptions);
}
client_logger.identify({
distinctId: user._id,
properties: {
name: latestUpdatedProfile?.profile?.firstName || '',
}
});
client_logger.capture({
distinctId: user._id,
event: 'server@' + req.method + '@' + req.originalUrl,
});
return {
status: "ok",
user_sid: user._id,
session_id: sessionObj.insertedId,
profile_id: latestUpdatedProfile?._id
};
};
// Function to Singup new users. An user is a combination of a user obj and a profile. // Function to Singup new users. An user is a combination of a user obj and a profile.
// When new users are subscribed, they have a single profile, which is the personal one. // When new users are subscribed, they have a single profile, which is the personal one.
// Other profiles can be link to that user, like groups or courses. // Other profiles can be link to that user, like groups or courses.
const signup = async function (req, res) { const signup = async function (req, res) {
const username = req.query.username || req.body.username; const username = (req.body.username || "").trim().toLowerCase();
const password = req.query.password || req.body.password; const password = req.body.password;
const email = req.query.email || req.body.email; const email = (req.body.email || "").trim().toLowerCase();
const profile = req.query.profile || req.body.profile; const profile = req.body.profile;
if (!username || !password || !email) return res.json({ status: "Incomplete information!" }); if (!username || !password || !email) return res.json({ status: "Incomplete information!" });
// Check if the new user has an invitation. // Check if the new user has an invitation.
const DB = await MongoDB.getDB; const DB = await MongoDB.getDB;
@@ -34,12 +68,10 @@ const signup = async function (req, res) {
} }
let isUserAlreadyRegistered = await DB.getUser(email); let isUserAlreadyRegistered = await DB.getUser(email);
if (isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({ status: "This user is already registered" }); if (isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({ status: "This user is already registered" });
// Hash password to be stored on the DB.
// TODO: I think this is missing a Salt factor to improve security
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const newUserObject = await DB.newUser({ const newUserObject = await DB.newUser({
username: username.toLowerCase(), username,
email: email.toLowerCase(), email,
password: hashedPassword password: hashedPassword
}); });
// If newUserObject it's an error message, we check by looking toLowerCase function // If newUserObject it's an error message, we check by looking toLowerCase function
@@ -74,52 +106,28 @@ const login = async function (req, res) {
// Check if user is already logged in and redirect to root if so. // Check if user is already logged in and redirect to root if so.
const session_id = getSessionId(req); const session_id = getSessionId(req);
const user_sid = getUserId(req); const user_sid = getUserId(req);
const DB = await MongoDB.getDB;
if (session_id && user_sid) { if (session_id && user_sid) {
const userInfo = await DB.checkSessionOnDB(session_id, user_sid); const userInfo = await DB.checkSessionOnDB(session_id, user_sid);
if (userInfo) return res.redirect('/'); if (userInfo) return res.redirect('/');
} }
const username = req.body.username || req.query.username; const invalidCredentials = () => res.status(401).json({ status: "Invalid credentials" });
const password = req.body.password || req.query.password || ""; const username = (req.body.username || req.body.email || "").trim().toLowerCase();
const DB = await MongoDB.getDB; const password = req.body.password || "";
if (!username || !password) return invalidCredentials();
const user = await DB.getUser(username); const user = await DB.getUser(username);
if (!user) { if (!user) {
client_logger.capture({ client_logger.capture({
distinctId: 'app_level', distinctId: 'app_level',
event: 'server@' + req.method + '@' + req.originalUrl + '@userNotFound', event: 'server@' + req.method + '@' + req.originalUrl + '@invalidCredentials',
properties: { properties: { username },
username: username,
}
}); });
return res.json({ status: "user not founded" });
} }
// TODO: Also add salt parameter here. const isSamePassword = await bcrypt.compare(password, user?.password || DUMMY_BCRYPT_HASH);
const isSamePassword = await bcrypt.compare(password, user.password); if (!user || !isSamePassword) return invalidCredentials();
if (!isSamePassword) return res.json({ status: "incorrect password" });
try { try {
// Store a new session loging on DB, and use ID as session ID return res.json(await createSessionFromUser({ DB, user, req, res }));
const sessionObj = await DB.newSession(user._id);
// Create coockies with information for Auth
res.cookie('user_sid', user._id, cookiesOptions);
res.cookie('session_id', sessionObj.insertedId, cookiesOptions);
// Chooses the most recent update profile as current active profile
const latestUpdatedProfile = await DB.latestProfile(user._id);
res.cookie('profile_id', latestUpdatedProfile._id, cookiesOptions);
client_logger.identify({
distinctId: user._id,
properties: {
name: latestUpdatedProfile.profile.firstName,
}
});
client_logger.capture({
distinctId: user._id,
event: 'server@' + req.method + '@' + req.originalUrl,
});
return res.json({
status: "ok",
user_sid: user._id,
session_id: sessionObj.insertedId,
profile_id: latestUpdatedProfile._id
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);
client_logger.capture({ client_logger.capture({
@@ -136,8 +144,9 @@ const logout = async function (req, res) {
const session_id = getSessionId(req); const session_id = getSessionId(req);
const user_sid = getUserId(req); const user_sid = getUserId(req);
if (session_id && user_sid) { if (session_id && user_sid) {
res.clearCookie('session_id'); const cookiesOptions = getCookiesOptions(req);
res.clearCookie('user_sid'); res.clearCookie('session_id', cookiesOptions);
res.clearCookie('user_sid', cookiesOptions);
//remove from DB //remove from DB
const DB = await MongoDB.getDB; const DB = await MongoDB.getDB;
DB.removeSession(session_id); DB.removeSession(session_id);
@@ -151,51 +160,43 @@ const logout = async function (req, res) {
} }
} }
// Util function for generating new random password for users.
function generatePassword(length = 12) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
return Array.from(crypto.randomFillSync(new Uint8Array(length)))
.map((x) => charset[x % charset.length])
.join("");
}
const resetPassword = async function (req, res) { const resetPassword = async function (req, res) {
const session_id = getSessionId(req);
const user_sid = getUserId(req);
const DB = await MongoDB.getDB; const DB = await MongoDB.getDB;
if (session_id && user_sid) {
// Sadly reusing this endpoint to change password to legged in users. const genericResetResponse = {
// TODO: Move change password logic to its own endpoint. status: "ok",
const userInfo = await DB.checkSessionOnDB(session_id, user_sid); details: "If the account exists, check your email for next steps"
if (userInfo) { };
const password = req.body.password;
const hashedPassword = await bcrypt.hash(password, 10);
// TODO: Add salt to password here as well.
DB.resetUserPassword(userInfo.username, hashedPassword);
return res.json({
status: "ok",
details: 'password changed!' // This should be an enum that syncs with clients.
});
}
}
// Logic for non-logged in users. // Logic for non-logged in users.
const username = req.body.username; const username = (req.body.username || req.body.email || "").trim().toLowerCase();
if (!username) return res.json(genericResetResponse);
const user = await DB.getUser(username); const user = await DB.getUser(username);
if (!user) return res.json({ status: "user not founded" }); if (!user) {
const password = generatePassword(); client_logger.capture({
const hashedPassword = await bcrypt.hash(password, 10); distinctId: 'app_level',
// TODO: Add salt to password here as well. event: 'server@' + req.method + '@' + req.originalUrl + '@resetRequestedUnknownUser',
// TODO: We need to limit this to every 2 hours or something like this. properties: { username }
// TODO: Move this template to the Notif file. });
DB.resetUserPassword(username, hashedPassword); return res.json(genericResetResponse);
Notifications.sendEmail(username, "Your new credentials", }
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = createPasswordTokenHash(rawToken);
const expiresAt = new Date(Date.now() + PASSWORD_TOKEN_TTL_MINUTES * 60 * 1000);
const tokenStored = await DB.createPasswordLoginToken(user._id, tokenHash, expiresAt);
if (!tokenStored) {
return res.json(genericResetResponse);
}
const loginUrl = `${FRONTEND_URL}${PASSWORD_TOKEN_PATH}?token=${rawToken}`;
Notifications.sendEmail(username, "Your secure sign-in link",
` `
<p> Hello,</p> <p>Hello,</p>
<p> This is your new password: ${password}</p> <p>Use this one-time sign-in link to access your account:</p>
<p><a href="https://social.emmint.com/">Log in</a></p> <p><a href="${loginUrl}">${loginUrl}</a></p>
<p>This link expires in ${PASSWORD_TOKEN_TTL_MINUTES} minutes and can only be used once.</p>
<p>If you did not request this, you can ignore this email.</p>
<p>Blessings</p> <p>Blessings</p>
<p>Emmanuel International Ministries</p> <p>Emmanuel International Ministries</p>
`) `);
client_logger.capture({ client_logger.capture({
distinctId: user._id, distinctId: user._id,
event: 'server@' + req.method + '@' + req.originalUrl, event: 'server@' + req.method + '@' + req.originalUrl,
@@ -203,10 +204,30 @@ const resetPassword = async function (req, res) {
username: username, username: username,
} }
}); });
return res.json({ return res.json(genericResetResponse);
status: "ok", }
details: 'Check your email for new password' // Enum of details?
}); const loginWithPasswordToken = async function (req, res) {
const DB = await MongoDB.getDB;
const token = (req.body.token || "").trim();
if (!token || token.length < 32) {
return res.status(401).json({ status: "Invalid or expired token" });
}
const tokenHash = createPasswordTokenHash(token);
const tokenDoc = await DB.consumePasswordLoginToken(tokenHash);
if (!tokenDoc || !tokenDoc.userId) {
return res.status(401).json({ status: "Invalid or expired token" });
}
const user = await DB.getUserById(tokenDoc.userId);
if (!user || !user._id) {
return res.status(401).json({ status: "Invalid or expired token" });
}
try {
return res.json(await createSessionFromUser({ DB, user, req, res }));
} catch (error) {
console.error("Token login error", error);
return res.status(500).json({ status: "Internal server error" });
}
} }
@@ -215,4 +236,5 @@ module.exports = {
login, login,
logout, logout,
resetPassword, resetPassword,
} loginWithPasswordToken,
}

View File

@@ -1,8 +1,49 @@
const cookiesOptions = { const isProduction = process.env.NODE_ENV === "production";
maxAge: 1000 * 60 * 60 * 24 * 90, // would expire after 30 days const forceSecureCookie = process.env.COOKIE_SECURE === "true";
httpOnly: true, // The cookie only accessible by the web server
sameSite: 'none', // This and secure are required for properly const COOKIE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 90; // 90 days
secure: true, // manage cockies in cros-domain const LOCAL_ORIGIN_REGEX = /^http:\/\/(localhost|127\.0\.0\.1|aeropi\.local)(:\d+)?$/i;
const LOCAL_HOST_REGEX = /^(localhost|127\.0\.0\.1|aeropi\.local)(:\d+)?$/i;
const getHeaderValue = (req, key) => {
if (!req || !req.headers) return "";
const raw = req.headers[key];
if (Array.isArray(raw)) return raw[0] || "";
return raw || "";
}; };
module.exports = { cookiesOptions }; const isLocalRequest = (req) => {
const origin = getHeaderValue(req, "origin");
const host = getHeaderValue(req, "host");
return LOCAL_ORIGIN_REGEX.test(origin) || LOCAL_HOST_REGEX.test(host);
};
const isHttpsRequest = (req) => {
if (!req) return false;
const forwardedProto = String(getHeaderValue(req, "x-forwarded-proto")).split(",")[0].trim().toLowerCase();
const reqProtocol = String(req.protocol || "").toLowerCase();
const origin = String(getHeaderValue(req, "origin") || "").toLowerCase();
if (forwardedProto === "https" || reqProtocol === "https") return true;
return origin.startsWith("https://");
};
const shouldUseSecureCookie = (req) => {
if (forceSecureCookie) return true;
if (isLocalRequest(req)) return false;
if (isHttpsRequest(req)) return true;
return isProduction;
};
const getCookiesOptions = (req) => {
const secure = shouldUseSecureCookie(req);
return {
maxAge: COOKIE_MAX_AGE_MS,
httpOnly: true,
sameSite: secure ? "none" : "lax",
secure,
};
};
const cookiesOptions = getCookiesOptions();
module.exports = { cookiesOptions, getCookiesOptions };

View File

@@ -1,12 +1,17 @@
var corsOptions = { var corsOptions = {
origin: [ origin: [
'http://localhost:8080', 'http://localhost:8080',
'http://localhost:8081',
'http://127.0.0.1:3000',
'http://127.0.0.1:8080',
'http://127.0.0.1:8081',
'http://localhost:3000', 'http://localhost:3000',
"https://social.emmint.com", "https://social.emmint.com",
"https://www.social.emmint.com",
"https://fellowship.emmint.com", "https://fellowship.emmint.com",
"https://aeropi.local", "https://aeropi.local",
], ],
credentials: true credentials: true
}; };
module.exports = { corsOptions }; module.exports = { corsOptions };

62
dbTools/chat.js Normal file
View File

@@ -0,0 +1,62 @@
const DBName = "EMI_SOCIAL";
const chatDB = (DB) => {
DB.chatMessagesCol = DB.db.db(DBName).collection("chat_messages");
DB.chatMessagesCol.createIndex({ createdAt: -1 }).catch(console.error);
DB.addChatMessage = async ({ senderId, senderProfileId, senderName, text, sourceLang }) => {
const safeText = (text || "").trim();
if (!safeText) return false;
const message = {
senderId: senderId ? senderId + "" : "",
senderProfileId: senderProfileId ? senderProfileId + "" : "",
senderName: senderName || "Anonymous",
text: safeText,
sourceLang: sourceLang || "en",
translations: {},
createdAt: new Date(),
};
const result = await DB.chatMessagesCol.insertOne(message).catch((err) => {
console.log(err);
return false;
});
if (!result || !result.insertedId) return false;
return {
...message,
_id: result.insertedId,
};
};
DB.getRecentChatMessages = async (limit = 100) => {
const safeLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
const messages = await DB.chatMessagesCol.find({})
.sort({ createdAt: -1 })
.limit(safeLimit)
.toArray()
.catch((err) => {
console.log(err);
return [];
});
return messages.reverse();
};
DB.setChatMessageTranslation = async ({ messageId, targetLang, text, provider, model }) => {
if (!messageId || !targetLang || !text) return false;
const _id = typeof messageId === "string" ? DB.ObjectID(messageId) : messageId;
const fieldBase = `translations.${targetLang}`;
const update = {
$set: {
[`${fieldBase}.text`]: text,
[`${fieldBase}.provider`]: provider || "openai",
[`${fieldBase}.model`]: model || "",
[`${fieldBase}.updatedAt`]: new Date(),
},
};
return DB.chatMessagesCol.updateOne({ _id }, update).catch((err) => {
console.log(err);
return false;
});
};
};
module.exports = chatDB;

View File

@@ -233,7 +233,7 @@ postDB = (DB)=>{
if(!DB.ObjectID.isValid(profileId)) return []; if(!DB.ObjectID.isValid(profileId)) return [];
const profile = await DB.getProfile(profileId); const profile = await DB.getProfile(profileId);
if(!profile) return []; if(!profile) return [];
query = { const query = {
nonOrganicType: null // Exlcude news nonOrganicType: null // Exlcude news
}; };
return DB.postCols.find(query).sort({lastUpdated: -1}).limit(50).toArray().then(async (posts)=>{ return DB.postCols.find(query).sort({lastUpdated: -1}).limit(50).toArray().then(async (posts)=>{
@@ -244,6 +244,25 @@ postDB = (DB)=>{
}); });
} }
// For all post with tags const query = { content: { $regex: '#\\w+', $options: 'i' } };
DB.getPostsByTag = async (tag, profileId, limit = 50) => {
if(!DB.ObjectID.isValid(profileId)) return [];
const profile = await DB.getProfile(profileId);
if(!profile) return [];
let query = {
content: {
"$regex": tag
},
nonOrganicType: null // Exlcude news
};
return DB.postCols.find(query).sort({lastUpdated: -1}).limit(limit).toArray().then(async (posts)=>{
return await filterPrivateGroups(posts, profile);
}).catch((err)=>{
console.log(err);
return false;
});
}
DB.getNews = async () => { DB.getNews = async () => {
let query = { let query = {
nonOrganicType: 'News' nonOrganicType: 'News'

View File

@@ -15,7 +15,7 @@ userDB = (DB) => {
DB.removeProfile = (profileid) => { DB.removeProfile = (profileid) => {
const _id = DB.ObjectID(profileid); const _id = DB.ObjectID(profileid);
if (userProfileCache[profileid]) delete userProfileCache[profileid]; if (userProfileCache[profileid]) delete userProfileCache[profileid];
return DB.profileCols.deleteOne({_id}).catch((err)=>{ return DB.profileCols.deleteOne({ _id }).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -23,7 +23,9 @@ userDB = (DB) => {
DB.updateProfile = async (profileid, profileObj) => { DB.updateProfile = async (profileid, profileObj) => {
let tempProfile = profileObj.toObj(); let tempProfile = profileObj.toObj();
const query = {_id: profileid}; if (!DB.ObjectID.isValid(profileid)) return false;
const _id = DB.ObjectID(profileid);
const query = { _id };
const update = { const update = {
$set: { $set: {
profile: tempProfile.profile, profile: tempProfile.profile,
@@ -34,13 +36,14 @@ userDB = (DB) => {
console.log(err); console.log(err);
return false; return false;
}); });
if (userProfileCache[profileid]) delete userProfileCache[profileid];
return r; return r;
} }
DB.getProfile = async (profileId) => { DB.getProfile = async (profileId) => {
//if (userProfileCache[profileId] && !userProfileCache[profileId].isGroup) return userProfileCache[profileId]; //if (userProfileCache[profileId] && !userProfileCache[profileId].isGroup) return userProfileCache[profileId];
if(!profileId) return false; if (!profileId) return false;
try{ try {
const _id = DB.ObjectID(profileId); const _id = DB.ObjectID(profileId);
let r = await DB.profileCols.findOne({ _id }).catch((err) => { let r = await DB.profileCols.findOne({ _id }).catch((err) => {
console.log(err); console.log(err);
@@ -48,7 +51,7 @@ userDB = (DB) => {
}); });
if (r) userProfileCache[profileId] = r; if (r) userProfileCache[profileId] = r;
return r; return r;
}catch(_){ } catch (_) {
return {}; return {};
} }
} }
@@ -56,16 +59,16 @@ userDB = (DB) => {
DB.getPopularProfiles = async (limit = 10) => { DB.getPopularProfiles = async (limit = 10) => {
return DB.profileCols.aggregate([ return DB.profileCols.aggregate([
{ {
$match: {isGroup: {$ne: true}} $match: { isGroup: { $ne: true } }
}, },
{ {
$addFields: { subscribed_count: {$size: { "$ifNull": [ "$following", [] ] } } } $addFields: { subscribed_count: { $size: { "$ifNull": ["$following", []] } } }
},
{
$sort: {"subscribed_count":-1}
}, },
{ {
$project: {_id: 1, "subscribed_count": 1} $sort: { "subscribed_count": -1 }
},
{
$project: { _id: 1, "subscribed_count": 1 }
} }
]).limit(limit).toArray().catch((err) => { ]).limit(limit).toArray().catch((err) => {
console.log(err); console.log(err);
@@ -76,16 +79,16 @@ userDB = (DB) => {
DB.getPopularGroups = async (limit = 10) => { DB.getPopularGroups = async (limit = 10) => {
return DB.profileCols.aggregate([ return DB.profileCols.aggregate([
{ {
$match: {isGroup: true, isPrivate: {$ne: true}, isCourse: {$ne: true}} $match: { isGroup: true, isPrivate: { $ne: true }, isCourse: { $ne: true } }
}, },
{ {
$addFields: { subscribed_count: {$size: { "$ifNull": [ {"$objectToArray" : "$subscribed"}, [] ] } } } $addFields: { subscribed_count: { $size: { "$ifNull": [{ "$objectToArray": "$subscribed" }, []] } } }
},
{
$sort: {"subscribed_count":-1}
}, },
{ {
$project: {_id: 1, "subscribed_count": 1} $sort: { "subscribed_count": -1 }
},
{
$project: { _id: 1, "subscribed_count": 1 }
} }
]).limit(limit).toArray().catch((err) => { ]).limit(limit).toArray().catch((err) => {
console.log(err); console.log(err);
@@ -95,19 +98,21 @@ userDB = (DB) => {
DB.getFriendsFriends = async (profileId, limit = 10) => { DB.getFriendsFriends = async (profileId, limit = 10) => {
const profile = await DB.getProfile(profileId); const profile = await DB.getProfile(profileId);
if(!profile) return []; if (!profile) return [];
let ids = profile.following.map((id)=>DB.ObjectID(id)); const following = Array.isArray(profile.following) ? profile.following : [];
let ids = following.filter((id) => DB.ObjectID.isValid(id)).map((id) => DB.ObjectID(id));
let alreadyFollowingMap = {}; let alreadyFollowingMap = {};
alreadyFollowingMap[profileId] = 1; //skip that profile alreadyFollowingMap[profileId] = 1; //skip that profile
profile.following.forEach(id => { following.forEach(id => {
if(!alreadyFollowingMap[id]) alreadyFollowingMap[id] = 1; if (!alreadyFollowingMap[id]) alreadyFollowingMap[id] = 1;
}) })
return DB.profileCols.find({_id:{$in: ids}}).project({following: 1}).limit(limit).toArray().then(profiles => { return DB.profileCols.find({ _id: { $in: ids } }).project({ following: 1 }).limit(limit).toArray().then(profiles => {
let friendsOfFriendsMap = {}; let friendsOfFriendsMap = {};
profiles.forEach(p => { profiles.forEach(p => {
p.following.forEach(followingId => { const related = Array.isArray(p.following) ? p.following : [];
if(alreadyFollowingMap[followingId]) return 0; related.forEach(followingId => {
if(!friendsOfFriendsMap[followingId]) friendsOfFriendsMap[followingId] = 0; if (alreadyFollowingMap[followingId]) return 0;
if (!friendsOfFriendsMap[followingId]) friendsOfFriendsMap[followingId] = 0;
friendsOfFriendsMap[followingId] = friendsOfFriendsMap[followingId] + 1; friendsOfFriendsMap[followingId] = friendsOfFriendsMap[followingId] + 1;
}); });
}); });
@@ -124,29 +129,43 @@ userDB = (DB) => {
return DB.getProfile(profileId); return DB.getProfile(profileId);
} }
DB.getProfileCache = async (profileId) => {
const cachedProfile = userProfileCache[profileId];
if (cachedProfile?.isGroup === false) {
return cachedProfile;
}
return await DB.getProfile(profileId);
};
DB.searchProfile = async (queryStr) => { DB.searchProfile = async (queryStr) => {
let regEx = new RegExp(queryStr, 'i'); let regEx = new RegExp(queryStr, 'i');
let query = { let query = {
isGroup: false, isGroup: false,
isChat: {$ne: true}, isChat: { $ne: true },
$or: [ $or: [
{"profile.firstName": { {
$regex: regEx "profile.firstName": {
}}, $regex: regEx
{"profile.lastName": { }
$regex: regEx },
}}, {
{"profile.description": { "profile.lastName": {
$regex: regEx $regex: regEx
}}, }
},
{
"profile.description": {
$regex: regEx
}
},
] ]
}; };
let r = await DB.profileCols.find(queryStr ? query : {isGroup: false, isChat: {$ne: true}}) let r = await DB.profileCols.find(queryStr ? query : { isGroup: false, isChat: { $ne: true } })
.sort({ lastUpdate: -1 }).limit(20) .sort({ lastUpdate: -1 }).limit(20)
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
return r; return r;
} }
@@ -154,45 +173,45 @@ userDB = (DB) => {
const userid = DB.ObjectID(userId); const userid = DB.ObjectID(userId);
return await DB.profileCols.find({ userid }).toArray().catch((err) => { return await DB.profileCols.find({ userid }).toArray().catch((err) => {
console.log(err); console.log(err);
return false; return [];
}); });
} }
DB.latestProfile = async (userId) => { DB.latestProfile = async (userId) => {
const userid = DB.ObjectID(userId); const userid = DB.ObjectID(userId);
let r = await DB.profileCols.find({ userid }) let r = await DB.profileCols.find({ userid })
.sort({ lastUpdate: -1 }) .sort({ lastUpdate: -1 })
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
let index = 0; let index = 0;
while(r[index].isGroup || r[index].isChat) index += 1; while (r[index].isGroup || r[index].isChat) index += 1;
if (r[index]) userProfileCache[r[index]._id] = r[index]; if (r[index]) userProfileCache[r[index]._id] = r[index];
return r[index]; return r[index];
} }
DB.followProfile = async (profileId, followProfileId)=>{ DB.followProfile = async (profileId, followProfileId) => {
const _id = DB.ObjectID(profileId); const _id = DB.ObjectID(profileId);
let update = { let update = {
$addToSet:{ $addToSet: {
following: followProfileId + '' //converts to str following: followProfileId + '' //converts to str
} }
} }
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
} }
DB.unfollowProfile = async (profileId, followProfileId)=>{ DB.unfollowProfile = async (profileId, followProfileId) => {
const _id = DB.ObjectID(profileId); const _id = DB.ObjectID(profileId);
let update = { let update = {
$pull:{ $pull: {
following: followProfileId + '' //converts to str following: followProfileId + '' //converts to str
} }
} }
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -200,7 +219,7 @@ userDB = (DB) => {
DB.getFollowingTheProfile = async (profileId) => { DB.getFollowingTheProfile = async (profileId) => {
//const profile_id = DB.ObjectID(profileId); //const profile_id = DB.ObjectID(profileId);
let r = await DB.profileCols.find({ following: (profileId+'') }) let r = await DB.profileCols.find({ following: (profileId + '') })
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return []; return [];
@@ -216,40 +235,40 @@ userDB = (DB) => {
DB.setData = async (profileid, key, value) => { DB.setData = async (profileid, key, value) => {
const _id = DB.ObjectID(profileid); const _id = DB.ObjectID(profileid);
let update = { let update = {
$set:{ $set: {
["data." + key]: value ["data." + key]: value
} }
} }
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
} }
DB.setProfileToken = (profileid, token)=>{ DB.setProfileToken = (profileid, token) => {
if(!token) return false; if (!token) return false;
const _id = DB.ObjectID(profileid); const _id = DB.ObjectID(profileid);
let update = { let update = {
$addToSet:{ $addToSet: {
token token
} }
} }
if (userProfileCache[profileid]) delete userProfileCache[profileid]; if (userProfileCache[profileid]) delete userProfileCache[profileid];
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
} }
DB.setWebSubscription = (profileid, webSubscription)=>{ DB.setWebSubscription = (profileid, webSubscription) => {
const _id = DB.ObjectID(profileid); const _id = DB.ObjectID(profileid);
let update = { let update = {
$set:{ $set: {
webSubscription webSubscription
} }
} }
if (userProfileCache[profileid]) delete userProfileCache[profileid]; if (userProfileCache[profileid]) delete userProfileCache[profileid];
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -258,20 +277,43 @@ userDB = (DB) => {
DB.addNotification = async (profileid, message, postid, commentIndx, actorid) => { DB.addNotification = async (profileid, message, postid, commentIndx, actorid) => {
const _id = DB.ObjectID(profileid); const _id = DB.ObjectID(profileid);
let update = { let update = {
$push:{ $push: {
notifications: { notifications: {
ts: new Date(), ts: new Date(),
body: message, body: message,
postid, postid,
commentIndx, commentIndx,
actorid, actorid,
viewed: false,
} }
} }
} }
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ const r = await DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
if (userProfileCache[profileid]) delete userProfileCache[profileid];
return r;
}
DB.markNotificationsViewed = async (profileid) => {
const _id = DB.ObjectID(profileid);
const update = {
$set: {
"notifications.$[n].viewed": true
}
};
const options = {
arrayFilters: [
{ "n.viewed": { $ne: true } }
]
};
const r = await DB.profileCols.updateOne({ _id }, update, options).catch((err) => {
console.log(err);
return false;
});
if (userProfileCache[profileid]) delete userProfileCache[profileid];
return r;
} }
DB.isSubscriptor = async (profileid) => { DB.isSubscriptor = async (profileid) => {
@@ -282,37 +324,38 @@ userDB = (DB) => {
//Groups //Groups
DB.getGroups = async (excludePrivate = false) => { DB.getGroups = async (excludePrivate = false) => {
let query = { let query = {
isGroup: true, isGroup: true,
isCourse: {$ne: true}, isCourse: { $ne: true },
isChat: {$ne: true}, isChat: { $ne: true },
}; };
if(excludePrivate) query.isPrivate = false; if (excludePrivate) query.isPrivate = false;
let r = await DB.profileCols.find(query).sort({ lastUpdate: -1 }).limit(10) let r = await DB.profileCols.find(query).sort({ lastUpdate: -1 }).limit(10)
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
return r; return r;
} }
DB.getFollowingGroups = async (profileid) => { DB.getFollowingGroups = async (profileid) => {
const profile = await DB.getProfile(profileid); const profile = await DB.getProfile(profileid);
let ids = []; let ids = [];
for(id in profile.following){ const following = Array.isArray(profile?.following) ? profile.following : [];
try{ for (id in following) {
let oId = DB.ObjectID(profile.following[id]); try {
let oId = DB.ObjectID(following[id]);
let checkProfile = await DB.getProfileCache(oId) let checkProfile = await DB.getProfileCache(oId)
if(checkProfile && checkProfile.isGroup && !checkProfile.isChat){ if (checkProfile && checkProfile.isGroup && !checkProfile.isChat) {
ids.push(oId) ids.push(oId)
} }
}catch{ } catch {
} }
} }
let query = { let query = {
isGroup: true, isGroup: true,
isCourse: {$ne: true}, isCourse: { $ne: true },
isChat: {$ne: true}, isChat: { $ne: true },
_id: { _id: {
$in: ids $in: ids
} }
@@ -321,7 +364,7 @@ userDB = (DB) => {
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
return r; return r;
} }
@@ -329,41 +372,49 @@ userDB = (DB) => {
let regEx = new RegExp(queryStr, 'i'); let regEx = new RegExp(queryStr, 'i');
let query = queryStr ? { let query = queryStr ? {
isGroup: true, isGroup: true,
isChat: {$ne: true}, isChat: { $ne: true },
isCourse: coursesB, isCourse: coursesB,
$or: [ $or: [
{"profile.firstName": { {
$regex: regEx "profile.firstName": {
}}, $regex: regEx
{"profile.lastName": { }
$regex: regEx },
}}, {
{"profile.description": { "profile.lastName": {
$regex: regEx $regex: regEx
}}, }
{"data.author": { },
$regex: regEx {
}} "profile.description": {
$regex: regEx
}
},
{
"data.author": {
$regex: regEx
}
}
] ]
} : {isGroup: true, isChat: {$ne: true}, isCourse: coursesB}; } : { isGroup: true, isChat: { $ne: true }, isCourse: coursesB };
let r = await DB.profileCols.find(query) let r = await DB.profileCols.find(query)
.sort({ lastUpdate: -1 }).limit(20) .sort({ lastUpdate: -1 }).limit(20)
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
return r; return r;
} }
let privateGroupsCache = {}; let privateGroupsCache = {};
DB.isGroupPrivate = async (groupid) => { DB.isGroupPrivate = async (groupid) => {
if(userProfileCache[groupid]) return userProfileCache[groupid].isPrivate; if (userProfileCache[groupid]) return userProfileCache[groupid].isPrivate;
let g = await DB.getGroup(groupid); let g = await DB.getGroup(groupid);
return g ? g.isPrivate : false; return g ? g.isPrivate : false;
} }
DB.isGroupNewsOnly = async (groupid) => { DB.isGroupNewsOnly = async (groupid) => {
if(userProfileCache[groupid]) return userProfileCache[groupid].newsOnly; if (userProfileCache[groupid]) return userProfileCache[groupid].newsOnly;
let g = await DB.getGroup(groupid); let g = await DB.getGroup(groupid);
return g ? g.newsOnly : false; return g ? g.newsOnly : false;
} }
@@ -377,7 +428,7 @@ userDB = (DB) => {
DB.getGroup = async (groupid) => { DB.getGroup = async (groupid) => {
const _id = DB.ObjectID(groupid); const _id = DB.ObjectID(groupid);
//if(userProfileCache[groupid]) return userProfileCache[groupid]; //if(userProfileCache[groupid]) return userProfileCache[groupid];
let r = await DB.profileCols.findOne({_id, isGroup: true, isChat: {$ne: true},}).catch((err) => { let r = await DB.profileCols.findOne({ _id, isGroup: true, isChat: { $ne: true }, }).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -389,13 +440,13 @@ userDB = (DB) => {
const _id = DB.ObjectID(groupid); const _id = DB.ObjectID(groupid);
const subOrRequest = reqSubscription ? "pending." : "subscribed."; const subOrRequest = reqSubscription ? "pending." : "subscribed.";
let update = { let update = {
$set:{ $set: {
[subOrRequest + profileid]: new Date() [subOrRequest + profileid]: new Date()
} }
} }
if(!reqSubscription) DB.followProfile(profileid, groupid); if (!reqSubscription) DB.followProfile(profileid, groupid);
delete userProfileCache[groupid]; delete userProfileCache[groupid];
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -404,16 +455,16 @@ userDB = (DB) => {
DB.acceptGroupJoinReq = async (profileid, groupid) => { DB.acceptGroupJoinReq = async (profileid, groupid) => {
const _id = DB.ObjectID(groupid); const _id = DB.ObjectID(groupid);
let update = { let update = {
$set:{ $set: {
["subscribed." + profileid]: new Date() ["subscribed." + profileid]: new Date()
}, },
$unset:{ $unset: {
["pending." + profileid]: "" ["pending." + profileid]: ""
} }
} }
DB.followProfile(profileid, groupid); DB.followProfile(profileid, groupid);
delete userProfileCache[groupid]; delete userProfileCache[groupid];
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -422,12 +473,12 @@ userDB = (DB) => {
DB.rejectGroupJoinReq = async (profileid, groupid) => { DB.rejectGroupJoinReq = async (profileid, groupid) => {
const _id = DB.ObjectID(groupid); const _id = DB.ObjectID(groupid);
let update = { let update = {
$unset:{ $unset: {
["pending." + profileid]: "" ["pending." + profileid]: ""
} }
} }
delete userProfileCache[groupid]; delete userProfileCache[groupid];
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -436,12 +487,12 @@ userDB = (DB) => {
DB.unsubscribeToGroup = async (profileid, groupid) => { DB.unsubscribeToGroup = async (profileid, groupid) => {
const _id = DB.ObjectID(groupid); const _id = DB.ObjectID(groupid);
let update = { let update = {
$unset:{ $unset: {
["subscribed." + profileid]: "", ["subscribed." + profileid]: "",
} }
} }
DB.unfollowProfile(profileid, groupid) DB.unfollowProfile(profileid, groupid)
return DB.profileCols.updateOne({_id}, update).catch((err)=>{ return DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
@@ -449,15 +500,15 @@ userDB = (DB) => {
//Courses //Courses
DB.getCourses = async () => { DB.getCourses = async () => {
let r = await DB.profileCols.find({isGroup: true, isCourse: true, isChat: {$ne: true}}) let r = await DB.profileCols.find({ isGroup: true, isCourse: true, isChat: { $ne: true } })
.sort({ lastUpdate: -1 }).limit(20) .sort({ lastUpdate: -1 }).limit(20)
.toArray().catch((err) => { .toArray().catch((err) => {
console.log(err); console.log(err);
return false; return false;
}); });
return r; return r;
} }
} }
module.exports = userDB; module.exports = userDB;

View File

@@ -1,15 +1,15 @@
class User { class User {
constructor(info){ constructor(info){
if(!info || !info.userid) throw "Can not construct empty profile"; if(!info || !info.userid) throw new Error("Cannot construct empty profile");
this.userid = info.userid; this.userid = info.userid;
this.profile = { this.profile = {
firstName: info.profile && info.profile.firstName || '', firstName: info.profile?.firstName || '',
lastName: info.profile && info.profile.lastName || '', lastName: info.profile?.lastName || '',
photo: info.profile && info.profile.photo || '', photo: info.profile?.photo || '',
location: info.profile && info.profile.location || 'USA', location: info.profile?.location || 'USA',
language: info.profile && info.profile.language || 'en', language: info.profile?.language || 'en',
status: info.profile && info.profile.status || '', status: info.profile?.status || '',
description: info.profile && info.profile.description || '', description: info.profile?.description || '',
}; };
this.data = info.data || {}; this.data = info.data || {};
this.username = info.username || ''; this.username = info.username || '';
@@ -23,8 +23,8 @@ class User {
this.isCourse = info.isCourse || false; this.isCourse = info.isCourse || false;
this.isPrivate = info.isPrivate || false; this.isPrivate = info.isPrivate || false;
this.isChat = info.isChat || false; this.isChat = info.isChat || false;
this.subscribed = info.subscribed || {}; //Subscribed user to groups this.subscribed = JSON.parse(JSON.stringify(info.subscribed || {})); //Subscribed user to groups
this.pending = info.pending || {}; //Private groups require authorization this.pending = JSON.parse(JSON.stringify(info.pending || {})); //Private groups require authorization
} }
toObj(){ toObj(){

337
index.js
View File

@@ -9,6 +9,7 @@ require('dotenv').config();
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
app.set('trust proxy', true);
// -- Accept request from other origins // -- Accept request from other origins
const cors = require('cors'); const cors = require('cors');
const { corsOptions } = require('./config/corsOptions'); const { corsOptions } = require('./config/corsOptions');
@@ -34,15 +35,147 @@ const limiter = rateLimit({
return ip.includes(":") ? ip.split(":")[0] : ip; // Remove port if present return ip.includes(":") ? ip.split(":")[0] : ip; // Remove port if present
} }
}); });
app.set('trust proxy', true);
app.use(limiter); app.use(limiter);
// Authentication // Authentication
const { signup, login, logout, resetPassword } = require('./auth/authEmail.js'); const { signup, login, logout, resetPassword, loginWithPasswordToken } = require('./auth/authEmail.js');
app.route('/signup').get(signup).post(signup); const { authRateLimiter } = require('./middleware/authRateLimiter');
app.route('/login').get(login).post(login); /**
* @swagger
* /signup:
* post:
* summary: Signs up a new user
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* format: email
* password:
* type: string
* format: password
* responses:
* 200:
* description: The user was successfully signed up.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* 400:
* description: Bad request.
*/
app.post('/signup', signup);
/**
* @swagger
* /login:
* post:
* summary: Logs in a user
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* format: email
* password:
* type: string
* format: password
* responses:
* 200:
* description: The user was successfully logged in.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* 401:
* description: Invalid credentials.
*/
app.post('/login', authRateLimiter('login'), login);
/**
* @swagger
* /logout:
* get:
* summary: Logs out a user
* tags: [Auth]
* responses:
* 200:
* description: The user was successfully logged out.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
*/
app.get('/logout', logout); app.get('/logout', logout);
app.route('/resetPassword').post(resetPassword); /**
* @swagger
* /resetPassword:
* post:
* summary: Sends a one-time sign-in link if the account exists
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* format: email
* responses:
* 200:
* description: A password reset link has been sent to the user's email.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* 400:
* description: Bad request.
*/
app.route('/resetPassword').post(authRateLimiter('reset'), resetPassword);
/**
* @swagger
* /password/token-login:
* post:
* summary: Consumes a one-time password token and starts a session
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* responses:
* 200:
* description: Logged in with one-time token
* 401:
* description: Invalid or expired token
*/
app.post('/password/token-login', authRateLimiter('token'), loginWithPasswordToken);
// Routes // Routes
const profileRoute = require('./routes/profile.js'); const profileRoute = require('./routes/profile.js');
@@ -50,17 +183,55 @@ const postRoute = require('./routes/post.js');
const songsRoute = require('./routes/songs.js'); const songsRoute = require('./routes/songs.js');
const paymentsRoute = require('./routes/payments.js'); const paymentsRoute = require('./routes/payments.js');
const bibleRoute = require('./routes/bible.js'); const bibleRoute = require('./routes/bible.js');
const chatRoute = require('./routes/chat.js');
const sessionChecker = require('./middleware/sessionChecker'); const sessionChecker = require('./middleware/sessionChecker');
// -- Private Routes // -- Private Routes
app.use('/user', sessionChecker, profileRoute); app.use('/user', sessionChecker, profileRoute);
app.use('/post', sessionChecker, postRoute); app.use('/post', sessionChecker, postRoute);
app.use('/payments', sessionChecker, paymentsRoute); app.use('/payments', paymentsRoute);
app.use('/bible', sessionChecker, bibleRoute); app.use('/bible', sessionChecker, bibleRoute);
app.use('/songs', sessionChecker, songsRoute); app.use('/songs', sessionChecker, songsRoute);
app.use('/chat', sessionChecker, chatRoute);
// -- Public Routes // -- Public Routes
const subsplashRoute = require('./routes/subsplash.js'); const subsplashRoute = require('./routes/subsplash.js');
app.use('/subsplash', subsplashRoute); app.use('/subsplash', subsplashRoute);
// Swagger API Docs
const swaggerJSDoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'EMI Backend API',
version: '1.0.0',
description: 'This is the REST API for the EMI Backend'
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
}
],
components: {
securitySchemes: {
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: 'user_sid'
}
}
}
};
const options = {
swaggerDefinition,
apis: ['./index.js', './routes/*.js']
};
const swaggerSpec = swaggerJSDoc(options);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Web Push Notifications // Web Push Notifications
const webPush = require('web-push'); const webPush = require('web-push');
const publicVapidKey = process.env.PUBLIC_VAPID_KEY; const publicVapidKey = process.env.PUBLIC_VAPID_KEY;
@@ -69,7 +240,7 @@ const webPushEmail = process.env.WEB_PUSH_EMAIL;
webPush.setVapidDetails('mailto:' + webPushEmail, publicVapidKey, privateVapidKey); webPush.setVapidDetails('mailto:' + webPushEmail, publicVapidKey, privateVapidKey);
const { cookiesOptions } = require('./config/cookiesOptions'); const { getCookiesOptions } = require('./config/cookiesOptions');
const { client_logger } = require('./utils/analyticsLogger.js'); const { client_logger } = require('./utils/analyticsLogger.js');
const { getSessionId, getUserId, getProfileId } = require('./utils/sessionUtils.js'); const { getSessionId, getUserId, getProfileId } = require('./utils/sessionUtils.js');
@@ -78,7 +249,31 @@ const DB = require("./mongoDB.js");
DB.getDB.then((DB) => { DB.getDB.then((DB) => {
console.log("Main logic: DB connected!"); console.log("Main logic: DB connected!");
// route for Home-Page /**
* @swagger
* /:
* get:
* summary: Returns basic information about the logged-in user
* tags: [General]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* userInfo:
* type: object
* profileInfo:
* type: object
* 401:
* description: Unauthorized
*/
app.get('/', sessionChecker, async (req, res) => { app.get('/', sessionChecker, async (req, res) => {
try { try {
const userInfo = req.userInfo; const userInfo = req.userInfo;
@@ -97,7 +292,38 @@ DB.getDB.then((DB) => {
} }
}); });
// Check for an invitation for an email /**
* @swagger
* /invite/{email}:
* get:
* summary: Checks if an invitation exists for a given email
* tags: [General]
* parameters:
* - in: path
* name: email
* required: true
* schema:
* type: string
* format: email
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* invitation:
* type: object
* 400:
* description: Provide a valid email
* 404:
* description: No invitation found for this email
* 409:
* description: This user is already registered
*/
app.get("/invite/:email", async (req, res) => { app.get("/invite/:email", async (req, res) => {
try { try {
const email = req.params.email.trim().toLowerCase(); const email = req.params.email.trim().toLowerCase();
@@ -130,7 +356,42 @@ DB.getDB.then((DB) => {
}); });
// Change the active profile for the user. /**
* @swagger
* /changeProfile:
* post:
* summary: Changes the active profile for the user
* tags: [General]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* profileid:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* profile:
* type: object
* 400:
* description: Profile ID is required
* 403:
* description: Profile does not belong to the logged-in user
* 404:
* description: Profile does not exist
*/
app.post('/changeProfile', sessionChecker, async (req, res) => { app.post('/changeProfile', sessionChecker, async (req, res) => {
try { try {
const user_sid = getUserId(req); const user_sid = getUserId(req);
@@ -149,7 +410,7 @@ DB.getDB.then((DB) => {
return res.status(403).json({ status: "Profile does not belong to the logged-in user" }); return res.status(403).json({ status: "Profile does not belong to the logged-in user" });
} }
// Update active profile cookie // Update active profile cookie
res.cookie('profile_id', profile._id, cookiesOptions); res.cookie('profile_id', profile._id, getCookiesOptions(req));
return res.json({ status: "ok", profile }); return res.json({ status: "ok", profile });
} catch (error) { } catch (error) {
console.error("Error changing profile:", error); console.error("Error changing profile:", error);
@@ -157,7 +418,38 @@ DB.getDB.then((DB) => {
} }
}); });
// This is the endpoint to refresh the push notification token /**
* @swagger
* /token:
* post:
* summary: Refreshes the push notification token
* tags: [General]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* 400:
* description: Token is required
* 500:
* description: Failed to update token
*/
app.post('/token/', sessionChecker, async (req, res) => { app.post('/token/', sessionChecker, async (req, res) => {
try { try {
const profileid = getProfileId(req); const profileid = getProfileId(req);
@@ -179,7 +471,24 @@ DB.getDB.then((DB) => {
} }
}); });
// Used for webpush notifications /**
* @swagger
* /subscribe:
* post:
* summary: Subscribes a user to webpush notifications
* tags: [General]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 201:
* description: Created
*/
app.post('/subscribe', sessionChecker, async (req, res) => { app.post('/subscribe', sessionChecker, async (req, res) => {
const subscription = req.body; const subscription = req.body;
res.status(201).json({}); res.status(201).json({});
@@ -202,4 +511,4 @@ DB.getDB.then((DB) => {
}); });
// Export the app for testing purposes // Export the app for testing purposes
module.exports = { app, mongoDB: DB }; module.exports = { app, mongoDB: DB };

View File

@@ -0,0 +1,116 @@
const crypto = require('crypto');
const { client_logger } = require('../utils/analyticsLogger');
const AUTH_ATTEMPT_WINDOW_MS = Math.max(60 * 1000, parseInt(process.env.AUTH_ATTEMPT_WINDOW_MS || `${15 * 60 * 1000}`, 10));
const AUTH_ATTEMPT_MAX = Math.max(1, parseInt(process.env.AUTH_ATTEMPT_MAX || '5', 10));
const AUTH_BLOCK_BASE_MS = Math.max(30 * 1000, parseInt(process.env.AUTH_BLOCK_BASE_MS || `${5 * 60 * 1000}`, 10));
const AUTH_BLOCK_MAX_MS = Math.max(AUTH_BLOCK_BASE_MS, parseInt(process.env.AUTH_BLOCK_MAX_MS || `${60 * 60 * 1000}`, 10));
const limiterStore = new Map();
let lastPruneAt = 0;
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for']?.split(',')[0]?.trim();
const rawIp = forwarded || req.ip || req.connection?.remoteAddress || 'unknown';
return rawIp.replace('::ffff:', '');
};
const hashValue = (value) =>
crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
const getIdentity = (req, mode) => {
if (mode === 'token') {
const token = (req.body?.token || '').trim();
return token ? `token:${hashValue(token)}` : 'token:anonymous';
}
const username = (req.body?.username || req.body?.email || '').trim().toLowerCase();
return username ? `acct:${hashValue(username)}` : 'acct:anonymous';
};
const getLimiterKey = (req, mode) => `${mode}:${getIdentity(req, mode)}:ip:${getClientIp(req)}`;
const getOrInitRecord = (key, now) => {
const existing = limiterStore.get(key);
if (existing) {
return existing;
}
const record = {
count: 0,
windowStartedAt: now,
blockedUntil: 0,
blockLevel: 0,
};
limiterStore.set(key, record);
return record;
};
const computeBlockMs = (blockLevel) =>
Math.min(AUTH_BLOCK_BASE_MS * (2 ** Math.max(0, blockLevel - 1)), AUTH_BLOCK_MAX_MS);
const authRateLimiter = (mode) => (req, res, next) => {
const now = Date.now();
if (now - lastPruneAt > 5 * 60 * 1000) {
for (const [storeKey, storeValue] of limiterStore.entries()) {
const isWindowExpired = now - storeValue.windowStartedAt > AUTH_ATTEMPT_WINDOW_MS;
const isNotBlocked = storeValue.blockedUntil <= now;
if (isWindowExpired && isNotBlocked) {
limiterStore.delete(storeKey);
}
}
lastPruneAt = now;
}
const key = getLimiterKey(req, mode);
const record = getOrInitRecord(key, now);
if (now - record.windowStartedAt > AUTH_ATTEMPT_WINDOW_MS) {
record.count = 0;
record.windowStartedAt = now;
}
if (record.blockedUntil > now) {
const retryAfterSec = Math.ceil((record.blockedUntil - now) / 1000);
res.set('Retry-After', retryAfterSec.toString());
client_logger.capture({
distinctId: 'app_level',
event: 'security@auth@rate_limited',
properties: {
route: req.originalUrl,
method: req.method,
mode,
keyHash: hashValue(key),
retryAfterSec,
blockLevel: record.blockLevel,
}
});
return res.status(429).json({ status: 'Too many attempts. Please try again later.' });
}
record.count += 1;
if (record.count > AUTH_ATTEMPT_MAX) {
record.blockLevel += 1;
const blockMs = computeBlockMs(record.blockLevel);
record.blockedUntil = now + blockMs;
record.count = 0;
record.windowStartedAt = now;
res.set('Retry-After', Math.ceil(blockMs / 1000).toString());
client_logger.capture({
distinctId: 'app_level',
event: 'security@auth@rate_limited',
properties: {
route: req.originalUrl,
method: req.method,
mode,
keyHash: hashValue(key),
retryAfterSec: Math.ceil(blockMs / 1000),
blockLevel: record.blockLevel,
}
});
return res.status(429).json({ status: 'Too many attempts. Please try again later.' });
}
return next();
};
module.exports = {
authRateLimiter,
};

View File

@@ -1,38 +1,62 @@
const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils'); const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils');
const { client_logger } = require('../utils/analyticsLogger'); const { client_logger } = require('../utils/analyticsLogger');
const { cookiesOptions } = require('../config/cookiesOptions'); const { getCookiesOptions } = require('../config/cookiesOptions');
const MongoDB = require("../mongoDB.js"); const MongoDB = require("../mongoDB.js");
const { ObjectId } = require("mongodb");
const shouldReturnJson = (req) => {
const accept = String(req?.headers?.accept || "").toLowerCase();
const contentType = String(req?.headers?.["content-type"] || "").toLowerCase();
return !!req?.headers?.origin || accept.includes("application/json") || contentType.includes("application/json");
};
const rejectUnauthorized = (req, res) => {
if (shouldReturnJson(req)) {
return res.status(401).json({ status: "Unauthorized" });
}
return res.redirect('/login');
};
const sessionChecker = async (req, res, next) => { const sessionChecker = async (req, res, next) => {
const session_id = getSessionId(req); try {
const user_sid = getUserId(req); const session_id = getSessionId(req);
let profile_id = getProfileId(req); const user_sid = getUserId(req);
let profile_id = getProfileId(req);
if (session_id && user_sid) { if (!session_id || !user_sid) {
DB = await MongoDB.getDB; return rejectUnauthorized(req, res);
}
if (!ObjectId.isValid(session_id) || !ObjectId.isValid(user_sid)) {
return rejectUnauthorized(req, res);
}
const DB = await MongoDB.getDB;
const userInfo = await DB.checkSessionOnDB(session_id, user_sid); const userInfo = await DB.checkSessionOnDB(session_id, user_sid);
req.userInfo = userInfo; req.userInfo = userInfo;
if (!await DB.getProfileCache(profile_id)) { if (!await DB.getProfileCache(profile_id)) {
const latestProfile = await DB.latestProfile(user_sid); const latestProfile = await DB.latestProfile(user_sid);
res.cookie('profile_id', latestProfile._id, cookiesOptions); if (!latestProfile || !latestProfile._id) {
return rejectUnauthorized(req, res);
}
res.cookie('profile_id', latestProfile._id, getCookiesOptions(req));
profile_id = latestProfile._id; profile_id = latestProfile._id;
} }
req.profileInfo = { _id: profile_id }; req.profileInfo = { _id: profile_id };
if (!userInfo) return res.redirect('/login'); if (!userInfo) return rejectUnauthorized(req, res);
// Log Request
client_logger.capture({ client_logger.capture({
distinctId: user_sid, distinctId: user_sid,
event: 'server@' + req.method + '@' + req.originalUrl, event: 'server@' + req.method + '@' + req.originalUrl,
}); });
next(); next();
} else { } catch (error) {
return res.redirect('/login'); console.error("Session checker error", error);
return rejectUnauthorized(req, res);
} }
}; };
module.exports = sessionChecker; module.exports = sessionChecker;

View File

@@ -9,21 +9,42 @@ const postDB = require("./dbTools/post.js");
const profileDB = require("./dbTools/profile.js"); const profileDB = require("./dbTools/profile.js");
const paymentDB = require("./dbTools/payments.js"); const paymentDB = require("./dbTools/payments.js");
const songsDB = require("./dbTools/songs.js"); const songsDB = require("./dbTools/songs.js");
const chatDB = require("./dbTools/chat.js");
console.log("Connecting to MongoDB..."); console.log("Connecting to MongoDB...");
const nodeMajorVersion = parseInt((process.versions.node || "0").split(".")[0], 10);
if (nodeMajorVersion >= 22) {
console.warn("Warning: mongodb@3.x is not fully tested on Node.js 22+. Prefer Node.js 20 LTS for local stability.");
}
const mongoConnectOptions = {
useNewUrlParser: true,
useUnifiedTopology: true,
serverSelectionTimeoutMS: 10000,
connectTimeoutMS: 10000,
socketTimeoutMS: 45000,
keepAlive: true,
};
const getDB = new Promise((resolve, reject) => { const getDB = new Promise((resolve, reject) => {
const DB = {ObjectID: mongo.ObjectID}; const DB = {ObjectID: mongo.ObjectID};
MongoClient.connect(mongoUrl, function(err, db) { MongoClient.connect(mongoUrl, mongoConnectOptions, function(err, db) {
if (err) return reject(err); if (err) return reject(err);
console.log("Connected to DB!"); console.log("Connected to DB!");
DB.db = db; DB.db = db;
DB.ObjectID = ObjectID; DB.ObjectID = ObjectID;
DB.db.on("close", () => console.error("MongoDB connection closed"));
DB.db.on("reconnect", () => console.log("MongoDB reconnected"));
DB.db.on("error", (error) => console.error("MongoDB connection error", error));
DB.usersCol = db.db(DBName).collection("users"); DB.usersCol = db.db(DBName).collection("users");
DB.tokensCol = db.db(DBName).collection("tokens"); DB.tokensCol = db.db(DBName).collection("tokens");
DB.invitationCol = db.db(DBName).collection("invitation"); DB.invitationCol = db.db(DBName).collection("invitation");
DB.passwordLoginTokensCol = db.db(DBName).collection("password_login_tokens");
DB.passwordLoginTokensCol.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(console.error);
DB.passwordLoginTokensCol.createIndex({ tokenHash: 1 }, { unique: true }).catch(console.error);
DB.checkSessionOnDB = async (session_id, user_sid)=>{ DB.checkSessionOnDB = async (session_id, user_sid)=>{
const temp_id = new mongo.ObjectID(session_id); const temp_id = new mongo.ObjectID(session_id);
@@ -31,7 +52,7 @@ const getDB = new Promise((resolve, reject) => {
const doc = await DB.tokensCol.findOne({"_id":temp_id}); const doc = await DB.tokensCol.findOne({"_id":temp_id});
if(doc && doc.uid == user_sid){ if(doc && doc.uid == user_sid){
const userMongoId = new mongo.ObjectID(user_sid); const userMongoId = new mongo.ObjectID(user_sid);
const userInfo = await DB.usersCol.findOne({"_id": userMongoId}, {fields: {password: 0}}); const userInfo = await DB.usersCol.findOne({"_id": userMongoId}, {projection: {password: 0}});
return userInfo; return userInfo;
} }
return false; return false;
@@ -61,6 +82,42 @@ const getDB = new Promise((resolve, reject) => {
return DB.usersCol.findOne({ _id }); return DB.usersCol.findOne({ _id });
} }
DB.createPasswordLoginToken = async (userId, tokenHash, expiresAt) => {
const userObjectId = mongo.ObjectID.isValid(userId) ? new mongo.ObjectID(userId) : userId;
const tokenDoc = {
userId: userObjectId,
tokenHash,
createdAt: new Date(),
expiresAt,
usedAt: null,
};
return DB.passwordLoginTokensCol.insertOne(tokenDoc).catch((err) => {
console.log(err);
return false;
});
};
DB.consumePasswordLoginToken = async (tokenHash) => {
const now = new Date();
const result = await DB.passwordLoginTokensCol.findOneAndUpdate(
{
tokenHash,
usedAt: null,
expiresAt: { $gt: now }
},
{
$set: { usedAt: now }
},
{
returnOriginal: false
}
).catch((err) => {
console.log(err);
return false;
});
return result?.value || null;
};
let usernamesCache = {} let usernamesCache = {}
DB.getUsernameByIdCache = async (userid)=>{ DB.getUsernameByIdCache = async (userid)=>{
if(!userid) return {}; if(!userid) return {};
@@ -121,9 +178,10 @@ const getDB = new Promise((resolve, reject) => {
profileDB(DB); profileDB(DB);
paymentDB(DB); paymentDB(DB);
songsDB(DB); songsDB(DB);
chatDB(DB);
resolve(DB); resolve(DB);
}); });
}); });
exports.getDB = getDB; exports.getDB = getDB;

View File

@@ -347,7 +347,7 @@ const Notifications = {
const bookedProfile = subscribed[index]; const bookedProfile = subscribed[index];
if (bookedProfile._id == senderProfile._id) return 0; if (bookedProfile._id == senderProfile._id) return 0;
const notifBody = `${senderProfile.profile.firstName} commented in a post you follow`; const notifBody = `${senderProfile.profile.firstName} commented in a post you follow`;
sendPushNotification(bookedProfile.token, notifBody, {}); sendPushNotification(bookedProfile.token, notifBody, {post_id: post._id});
DB.addNotification(bookedProfile._id, notifBody, post._id, post.comments.length - 1, senderProfile._id); DB.addNotification(bookedProfile._id, notifBody, post._id, post.comments.length - 1, senderProfile._id);
yourBookmarkedPostGotACommentTemplate(post, userEmail, postProfile, senderProfile, bookedProfile, message); yourBookmarkedPostGotACommentTemplate(post, userEmail, postProfile, senderProfile, bookedProfile, message);
}); });
@@ -363,7 +363,7 @@ const Notifications = {
} }
if (postProfile.isCourse || senderProfile._id == postProfile._id) return 0; //Course owners do not need to receive notifs if (postProfile.isCourse || senderProfile._id == postProfile._id) return 0; //Course owners do not need to receive notifs
const notifBody = `${senderProfile.profile.firstName} commented in your post`; const notifBody = `${senderProfile.profile.firstName} commented in your post`;
sendPushNotification(postProfile.token, notifBody, {}); sendPushNotification(postProfile.token, notifBody, {post_id: postId, comment: message});
DB.addNotification(post.profileid, notifBody, postId, post.comments.length - 1, senderProfile._id); DB.addNotification(post.profileid, notifBody, postId, post.comments.length - 1, senderProfile._id);
return youGotANewPostCommentTemplate(post, userEmail, postProfile, senderProfile, message); return youGotANewPostCommentTemplate(post, userEmail, postProfile, senderProfile, message);
}, },
@@ -376,7 +376,7 @@ const Notifications = {
subscribed.forEach((bookedProfile) => { subscribed.forEach((bookedProfile) => {
if (bookedProfile._id == senderProfile._id) return 0; if (bookedProfile._id == senderProfile._id) return 0;
const notifBody = `${senderProfile.profile.firstName} liked a post you follow`; const notifBody = `${senderProfile.profile.firstName} liked a post you follow`;
sendPushNotification(bookedProfile.token, notifBody, {}); sendPushNotification(bookedProfile.token, notifBody, {post_id: post._id});
DB.addNotification(bookedProfile._id, notifBody, post._id, null, senderProfile._id); DB.addNotification(bookedProfile._id, notifBody, post._id, null, senderProfile._id);
}); });
}, },
@@ -391,7 +391,7 @@ const Notifications = {
} }
if (postProfile.isCourse || senderProfile._id == postProfile._id) return 0; //Course owners do not need to receive notifs if (postProfile.isCourse || senderProfile._id == postProfile._id) return 0; //Course owners do not need to receive notifs
const notifBody = `${senderProfile.profile.firstName} liked your post`; const notifBody = `${senderProfile.profile.firstName} liked your post`;
sendPushNotification(postProfile.token, notifBody, {}); sendPushNotification(postProfile.token, notifBody, {post_id: post._id});
DB.addNotification(post.profileid, notifBody, postId, null, senderProfile._id); DB.addNotification(post.profileid, notifBody, postId, null, senderProfile._id);
return 0; return 0;
}, },
@@ -411,11 +411,11 @@ const Notifications = {
if (userProfile._id == senderProfile._id) return 0; //avoid sending self notifications if (userProfile._id == senderProfile._id) return 0; //avoid sending self notifications
if(groupProfile._id == senderProfile._id){ if(groupProfile._id == senderProfile._id){
const notifBody = `${groupProfile.profile.firstName} ${groupProfile.profile.lastName} has a new post!`; const notifBody = `${groupProfile.profile.firstName} ${groupProfile.profile.lastName} has a new post!`;
sendPushNotification(userProfile.token, notifBody, {}); sendPushNotification(userProfile.token, notifBody, {post_id: post._id, profile_id: groupProfile._id});
return DB.addNotification(userProfile._id, notifBody, post._id, null, senderProfile._id); return DB.addNotification(userProfile._id, notifBody, post._id, null, senderProfile._id);
} }
const notifBody = `${senderProfile.profile.firstName} post in the group ${groupProfile.profile.firstName} ${groupProfile.profile.lastName}`; const notifBody = `${senderProfile.profile.firstName} post in the group ${groupProfile.profile.firstName} ${groupProfile.profile.lastName}`;
sendPushNotification(userProfile.token, notifBody, {}); sendPushNotification(userProfile.token, notifBody, {post_id: post._id, profile_id: groupProfile._id});
DB.addNotification(userProfile._id, notifBody, post._id, null, senderProfile._id); DB.addNotification(userProfile._id, notifBody, post._id, null, senderProfile._id);
// Disabling email notifications for now, until settings are implemented // Disabling email notifications for now, until settings are implemented
// yourGroupGotANewPostTemplate(groupProfile, userEmail, userProfile, senderProfile, message); // yourGroupGotANewPostTemplate(groupProfile, userEmail, userProfile, senderProfile, message);
@@ -437,7 +437,7 @@ const Notifications = {
return this.broadcastNews(post, emails); return this.broadcastNews(post, emails);
} }
const notifBody = `${senderProfile.profile.firstName} post in your profile`; const notifBody = `${senderProfile.profile.firstName} post in your profile`;
sendPushNotification(profile.token, notifBody, {}); sendPushNotification(profile.token, notifBody, {post_id: post._id, profile_id: post.profileid});
// sendWebNotification(profile.webSubscription, notifBody, message); // sendWebNotification(profile.webSubscription, notifBody, message);
DB.addNotification(toProfileId, notifBody, post._id, null, senderProfile._id); DB.addNotification(toProfileId, notifBody, post._id, null, senderProfile._id);
return youGotANewPostTemplate(profile, user.username, senderProfile, message); return youGotANewPostTemplate(profile, user.username, senderProfile, message);
@@ -461,7 +461,7 @@ const Notifications = {
const userProfile = subscribed_profiles[index]; const userProfile = subscribed_profiles[index];
if (userProfile._id == whoPostedId._id) return 0; if (userProfile._id == whoPostedId._id) return 0;
const notifBody = `${profile.profile.firstName} posted: ${message.substring(0, 50)}...`; const notifBody = `${profile.profile.firstName} posted: ${message.substring(0, 50)}...`;
sendPushNotification(userProfile.token, notifBody, {}); sendPushNotification(userProfile.token, notifBody, {profile_id: whoPostedId});
//sendWebNotification(userProfile.webSubscription, notifBody, message); //sendWebNotification(userProfile.webSubscription, notifBody, message);
DB.addNotification(userProfile._id, notifBody, post._id, null, profile._id); DB.addNotification(userProfile._id, notifBody, post._id, null, profile._id);
}); });

433
package-lock.json generated
View File

@@ -26,6 +26,8 @@
"posthog-node": "^4.4.1", "posthog-node": "^4.4.1",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"stripe": "^8.178.0", "stripe": "^8.178.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"web-push": "^3.4.5" "web-push": "^3.4.5"
}, },
"devDependencies": { "devDependencies": {
@@ -34,6 +36,50 @@
"supertest": "^7.0.0" "supertest": "^7.0.0"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -137,6 +183,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@mapbox/node-pre-gyp": { "node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
@@ -167,6 +219,13 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
@@ -185,6 +244,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "16.10.2", "version": "16.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz",
@@ -305,7 +370,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
@@ -516,6 +580,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -757,6 +827,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/component-emitter": { "node_modules/component-emitter": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@@ -983,6 +1062,18 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -1221,6 +1312,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -1884,7 +1984,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -1928,6 +2027,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/log-symbols": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -2421,6 +2540,13 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/optional-require": { "node_modules/optional-require": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz", "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz",
@@ -3251,6 +3377,83 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"license": "MIT",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.27.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.0.tgz",
"integrity": "sha512-tS6LRyBhY6yAqxrfsA9IYpGWPUJOri6sclySa7TdC7XQfGLvTwDY531KLgfQwHEtQsn+sT4JlUspbeQDBVGWig==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
@@ -3326,6 +3529,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -3563,6 +3775,15 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -3668,9 +3889,73 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
} }
}, },
"dependencies": { "dependencies": {
"@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"requires": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="
},
"@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="
},
"@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"requires": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
}
},
"@isaacs/cliui": { "@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -3736,6 +4021,11 @@
} }
} }
}, },
"@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
},
"@mapbox/node-pre-gyp": { "@mapbox/node-pre-gyp": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
@@ -3759,6 +4049,11 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="
},
"@socket.io/component-emitter": { "@socket.io/component-emitter": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
@@ -3777,6 +4072,11 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"@types/node": { "@types/node": {
"version": "16.10.2", "version": "16.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz",
@@ -3866,8 +4166,7 @@
"argparse": { "argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
"dev": true
}, },
"array-flatten": { "array-flatten": {
"version": "1.1.1", "version": "1.1.1",
@@ -4032,6 +4331,11 @@
"get-intrinsic": "^1.2.6" "get-intrinsic": "^1.2.6"
} }
}, },
"call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="
},
"camelcase": { "camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -4199,6 +4503,11 @@
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
} }
}, },
"commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q=="
},
"component-emitter": { "component-emitter": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@@ -4361,6 +4670,14 @@
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"dev": true "dev": true
}, },
"doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"requires": {
"esutils": "^2.0.2"
}
},
"dom-serializer": { "dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -4525,6 +4842,11 @@
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true "dev": true
}, },
"esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"etag": { "etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4979,7 +5301,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"requires": { "requires": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
} }
@@ -5012,6 +5333,21 @@
"p-locate": "^5.0.0" "p-locate": "^5.0.0"
} }
}, },
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
},
"lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
},
"lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="
},
"log-symbols": { "log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -5346,6 +5682,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"peer": true
},
"optional-require": { "optional-require": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz", "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz",
@@ -5940,6 +6282,58 @@
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
} }
}, },
"swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"requires": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"dependencies": {
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"requires": {
"@apidevtools/swagger-parser": "10.0.3"
}
},
"swagger-ui-dist": {
"version": "5.27.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.0.tgz",
"integrity": "sha512-tS6LRyBhY6yAqxrfsA9IYpGWPUJOri6sclySa7TdC7XQfGLvTwDY531KLgfQwHEtQsn+sT4JlUspbeQDBVGWig==",
"requires": {
"@scarf/scarf": "=1.4.0"
}
},
"swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"requires": {
"swagger-ui-dist": ">=5.0.0"
}
},
"tar": { "tar": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
@@ -5996,6 +6390,11 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
}, },
"validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="
},
"vary": { "vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -6154,6 +6553,11 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ=="
},
"yargs": { "yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -6226,6 +6630,25 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true "dev": true
},
"z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"requires": {
"commander": "^9.4.1",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"dependencies": {
"commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"optional": true
}
}
} }
} }
} }

View File

@@ -30,6 +30,8 @@
"posthog-node": "^4.4.1", "posthog-node": "^4.4.1",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"stripe": "^8.178.0", "stripe": "^8.178.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"web-push": "^3.4.5" "web-push": "^3.4.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -13,11 +13,47 @@ const defaultBibleId = "592420522e16049f-01";
//getMedia('y42zyf3').then(console.log) //getMedia('y42zyf3').then(console.log)
DB.getDB.then((DB) => { DB.getDB.then((DB) => {
/**
* @swagger
* tags:
* name: Bible
* description: Bible API
*/
/**
* @swagger
* /bible:
* get:
* summary: Get a list of available Bibles
* tags: [Bible]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
*/
router.get("", async (req, res) => { router.get("", async (req, res) => {
const bibles = await fetchAPI('bibles'); const bibles = await fetchAPI('bibles');
return res.json(bibles); return res.json(bibles);
}); });
/**
* @swagger
* /bible/books:
* get:
* summary: Get the books of a Bible
* tags: [Bible]
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: bibleId
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/books", async (req, res) => { router.get("/books", async (req, res) => {
const bibleId = req.query.bibleId || defaultBibleId; const bibleId = req.query.bibleId || defaultBibleId;
const bibles = await fetchAPI('bibles/' + bibleId +"/books"); const bibles = await fetchAPI('bibles/' + bibleId +"/books");
@@ -30,12 +66,56 @@ DB.getDB.then((DB) => {
return res.json(bibles); return res.json(bibles);
}); });
/**
* @swagger
* /bible/books/{bookId}:
* get:
* summary: Get details for a specific book
* tags: [Bible]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: bookId
* required: true
* schema:
* type: string
* - in: query
* name: bibleId
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/books/:bookId", async (req, res) => { router.get("/books/:bookId", async (req, res) => {
const bookId = req.params.bookId; const bookId = req.params.bookId;
const bibles = await fetchAPI('bibles/' + bibleId +"/books/" + bookId); const bibles = await fetchAPI('bibles/' + bibleId +"/books/" + bookId);
return res.json(bibles); return res.json(bibles);
}); });
/**
* @swagger
* /bible/books/{bookId}/chapters:
* get:
* summary: Get the chapters of a book
* tags: [Bible]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: bookId
* required: true
* schema:
* type: string
* - in: query
* name: bibleId
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/books/:bookId/chapters", async (req, res) => { router.get("/books/:bookId/chapters", async (req, res) => {
const bookId = req.params.bookId; const bookId = req.params.bookId;
const bibleId = req.query.bibleId || defaultBibleId; const bibleId = req.query.bibleId || defaultBibleId;
@@ -43,6 +123,28 @@ DB.getDB.then((DB) => {
return res.json(bibles); return res.json(bibles);
}); });
/**
* @swagger
* /bible/chapters/{chapterId}:
* get:
* summary: Get the content of a chapter
* tags: [Bible]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: chapterId
* required: true
* schema:
* type: string
* - in: query
* name: bibleId
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/chapters/:chapterId", async (req, res) => { router.get("/chapters/:chapterId", async (req, res) => {
const chapterId = req.params.chapterId; const chapterId = req.params.chapterId;
const bibleId = req.query.bibleId || defaultBibleId; const bibleId = req.query.bibleId || defaultBibleId;
@@ -50,6 +152,28 @@ DB.getDB.then((DB) => {
return res.json(bibles); return res.json(bibles);
}); });
/**
* @swagger
* /bible/chapters/{chapterId}/verses:
* get:
* summary: Get the verses of a chapter
* tags: [Bible]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: chapterId
* required: true
* schema:
* type: string
* - in: query
* name: bibleId
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/chapters/:chapterId/verses", async (req, res) => { router.get("/chapters/:chapterId/verses", async (req, res) => {
const chapterId = req.params.chapterId; const chapterId = req.params.chapterId;
const bibleId = req.query.bibleId || defaultBibleId; const bibleId = req.query.bibleId || defaultBibleId;
@@ -57,6 +181,33 @@ DB.getDB.then((DB) => {
return res.json(bibles); return res.json(bibles);
}); });
/**
* @swagger
* /bible/search:
* get:
* summary: Search the Bible
* tags: [Bible]
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: query
* required: true
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* - in: query
* name: bibleId
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
const query = req.query.query; const query = req.query.query;
const limit = req.query.limit || 10; const limit = req.query.limit || 10;

238
routes/chat.js Normal file
View File

@@ -0,0 +1,238 @@
var express = require('express');
var router = express.Router();
const DB = require("../mongoDB.js");
const { getUserId, getProfileId } = require("../utils/sessionUtils.js");
const { normalizeLanguageCode, translateText } = require("../utils/chatTranslation.js");
const ACTIVE_WINDOW_MS = 120000;
const MESSAGE_MAX_LENGTH = 500;
const activeUsers = new Map();
const translationInflight = new Map();
const toDisplayName = (profile, fallbackName) => {
const firstName = profile?.profile?.firstName || "";
const lastName = profile?.profile?.lastName || "";
const displayName = (firstName + " " + lastName).trim();
return displayName || fallbackName || "Anonymous";
};
const pruneActiveUsers = () => {
const now = Date.now();
for (const [profileId, entry] of activeUsers.entries()) {
if (now - entry.lastSeen > ACTIVE_WINDOW_MS) {
activeUsers.delete(profileId);
}
}
};
const getActiveUsersList = () => {
pruneActiveUsers();
return Array.from(activeUsers.values())
.sort((a, b) => b.lastSeen - a.lastSeen)
.map((entry) => ({
profileId: entry.profileId,
userId: entry.userId,
displayName: entry.displayName,
lastSeen: entry.lastSeen,
}));
};
DB.getDB.then((DB) => {
const resolveTargetLanguage = (req) => {
const requested = req.query?.lang || req.headers["x-app-language"] || req.headers["accept-language"] || "en";
return normalizeLanguageCode(requested);
};
const mapChatMessageForLanguage = async (message, targetLang) => {
const normalizedTarget = normalizeLanguageCode(targetLang);
const sourceLang = normalizeLanguageCode(message?.sourceLang || "auto");
const originalText = message?.text || "";
if (!originalText) {
return {
...message,
textOriginal: "",
text: "",
displayLang: sourceLang,
};
}
if (sourceLang === normalizedTarget) {
return {
...message,
textOriginal: originalText,
text: originalText,
displayLang: sourceLang,
};
}
const cachedTranslation = message?.translations?.[normalizedTarget]?.text;
if (cachedTranslation) {
return {
...message,
textOriginal: originalText,
text: cachedTranslation,
displayLang: normalizedTarget,
};
}
const translationKey = `${message?._id?.toString?.() || ""}:${normalizedTarget}`;
if (translationInflight.has(translationKey)) {
await translationInflight.get(translationKey);
const refreshed = await DB.chatMessagesCol.findOne({ _id: message._id }).catch(() => null);
const refreshedCached = refreshed?.translations?.[normalizedTarget]?.text;
if (refreshedCached) {
return {
...message,
translations: refreshed.translations,
textOriginal: originalText,
text: refreshedCached,
displayLang: normalizedTarget,
};
}
return {
...message,
textOriginal: originalText,
text: originalText,
displayLang: sourceLang,
};
}
const inFlightTask = (async () => {
const translated = await translateText({
text: originalText,
sourceLang,
targetLang: normalizedTarget,
});
if (!translated?.translatedText) return null;
await DB.setChatMessageTranslation({
messageId: message._id,
targetLang: normalizedTarget,
text: translated.translatedText,
provider: translated.provider,
model: translated.model,
});
return translated.translatedText;
})();
translationInflight.set(translationKey, inFlightTask);
let translatedText = null;
try {
translatedText = await inFlightTask;
} finally {
translationInflight.delete(translationKey);
}
return {
...message,
textOriginal: originalText,
text: translatedText || originalText,
displayLang: translatedText ? normalizedTarget : sourceLang,
};
};
const markActiveUser = async (req) => {
const userId = getUserId(req);
const profileId = req.profileInfo?._id || getProfileId(req);
if (!profileId || !userId) return null;
const profile = await DB.getProfileCache(profileId);
const displayName = toDisplayName(profile, req.userInfo?.username);
activeUsers.set(profileId + "", {
profileId: profileId + "",
userId: userId + "",
displayName,
lastSeen: Date.now(),
});
return activeUsers.get(profileId + "");
};
router.get("/messages", async (req, res) => {
try {
await markActiveUser(req);
const targetLang = resolveTargetLanguage(req);
const messages = await DB.getRecentChatMessages(req.query.limit || 100);
const translatedMessages = await Promise.all(messages.map((message) => mapChatMessageForLanguage(message, targetLang)));
return res.json({
status: "ok",
requestedLang: targetLang,
messages: translatedMessages,
});
} catch (error) {
console.error("Error getting chat messages", error);
return res.status(500).json({ status: "Internal server error", messages: [] });
}
});
router.post("/messages", async (req, res) => {
try {
const userId = getUserId(req);
const profileId = req.profileInfo?._id || getProfileId(req);
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
const sourceLang = normalizeLanguageCode(req.body?.sourceLang || req.headers["x-app-language"] || "en");
if (!text) {
return res.status(400).json({ status: "Message text is required" });
}
if (text.length > MESSAGE_MAX_LENGTH) {
return res.status(400).json({ status: `Message too long (${MESSAGE_MAX_LENGTH} max chars)` });
}
const profile = await DB.getProfileCache(profileId);
const senderName = toDisplayName(profile, req.userInfo?.username);
const message = await DB.addChatMessage({
senderId: userId,
senderProfileId: profileId,
senderName,
text,
sourceLang,
});
if (!message) {
return res.status(500).json({ status: "Could not save message" });
}
activeUsers.set(profileId + "", {
profileId: profileId + "",
userId: userId + "",
displayName: senderName,
lastSeen: Date.now(),
});
return res.json({
status: "ok",
message,
activeUsers: getActiveUsersList(),
});
} catch (error) {
console.error("Error posting chat message", error);
return res.status(500).json({ status: "Internal server error" });
}
});
router.get("/active", async (req, res) => {
try {
await markActiveUser(req);
return res.json({
status: "ok",
activeUsers: getActiveUsersList(),
});
} catch (error) {
console.error("Error getting active chat users", error);
return res.status(500).json({ status: "Internal server error", activeUsers: [] });
}
});
router.post("/ping", async (req, res) => {
try {
await markActiveUser(req);
return res.json({
status: "ok",
activeUsers: getActiveUsersList(),
});
} catch (error) {
console.error("Error updating chat presence", error);
return res.status(500).json({ status: "Internal server error", activeUsers: [] });
}
});
});
module.exports = router;

View File

@@ -2,6 +2,7 @@ var express = require('express');
var router = express.Router(); var router = express.Router();
const DB = require("../mongoDB.js"); const DB = require("../mongoDB.js");
const mongo = require('mongodb');
//const Payments = require("../payments.js"); //const Payments = require("../payments.js");
const Stripe = require('stripe'); const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE); const stripe = Stripe(process.env.STRIPE);
@@ -38,6 +39,13 @@ DB.getDB.then((DB) => {
// }); // });
// }); // });
/**
* @swagger
* tags:
* name: Payments
* description: Payment processing
*/
let intent = async (req, res) => { let intent = async (req, res) => {
const userid = req.body.userid; const userid = req.body.userid;
const price = req.body.price || 500; const price = req.body.price || 500;
@@ -52,27 +60,117 @@ DB.getDB.then((DB) => {
], ],
}); });
//Register in DB // check if user is email or userid
const intent = { const isUserId = mongo.ObjectId.isValid(userid);
paymentIntent, const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
userid, const isEmail = emailRegex.test(userid.trim().toLowerCase());
price, console.log("isUserId: ", isUserId);
description, console.log("isEmail: ", isEmail);
client_secret: paymentIntent.client_secret, console.log("userid: ", userid);
};
DB.newIntent(intent);
if (isUserId) {
//Register in DB
const intent = {
paymentIntent,
userid,
price,
description,
client_secret: paymentIntent.client_secret,
};
DB.newIntent(intent);
return res.send({
clientSecret: paymentIntent.client_secret,
email: await DB.getUsernameByIdCache(userid),
price
});
}
if (isEmail) {
//Register in DB
return res.send({
clientSecret: paymentIntent.client_secret,
email: userid,
price
});
}
return res.send({ return res.send({
clientSecret: paymentIntent.client_secret, clientSecret: paymentIntent.client_secret,
email: await DB.getUsernameByIdCache(userid), email: 'guess',
price price
}); });
}; };
/**
* @swagger
* /payments/create-payment-intent:
* post:
* summary: Creates a Stripe Payment Intent
* tags: [Payments]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userid:
* type: string
* price:
* type: number
* description:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/create-payment-intent", intent); router.post("/create-payment-intent", intent);
/**
* @swagger
* /payments/intent:
* post:
* summary: Creates a Stripe Payment Intent (Alias for /create-payment-intent)
* tags: [Payments]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userid:
* type: string
* price:
* type: number
* description:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/intent", intent); router.post("/intent", intent);
/**
* @swagger
* /payments/register:
* post:
* summary: Registers a payment after a successful Stripe transaction
* tags: [Payments]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userid:
* type: string
* result:
* type: object
* responses:
* 200:
* description: OK
*/
router.post("/register", async (req, res) => { router.post("/register", async (req, res) => {
const userid = req.body.userid; const userid = req.body.userid;
const result = req.body.result; const result = req.body.result;
@@ -84,7 +182,7 @@ DB.getDB.then((DB) => {
}; };
//console.log(payment); //console.log(payment);
const intent = await DB.getIntent(result.client_secret); const intent = await DB.getIntent(result.client_secret);
if(intent.description === "Subscription 1 Month"){ if (intent.description === "Subscription 1 Month") {
//update profile subscription status //update profile subscription status
const profileid = getProfileId(req); const profileid = getProfileId(req);
const isSubscriptor = await DB.isSubscriptor(profileid); const isSubscriptor = await DB.isSubscriptor(profileid);

View File

@@ -8,7 +8,10 @@ const Notifications = require("./../notifications.js");
DB.getDB.then((DB) => { DB.getDB.then((DB) => {
const getProfileId = (req) => { const getProfileId = (req) => {
return DB.ObjectID(req.cookies.profile_id || req.query.profile_id || req.body.profile_id); const rawProfileId = req.cookies.profile_id || req.query.profile_id || req.body.profile_id || req.profileInfo?._id;
if (!rawProfileId) return null;
if (!DB.ObjectID.isValid(rawProfileId)) return null;
return DB.ObjectID(rawProfileId);
}; };
const postBelongToProfile = (post, profileid) => { const postBelongToProfile = (post, profileid) => {
@@ -77,24 +80,193 @@ DB.getDB.then((DB) => {
return mergedPosts; return mergedPosts;
}; };
/**
* @swagger
* tags:
* name: Posts
* description: Post management
*/
/**
* @swagger
* components:
* schemas:
* Post:
* type: object
* properties:
* profileid:
* type: string
* description: The ID of the profile that created the post.
* content:
* type: string
* description: The content of the post.
* createdAt:
* type: string
* format: date-time
* description: The timestamp when the post was created.
* reactions:
* type: object
* description: An object containing reactions to the post, keyed by profile ID.
* comments:
* type: array
* items:
* type: object
* description: An array of comments on the post.
* bookmarks:
* type: array
* items:
* type: string
* description: An array of profile IDs that bookmarked the post.
* nonOrganicType:
* type: string
* description: Type of non-organic content (e.g., "News", "PopularUsers", "PopularGroups").
* contentHistory:
* type: array
* items:
* type: string
* description: A history of content edits.
* toProfile:
* type: string
* description: The ID of the profile this post is directed to (e.g., a group).
* lastUpdated:
* type: string
* format: date-time
* description: The timestamp when the post was last updated.
* tags:
* type: array
* items:
* type: string
* description: An array of tags associated with the post.
* chatSenderId:
* type: string
* description: The ID of the chat sender, if applicable.
*
* /post/organic:
* get:
* summary: Get the organic feed for the current user
* tags: [Posts]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/organic", async (req, res) => { router.get("/organic", async (req, res) => {
const profileid = getProfileId(req); try {
let organicPosts = await DB.getFeed(profileid); const profileid = getProfileId(req);
//Add non-organic posts if (!profileid) return res.status(400).json([]);
const nonOrganicPosts = await generateNonOrganicPosts(req, profileid); let organicPosts = await DB.getFeed(profileid);
const posts = mergePosts(organicPosts, nonOrganicPosts); const nonOrganicPosts = await generateNonOrganicPosts(req, profileid);
return res.json(posts); const posts = mergePosts(organicPosts || [], nonOrganicPosts || []);
return res.json(posts);
} catch (error) {
console.error("Error loading organic feed", error);
return res.status(500).json([]);
}
}); });
/**
* @swagger
* /post:
* get:
* summary: Get the feed with promotional content
* tags: [Posts]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try {
const profileid = getProfileId(req);
if (!profileid) return res.status(400).json([]);
const nonOrganicPosts = await generateNonOrganicPosts(req, profileid);
let promotionalPosts = await DB.getPromotionalPosts(profileid);
const posts = mergePosts(promotionalPosts || [], nonOrganicPosts || []);
return res.json(posts);
} catch (error) {
console.error("Error loading feed", error);
return res.status(500).json([]);
}
});
/**
* @swagger
* /post/tag/{tag}:
* get:
* summary: Get posts with a specific tag
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: tag
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Post'
* 400:
* description: Tag is required
*/
router.get("/tag/:tag", async (req, res) => {
const profileid = getProfileId(req); const profileid = getProfileId(req);
//Add non-organic posts const tag = req.query.tag || req.params.tag;
const nonOrganicPosts = await generateNonOrganicPosts(req, profileid); if(!tag) {
let promotionalPosts = await DB.getPromotionalPosts(profileid); console.log("Tag query empty: ", tag);
const posts = mergePosts(promotionalPosts, nonOrganicPosts); return res.json({
status: "Tag is required",
});
}
console.log("Tag query: ", tag);
let posts = await DB.getPostsByTag('#' + tag, profileid);
return res.json(posts); return res.json(posts);
}); });
/**
* @swagger
* /post/usr/{id}:
* get:
* summary: Get posts from a specific user
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/usr/:id", async (req, res) => { router.get("/usr/:id", async (req, res) => {
const profileId = req.params.id; const profileId = req.params.id;
const viewerProdileId = getProfileId(req); const viewerProdileId = getProfileId(req);
@@ -110,6 +282,35 @@ DB.getDB.then((DB) => {
return res.json(posts); return res.json(posts);
}); });
/**
* @swagger
* /post/usr/{id}/images:
* get:
* summary: Get all image posts from a user
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* posts:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/usr/:id/images", async (req, res) => { router.get("/usr/:id/images", async (req, res) => {
const profileid = req.params.id; const profileid = req.params.id;
const viewerProfileId = getProfileId(req); const viewerProfileId = getProfileId(req);
@@ -120,6 +321,35 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/usr/{id}/embedded:
* get:
* summary: Get all embedded posts from a user
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* posts:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/usr/:id/embedded", async (req, res) => { router.get("/usr/:id/embedded", async (req, res) => {
const profileid = req.params.id; const profileid = req.params.id;
const viewerProfileId = getProfileId(req); const viewerProfileId = getProfileId(req);
@@ -130,6 +360,35 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/usr/{id}/media:
* get:
* summary: Get all media posts from a user
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* posts:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/usr/:id/media", async (req, res) => { router.get("/usr/:id/media", async (req, res) => {
const profileid = req.params.id; const profileid = req.params.id;
const viewerProfileId = getProfileId(req); const viewerProfileId = getProfileId(req);
@@ -140,11 +399,49 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/video/{id}:
* get:
* summary: Get video details by ID
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/video/:id", async (req, res) => { router.get("/video/:id", async (req, res) => {
videoId = req.params.id; videoId = req.params.id;
return res.json([]); return res.json([]);
}); });
/**
* @swagger
* /post:
* post:
* summary: Create a new post
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: OK
* 403:
* description: Not authorized to post to this group
*/
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
let post = { let post = {
profileid: getProfileId(req), profileid: getProfileId(req),
@@ -184,6 +481,27 @@ DB.getDB.then((DB) => {
}) })
}); });
/**
* @swagger
* /post/react:
* post:
* summary: React to a post
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/react", async (req, res) => { router.post("/react", async (req, res) => {
let profileid = getProfileId(req); let profileid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -198,6 +516,27 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/unreact:
* post:
* summary: Remove a reaction from a post
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/unreact", async (req, res) => { router.post("/unreact", async (req, res) => {
let profileid = getProfileId(req); let profileid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -207,6 +546,27 @@ DB.getDB.then((DB) => {
}) })
}); });
/**
* @swagger
* /post/bookmark:
* post:
* summary: Bookmark a post
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/bookmark", async (req, res) => { router.post("/bookmark", async (req, res) => {
let profileid = getProfileId(req); let profileid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -216,6 +576,27 @@ DB.getDB.then((DB) => {
}); });
}) })
/**
* @swagger
* /post/unbookmark:
* post:
* summary: Remove a bookmark from a post
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/unbookmark", async (req, res) => { router.post("/unbookmark", async (req, res) => {
let profileid = getProfileId(req); let profileid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -225,6 +606,29 @@ DB.getDB.then((DB) => {
}) })
}); });
/**
* @swagger
* /post/comment:
* post:
* summary: Add a comment to a post
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* content:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/comment/", async (req, res) => { router.post("/comment/", async (req, res) => {
let profileid = getProfileId(req); let profileid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -244,6 +648,30 @@ DB.getDB.then((DB) => {
}) })
}); });
/**
* @swagger
* /post/comment/react:
* post:
* summary: React to a comment
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* commentDate:
* type: string
* format: date-time
* responses:
* 200:
* description: OK
*/
router.post("/comment/react", async (req, res) => { router.post("/comment/react", async (req, res) => {
let userid = getProfileId(req); let userid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -258,6 +686,30 @@ DB.getDB.then((DB) => {
}) })
}); });
/**
* @swagger
* /post/comment/unreact:
* post:
* summary: Remove a reaction from a comment
* tags: [Posts]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* postid:
* type: string
* commentDate:
* type: string
* format: date-time
* responses:
* 200:
* description: OK
*/
router.post("/comment/unreact", async (req, res) => { router.post("/comment/unreact", async (req, res) => {
let profileid = getProfileId(req); let profileid = getProfileId(req);
let postid = req.body.postid; let postid = req.body.postid;
@@ -268,6 +720,29 @@ DB.getDB.then((DB) => {
}) })
}); });
/**
* @swagger
* /post/images:
* get:
* summary: Get all image posts for the current user
* tags: [Posts]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* posts:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/images", async (req, res) => { router.get("/images", async (req, res) => {
const profileid = getProfileId(req); const profileid = getProfileId(req);
const posts = await DB.getMediaTagPostOfUser(profileid, profileid); const posts = await DB.getMediaTagPostOfUser(profileid, profileid);
@@ -277,6 +752,29 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/embedded:
* get:
* summary: Get all embedded posts for the current user
* tags: [Posts]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* posts:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/embedded", async (req, res) => { router.get("/embedded", async (req, res) => {
const profileid = getProfileId(req); const profileid = getProfileId(req);
const posts = await DB.getMediaTagPostOfUser(profileid, profileid, "@iframe:"); const posts = await DB.getMediaTagPostOfUser(profileid, profileid, "@iframe:");
@@ -286,6 +784,29 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/media:
* get:
* summary: Get all media posts for the current user
* tags: [Posts]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* posts:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
router.get("/media", async (req, res) => { router.get("/media", async (req, res) => {
const profileid = getProfileId(req); const profileid = getProfileId(req);
const posts = await DB.getMediaTagPostOfUser(profileid, profileid, "@youtube:|@vimeo:|@hls:"); const posts = await DB.getMediaTagPostOfUser(profileid, profileid, "@youtube:|@vimeo:|@hls:");
@@ -295,6 +816,74 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/course/recent:
* get:
* summary: Get recently watched media from courses
* tags: [Posts]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* recentMedia:
* type: object
* additionalProperties:
* type: array
* items:
* type: object
* properties:
* watchRecord:
* type: object
* _id:
* type: string
* profileid:
* type: string
* content:
* type: string
* createdAt:
* type: string
* format: date-time
* reactions:
* type: object
* comments:
* type: array
* items:
* type: object
* bookmarks:
* type: array
* items:
* type: string
* nonOrganicType:
* type: string
* contentHistory:
* type: array
* items:
* type: string
* toProfile:
* type: string
* lastUpdated:
* type: string
* format: date-time
* tags:
* type: array
* items:
* type: string
* chatSenderId:
* type: string
* mediaProfileMap:
* type: object
* additionalProperties:
* type: object
*/
router.get("/course/recent", async (req, res) => { router.get("/course/recent", async (req, res) => {
const profileid = getProfileId(req); const profileid = getProfileId(req);
const profile = await DB.getProfileCache(profileid); const profile = await DB.getProfileCache(profileid);
@@ -342,6 +931,28 @@ DB.getDB.then((DB) => {
}); });
}); });
/**
* @swagger
* /post/{id}:
* get:
* summary: Get a specific post by ID
* tags: [Posts]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Post'
*/
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
const postId = req.params.id; const postId = req.params.id;
const post = await DB.getPost(postId); const post = await DB.getPost(postId);
@@ -382,4 +993,4 @@ DB.getDB.then((DB) => {
}); });
module.exports = router module.exports = router

View File

@@ -4,26 +4,112 @@ var router = express.Router()
const DB = require("../mongoDB.js"); const DB = require("../mongoDB.js");
const Profile = require("../def/profile.js"); const Profile = require("../def/profile.js");
const Notifications = require("./../notifications.js"); const Notifications = require("./../notifications.js");
const { getSessionId, getUserId, getProfileId } = require("./../utils/sessionUtils.js");
DB.getDB.then((DB)=>{ DB.getDB.then((DB) => {
const getUserId = function(req){
const user_sid = req.cookies.user_sid || req.query.user_sid || req.body.user_sid;
return DB.ObjectID(user_sid);
}
const getProfileId = (req)=>{
return DB.ObjectID(req.cookies.profile_id || req.query.profile_id || req.body.profile_id);
}
const profileBelongsToUser = async (profileid, userid) => { const profileBelongsToUser = async (profileid, userid) => {
const profile = await DB.getProfileCache(profileid); const profile = await DB.getProfileCache(profileid);
if(!profile) return false; if (!profile) return false;
return profile.userid == (userid + ''); return profile.userid === String(userid);
} }
/**
* @swagger
* tags:
* name: Profiles
* description: User profile management
*/
/**
* @swagger
* components:
* schemas:
* Profile:
* type: object
* properties:
* userid:
* type: string
* description: The ID of the user associated with the profile.
* profile:
* type: object
* properties:
* firstName:
* type: string
* lastName:
* type: string
* photo:
* type: string
* location:
* type: string
* language:
* type: string
* status:
* type: string
* description:
* type: string
* data:
* type: object
* description: Additional custom data for the profile.
* username:
* type: string
* following:
* type: array
* items:
* type: string
* description: List of profile IDs this profile is following.
* lastUpdate:
* type: string
* format: date-time
* newsFeedCache:
* type: array
* items:
* type: object
* notifications:
* type: array
* items:
* type: object
* isGroup:
* type: boolean
* isCourse:
* type: boolean
* isPrivate:
* type: boolean
* isChat:
* type: boolean
* subscribed:
* type: object
* description: Users subscribed to this group (if isGroup is true).
* pending:
* type: object
* description: Pending subscription requests for private groups.
*/
/**
* @swagger
* /user/mine:
* get:
* summary: Get all profiles for the logged-in user
* tags: [Profiles]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* profiles:
* type: array
* items:
* $ref: '#/components/schemas/Profile'
*/
router.get("/mine", async (req, res) => { router.get("/mine", async (req, res) => {
let userid = req.cookies.user_sid; let userid = getUserId(req);
let profiles = await DB.getUserProfiles(userid); let profiles = await DB.getUserProfiles(userid);
return res.json({ return res.json({
status: "ok", status: "ok",
@@ -31,64 +117,212 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/new:
* get:
* summary: (DEPRECATED) Create a new profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: content
* required: true
* schema:
* type: object
* $ref: '#/components/schemas/Profile'
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Profile'
*/
router.get("/new", async (req, res) => { //Deprecated please use route post("/") router.get("/new", async (req, res) => { //Deprecated please use route post("/")
let profile = { let profile = {
userid: getUserId(req), userid: getUserId(req),
... req.query.content ...req.query.content
}; };
let profileObj = new Profile(profile); let profileObj = new Profile(profile);
let r = await DB.newProfile(profileObj); let r = await DB.newProfile(profileObj);
return res.json({ return res.json({
status: "ok", status: "ok",
... profileObj.toObj() ...profileObj.toObj()
}); });
}); });
/**
* @swagger
* /user:
* post:
* summary: Create a new profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Profile'
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Profile'
*/
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
let profile = { let profile = {
userid: getUserId(req), userid: getUserId(req),
... req.body.content ...req.body.content
}; };
let profileObj = new Profile(profile); try {
let r = await DB.newProfile(profileObj); let profileObj = new Profile(profile);
return res.json({ let r = await DB.newProfile(profileObj);
status: "ok",
... profileObj.toObj()
});
});
router.post("/invite", async (req, res) => {
const userid = getUserId(req);
const name = req.body.name;
const email = req.body.email;
//validate email?
if(!name || !email) return res.json({status: "incomplete request"});
let r = await DB.newInvitation(userid, name, email);
if(!r.toLowerCase){
//send email invitation
let senderProfile = await DB.getProfile(getProfileId(req));
Notifications.youHaveAnInvitation(name, email, senderProfile);
return res.json({ return res.json({
status: "ok" status: "ok",
...profileObj.toObj()
});
} catch (error) {
console.error("Error creating profile", error);
return res.json({
status: error,
}); });
} }
return res.json({
status: r
});
}); });
/**
* @swagger
* /user/invite:
* post:
* summary: Invite a new user by email
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* email:
* type: string
* format: email
* responses:
* 200:
* description: OK
* 400:
* description: Bad request
*/
router.post("/invite", async (req, res) => {
try {
const userid = getUserId(req);
let { name, email } = req.body; // Destructuring for clarity
// Validate required fields
if (!name || !email) {
return res.status(400).json({ status: "Name and email are required" });
}
// Validate email format
email = email.trim().toLowerCase()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ status: "Invalid email format" });
}
// Create new invitation, this returns a string if failed
let r = await DB.newInvitation(userid, name, email);
if (r instanceof String) {
// Handle failure response from DB.newInvitation
return res.status(400).json({
status: r,
message: `Failed to send invitation: ${r}`
});
}
// Handle response from DB.newInvitation
// Send email invitation
let senderProfile = await DB.getProfile(getProfileId(req));
Notifications.youHaveAnInvitation(name, email, senderProfile);
return res.status(200).json({
status: "ok",
message: `Invitation sent to ${name} (${email})`
});
} catch (error) {
console.error("Error during invitation process:", error);
return res.status(500).json({ status: "error", message: "Something went wrong, please try again later" });
}
});
/**
* @swagger
* /user/invite/{email}:
* get:
* summary: Get invitation details for an email
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: email
* required: true
* schema:
* type: string
* format: email
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* invitation:
* $ref: '#/components/schemas/Profile'
*
*/
router.get("/invite/:email", async (req, res) => { router.get("/invite/:email", async (req, res) => {
const userid = getUserId(req); const userid = getUserId(req);
const email = req.params.email; const email = req.params.email;
//validate email? //validate email?
if(!email) return res.json({status: "provide valid email"}); if (!email) return res.json({ status: "provide valid email" });
let r = await DB.getInvitation(email); let r = await DB.getInvitation(email);
if(!r) return res.json({status: "no invitation found with that email"}); if (!r) return res.json({ status: "no invitation found with that email" });
let isUserAlreadyRegistered = await DB.getUser(email); let isUserAlreadyRegistered = await DB.getUser(email);
if(isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({status: "This user is already registered"}); if (isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({ status: "This user is already registered" });
return res.json({status: "ok", ... r}); return res.json({ status: "ok", ...r });
}); });
/**
* @swagger
* /user/groups:
* get:
* summary: Get a list of all groups
* tags: [Profiles]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* groups:
* type: array
* items:
* $ref: '#/components/schemas/Profile'
*/
router.get("/groups", async (req, res) => { router.get("/groups", async (req, res) => {
let groups = await DB.getGroups(); let groups = await DB.getGroups();
return res.json({ return res.json({
@@ -97,6 +331,29 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups/following:
* get:
* summary: Get a list of groups the current profile is following
* tags: [Profiles]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* groups:
* type: array
* items:
* $ref: '#/components/schemas/Profile'
*/
router.get("/groups/following", async (req, res) => { router.get("/groups/following", async (req, res) => {
const profileId = getProfileId(req); const profileId = getProfileId(req);
let groups = await DB.getFollowingGroups(profileId); let groups = await DB.getFollowingGroups(profileId);
@@ -106,20 +363,65 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups:
* post:
* summary: Create a new group
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Profile'
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Profile'
*/
router.post("/groups", async (req, res) => { router.post("/groups", async (req, res) => {
let profile = { let profile = {
userid: getUserId(req), userid: getUserId(req),
isGroup: true, isGroup: true,
... req.body ...req.body
}; };
let profileObj = new Profile(profile); let profileObj = new Profile(profile);
DB.newProfile(profileObj) DB.newProfile(profileObj)
return res.json({ return res.json({
status: "ok", status: "ok",
... profileObj.toObj() ...profileObj.toObj()
}); });
}); });
/**
* @swagger
* /user/courses:
* get:
* summary: Get a list of all courses
* tags: [Profiles]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* groups:
* type: array
* items:
* $ref: '#/components/schemas/Profile'
*/
router.get("/courses", async (req, res) => { router.get("/courses", async (req, res) => {
let groups = await DB.getCourses(); let groups = await DB.getCourses();
return res.json({ return res.json({
@@ -128,12 +430,35 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups/accept:
* post:
* summary: Accept a request to join a private group
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* groupid:
* type: string
* profileid:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/groups/accept", async (req, res) => { router.post("/groups/accept", async (req, res) => {
//This function should be called to accept the join request //This function should be called to accept the join request
//of an user that attempt to join a private group. //of an user that attempt to join a private group.
const groupid = getProfileId(req); //It needs to have this profile context const groupid = getProfileId(req); //It needs to have this profile context
const groupidBody = req.body.groupid ? DB.ObjectID(req.body.groupid) : undefined; const groupidBody = req.body.groupid ? DB.ObjectID(req.body.groupid) : undefined;
if(groupidBody && groupid != groupidBody && !DB.isOwnerOfGroup(groupid, groupidBody)){ if (groupidBody && groupid != groupidBody && !DB.isOwnerOfGroup(groupid, groupidBody)) {
return res.json({ return res.json({
status: "Only group owner can accept new subscribers" status: "Only group owner can accept new subscribers"
}); });
@@ -147,12 +472,35 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups/reject:
* post:
* summary: Reject a request to join a private group
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* groupid:
* type: string
* profileid:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/groups/reject", async (req, res) => { router.post("/groups/reject", async (req, res) => {
//This function should be called to reject the join request //This function should be called to reject the join request
//of an user that attempt to join a private group. //of an user that attempt to join a private group.
const groupid = getProfileId(req); //It needs to have this profile context const groupid = getProfileId(req); //It needs to have this profile context
const groupidBody = req.body.groupid ? DB.ObjectID(req.body.groupid) : undefined; const groupidBody = req.body.groupid ? DB.ObjectID(req.body.groupid) : undefined;
if(groupidBody && groupid != groupidBody && !DB.isOwnerOfGroup(groupid, groupidBody)){ if (groupidBody && groupid != groupidBody && !DB.isOwnerOfGroup(groupid, groupidBody)) {
return res.json({ return res.json({
status: "Only group owner can reject new subscribers" status: "Only group owner can reject new subscribers"
}); });
@@ -166,6 +514,39 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups/search:
* get:
* summary: Search for groups
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: query
* required: true
* schema:
* type: string
* - in: query
* name: courses
* schema:
* type: boolean
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* groups:
* type: array
* items:
* $ref: '#/components/schemas/Profile'
*/
router.get("/groups/search", async (req, res) => { router.get("/groups/search", async (req, res) => {
let query = req.query.query; let query = req.query.query;
let coursesB = req.query.courses ? true : false; let coursesB = req.query.courses ? true : false;
@@ -176,6 +557,33 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups/{id}:
* get:
* summary: Get details for a specific group
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* groups:
* $ref: '#/components/schemas/Profile'
*/
router.get("/groups/:id", async (req, res) => { router.get("/groups/:id", async (req, res) => {
const groupid = req.params.id; const groupid = req.params.id;
let groups = await DB.getGroup(groupid); let groups = await DB.getGroup(groupid);
@@ -185,18 +593,54 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/groups/{id}/subscribe:
* get:
* summary: Subscribe to a group
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/groups/:id/subscribe", async (req, res) => { router.get("/groups/:id/subscribe", async (req, res) => {
const groupid = req.params.id; const groupid = req.params.id;
const profileid = getProfileId(req); const profileid = getProfileId(req);
const isPrivate = await DB.isGroupPrivate(groupid); const isPrivate = await DB.isGroupPrivate(groupid);
DB.subscribeToGroup(profileid, groupid, isPrivate); DB.subscribeToGroup(profileid, groupid, isPrivate);
//Add notification to group owner //Add notification to group owner
if(isPrivate) Notifications.yourGroupHasARequest(profileid, groupid) if (isPrivate) Notifications.yourGroupHasARequest(profileid, groupid)
return res.json({ return res.json({
status: "ok" status: "ok"
}); });
}); });
/**
* @swagger
* /user/groups/{id}/unsubscribe:
* get:
* summary: Unsubscribe from a group
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/groups/:id/unsubscribe", async (req, res) => { router.get("/groups/:id/unsubscribe", async (req, res) => {
const groupid = req.params.id; const groupid = req.params.id;
const profileid = getProfileId(req); const profileid = getProfileId(req);
@@ -207,6 +651,35 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/search:
* get:
* summary: Search for profiles
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: query
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* profiles:
* type: array
* items:
* $ref: '#/components/schemas/Profile'
*/
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
let query = req.query.query; let query = req.query.query;
let profiles = await DB.searchProfile(query); let profiles = await DB.searchProfile(query);
@@ -216,6 +689,36 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/setData:
* post:
* summary: Set custom data for a profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* key:
* type: string
* value:
* type: object
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
*/
router.post("/setData", (req, res) => { router.post("/setData", (req, res) => {
const key = req.body.key; const key = req.body.key;
const value = req.body.value; const value = req.body.value;
@@ -226,32 +729,153 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/myProfile:
* post:
* summary: Update the current user's profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* profile:
* $ref: '#/components/schemas/Profile'
* data:
* type: object
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
*/
router.post("/myProfile", async (req, res) => { router.post("/myProfile", async (req, res) => {
let profile = { try {
userid: getUserId(req), let profile = {
profile: req.body.profile, userid: getUserId(req),
data: req.body.data profile: req.body.profile,
}; data: req.body.data
let profileObj = new Profile(profile); //validates profile };
DB.updateProfile(getProfileId(req), profileObj); let profileObj = new Profile(profile); //validates profile
return res.json({ const updateRes = await DB.updateProfile(getProfileId(req), profileObj);
status: "ok" if (!updateRes || !updateRes.matchedCount) {
}); return res.status(400).json({
status: "Could not update profile"
});
}
return res.json({
status: "ok"
});
} catch (error) {
console.error("Error updating myProfile", error);
return res.status(500).json({
status: "Internal server error"
});
}
}); });
router.post("/notifications/viewed", async (req, res) => {
try {
const profileid = getProfileId(req);
const result = await DB.markNotificationsViewed(profileid);
if (!result) {
return res.status(400).json({
status: "Could not update notifications"
});
}
return res.json({
status: "ok"
});
} catch (error) {
console.error("Error marking notifications as viewed", error);
return res.status(500).json({
status: "Internal server error"
});
}
});
/**
* @swagger
* /user/{id}:
* get:
* summary: Get a specific profile by ID
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Profile'
*/
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
let profileId = req.params.id; try {
let profile = await DB.getProfile(profileId); let profileId = req.params.id;
return res.json({ let profile = await DB.getProfile(profileId);
status: "ok", if (!profile || !profile._id) {
... profile return res.status(404).json({
}); status: "Profile not found",
});
}
return res.json({
status: "ok",
...profile
});
} catch (error) {
console.error("Error loading profile", error);
return res.status(500).json({
status: "Internal server error"
});
}
}); });
/**
* @swagger
* /user/{id}:
* delete:
* summary: Delete a profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
*/
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
const profileId = req.params.id; const profileId = req.params.id;
const userid = getUserId(req); const userid = getUserId(req);
if(!await profileBelongsToUser(profileId, userid)) if (!await profileBelongsToUser(profileId, userid))
return res.json({ return res.json({
status: "This profile is not yours." status: "This profile is not yours."
}); });
@@ -261,6 +885,31 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/{id}/follow:
* get:
* summary: Follow a profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
*/
router.get("/:id/follow", async (req, res) => { router.get("/:id/follow", async (req, res) => {
let followProfileId = req.params.id; let followProfileId = req.params.id;
const profileid = getProfileId(req); const profileid = getProfileId(req);
@@ -271,6 +920,31 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /user/{id}/unfollow:
* get:
* summary: Unfollow a profile
* tags: [Profiles]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
*/
router.get("/:id/unfollow", async (req, res) => { router.get("/:id/unfollow", async (req, res) => {
let followProfileId = req.params.id; let followProfileId = req.params.id;
const profileid = getProfileId(req); const profileid = getProfileId(req);
@@ -283,4 +957,4 @@ DB.getDB.then((DB)=>{
}); });
module.exports = router module.exports = router

View File

@@ -15,6 +15,25 @@ DB.getDB.then((DB)=>{
return DB.ObjectID(req.cookies.profile_id || req.query.profile_id || req.body.profile_id); return DB.ObjectID(req.cookies.profile_id || req.query.profile_id || req.body.profile_id);
} }
/**
* @swagger
* tags:
* name: Songs
* description: Song management
*/
/**
* @swagger
* /songs:
* get:
* summary: Get all songs
* tags: [Songs]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: OK
*/
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
let profileId = req.params.id; let profileId = req.params.id;
let songs = await DB.getSongs(); let songs = await DB.getSongs();
@@ -24,6 +43,24 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /songs:
* post:
* summary: Create a new song
* tags: [Songs]
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: OK
*/
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
let post = { let post = {
userid: getUserId(req), userid: getUserId(req),
@@ -39,6 +76,24 @@ DB.getDB.then((DB)=>{
}) })
}); });
/**
* @swagger
* /songs/{id}:
* get:
* summary: Get a specific song by ID
* tags: [Songs]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
let profileId = req.params.id; let profileId = req.params.id;
let profile = await DB.getProfile(profileId); let profile = await DB.getProfile(profileId);
@@ -53,6 +108,24 @@ DB.getDB.then((DB)=>{
return true; return true;
} }
/**
* @swagger
* /songs/{id}:
* delete:
* summary: Delete a song
* tags: [Songs]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
const userid = getUserId(req); const userid = getUserId(req);
const songId = req.params.id; const songId = req.params.id;
@@ -66,6 +139,33 @@ DB.getDB.then((DB)=>{
}); });
}); });
/**
* @swagger
* /songs/{id}:
* post:
* summary: Update a song
* tags: [Songs]
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* content:
* type: string
* responses:
* 200:
* description: OK
*/
router.post("/:id", async (req, res) => { router.post("/:id", async (req, res) => {
const userid = getUserId(req); const userid = getUserId(req);
const songId = req.params.id; const songId = req.params.id;

View File

@@ -67,12 +67,51 @@ const getMedia = async (eventId) => {
}; };
//getMedia('y42zyf3').then(console.log) //getMedia('y42zyf3').then(console.log)
/**
* @swagger
* tags:
* name: Subsplash
* description: Subsplash API integration
*/
DB.getDB.then((DB) => { DB.getDB.then((DB) => {
/**
* @swagger
* /subsplash/events/{calendarId}:
* get:
* summary: Get events from a Subsplash calendar
* tags: [Subsplash]
* parameters:
* - in: path
* name: calendarId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/events/:calendarId", async (req, res) => { router.get("/events/:calendarId", async (req, res) => {
const events = await getEvents(req.params.calendarId) const events = await getEvents(req.params.calendarId)
return res.json(events); return res.json(events);
}); });
/**
* @swagger
* /subsplash/media/{seriesId}:
* get:
* summary: Get media from a Subsplash media series
* tags: [Subsplash]
* parameters:
* - in: path
* name: seriesId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get("/media/:seriesId", async (req, res) => { router.get("/media/:seriesId", async (req, res) => {
const events = await getMedia(req.params.seriesId) const events = await getMedia(req.params.seriesId)
return res.json(events); return res.json(events);

97
utils/chatTranslation.js Normal file
View File

@@ -0,0 +1,97 @@
const axios = require("axios");
const DEFAULT_MODEL = process.env.OPENAI_TRANSLATION_MODEL || process.env.OPENAI_MODEL || "gpt-4o-mini";
const normalizeLanguageCode = (rawLanguage) => {
if (!rawLanguage || typeof rawLanguage !== "string") return "en";
const firstValue = rawLanguage.split(",")[0].trim().toLowerCase();
if (!firstValue) return "en";
const noQuality = firstValue.split(";")[0].trim();
const shortCode = noQuality.split("-")[0].trim();
return shortCode || "en";
};
const extractOutputText = (data) => {
if (!data) return "";
if (typeof data.output_text === "string" && data.output_text.trim()) {
return data.output_text.trim();
}
if (!Array.isArray(data.output)) return "";
const chunks = [];
data.output.forEach((item) => {
if (!Array.isArray(item?.content)) return;
item.content.forEach((entry) => {
if (entry?.type === "output_text" && typeof entry?.text === "string") {
chunks.push(entry.text);
}
});
});
return chunks.join("\n").trim();
};
const translateText = async ({ text, sourceLang, targetLang }) => {
const normalizedSource = normalizeLanguageCode(sourceLang);
const normalizedTarget = normalizeLanguageCode(targetLang);
if (!text || !normalizedTarget || normalizedSource === normalizedTarget) {
return {
translatedText: text,
provider: "none",
model: "none",
};
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return null;
try {
const response = await axios.post(
"https://api.openai.com/v1/responses",
{
model: DEFAULT_MODEL,
input: [
{
role: "system",
content: [
{
type: "input_text",
text: "You translate chat messages. Keep meaning, tone, emojis, names, and references. Return only the translated text.",
},
],
},
{
role: "user",
content: [
{
type: "input_text",
text: `Translate this message from ${normalizedSource} to ${normalizedTarget}:\n\n${text}`,
},
],
},
],
},
{
timeout: 15000,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
}
);
const translatedText = extractOutputText(response?.data);
if (!translatedText) return null;
return {
translatedText,
provider: "openai",
model: DEFAULT_MODEL,
};
} catch (error) {
console.error("Error translating chat message", error?.response?.data || error?.message || error);
return null;
}
};
module.exports = {
normalizeLanguageCode,
translateText,
};

View File

@@ -1,14 +1,43 @@
const { ObjectId } = require("mongodb");
const isValidObjectId = (id) => ObjectId.isValid(id);
// Utilities // Utilities
const getSessionId = function (req) { const getSessionId = function (req) {
const session_id = req.cookies.session_id || req.query.session_id || req.body.session_id; const session_id = req.cookies.session_id || req.query.session_id || req.body.session_id;
if(!session_id) {
return session_id;
}
if(isValidObjectId(session_id)) {
return session_id;
}
console.trace();
console.error("Invalid session_id format: ", session_id);
return session_id; return session_id;
} }
const getUserId = function (req) { const getUserId = function (req) {
const user_sid = req.cookies.user_sid || req.query.user_sid || req.body.user_sid; const user_sid = req.cookies.user_sid || req.query.user_sid || req.body.user_sid;
// validate user_sid
if(!user_sid) {
return user_sid;
}
if(isValidObjectId(user_sid)) {
return user_sid;
}
console.trace();
console.error("Invalid user_sid format: ", user_sid);
return user_sid; return user_sid;
} }
const getProfileId = function (req) { const getProfileId = function (req) {
const profile_id = req.cookies.profile_id || req.query.profile_id || req.body.profile_id; const profile_id = req.cookies.profile_id || req.query.profile_id || req.body.profile_id;
if(!profile_id) {
return profile_id;
}
if(isValidObjectId(profile_id)) {
return profile_id;
}
console.trace();
console.error("Invalid profile_id format: ", profile_id);
return profile_id; return profile_id;
} }