UNPKG

backend.in

Version:

A npm module that could help node.js servers in one line of code

703 lines (702 loc) 26.3 kB
// src/index.ts import express from "express"; import mongoose, { Schema } from "mongoose"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import session from "express-session"; import multer from "multer"; import path from "path"; import helmet from "helmet"; import cors from "cors"; import morgan from "morgan"; import rateLimit from "express-rate-limit"; import nodemailer from "nodemailer"; import { createClient } from "redis"; import { v4 as uuidv4 } from "uuid"; import axios from "axios"; var ResetTokenSchema = new Schema({ userId: { type: Schema.Types.ObjectId, required: true, index: true }, token: { type: String, required: true, index: true }, expiresAt: { type: Date, required: true }, used: { type: Boolean, default: false } }); var ApiLogSchema = new Schema({ projectId: { type: String, required: true, index: true }, path: { type: String, required: true }, method: { type: String, required: true }, statusCode: { type: Number, required: true }, responseTime: { type: Number, required: true }, userId: { type: Schema.Types.ObjectId, index: true }, ipAddress: { type: String }, userAgent: { type: String }, timestamp: { type: Date, default: Date.now, index: true } }); var ProjectUsageSchema = new Schema({ projectId: { type: String, required: true, unique: true }, requests: { type: Number, default: 0 }, lastReset: { type: Date, default: Date.now }, users: { type: Number, default: 0 }, storage: { type: Number, default: 0 } // in bytes }); var ResetToken = mongoose.models.ResetToken || mongoose.model("ResetToken", ResetTokenSchema); var ApiLog = mongoose.models.ApiLog || mongoose.model("ApiLog", ApiLogSchema); var ProjectUsage = mongoose.models.ProjectUsage || mongoose.model("ProjectUsage", ProjectUsageSchema); var DB = null; var appInstance = null; var redisClient = null; var projectConfig = null; function defaultRoutes() { return { signup: "/auth/signup", login: "/auth/login", logout: "/auth/logout", me: "/auth/me", refresh: "/auth/refresh", update: "/auth/update", changePassword: "/auth/change-password", forgotPassword: "/auth/forgot-password", resetPassword: "/auth/reset-password", verifyEmail: "/auth/verify-email" }; } function signJwt(payload, secret, options) { return options ? jwt.sign(payload, secret, options) : jwt.sign(payload, secret); } function verifyJwt(token, secret) { return jwt.verify(token, secret); } async function validateProject(apiKey, secret, projectId, validationService) { if (!validationService) { const tierConfigs = { starter: { id: projectId, name: "Starter Project", tier: "starter", maxRoutes: 3, maxLibraries: 0, maxUsers: 100, maxRequestsPerMonth: 1e4, features: { database: false, advancedAuth: false, storage: false, email: false, redis: false, rateLimiting: true, realtime: false, analytics: false } }, basic: { id: projectId, name: "Basic Project", tier: "basic", maxRoutes: 10, maxLibraries: 3, maxUsers: 1e3, maxRequestsPerMonth: 5e4, features: { database: true, advancedAuth: false, storage: true, email: false, redis: false, rateLimiting: true, realtime: false, analytics: true } }, premium: { id: projectId, name: "Premium Project", tier: "premium", maxRoutes: 50, maxLibraries: 10, maxUsers: 1e4, maxRequestsPerMonth: 25e4, features: { database: true, advancedAuth: true, storage: true, email: true, redis: true, rateLimiting: true, realtime: true, analytics: true } }, advanced: { id: projectId, name: "Advanced Project", tier: "advanced", maxRoutes: Infinity, maxLibraries: Infinity, maxUsers: Infinity, maxRequestsPerMonth: Infinity, features: { database: true, advancedAuth: true, storage: true, email: true, redis: true, rateLimiting: true, realtime: true, analytics: true } } }; return tierConfigs[apiKey] || tierConfigs.starter; } try { const response = await axios.post( `${validationService.url}${validationService.validateEndpoint}`, { projectId }, { headers: { "X-API-Key": apiKey, "X-API-Secret": secret } } ); return response.data; } catch (error) { console.error("Project validation failed:", error); throw new Error("Invalid project credentials or configuration"); } } async function reportUsage(projectId, metrics, validationService) { if (!validationService) return; try { await axios.post( `${validationService.url}${validationService.reportUsageEndpoint}`, { projectId, ...metrics }, { timeout: 5e3 } ); } catch (error) { console.error("Failed to report usage metrics:", error); } } async function startServer(options) { const { apiKey, secret, port, projectId, tier, usage, validationService, libraries = [], routes = [], credentials, mongoUri, baseRoute = "/api", auth, session: sessionConfig, storage, enableHelmet = true, enableCors = true, enableLogging = true, rateLimit: rateLimitConfig, email, redis: redisConfig, validators = {}, webhooks, analytics = { enabled: true, ignorePaths: [] } } = options; if (!apiKey || !secret || !port || !projectId) { throw new Error("API key, secret, port and projectId are required"); } const validKey = credentials?.apiKey || process.env.MY_API_KEY; const validSecret = credentials?.secret || process.env.MY_API_SECRET; if (validKey && validSecret) { if (apiKey !== validKey || secret !== validSecret) { throw new Error("Invalid starting credentials"); } } try { projectConfig = await validateProject(apiKey, secret, projectId, validationService); console.log(`\u2705 Project validated: ${projectConfig.name} (${projectConfig.tier} tier)`); } catch (error) { throw new Error(`Project validation failed: ${error.message}`); } if (routes.length > projectConfig.maxRoutes) { throw new Error(`Tier ${projectConfig.tier} allows maximum ${projectConfig.maxRoutes} routes (${routes.length} provided)`); } if (libraries.length > projectConfig.maxLibraries) { throw new Error(`Tier ${projectConfig.tier} allows maximum ${projectConfig.maxLibraries} libraries (${libraries.length} provided)`); } const app = express(); appInstance = app; app.use((req, res, next) => { req.project = projectConfig; next(); }); if (enableHelmet) app.use(helmet()); if (enableCors) app.use(typeof enableCors === "boolean" ? cors() : cors(enableCors)); app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: false, limit: "10mb" })); if (enableLogging) { app.use(morgan("dev", { skip: (req) => (analytics.ignorePaths || []).includes(req.path) })); } if (analytics.enabled) { app.use(async (req, res, next) => { const start = Date.now(); const ignorePaths = analytics.ignorePaths || []; res.on("finish", async () => { if (!ignorePaths.includes(req.path)) { try { const log = new ApiLog({ projectId, path: req.path, method: req.method, statusCode: res.statusCode, responseTime: Date.now() - start, userId: req.user?._id, ipAddress: req.ip, userAgent: req.get("User-Agent") }); await log.save(); await ProjectUsage.updateOne( { projectId }, { $inc: { requests: 1 } }, { upsert: true } ); if (validationService) { await reportUsage(projectId, { requests: 1 }, validationService); } } catch (error) { console.error("Failed to log analytics:", error); } } }); next(); }); } if (projectConfig.features.rateLimiting && rateLimitConfig !== false) { const baseConfig = { windowMs: rateLimitConfig?.windowMs ?? 15 * 60 * 1e3, max: rateLimitConfig?.max ?? 200, standardHeaders: true, legacyHeaders: false, message: rateLimitConfig?.message ?? "Too many requests, please try again later." }; if (rateLimitConfig?.tierOverrides?.[projectConfig.tier]) { baseConfig.max = rateLimitConfig.tierOverrides[projectConfig.tier].max; } const rl = rateLimit(baseConfig); app.use(rl); } if (projectConfig.features.database) { const mongoConnectionUri = mongoUri || process.env.MONGO_URI; if (mongoConnectionUri) { DB = mongoose; try { await mongoose.connect(mongoConnectionUri); console.log("\u2705 MongoDB connected"); } catch (err) { console.error("\u274C MongoDB connection failed:", err.message || err); throw err; } } else { console.warn("\u26A0\uFE0F MongoDB URI not provided, skipping database connection"); } } if (projectConfig.features.redis && redisConfig?.enabled) { try { redisClient = createClient({ url: redisConfig.url || process.env.REDIS_URL }); redisClient.on("error", (err) => console.log("Redis Client Error", err)); await redisClient.connect(); console.log("\u2705 Redis connected"); } catch (err) { console.error("\u274C Redis connection failed:", err.message || err); } } if (sessionConfig?.enabled) { let sessionStore; if (sessionConfig.redis && redisClient) { const redisStoreModule = await import("connect-redis"); const RedisStore = redisStoreModule.default || redisStoreModule; sessionStore = new RedisStore({ client: redisClient }); } app.use(session(sessionConfig.options || { secret, resave: false, saveUninitialized: false, store: sessionStore })); console.log("\u2705 Session enabled"); } let upload = null; if (projectConfig.features.storage && storage?.enabled) { const cfg = storage.config || {}; const uploadDir = path.resolve(cfg.uploadDir || "uploads"); const multerStorage = cfg.multerStorage || multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `${uuidv4()}${ext}`); } }); upload = multer({ storage: multerStorage, limits: { fileSize: cfg.maxFileSize || 5 * 1024 * 1024 // 5MB default }, fileFilter: (req, file, cb) => { if (cfg.allowedMimeTypes && !cfg.allowedMimeTypes.includes(file.mimetype)) { return cb(new Error("File type not allowed")); } cb(null, true); } }); app.use(cfg.staticRoute || "/uploads", express.static(uploadDir)); console.log("\u2705 Storage enabled at", cfg.staticRoute || "/uploads"); } let transporter = null; if (projectConfig.features.email && email) { transporter = nodemailer.createTransport(email.transporterOptions); const emailConfig = email; if (!emailConfig.sendVerificationEmail) { emailConfig.sendVerificationEmail = async (to, token) => { const url = `${baseRoute}/auth/verify-email?token=${encodeURIComponent(token)}`; await transporter.sendMail({ from: emailConfig.from, to, subject: "Verify your email", text: `Verify here: ${url}`, html: `<p>Verify here: <a href="${url}">${url}</a></p>` }); }; } if (!emailConfig.sendResetEmail) { emailConfig.sendResetEmail = async (to, token) => { const url = `${baseRoute}/auth/reset-password?token=${encodeURIComponent(token)}`; await transporter.sendMail({ from: emailConfig.from, to, subject: "Reset your password", text: `Reset here: ${url}`, html: `<p>Reset here: <a href="${url}">${url}</a></p>` }); }; } console.log("\u2705 Email configured"); } if (libraries.length > 0) { libraries.forEach((lib) => app.use(lib)); console.log(`\u2705 ${libraries.length} custom libraries applied`); } if (projectConfig.features.database && auth?.enabled) { if (!auth.model) throw new Error("When auth.enabled=true you must pass auth.model (Mongoose Model<T>)"); if (!auth.jwt) throw new Error("When auth.enabled=true you must pass auth.jwt config (JWT Config)"); const userModel = auth.model; const jwtConf = auth.jwt; const routesPaths = { ...defaultRoutes(), ...auth.routes || {} }; const generateAccessToken = (payload) => signJwt(payload, jwtConf.accessTokenSecret, { expiresIn: jwtConf.accessTokenExpiresIn || "1h" }); const generateRefreshToken = (payload) => { if (!jwtConf.refreshTokenSecret) throw new Error("refreshTokenSecret required for refresh tokens"); return signJwt(payload, jwtConf.refreshTokenSecret, { expiresIn: jwtConf.refreshTokenExpiresIn || "7d" }); }; const requireAuth = (roles) => { return async (req, res, next) => { try { const header = req.headers.authorization; if (!header || !header.startsWith("Bearer ")) return res.status(401).json({ message: "No token" }); const token = header.split(" ")[1]; const decoded = verifyJwt(token, jwtConf.accessTokenSecret); req.user = decoded; if (roles && roles.length > 0) { const roleField = auth.roleField || "role"; const user = await userModel.findById(decoded.id).lean(); if (!user) return res.status(403).json({ message: "Forbidden" }); const userObj = user; if (!roles.includes(userObj[roleField])) return res.status(403).json({ message: "Forbidden" }); } next(); } catch (err) { return res.status(401).json({ message: "Invalid token" }); } }; }; const router = express.Router(); if (projectConfig.features.advancedAuth && auth.advanced) { router.post(routesPaths.signup, async (req, res) => { try { const userCount = await userModel.countDocuments(); if (projectConfig?.maxUsers && userCount >= projectConfig.maxUsers) { return res.status(429).json({ message: "User limit reached" }); } const v = validators.signup?.(req.body); if (v && !v.ok) return res.status(400).json({ message: v.message }); const { email: email2, password, ...rest } = req.body; if (!email2 || !password) return res.status(400).json({ message: "Email and password required" }); const exists = await userModel.findOne({ email: email2 }); if (exists) return res.status(409).json({ message: "Email already exists" }); const hashed = await bcrypt.hash(password, 12); const doc = new userModel({ email: email2, password: hashed, ...rest }); await doc.save(); await ProjectUsage.updateOne( { projectId }, { $inc: { users: 1 } }, { upsert: true } ); if (webhooks?.onUserSignup) { try { await webhooks.onUserSignup(doc.toObject()); } catch (error) { console.error("Webhook error:", error); } } if (options.auth?.requireEmailVerification && email2) { const token = signJwt({ id: doc._id }, jwtConf.accessTokenSecret, { expiresIn: "1d" }); if (email2 && options.email) await options.email.sendVerificationEmail(email2, token); return res.status(201).json({ message: "User created. Verify your email." }); } return res.status(201).json({ message: "User created", user: { id: doc._id, email: email2 } }); } catch (err) { if (webhooks?.onError) { try { await webhooks.onError(err, "signup"); } catch (error) { console.error("Error webhook failed:", error); } } return res.status(500).json({ message: err.message || "Server error" }); } }); } router.post(routesPaths.login, async (req, res) => { try { const v = validators.login?.(req.body); if (v && !v.ok) return res.status(400).json({ message: v.message }); const { email: email2, password } = req.body; if (!email2 || !password) return res.status(400).json({ message: "Email and password required" }); const user = await userModel.findOne({ email: email2 }); if (!user) return res.status(401).json({ message: "Invalid credentials" }); const userObj = user; const match = await bcrypt.compare(password, userObj.password); if (!match) return res.status(401).json({ message: "Invalid credentials" }); const payload = { id: userObj._id, email: email2 }; const accessToken = generateAccessToken(payload); const result = { accessToken }; if (jwtConf.refreshTokenSecret) { const refreshToken = generateRefreshToken(payload); result.refreshToken = refreshToken; if (redisClient) { await redisClient.set(`refresh:${userObj._id}`, refreshToken, { EX: 7 * 24 * 60 * 60 // 7 days }); } } if (webhooks?.onUserLogin) { try { await webhooks.onUserLogin(user.toObject()); } catch (error) { console.error("Webhook error:", error); } } return res.json(result); } catch (err) { if (webhooks?.onError) { try { await webhooks.onError(err, "login"); } catch (error) { console.error("Error webhook failed:", error); } } return res.status(500).json({ message: err.message || "Server error" }); } }); if (jwtConf.refreshTokenSecret) { router.post(routesPaths.refresh, async (req, res) => { try { const { refreshToken } = req.body; if (!refreshToken) return res.status(400).json({ message: "Missing refresh token" }); const decoded = verifyJwt(refreshToken, jwtConf.refreshTokenSecret); if (redisClient) { const storedToken = await redisClient.get(`refresh:${decoded.id}`); if (storedToken !== refreshToken) { return res.status(401).json({ message: "Invalid refresh token" }); } } const payload = { id: decoded.id, email: decoded.email }; const accessToken = generateAccessToken(payload); return res.json({ accessToken }); } catch (err) { return res.status(401).json({ message: "Invalid refresh token" }); } }); } router.get(routesPaths.me, requireAuth(), async (req, res) => { const authPayload = req.user; const user = await userModel.findById(authPayload.id).select("-password"); if (!user) return res.status(404).json({ message: "User not found" }); return res.json(user); }); router.put(routesPaths.update, requireAuth(), async (req, res) => { const authPayload = req.user; const updates = req.body; delete updates.password; const updated = await userModel.findByIdAndUpdate(authPayload.id, updates, { new: true }).select("-password"); return res.json(updated); }); router.put(routesPaths.changePassword, requireAuth(), async (req, res) => { try { const authPayload = req.user; const { oldPassword, newPassword } = req.body; if (!oldPassword || !newPassword) return res.status(400).json({ message: "Missing fields" }); const user = await userModel.findById(authPayload.id); if (!user) return res.status(404).json({ message: "User not found" }); const userObj = user; const match = await bcrypt.compare(oldPassword, userObj.password); if (!match) return res.status(401).json({ message: "Old password incorrect" }); userObj.password = await bcrypt.hash(newPassword, 12); await userObj.save(); return res.json({ message: "Password changed" }); } catch (err) { return res.status(500).json({ message: err.message || "Server error" }); } }); if (projectConfig.features.advancedAuth) { router.post(routesPaths.forgotPassword, async (req, res) => { try { const { email: reqEmail } = req.body; if (!reqEmail) return res.status(400).json({ message: "Email required" }); const user = await userModel.findOne({ email: reqEmail }); if (!user) return res.status(404).json({ message: "User not found" }); const token = jwt.sign({ id: user._id }, jwtConf.accessTokenSecret, { expiresIn: "15m" }); const expiresAt = new Date(Date.now() + 15 * 60 * 1e3); const rt = await ResetToken.create({ userId: user._id, token, expiresAt }); if (email && email.sendResetEmail) { await email.sendResetEmail(reqEmail, token); return res.json({ message: "Reset email sent" }); } else { return res.json({ message: "Reset token generated (dev)", token }); } } catch (err) { return res.status(500).json({ message: err.message || "Server error" }); } }); router.post(routesPaths.resetPassword, async (req, res) => { try { const { token, newPassword } = req.body; const v = validators.resetPassword?.(req.body); if (v && !v.ok) return res.status(400).json({ message: v.message }); if (!token || !newPassword) return res.status(400).json({ message: "Missing fields" }); const rt = await ResetToken.findOne({ token, used: false }); if (!rt) return res.status(400).json({ message: "Invalid or used token" }); if (rt.expiresAt < /* @__PURE__ */ new Date()) return res.status(400).json({ message: "Token expired" }); try { verifyJwt(token, jwtConf.accessTokenSecret); } catch (err) { return res.status(400).json({ message: "Invalid token payload" }); } const user = await userModel.findById(rt.userId); if (!user) return res.status(404).json({ message: "User not found" }); const userObj = user; userObj.password = await bcrypt.hash(newPassword, 12); await userObj.save(); rt.used = true; await rt.save(); return res.json({ message: "Password reset successful" }); } catch (err) { return res.status(500).json({ message: err.message || "Server error" }); } }); router.get(routesPaths.verifyEmail, async (req, res) => { try { const token = req.query.token; if (!token) return res.status(400).send("Missing token"); const decoded = verifyJwt(token, jwtConf.accessTokenSecret); const user = await userModel.findById(decoded.id); if (!user) return res.status(404).send("User not found"); const userObj = user; if (userObj.emailVerified !== void 0) { userObj.emailVerified = true; await userObj.save(); } return res.send("Email verified"); } catch (err) { return res.status(400).send("Invalid or expired token"); } }); } app.use(baseRoute, router); console.log(`\u{1F510} Auth mounted at ${baseRoute}`); } if (routes && routes.length) { for (const r of routes) { const handlerArray = Array.isArray(r.handlers) ? r.handlers : [r.handlers]; const p = r.path.startsWith("/") ? r.path : `/${r.path}`; app[r.method.toLowerCase()](baseRoute + p, ...handlerArray); } console.log(`\u2699\uFE0F ${routes.length} custom routes mounted under ${baseRoute}`); } app.get(`${baseRoute}/health`, (req, res) => { res.json({ status: "OK", project: projectConfig?.name, tier: projectConfig?.tier, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); }); app.get(`${baseRoute}/usage`, async (req, res) => { try { const usageStats = await ProjectUsage.findOne({ projectId }); res.json(usageStats || { requests: 0, users: 0, storage: 0 }); } catch (error) { res.status(500).json({ message: "Failed to fetch usage stats" }); } }); app.use((err, req, res, next) => { console.error("Unhandled error:", err); if (webhooks?.onError) { webhooks.onError(err, "global").catch((e) => console.error("Error webhook failed:", e)); } res.status(err.status || 500).json({ message: err.message || "Internal server error" }); }); const server = app.listen(port, () => { console.log(`\u{1F680} Server running at http://localhost:${port}${baseRoute}`); console.log(`\u{1F4CA} Tier: ${projectConfig?.tier}`); console.log(`\u{1F4C8} Limits: ${projectConfig?.maxRoutes} routes, ${projectConfig?.maxUsers} users`); }); const shutdown = async () => { console.log("Shutting down..."); server.close(); if (DB) await mongoose.disconnect(); if (redisClient) await redisClient.quit(); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); DB = mongoose; return app; } async function stopServer() { try { if (DB) { await mongoose.disconnect(); DB = null; } if (redisClient) { await redisClient.quit(); redisClient = null; } } catch (err) { } } export { DB, appInstance, projectConfig, redisClient, startServer, stopServer };