1+ import mongoose from 'mongoose' ;
12import User from '../models/User.js' ;
23import UserStatsService from '../components/utils/userStatsService.js' ;
4+ import { rateLimit } from '../utils/rateLimit.js' ;
35
6+ // Username validation regex: alphanumeric and underscores only, 3-20 characters
7+ const USERNAME_REGEX = / ^ [ a - z A - Z 0 - 9 _ ] { 3 , 20 } $ / ;
8+
9+ // MongoDB ObjectId validation regex
10+ const OBJECT_ID_REGEX = / ^ [ 0 - 9 a - f A - F ] { 24 } $ / ;
11+
12+ /**
13+ * Sanitize progression data by removing sensitive fields
14+ * @param {Array } progression - Raw progression data
15+ * @param {boolean } isPublic - Whether this is a public (username-based) request
16+ * @returns {Array } Sanitized progression data
17+ */
18+ function sanitizeProgression ( progression , isPublic = false ) {
19+ return progression . map ( stat => {
20+ const sanitized = {
21+ timestamp : stat . timestamp ,
22+ totalXp : stat . totalXp ,
23+ xpRank : stat . xpRank ,
24+ elo : stat . elo ,
25+ eloRank : stat . eloRank ,
26+ triggerEvent : stat . triggerEvent ,
27+ // Calculated fields
28+ xpGain : stat . xpGain || 0 ,
29+ eloChange : stat . eloChange || 0 ,
30+ rankImprovement : stat . rankImprovement || 0
31+ } ;
32+
33+ // Never expose userId for public requests
34+ if ( ! isPublic ) {
35+ sanitized . userId = stat . userId ;
36+ }
37+
38+ // Never expose gameId, eloRefundDetails, or other sensitive fields
39+ // These are intentionally excluded for security
40+
41+ return sanitized ;
42+ } ) ;
43+ }
44+
45+ /**
46+ * User Progression API Endpoint
47+ * Returns user stats progression for charts
48+ * Includes rate limiting, input validation, and security measures
49+ */
450export default async function handler ( req , res ) {
551 // Only allow POST requests
652 if ( req . method !== 'POST' ) {
753 return res . status ( 405 ) . json ( { message : 'Method not allowed' } ) ;
854 }
955
10- try {
11- const { userId, username } = req . body ;
56+ // Determine if this is a public (username-based) or authenticated (userId-based) request
57+ const { userId, username } = req . body ;
58+ const isPublicRequest = ! userId && ! ! username ;
59+
60+ // Apply stricter rate limiting for public requests
61+ // Public: 5 requests per minute per IP
62+ // Authenticated: 20 requests per minute per IP
63+ const limiter = rateLimit ( {
64+ max : isPublicRequest ? 5 : 20 ,
65+ windowMs : 60000 ,
66+ message : 'Too many requests. Please try again later.'
67+ } ) ;
68+
69+ if ( ! limiter ( req , res ) ) {
70+ return ; // Rate limit exceeded, response already sent
71+ }
1272
13- // Accept either userId or username
73+ try {
74+ // Validate input: must provide either userId or username, but not both
1475 if ( ! userId && ! username ) {
1576 return res . status ( 400 ) . json ( { message : 'UserId or username is required' } ) ;
1677 }
1778
79+ if ( userId && username ) {
80+ return res . status ( 400 ) . json ( { message : 'Provide either userId or username, not both' } ) ;
81+ }
82+
83+ // Validate userId format (MongoDB ObjectId)
84+ if ( userId ) {
85+ if ( typeof userId !== 'string' || ! OBJECT_ID_REGEX . test ( userId ) ) {
86+ return res . status ( 400 ) . json ( { message : 'Invalid userId format' } ) ;
87+ }
88+ }
89+
90+ // Validate username format (prevent injection attacks)
91+ if ( username ) {
92+ if ( typeof username !== 'string' ) {
93+ return res . status ( 400 ) . json ( { message : 'Username must be a string' } ) ;
94+ }
95+ if ( ! USERNAME_REGEX . test ( username ) ) {
96+ return res . status ( 400 ) . json ( {
97+ message : 'Invalid username format. Username must be 3-20 characters and contain only letters, numbers, and underscores.'
98+ } ) ;
99+ }
100+ }
101+
102+ // Connect to MongoDB if not already connected
103+ if ( mongoose . connection . readyState !== 1 ) {
104+ try {
105+ await mongoose . connect ( process . env . MONGODB ) ;
106+ } catch ( error ) {
107+ console . error ( 'Database connection failed:' , error ) ;
108+ return res . status ( 500 ) . json ( { message : 'Internal server error' } ) ;
109+ }
110+ }
111+
18112 // Find user by userId or username
19113 let user ;
20114 if ( userId ) {
@@ -23,30 +117,42 @@ export default async function handler(req, res) {
23117 user = await User . findOne ( { username : username } ) ;
24118 }
25119
120+ // Generic error message to prevent user enumeration
26121 if ( ! user ) {
27122 return res . status ( 404 ) . json ( { message : 'User not found' } ) ;
28123 }
29124
30125 // Exclude banned users and users with pending name changes (public API security)
31- if ( ( user . banned === true || user . pendingNameChange === true ) && ! userId ) {
126+ if ( ( user . banned === true || user . pendingNameChange === true ) && isPublicRequest ) {
32127 // Only apply this check for username-based requests (public access)
33128 // Allow userId-based requests (authenticated user viewing their own data)
34129 return res . status ( 404 ) . json ( { message : 'User not found' } ) ;
35130 }
36131
37- // Get user's stats progression - all available data
132+ // Get user's stats progression
38133 const progression = await UserStatsService . getUserProgression ( user . _id ) ;
39134
40- return res . status ( 200 ) . json ( {
41- progression,
42- userId : user . _id ,
135+ // Sanitize progression data - remove gameId and other sensitive fields
136+ const sanitizedProgression = sanitizeProgression ( progression , isPublicRequest ) ;
137+
138+ // Build response
139+ const response = {
140+ progression : sanitizedProgression ,
43141 username : user . username
44- } ) ;
142+ } ;
143+
144+ // Only include userId for authenticated requests (not public)
145+ if ( ! isPublicRequest ) {
146+ response . userId = user . _id . toString ( ) ;
147+ }
148+
149+ return res . status ( 200 ) . json ( response ) ;
150+
45151 } catch ( error ) {
46152 console . error ( 'Error fetching user progression:' , error ) ;
153+ // Don't expose internal error details in production
47154 return res . status ( 500 ) . json ( {
48- message : 'An error occurred' ,
49- error : error . message
155+ message : 'An error occurred while fetching progression data'
50156 } ) ;
51157 }
52- }
158+ }
0 commit comments