Files
EMI-Backend/index.js
2026-02-26 22:31:00 -05:00

517 lines
16 KiB
JavaScript

// This is the backend main file for the Fellowship social app.
// It uses mongo as DB, and the connection url should be defined on the .env file
// Most of the API endpoints require authentication, which is checked by sessionChecker function.
// Load enviroment keys for stripe, mongo, vimeo, email
require('dotenv').config();
// Server Application using express
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');
app.use(cors(corsOptions));
// -- Parse incoming requests with JSON payloads
const bodyParser = require('body-parser');
app.use(bodyParser.json());
// -- Parse incoming requests with urlencoded payloads
app.use(bodyParser.urlencoded({ extended: true }));
// -- Parse cookies
const cookieParser = require('cookie-parser');
app.use(cookieParser());
// -- Rate limiting
const { rateLimit } = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 10 * 60 * 1000, // 15 minutes
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.
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.use(limiter);
// Authentication
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);
/**
* @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');
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', 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;
const privateVapidKey = process.env.PRIVATE_VAPID_KEY;
const webPushEmail = process.env.WEB_PUSH_EMAIL;
webPush.setVapidDetails('mailto:' + webPushEmail, publicVapidKey, privateVapidKey);
const { getCookiesOptions } = require('./config/cookiesOptions');
const { client_logger } = require('./utils/analyticsLogger.js');
const { getSessionId, getUserId, getProfileId } = require('./utils/sessionUtils.js');
// Only bind the port if the DB connection is successful
const DB = require("./mongoDB.js");
DB.getDB.then((DB) => {
console.log("Main logic: DB connected!");
/**
* @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;
if (!userInfo) {
// This should not happend, since the sessionChecker should redirect to login
return res.status(401).json({ status: "Unauthorized" });
}
return res.json({
status: "ok",
userInfo: req.userInfo,
profileInfo: req.profileInfo
});
} catch (error) {
console.error("Error processing / path", error);
return res.status(500).json({ status: "Internal server error" });
}
});
/**
* @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();
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ status: "Provide a valid email" });
}
// Check for an invitation
const invitation = await DB.getInvitation(email);
if (!invitation) {
return res.status(404).json({ status: "No invitation found for this email" });
}
// Check if the user is already registered
const existingUser = await DB.getUser(email);
if (existingUser?._id) {
return res.status(409).json({ status: "This user is already registered" });
}
// Capture event in logging
client_logger.capture({
distinctId: 'app_level',
event: `server@${req.method}@${req.originalUrl}`,
});
// Respond with invitation details
return res.json({ status: "ok", invitation });
} catch (error) {
console.error("Error processing invite check:", error);
return res.status(500).json({ status: "Internal server error" });
}
});
/**
* @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);
const profileId = req.body.profileid;
// Validate input
if (!profileId) {
return res.status(400).json({ status: "Profile ID is required" });
}
// Fetch profile from DB
const profile = await DB.getProfile(profileId);
if (!profile) {
return res.status(404).json({ status: "Profile does not exist" });
}
// Check ownership
if (profile.userid !== user_sid) {
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, getCookiesOptions(req));
return res.json({ status: "ok", profile });
} catch (error) {
console.error("Error changing profile:", error);
return res.status(500).json({ status: "Internal server error" });
}
});
/**
* @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);
const { token } = req.body;
// Validate token presence
if (!token) {
return res.status(400).json({ status: 'Token is required' });
}
// Set the token in the database and wait for completion
const result = await DB.setProfileToken(profileid, token);
// Assuming DB.setProfileToken() could fail, handle it appropriately
if (!result) {
return res.status(500).json({ status: 'Failed to update token' });
}
return res.json({ status: 'ok' });
} catch (error) {
console.error('Error setting profile token:', error);
return res.status(500).json({ status: 'Internal server error' });
}
});
/**
* @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({});
const profileid = getProfileId(req);
if (profileid) await DB.setWebSubscription(profileid, subscription);
});
// route for handling 404 requests(unavailable routes)
app.use(function (req, res, next) {
res.status(404).send("Sorry can't find that!")
});
app.listen(port, () => {
console.log(`Fellowship app running at http://localhost:${port}`);
}).on('error', (err) => {
console.error('Server failed to start:', err);
});
}).catch((err) => {
console.error('Error connecting to MongoDB:', err);
throw err;
});
// Export the app for testing purposes
module.exports = { app, mongoDB: DB };