'use strict'; const env = require('../helpers/env.js'), { verifyAsync, decodeAsync } = require('../helpers/jwt_async.js'), { TokenExpiredError } = require('jsonwebtoken'), utils = require('../helpers/utils.js'), cache = require('../helpers/mem_cache.js'), subUtil = require('../helpers/subscription_util.js'), { Errors, Fields } = require('../helpers/constants.js'), { AppAuthError, AppMembershipError, AppParamError } = require('../helpers/app_error.js'), { SubType, SubFields } = require('../model/subscription.js'), Vehicle = require('../model/vehicle.js'), bcrypt = require('bcryptjs'), ObjectId = require('mongodb').ObjectId; const USE_SUBSCRIPTION = env.ENABLE_SUBSCRIPTION; function getRoutePath(req) { return req && req.baseUrl + req.path; } function isSecuredRoute(routePath, method) { const nonSecurePaths = [ { path: '/login', method: 'ALL' }, { path: '/siteVer', method: 'ALL' }, { path: '/mailPwdReset', method: 'POST' }, { path: '/resetPassword', method: 'ALL' }, { path: '/signup', method: 'ALL' }, { path: '/api/partners', method: 'GET', exact: true }, // Allow unauthenticated GET /api/partners only (not subroutes) { path: '/exists', method: 'POST' }, { path: '/countries', method: 'GET' }, { path: '/testAuth', method: 'ALL' }, { path: '/stPmtWH_EP', method: 'POST' }, // Stripe webhook endpoint - authenticated via signature verification { path: '/api/v1/', method: 'ALL' } // Public data-export API — authenticated via X-API-Key header instead ]; if (env.INV_IMG_VIR_DIR) { nonSecurePaths.push({ path: env.INV_IMG_VIR_DIR, method: 'ALL' }); } return !nonSecurePaths.some( (route) => { const methodMatches = (route.method === 'ALL') || (method && method === route.method); const pathMatches = route.exact ? routePath === route.path : routePath.includes(route.path); return pathMatches && methodMatches; } ); } /** Check on every request and authenticate and authorize users. This middleware must be call first to ensure user authentication and related user session data */ async function checkUser(req, res, next) { // console.log(req.url); if (!isSecuredRoute(getRoutePath(req), req.method)) return next && next(); const token = req.headers.authorization ? req.headers.authorization.split(' ') : null; if (!token || token.length !== 2 || token[0].toLowerCase() !== 'bearer') AppAuthError.throw(); const bearer = token[1]; try { const decodedToken = await verifyAsync(bearer, env.TOKEN_SECRET); /* Setup for current logged in user */ req.uid = decodedToken.uid; // Set req User id if (!ObjectId.isValid(req.uid)) AppAuthError.throw(); req.ut = decodedToken.ut; // Set req User type // Rebuild the in-memory cache for this user in case of server restarted or the user's cache has expired const userInfo = cache.get(req.uid); if (userInfo && userInfo[Fields.MARKED_DELETE]) { AppAuthError.throw(); } else if (!userInfo || cache.isExpired(req.uid, env.MAX_SESSION_SECS)) { const curUserInfo = await cache.loadUser(req.uid); if (!curUserInfo && curUserInfo[Fields.MARKED_DELETE]) { AppAuthError.throw(); } req.userInfo = curUserInfo; return next && next(); } else { req.userInfo = userInfo; return next && next(); } } catch (err) { if (err instanceof TokenExpiredError) { const decodedPkg = await decodeAsync(bearer); // Handle the case the user logout when token already expired => should still allow clearing temp data if (req.path.endsWith('/clearTempData')) { req.uid = decodedPkg.uid; if (!ObjectId.isValid(req.uid)) AppAuthError.throw(); req.ut = decodedPkg.ut; return next && next(); } AppAuthError.throw(Errors.TOKEN_EXPIRED); } else { AppAuthError.throw(); } } } function getUserInfo(req) { if (!req) AppParamError.throw(); const userInfo = req.userInfo; if (!USE_SUBSCRIPTION) return userInfo; // Temporarily before merging subscription management feature if (!userInfo || !userInfo.membership || utils.isEmptyArray(userInfo.membership?.subscriptions)) AppMembershipError.throw(); return userInfo; } /** Check for require any subscription. It requires to be called after checkUser middleware */ async function checkRqAnySubscription(req, res, next) { if (!USE_SUBSCRIPTION) return next && next(); // Temporarily before merging subscription management feature const routeUrl = getRoutePath(req); if (!isSecuredRoute(routeUrl, req.method)) return next && next(); try { getUserInfo(req); return next && next(); } catch (err) { return next && next(err); } } /** Check for require any subscription. It requires to be called after checkUser middleware */ async function checkRqPkgSubscription(req, res, next) { if (!USE_SUBSCRIPTION) return next && next(); // Temporarily before merging subscription management feature const routeUrl = getRoutePath(req); if (!isSecuredRoute(routeUrl, req.method)) return next && next(); try { const userInfo = getUserInfo(req); if (utils.isEmptyArray(userInfo?.membership?.subscriptions) || !(userInfo.membership.subscriptions.some(sub => sub.type === SubType.PACKAGE))) { return AppMembershipError.throw(Errors.PKG_SUBSCRIPTION_NOT_FOUND); } return next && next(); } catch (err) { return next && next(err); } } // async function checkRqTrackingSubscription(req, res, next) { // // TODO: Handle the check for subscription for addon(s) by name. // } async function checkACsLimits(userInfo) { if (!USE_SUBSCRIPTION) return true; // Temporarily before merging subscription management feature if (!userInfo) AppAuthError.throw(); const pkgSub = subUtil.getPkgSubfromUserInfo(userInfo); const numOfAC = await Vehicle.countDocuments({ parent: userInfo.puid, [Fields.MARKED_DELETE]: { $in: [false, null] }, pkgActive: true }); // Check for customer-specific override first, fallback to package limit const maxVehicles = userInfo.membership?.customLimits?.maxVehicles ?? subUtil.getSubMetaField(pkgSub, SubFields.MAX_VEHICLES) ?? 0; if (numOfAC && numOfAC >= maxVehicles) { AppMembershipError.throw(Errors.REACHED_VEHICLES_LIMIT); } return true; } async function checkUsageLimits(userInfo) { if (!USE_SUBSCRIPTION) return true; // Temporarily before merging subscription management feature if (!userInfo) AppAuthError.throw(); const pkgSub = subUtil.getPkgSubfromUserInfo(userInfo); // Check and skip paid migrated users which have trial subscription and migratedDate is not null if (pkgSub.type === SubType.TRIAL && userInfo?.migratedDate) return true; // Check for customer-specific override first, fallback to package limit const priceMaxAcres = userInfo.membership?.customLimits?.maxAcres ?? subUtil.getSubMetaField(pkgSub, SubFields.MAX_ACRES) ?? 0; if (priceMaxAcres) { const ttSprArea = await subUtil.calcTotalAreaByUser(ObjectId(userInfo.puid), pkgSub.periodStart, pkgSub.periodEnd); if (ttSprArea && utils.haToAcre(ttSprArea) >= priceMaxAcres) { AppMembershipError.throw(Errors.REACHED_AREA_LIMIT); } } return true; } async function checkRqACsLimits(req, res, next) { if (!USE_SUBSCRIPTION) return next && next(); // Temporarily before merging subscription management feature const userInfo = getUserInfo(req); await checkACsLimits(userInfo); return next && (next()); } async function checkRqUsageLimits(req, res, next) { if (!USE_SUBSCRIPTION) return next && next(); // Temporarily before merging subscription management feature const userInfo = getUserInfo(req); await checkUsageLimits(userInfo); return next && next(); } /** * API-Key middleware for the public data-export API (/api/v1/ routes). * * Reads the X-API-Key header, looks up a candidate key by its prefix (first 8 chars), * then bcrypt-compares the full key. On success, sets req.uid identical to checkUser * so all existing controller ownership filters work without modification. * * lastUsedAt is updated fire-and-forget to avoid adding latency to every request. */ async function checkApiKey(req, res, next) { const ApiKey = require('../model/api_key'); // lazy require to avoid circular dependency risk const rawKey = req.headers['x-api-key']; if (!rawKey || rawKey.length < 8) return AppAuthError.throw(); const prefix = rawKey.substring(0, 8); // Find active keys matching this prefix (should be at most one, prefix is not a unique index // to avoid leaking timing info about key existence) const candidates = await ApiKey.find({ prefix, active: true }).limit(5).lean(); if (!candidates.length) AppAuthError.throw(); let matched = null; for (const candidate of candidates) { const ok = await bcrypt.compare(rawKey, candidate.keyHash); if (ok) { matched = candidate; break; } } if (!matched) AppAuthError.throw(); // Mirror what checkUser sets — controllers need req.uid and nothing else req.uid = matched.owner.toString(); req.apiKeyId = matched._id; // Fire-and-forget lastUsedAt + requestCount update (do not await — avoids adding DB latency to request) ApiKey.updateOne({ _id: matched._id }, { $set: { lastUsedAt: new Date() }, $inc: { requestCount: 1 } }).catch(() => {}); // Load owner's userInfo from cache (same path as checkUser) const userInfo = cache.get(req.uid); if (userInfo && !userInfo[Fields.MARKED_DELETE]) { req.userInfo = userInfo; } else { req.userInfo = await cache.loadUser(req.uid); } return next && next(); } module.exports = { isSecuredRoute, getUserInfo, checkUser, checkApiKey, checkACsLimits, checkUsageLimits, checkRqAnySubscription, checkRqPkgSubscription, checkRqACsLimits, checkRqUsageLimits, };