515 lines
16 KiB
JavaScript
515 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 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);
|
|
// -- 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 { cookiesOptions } = 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, cookiesOptions);
|
|
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 };
|