Skip to content

Commit 3f78c71

Browse files
committed
feat: Implement profile view tracking and display in public profiles
- Added functionality to track unique profile views based on IP address, preventing spam and ensuring accurate view counts. - Updated the User model to include a profileViews field for tracking total views. - Enhanced the public profile API to return the updated profile view count. - Modified the AccountView component to display the number of profile views in the public profile. - Added translations for "Profile Views" in German, English, Spanish, French, and Russian locales to support internationalization.
1 parent 2b2be5d commit 3f78c71

File tree

8 files changed

+113
-12
lines changed

8 files changed

+113
-12
lines changed

api/publicProfile.js

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import { rateLimit } from '../utils/rateLimit.js';
66
// Username validation regex: alphanumeric and underscores only, 3-20 characters
77
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/;
88

9-
// Cache for profile data (username -> {data, timestamp})
9+
// Cache for profile data (userId -> {data, timestamp})
1010
const profileCache = new Map();
1111
const 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
1419
setInterval(() => {
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

components/accountView.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useTranslation } from '@/components/useTranslations'
33
import { getLeague, leagues } from "./utils/leagues";
44
import { useEffect, useState } from "react";
55
import { createPortal } from "react-dom";
6-
import { FaClock, FaGamepad, FaStar, FaEye } from "react-icons/fa6";
6+
import { FaClock, FaGamepad, FaStar, FaEye, FaUsers } from "react-icons/fa6";
77
import XPGraph from "./XPGraph";
88
import PendingNameChangeModal from "./pendingNameChangeModal";
99

@@ -167,6 +167,13 @@ export default function AccountView({ accountData, supporter, eloData, session,
167167
{text("gamesPlayed", { games: accountData.gamesLen || accountData.gamesPlayed || 0 })}
168168
</div>
169169

170+
{viewingPublicProfile && accountData.profileViews !== undefined && (
171+
<div style={textStyle}>
172+
<FaUsers style={iconStyle} />
173+
{text("profileViews") || "Profile Views"}: {accountData.profileViews.toLocaleString()}
174+
</div>
175+
)}
176+
170177
{/* change name button - hidden in public view */}
171178
{!isPublic && (
172179
<>

models/User.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ const userSchema = new mongoose.Schema({
156156
lastNameChange: {
157157
type: Date,
158158
default: 0
159+
},
160+
profileViews: {
161+
type: Number,
162+
default: 0
159163
}
160164
});
161165

public/locales/de/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"joined": "Beigetreten vor {{t}}",
7777
"lastSeen": "Zuletzt gesehen",
7878
"ago": "vor",
79+
"profileViews": "Profilaufrufe",
7980
"chat": "Chat",
8081
"minuteSingular": "Minute",
8182
"minutePlural": "Minuten",

public/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"joined": "Joined {{t}} ago",
7777
"lastSeen": "Last seen",
7878
"ago": "ago",
79+
"profileViews": "Profile Views",
7980
"chat": "Chat",
8081
"minuteSingular": "minute",
8182
"minutePlural": "minutes",

public/locales/es/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"joined": "Unido hace {{t}}",
7777
"lastSeen": "Última vez visto",
7878
"ago": "hace",
79+
"profileViews": "Vistas del perfil",
7980
"chat": "Chat",
8081
"minuteSingular": "minuto",
8182
"minutePlural": "minutos",

public/locales/fr/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"joined": "Inscrit il y a {{t}}",
7777
"lastSeen": "Dernière connexion",
7878
"ago": "il y a",
79+
"profileViews": "Vues du profil",
7980
"chat": "Chat",
8081
"minuteSingular": "minute",
8182
"minutePlural": "minutes",

public/locales/ru/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"joined": "Присоединился {{t}} назад",
7777
"lastSeen": "Последний раз в сети",
7878
"ago": "назад",
79+
"profileViews": "Просмотры профиля",
7980
"chat": "Чат",
8081
"minuteSingular": "минута",
8182
"minutePlural": "минут",

0 commit comments

Comments
 (0)