@@ -6,10 +6,15 @@ import { rateLimit } from '../utils/rateLimit.js';
66// Username validation regex: alphanumeric and underscores only, 3-20 characters
77const USERNAME_REGEX = / ^ [ a - z A - Z 0 - 9 _ ] { 3 , 20 } $ / ;
88
9- // Cache for profile data (username -> {data, timestamp})
9+ // Cache for profile data (userId -> {data, timestamp})
1010const profileCache = new Map ( ) ;
1111const CACHE_DURATION = 60000 ; // 60 seconds
1212
13+ // In-memory store for IP -> profile views to prevent refresh spam
14+ // Format: "ip:userId" -> timestamp
15+ const profileViewTracking = new Map ( ) ;
16+ const VIEW_COOLDOWN = 5 * 60 * 1000 ; // 5 minutes - same IP can't count as a view again for 5 minutes
17+
1318// Cleanup old cache entries every 2 minutes
1419setInterval ( ( ) => {
1520 const now = Date . now ( ) ;
@@ -20,6 +25,16 @@ setInterval(() => {
2025 }
2126} , 120000 ) ;
2227
28+ // Cleanup old profile view tracking entries every 5 minutes
29+ setInterval ( ( ) => {
30+ const now = Date . now ( ) ;
31+ for ( const [ key , timestamp ] of profileViewTracking . entries ( ) ) {
32+ if ( now - timestamp > VIEW_COOLDOWN * 2 ) {
33+ profileViewTracking . delete ( key ) ;
34+ }
35+ }
36+ } , 5 * 60 * 1000 ) ;
37+
2338/**
2439 * Public Profile API Endpoint
2540 * Returns public profile data for a given username
@@ -51,13 +66,7 @@ export default async function handler(req, res) {
5166 } ) ;
5267 }
5368
54- // Check cache first
55- const cached = profileCache . get ( username . toLowerCase ( ) ) ;
56- if ( cached && Date . now ( ) - cached . timestamp < CACHE_DURATION ) {
57- return res . status ( 200 ) . json ( cached . data ) ;
58- }
59-
60- // Connect to MongoDB if not already connected
69+ // Connect to MongoDB if not already connected (needed for view tracking)
6170 if ( mongoose . connection . readyState !== 1 ) {
6271 try {
6372 await mongoose . connect ( process . env . MONGODB ) ;
@@ -86,6 +95,27 @@ export default async function handler(req, res) {
8695 return res . status ( 404 ) . json ( { message : 'User not found' } ) ;
8796 }
8897
98+ const userId = user . _id . toString ( ) ;
99+
100+ // Track profile view before checking cache (to ensure all unique IPs count)
101+ const clientIP = getClientIP ( req ) ;
102+ let viewCounted = false ;
103+ try {
104+ viewCounted = await trackProfileView ( userId , clientIP ) ;
105+ // If view was counted, invalidate cache to get fresh view count
106+ if ( viewCounted ) {
107+ profileCache . delete ( userId ) ;
108+ }
109+ } catch ( err ) {
110+ console . error ( 'Error tracking profile view:' , err ) ;
111+ }
112+
113+ // Check cache (after potentially invalidating it)
114+ const cached = profileCache . get ( userId ) ;
115+ if ( cached && Date . now ( ) - cached . timestamp < CACHE_DURATION ) {
116+ return res . status ( 200 ) . json ( cached . data ) ;
117+ }
118+
89119 // Calculate ELO rank (exclude banned users and pending name changes)
90120 const rank = ( await User . countDocuments ( {
91121 elo : { $gt : user . elo } ,
@@ -103,15 +133,25 @@ export default async function handler(req, res) {
103133 // Calculate "member since" duration
104134 const memberSince = calculateMemberSince ( user . created_at ) ;
105135
136+ // Refresh user data if view was counted to get updated view count
137+ let profileViews = user . profileViews || 0 ;
138+ if ( viewCounted ) {
139+ const refreshedUser = await User . findById ( userId ) ;
140+ if ( refreshedUser ) {
141+ profileViews = refreshedUser . profileViews || 0 ;
142+ }
143+ }
144+
106145 // Build public profile response (ONLY public data)
107146 const publicProfile = {
108147 username : user . username ,
109- userId : user . _id . toString ( ) ,
148+ userId : userId ,
110149 totalXp : user . totalXp || 0 ,
111150 gamesPlayed : user . totalGamesPlayed || 0 ,
112151 createdAt : user . created_at ,
113152 memberSince : memberSince ,
114153 lastLogin : user . lastLogin || user . created_at ,
154+ profileViews : profileViews ,
115155 elo : user . elo || 1000 ,
116156 rank : rank ,
117157 league : {
@@ -129,8 +169,8 @@ export default async function handler(req, res) {
129169 supporter : user . supporter === true
130170 } ;
131171
132- // Cache the response
133- profileCache . set ( username . toLowerCase ( ) , {
172+ // Cache the response using userId
173+ profileCache . set ( userId , {
134174 data : publicProfile ,
135175 timestamp : Date . now ( )
136176 } ) ;
@@ -144,6 +184,51 @@ export default async function handler(req, res) {
144184 }
145185}
146186
187+ /**
188+ * Get client IP address from request
189+ * @param {Object } req - Express request object
190+ * @returns {string } Client IP address
191+ */
192+ function getClientIP ( req ) {
193+ return req . headers [ 'x-forwarded-for' ] ?. split ( ',' ) [ 0 ] ?. trim ( ) ||
194+ req . headers [ 'x-real-ip' ] ||
195+ req . connection ?. remoteAddress ||
196+ req . socket ?. remoteAddress ||
197+ 'unknown' ;
198+ }
199+
200+ /**
201+ * Track profile view if not recently viewed by this IP
202+ * @param {string } userId - User ID of the profile being viewed
203+ * @param {string } ip - IP address of the viewer
204+ * @returns {boolean } True if view was counted, false if it was a duplicate
205+ */
206+ async function trackProfileView ( userId , ip ) {
207+ const viewKey = `${ ip } :${ userId } ` ;
208+ const now = Date . now ( ) ;
209+
210+ // Check if this IP recently viewed this profile
211+ const lastViewTime = profileViewTracking . get ( viewKey ) ;
212+ if ( lastViewTime && ( now - lastViewTime ) < VIEW_COOLDOWN ) {
213+ return false ; // Don't count duplicate views
214+ }
215+
216+ // Record this view
217+ profileViewTracking . set ( viewKey , now ) ;
218+
219+ // Increment profile view count in database
220+ try {
221+ await User . updateOne (
222+ { _id : userId } ,
223+ { $inc : { profileViews : 1 } }
224+ ) ;
225+ return true ; // View was counted
226+ } catch ( error ) {
227+ console . error ( 'Error tracking profile view:' , error ) ;
228+ return false ;
229+ }
230+ }
231+
147232/**
148233 * Calculate human-readable "member since" duration
149234 * @param {Date } createdAt - User creation date
0 commit comments