mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: JWT authentication guard.
|
||||
* Reads the Bearer token from the Authorization header,
|
||||
* verifies it, attaches decoded payload to req.user,
|
||||
* and verifies the session is still active in the DB.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { authenticate } = require('../middleware/auth.middleware');
|
||||
* router.get('/profile', authenticate, handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { verifyAccessToken } = require('../utils/token.util');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const R = require('../utils/response.util');
|
||||
const { checkAccountStatus } = require('../services/accountStatus.service');
|
||||
|
||||
/**
|
||||
* Validates JWT and loads user from DB.
|
||||
* Attaches the full user record to req.user.
|
||||
*/
|
||||
const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer '))
|
||||
return R.error(res, 'No token provided.', 401);
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyAccessToken(token);
|
||||
|
||||
const user = await mdl_Users.findByPk(decoded.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
|
||||
if (!user) return R.error(res, 'User not found.', 401);
|
||||
|
||||
const status = await checkAccountStatus(user);
|
||||
if (!status.ok) {
|
||||
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
||||
return R.error(res, 'Your account has been suspended.', 403, {
|
||||
banned: true,
|
||||
ban_expires_at: status.ban_expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError')
|
||||
return R.error(res, 'Token expired. Please log in again.', 401);
|
||||
return R.error(res, 'Invalid token.', 401);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Soft authentication for endpoints that are useful both authenticated and unauthenticated.
|
||||
*
|
||||
* Behaviour:
|
||||
* - No Authorization header → guest mode (req.user = null, continues)
|
||||
* - Valid token → authenticated (req.user = user, continues)
|
||||
* - Present but invalid/expired token → 401 (a token was attempted; reject it)
|
||||
*
|
||||
* The distinction between "no token" and "bad token" matters:
|
||||
* silently ignoring a malformed token would allow it to be used as a scapegoat
|
||||
* to probe the endpoint without being flagged as unauthenticated.
|
||||
*/
|
||||
const softAuthenticate = async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// No token at all → guest mode
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Token present → validate it fully (same rules as authenticate)
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyAccessToken(token);
|
||||
const user = await mdl_Users.findByPk(decoded.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const status = await checkAccountStatus(user);
|
||||
if (!status.ok) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError')
|
||||
return R.error(res, 'Token expired. Please log in again.', 401);
|
||||
return R.error(res, 'Invalid token.', 401);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { authenticate, softAuthenticate };
|
||||
@@ -0,0 +1,45 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: avatar_upload.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Multer config for avatar uploads.
|
||||
* Memory storage — buffer is passed directly to S3.
|
||||
* Accepts JPEG, PNG, WebP, GIF only. 5 MB limit.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 18, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const multer = require('multer');
|
||||
|
||||
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter(req, file, cb) {
|
||||
if (ALLOWED_MIME.has(file.mimetype)) return cb(null, true);
|
||||
cb(Object.assign(new Error('Only JPEG, PNG, WebP, or GIF images are allowed.'), { code: 'INVALID_TYPE' }));
|
||||
},
|
||||
});
|
||||
|
||||
const avatarSingle = upload.single('avatar');
|
||||
|
||||
// Wraps multer errors into the project's standard error shape.
|
||||
const handleAvatarUpload = (req, res, next) => {
|
||||
avatarSingle(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ status: 'error', message: 'Avatar must be under 5 MB.' });
|
||||
}
|
||||
if (err.code === 'INVALID_TYPE') {
|
||||
return res.status(400).json({ status: 'error', message: err.message });
|
||||
}
|
||||
|
||||
console.error('[MULTER AVATAR]', err);
|
||||
return res.status(400).json({ status: 'error', message: err.message ?? 'Upload failed.' });
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { handleAvatarUpload };
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
const multer = require('multer');
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml',
|
||||
]);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter(req, file, cb) {
|
||||
if (ALLOWED_MIME.has(file.mimetype)) return cb(null, true);
|
||||
cb(Object.assign(new Error('Only JPEG, PNG, WebP, GIF, or SVG images are allowed.'), { code: 'INVALID_TYPE' }));
|
||||
},
|
||||
});
|
||||
|
||||
const badgeSingle = upload.single('badge');
|
||||
|
||||
const handleBadgeUpload = (req, res, next) => {
|
||||
badgeSingle(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
if (err.code === 'LIMIT_FILE_SIZE')
|
||||
return res.status(400).json({ status: 'error', message: 'Badge image must be under 5 MB.' });
|
||||
if (err.code === 'INVALID_TYPE')
|
||||
return res.status(400).json({ status: 'error', message: err.message });
|
||||
console.error('[MULTER BADGE]', err);
|
||||
return res.status(400).json({ status: 'error', message: err.message ?? 'Upload failed.' });
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { handleBadgeUpload };
|
||||
@@ -0,0 +1,64 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: csrf.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: CSRF protection using the `csurf` package (Double Submit Cookie pattern).
|
||||
* - csrfProtection → the csurf middleware instance (attach to state-changing routes)
|
||||
* - getCsrfToken → GET /csrf-token handler — sends the token to the client
|
||||
* - csrfErrorHandler → catches EBADCSRFTOKEN and returns a 403
|
||||
*
|
||||
* NOTE: Because we use stateless JWT (no server sessions), CSRF is only
|
||||
* relevant for cookie-based flows (e.g., CSRF token embedded in form headers).
|
||||
* For REST / SPA clients, the standard practice is to omit CSRF and rely on
|
||||
* the Authorization Bearer header (which is already CSRF-safe by design).
|
||||
* This file keeps CSRF available for SSR / hybrid flows.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* // 1. Mount the token endpoint (public):
|
||||
* app.get('/api/csrf-token', getCsrfToken);
|
||||
*
|
||||
* // 2. Apply to state-mutating cookie-based routes:
|
||||
* router.post('/login', csrfProtection, loginHandler);
|
||||
*
|
||||
* // 3. Register the error handler AFTER all routes:
|
||||
* app.use(csrfErrorHandler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const csurf = require('csurf');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
/**
|
||||
* csurf instance — stores token in a signed cookie.
|
||||
* sameSite:'none' (not 'strict') in production — frontend (Vercel) and this
|
||||
* API (Render) are cross-site, so 'strict' drops the cookie on every
|
||||
* fetch/XHR call. 'none' requires secure:true, set alongside it below.
|
||||
*/
|
||||
const csrfProtection = csurf({
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/csrf-token
|
||||
* Returns the CSRF token the client must echo back on state-changing requests
|
||||
* via the `X-CSRF-Token` header or `_csrf` body field.
|
||||
*/
|
||||
const getCsrfToken = (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Error handler for invalid / missing CSRF tokens.
|
||||
* Must be registered as Express error-handling middleware (4 args).
|
||||
*/
|
||||
const csrfErrorHandler = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN')
|
||||
return R.error(res, 'Invalid or missing CSRF token.', 403);
|
||||
next(err);
|
||||
};
|
||||
|
||||
module.exports = { csrfProtection, getCsrfToken, csrfErrorHandler };
|
||||
@@ -0,0 +1,50 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: mediaGuard.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Blocks non-browser clients from accessing protected media stream endpoints.
|
||||
* Checks:
|
||||
* 1. User-Agent blocklist — cURL, wget, python, httpie, etc.
|
||||
* 2. Origin / Referer — must be present (browser always sends at least one)
|
||||
* 3. Allowed origin — must match APP_ORIGIN env (prevents hotlinking)
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 12, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const R = require("../utils/response.util");
|
||||
|
||||
const UA_BLOCKLIST = [
|
||||
"curl", "wget", "python-requests", "python-urllib",
|
||||
"axios", "httpie", "insomnia", "postman", "thunder client",
|
||||
"go-http-client", "java/", "ruby", "php/", "perl/",
|
||||
];
|
||||
|
||||
const APP_ORIGIN = process.env.FRONTEND_URL ?? "http://localhost:5173";
|
||||
|
||||
module.exports = function mediaGuard(req, res, next) {
|
||||
const ua = (req.headers["user-agent"] ?? "").toLowerCase();
|
||||
const origin = req.headers["origin"] ?? "";
|
||||
const referer = req.headers["referer"] ?? "";
|
||||
|
||||
// ── 1. Block known non-browser user-agents ────────────────────────────────
|
||||
if (UA_BLOCKLIST.some((b) => ua.includes(b))) {
|
||||
return R.error(res, "Forbidden.", 403);
|
||||
}
|
||||
|
||||
// ── 2. No UA at all → almost certainly a script ──────────────────────────
|
||||
if (!ua) {
|
||||
return R.error(res, "Forbidden.", 403);
|
||||
}
|
||||
|
||||
// ── 3. Must have at least Origin or Referer (browsers always send one) ───
|
||||
if (!origin && !referer) {
|
||||
return R.error(res, "Forbidden.", 403);
|
||||
}
|
||||
|
||||
// ── 4. Origin must match app origin (blocks hotlinking from other sites) ─
|
||||
if (origin && origin !== APP_ORIGIN) {
|
||||
return R.error(res, "Forbidden.", 403);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: originGuard.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Three-layer server-side guard that blocks non-browser clients from reaching any API route.
|
||||
*
|
||||
* ── Layer 1 — Fetch Metadata family (ALL methods including GET) ─────────────────────────────────
|
||||
*
|
||||
* 1a) Sec-Fetch-Site presence + value
|
||||
* Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach Sec-Fetch-Site on
|
||||
* every request. It is a forbidden request header — JavaScript cannot set, override, or
|
||||
* remove it. Its absence reliably signals a non-browser client. The value cross-site is
|
||||
* also rejected; only same-origin, same-site, and none (direct navigation) are accepted.
|
||||
*
|
||||
* Tools blocked (default configurations):
|
||||
* ✓ Metasploit (Rex HTTP client) — no Sec-Fetch-Site
|
||||
* ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site
|
||||
* ✓ Postman — no Sec-Fetch-Site
|
||||
* ✓ curl / wget / httpie / python-requests — no Sec-Fetch-Site
|
||||
* ✓ Nikto — no Sec-Fetch-Site
|
||||
* ✓ sqlmap — no Sec-Fetch-Site
|
||||
* ✓ dirb / gobuster / feroxbuster — no Sec-Fetch-Site
|
||||
* ✓ nmap HTTP scripts — no Sec-Fetch-Site
|
||||
*
|
||||
* 1b) Sec-Fetch-Mode + Sec-Fetch-Dest presence + valid combination
|
||||
* Browsers that send Sec-Fetch-Site always send Mode and Dest too (Chrome 80+,
|
||||
* Firefox 90+, Safari 16.4+). Missing headers or impossible combinations signal
|
||||
* manual header injection. Only combinations expected on an API server are allowed:
|
||||
* cors|empty — standard fetch() call from a cross-origin SPA
|
||||
* same-origin|empty — same-origin fetch()
|
||||
* navigate|document — direct browser navigation to an API URL
|
||||
*
|
||||
* Additional tools blocked:
|
||||
* ✓ Scripts that fake only Sec-Fetch-Site — missing Mode or Dest
|
||||
* ✓ Scripts with wrong Mode+Dest combos — no-cors, cors+document, etc.
|
||||
*
|
||||
* ── Layer 2 — Browser presence signals (ALL methods including GET) ─────────────────────────────
|
||||
*
|
||||
* At least one browser-native header must be present:
|
||||
* Sec-CH-UA — Chromium client hint, forbidden in non-browser contexts
|
||||
* Accept-Language — sent by all browsers (Chrome, Firefox, Safari)
|
||||
* Absence of both is a strong automation signal that catches tools sophisticated enough to
|
||||
* replicate the Sec-Fetch-* family but not the full browser header profile.
|
||||
*
|
||||
* ── Layer 3 — Origin allowlist (POST / PUT / PATCH / DELETE only) ──────────────────────────────
|
||||
*
|
||||
* State-mutating requests must also carry an Origin header that matches ALLOWED_ORIGINS.
|
||||
* Stops credential-stuffing and cross-origin mutation attempts from unlisted domains,
|
||||
* even if an attacker replicated all browser headers above.
|
||||
*
|
||||
* ── What this does NOT stop ──────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* ✗ BurpSuite running as MITM proxy through a real browser session.
|
||||
* ✗ Playwright / Puppeteer / Selenium controlling a real browser — they produce all correct
|
||||
* Sec-Fetch-* headers, Sec-CH-UA, and Accept-Language automatically.
|
||||
* ✗ A determined attacker who manually replicates all required headers.
|
||||
* The only defences at that point are rate limiting and valid credentials.
|
||||
*
|
||||
* ── Browser compatibility note ───────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Sec-Fetch-Site is supported by Chrome 76+ (Aug 2019), Firefox 90+ (Jul 2021),
|
||||
* and Safari 16.4+ (Mar 2023). Requests from browsers older than these thresholds
|
||||
* will be rejected. Given this is a modern SPA (Vite + React), this is acceptable.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 20, 2026
|
||||
* Date Modified: Jun. 29, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const ALLOWED = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || '')
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
// Routes that legitimately receive a top-level cross-site navigation — a third-party
|
||||
// IdP (Google) redirects the browser here after consent, so Sec-Fetch-Site is
|
||||
// correctly "cross-site" even though the request is a real browser, not an attacker.
|
||||
// Still gated on GET + navigate|document below, so this doesn't open the route to
|
||||
// cross-site fetch()/XHR — only actual browser navigations.
|
||||
const CROSS_SITE_NAVIGATION_PATHS = new Set(['/google/callback']);
|
||||
|
||||
// Valid Sec-Fetch-Mode + Sec-Fetch-Dest combinations expected on this API server.
|
||||
const VALID_FETCH_COMBOS = new Set([
|
||||
'cors|empty', // standard fetch() from cross-origin SPA
|
||||
'same-origin|empty', // same-origin fetch()
|
||||
'navigate|document', // direct browser navigation to an API URL
|
||||
'no-cors|image', // <img src="..."> loading a media/asset route
|
||||
'no-cors|video', // <video src="..."> / <source> streaming a media route
|
||||
'no-cors|audio', // <audio src="...">
|
||||
'no-cors|font', // @font-face url() cross-origin font fetch
|
||||
'no-cors|track', // <track src="..."> (video subtitle/caption tracks)
|
||||
]);
|
||||
|
||||
module.exports = function originGuard(req, res, next) {
|
||||
// Dev bypass: set ORIGIN_GUARD_DISABLED=true in .env to allow Postman/curl through.
|
||||
// Hardcoded production lock — never bypassed even if the flag is accidentally set.
|
||||
if (process.env.ORIGIN_GUARD_DISABLED === 'true' && process.env.NODE_ENV !== 'production') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') return next(); // preflight — handled by cors()
|
||||
|
||||
// ── Layer 1a: Sec-Fetch-Site must be present and not cross-site ───────────
|
||||
// Exception: OAuth callback routes receive a real cross-site top-level navigation
|
||||
// from the IdP's domain — allowed only in combination with the navigate|document
|
||||
// check in Layer 1b, so cross-site fetch()/XHR is still rejected everywhere.
|
||||
const fetchSite = req.headers['sec-fetch-site'];
|
||||
const isCrossSiteNavigationRoute = req.method === 'GET' && CROSS_SITE_NAVIGATION_PATHS.has(req.path);
|
||||
if (!fetchSite || (fetchSite === 'cross-site' && !isCrossSiteNavigationRoute)) {
|
||||
return R.error(res, 'Forbidden.', 403);
|
||||
}
|
||||
|
||||
// ── Layer 1b: Fetch Metadata family must be complete and form a valid combo ─
|
||||
const fetchMode = req.headers['sec-fetch-mode'];
|
||||
const fetchDest = req.headers['sec-fetch-dest'];
|
||||
if (!fetchMode || !fetchDest || !VALID_FETCH_COMBOS.has(`${fetchMode}|${fetchDest}`)) {
|
||||
return R.error(res, 'Forbidden.', 403);
|
||||
}
|
||||
if (isCrossSiteNavigationRoute && `${fetchMode}|${fetchDest}` !== 'navigate|document') {
|
||||
return R.error(res, 'Forbidden.', 403);
|
||||
}
|
||||
|
||||
// ── Layer 2: At least one browser-native fingerprint header must be present ─
|
||||
if (!req.headers['sec-ch-ua'] && !req.headers['accept-language']) {
|
||||
return R.error(res, 'Forbidden.', 403);
|
||||
}
|
||||
|
||||
// ── Layer 3: Origin must be in allowlist for state-changing requests ───────
|
||||
if (MUTATION_METHODS.has(req.method)) {
|
||||
const origin = req.headers['origin'];
|
||||
if (!origin || !ALLOWED.includes(origin)) {
|
||||
return R.error(res, 'Forbidden.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: rateLimiter.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Express-rate-limit configurations for different route tiers.
|
||||
* - globalLimiter → applied to ALL routes (1000 req / 15 min)
|
||||
* - authLimiter → applied to login/register (20 req / 15 min)
|
||||
* - otpLimiter → applied to OTP send/verify (5 req / 15 min)
|
||||
* - sensitiveOpsLimiter → password change, account delete (10 req / hour)
|
||||
* - adminLimiter → admin routes (200 req / 15 min)
|
||||
*
|
||||
* Store selection is driven by CACHE_DRIVER:
|
||||
* redis → RedisStore (shared across processes; required in production)
|
||||
* memory → in-process MemoryStore (fine for local dev, single process)
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
* Date Modified: Jun. 19, 2026
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { authLimiter } = require('../middleware/rateLimiter.middleware');
|
||||
* router.post('/login', authLimiter, loginHandler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const redisClient = require('../config/redis.config');
|
||||
|
||||
const windowMs15 = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
const makeStore = redisClient
|
||||
? (() => {
|
||||
const { RedisStore } = require('rate-limit-redis');
|
||||
return (prefix) => new RedisStore({
|
||||
sendCommand: (...args) => redisClient.sendCommand(args),
|
||||
prefix: `rl:${prefix}:`,
|
||||
});
|
||||
})()
|
||||
: () => undefined; // undefined → express-rate-limit uses its default MemoryStore
|
||||
|
||||
/** Applied globally in server.js */
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 1000,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: makeStore('global'),
|
||||
message: { status: 'error', message: 'Too many requests, please try again later.' },
|
||||
});
|
||||
|
||||
/** Login & register routes */
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: makeStore('auth'),
|
||||
message: { status: 'error', message: 'Too many auth attempts. Please wait 15 minutes.' },
|
||||
});
|
||||
|
||||
/** OTP send / verify */
|
||||
const otpLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: makeStore('otp'),
|
||||
message: { status: 'error', message: 'Too many OTP requests. Please wait 15 minutes.' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Sensitive write operations — bulk actions, uploads, financial transactions.
|
||||
* Keys by authenticated user ID when available, falls back to IP.
|
||||
* Keying by IP alone is unfair on shared networks (office NAT, university Wi-Fi)
|
||||
* where one user triggering the limit would block everyone behind the same IP.
|
||||
*/
|
||||
const sensitiveOpsLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
keyGenerator: (req) => req.user?.user_id?.toString() ?? req.ip,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: makeStore('sensitive'),
|
||||
message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' },
|
||||
});
|
||||
|
||||
/** Admin routes — per authenticated user, not per IP */
|
||||
const adminLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 200,
|
||||
keyGenerator: (req) => req.user?.user_id?.toString() ?? req.ip,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: makeStore('admin'),
|
||||
message: { status: 'error', message: 'Too many admin requests. Please wait 15 minutes.' },
|
||||
});
|
||||
|
||||
module.exports = { globalLimiter, authLimiter, otpLimiter, sensitiveOpsLimiter, adminLimiter };
|
||||
@@ -0,0 +1,77 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: rbac.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Role-Based Access Control (RBAC) guards.
|
||||
* Hierarchy: admin > staff > user (client)
|
||||
*
|
||||
* Exported guards:
|
||||
* - requireClient() → acc_type in ['user', 'staff', 'admin']
|
||||
* - requireStaff() → acc_type in ['staff', 'admin']
|
||||
* - requireAdmin() → acc_type === 'admin' only
|
||||
* - requireOwnerOrStaff() → owns the resource OR is staff/admin
|
||||
* - requireOwnerOrAdmin() → owns the resource OR is admin
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { requireAdmin, requireStaff } = require('../middleware/rbac.middleware');
|
||||
* // Must be used AFTER authenticate middleware
|
||||
* router.get('/users', authenticate, requireStaff(), handler);
|
||||
* router.delete('/users/:id', authenticate, requireAdmin(), handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const ROLES = { user: 1, staff: 2, admin: 3 };
|
||||
|
||||
/**
|
||||
* Generic role guard factory.
|
||||
* @param {string[]} allowed - list of acc_type values that can pass
|
||||
*/
|
||||
const requireRole = (allowed) => (req, res, next) => {
|
||||
if (!req.user)
|
||||
return R.error(res, 'Authentication required.', 401);
|
||||
|
||||
if (!allowed.includes(req.user.acc_type))
|
||||
return R.error(res, 'You do not have permission to access this resource.', 403);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/** Any logged-in user (client, staff, admin) */
|
||||
const requireClient = () => requireRole(['user', 'staff', 'admin']);
|
||||
|
||||
/** Staff or Admin */
|
||||
const requireStaff = () => requireRole(['staff', 'admin']);
|
||||
|
||||
/** Admin only */
|
||||
const requireAdmin = () => requireRole(['admin']);
|
||||
|
||||
/**
|
||||
* Allows resource owner OR staff/admin.
|
||||
* Reads the owner's user_id from req.params.user_id or req.params.id.
|
||||
*/
|
||||
const requireOwnerOrStaff = () => (req, res, next) => {
|
||||
if (!req.user) return R.error(res, 'Authentication required.', 401);
|
||||
const targetId = Number(req.params.user_id || req.params.id);
|
||||
const isOwner = req.user.user_id === targetId;
|
||||
const elevated = ['staff', 'admin'].includes(req.user.acc_type);
|
||||
if (!isOwner && !elevated)
|
||||
return R.error(res, 'You do not have permission.', 403);
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows resource owner OR admin only.
|
||||
*/
|
||||
const requireOwnerOrAdmin = () => (req, res, next) => {
|
||||
if (!req.user) return R.error(res, 'Authentication required.', 401);
|
||||
const targetId = Number(req.params.user_id || req.params.id);
|
||||
const isOwner = req.user.user_id === targetId;
|
||||
const isAdmin = req.user.acc_type === 'admin';
|
||||
if (!isOwner && !isAdmin)
|
||||
return R.error(res, 'You do not have permission.', 403);
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { requireClient, requireStaff, requireAdmin, requireOwnerOrStaff, requireOwnerOrAdmin };
|
||||
@@ -0,0 +1,50 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: taskUpload.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Multer config for task completion file uploads.
|
||||
* Uses memory storage — buffer is passed directly to S3.
|
||||
* Single file per request (matches the two-step upload flow in
|
||||
* ViewTaskDetails where each file is uploaded individually).
|
||||
*
|
||||
* Limits:
|
||||
* fileSize: 500 MB (matches FileUpload.jsx DEFAULT_MAX_BYTES)
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 13, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const multer = require('multer');
|
||||
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 500 * 1024 * 1024, // 500 MB
|
||||
},
|
||||
});
|
||||
|
||||
// Single file per request — field name must be "file"
|
||||
const uploadTaskFile = upload.single('file');
|
||||
|
||||
// ─── Error wrapper ────────────────────────────────────────────────────────────
|
||||
// Converts multer errors to a consistent R.error-style response.
|
||||
const handleUpload = (req, res, next) => {
|
||||
uploadTaskFile(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'File exceeds the 500 MB size limit.',
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[MULTER ERROR]', err);
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: err.message ?? 'File upload failed.',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { handleUpload };
|
||||
@@ -0,0 +1,25 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: validate.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: express-validator result checker.
|
||||
* Pairs with validators/*.validator.js — those files define the rules,
|
||||
* this file intercepts the request if any rule failed and returns 422.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { registerValidator } = require('../../validators/auth.validator');
|
||||
* const { validate } = require('../../middleware/validate.middleware');
|
||||
* router.post('/register', ...registerValidator, validate, handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { validationResult } = require('express-validator');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const validate = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty())
|
||||
return R.validationError(res, errors.array());
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { validate };
|
||||
Reference in New Issue
Block a user