Compare commits
24 Commits
codex/fix-
...
3737edab72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3737edab72 | ||
|
|
5195317c0c | ||
|
|
8aa1f3addd | ||
| af4471b463 | |||
|
|
f0afa200b1 | ||
|
|
503c5ef1f4 | ||
|
|
e8dd905f27 | ||
|
|
c5fd09d71d | ||
|
|
989fdce883 | ||
|
|
fd82643477 | ||
| 93d5b6b5f3 | |||
|
|
83727957ab | ||
| a8ddae4b1e | |||
|
|
77134b6bab | ||
|
|
d907aeecee | ||
|
|
1ca38ca3b9 | ||
|
|
e13678ad56 | ||
| bbc8c36439 | |||
|
|
f3a782a360 | ||
|
|
19d805d322 | ||
|
|
469962d03c | ||
|
|
c6d9dfd3c1 | ||
|
|
0baf237548 | ||
| 822e2bc0d6 |
100
Agent.md
Normal file
100
Agent.md
Normal 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.
|
||||
|
||||
@@ -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
|
||||
@@ -79,47 +111,23 @@ const login = async function (req, res) {
|
||||
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 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({
|
||||
status: "ok",
|
||||
details: 'password changed!' // This should be an enum that syncs with clients.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const genericResetResponse = {
|
||||
status: "ok",
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const forceSecureCookie = process.env.COOKIE_SECURE === "true";
|
||||
const secure = forceSecureCookie || isProduction;
|
||||
|
||||
const cookiesOptions = {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 90, // would expire after 90 days
|
||||
httpOnly: true, // The cookie only accessible by the web server
|
||||
sameSite: secure ? 'none' : 'lax',
|
||||
secure,
|
||||
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 };
|
||||
|
||||
@@ -7,6 +7,7 @@ var corsOptions = {
|
||||
'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
69
dbTools/chat.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +36,7 @@ userDB = (DB) => {
|
||||
console.log(err);
|
||||
return false;
|
||||
});
|
||||
if (userProfileCache[profileid]) delete userProfileCache[profileid];
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -281,13 +284,36 @@ userDB = (DB) => {
|
||||
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) => {
|
||||
|
||||
46
index.js
46
index.js
@@ -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');
|
||||
@@ -28,17 +29,18 @@ const limiter = rateLimit({
|
||||
limit: 500, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
|
||||
standardHeaders: 'draft-8', // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
|
||||
skip: (req) => req.path.startsWith("/live-captions"),
|
||||
keyGenerator: (req) => {
|
||||
const forwarded = req.headers["x-forwarded-for"]?.split(",")[0]; // Take the first IP in the list
|
||||
const ip = forwarded || req.ip; // Fallback to req.ip
|
||||
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');
|
||||
const { signup, login, logout, resetPassword, loginWithPasswordToken } = require('./auth/authEmail.js');
|
||||
const { authRateLimiter } = require('./middleware/authRateLimiter');
|
||||
/**
|
||||
* @swagger
|
||||
* /signup:
|
||||
@@ -71,7 +73,7 @@ const { signup, login, logout, resetPassword } = require('./auth/authEmail.js');
|
||||
* 400:
|
||||
* description: Bad request.
|
||||
*/
|
||||
app.route('/signup').get(signup).post(signup);
|
||||
app.post('/signup', signup);
|
||||
/**
|
||||
* @swagger
|
||||
* /login:
|
||||
@@ -104,7 +106,7 @@ app.route('/signup').get(signup).post(signup);
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
*/
|
||||
app.route('/login').get(login).post(login);
|
||||
app.post('/login', authRateLimiter('login'), login);
|
||||
/**
|
||||
* @swagger
|
||||
* /logout:
|
||||
@@ -127,7 +129,7 @@ app.get('/logout', logout);
|
||||
* @swagger
|
||||
* /resetPassword:
|
||||
* post:
|
||||
* summary: Resets a user's password
|
||||
* summary: Sends a one-time sign-in link if the account exists
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
@@ -152,7 +154,29 @@ app.get('/logout', logout);
|
||||
* 400:
|
||||
* description: Bad request.
|
||||
*/
|
||||
app.route('/resetPassword').post(resetPassword);
|
||||
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');
|
||||
@@ -160,6 +184,8 @@ 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);
|
||||
@@ -167,6 +193,8 @@ app.use('/post', sessionChecker, postRoute);
|
||||
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);
|
||||
@@ -215,7 +243,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');
|
||||
|
||||
@@ -385,7 +413,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);
|
||||
@@ -486,4 +514,4 @@ DB.getDB.then((DB) => {
|
||||
});
|
||||
|
||||
// Export the app for testing purposes
|
||||
module.exports = { app, mongoDB: DB };
|
||||
module.exports = { app, mongoDB: DB };
|
||||
|
||||
116
middleware/authRateLimiter.js
Normal file
116
middleware/authRateLimiter.js
Normal 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,
|
||||
};
|
||||
@@ -1,9 +1,22 @@
|
||||
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);
|
||||
@@ -11,10 +24,10 @@ const sessionChecker = async (req, res, next) => {
|
||||
let profile_id = getProfileId(req);
|
||||
|
||||
if (!session_id || !user_sid) {
|
||||
return res.redirect('/login');
|
||||
return rejectUnauthorized(req, res);
|
||||
}
|
||||
if (!ObjectId.isValid(session_id) || !ObjectId.isValid(user_sid)) {
|
||||
return res.redirect('/login');
|
||||
return rejectUnauthorized(req, res);
|
||||
}
|
||||
|
||||
const DB = await MongoDB.getDB;
|
||||
@@ -24,15 +37,15 @@ const sessionChecker = async (req, res, next) => {
|
||||
if (!await DB.getProfileCache(profile_id)) {
|
||||
const latestProfile = await DB.latestProfile(user_sid);
|
||||
if (!latestProfile || !latestProfile._id) {
|
||||
return res.redirect('/login');
|
||||
return rejectUnauthorized(req, res);
|
||||
}
|
||||
res.cookie('profile_id', latestProfile._id, cookiesOptions);
|
||||
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);
|
||||
|
||||
client_logger.capture({
|
||||
distinctId: user_sid,
|
||||
@@ -42,7 +55,7 @@ const sessionChecker = async (req, res, next) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("Session checker error", error);
|
||||
return res.redirect('/login');
|
||||
return rejectUnauthorized(req, res);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
41
mongoDB.js
41
mongoDB.js
@@ -9,6 +9,7 @@ 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);
|
||||
@@ -41,6 +42,9 @@ const getDB = new Promise((resolve, reject) => {
|
||||
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);
|
||||
@@ -78,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 {};
|
||||
@@ -138,6 +178,7 @@ const getDB = new Promise((resolve, reject) => {
|
||||
profileDB(DB);
|
||||
paymentDB(DB);
|
||||
songsDB(DB);
|
||||
chatDB(DB);
|
||||
|
||||
resolve(DB);
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"test": "npx mocha test/auth.test.js",
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch 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"
|
||||
|
||||
@@ -148,7 +148,8 @@ DB.getDB.then((DB) => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
241
routes/chat.js
Normal file
241
routes/chat.js
Normal 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;
|
||||
213
routes/liveCaptions.js
Normal file
213
routes/liveCaptions.js
Normal file
@@ -0,0 +1,213 @@
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
const { rateLimit } = require("express-rate-limit");
|
||||
|
||||
const sessionChecker = require("../middleware/sessionChecker.js");
|
||||
|
||||
const MAX_BUFFER_SIZE = 300;
|
||||
const DEFAULT_INITIAL_LIMIT = 40;
|
||||
const MAX_INITIAL_LIMIT = 120;
|
||||
const INACTIVITY_RESET_MS = 10 * 60 * 1000;
|
||||
const CAPTION_META_KEYS = new Set(["sequence", "createdAt", "original", "draft", "sourceLang", "lang", "isDraft", "status", "translations"]);
|
||||
|
||||
const liveCaptionState = {
|
||||
startedAt: Date.now(),
|
||||
lastIngestAt: 0,
|
||||
latestSequence: 0,
|
||||
captions: [],
|
||||
};
|
||||
|
||||
const liveCaptionsLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
limit: 6000,
|
||||
standardHeaders: "draft-8",
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
const forwarded = req.headers["x-forwarded-for"]?.split(",")[0];
|
||||
const ip = forwarded || req.ip || "";
|
||||
return ip.includes(":") ? ip.split(":")[0] : ip;
|
||||
},
|
||||
});
|
||||
|
||||
router.use(liveCaptionsLimiter);
|
||||
|
||||
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 readText = (value) => {
|
||||
if (typeof value === "string") return value.trim();
|
||||
return "";
|
||||
};
|
||||
|
||||
const extractDraftText = (body = {}) => {
|
||||
const directDraft = readText(body?.draft);
|
||||
if (directDraft) return directDraft;
|
||||
const nestedDraft = readText(body?.draft?.text);
|
||||
if (nestedDraft) return nestedDraft;
|
||||
const fallbackText = readText(body?.text);
|
||||
if (fallbackText) return fallbackText;
|
||||
return "";
|
||||
};
|
||||
|
||||
const buildTranslationsFromFlatPayload = (payload) => {
|
||||
const ignoredKeys = new Set(["original", "draft", "sourceLang", "lang", "isDraft", "status", "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();
|
||||
};
|
||||
|
||||
const resetLiveCaptionState = () => {
|
||||
liveCaptionState.startedAt = Date.now();
|
||||
liveCaptionState.lastIngestAt = 0;
|
||||
liveCaptionState.latestSequence = 0;
|
||||
liveCaptionState.captions = [];
|
||||
};
|
||||
|
||||
const maybeResetForInactivity = () => {
|
||||
if (!liveCaptionState.lastIngestAt) return;
|
||||
if ((Date.now() - liveCaptionState.lastIngestAt) < INACTIVITY_RESET_MS) return;
|
||||
resetLiveCaptionState();
|
||||
};
|
||||
|
||||
router.get("/stream", sessionChecker, async (req, res) => {
|
||||
try {
|
||||
maybeResetForInactivity();
|
||||
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 draft = extractDraftText(req.body || {});
|
||||
const originalFromPayload = readText(req.body?.original);
|
||||
const original = originalFromPayload || draft;
|
||||
const requestedLang = normalizeLang(req.body?.lang);
|
||||
const sourceLangFromRequest = normalizeLang(req.body?.sourceLang || (requestedLang && requestedLang !== "draft" ? requestedLang : ""));
|
||||
const isDraft = !!draft || requestedLang === "draft" || sourceLangFromRequest === "draft" || req.body?.isDraft === true || req.body?.status === "draft";
|
||||
const mapFromNested = normalizeTranslations(req.body?.translations);
|
||||
const mapFromFlat = buildTranslationsFromFlatPayload(req.body);
|
||||
const translations = isDraft ? {} : { ...mapFromNested, ...mapFromFlat };
|
||||
const inferredSource = inferSourceLangFromTranslations(original, translations);
|
||||
const sourceLang = isDraft ? "" : (sourceLangFromRequest || inferredSource);
|
||||
|
||||
if (!original) {
|
||||
return res.status(400).json({ status: "Original text is required" });
|
||||
}
|
||||
if (sourceLang && sourceLang !== "original" && sourceLang !== "draft" && !translations[sourceLang]) {
|
||||
translations[sourceLang] = original;
|
||||
}
|
||||
|
||||
const sequence = liveCaptionState.latestSequence + 1;
|
||||
const caption = {
|
||||
sequence,
|
||||
createdAt: new Date().toISOString(),
|
||||
original,
|
||||
sourceLang: sourceLang || undefined,
|
||||
lang: isDraft ? "draft" : (sourceLang || undefined),
|
||||
isDraft,
|
||||
status: isDraft ? "draft" : "final",
|
||||
...translations,
|
||||
};
|
||||
|
||||
liveCaptionState.latestSequence = sequence;
|
||||
liveCaptionState.lastIngestAt = Date.now();
|
||||
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.
|
||||
resetLiveCaptionState();
|
||||
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;
|
||||
@@ -4,6 +4,7 @@ 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) => {
|
||||
|
||||
@@ -481,6 +482,47 @@ 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:
|
||||
|
||||
@@ -760,16 +760,48 @@ DB.getDB.then((DB) => {
|
||||
* type: string
|
||||
*/
|
||||
router.post("/myProfile", async (req, res) => {
|
||||
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);
|
||||
return res.json({
|
||||
status: "ok"
|
||||
});
|
||||
try {
|
||||
let profile = {
|
||||
userid: getUserId(req),
|
||||
profile: req.body.profile,
|
||||
data: req.body.data
|
||||
};
|
||||
let profileObj = new Profile(profile); //validates profile
|
||||
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"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
89
scripts/liveCaptionsTestSender.js
Normal file
89
scripts/liveCaptionsTestSender.js
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/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 = 6000;
|
||||
|
||||
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 postPayload = async (payload) => {
|
||||
const kind = payload?.draft ? "draft" : "final";
|
||||
|
||||
try {
|
||||
const response = await axios.post(ingestUrl, payload, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
const seq = response?.data?.caption?.sequence || response?.data?.latestSequence || "?";
|
||||
const text = payload?.draft || payload?.original || "";
|
||||
console.log(`[live-captions:test-sender] sent ${kind} sequence=${seq} text="${text}"`);
|
||||
} 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 sendNextSample = async () => {
|
||||
const payload = samples[sampleIndex];
|
||||
|
||||
const draftWords = String(payload?.original || "").split(" ").filter(Boolean);
|
||||
if (draftWords.length > 2) {
|
||||
await postPayload({ draft: draftWords.slice(0, 2).join(" ") });
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
await postPayload({ draft: draftWords.slice(0, 4).join(" ") });
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
}
|
||||
|
||||
await postPayload(payload);
|
||||
sampleIndex = (sampleIndex + 1) % samples.length;
|
||||
};
|
||||
|
||||
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
97
utils/chatTranslation.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user