Spaces:
Running
Running
Upload 35 files
Browse files- .dockerignore +8 -0
- .gitignore +3 -0
- Dockerfile +33 -0
- README.md +4 -3
- package.json +40 -0
- src/app.js +86 -0
- src/config/cloudinary.js +11 -0
- src/config/db.js +44 -0
- src/config/env.js +103 -0
- src/controllers/admin.controller.js +198 -0
- src/controllers/auth.controller.js +192 -0
- src/controllers/content.controller.js +278 -0
- src/controllers/course.controller.js +56 -0
- src/controllers/order.controller.js +161 -0
- src/middleware/auth.js +45 -0
- src/middleware/errorHandler.js +33 -0
- src/middleware/rateLimiters.js +31 -0
- src/middleware/upload.js +50 -0
- src/middleware/validate.js +14 -0
- src/models/AuditLog.js +21 -0
- src/models/Course.js +104 -0
- src/models/Order.js +81 -0
- src/models/User.js +88 -0
- src/routes/admin.routes.js +49 -0
- src/routes/auth.routes.js +45 -0
- src/routes/course.routes.js +33 -0
- src/routes/order.routes.js +40 -0
- src/seed/seed.js +128 -0
- src/server.js +46 -0
- src/utils/cloudinaryUpload.js +61 -0
- src/utils/crypto.js +56 -0
- src/utils/email.js +147 -0
- src/utils/jwt.js +33 -0
- src/utils/logger.js +29 -0
- src/utils/orderNumber.js +15 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
npm-debug.log*
|
| 3 |
+
.env
|
| 4 |
+
.env.*
|
| 5 |
+
!.env.example
|
| 6 |
+
.git
|
| 7 |
+
.gitignore
|
| 8 |
+
*.md
|
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.env
|
| 3 |
+
npm-debug.log*
|
Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies in their own layer so this only re-runs when
|
| 6 |
+
# package*.json actually changes, speeding up rebuilds. Using `npm
|
| 7 |
+
# install` rather than `npm ci` since this repo doesn't ship a
|
| 8 |
+
# package-lock.json — if you generate one locally (recommended:
|
| 9 |
+
# `npm install` once, then commit package-lock.json), switch this to
|
| 10 |
+
# `npm ci --omit=dev` for fully reproducible, faster builds.
|
| 11 |
+
COPY package*.json ./
|
| 12 |
+
RUN npm install --omit=dev
|
| 13 |
+
|
| 14 |
+
# Copy the rest of the application source
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Hugging Face Spaces (Docker SDK) route external traffic to whatever
|
| 18 |
+
# port is declared as "app_port" in the Space's README.md metadata —
|
| 19 |
+
# 7860 is Hugging Face's own default, so we match it here. If you set a
|
| 20 |
+
# different app_port in your Space's README, update PORT to match.
|
| 21 |
+
ENV NODE_ENV=production
|
| 22 |
+
ENV PORT=7860
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
# Run as the non-root "node" user (built into the official Node image)
|
| 26 |
+
# rather than root, as one more layer of defense-in-depth.
|
| 27 |
+
RUN chown -R node:node /app
|
| 28 |
+
USER node
|
| 29 |
+
|
| 30 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
| 31 |
+
CMD node -e "require('http').get('http://localhost:7860/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
|
| 32 |
+
|
| 33 |
+
CMD ["node", "src/server.js"]
|
README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Aynwerdstudy
|
| 3 |
+
emoji: 🎓
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "aynwerd-server",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "Node.js/Express + MongoDB Atlas backend for courses, checkout, and admin-approved enrolment.",
|
| 6 |
+
"type": "commonjs",
|
| 7 |
+
"main": "src/server.js",
|
| 8 |
+
"scripts": {
|
| 9 |
+
"start": "node src/server.js",
|
| 10 |
+
"dev": "nodemon src/server.js",
|
| 11 |
+
"seed": "node src/seed/seed.js"
|
| 12 |
+
},
|
| 13 |
+
"engines": {
|
| 14 |
+
"node": ">=18.0.0"
|
| 15 |
+
},
|
| 16 |
+
"dependencies": {
|
| 17 |
+
"bcryptjs": "^2.4.3",
|
| 18 |
+
"cloudinary": "^2.5.1",
|
| 19 |
+
"compression": "^1.7.4",
|
| 20 |
+
"cookie-parser": "^1.4.6",
|
| 21 |
+
"cors": "^2.8.5",
|
| 22 |
+
"dotenv": "^16.4.5",
|
| 23 |
+
"express": "^4.19.2",
|
| 24 |
+
"express-mongo-sanitize": "^2.2.0",
|
| 25 |
+
"express-rate-limit": "^7.4.0",
|
| 26 |
+
"express-validator": "^7.2.0",
|
| 27 |
+
"helmet": "^7.1.0",
|
| 28 |
+
"hpp": "^0.2.3",
|
| 29 |
+
"jsonwebtoken": "^9.0.2",
|
| 30 |
+
"mongoose": "^8.6.0",
|
| 31 |
+
"morgan": "^1.10.0",
|
| 32 |
+
"multer": "^1.4.5-lts.1",
|
| 33 |
+
"nodemailer": "^6.9.14",
|
| 34 |
+
"uuid": "^9.0.1",
|
| 35 |
+
"winston": "^3.14.2"
|
| 36 |
+
},
|
| 37 |
+
"devDependencies": {
|
| 38 |
+
"nodemon": "^3.1.4"
|
| 39 |
+
}
|
| 40 |
+
}
|
src/app.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const helmet = require("helmet");
|
| 3 |
+
const cors = require("cors");
|
| 4 |
+
const cookieParser = require("cookie-parser");
|
| 5 |
+
const compression = require("compression");
|
| 6 |
+
const morgan = require("morgan");
|
| 7 |
+
const mongoSanitize = require("express-mongo-sanitize");
|
| 8 |
+
const hpp = require("hpp");
|
| 9 |
+
|
| 10 |
+
const { env } = require("./config/env");
|
| 11 |
+
const { apiLimiter } = require("./middleware/rateLimiters");
|
| 12 |
+
const { errorHandler, notFound } = require("./middleware/errorHandler");
|
| 13 |
+
const logger = require("./utils/logger");
|
| 14 |
+
|
| 15 |
+
const authRoutes = require("./routes/auth.routes");
|
| 16 |
+
const courseRoutes = require("./routes/course.routes");
|
| 17 |
+
const orderRoutes = require("./routes/order.routes");
|
| 18 |
+
const adminRoutes = require("./routes/admin.routes");
|
| 19 |
+
|
| 20 |
+
const app = express();
|
| 21 |
+
|
| 22 |
+
// Trust the first proxy hop (Render/Heroku/Nginx/etc.) so req.ip and the
|
| 23 |
+
// "secure" cookie/HSTS logic reflect the real client over HTTPS.
|
| 24 |
+
app.set("trust proxy", 1);
|
| 25 |
+
|
| 26 |
+
// --- Security headers -------------------------------------------------
|
| 27 |
+
app.use(
|
| 28 |
+
helmet({
|
| 29 |
+
contentSecurityPolicy: {
|
| 30 |
+
directives: {
|
| 31 |
+
defaultSrc: ["'self'"],
|
| 32 |
+
scriptSrc: ["'self'"],
|
| 33 |
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
| 34 |
+
imgSrc: ["'self'", "data:", "https:"],
|
| 35 |
+
objectSrc: ["'none'"],
|
| 36 |
+
frameAncestors: ["'none'"],
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
crossOriginResourcePolicy: { policy: "same-site" },
|
| 40 |
+
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
|
| 41 |
+
})
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
// --- CORS: only the configured frontend origins may call the API, and
|
| 45 |
+
// credentials (refresh-token cookie) are only sent to those origins. ---
|
| 46 |
+
app.use(
|
| 47 |
+
cors({
|
| 48 |
+
origin(origin, callback) {
|
| 49 |
+
if (!origin || env.clientOrigins.includes(origin)) return callback(null, true);
|
| 50 |
+
return callback(new Error("Not allowed by CORS"));
|
| 51 |
+
},
|
| 52 |
+
credentials: true,
|
| 53 |
+
})
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
app.use(compression());
|
| 57 |
+
app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
|
| 58 |
+
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
| 59 |
+
app.use(cookieParser(env.cookieSecret));
|
| 60 |
+
|
| 61 |
+
// Strip any keys starting with "$" or containing "." from user input to
|
| 62 |
+
// block MongoDB operator-injection attacks.
|
| 63 |
+
app.use(mongoSanitize());
|
| 64 |
+
// Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
|
| 65 |
+
app.use(hpp());
|
| 66 |
+
|
| 67 |
+
app.use(
|
| 68 |
+
morgan("combined", {
|
| 69 |
+
stream: { write: (message) => logger.info(message.trim()) },
|
| 70 |
+
skip: () => env.nodeEnv === "test",
|
| 71 |
+
})
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
app.use("/api/", apiLimiter);
|
| 75 |
+
|
| 76 |
+
app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));
|
| 77 |
+
|
| 78 |
+
app.use("/api/auth", authRoutes);
|
| 79 |
+
app.use("/api/courses", courseRoutes);
|
| 80 |
+
app.use("/api/orders", orderRoutes);
|
| 81 |
+
app.use("/api/admin", adminRoutes);
|
| 82 |
+
|
| 83 |
+
app.use(notFound);
|
| 84 |
+
app.use(errorHandler);
|
| 85 |
+
|
| 86 |
+
module.exports = app;
|
src/config/cloudinary.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { v2: cloudinary } = require("cloudinary");
|
| 2 |
+
const { env } = require("./env");
|
| 3 |
+
|
| 4 |
+
cloudinary.config({
|
| 5 |
+
cloud_name: env.cloudinary.cloudName,
|
| 6 |
+
api_key: env.cloudinary.apiKey,
|
| 7 |
+
api_secret: env.cloudinary.apiSecret,
|
| 8 |
+
secure: true,
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
module.exports = cloudinary;
|
src/config/db.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const { env } = require("./env");
|
| 3 |
+
const logger = require("../utils/logger");
|
| 4 |
+
|
| 5 |
+
mongoose.set("strictQuery", true);
|
| 6 |
+
|
| 7 |
+
async function connectDB() {
|
| 8 |
+
if (!env.mongoUri) {
|
| 9 |
+
throw new Error("MONGODB_URI is not set. Copy .env.example to .env and configure your Atlas connection string.");
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
mongoose.connection.on("connected", () => logger.info("MongoDB connected"));
|
| 13 |
+
mongoose.connection.on("error", (err) => logger.error("MongoDB connection error", { error: err.message }));
|
| 14 |
+
mongoose.connection.on("disconnected", () => logger.warn("MongoDB disconnected"));
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
await mongoose.connect(env.mongoUri, {
|
| 18 |
+
// Modern mongoose driver already defaults to these; kept explicit for clarity.
|
| 19 |
+
autoIndex: env.nodeEnv !== "production", // build indexes lazily in prod via migration/ops task
|
| 20 |
+
maxPoolSize: 20,
|
| 21 |
+
serverSelectionTimeoutMS: 10000,
|
| 22 |
+
});
|
| 23 |
+
} catch (err) {
|
| 24 |
+
if (err.code === "ECONNREFUSED" && err.syscall === "querySrv") {
|
| 25 |
+
throw new Error(
|
| 26 |
+
"Could not resolve your MongoDB Atlas SRV connection string " +
|
| 27 |
+
`(${err.hostname || "the cluster hostname"}). This almost always means your network, router, ` +
|
| 28 |
+
"or ISP is blocking DNS SRV record lookups (common on home/office Wi-Fi) — it is not a code " +
|
| 29 |
+
"or credentials problem. Fix: in Atlas, go to your cluster → Connect → Drivers, and use the " +
|
| 30 |
+
"'standard connection string' (starts with mongodb:// and lists three hosts) instead of the " +
|
| 31 |
+
"mongodb+srv:// one, then update MONGODB_URI in your .env. See server/README.md for details."
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
throw err;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return mongoose.connection;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
async function disconnectDB() {
|
| 41 |
+
await mongoose.disconnect();
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
module.exports = { connectDB, disconnectDB };
|
src/config/env.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
require("dotenv").config();
|
| 2 |
+
|
| 3 |
+
// Fail fast if critical secrets are missing or left as placeholders — this
|
| 4 |
+
// is a cheap, high-value guard against accidentally deploying with
|
| 5 |
+
// insecure defaults.
|
| 6 |
+
const REQUIRED_IN_PRODUCTION = [
|
| 7 |
+
"MONGODB_URI",
|
| 8 |
+
"JWT_ACCESS_SECRET",
|
| 9 |
+
"JWT_REFRESH_SECRET",
|
| 10 |
+
"FIELD_ENCRYPTION_KEY",
|
| 11 |
+
"COOKIE_SECRET",
|
| 12 |
+
"SMTP_HOST",
|
| 13 |
+
"SMTP_USER",
|
| 14 |
+
"SMTP_PASS",
|
| 15 |
+
"CLOUDINARY_CLOUD_NAME",
|
| 16 |
+
"CLOUDINARY_API_KEY",
|
| 17 |
+
"CLOUDINARY_API_SECRET",
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
const env = {
|
| 21 |
+
nodeEnv: process.env.NODE_ENV || "development",
|
| 22 |
+
port: parseInt(process.env.PORT, 10) || 5000,
|
| 23 |
+
clientOrigins: (process.env.CLIENT_ORIGINS || "http://localhost:5173")
|
| 24 |
+
.split(",")
|
| 25 |
+
.map((s) => s.trim())
|
| 26 |
+
.filter(Boolean),
|
| 27 |
+
|
| 28 |
+
mongoUri: process.env.MONGODB_URI,
|
| 29 |
+
|
| 30 |
+
jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
|
| 31 |
+
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
|
| 32 |
+
jwtAccessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
|
| 33 |
+
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
| 34 |
+
|
| 35 |
+
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
|
| 36 |
+
cookieSecret: process.env.COOKIE_SECRET,
|
| 37 |
+
cookieSecure: process.env.COOKIE_SECURE === "true",
|
| 38 |
+
|
| 39 |
+
smtp: {
|
| 40 |
+
host: process.env.SMTP_HOST,
|
| 41 |
+
port: parseInt(process.env.SMTP_PORT, 10) || 587,
|
| 42 |
+
secure: process.env.SMTP_SECURE === "true",
|
| 43 |
+
user: process.env.SMTP_USER,
|
| 44 |
+
pass: process.env.SMTP_PASS,
|
| 45 |
+
},
|
| 46 |
+
emailFrom: process.env.EMAIL_FROM || "Aynwerd Academy <no-reply@aynwerd.com>",
|
| 47 |
+
adminNotifyEmail: process.env.ADMIN_NOTIFY_EMAIL,
|
| 48 |
+
|
| 49 |
+
bank: {
|
| 50 |
+
name: process.env.BANK_NAME || "",
|
| 51 |
+
accountName: process.env.BANK_ACCOUNT_NAME || "",
|
| 52 |
+
accountNumber: process.env.BANK_ACCOUNT_NUMBER || "",
|
| 53 |
+
routingNumber: process.env.BANK_ROUTING_NUMBER || "",
|
| 54 |
+
swiftBic: process.env.BANK_SWIFT_BIC || "",
|
| 55 |
+
iban: process.env.BANK_IBAN || "",
|
| 56 |
+
address: process.env.BANK_ADDRESS || "",
|
| 57 |
+
},
|
| 58 |
+
payoneer: {
|
| 59 |
+
paymentUrl: process.env.PAYONEER_PAYMENT_URL || "https://www.payoneer.com/",
|
| 60 |
+
accountEmail: process.env.PAYONEER_ACCOUNT_EMAIL || "",
|
| 61 |
+
},
|
| 62 |
+
|
| 63 |
+
seedAdmin: {
|
| 64 |
+
name: process.env.SEED_ADMIN_NAME || "Site Admin",
|
| 65 |
+
email: process.env.SEED_ADMIN_EMAIL,
|
| 66 |
+
password: process.env.SEED_ADMIN_PASSWORD,
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
// Cloudinary: free-tier media storage used exclusively for admin-uploaded
|
| 70 |
+
// course videos/documents. Files are uploaded with delivery type
|
| 71 |
+
// "authenticated" so nothing is reachable without a freshly signed,
|
| 72 |
+
// short-lived URL minted server-side for a verified, paying learner.
|
| 73 |
+
cloudinary: {
|
| 74 |
+
cloudName: process.env.CLOUDINARY_CLOUD_NAME,
|
| 75 |
+
apiKey: process.env.CLOUDINARY_API_KEY,
|
| 76 |
+
apiSecret: process.env.CLOUDINARY_API_SECRET,
|
| 77 |
+
},
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
function assertProductionSafety() {
|
| 81 |
+
if (env.nodeEnv !== "production") return;
|
| 82 |
+
|
| 83 |
+
const missing = REQUIRED_IN_PRODUCTION.filter((key) => {
|
| 84 |
+
const value = process.env[key];
|
| 85 |
+
return !value || value.startsWith("replace_with_") || value.includes("<");
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
if (missing.length > 0) {
|
| 89 |
+
// eslint-disable-next-line no-console
|
| 90 |
+
console.error(
|
| 91 |
+
`FATAL: refusing to start in production with missing/placeholder env vars: ${missing.join(", ")}`
|
| 92 |
+
);
|
| 93 |
+
process.exit(1);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
if (Buffer.from(env.fieldEncryptionKey || "", "hex").length !== 32) {
|
| 97 |
+
// eslint-disable-next-line no-console
|
| 98 |
+
console.error("FATAL: FIELD_ENCRYPTION_KEY must be a 32-byte (64 hex character) value.");
|
| 99 |
+
process.exit(1);
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
module.exports = { env, assertProductionSafety };
|
src/controllers/admin.controller.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const Order = require("../models/Order");
|
| 3 |
+
const User = require("../models/User");
|
| 4 |
+
const Course = require("../models/Course");
|
| 5 |
+
const AuditLog = require("../models/AuditLog");
|
| 6 |
+
const { sendPaymentConfirmedEmail, sendPaymentRejectedEmail } = require("../utils/email");
|
| 7 |
+
const logger = require("../utils/logger");
|
| 8 |
+
|
| 9 |
+
async function listOrders(req, res, next) {
|
| 10 |
+
try {
|
| 11 |
+
const { status } = req.query;
|
| 12 |
+
const filter = {};
|
| 13 |
+
if (status) filter.status = status;
|
| 14 |
+
|
| 15 |
+
const orders = await Order.find(filter)
|
| 16 |
+
.sort({ createdAt: -1 })
|
| 17 |
+
.populate("user", "name email")
|
| 18 |
+
.populate("course", "title");
|
| 19 |
+
|
| 20 |
+
res.json({
|
| 21 |
+
orders: orders.map((o) => ({
|
| 22 |
+
...o.toPublicJSON(),
|
| 23 |
+
user: o.user ? { id: o.user._id, name: o.user.name, email: o.user.email } : null,
|
| 24 |
+
})),
|
| 25 |
+
});
|
| 26 |
+
} catch (err) {
|
| 27 |
+
next(err);
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/** Admin views the decrypted payment reference only when actively reviewing a specific order. */
|
| 32 |
+
async function getOrderDetail(req, res, next) {
|
| 33 |
+
try {
|
| 34 |
+
const order = await Order.findById(req.params.id)
|
| 35 |
+
.select("+paymentReferenceEncrypted +payerNoteEncrypted")
|
| 36 |
+
.populate("user", "name email")
|
| 37 |
+
.populate("course", "title price");
|
| 38 |
+
if (!order) return res.status(404).json({ error: "Order not found." });
|
| 39 |
+
|
| 40 |
+
await AuditLog.create({
|
| 41 |
+
actor: req.user._id,
|
| 42 |
+
actorEmail: req.user.email,
|
| 43 |
+
action: "admin.order_viewed",
|
| 44 |
+
targetType: "Order",
|
| 45 |
+
targetId: order._id,
|
| 46 |
+
ip: req.ip,
|
| 47 |
+
userAgent: req.headers["user-agent"],
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
res.json({
|
| 51 |
+
order: {
|
| 52 |
+
...order.toPublicJSON({ includeSensitive: true }),
|
| 53 |
+
user: order.user ? { id: order.user._id, name: order.user.name, email: order.user.email } : null,
|
| 54 |
+
},
|
| 55 |
+
});
|
| 56 |
+
} catch (err) {
|
| 57 |
+
next(err);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Confirms a payment: marks the order confirmed, grants course access to
|
| 63 |
+
* the user, and emails the customer. Wrapped in a transaction so a
|
| 64 |
+
* partial failure can't leave the order confirmed without access
|
| 65 |
+
* granted (or vice versa).
|
| 66 |
+
*/
|
| 67 |
+
async function confirmOrder(req, res, next) {
|
| 68 |
+
const session = await mongoose.startSession();
|
| 69 |
+
try {
|
| 70 |
+
let resultOrder;
|
| 71 |
+
let resultUser;
|
| 72 |
+
let course;
|
| 73 |
+
|
| 74 |
+
await session.withTransaction(async () => {
|
| 75 |
+
const order = await Order.findById(req.params.id).session(session);
|
| 76 |
+
if (!order) throw Object.assign(new Error("Order not found."), { status: 404 });
|
| 77 |
+
if (order.status === "confirmed") throw Object.assign(new Error("Order already confirmed."), { status: 409 });
|
| 78 |
+
if (!["awaiting_confirmation", "pending_payment"].includes(order.status)) {
|
| 79 |
+
throw Object.assign(new Error("Order cannot be confirmed from its current status."), { status: 409 });
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
order.status = "confirmed";
|
| 83 |
+
order.confirmedBy = req.user._id;
|
| 84 |
+
order.confirmedAt = new Date();
|
| 85 |
+
await order.save({ session });
|
| 86 |
+
|
| 87 |
+
const user = await User.findById(order.user).session(session);
|
| 88 |
+
if (!user) throw Object.assign(new Error("Associated user not found."), { status: 404 });
|
| 89 |
+
|
| 90 |
+
const alreadyEnrolled = user.enrolledCourses.some((ec) => ec.course.toString() === order.course.toString());
|
| 91 |
+
if (!alreadyEnrolled) {
|
| 92 |
+
user.enrolledCourses.push({ course: order.course, order: order._id, accessGrantedAt: new Date() });
|
| 93 |
+
await user.save({ session });
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
course = await Course.findById(order.course).session(session);
|
| 97 |
+
resultOrder = order;
|
| 98 |
+
resultUser = user;
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
await AuditLog.create({
|
| 102 |
+
actor: req.user._id,
|
| 103 |
+
actorEmail: req.user.email,
|
| 104 |
+
action: "admin.order_confirmed",
|
| 105 |
+
targetType: "Order",
|
| 106 |
+
targetId: resultOrder._id,
|
| 107 |
+
ip: req.ip,
|
| 108 |
+
userAgent: req.headers["user-agent"],
|
| 109 |
+
metadata: { userId: resultUser._id.toString() },
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
sendPaymentConfirmedEmail({
|
| 113 |
+
to: resultUser.email,
|
| 114 |
+
name: resultUser.name,
|
| 115 |
+
order: resultOrder,
|
| 116 |
+
courseTitle: course ? course.title : resultOrder.courseTitleSnapshot,
|
| 117 |
+
}).catch((err) => logger.error("Payment-confirmed email failed", { error: err.message }));
|
| 118 |
+
|
| 119 |
+
res.json({ order: resultOrder.toPublicJSON() });
|
| 120 |
+
} catch (err) {
|
| 121 |
+
next(err);
|
| 122 |
+
} finally {
|
| 123 |
+
session.endSession();
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async function rejectOrder(req, res, next) {
|
| 128 |
+
try {
|
| 129 |
+
const { reason } = req.body;
|
| 130 |
+
const order = await Order.findById(req.params.id).populate("user", "name email");
|
| 131 |
+
if (!order) return res.status(404).json({ error: "Order not found." });
|
| 132 |
+
if (order.status === "confirmed") {
|
| 133 |
+
return res.status(409).json({ error: "A confirmed order cannot be rejected. Consider revoking access separately." });
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
order.status = "rejected";
|
| 137 |
+
order.rejectionReason = reason || null;
|
| 138 |
+
await order.save();
|
| 139 |
+
|
| 140 |
+
await AuditLog.create({
|
| 141 |
+
actor: req.user._id,
|
| 142 |
+
actorEmail: req.user.email,
|
| 143 |
+
action: "admin.order_rejected",
|
| 144 |
+
targetType: "Order",
|
| 145 |
+
targetId: order._id,
|
| 146 |
+
ip: req.ip,
|
| 147 |
+
userAgent: req.headers["user-agent"],
|
| 148 |
+
metadata: { reason: reason || null },
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
if (order.user) {
|
| 152 |
+
sendPaymentRejectedEmail({
|
| 153 |
+
to: order.user.email,
|
| 154 |
+
name: order.user.name,
|
| 155 |
+
order,
|
| 156 |
+
courseTitle: order.courseTitleSnapshot,
|
| 157 |
+
reason,
|
| 158 |
+
}).catch((err) => logger.error("Payment-rejected email failed", { error: err.message }));
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
res.json({ order: order.toPublicJSON() });
|
| 162 |
+
} catch (err) {
|
| 163 |
+
next(err);
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async function dashboardStats(req, res, next) {
|
| 168 |
+
try {
|
| 169 |
+
const [pendingCount, confirmedCount, rejectedCount, totalUsers, revenueAgg] = await Promise.all([
|
| 170 |
+
Order.countDocuments({ status: "awaiting_confirmation" }),
|
| 171 |
+
Order.countDocuments({ status: "confirmed" }),
|
| 172 |
+
Order.countDocuments({ status: "rejected" }),
|
| 173 |
+
User.countDocuments({ role: "user" }),
|
| 174 |
+
Order.aggregate([{ $match: { status: "confirmed" } }, { $group: { _id: "$currency", total: { $sum: "$amount" } } }]),
|
| 175 |
+
]);
|
| 176 |
+
|
| 177 |
+
res.json({
|
| 178 |
+
pendingOrders: pendingCount,
|
| 179 |
+
confirmedOrders: confirmedCount,
|
| 180 |
+
rejectedOrders: rejectedCount,
|
| 181 |
+
totalUsers,
|
| 182 |
+
revenueByCurrency: revenueAgg.map((r) => ({ currency: r._id, total: r.total })),
|
| 183 |
+
});
|
| 184 |
+
} catch (err) {
|
| 185 |
+
next(err);
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
async function listUsers(req, res, next) {
|
| 190 |
+
try {
|
| 191 |
+
const users = await User.find({}).sort({ createdAt: -1 }).limit(500);
|
| 192 |
+
res.json({ users: users.map((u) => u.toSafeJSON()) });
|
| 193 |
+
} catch (err) {
|
| 194 |
+
next(err);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
module.exports = { listOrders, getOrderDetail, confirmOrder, rejectOrder, dashboardStats, listUsers };
|
src/controllers/auth.controller.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const User = require("../models/User");
|
| 2 |
+
const AuditLog = require("../models/AuditLog");
|
| 3 |
+
const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
|
| 4 |
+
const { env } = require("../config/env");
|
| 5 |
+
const logger = require("../utils/logger");
|
| 6 |
+
|
| 7 |
+
const MAX_FAILED_ATTEMPTS = 5;
|
| 8 |
+
const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
| 9 |
+
const REFRESH_COOKIE_NAME = "aynwerd_rt";
|
| 10 |
+
|
| 11 |
+
function refreshCookieOptions() {
|
| 12 |
+
return {
|
| 13 |
+
httpOnly: true,
|
| 14 |
+
secure: env.cookieSecure,
|
| 15 |
+
sameSite: "lax",
|
| 16 |
+
path: "/api/auth",
|
| 17 |
+
maxAge: 7 * 24 * 60 * 60 * 1000,
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function audit(action, req, extra = {}) {
|
| 22 |
+
try {
|
| 23 |
+
await AuditLog.create({
|
| 24 |
+
actor: req.user ? req.user._id : null,
|
| 25 |
+
actorEmail: req.user ? req.user.email : extra.email || null,
|
| 26 |
+
action,
|
| 27 |
+
ip: req.ip,
|
| 28 |
+
userAgent: req.headers["user-agent"],
|
| 29 |
+
metadata: extra.metadata || {},
|
| 30 |
+
});
|
| 31 |
+
} catch (err) {
|
| 32 |
+
logger.error("Failed to write audit log", { action, error: err.message });
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function signup(req, res, next) {
|
| 37 |
+
try {
|
| 38 |
+
const { name, email, password } = req.body;
|
| 39 |
+
|
| 40 |
+
const existing = await User.findOne({ email: email.toLowerCase() });
|
| 41 |
+
if (existing) {
|
| 42 |
+
// Do not reveal which part of the credential pair is wrong to
|
| 43 |
+
// avoid account enumeration.
|
| 44 |
+
return res.status(409).json({ error: "Unable to create account with the details provided." });
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const user = new User({ name, email: email.toLowerCase() });
|
| 48 |
+
await user.setPassword(password);
|
| 49 |
+
await user.save();
|
| 50 |
+
|
| 51 |
+
await audit("user.signup", req, { email: user.email });
|
| 52 |
+
|
| 53 |
+
const accessToken = signAccessToken(user);
|
| 54 |
+
const { token: refreshToken, jti } = signRefreshToken(user);
|
| 55 |
+
user.activeRefreshTokens = user.activeRefreshTokens || [];
|
| 56 |
+
user.activeRefreshTokens.push({
|
| 57 |
+
jti,
|
| 58 |
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 59 |
+
userAgent: req.headers["user-agent"],
|
| 60 |
+
ip: req.ip,
|
| 61 |
+
});
|
| 62 |
+
await user.save();
|
| 63 |
+
|
| 64 |
+
res.cookie(REFRESH_COOKIE_NAME, refreshToken, refreshCookieOptions());
|
| 65 |
+
res.status(201).json({ user: user.toSafeJSON(), accessToken });
|
| 66 |
+
} catch (err) {
|
| 67 |
+
next(err);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async function login(req, res, next) {
|
| 72 |
+
try {
|
| 73 |
+
const { email, password } = req.body;
|
| 74 |
+
|
| 75 |
+
const user = await User.findOne({ email: email.toLowerCase() }).select(
|
| 76 |
+
"+passwordHash +failedLoginAttempts +lockUntil +activeRefreshTokens"
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
// Constant-shaped response whether the user exists or not.
|
| 80 |
+
const genericError = () => res.status(401).json({ error: "Invalid email or password." });
|
| 81 |
+
|
| 82 |
+
if (!user || !user.isActive) return genericError();
|
| 83 |
+
|
| 84 |
+
if (user.isLocked()) {
|
| 85 |
+
return res.status(423).json({
|
| 86 |
+
error: "Account temporarily locked due to repeated failed attempts. Try again later.",
|
| 87 |
+
});
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const valid = await user.comparePassword(password);
|
| 91 |
+
if (!valid) {
|
| 92 |
+
user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;
|
| 93 |
+
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
|
| 94 |
+
user.lockUntil = new Date(Date.now() + LOCK_DURATION_MS);
|
| 95 |
+
user.failedLoginAttempts = 0;
|
| 96 |
+
await audit("user.login.locked", req, { email: user.email });
|
| 97 |
+
}
|
| 98 |
+
await user.save();
|
| 99 |
+
return genericError();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
user.failedLoginAttempts = 0;
|
| 103 |
+
user.lockUntil = null;
|
| 104 |
+
|
| 105 |
+
const accessToken = signAccessToken(user);
|
| 106 |
+
const { token: refreshToken, jti } = signRefreshToken(user);
|
| 107 |
+
user.activeRefreshTokens = (user.activeRefreshTokens || [])
|
| 108 |
+
.filter((rt) => rt.expiresAt > new Date()) // prune expired sessions
|
| 109 |
+
.concat({
|
| 110 |
+
jti,
|
| 111 |
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 112 |
+
userAgent: req.headers["user-agent"],
|
| 113 |
+
ip: req.ip,
|
| 114 |
+
});
|
| 115 |
+
await user.save();
|
| 116 |
+
|
| 117 |
+
await audit("user.login", req, { email: user.email });
|
| 118 |
+
|
| 119 |
+
res.cookie(REFRESH_COOKIE_NAME, refreshToken, refreshCookieOptions());
|
| 120 |
+
res.json({ user: user.toSafeJSON(), accessToken });
|
| 121 |
+
} catch (err) {
|
| 122 |
+
next(err);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
async function refresh(req, res, next) {
|
| 127 |
+
try {
|
| 128 |
+
const token = req.cookies[REFRESH_COOKIE_NAME];
|
| 129 |
+
if (!token) return res.status(401).json({ error: "No refresh token provided." });
|
| 130 |
+
|
| 131 |
+
let payload;
|
| 132 |
+
try {
|
| 133 |
+
payload = verifyRefreshToken(token);
|
| 134 |
+
} catch {
|
| 135 |
+
res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
|
| 136 |
+
return res.status(401).json({ error: "Invalid or expired session, please log in again." });
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const user = await User.findById(payload.sub).select("+activeRefreshTokens");
|
| 140 |
+
if (!user || !user.isActive || user.tokenVersion !== payload.tokenVersion) {
|
| 141 |
+
return res.status(401).json({ error: "Session no longer valid, please log in again." });
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
|
| 145 |
+
if (!stillActive) {
|
| 146 |
+
// Possible token reuse after logout/rotation — deny and clear cookie.
|
| 147 |
+
res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
|
| 148 |
+
return res.status(401).json({ error: "Session no longer valid, please log in again." });
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// Rotate: remove old jti, issue a new refresh token.
|
| 152 |
+
user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
|
| 153 |
+
const accessToken = signAccessToken(user);
|
| 154 |
+
const { token: newRefreshToken, jti: newJti } = signRefreshToken(user);
|
| 155 |
+
user.activeRefreshTokens.push({
|
| 156 |
+
jti: newJti,
|
| 157 |
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 158 |
+
userAgent: req.headers["user-agent"],
|
| 159 |
+
ip: req.ip,
|
| 160 |
+
});
|
| 161 |
+
await user.save();
|
| 162 |
+
|
| 163 |
+
res.cookie(REFRESH_COOKIE_NAME, newRefreshToken, refreshCookieOptions());
|
| 164 |
+
res.json({ accessToken, user: user.toSafeJSON() });
|
| 165 |
+
} catch (err) {
|
| 166 |
+
next(err);
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
async function logout(req, res, next) {
|
| 171 |
+
try {
|
| 172 |
+
const token = req.cookies[REFRESH_COOKIE_NAME];
|
| 173 |
+
if (token) {
|
| 174 |
+
try {
|
| 175 |
+
const payload = verifyRefreshToken(token);
|
| 176 |
+
await User.findByIdAndUpdate(payload.sub, { $pull: { activeRefreshTokens: { jti: payload.jti } } });
|
| 177 |
+
} catch {
|
| 178 |
+
// token already invalid — nothing to revoke
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
|
| 182 |
+
res.json({ success: true });
|
| 183 |
+
} catch (err) {
|
| 184 |
+
next(err);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
async function me(req, res) {
|
| 189 |
+
res.json({ user: req.user.toSafeJSON() });
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
module.exports = { signup, login, refresh, logout, me };
|
src/controllers/content.controller.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require("fs/promises");
|
| 2 |
+
const mongoose = require("mongoose");
|
| 3 |
+
const Course = require("../models/Course");
|
| 4 |
+
const AuditLog = require("../models/AuditLog");
|
| 5 |
+
const { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype } = require("../utils/cloudinaryUpload");
|
| 6 |
+
const logger = require("../utils/logger");
|
| 7 |
+
|
| 8 |
+
function userHasAccess(user, courseId) {
|
| 9 |
+
if (!user) return false;
|
| 10 |
+
if (user.role === "admin") return true;
|
| 11 |
+
return user.hasAccessToCourse(courseId);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// ---------------------------------------------------------------------
|
| 15 |
+
// Learner-facing: view curriculum structure, and request a signed,
|
| 16 |
+
// time-limited playback URL for one lesson at a time.
|
| 17 |
+
// ---------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
async function getCourseCurriculum(req, res, next) {
|
| 20 |
+
try {
|
| 21 |
+
const course = await Course.findById(req.params.id);
|
| 22 |
+
if (!course || !course.isPublished) return res.status(404).json({ error: "Course not found." });
|
| 23 |
+
|
| 24 |
+
const hasAccess = userHasAccess(req.user, course._id);
|
| 25 |
+
res.json({
|
| 26 |
+
courseId: course._id,
|
| 27 |
+
title: course.title,
|
| 28 |
+
hasAccess,
|
| 29 |
+
curriculum: hasAccess ? course.toCurriculumJSON() : course.toLockedCurriculumJSON(),
|
| 30 |
+
});
|
| 31 |
+
} catch (err) {
|
| 32 |
+
next(err);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function getLessonAccess(req, res, next) {
|
| 37 |
+
try {
|
| 38 |
+
const { id: courseId, sectionId, lessonId } = req.params;
|
| 39 |
+
|
| 40 |
+
const course = await Course.findById(courseId);
|
| 41 |
+
if (!course || !course.isPublished) return res.status(404).json({ error: "Course not found." });
|
| 42 |
+
|
| 43 |
+
const section = course.curriculum.id(sectionId);
|
| 44 |
+
const lesson = section?.lessons.id(lessonId);
|
| 45 |
+
if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." });
|
| 46 |
+
|
| 47 |
+
const hasAccess = userHasAccess(req.user, course._id);
|
| 48 |
+
if (!hasAccess && !lesson.isPreview) {
|
| 49 |
+
return res.status(403).json({ error: "Purchase this course to access this lesson." });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const { url, expiresAt } = getSignedPlaybackUrl(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType, {
|
| 53 |
+
format: lesson.format,
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
await AuditLog.create({
|
| 57 |
+
actor: req.user._id,
|
| 58 |
+
actorEmail: req.user.email,
|
| 59 |
+
action: "lesson.access_granted",
|
| 60 |
+
targetType: "Lesson",
|
| 61 |
+
targetId: lesson._id,
|
| 62 |
+
ip: req.ip,
|
| 63 |
+
userAgent: req.headers["user-agent"],
|
| 64 |
+
metadata: { courseId: course._id.toString() },
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
res.json({
|
| 68 |
+
url,
|
| 69 |
+
expiresAt,
|
| 70 |
+
type: lesson.type,
|
| 71 |
+
title: lesson.title,
|
| 72 |
+
});
|
| 73 |
+
} catch (err) {
|
| 74 |
+
next(err);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// ---------------------------------------------------------------------
|
| 79 |
+
// Admin-only: manage curriculum structure and upload content to
|
| 80 |
+
// Cloudinary. No learner-facing route ever exposes an upload capability.
|
| 81 |
+
// ---------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
async function createSection(req, res, next) {
|
| 84 |
+
try {
|
| 85 |
+
const { title } = req.body;
|
| 86 |
+
const course = await Course.findById(req.params.id);
|
| 87 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 88 |
+
|
| 89 |
+
const order = course.curriculum.length;
|
| 90 |
+
course.curriculum.push({ title, order, lessons: [] });
|
| 91 |
+
await course.save();
|
| 92 |
+
|
| 93 |
+
res.status(201).json({ curriculum: course.toCurriculumJSON() });
|
| 94 |
+
} catch (err) {
|
| 95 |
+
next(err);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
async function updateSection(req, res, next) {
|
| 100 |
+
try {
|
| 101 |
+
const { title, order } = req.body;
|
| 102 |
+
const course = await Course.findById(req.params.id);
|
| 103 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 104 |
+
|
| 105 |
+
const section = course.curriculum.id(req.params.sectionId);
|
| 106 |
+
if (!section) return res.status(404).json({ error: "Section not found." });
|
| 107 |
+
|
| 108 |
+
if (title !== undefined) section.title = title;
|
| 109 |
+
if (order !== undefined) section.order = order;
|
| 110 |
+
await course.save();
|
| 111 |
+
|
| 112 |
+
res.json({ curriculum: course.toCurriculumJSON() });
|
| 113 |
+
} catch (err) {
|
| 114 |
+
next(err);
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
async function deleteSection(req, res, next) {
|
| 119 |
+
try {
|
| 120 |
+
const course = await Course.findById(req.params.id);
|
| 121 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 122 |
+
|
| 123 |
+
const section = course.curriculum.id(req.params.sectionId);
|
| 124 |
+
if (!section) return res.status(404).json({ error: "Section not found." });
|
| 125 |
+
|
| 126 |
+
// Clean up every Cloudinary asset in this section before removing it.
|
| 127 |
+
await Promise.all(
|
| 128 |
+
section.lessons.map((lesson) =>
|
| 129 |
+
deleteAsset(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType).catch((err) =>
|
| 130 |
+
logger.error("Failed to delete Cloudinary asset", { error: err.message, lessonId: lesson._id.toString() })
|
| 131 |
+
)
|
| 132 |
+
)
|
| 133 |
+
);
|
| 134 |
+
|
| 135 |
+
section.deleteOne();
|
| 136 |
+
await course.save();
|
| 137 |
+
|
| 138 |
+
res.json({ curriculum: course.toCurriculumJSON() });
|
| 139 |
+
} catch (err) {
|
| 140 |
+
next(err);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
async function uploadLesson(req, res, next) {
|
| 145 |
+
const tempFilePath = req.file?.path;
|
| 146 |
+
try {
|
| 147 |
+
const { title, description, isPreview, order } = req.body;
|
| 148 |
+
if (!req.file) return res.status(400).json({ error: "A file is required." });
|
| 149 |
+
|
| 150 |
+
const course = await Course.findById(req.params.id);
|
| 151 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 152 |
+
|
| 153 |
+
const section = course.curriculum.id(req.params.sectionId);
|
| 154 |
+
if (!section) return res.status(404).json({ error: "Section not found." });
|
| 155 |
+
|
| 156 |
+
const resourceType = resourceTypeForMimetype(req.file.mimetype);
|
| 157 |
+
const lessonType = resourceType === "video" ? "video" : "document";
|
| 158 |
+
|
| 159 |
+
const result = await uploadFromPath(tempFilePath, {
|
| 160 |
+
resourceType,
|
| 161 |
+
filenameHint: title,
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
section.lessons.push({
|
| 165 |
+
title,
|
| 166 |
+
description: description || "",
|
| 167 |
+
type: lessonType,
|
| 168 |
+
order: order !== undefined ? Number(order) : section.lessons.length,
|
| 169 |
+
isPreview: isPreview === "true" || isPreview === true,
|
| 170 |
+
cloudinaryPublicId: result.public_id,
|
| 171 |
+
cloudinaryResourceType: result.resource_type,
|
| 172 |
+
format: result.format || "",
|
| 173 |
+
bytes: result.bytes || 0,
|
| 174 |
+
durationSeconds: result.duration ? Math.round(result.duration) : 0,
|
| 175 |
+
});
|
| 176 |
+
await course.save();
|
| 177 |
+
|
| 178 |
+
await AuditLog.create({
|
| 179 |
+
actor: req.user._id,
|
| 180 |
+
actorEmail: req.user.email,
|
| 181 |
+
action: "admin.lesson_uploaded",
|
| 182 |
+
targetType: "Course",
|
| 183 |
+
targetId: course._id,
|
| 184 |
+
ip: req.ip,
|
| 185 |
+
userAgent: req.headers["user-agent"],
|
| 186 |
+
metadata: { sectionId: section._id.toString(), title },
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
res.status(201).json({ curriculum: course.toCurriculumJSON() });
|
| 190 |
+
} catch (err) {
|
| 191 |
+
next(err);
|
| 192 |
+
} finally {
|
| 193 |
+
// Always clean up the local temp file, whether the upload succeeded,
|
| 194 |
+
// failed partway, or Cloudinary itself errored out.
|
| 195 |
+
if (tempFilePath) {
|
| 196 |
+
fs.unlink(tempFilePath).catch((err) =>
|
| 197 |
+
logger.error("Failed to remove temp upload file", { error: err.message, tempFilePath })
|
| 198 |
+
);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
async function updateLesson(req, res, next) {
|
| 204 |
+
try {
|
| 205 |
+
const { title, description, isPreview, order } = req.body;
|
| 206 |
+
const course = await Course.findById(req.params.id);
|
| 207 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 208 |
+
|
| 209 |
+
const section = course.curriculum.id(req.params.sectionId);
|
| 210 |
+
const lesson = section?.lessons.id(req.params.lessonId);
|
| 211 |
+
if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." });
|
| 212 |
+
|
| 213 |
+
if (title !== undefined) lesson.title = title;
|
| 214 |
+
if (description !== undefined) lesson.description = description;
|
| 215 |
+
if (isPreview !== undefined) lesson.isPreview = isPreview === "true" || isPreview === true;
|
| 216 |
+
if (order !== undefined) lesson.order = Number(order);
|
| 217 |
+
await course.save();
|
| 218 |
+
|
| 219 |
+
res.json({ curriculum: course.toCurriculumJSON() });
|
| 220 |
+
} catch (err) {
|
| 221 |
+
next(err);
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
async function deleteLesson(req, res, next) {
|
| 226 |
+
try {
|
| 227 |
+
const course = await Course.findById(req.params.id);
|
| 228 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 229 |
+
|
| 230 |
+
const section = course.curriculum.id(req.params.sectionId);
|
| 231 |
+
const lesson = section?.lessons.id(req.params.lessonId);
|
| 232 |
+
if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." });
|
| 233 |
+
|
| 234 |
+
await deleteAsset(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType).catch((err) =>
|
| 235 |
+
logger.error("Failed to delete Cloudinary asset", { error: err.message })
|
| 236 |
+
);
|
| 237 |
+
|
| 238 |
+
lesson.deleteOne();
|
| 239 |
+
await course.save();
|
| 240 |
+
|
| 241 |
+
await AuditLog.create({
|
| 242 |
+
actor: req.user._id,
|
| 243 |
+
actorEmail: req.user.email,
|
| 244 |
+
action: "admin.lesson_deleted",
|
| 245 |
+
targetType: "Course",
|
| 246 |
+
targetId: course._id,
|
| 247 |
+
ip: req.ip,
|
| 248 |
+
userAgent: req.headers["user-agent"],
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
res.json({ curriculum: course.toCurriculumJSON() });
|
| 252 |
+
} catch (err) {
|
| 253 |
+
next(err);
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/** Admin editing view: full curriculum, same shape as the learner view (no raw Cloudinary IDs sent to the browser here either — uploads/deletes are the only mutating ops, playback still goes through the signed-URL endpoint). */
|
| 258 |
+
async function getCourseCurriculumForAdmin(req, res, next) {
|
| 259 |
+
try {
|
| 260 |
+
const course = await Course.findById(req.params.id);
|
| 261 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 262 |
+
res.json({ courseId: course._id, title: course.title, curriculum: course.toCurriculumJSON() });
|
| 263 |
+
} catch (err) {
|
| 264 |
+
next(err);
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
module.exports = {
|
| 269 |
+
getCourseCurriculum,
|
| 270 |
+
getLessonAccess,
|
| 271 |
+
getCourseCurriculumForAdmin,
|
| 272 |
+
createSection,
|
| 273 |
+
updateSection,
|
| 274 |
+
deleteSection,
|
| 275 |
+
uploadLesson,
|
| 276 |
+
updateLesson,
|
| 277 |
+
deleteLesson,
|
| 278 |
+
};
|
src/controllers/course.controller.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Course = require("../models/Course");
|
| 2 |
+
|
| 3 |
+
async function listCourses(req, res, next) {
|
| 4 |
+
try {
|
| 5 |
+
const filter = req.user?.role === "admin" ? {} : { isPublished: true };
|
| 6 |
+
const courses = await Course.find(filter).sort({ createdAt: 1 });
|
| 7 |
+
res.json({ courses });
|
| 8 |
+
} catch (err) {
|
| 9 |
+
next(err);
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async function getCourse(req, res, next) {
|
| 14 |
+
try {
|
| 15 |
+
const course = await Course.findById(req.params.id);
|
| 16 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 17 |
+
res.json({ course });
|
| 18 |
+
} catch (err) {
|
| 19 |
+
next(err);
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async function createCourse(req, res, next) {
|
| 24 |
+
try {
|
| 25 |
+
const course = await Course.create(req.body);
|
| 26 |
+
res.status(201).json({ course });
|
| 27 |
+
} catch (err) {
|
| 28 |
+
if (err.code === 11000) return res.status(409).json({ error: "A course with this slug already exists." });
|
| 29 |
+
next(err);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async function updateCourse(req, res, next) {
|
| 34 |
+
try {
|
| 35 |
+
const course = await Course.findByIdAndUpdate(req.params.id, req.body, {
|
| 36 |
+
new: true,
|
| 37 |
+
runValidators: true,
|
| 38 |
+
});
|
| 39 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 40 |
+
res.json({ course });
|
| 41 |
+
} catch (err) {
|
| 42 |
+
next(err);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
async function deleteCourse(req, res, next) {
|
| 47 |
+
try {
|
| 48 |
+
const course = await Course.findByIdAndUpdate(req.params.id, { isPublished: false }, { new: true });
|
| 49 |
+
if (!course) return res.status(404).json({ error: "Course not found." });
|
| 50 |
+
res.json({ success: true });
|
| 51 |
+
} catch (err) {
|
| 52 |
+
next(err);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
module.exports = { listCourses, getCourse, createCourse, updateCourse, deleteCourse };
|
src/controllers/order.controller.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const Order = require("../models/Order");
|
| 3 |
+
const Course = require("../models/Course");
|
| 4 |
+
const AuditLog = require("../models/AuditLog");
|
| 5 |
+
const { generateOrderNumber } = require("../utils/orderNumber");
|
| 6 |
+
const { env } = require("../config/env");
|
| 7 |
+
const { sendOrderPlacedEmail, sendAdminNewOrderAlert } = require("../utils/email");
|
| 8 |
+
const logger = require("../utils/logger");
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Public checkout configuration — payment instructions shown on the
|
| 12 |
+
* frontend before the customer submits their reference number. No
|
| 13 |
+
* secrets here, just receiving-account details that are meant to be
|
| 14 |
+
* shared with paying customers.
|
| 15 |
+
*/
|
| 16 |
+
function getPaymentOptions(req, res) {
|
| 17 |
+
res.json({
|
| 18 |
+
bankWire: {
|
| 19 |
+
currency: "USD",
|
| 20 |
+
bankName: env.bank.name,
|
| 21 |
+
accountName: env.bank.accountName,
|
| 22 |
+
accountNumber: env.bank.accountNumber,
|
| 23 |
+
routingNumber: env.bank.routingNumber,
|
| 24 |
+
swiftBic: env.bank.swiftBic,
|
| 25 |
+
iban: env.bank.iban || undefined,
|
| 26 |
+
address: env.bank.address,
|
| 27 |
+
},
|
| 28 |
+
payoneer: {
|
| 29 |
+
accountEmail: env.payoneer.accountEmail,
|
| 30 |
+
paymentUrl: env.payoneer.paymentUrl,
|
| 31 |
+
},
|
| 32 |
+
});
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async function createOrder(req, res, next) {
|
| 36 |
+
try {
|
| 37 |
+
const { courseId, paymentMethod } = req.body;
|
| 38 |
+
|
| 39 |
+
if (!mongoose.isValidObjectId(courseId)) {
|
| 40 |
+
return res.status(400).json({ error: "Invalid course selected." });
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const course = await Course.findById(courseId);
|
| 44 |
+
if (!course || !course.isPublished) {
|
| 45 |
+
return res.status(404).json({ error: "Course not found or unavailable." });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (req.user.hasAccessToCourse(course._id)) {
|
| 49 |
+
return res.status(409).json({ error: "You already have access to this course." });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Prevent duplicate open orders for the same course by the same user.
|
| 53 |
+
const existingOpenOrder = await Order.findOne({
|
| 54 |
+
user: req.user._id,
|
| 55 |
+
course: course._id,
|
| 56 |
+
status: { $in: ["pending_payment", "awaiting_confirmation"] },
|
| 57 |
+
});
|
| 58 |
+
if (existingOpenOrder) {
|
| 59 |
+
return res.status(200).json({ order: existingOpenOrder.toPublicJSON(), reused: true });
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const order = new Order({
|
| 63 |
+
orderNumber: generateOrderNumber(),
|
| 64 |
+
user: req.user._id,
|
| 65 |
+
course: course._id,
|
| 66 |
+
courseTitleSnapshot: course.title,
|
| 67 |
+
amount: course.price,
|
| 68 |
+
currency: course.currency || "USD",
|
| 69 |
+
paymentMethod,
|
| 70 |
+
status: "awaiting_confirmation",
|
| 71 |
+
});
|
| 72 |
+
await order.save();
|
| 73 |
+
|
| 74 |
+
await AuditLog.create({
|
| 75 |
+
actor: req.user._id,
|
| 76 |
+
actorEmail: req.user.email,
|
| 77 |
+
action: "order.created",
|
| 78 |
+
targetType: "Order",
|
| 79 |
+
targetId: order._id,
|
| 80 |
+
ip: req.ip,
|
| 81 |
+
userAgent: req.headers["user-agent"],
|
| 82 |
+
metadata: { courseId: course._id.toString(), paymentMethod },
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
// Fire-and-forget emails; failures are logged but never block checkout.
|
| 86 |
+
sendOrderPlacedEmail({
|
| 87 |
+
to: req.user.email,
|
| 88 |
+
name: req.user.name,
|
| 89 |
+
order,
|
| 90 |
+
courseTitle: course.title,
|
| 91 |
+
bankDetails: env.bank,
|
| 92 |
+
payoneerDetails: env.payoneer,
|
| 93 |
+
})
|
| 94 |
+
.then(() => {
|
| 95 |
+
order.confirmationEmailSentAt = new Date();
|
| 96 |
+
return order.save();
|
| 97 |
+
})
|
| 98 |
+
.catch((err) => logger.error("Order confirmation email failed", { error: err.message }));
|
| 99 |
+
|
| 100 |
+
sendAdminNewOrderAlert({ order, courseTitle: course.title, customerEmail: req.user.email }).catch((err) =>
|
| 101 |
+
logger.error("Admin new-order alert failed", { error: err.message })
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
res.status(201).json({ order: order.toPublicJSON() });
|
| 105 |
+
} catch (err) {
|
| 106 |
+
next(err);
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/** Customer submits their bank wire reference / Payoneer transaction ID after paying. */
|
| 111 |
+
async function submitPaymentProof(req, res, next) {
|
| 112 |
+
try {
|
| 113 |
+
const { reference, note } = req.body;
|
| 114 |
+
const order = await Order.findOne({ _id: req.params.id, user: req.user._id });
|
| 115 |
+
if (!order) return res.status(404).json({ error: "Order not found." });
|
| 116 |
+
|
| 117 |
+
if (!["pending_payment", "awaiting_confirmation"].includes(order.status)) {
|
| 118 |
+
return res.status(409).json({ error: "This order can no longer be updated." });
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
order.setPaymentReference(reference);
|
| 122 |
+
if (note) order.setPayerNote(note);
|
| 123 |
+
order.status = "awaiting_confirmation";
|
| 124 |
+
await order.save();
|
| 125 |
+
|
| 126 |
+
await AuditLog.create({
|
| 127 |
+
actor: req.user._id,
|
| 128 |
+
actorEmail: req.user.email,
|
| 129 |
+
action: "order.payment_proof_submitted",
|
| 130 |
+
targetType: "Order",
|
| 131 |
+
targetId: order._id,
|
| 132 |
+
ip: req.ip,
|
| 133 |
+
userAgent: req.headers["user-agent"],
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
res.json({ order: order.toPublicJSON() });
|
| 137 |
+
} catch (err) {
|
| 138 |
+
next(err);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
async function listMyOrders(req, res, next) {
|
| 143 |
+
try {
|
| 144 |
+
const orders = await Order.find({ user: req.user._id }).sort({ createdAt: -1 });
|
| 145 |
+
res.json({ orders: orders.map((o) => o.toPublicJSON()) });
|
| 146 |
+
} catch (err) {
|
| 147 |
+
next(err);
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async function getMyOrder(req, res, next) {
|
| 152 |
+
try {
|
| 153 |
+
const order = await Order.findOne({ _id: req.params.id, user: req.user._id });
|
| 154 |
+
if (!order) return res.status(404).json({ error: "Order not found." });
|
| 155 |
+
res.json({ order: order.toPublicJSON({ includeSensitive: true }) });
|
| 156 |
+
} catch (err) {
|
| 157 |
+
next(err);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
module.exports = { getPaymentOptions, createOrder, submitPaymentProof, listMyOrders, getMyOrder };
|
src/middleware/auth.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { verifyAccessToken } = require("../utils/jwt");
|
| 2 |
+
const User = require("../models/User");
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Requires a valid, non-expired access token in the Authorization header.
|
| 6 |
+
* Attaches the full (fresh) user document to req.user so downstream
|
| 7 |
+
* handlers always see current role/lock/tokenVersion state rather than
|
| 8 |
+
* trusting stale claims baked into the JWT.
|
| 9 |
+
*/
|
| 10 |
+
async function requireAuth(req, res, next) {
|
| 11 |
+
try {
|
| 12 |
+
const header = req.headers.authorization || "";
|
| 13 |
+
const [scheme, token] = header.split(" ");
|
| 14 |
+
if (scheme !== "Bearer" || !token) {
|
| 15 |
+
return res.status(401).json({ error: "Authentication required." });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const payload = verifyAccessToken(token);
|
| 19 |
+
const user = await User.findById(payload.sub);
|
| 20 |
+
|
| 21 |
+
if (!user || !user.isActive) {
|
| 22 |
+
return res.status(401).json({ error: "Account not found or disabled." });
|
| 23 |
+
}
|
| 24 |
+
if (user.tokenVersion !== payload.tokenVersion) {
|
| 25 |
+
// Token was issued before a password change / forced logout.
|
| 26 |
+
return res.status(401).json({ error: "Session expired, please log in again." });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
req.user = user;
|
| 30 |
+
next();
|
| 31 |
+
} catch (err) {
|
| 32 |
+
return res.status(401).json({ error: "Invalid or expired token." });
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function requireRole(...roles) {
|
| 37 |
+
return (req, res, next) => {
|
| 38 |
+
if (!req.user || !roles.includes(req.user.role)) {
|
| 39 |
+
return res.status(403).json({ error: "You do not have permission to perform this action." });
|
| 40 |
+
}
|
| 41 |
+
next();
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
module.exports = { requireAuth, requireRole };
|
src/middleware/errorHandler.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const logger = require("../utils/logger");
|
| 2 |
+
|
| 3 |
+
// eslint-disable-next-line no-unused-vars
|
| 4 |
+
function errorHandler(err, req, res, next) {
|
| 5 |
+
let status = err.status || 500;
|
| 6 |
+
|
| 7 |
+
// Multer surfaces file-size/type problems as plain Errors/MulterErrors
|
| 8 |
+
// without a status code — treat those as client errors (400), not
|
| 9 |
+
// server failures.
|
| 10 |
+
if (err.name === "MulterError" || /Unsupported file type/.test(err.message || "")) {
|
| 11 |
+
status = 400;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
logger.error("Unhandled error", {
|
| 15 |
+
message: err.message,
|
| 16 |
+
status,
|
| 17 |
+
path: req.path,
|
| 18 |
+
method: req.method,
|
| 19 |
+
// stack only ever goes to server-side logs, never to the client
|
| 20 |
+
stack: err.stack,
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
const isProd = process.env.NODE_ENV === "production";
|
| 24 |
+
res.status(status).json({
|
| 25 |
+
error: status === 500 && isProd ? "Something went wrong. Please try again later." : err.message,
|
| 26 |
+
});
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function notFound(req, res) {
|
| 30 |
+
res.status(404).json({ error: "Not found." });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
module.exports = { errorHandler, notFound };
|
src/middleware/rateLimiters.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const rateLimit = require("express-rate-limit");
|
| 2 |
+
|
| 3 |
+
// General API traffic
|
| 4 |
+
const apiLimiter = rateLimit({
|
| 5 |
+
windowMs: 15 * 60 * 1000,
|
| 6 |
+
limit: 300,
|
| 7 |
+
standardHeaders: true,
|
| 8 |
+
legacyHeaders: false,
|
| 9 |
+
message: { error: "Too many requests, please try again later." },
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
// Tight limits on auth endpoints to blunt credential stuffing / brute force
|
| 13 |
+
const authLimiter = rateLimit({
|
| 14 |
+
windowMs: 15 * 60 * 1000,
|
| 15 |
+
limit: 10,
|
| 16 |
+
standardHeaders: true,
|
| 17 |
+
legacyHeaders: false,
|
| 18 |
+
skipSuccessfulRequests: true,
|
| 19 |
+
message: { error: "Too many attempts. Please wait a few minutes and try again." },
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// Checkout/order creation limiter to prevent order-spam / enumeration
|
| 23 |
+
const orderLimiter = rateLimit({
|
| 24 |
+
windowMs: 60 * 60 * 1000,
|
| 25 |
+
limit: 20,
|
| 26 |
+
standardHeaders: true,
|
| 27 |
+
legacyHeaders: false,
|
| 28 |
+
message: { error: "Too many order attempts. Please try again later or contact support." },
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
module.exports = { apiLimiter, authLimiter, orderLimiter };
|
src/middleware/upload.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const multer = require("multer");
|
| 2 |
+
const os = require("os");
|
| 3 |
+
const path = require("path");
|
| 4 |
+
const crypto = require("crypto");
|
| 5 |
+
|
| 6 |
+
// Only the file types actually needed for course content are allowed.
|
| 7 |
+
// Uploads are only ever accepted from an authenticated admin (enforced
|
| 8 |
+
// in the route, not here), but validating the mimetype/extension server-
|
| 9 |
+
// side is still essential since a client can lie about content-type.
|
| 10 |
+
const ALLOWED_MIMETYPES = new Set([
|
| 11 |
+
"video/mp4",
|
| 12 |
+
"video/webm",
|
| 13 |
+
"video/quicktime",
|
| 14 |
+
"application/pdf",
|
| 15 |
+
"application/msword",
|
| 16 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 17 |
+
"application/vnd.ms-powerpoint",
|
| 18 |
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
| 19 |
+
"image/png",
|
| 20 |
+
"image/jpeg",
|
| 21 |
+
"image/webp",
|
| 22 |
+
]);
|
| 23 |
+
|
| 24 |
+
const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500MB — generous for course videos on Cloudinary's free tier
|
| 25 |
+
|
| 26 |
+
// Files are streamed to a temp file on disk rather than buffered in
|
| 27 |
+
// memory: buffering large videos in RAM can exhaust the memory limit on
|
| 28 |
+
// small/free-tier hosts (e.g. a 512MB instance) well before hitting the
|
| 29 |
+
// 500MB file-size cap. The temp file is deleted right after the upload
|
| 30 |
+
// completes (see content.controller.js), whether it succeeds or fails.
|
| 31 |
+
const storage = multer.diskStorage({
|
| 32 |
+
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
| 33 |
+
filename: (req, file, cb) => {
|
| 34 |
+
const unique = crypto.randomBytes(16).toString("hex");
|
| 35 |
+
cb(null, `aynwerd-upload-${unique}${path.extname(file.originalname)}`);
|
| 36 |
+
},
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
const upload = multer({
|
| 40 |
+
storage,
|
| 41 |
+
limits: { fileSize: MAX_FILE_SIZE_BYTES, files: 1 },
|
| 42 |
+
fileFilter(req, file, cb) {
|
| 43 |
+
if (!ALLOWED_MIMETYPES.has(file.mimetype)) {
|
| 44 |
+
return cb(new Error("Unsupported file type. Allowed: mp4/webm/mov video, pdf, doc(x), ppt(x), png/jpg/webp."));
|
| 45 |
+
}
|
| 46 |
+
cb(null, true);
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
module.exports = { upload };
|
src/middleware/validate.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { validationResult } = require("express-validator");
|
| 2 |
+
|
| 3 |
+
function validate(req, res, next) {
|
| 4 |
+
const errors = validationResult(req);
|
| 5 |
+
if (!errors.isEmpty()) {
|
| 6 |
+
return res.status(400).json({
|
| 7 |
+
error: "Validation failed.",
|
| 8 |
+
details: errors.array().map((e) => ({ field: e.path, message: e.msg })),
|
| 9 |
+
});
|
| 10 |
+
}
|
| 11 |
+
next();
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
module.exports = { validate };
|
src/models/AuditLog.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
|
| 3 |
+
// Immutable-by-convention trail of security-sensitive actions (payment
|
| 4 |
+
// confirmations/rejections, role changes, logins) for accountability and
|
| 5 |
+
// incident investigation. Nothing sensitive (passwords, tokens, full
|
| 6 |
+
// payment references) is ever written here.
|
| 7 |
+
const auditLogSchema = new mongoose.Schema(
|
| 8 |
+
{
|
| 9 |
+
actor: { type: mongoose.Schema.Types.ObjectId, ref: "User", default: null },
|
| 10 |
+
actorEmail: { type: String, default: null },
|
| 11 |
+
action: { type: String, required: true, index: true },
|
| 12 |
+
targetType: { type: String, default: null },
|
| 13 |
+
targetId: { type: mongoose.Schema.Types.ObjectId, default: null },
|
| 14 |
+
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
|
| 15 |
+
ip: { type: String, default: null },
|
| 16 |
+
userAgent: { type: String, default: null },
|
| 17 |
+
},
|
| 18 |
+
{ timestamps: true, capped: { size: 50 * 1024 * 1024, max: 200000 } }
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
module.exports = mongoose.model("AuditLog", auditLogSchema);
|
src/models/Course.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
|
| 3 |
+
const lessonSchema = new mongoose.Schema(
|
| 4 |
+
{
|
| 5 |
+
title: { type: String, required: true, trim: true, maxlength: 200 },
|
| 6 |
+
description: { type: String, default: "", maxlength: 1000 },
|
| 7 |
+
type: { type: String, enum: ["video", "document"], required: true },
|
| 8 |
+
order: { type: Number, default: 0 },
|
| 9 |
+
|
| 10 |
+
// Free preview lessons can be viewed without purchasing the course
|
| 11 |
+
// (e.g. an intro video) — everything else requires confirmed access.
|
| 12 |
+
isPreview: { type: Boolean, default: false },
|
| 13 |
+
|
| 14 |
+
// Cloudinary storage details. Uploaded with delivery type
|
| 15 |
+
// "authenticated" so files can NEVER be fetched by guessing/sharing
|
| 16 |
+
// the public ID — every playback URL must be freshly signed
|
| 17 |
+
// server-side (see utils/cloudinaryUpload.js) for a user who is
|
| 18 |
+
// confirmed to have access. These are plain (selected-by-default)
|
| 19 |
+
// fields — deliberately NOT using Mongoose's `select: false` here,
|
| 20 |
+
// because that projection option is unreliable two levels deep
|
| 21 |
+
// inside an array-of-subdocuments-within-an-array-of-subdocuments
|
| 22 |
+
// (curriculum[].lessons[].field) across Mongoose versions. The real
|
| 23 |
+
// security boundary is that every response is built through
|
| 24 |
+
// toCurriculumJSON()/toLockedCurriculumJSON() below, which simply
|
| 25 |
+
// never copies these fields into what gets sent to the client.
|
| 26 |
+
cloudinaryPublicId: { type: String, required: true },
|
| 27 |
+
cloudinaryResourceType: { type: String, enum: ["video", "raw", "image"], required: true },
|
| 28 |
+
format: { type: String, default: "" },
|
| 29 |
+
bytes: { type: Number, default: 0 },
|
| 30 |
+
durationSeconds: { type: Number, default: 0 },
|
| 31 |
+
},
|
| 32 |
+
{ timestamps: true }
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
const sectionSchema = new mongoose.Schema(
|
| 36 |
+
{
|
| 37 |
+
title: { type: String, required: true, trim: true, maxlength: 200 },
|
| 38 |
+
order: { type: Number, default: 0 },
|
| 39 |
+
lessons: { type: [lessonSchema], default: [] },
|
| 40 |
+
},
|
| 41 |
+
{ timestamps: true }
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
const courseSchema = new mongoose.Schema(
|
| 45 |
+
{
|
| 46 |
+
title: { type: String, required: true, trim: true, maxlength: 200 },
|
| 47 |
+
slug: { type: String, required: true, unique: true, lowercase: true, trim: true, index: true },
|
| 48 |
+
price: { type: Number, required: true, min: 0 },
|
| 49 |
+
currency: { type: String, default: "USD", uppercase: true, minlength: 3, maxlength: 3 },
|
| 50 |
+
duration: { type: String, required: true, trim: true },
|
| 51 |
+
level: { type: String, required: true, trim: true },
|
| 52 |
+
description: { type: String, required: true },
|
| 53 |
+
image: { type: String, default: "" },
|
| 54 |
+
highlights: { type: [String], default: [] },
|
| 55 |
+
isPublished: { type: Boolean, default: true },
|
| 56 |
+
|
| 57 |
+
// Study content, uploaded exclusively by admins (see content.controller.js).
|
| 58 |
+
curriculum: { type: [sectionSchema], default: [] },
|
| 59 |
+
},
|
| 60 |
+
{ timestamps: true }
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
/** Curriculum view for enrolled users / admins: structure + metadata, no storage internals. */
|
| 64 |
+
courseSchema.methods.toCurriculumJSON = function toCurriculumJSON() {
|
| 65 |
+
return this.curriculum
|
| 66 |
+
.slice()
|
| 67 |
+
.sort((a, b) => a.order - b.order)
|
| 68 |
+
.map((section) => ({
|
| 69 |
+
id: section._id,
|
| 70 |
+
title: section.title,
|
| 71 |
+
order: section.order,
|
| 72 |
+
lessons: section.lessons
|
| 73 |
+
.slice()
|
| 74 |
+
.sort((a, b) => a.order - b.order)
|
| 75 |
+
.map((lesson) => ({
|
| 76 |
+
id: lesson._id,
|
| 77 |
+
title: lesson.title,
|
| 78 |
+
description: lesson.description,
|
| 79 |
+
type: lesson.type,
|
| 80 |
+
order: lesson.order,
|
| 81 |
+
isPreview: lesson.isPreview,
|
| 82 |
+
durationSeconds: lesson.durationSeconds,
|
| 83 |
+
})),
|
| 84 |
+
}));
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
/** Locked view for logged-in users who have NOT purchased the course: titles only, previews flagged. */
|
| 88 |
+
courseSchema.methods.toLockedCurriculumJSON = function toLockedCurriculumJSON() {
|
| 89 |
+
return this.toCurriculumJSON().map((section) => ({
|
| 90 |
+
...section,
|
| 91 |
+
lessons: section.lessons.map((lesson) => ({
|
| 92 |
+
id: lesson.id,
|
| 93 |
+
title: lesson.title,
|
| 94 |
+
type: lesson.type,
|
| 95 |
+
order: lesson.order,
|
| 96 |
+
isPreview: lesson.isPreview,
|
| 97 |
+
durationSeconds: lesson.durationSeconds,
|
| 98 |
+
locked: !lesson.isPreview,
|
| 99 |
+
})),
|
| 100 |
+
}));
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
module.exports = mongoose.model("Course", courseSchema);
|
| 104 |
+
|
src/models/Order.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const { encryptField, decryptField, hmacIndex } = require("../utils/crypto");
|
| 3 |
+
|
| 4 |
+
const PAYMENT_METHODS = ["bank_wire", "payoneer"];
|
| 5 |
+
const ORDER_STATUSES = ["pending_payment", "awaiting_confirmation", "confirmed", "rejected", "cancelled"];
|
| 6 |
+
|
| 7 |
+
const orderSchema = new mongoose.Schema(
|
| 8 |
+
{
|
| 9 |
+
orderNumber: { type: String, required: true, unique: true, index: true },
|
| 10 |
+
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
|
| 11 |
+
course: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
|
| 12 |
+
|
| 13 |
+
// Snapshot of course price/title at time of purchase so historical
|
| 14 |
+
// orders remain accurate even if the course is later edited.
|
| 15 |
+
courseTitleSnapshot: { type: String, required: true },
|
| 16 |
+
amount: { type: Number, required: true, min: 0 },
|
| 17 |
+
currency: { type: String, default: "USD" },
|
| 18 |
+
|
| 19 |
+
paymentMethod: { type: String, enum: PAYMENT_METHODS, required: true },
|
| 20 |
+
|
| 21 |
+
// Sensitive: encrypted at rest with AES-256-GCM. Never returned by
|
| 22 |
+
// default queries (select: false) and never logged in plaintext.
|
| 23 |
+
// paymentReferenceIndex is a keyed HMAC used only to detect
|
| 24 |
+
// duplicate submissions without ever decrypting.
|
| 25 |
+
paymentReferenceEncrypted: { type: String, default: null, select: false },
|
| 26 |
+
paymentReferenceIndex: { type: String, default: null, index: true },
|
| 27 |
+
payerNoteEncrypted: { type: String, default: null, select: false },
|
| 28 |
+
|
| 29 |
+
status: { type: String, enum: ORDER_STATUSES, default: "pending_payment", index: true },
|
| 30 |
+
|
| 31 |
+
confirmedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", default: null },
|
| 32 |
+
confirmedAt: { type: Date, default: null },
|
| 33 |
+
rejectionReason: { type: String, default: null },
|
| 34 |
+
|
| 35 |
+
confirmationEmailSentAt: { type: Date, default: null },
|
| 36 |
+
},
|
| 37 |
+
{ timestamps: true }
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
// --- Helpers to write/read the encrypted payment reference safely ---
|
| 41 |
+
orderSchema.methods.setPaymentReference = function setPaymentReference(plainReference) {
|
| 42 |
+
this.paymentReferenceEncrypted = encryptField(plainReference);
|
| 43 |
+
this.paymentReferenceIndex = hmacIndex(plainReference);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
orderSchema.methods.getPaymentReference = function getPaymentReference() {
|
| 47 |
+
return decryptField(this.paymentReferenceEncrypted);
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
orderSchema.methods.setPayerNote = function setPayerNote(note) {
|
| 51 |
+
this.payerNoteEncrypted = encryptField(note);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
orderSchema.methods.getPayerNote = function getPayerNote() {
|
| 55 |
+
return decryptField(this.payerNoteEncrypted);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
orderSchema.methods.toPublicJSON = function toPublicJSON({ includeSensitive = false } = {}) {
|
| 59 |
+
const json = {
|
| 60 |
+
id: this._id,
|
| 61 |
+
orderNumber: this.orderNumber,
|
| 62 |
+
course: this.course,
|
| 63 |
+
courseTitleSnapshot: this.courseTitleSnapshot,
|
| 64 |
+
amount: this.amount,
|
| 65 |
+
currency: this.currency,
|
| 66 |
+
paymentMethod: this.paymentMethod,
|
| 67 |
+
status: this.status,
|
| 68 |
+
confirmedAt: this.confirmedAt,
|
| 69 |
+
rejectionReason: this.rejectionReason,
|
| 70 |
+
createdAt: this.createdAt,
|
| 71 |
+
};
|
| 72 |
+
if (includeSensitive) {
|
| 73 |
+
json.paymentReference = this.getPaymentReference();
|
| 74 |
+
json.payerNote = this.getPayerNote();
|
| 75 |
+
}
|
| 76 |
+
return json;
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
module.exports = mongoose.model("Order", orderSchema);
|
| 80 |
+
module.exports.PAYMENT_METHODS = PAYMENT_METHODS;
|
| 81 |
+
module.exports.ORDER_STATUSES = ORDER_STATUSES;
|
src/models/User.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require("mongoose");
|
| 2 |
+
const bcrypt = require("bcryptjs");
|
| 3 |
+
|
| 4 |
+
const enrolledCourseSchema = new mongoose.Schema(
|
| 5 |
+
{
|
| 6 |
+
course: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
|
| 7 |
+
order: { type: mongoose.Schema.Types.ObjectId, ref: "Order", required: true },
|
| 8 |
+
accessGrantedAt: { type: Date, default: Date.now },
|
| 9 |
+
},
|
| 10 |
+
{ _id: false }
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
const userSchema = new mongoose.Schema(
|
| 14 |
+
{
|
| 15 |
+
name: { type: String, required: true, trim: true, maxlength: 120 },
|
| 16 |
+
email: {
|
| 17 |
+
type: String,
|
| 18 |
+
required: true,
|
| 19 |
+
unique: true,
|
| 20 |
+
lowercase: true,
|
| 21 |
+
trim: true,
|
| 22 |
+
maxlength: 254,
|
| 23 |
+
index: true,
|
| 24 |
+
},
|
| 25 |
+
passwordHash: { type: String, required: true, select: false },
|
| 26 |
+
role: { type: String, enum: ["user", "admin"], default: "user" },
|
| 27 |
+
|
| 28 |
+
// Access-control / session-security bookkeeping
|
| 29 |
+
tokenVersion: { type: Number, default: 0 }, // bump to invalidate all issued JWTs
|
| 30 |
+
activeRefreshTokens: {
|
| 31 |
+
// store only the jti + expiry, never the raw token, so a leaked DB
|
| 32 |
+
// dump can't be replayed as a session
|
| 33 |
+
type: [
|
| 34 |
+
{
|
| 35 |
+
jti: String,
|
| 36 |
+
expiresAt: Date,
|
| 37 |
+
userAgent: String,
|
| 38 |
+
ip: String,
|
| 39 |
+
},
|
| 40 |
+
],
|
| 41 |
+
default: [],
|
| 42 |
+
select: false,
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
// Brute-force protection
|
| 46 |
+
failedLoginAttempts: { type: Number, default: 0, select: false },
|
| 47 |
+
lockUntil: { type: Date, default: null, select: false },
|
| 48 |
+
|
| 49 |
+
enrolledCourses: { type: [enrolledCourseSchema], default: [] },
|
| 50 |
+
|
| 51 |
+
isActive: { type: Boolean, default: true },
|
| 52 |
+
},
|
| 53 |
+
{ timestamps: true }
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
userSchema.methods.setPassword = async function setPassword(plainPassword) {
|
| 57 |
+
const salt = await bcrypt.genSalt(12);
|
| 58 |
+
this.passwordHash = await bcrypt.hash(plainPassword, salt);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
userSchema.methods.comparePassword = function comparePassword(plainPassword) {
|
| 62 |
+
return bcrypt.compare(plainPassword, this.passwordHash);
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
userSchema.methods.isLocked = function isLocked() {
|
| 66 |
+
return !!(this.lockUntil && this.lockUntil > Date.now());
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
userSchema.methods.hasAccessToCourse = function hasAccessToCourse(courseId) {
|
| 70 |
+
return this.enrolledCourses.some((ec) => ec.course.toString() === courseId.toString());
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
userSchema.methods.toSafeJSON = function toSafeJSON() {
|
| 74 |
+
return {
|
| 75 |
+
id: this._id,
|
| 76 |
+
name: this.name,
|
| 77 |
+
email: this.email,
|
| 78 |
+
role: this.role,
|
| 79 |
+
enrolledCourses: this.enrolledCourses.map((ec) => ({
|
| 80 |
+
course: ec.course,
|
| 81 |
+
order: ec.order,
|
| 82 |
+
accessGrantedAt: ec.accessGrantedAt,
|
| 83 |
+
})),
|
| 84 |
+
createdAt: this.createdAt,
|
| 85 |
+
};
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
module.exports = mongoose.model("User", userSchema);
|
src/routes/admin.routes.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const { body } = require("express-validator");
|
| 3 |
+
const { validate } = require("../middleware/validate");
|
| 4 |
+
const { requireAuth, requireRole } = require("../middleware/auth");
|
| 5 |
+
const { upload } = require("../middleware/upload");
|
| 6 |
+
const ctrl = require("../controllers/admin.controller");
|
| 7 |
+
const contentCtrl = require("../controllers/content.controller");
|
| 8 |
+
|
| 9 |
+
const router = express.Router();
|
| 10 |
+
|
| 11 |
+
router.use(requireAuth, requireRole("admin"));
|
| 12 |
+
|
| 13 |
+
router.get("/dashboard", ctrl.dashboardStats);
|
| 14 |
+
router.get("/users", ctrl.listUsers);
|
| 15 |
+
router.get("/orders", ctrl.listOrders);
|
| 16 |
+
router.get("/orders/:id", ctrl.getOrderDetail);
|
| 17 |
+
router.post("/orders/:id/confirm", ctrl.confirmOrder);
|
| 18 |
+
router.post(
|
| 19 |
+
"/orders/:id/reject",
|
| 20 |
+
[body("reason").optional({ nullable: true }).trim().isLength({ max: 500 })],
|
| 21 |
+
validate,
|
| 22 |
+
ctrl.rejectOrder
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
// --- Course content management (admin-only; only admins may ever
|
| 26 |
+
// upload or modify study materials, per course requirements) ----------
|
| 27 |
+
router.get("/courses/:id/curriculum", contentCtrl.getCourseCurriculumForAdmin);
|
| 28 |
+
|
| 29 |
+
router.post(
|
| 30 |
+
"/courses/:id/sections",
|
| 31 |
+
[body("title").trim().isLength({ min: 1, max: 200 })],
|
| 32 |
+
validate,
|
| 33 |
+
contentCtrl.createSection
|
| 34 |
+
);
|
| 35 |
+
router.put("/courses/:id/sections/:sectionId", contentCtrl.updateSection);
|
| 36 |
+
router.delete("/courses/:id/sections/:sectionId", contentCtrl.deleteSection);
|
| 37 |
+
|
| 38 |
+
router.post(
|
| 39 |
+
"/courses/:id/sections/:sectionId/lessons",
|
| 40 |
+
upload.single("file"),
|
| 41 |
+
[body("title").trim().isLength({ min: 1, max: 200 })],
|
| 42 |
+
validate,
|
| 43 |
+
contentCtrl.uploadLesson
|
| 44 |
+
);
|
| 45 |
+
router.put("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.updateLesson);
|
| 46 |
+
router.delete("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.deleteLesson);
|
| 47 |
+
|
| 48 |
+
module.exports = router;
|
| 49 |
+
|
src/routes/auth.routes.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const { body } = require("express-validator");
|
| 3 |
+
const { validate } = require("../middleware/validate");
|
| 4 |
+
const { requireAuth } = require("../middleware/auth");
|
| 5 |
+
const { authLimiter } = require("../middleware/rateLimiters");
|
| 6 |
+
const ctrl = require("../controllers/auth.controller");
|
| 7 |
+
|
| 8 |
+
const router = express.Router();
|
| 9 |
+
|
| 10 |
+
router.post(
|
| 11 |
+
"/signup",
|
| 12 |
+
authLimiter,
|
| 13 |
+
[
|
| 14 |
+
body("name").trim().isLength({ min: 2, max: 120 }).withMessage("Name must be 2-120 characters."),
|
| 15 |
+
body("email").isEmail().normalizeEmail().withMessage("A valid email is required."),
|
| 16 |
+
body("password")
|
| 17 |
+
.isLength({ min: 10 })
|
| 18 |
+
.withMessage("Password must be at least 10 characters.")
|
| 19 |
+
.matches(/[A-Z]/)
|
| 20 |
+
.withMessage("Password must include an uppercase letter.")
|
| 21 |
+
.matches(/[a-z]/)
|
| 22 |
+
.withMessage("Password must include a lowercase letter.")
|
| 23 |
+
.matches(/[0-9]/)
|
| 24 |
+
.withMessage("Password must include a number."),
|
| 25 |
+
],
|
| 26 |
+
validate,
|
| 27 |
+
ctrl.signup
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
router.post(
|
| 31 |
+
"/login",
|
| 32 |
+
authLimiter,
|
| 33 |
+
[
|
| 34 |
+
body("email").isEmail().normalizeEmail().withMessage("A valid email is required."),
|
| 35 |
+
body("password").notEmpty().withMessage("Password is required."),
|
| 36 |
+
],
|
| 37 |
+
validate,
|
| 38 |
+
ctrl.login
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
router.post("/refresh", ctrl.refresh);
|
| 42 |
+
router.post("/logout", ctrl.logout);
|
| 43 |
+
router.get("/me", requireAuth, ctrl.me);
|
| 44 |
+
|
| 45 |
+
module.exports = router;
|
src/routes/course.routes.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const { body } = require("express-validator");
|
| 3 |
+
const { validate } = require("../middleware/validate");
|
| 4 |
+
const { requireAuth, requireRole } = require("../middleware/auth");
|
| 5 |
+
const ctrl = require("../controllers/course.controller");
|
| 6 |
+
const contentCtrl = require("../controllers/content.controller");
|
| 7 |
+
|
| 8 |
+
const router = express.Router();
|
| 9 |
+
|
| 10 |
+
router.get("/", ctrl.listCourses);
|
| 11 |
+
router.get("/:id", ctrl.getCourse);
|
| 12 |
+
|
| 13 |
+
// Curriculum viewing / lesson playback (learner-facing, requires login;
|
| 14 |
+
// access to actual lesson files is enforced per-lesson in the controller
|
| 15 |
+
// based on confirmed purchase, not just being logged in).
|
| 16 |
+
router.get("/:id/curriculum", requireAuth, contentCtrl.getCourseCurriculum);
|
| 17 |
+
router.get("/:id/sections/:sectionId/lessons/:lessonId/access", requireAuth, contentCtrl.getLessonAccess);
|
| 18 |
+
|
| 19 |
+
const courseValidators = [
|
| 20 |
+
body("title").trim().isLength({ min: 2, max: 200 }),
|
| 21 |
+
body("slug").trim().isSlug().withMessage("Slug must be URL-safe (lowercase, hyphens)."),
|
| 22 |
+
body("price").isFloat({ min: 0 }),
|
| 23 |
+
body("duration").trim().notEmpty(),
|
| 24 |
+
body("level").trim().notEmpty(),
|
| 25 |
+
body("description").trim().notEmpty(),
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
router.post("/", requireAuth, requireRole("admin"), courseValidators, validate, ctrl.createCourse);
|
| 29 |
+
router.put("/:id", requireAuth, requireRole("admin"), ctrl.updateCourse);
|
| 30 |
+
router.delete("/:id", requireAuth, requireRole("admin"), ctrl.deleteCourse);
|
| 31 |
+
|
| 32 |
+
module.exports = router;
|
| 33 |
+
|
src/routes/order.routes.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require("express");
|
| 2 |
+
const { body } = require("express-validator");
|
| 3 |
+
const { validate } = require("../middleware/validate");
|
| 4 |
+
const { requireAuth } = require("../middleware/auth");
|
| 5 |
+
const { orderLimiter } = require("../middleware/rateLimiters");
|
| 6 |
+
const { PAYMENT_METHODS } = require("../models/Order");
|
| 7 |
+
const ctrl = require("../controllers/order.controller");
|
| 8 |
+
|
| 9 |
+
const router = express.Router();
|
| 10 |
+
|
| 11 |
+
router.get("/payment-options", ctrl.getPaymentOptions);
|
| 12 |
+
|
| 13 |
+
router.use(requireAuth);
|
| 14 |
+
|
| 15 |
+
router.get("/", ctrl.listMyOrders);
|
| 16 |
+
router.get("/:id", ctrl.getMyOrder);
|
| 17 |
+
|
| 18 |
+
router.post(
|
| 19 |
+
"/",
|
| 20 |
+
orderLimiter,
|
| 21 |
+
[
|
| 22 |
+
body("courseId").isMongoId().withMessage("A valid course must be selected."),
|
| 23 |
+
body("paymentMethod").isIn(PAYMENT_METHODS).withMessage("Choose bank_wire or payoneer."),
|
| 24 |
+
],
|
| 25 |
+
validate,
|
| 26 |
+
ctrl.createOrder
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
router.post(
|
| 30 |
+
"/:id/payment-proof",
|
| 31 |
+
orderLimiter,
|
| 32 |
+
[
|
| 33 |
+
body("reference").trim().isLength({ min: 3, max: 200 }).withMessage("Enter your payment reference/transaction ID."),
|
| 34 |
+
body("note").optional({ nullable: true }).trim().isLength({ max: 500 }),
|
| 35 |
+
],
|
| 36 |
+
validate,
|
| 37 |
+
ctrl.submitPaymentProof
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
module.exports = router;
|
src/seed/seed.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* eslint-disable no-console */
|
| 2 |
+
const { env } = require("../config/env");
|
| 3 |
+
const { connectDB, disconnectDB } = require("../config/db");
|
| 4 |
+
const User = require("../models/User");
|
| 5 |
+
const Course = require("../models/Course");
|
| 6 |
+
|
| 7 |
+
const courses = [
|
| 8 |
+
{
|
| 9 |
+
title: "Artificial Intelligence Essentials",
|
| 10 |
+
slug: "artificial-intelligence-essentials",
|
| 11 |
+
price: 350,
|
| 12 |
+
duration: "8 weeks",
|
| 13 |
+
level: "Beginner",
|
| 14 |
+
description:
|
| 15 |
+
"A practical introduction to AI: learn what AI can (and cannot) do, work with real datasets, and build your first machine-learning models.",
|
| 16 |
+
highlights: [
|
| 17 |
+
"AI concepts, terminology and real-world use cases",
|
| 18 |
+
"Hands-on Python & ML experiments",
|
| 19 |
+
"Build a simple predictive model end-to-end",
|
| 20 |
+
],
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
title: "Cyber Security Essentials",
|
| 24 |
+
slug: "cyber-security-essentials",
|
| 25 |
+
price: 300,
|
| 26 |
+
duration: "6 weeks",
|
| 27 |
+
level: "Beginner",
|
| 28 |
+
description:
|
| 29 |
+
"Learn the foundations of cyber defense: protect devices, networks, and data against the most common digital threats.",
|
| 30 |
+
highlights: [
|
| 31 |
+
"Threat landscape and attacker tactics",
|
| 32 |
+
"Secure browsing, password & identity hygiene",
|
| 33 |
+
"Basic network and endpoint protection",
|
| 34 |
+
],
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
title: "AI-Powered Cybersecurity",
|
| 38 |
+
slug: "ai-powered-cybersecurity",
|
| 39 |
+
price: 550,
|
| 40 |
+
duration: "10 weeks",
|
| 41 |
+
level: "Intermediate to Advanced",
|
| 42 |
+
description:
|
| 43 |
+
"Combine AI with security operations: deploy intelligent threat detection, automate incident response, and build resilient SOC workflows.",
|
| 44 |
+
highlights: [
|
| 45 |
+
"ML-based threat detection and anomaly analytics",
|
| 46 |
+
"Automated incident response playbooks",
|
| 47 |
+
"AI governance, ethics and adversarial risks",
|
| 48 |
+
],
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
title: "The AI Career Guidance",
|
| 52 |
+
slug: "the-ai-career-guidance",
|
| 53 |
+
price: 200,
|
| 54 |
+
duration: "4 weeks",
|
| 55 |
+
level: "Career Foundations",
|
| 56 |
+
description:
|
| 57 |
+
"Map your path into the AI economy. Discover roles, build a portfolio, and prepare for interviews with guided mentorship.",
|
| 58 |
+
highlights: [
|
| 59 |
+
"AI career landscape and skill roadmap",
|
| 60 |
+
"Portfolio project planning and resume review",
|
| 61 |
+
"Mock interviews and mentorship sessions",
|
| 62 |
+
],
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
title: "AI for Business Managers",
|
| 66 |
+
slug: "ai-for-business-managers",
|
| 67 |
+
price: 450,
|
| 68 |
+
duration: "6 weeks",
|
| 69 |
+
level: "Manager Level",
|
| 70 |
+
description:
|
| 71 |
+
"Learn how to evaluate, procure, and manage AI projects that drive real business value without needing to code.",
|
| 72 |
+
highlights: [
|
| 73 |
+
"AI strategy, ROI and use-case prioritisation",
|
| 74 |
+
"Managing data, vendors and project risk",
|
| 75 |
+
"Leading AI-enabled teams and change",
|
| 76 |
+
],
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
title: "AI for Business Executives",
|
| 80 |
+
slug: "ai-for-business-executives",
|
| 81 |
+
price: 650,
|
| 82 |
+
duration: "5 weeks",
|
| 83 |
+
level: "Executive Level",
|
| 84 |
+
description:
|
| 85 |
+
"A leadership-level course on AI governance, competitive strategy, and building an AI-ready organisation.",
|
| 86 |
+
highlights: [
|
| 87 |
+
"Board-level AI vision and investment decisions",
|
| 88 |
+
"Regulatory, ethical and governance frameworks",
|
| 89 |
+
"Building an AI culture and Centre of Excellence",
|
| 90 |
+
],
|
| 91 |
+
},
|
| 92 |
+
];
|
| 93 |
+
|
| 94 |
+
async function seed() {
|
| 95 |
+
await connectDB();
|
| 96 |
+
|
| 97 |
+
for (const c of courses) {
|
| 98 |
+
await Course.findOneAndUpdate({ slug: c.slug }, c, { upsert: true, new: true, setDefaultsOnInsert: true });
|
| 99 |
+
}
|
| 100 |
+
console.log(`Seeded ${courses.length} courses.`);
|
| 101 |
+
|
| 102 |
+
if (env.seedAdmin.email && env.seedAdmin.password) {
|
| 103 |
+
const existingAdmin = await User.findOne({ email: env.seedAdmin.email.toLowerCase() });
|
| 104 |
+
if (existingAdmin) {
|
| 105 |
+
console.log(`Admin account already exists for ${env.seedAdmin.email}, skipping.`);
|
| 106 |
+
} else {
|
| 107 |
+
const admin = new User({
|
| 108 |
+
name: env.seedAdmin.name,
|
| 109 |
+
email: env.seedAdmin.email.toLowerCase(),
|
| 110 |
+
role: "admin",
|
| 111 |
+
});
|
| 112 |
+
await admin.setPassword(env.seedAdmin.password);
|
| 113 |
+
await admin.save();
|
| 114 |
+
console.log(`Created admin account: ${env.seedAdmin.email}`);
|
| 115 |
+
console.log("IMPORTANT: log in and change this password immediately.");
|
| 116 |
+
}
|
| 117 |
+
} else {
|
| 118 |
+
console.log("SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set — skipping admin creation.");
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
await disconnectDB();
|
| 122 |
+
process.exit(0);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
seed().catch((err) => {
|
| 126 |
+
console.error("Seeding failed:", err);
|
| 127 |
+
process.exit(1);
|
| 128 |
+
});
|
src/server.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { env, assertProductionSafety } = require("./config/env");
|
| 2 |
+
const { connectDB, disconnectDB } = require("./config/db");
|
| 3 |
+
const logger = require("./utils/logger");
|
| 4 |
+
const app = require("./app");
|
| 5 |
+
|
| 6 |
+
assertProductionSafety();
|
| 7 |
+
|
| 8 |
+
let server;
|
| 9 |
+
|
| 10 |
+
async function start() {
|
| 11 |
+
try {
|
| 12 |
+
await connectDB();
|
| 13 |
+
server = app.listen(env.port, () => {
|
| 14 |
+
logger.info(`Aynwerd API listening on port ${env.port} [${env.nodeEnv}]`);
|
| 15 |
+
});
|
| 16 |
+
} catch (err) {
|
| 17 |
+
logger.error("Failed to start server", { error: err.message });
|
| 18 |
+
process.exit(1);
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function shutdown(signal) {
|
| 23 |
+
logger.info(`Received ${signal}, shutting down gracefully...`);
|
| 24 |
+
if (server) {
|
| 25 |
+
server.close(async () => {
|
| 26 |
+
await disconnectDB();
|
| 27 |
+
process.exit(0);
|
| 28 |
+
});
|
| 29 |
+
// Force-exit if something hangs
|
| 30 |
+
setTimeout(() => process.exit(1), 10000).unref();
|
| 31 |
+
} else {
|
| 32 |
+
process.exit(0);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
| 37 |
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
| 38 |
+
process.on("unhandledRejection", (reason) => {
|
| 39 |
+
logger.error("Unhandled promise rejection", { reason: reason?.message || reason });
|
| 40 |
+
});
|
| 41 |
+
process.on("uncaughtException", (err) => {
|
| 42 |
+
logger.error("Uncaught exception", { error: err.message, stack: err.stack });
|
| 43 |
+
process.exit(1);
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
start();
|
src/utils/cloudinaryUpload.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const cloudinary = require("../config/cloudinary");
|
| 2 |
+
|
| 3 |
+
const FOLDER = "aynwerd-courses";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Uploads a file from a local temp path to Cloudinary under private,
|
| 7 |
+
* signed-only access. `type: "authenticated"` means the asset can
|
| 8 |
+
* NEVER be fetched by its plain URL or by guessing the public ID —
|
| 9 |
+
* every request must carry a valid signature that we only ever mint
|
| 10 |
+
* for a verified, paying learner (see getSignedPlaybackUrl below).
|
| 11 |
+
*
|
| 12 |
+
* Videos use `upload_large`, which chunks the upload — required for
|
| 13 |
+
* anything beyond a small size and generally more reliable for video
|
| 14 |
+
* regardless of size. Everything else uses a normal upload.
|
| 15 |
+
*/
|
| 16 |
+
function uploadFromPath(filePath, { resourceType, filenameHint }) {
|
| 17 |
+
const options = {
|
| 18 |
+
folder: FOLDER,
|
| 19 |
+
resource_type: resourceType, // "video" | "image" | "raw"
|
| 20 |
+
type: "authenticated",
|
| 21 |
+
use_filename: true,
|
| 22 |
+
unique_filename: true,
|
| 23 |
+
filename_override: filenameHint,
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
if (resourceType === "video") {
|
| 27 |
+
return cloudinary.uploader.upload_large(filePath, { ...options, chunk_size: 6 * 1024 * 1024 });
|
| 28 |
+
}
|
| 29 |
+
return cloudinary.uploader.upload(filePath, options);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function deleteAsset(publicId, resourceType) {
|
| 33 |
+
return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" });
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Mints a short-lived, signed URL for a specific lesson asset. Valid for
|
| 38 |
+
* a couple of hours only, so a leaked link stops working quickly and
|
| 39 |
+
* cannot be redistributed as a permanent download.
|
| 40 |
+
*/
|
| 41 |
+
function getSignedPlaybackUrl(publicId, resourceType, { format, expiresInSeconds = 2 * 60 * 60 } = {}) {
|
| 42 |
+
const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
| 43 |
+
const url = cloudinary.url(publicId, {
|
| 44 |
+
resource_type: resourceType,
|
| 45 |
+
type: "authenticated",
|
| 46 |
+
sign_url: true,
|
| 47 |
+
secure: true,
|
| 48 |
+
expires_at: expiresAt,
|
| 49 |
+
format: format || undefined,
|
| 50 |
+
});
|
| 51 |
+
return { url, expiresAt };
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/** Maps an uploaded file's mimetype to the Cloudinary resource_type bucket it belongs in. */
|
| 55 |
+
function resourceTypeForMimetype(mimetype) {
|
| 56 |
+
if (mimetype.startsWith("video/")) return "video";
|
| 57 |
+
if (mimetype.startsWith("image/")) return "image";
|
| 58 |
+
return "raw"; // PDFs, docs, slides, zips, etc.
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
module.exports = { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype };
|
src/utils/crypto.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const crypto = require("crypto");
|
| 2 |
+
const { env } = require("../config/env");
|
| 3 |
+
|
| 4 |
+
const ALGORITHM = "aes-256-gcm";
|
| 5 |
+
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
|
| 6 |
+
|
| 7 |
+
function getKey() {
|
| 8 |
+
const key = Buffer.from(env.fieldEncryptionKey || "", "hex");
|
| 9 |
+
if (key.length !== 32) {
|
| 10 |
+
throw new Error(
|
| 11 |
+
"FIELD_ENCRYPTION_KEY must be a 32-byte value encoded as 64 hex characters. " +
|
| 12 |
+
"Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
return key;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Encrypts a plaintext string for storage at rest (e.g. bank wire
|
| 20 |
+
* reference numbers, Payoneer transaction IDs). Returns a single
|
| 21 |
+
* base64 payload containing iv + authTag + ciphertext so it can be
|
| 22 |
+
* stored as one string field in MongoDB.
|
| 23 |
+
*/
|
| 24 |
+
function encryptField(plaintext) {
|
| 25 |
+
if (plaintext === undefined || plaintext === null || plaintext === "") return null;
|
| 26 |
+
const iv = crypto.randomBytes(IV_LENGTH);
|
| 27 |
+
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
| 28 |
+
const ciphertext = Buffer.concat([cipher.update(String(plaintext), "utf8"), cipher.final()]);
|
| 29 |
+
const authTag = cipher.getAuthTag();
|
| 30 |
+
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Decrypts a payload produced by encryptField. Returns null if the
|
| 35 |
+
* input is empty rather than throwing, so callers can treat "no
|
| 36 |
+
* payment reference yet" gracefully.
|
| 37 |
+
*/
|
| 38 |
+
function decryptField(payload) {
|
| 39 |
+
if (!payload) return null;
|
| 40 |
+
const raw = Buffer.from(payload, "base64");
|
| 41 |
+
const iv = raw.subarray(0, IV_LENGTH);
|
| 42 |
+
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
|
| 43 |
+
const ciphertext = raw.subarray(IV_LENGTH + 16);
|
| 44 |
+
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
| 45 |
+
decipher.setAuthTag(authTag);
|
| 46 |
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
| 47 |
+
return plaintext.toString("utf8");
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** One-way hash used for equality lookups on encrypted fields (e.g. duplicate detection) without decrypting. */
|
| 51 |
+
function hmacIndex(value) {
|
| 52 |
+
if (!value) return null;
|
| 53 |
+
return crypto.createHmac("sha256", getKey()).update(String(value)).digest("hex");
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
module.exports = { encryptField, decryptField, hmacIndex };
|
src/utils/email.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const nodemailer = require("nodemailer");
|
| 2 |
+
const { env } = require("../config/env");
|
| 3 |
+
const logger = require("./logger");
|
| 4 |
+
|
| 5 |
+
let transporter = null;
|
| 6 |
+
|
| 7 |
+
function getTransporter() {
|
| 8 |
+
if (transporter) return transporter;
|
| 9 |
+
transporter = nodemailer.createTransport({
|
| 10 |
+
host: env.smtp.host,
|
| 11 |
+
port: env.smtp.port,
|
| 12 |
+
secure: env.smtp.secure, // true for 465, false for other ports (STARTTLS)
|
| 13 |
+
auth: env.smtp.user ? { user: env.smtp.user, pass: env.smtp.pass } : undefined,
|
| 14 |
+
requireTLS: !env.smtp.secure, // enforce STARTTLS when not already on a TLS port
|
| 15 |
+
});
|
| 16 |
+
return transporter;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
async function sendMail({ to, subject, html, text }) {
|
| 20 |
+
try {
|
| 21 |
+
const info = await getTransporter().sendMail({
|
| 22 |
+
from: env.emailFrom,
|
| 23 |
+
to,
|
| 24 |
+
subject,
|
| 25 |
+
html,
|
| 26 |
+
text,
|
| 27 |
+
});
|
| 28 |
+
return info;
|
| 29 |
+
} catch (err) {
|
| 30 |
+
// Email failures must never crash a request or leak SMTP credentials.
|
| 31 |
+
logger.error("Failed to send email", { subject, error: err.message });
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function baseLayout(bodyHtml) {
|
| 37 |
+
return `
|
| 38 |
+
<div style="font-family:Arial,Helvetica,sans-serif;max-width:600px;margin:0 auto;color:#1a1a1a;">
|
| 39 |
+
<div style="padding:24px 0;border-bottom:2px solid #eee;">
|
| 40 |
+
<h2 style="margin:0;color:#111;">Aynwerd Academy</h2>
|
| 41 |
+
</div>
|
| 42 |
+
<div style="padding:24px 0;">${bodyHtml}</div>
|
| 43 |
+
<div style="padding:16px 0;border-top:1px solid #eee;font-size:12px;color:#777;">
|
| 44 |
+
This is an automated message regarding your order. Never share your password or
|
| 45 |
+
full payment reference by email. If you didn't make this request, contact
|
| 46 |
+
support immediately.
|
| 47 |
+
</div>
|
| 48 |
+
</div>`;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function sendOrderPlacedEmail({ to, name, order, courseTitle, bankDetails, payoneerDetails }) {
|
| 52 |
+
const methodBlock =
|
| 53 |
+
order.paymentMethod === "bank_wire"
|
| 54 |
+
? `<h3>Bank wire transfer instructions</h3>
|
| 55 |
+
<table style="border-collapse:collapse;width:100%;font-size:14px;">
|
| 56 |
+
<tr><td style="padding:4px 0;color:#555;">Bank name</td><td><strong>${bankDetails.name}</strong></td></tr>
|
| 57 |
+
<tr><td style="padding:4px 0;color:#555;">Account name</td><td><strong>${bankDetails.accountName}</strong></td></tr>
|
| 58 |
+
<tr><td style="padding:4px 0;color:#555;">Account number</td><td><strong>${bankDetails.accountNumber}</strong></td></tr>
|
| 59 |
+
<tr><td style="padding:4px 0;color:#555;">Routing number</td><td><strong>${bankDetails.routingNumber}</strong></td></tr>
|
| 60 |
+
<tr><td style="padding:4px 0;color:#555;">SWIFT/BIC</td><td><strong>${bankDetails.swiftBic}</strong></td></tr>
|
| 61 |
+
<tr><td style="padding:4px 0;color:#555;">Bank address</td><td><strong>${bankDetails.address}</strong></td></tr>
|
| 62 |
+
</table>
|
| 63 |
+
<p>Please use order number <strong>${order.orderNumber}</strong> as your payment reference.</p>`
|
| 64 |
+
: `<h3>Payoneer payment instructions</h3>
|
| 65 |
+
<p>Send payment to Payoneer account <strong>${payoneerDetails.accountEmail}</strong>
|
| 66 |
+
or use your Payoneer app's "Request a Payment" link: <a href="${payoneerDetails.paymentUrl}">${payoneerDetails.paymentUrl}</a>.</p>
|
| 67 |
+
<p>Please use order number <strong>${order.orderNumber}</strong> as your payment note.</p>`;
|
| 68 |
+
|
| 69 |
+
const html = baseLayout(`
|
| 70 |
+
<p>Hi ${name},</p>
|
| 71 |
+
<p>Thanks for enrolling in <strong>${courseTitle}</strong>. Your order has been created and is
|
| 72 |
+
awaiting payment${order.status === "awaiting_confirmation" ? " confirmation" : ""}.</p>
|
| 73 |
+
<p><strong>Order number:</strong> ${order.orderNumber}<br/>
|
| 74 |
+
<strong>Amount due:</strong> ${order.currency} ${order.amount.toFixed(2)}<br/>
|
| 75 |
+
<strong>Payment method:</strong> ${order.paymentMethod === "bank_wire" ? "Bank wire transfer" : "Payoneer"}</p>
|
| 76 |
+
${methodBlock}
|
| 77 |
+
<p>Once our team confirms your payment, you'll receive a separate email and full course access
|
| 78 |
+
will be unlocked automatically on your account.</p>
|
| 79 |
+
`);
|
| 80 |
+
|
| 81 |
+
return sendMail({
|
| 82 |
+
to,
|
| 83 |
+
subject: `Your Aynwerd order ${order.orderNumber} — payment instructions`,
|
| 84 |
+
html,
|
| 85 |
+
text: `Order ${order.orderNumber} created for ${courseTitle}. Amount due: ${order.currency} ${order.amount.toFixed(2)}.`,
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function sendPaymentConfirmedEmail({ to, name, order, courseTitle }) {
|
| 90 |
+
const html = baseLayout(`
|
| 91 |
+
<p>Hi ${name},</p>
|
| 92 |
+
<p>Great news — we've confirmed your payment for <strong>${courseTitle}</strong>.</p>
|
| 93 |
+
<p><strong>Order number:</strong> ${order.orderNumber}<br/>
|
| 94 |
+
<strong>Amount paid:</strong> ${order.currency} ${order.amount.toFixed(2)}<br/>
|
| 95 |
+
<strong>Confirmed on:</strong> ${new Date(order.confirmedAt).toUTCString()}</p>
|
| 96 |
+
<p>Full access to the course has been unlocked on your account. Log in any time to get started.</p>
|
| 97 |
+
`);
|
| 98 |
+
|
| 99 |
+
return sendMail({
|
| 100 |
+
to,
|
| 101 |
+
subject: `Payment confirmed — order ${order.orderNumber}`,
|
| 102 |
+
html,
|
| 103 |
+
text: `Payment confirmed for order ${order.orderNumber}. Course access unlocked.`,
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
async function sendPaymentRejectedEmail({ to, name, order, courseTitle, reason }) {
|
| 108 |
+
const html = baseLayout(`
|
| 109 |
+
<p>Hi ${name},</p>
|
| 110 |
+
<p>We were unable to confirm your payment for <strong>${courseTitle}</strong> (order
|
| 111 |
+
<strong>${order.orderNumber}</strong>).</p>
|
| 112 |
+
${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ""}
|
| 113 |
+
<p>Please reply to this email or contact support so we can help resolve this.</p>
|
| 114 |
+
`);
|
| 115 |
+
|
| 116 |
+
return sendMail({
|
| 117 |
+
to,
|
| 118 |
+
subject: `Action needed on order ${order.orderNumber}`,
|
| 119 |
+
html,
|
| 120 |
+
text: `We could not confirm payment for order ${order.orderNumber}.${reason ? ` Reason: ${reason}` : ""}`,
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async function sendAdminNewOrderAlert({ order, courseTitle, customerEmail }) {
|
| 125 |
+
if (!env.adminNotifyEmail) return null;
|
| 126 |
+
const html = baseLayout(`
|
| 127 |
+
<p>New order awaiting confirmation.</p>
|
| 128 |
+
<p><strong>Order:</strong> ${order.orderNumber}<br/>
|
| 129 |
+
<strong>Customer:</strong> ${customerEmail}<br/>
|
| 130 |
+
<strong>Course:</strong> ${courseTitle}<br/>
|
| 131 |
+
<strong>Amount:</strong> ${order.currency} ${order.amount.toFixed(2)}<br/>
|
| 132 |
+
<strong>Method:</strong> ${order.paymentMethod}</p>
|
| 133 |
+
<p>Review and confirm it from the admin dashboard.</p>
|
| 134 |
+
`);
|
| 135 |
+
return sendMail({
|
| 136 |
+
to: env.adminNotifyEmail,
|
| 137 |
+
subject: `New order ${order.orderNumber} awaiting confirmation`,
|
| 138 |
+
html,
|
| 139 |
+
});
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
module.exports = {
|
| 143 |
+
sendOrderPlacedEmail,
|
| 144 |
+
sendPaymentConfirmedEmail,
|
| 145 |
+
sendPaymentRejectedEmail,
|
| 146 |
+
sendAdminNewOrderAlert,
|
| 147 |
+
};
|
src/utils/jwt.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const jwt = require("jsonwebtoken");
|
| 2 |
+
const crypto = require("crypto");
|
| 3 |
+
const { env } = require("../config/env");
|
| 4 |
+
|
| 5 |
+
function signAccessToken(user) {
|
| 6 |
+
return jwt.sign(
|
| 7 |
+
{ sub: user._id.toString(), role: user.role, tokenVersion: user.tokenVersion },
|
| 8 |
+
env.jwtAccessSecret,
|
| 9 |
+
{ expiresIn: env.jwtAccessExpiresIn, issuer: "aynwerd-api", audience: "aynwerd-client" }
|
| 10 |
+
);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function signRefreshToken(user) {
|
| 14 |
+
// A random jti lets us revoke a single refresh token (e.g. on logout /
|
| 15 |
+
// password change) without invalidating every session at once.
|
| 16 |
+
const jti = crypto.randomUUID();
|
| 17 |
+
const token = jwt.sign(
|
| 18 |
+
{ sub: user._id.toString(), tokenVersion: user.tokenVersion, jti },
|
| 19 |
+
env.jwtRefreshSecret,
|
| 20 |
+
{ expiresIn: env.jwtRefreshExpiresIn, issuer: "aynwerd-api", audience: "aynwerd-client" }
|
| 21 |
+
);
|
| 22 |
+
return { token, jti };
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function verifyAccessToken(token) {
|
| 26 |
+
return jwt.verify(token, env.jwtAccessSecret, { issuer: "aynwerd-api", audience: "aynwerd-client" });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function verifyRefreshToken(token) {
|
| 30 |
+
return jwt.verify(token, env.jwtRefreshSecret, { issuer: "aynwerd-api", audience: "aynwerd-client" });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
module.exports = { signAccessToken, signRefreshToken, verifyAccessToken, verifyRefreshToken };
|
src/utils/logger.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const winston = require("winston");
|
| 2 |
+
|
| 3 |
+
// Structured JSON logging so records can be shipped to a log aggregator.
|
| 4 |
+
// Never log secrets, raw passwords, tokens, or full payment identifiers —
|
| 5 |
+
// see redact() usage in controllers.
|
| 6 |
+
const logger = winston.createLogger({
|
| 7 |
+
level: process.env.LOG_LEVEL || "info",
|
| 8 |
+
format: winston.format.combine(
|
| 9 |
+
winston.format.timestamp(),
|
| 10 |
+
winston.format.errors({ stack: true }),
|
| 11 |
+
winston.format.json()
|
| 12 |
+
),
|
| 13 |
+
defaultMeta: { service: "aynwerd-api" },
|
| 14 |
+
transports: [new winston.transports.Console()],
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Redact all but the last `keep` characters of a sensitive string, e.g.
|
| 19 |
+
* for logging a payment reference without exposing it in full.
|
| 20 |
+
*/
|
| 21 |
+
function redact(value, keep = 4) {
|
| 22 |
+
if (!value) return value;
|
| 23 |
+
const str = String(value);
|
| 24 |
+
if (str.length <= keep) return "*".repeat(str.length);
|
| 25 |
+
return `${"*".repeat(str.length - keep)}${str.slice(-keep)}`;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
module.exports = logger;
|
| 29 |
+
module.exports.redact = redact;
|
src/utils/orderNumber.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const crypto = require("crypto");
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Human-friendly, hard-to-guess order number, e.g. AYN-20260825-4F9K2C.
|
| 5 |
+
* Not used as a security token (the order is always looked up by its
|
| 6 |
+
* MongoDB _id + the requesting user's identity), just a reference the
|
| 7 |
+
* customer can quote in support emails.
|
| 8 |
+
*/
|
| 9 |
+
function generateOrderNumber() {
|
| 10 |
+
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
| 11 |
+
const random = crypto.randomBytes(4).toString("hex").toUpperCase().slice(0, 6);
|
| 12 |
+
return `AYN-${date}-${random}`;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
module.exports = { generateOrderNumber };
|