32 Commits

Author SHA1 Message Date
Adolfo Reyna
f0afa200b1 Align live captions transport to flat language payloads 2026-02-26 22:56:51 -05:00
Adolfo Reyna
503c5ef1f4 Add live captions stream endpoints and test sender 2026-02-26 22:31:00 -05:00
Adolfo Reyna
e8dd905f27 fix: check if translation exists before processing in background 2026-02-25 18:49:44 -05:00
Adolfo Reyna
c5fd09d71d feat: add post translation queue and background AI translation via API 2026-02-25 18:41:46 -05:00
Adolfo Reyna
989fdce883 feat: Add JSON content-type to Bible API chapters endpoint 2026-02-25 18:13:32 -05:00
Adolfo Reyna
fd82643477 Add push notifications for chat participants 2026-02-23 19:57:31 -05:00
93d5b6b5f3 Merge pull request 'fix(auth): return JSON 401 for API sessions and harden cross-site cookies' (#4) from codex/sessionchecker-json-unauthorized into master
Reviewed-on: #4
2026-02-22 02:56:14 +00:00
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
27 changed files with 4094 additions and 370 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.
## 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
[ ] Define nodes schema
[ ] Implement basic login/registration
[ ]
- [ ] Define nodes schema
- [ ] Implement basic login/registration

View File

@@ -3,21 +3,55 @@ const { client_logger } = require('../utils/analyticsLogger');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils.js');
const { cookiesOptions } = require('../config/cookiesOptions');
const { getCookiesOptions } = require('../config/cookiesOptions');
const Notifications = require("../notifications");
// Object Definitions
const Post = require("../def/post.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.
// 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.
const signup = async function (req, res) {
const username = req.query.username || req.body.username;
const password = req.query.password || req.body.password;
const email = req.query.email || req.body.email;
const profile = req.query.profile || req.body.profile;
const username = (req.body.username || "").trim().toLowerCase();
const password = req.body.password;
const email = (req.body.email || "").trim().toLowerCase();
const profile = req.body.profile;
if (!username || !password || !email) return res.json({ status: "Incomplete information!" });
// Check if the new user has an invitation.
const DB = await MongoDB.getDB;
@@ -34,12 +68,10 @@ const signup = async function (req, res) {
}
let isUserAlreadyRegistered = await DB.getUser(email);
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 newUserObject = await DB.newUser({
username: username.toLowerCase(),
email: email.toLowerCase(),
username,
email,
password: hashedPassword
});
// 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.
const session_id = getSessionId(req);
const user_sid = getUserId(req);
const DB = await MongoDB.getDB;
if (session_id && user_sid) {
const userInfo = await DB.checkSessionOnDB(session_id, user_sid);
if (userInfo) return res.redirect('/');
}
const username = req.body.username || req.query.username;
const password = req.body.password || req.query.password || "";
const DB = await MongoDB.getDB;
const invalidCredentials = () => res.status(401).json({ status: "Invalid credentials" });
const username = (req.body.username || req.body.email || "").trim().toLowerCase();
const password = req.body.password || "";
if (!username || !password) return invalidCredentials();
const user = await DB.getUser(username);
if (!user) {
client_logger.capture({
distinctId: 'app_level',
event: 'server@' + req.method + '@' + req.originalUrl + '@userNotFound',
properties: {
username: username,
}
event: 'server@' + req.method + '@' + req.originalUrl + '@invalidCredentials',
properties: { username },
});
return res.json({ status: "user not founded" });
}
// TODO: Also add salt parameter here.
const isSamePassword = await bcrypt.compare(password, user.password);
if (!isSamePassword) return res.json({ status: "incorrect password" });
const isSamePassword = await bcrypt.compare(password, user?.password || DUMMY_BCRYPT_HASH);
if (!user || !isSamePassword) return invalidCredentials();
try {
// Store a new session loging on DB, and use ID as session ID
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
});
return res.json(await createSessionFromUser({ DB, user, req, res }));
} catch (error) {
console.error(error);
client_logger.capture({
@@ -136,8 +144,9 @@ const logout = async function (req, res) {
const session_id = getSessionId(req);
const user_sid = getUserId(req);
if (session_id && user_sid) {
res.clearCookie('session_id');
res.clearCookie('user_sid');
const cookiesOptions = getCookiesOptions(req);
res.clearCookie('session_id', cookiesOptions);
res.clearCookie('user_sid', cookiesOptions);
//remove from DB
const DB = await MongoDB.getDB;
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 session_id = getSessionId(req);
const user_sid = getUserId(req);
const DB = await MongoDB.getDB;
if (session_id && user_sid) {
// Sadly reusing this endpoint to change password to legged in users.
// TODO: Move change password logic to its own endpoint.
const userInfo = await DB.checkSessionOnDB(session_id, user_sid);
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({
const genericResetResponse = {
status: "ok",
details: 'password changed!' // This should be an enum that syncs with clients.
});
}
}
details: "If the account exists, check your email for next steps"
};
// 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);
if (!user) return res.json({ status: "user not founded" });
const password = generatePassword();
const hashedPassword = await bcrypt.hash(password, 10);
// TODO: Add salt to password here as well.
// TODO: We need to limit this to every 2 hours or something like this.
// TODO: Move this template to the Notif file.
DB.resetUserPassword(username, hashedPassword);
Notifications.sendEmail(username, "Your new credentials",
if (!user) {
client_logger.capture({
distinctId: 'app_level',
event: 'server@' + req.method + '@' + req.originalUrl + '@resetRequestedUnknownUser',
properties: { username }
});
return res.json(genericResetResponse);
}
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> This is your new password: ${password}</p>
<p><a href="https://social.emmint.com/">Log in</a></p>
<p>Hello,</p>
<p>Use this one-time sign-in link to access your account:</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>Emmanuel International Ministries</p>
`)
`);
client_logger.capture({
distinctId: user._id,
event: 'server@' + req.method + '@' + req.originalUrl,
@@ -203,10 +204,30 @@ const resetPassword = async function (req, res) {
username: username,
}
});
return res.json({
status: "ok",
details: 'Check your email for new password' // Enum of details?
});
return res.json(genericResetResponse);
}
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,
logout,
resetPassword,
loginWithPasswordToken,
}

View File

@@ -1,8 +1,49 @@
const cookiesOptions = {
maxAge: 1000 * 60 * 60 * 24 * 90, // would expire after 30 days
httpOnly: true, // The cookie only accessible by the web server
sameSite: 'none', // This and secure are required for properly
secure: true, // manage cockies in cros-domain
const isProduction = process.env.NODE_ENV === "production";
const forceSecureCookie = process.env.COOKIE_SECURE === "true";
const COOKIE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 90; // 90 days
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,8 +1,13 @@
var corsOptions = {
origin: [
'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',
"https://social.emmint.com",
"https://www.social.emmint.com",
"https://fellowship.emmint.com",
"https://aeropi.local",
],

69
dbTools/chat.js Normal file
View File

@@ -0,0 +1,69 @@
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.getChatParticipants = async () => {
return DB.chatMessagesCol.distinct("senderProfileId").catch((err) => {
console.log(err);
return [];
});
};
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

@@ -38,6 +38,20 @@ postDB = (DB)=>{
});
}
DB.addTranslation = (postid, lang, translatedText) => {
if(!DB.ObjectID.isValid(postid)) return false;
const id = DB.ObjectID(postid);
let update = {
$set:{
["translations." + lang]: translatedText
}
}
return DB.postCols.updateOne({_id: id}, update).catch((err)=>{
console.log(err);
return false;
});
}
DB.newReaction = (postid, profileid, reaction) => {
if(!DB.ObjectID.isValid(postid)) return false;
const id = DB.ObjectID(postid);
@@ -233,7 +247,7 @@ postDB = (DB)=>{
if(!DB.ObjectID.isValid(profileId)) return [];
const profile = await DB.getProfile(profileId);
if(!profile) return [];
query = {
const query = {
nonOrganicType: null // Exlcude news
};
return DB.postCols.find(query).sort({lastUpdated: -1}).limit(50).toArray().then(async (posts)=>{
@@ -244,6 +258,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 () => {
let query = {
nonOrganicType: 'News'

View File

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

View File

@@ -1,15 +1,15 @@
class User {
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.profile = {
firstName: info.profile && info.profile.firstName || '',
lastName: info.profile && info.profile.lastName || '',
photo: info.profile && info.profile.photo || '',
location: info.profile && info.profile.location || 'USA',
language: info.profile && info.profile.language || 'en',
status: info.profile && info.profile.status || '',
description: info.profile && info.profile.description || '',
firstName: info.profile?.firstName || '',
lastName: info.profile?.lastName || '',
photo: info.profile?.photo || '',
location: info.profile?.location || 'USA',
language: info.profile?.language || 'en',
status: info.profile?.status || '',
description: info.profile?.description || '',
};
this.data = info.data || {};
this.username = info.username || '';
@@ -23,8 +23,8 @@ class User {
this.isCourse = info.isCourse || false;
this.isPrivate = info.isPrivate || false;
this.isChat = info.isChat || false;
this.subscribed = info.subscribed || {}; //Subscribed user to groups
this.pending = info.pending || {}; //Private groups require authorization
this.subscribed = JSON.parse(JSON.stringify(info.subscribed || {})); //Subscribed user to groups
this.pending = JSON.parse(JSON.stringify(info.pending || {})); //Private groups require authorization
}
toObj(){

337
index.js
View File

@@ -9,6 +9,7 @@ require('dotenv').config();
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.set('trust proxy', true);
// -- Accept request from other origins
const cors = require('cors');
const { corsOptions } = require('./config/corsOptions');
@@ -34,15 +35,147 @@ const limiter = rateLimit({
return ip.includes(":") ? ip.split(":")[0] : ip; // Remove port if present
}
});
app.set('trust proxy', true);
app.use(limiter);
// Authentication
const { signup, login, logout, resetPassword } = require('./auth/authEmail.js');
app.route('/signup').get(signup).post(signup);
app.route('/login').get(login).post(login);
const { signup, login, logout, resetPassword, loginWithPasswordToken } = require('./auth/authEmail.js');
const { authRateLimiter } = require('./middleware/authRateLimiter');
/**
* @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.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
const profileRoute = require('./routes/profile.js');
@@ -50,17 +183,57 @@ const postRoute = require('./routes/post.js');
const songsRoute = require('./routes/songs.js');
const paymentsRoute = require('./routes/payments.js');
const bibleRoute = require('./routes/bible.js');
const chatRoute = require('./routes/chat.js');
const liveCaptionsRoute = require('./routes/liveCaptions.js');
const sessionChecker = require('./middleware/sessionChecker');
// -- Private Routes
app.use('/user', sessionChecker, profileRoute);
app.use('/post', sessionChecker, postRoute);
app.use('/payments', sessionChecker, paymentsRoute);
app.use('/payments', paymentsRoute);
app.use('/bible', sessionChecker, bibleRoute);
app.use('/songs', sessionChecker, songsRoute);
app.use('/chat', sessionChecker, chatRoute);
app.use('/live-captions', liveCaptionsRoute);
// -- Public Routes
const subsplashRoute = require('./routes/subsplash.js');
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
const webPush = require('web-push');
const publicVapidKey = process.env.PUBLIC_VAPID_KEY;
@@ -69,7 +242,7 @@ const webPushEmail = process.env.WEB_PUSH_EMAIL;
webPush.setVapidDetails('mailto:' + webPushEmail, publicVapidKey, privateVapidKey);
const { cookiesOptions } = require('./config/cookiesOptions');
const { getCookiesOptions } = require('./config/cookiesOptions');
const { client_logger } = require('./utils/analyticsLogger.js');
const { getSessionId, getUserId, getProfileId } = require('./utils/sessionUtils.js');
@@ -78,7 +251,31 @@ const DB = require("./mongoDB.js");
DB.getDB.then((DB) => {
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) => {
try {
const userInfo = req.userInfo;
@@ -97,7 +294,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) => {
try {
const email = req.params.email.trim().toLowerCase();
@@ -130,7 +358,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) => {
try {
const user_sid = getUserId(req);
@@ -149,7 +412,7 @@ DB.getDB.then((DB) => {
return res.status(403).json({ status: "Profile does not belong to the logged-in user" });
}
// 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 });
} catch (error) {
console.error("Error changing profile:", error);
@@ -157,7 +420,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) => {
try {
const profileid = getProfileId(req);
@@ -179,7 +473,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) => {
const subscription = req.body;
res.status(201).json({});

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,37 +1,61 @@
const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils');
const { client_logger } = require('../utils/analyticsLogger');
const { cookiesOptions } = require('../config/cookiesOptions');
const { getCookiesOptions } = require('../config/cookiesOptions');
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) => {
try {
const session_id = getSessionId(req);
const user_sid = getUserId(req);
let profile_id = getProfileId(req);
if (session_id && user_sid) {
DB = await MongoDB.getDB;
if (!session_id || !user_sid) {
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);
req.userInfo = userInfo;
if (!await DB.getProfileCache(profile_id)) {
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;
}
req.profileInfo = { _id: profile_id };
if (!userInfo) return res.redirect('/login');
if (!userInfo) return rejectUnauthorized(req, res);
// Log Request
client_logger.capture({
distinctId: user_sid,
event: 'server@' + req.method + '@' + req.originalUrl,
});
next();
} else {
return res.redirect('/login');
} catch (error) {
console.error("Session checker error", error);
return rejectUnauthorized(req, res);
}
};

View File

@@ -9,21 +9,42 @@ const postDB = require("./dbTools/post.js");
const profileDB = require("./dbTools/profile.js");
const paymentDB = require("./dbTools/payments.js");
const songsDB = require("./dbTools/songs.js");
const chatDB = require("./dbTools/chat.js");
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 DB = {ObjectID: mongo.ObjectID};
MongoClient.connect(mongoUrl, function(err, db) {
MongoClient.connect(mongoUrl, mongoConnectOptions, function(err, db) {
if (err) return reject(err);
console.log("Connected to DB!");
DB.db = db;
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.tokensCol = db.db(DBName).collection("tokens");
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)=>{
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});
if(doc && doc.uid == 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 false;
@@ -61,6 +82,42 @@ const getDB = new Promise((resolve, reject) => {
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 = {}
DB.getUsernameByIdCache = async (userid)=>{
if(!userid) return {};
@@ -121,6 +178,7 @@ const getDB = new Promise((resolve, reject) => {
profileDB(DB);
paymentDB(DB);
songsDB(DB);
chatDB(DB);
resolve(DB);
});

View File

@@ -347,7 +347,7 @@ const Notifications = {
const bookedProfile = subscribed[index];
if (bookedProfile._id == senderProfile._id) return 0;
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);
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
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);
return youGotANewPostCommentTemplate(post, userEmail, postProfile, senderProfile, message);
},
@@ -376,7 +376,7 @@ const Notifications = {
subscribed.forEach((bookedProfile) => {
if (bookedProfile._id == senderProfile._id) return 0;
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);
});
},
@@ -391,7 +391,7 @@ const Notifications = {
}
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`;
sendPushNotification(postProfile.token, notifBody, {});
sendPushNotification(postProfile.token, notifBody, {post_id: post._id});
DB.addNotification(post.profileid, notifBody, postId, null, senderProfile._id);
return 0;
},
@@ -411,11 +411,11 @@ const Notifications = {
if (userProfile._id == senderProfile._id) return 0; //avoid sending self notifications
if(groupProfile._id == senderProfile._id){
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);
}
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);
// Disabling email notifications for now, until settings are implemented
// yourGroupGotANewPostTemplate(groupProfile, userEmail, userProfile, senderProfile, message);
@@ -437,7 +437,7 @@ const Notifications = {
return this.broadcastNews(post, emails);
}
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);
DB.addNotification(toProfileId, notifBody, post._id, null, senderProfile._id);
return youGotANewPostTemplate(profile, user.username, senderProfile, message);
@@ -461,7 +461,7 @@ const Notifications = {
const userProfile = subscribed_profiles[index];
if (userProfile._id == whoPostedId._id) return 0;
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);
DB.addNotification(userProfile._id, notifBody, post._id, null, profile._id);
});
@@ -489,6 +489,26 @@ const Notifications = {
// sendWebNotification(requesterProfile.webSubscription, notifBody);
DB.addNotification(requesterProfile, notifBody, null, null, groupProfile._id);
},
async youGotANewChatMessage(senderProfileId, messageText) {
const DB = await DBGetter.getDB;
const participants = await DB.getChatParticipants();
const senderProfile = await DB.getProfileCache(senderProfileId);
const tokens = [];
for (const participantProfileId of participants) {
if (participantProfileId.toString() === senderProfileId.toString()) continue;
const participantProfile = await DB.getProfileCache(participantProfileId);
if (participantProfile && Array.isArray(participantProfile.token)) {
tokens.push(...participantProfile.token);
}
}
if (tokens.length > 0) {
const notifBody = `${senderProfile.profile.firstName}: ${messageText.substring(0, 100)}${messageText.length > 100 ? '...' : ''}`;
sendPushNotification(tokens, notifBody, { type: 'chat' });
}
},
}

433
package-lock.json generated
View File

@@ -26,6 +26,8 @@
"posthog-node": "^4.4.1",
"socket.io": "^4.6.1",
"stripe": "^8.178.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"web-push": "^3.4.5"
},
"devDependencies": {
@@ -34,6 +36,50 @@
"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": {
"version": "8.0.2",
"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"
}
},
"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": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
@@ -167,6 +219,13 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
@@ -185,6 +244,12 @@
"@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": {
"version": "16.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz",
@@ -305,7 +370,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-flatten": {
@@ -516,6 +580,12 @@
"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": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -757,6 +827,15 @@
"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": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@@ -983,6 +1062,18 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -1221,6 +1312,15 @@
"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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -1884,7 +1984,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -1928,6 +2027,26 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -2421,6 +2540,13 @@
"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": {
"version": "1.0.3",
"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"
}
},
"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": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
@@ -3326,6 +3529,15 @@
"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": {
"version": "1.1.2",
"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",
"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": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -3668,9 +3889,73 @@
"funding": {
"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": {
"@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": {
"version": "8.0.2",
"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": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
@@ -3759,6 +4049,11 @@
"dev": 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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
@@ -3777,6 +4072,11 @@
"@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": {
"version": "16.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz",
@@ -3866,8 +4166,7 @@
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"array-flatten": {
"version": "1.1.1",
@@ -4032,6 +4331,11 @@
"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": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -4199,6 +4503,11 @@
"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": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@@ -4361,6 +4670,14 @@
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -4525,6 +4842,11 @@
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4979,7 +5301,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"requires": {
"argparse": "^2.0.1"
}
@@ -5012,6 +5333,21 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -5346,6 +5682,12 @@
"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": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz",
@@ -5940,6 +6282,58 @@
"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": {
"version": "6.1.0",
"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",
"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": {
"version": "1.1.2",
"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",
"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": {
"version": "17.7.2",
"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",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"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

@@ -6,6 +6,7 @@
"scripts": {
"test": "npx mocha test/auth.test.js",
"start": "node index.js",
"live-captions:test-sender": "node scripts/liveCaptionsTestSender.js",
"docker": "docker compose up -d",
"docker_restore": "docker-compose exec mongo mongorestore --db EMI_SOCIAL /dump/EMI_SOCIAL/",
"docker_dump": "docker-compose exec mongo mongodump --uri ${MONGO_URL} --out /dump"
@@ -30,6 +31,8 @@
"posthog-node": "^4.4.1",
"socket.io": "^4.6.1",
"stripe": "^8.178.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"web-push": "^3.4.5"
},
"devDependencies": {

View File

@@ -13,11 +13,47 @@ const defaultBibleId = "592420522e16049f-01";
//getMedia('y42zyf3').then(console.log)
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) => {
const bibles = await fetchAPI('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) => {
const bibleId = req.query.bibleId || defaultBibleId;
const bibles = await fetchAPI('bibles/' + bibleId +"/books");
@@ -30,12 +66,56 @@ DB.getDB.then((DB) => {
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) => {
const bookId = req.params.bookId;
const bibles = await fetchAPI('bibles/' + bibleId +"/books/" + bookId);
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) => {
const bookId = req.params.bookId;
const bibleId = req.query.bibleId || defaultBibleId;
@@ -43,13 +123,58 @@ DB.getDB.then((DB) => {
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) => {
const chapterId = req.params.chapterId;
const bibleId = req.query.bibleId || defaultBibleId;
const bibles = await fetchAPI('bibles/' + bibleId + "/chapters/" + chapterId);
const contentType = req.query['content-type'] ? `?content-type=${req.query['content-type']}` : '';
const bibles = await fetchAPI('bibles/' + bibleId + "/chapters/" + chapterId + contentType);
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) => {
const chapterId = req.params.chapterId;
const bibleId = req.query.bibleId || defaultBibleId;
@@ -57,6 +182,33 @@ DB.getDB.then((DB) => {
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) => {
const query = req.query.query;
const limit = req.query.limit || 10;

241
routes/chat.js Normal file
View File

@@ -0,0 +1,241 @@
var express = require('express');
var router = express.Router();
const DB = require("../mongoDB.js");
const Notifications = require("../notifications.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" });
}
Notifications.youGotANewChatMessage(profileId, text);
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;

158
routes/liveCaptions.js Normal file
View File

@@ -0,0 +1,158 @@
var express = require('express');
var router = express.Router();
const sessionChecker = require("../middleware/sessionChecker.js");
const MAX_BUFFER_SIZE = 300;
const DEFAULT_INITIAL_LIMIT = 40;
const MAX_INITIAL_LIMIT = 120;
const CAPTION_META_KEYS = new Set(["sequence", "createdAt", "original"]);
const liveCaptionState = {
startedAt: Date.now(),
latestSequence: 0,
captions: [],
};
const normalizeLang = (lang = "") => {
const value = String(lang || "").trim().toLowerCase();
if (!value) return "";
const base = value.split(",")[0].split("-")[0].trim();
return base || value;
};
const normalizeTranslations = (translations) => {
if (!translations || typeof translations !== "object" || Array.isArray(translations)) return {};
const normalized = {};
for (const [langKey, translatedText] of Object.entries(translations)) {
const lang = normalizeLang(langKey);
const text = typeof translatedText === "string" ? translatedText.trim() : "";
if (!lang || !text) continue;
normalized[lang] = text;
}
return normalized;
};
const buildTranslationsFromFlatPayload = (payload) => {
const ignoredKeys = new Set(["original", "sourceLang", "translations"]);
const normalized = {};
for (const [key, value] of Object.entries(payload || {})) {
if (ignoredKeys.has(key)) continue;
const lang = normalizeLang(key);
const text = typeof value === "string" ? value.trim() : "";
if (!lang || !text) continue;
normalized[lang] = text;
}
return normalized;
};
const inferSourceLangFromTranslations = (original, translations) => {
const normalizedOriginal = String(original || "").trim();
if (!normalizedOriginal) return "original";
for (const [lang, text] of Object.entries(translations || {})) {
if (String(text || "").trim() === normalizedOriginal) return normalizeLang(lang);
}
return "original";
};
const getAvailableLanguages = () => {
const langs = new Set();
for (const caption of liveCaptionState.captions) {
Object.keys(caption || {}).forEach((key) => {
if (CAPTION_META_KEYS.has(key)) return;
const lang = normalizeLang(key);
if (lang) langs.add(lang);
});
}
return Array.from(langs).filter(Boolean).sort();
};
router.get("/stream", sessionChecker, async (req, res) => {
try {
const sinceSequence = Number.parseInt(req.query?.sinceSequence, 10);
const requestedLimit = Number.parseInt(req.query?.limit, 10);
const initialLimit = Number.isFinite(requestedLimit)
? Math.max(1, Math.min(requestedLimit, MAX_INITIAL_LIMIT))
: DEFAULT_INITIAL_LIMIT;
let captions = [];
if (Number.isFinite(sinceSequence) && sinceSequence >= 0) {
captions = liveCaptionState.captions.filter((item) => item.sequence > sinceSequence);
} else {
captions = liveCaptionState.captions.slice(-initialLimit);
}
return res.json({
status: "ok",
latestSequence: liveCaptionState.latestSequence,
startedAt: new Date(liveCaptionState.startedAt).toISOString(),
availableLanguages: getAvailableLanguages(),
captions,
});
} catch (error) {
console.error("Error getting live captions stream", error);
return res.status(500).json({
status: "Internal server error",
latestSequence: liveCaptionState.latestSequence,
captions: [],
availableLanguages: [],
});
}
});
router.post("/ingest", async (req, res) => {
try {
// TODO: Add basic auth/API key validation before production roll-out.
const original = typeof req.body?.original === "string" ? req.body.original.trim() : "";
const mapFromNested = normalizeTranslations(req.body?.translations);
const mapFromFlat = buildTranslationsFromFlatPayload(req.body);
const translations = { ...mapFromNested, ...mapFromFlat };
const sourceLang = normalizeLang(req.body?.sourceLang || inferSourceLangFromTranslations(original, translations));
if (!original) {
return res.status(400).json({ status: "Original text is required" });
}
if (sourceLang && sourceLang !== "original" && !translations[sourceLang]) {
translations[sourceLang] = original;
}
const sequence = liveCaptionState.latestSequence + 1;
const caption = {
sequence,
createdAt: new Date().toISOString(),
original,
...translations,
};
liveCaptionState.latestSequence = sequence;
liveCaptionState.captions.push(caption);
if (liveCaptionState.captions.length > MAX_BUFFER_SIZE) {
liveCaptionState.captions.splice(0, liveCaptionState.captions.length - MAX_BUFFER_SIZE);
}
return res.json({
status: "ok",
caption,
latestSequence: liveCaptionState.latestSequence,
availableLanguages: getAvailableLanguages(),
});
} catch (error) {
console.error("Error ingesting live captions", error);
return res.status(500).json({ status: "Internal server error" });
}
});
router.post("/reset", async (_, res) => {
try {
// TODO: Add admin authorization before exposing this endpoint.
liveCaptionState.startedAt = Date.now();
liveCaptionState.latestSequence = 0;
liveCaptionState.captions = [];
return res.json({ status: "ok" });
} catch (error) {
console.error("Error resetting live captions state", error);
return res.status(500).json({ status: "Internal server error" });
}
});
module.exports = router;

View File

@@ -2,6 +2,7 @@ var express = require('express');
var router = express.Router();
const DB = require("../mongoDB.js");
const mongo = require('mongodb');
//const Payments = require("../payments.js");
const Stripe = require('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) => {
const userid = req.body.userid;
const price = req.body.price || 500;
@@ -52,6 +60,15 @@ DB.getDB.then((DB) => {
],
});
// check if user is email or userid
const isUserId = mongo.ObjectId.isValid(userid);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isEmail = emailRegex.test(userid.trim().toLowerCase());
console.log("isUserId: ", isUserId);
console.log("isEmail: ", isEmail);
console.log("userid: ", userid);
if (isUserId) {
//Register in DB
const intent = {
paymentIntent,
@@ -67,12 +84,93 @@ DB.getDB.then((DB) => {
email: await DB.getUsernameByIdCache(userid),
price
});
}
if (isEmail) {
//Register in DB
return res.send({
clientSecret: paymentIntent.client_secret,
email: userid,
price
});
}
return res.send({
clientSecret: paymentIntent.client_secret,
email: 'guess',
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);
/**
* @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);
/**
* @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) => {
const userid = req.body.userid;
const result = req.body.result;
@@ -84,7 +182,7 @@ DB.getDB.then((DB) => {
};
//console.log(payment);
const intent = await DB.getIntent(result.client_secret);
if(intent.description === "Subscription 1 Month"){
if (intent.description === "Subscription 1 Month") {
//update profile subscription status
const profileid = getProfileId(req);
const isSubscriptor = await DB.isSubscriptor(profileid);

View File

@@ -4,11 +4,15 @@ var router = express.Router();
const DB = require("./../mongoDB.js");
const Post = require("./../def/post.js");
const Notifications = require("./../notifications.js");
const { translateText, normalizeLanguageCode } = require("../utils/chatTranslation.js");
DB.getDB.then((DB) => {
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) => {
@@ -77,24 +81,193 @@ DB.getDB.then((DB) => {
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) => {
try {
const profileid = getProfileId(req);
if (!profileid) return res.status(400).json([]);
let organicPosts = await DB.getFeed(profileid);
//Add non-organic posts
const nonOrganicPosts = await generateNonOrganicPosts(req, profileid);
const posts = mergePosts(organicPosts, nonOrganicPosts);
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) => {
try {
const profileid = getProfileId(req);
//Add non-organic posts
if (!profileid) return res.status(400).json([]);
const nonOrganicPosts = await generateNonOrganicPosts(req, profileid);
let promotionalPosts = await DB.getPromotionalPosts(profileid);
const posts = mergePosts(promotionalPosts, nonOrganicPosts);
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 tag = req.query.tag || req.params.tag;
if(!tag) {
console.log("Tag query empty: ", tag);
return res.json({
status: "Tag is required",
});
}
console.log("Tag query: ", tag);
let posts = await DB.getPostsByTag('#' + tag, profileid);
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) => {
const profileId = req.params.id;
const viewerProdileId = getProfileId(req);
@@ -110,6 +283,35 @@ DB.getDB.then((DB) => {
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) => {
const profileid = req.params.id;
const viewerProfileId = getProfileId(req);
@@ -120,6 +322,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) => {
const profileid = req.params.id;
const viewerProfileId = getProfileId(req);
@@ -130,6 +361,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) => {
const profileid = req.params.id;
const viewerProfileId = getProfileId(req);
@@ -140,11 +400,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) => {
videoId = req.params.id;
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) => {
let post = {
profileid: getProfileId(req),
@@ -184,6 +482,68 @@ DB.getDB.then((DB) => {
})
});
router.post("/translate", async (req, res) => {
let postid = req.body.postid;
let targetLang = normalizeLanguageCode(req.body.targetLang);
// Return ack immediately
res.json({ status: "ok", message: "Translation queued" });
if (!postid || !targetLang) return;
try {
// Get post
const posts = await DB.getPostsByTag('', null); // No good way to get one post by ID directly exposed?
// Let's use dbCols directly if needed or find it. Wait, how do we get a single post?
// I'll assume DB.getPost exists, let me check that later. Actually I will use DB.postCols directly.
const post = await DB.postCols.findOne({ _id: DB.ObjectID(postid) });
if (!post || !post.content) return;
// Strip inline tags and bible tags before translating to reduce token usage and confusion,
// or just translate the raw content and let the AI handle it? The chat translator prompt says:
// "You translate chat messages. Keep meaning, tone, emojis, names, and references. Return only the translated text."
// So it can handle tags.
// To avoid huge translations or mostly-media posts
if (post.content.length > 1000) return;
if (post.translations && post.translations[targetLang]) return;
const translation = await translateText({
text: post.content,
sourceLang: "auto",
targetLang: targetLang
});
if (translation && translation.translatedText) {
await DB.addTranslation(postid, targetLang, translation.translatedText);
}
} catch (error) {
console.error("Error in background post translation", error);
}
});
/**
* @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) => {
let profileid = getProfileId(req);
let postid = req.body.postid;
@@ -198,6 +558,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) => {
let profileid = getProfileId(req);
let postid = req.body.postid;
@@ -207,6 +588,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) => {
let profileid = getProfileId(req);
let postid = req.body.postid;
@@ -216,6 +618,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) => {
let profileid = getProfileId(req);
let postid = req.body.postid;
@@ -225,6 +648,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) => {
let profileid = getProfileId(req);
let postid = req.body.postid;
@@ -244,6 +690,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) => {
let userid = getProfileId(req);
let postid = req.body.postid;
@@ -258,6 +728,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) => {
let profileid = getProfileId(req);
let postid = req.body.postid;
@@ -268,6 +762,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) => {
const profileid = getProfileId(req);
const posts = await DB.getMediaTagPostOfUser(profileid, profileid);
@@ -277,6 +794,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) => {
const profileid = getProfileId(req);
const posts = await DB.getMediaTagPostOfUser(profileid, profileid, "@iframe:");
@@ -286,6 +826,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) => {
const profileid = getProfileId(req);
const posts = await DB.getMediaTagPostOfUser(profileid, profileid, "@youtube:|@vimeo:|@hls:");
@@ -295,6 +858,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) => {
const profileid = getProfileId(req);
const profile = await DB.getProfileCache(profileid);
@@ -342,6 +973,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) => {
const postId = req.params.id;
const post = await DB.getPost(postId);

View File

@@ -4,26 +4,112 @@ var router = express.Router()
const DB = require("../mongoDB.js");
const Profile = require("../def/profile.js");
const Notifications = require("./../notifications.js");
const { getSessionId, getUserId, getProfileId } = require("./../utils/sessionUtils.js");
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);
}
DB.getDB.then((DB) => {
const profileBelongsToUser = async (profileid, userid) => {
const profile = await DB.getProfileCache(profileid);
if(!profile) return false;
return profile.userid == (userid + '');
if (!profile) return false;
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) => {
let userid = req.cookies.user_sid;
let userid = getUserId(req);
let profiles = await DB.getUserProfiles(userid);
return res.json({
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("/")
let profile = {
userid: getUserId(req),
... req.query.content
...req.query.content
};
let profileObj = new Profile(profile);
let r = await DB.newProfile(profileObj);
return res.json({
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) => {
let profile = {
userid: getUserId(req),
... req.body.content
...req.body.content
};
try {
let profileObj = new Profile(profile);
let r = await DB.newProfile(profileObj);
return res.json({
status: "ok",
... profileObj.toObj()
...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);
} catch (error) {
console.error("Error creating profile", error);
return res.json({
status: "ok"
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) => {
const userid = getUserId(req);
const email = req.params.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);
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);
if(isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({status: "This user is already registered"});
return res.json({status: "ok", ... r});
if (isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({ status: "This user is already registered" });
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) => {
let groups = await DB.getGroups();
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) => {
const profileId = getProfileId(req);
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) => {
let profile = {
userid: getUserId(req),
isGroup: true,
... req.body
...req.body
};
let profileObj = new Profile(profile);
DB.newProfile(profileObj)
return res.json({
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) => {
let groups = await DB.getCourses();
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) => {
//This function should be called to accept the join request
//of an user that attempt to join a private group.
const groupid = getProfileId(req); //It needs to have this profile context
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({
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) => {
//This function should be called to reject the join request
//of an user that attempt to join a private group.
const groupid = getProfileId(req); //It needs to have this profile context
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({
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) => {
let query = req.query.query;
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) => {
const groupid = req.params.id;
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) => {
const groupid = req.params.id;
const profileid = getProfileId(req);
const isPrivate = await DB.isGroupPrivate(groupid);
DB.subscribeToGroup(profileid, groupid, isPrivate);
//Add notification to group owner
if(isPrivate) Notifications.yourGroupHasARequest(profileid, groupid)
if (isPrivate) Notifications.yourGroupHasARequest(profileid, groupid)
return res.json({
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) => {
const groupid = req.params.id;
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) => {
let query = req.query.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) => {
const key = req.body.key;
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) => {
try {
let profile = {
userid: getUserId(req),
profile: req.body.profile,
data: req.body.data
};
let profileObj = new Profile(profile); //validates profile
DB.updateProfile(getProfileId(req), profileObj);
const updateRes = await DB.updateProfile(getProfileId(req), profileObj);
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) => {
try {
let profileId = req.params.id;
let profile = await DB.getProfile(profileId);
if (!profile || !profile._id) {
return res.status(404).json({
status: "Profile not found",
});
}
return res.json({
status: "ok",
... profile
...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) => {
const profileId = req.params.id;
const userid = getUserId(req);
if(!await profileBelongsToUser(profileId, userid))
if (!await profileBelongsToUser(profileId, userid))
return res.json({
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) => {
let followProfileId = req.params.id;
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) => {
let followProfileId = req.params.id;
const profileid = getProfileId(req);

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);
}
/**
* @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) => {
let profileId = req.params.id;
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) => {
let post = {
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) => {
let profileId = req.params.id;
let profile = await DB.getProfile(profileId);
@@ -53,6 +108,24 @@ DB.getDB.then((DB)=>{
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) => {
const userid = getUserId(req);
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) => {
const userid = getUserId(req);
const songId = req.params.id;

View File

@@ -67,12 +67,51 @@ const getMedia = async (eventId) => {
};
//getMedia('y42zyf3').then(console.log)
/**
* @swagger
* tags:
* name: Subsplash
* description: Subsplash API integration
*/
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) => {
const events = await getEvents(req.params.calendarId)
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) => {
const events = await getMedia(req.params.seriesId)
return res.json(events);

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
require("dotenv").config();
const axios = require("axios");
const baseUrl = (process.env.CAPTION_TEST_BASE_URL || process.env.BASE_URL || "http://localhost:3000").replace(/\/+$/, "");
const ingestUrl = `${baseUrl}/live-captions/ingest`;
const intervalMs = 5000;
const samples = [
{
original: "Bienvenidos a nuestro servicio de adoracion.",
es: "Bienvenidos a nuestro servicio de adoracion.",
en: "Welcome to our worship service.",
fr: "Bienvenue a notre service de louange.",
},
{
original: "Leamos juntos en el Salmo 23.",
es: "Leamos juntos en el Salmo 23.",
en: "Let us read together in Psalm 23.",
fr: "Lisons ensemble le Psaume 23.",
},
{
original: "Dios es fiel en todo tiempo.",
es: "Dios es fiel en todo tiempo.",
en: "God is faithful at all times.",
fr: "Dieu est fidele en tout temps.",
},
{
original: "Tomemos un momento para orar.",
es: "Tomemos un momento para orar.",
en: "Let us take a moment to pray.",
fr: "Prenons un moment pour prier.",
},
];
let sampleIndex = 0;
let timer = null;
const sendNextSample = async () => {
const payload = samples[sampleIndex];
sampleIndex = (sampleIndex + 1) % samples.length;
try {
const response = await axios.post(ingestUrl, payload, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
const seq = response?.data?.caption?.sequence || response?.data?.latestSequence || "?";
console.log(`[live-captions:test-sender] sent sequence=${seq} original="${payload.original}"`);
} catch (error) {
const status = error?.response?.status;
const body = error?.response?.data;
const message = error?.message || "request failed";
console.error("[live-captions:test-sender] send failed", { status, body, message });
}
};
const start = async () => {
console.log(`[live-captions:test-sender] posting to ${ingestUrl} every ${intervalMs / 1000}s`);
await sendNextSample();
timer = setInterval(sendNextSample, intervalMs);
};
const shutdown = () => {
if (timer) clearInterval(timer);
console.log("[live-captions:test-sender] stopped");
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
start();

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 and posts. Keep meaning, tone, emojis, names, and references. Do not translate structural tags starting with @ (e.g. @image:..., @youtube:..., @bible:...). Leave them exactly as they are or omit them if they do not fit the text flow. 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
const getSessionId = function (req) {
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;
}
const getUserId = function (req) {
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;
}
const getProfileId = function (req) {
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;
}