Skip to content

Commit 276cd2c

Browse files
authored
Merge pull request #345 from gabito1451/#323
IP Geolocation and Localization Middleware
2 parents 610bd35 + 17a50fe commit 276cd2c

File tree

7 files changed

+380
-13
lines changed

7 files changed

+380
-13
lines changed

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"class-transformer": "^0.5.1",
4040
"class-validator": "^0.14.1",
4141
"fast-csv": "^5.0.2",
42+
"geoip-lite": "^1.4.10",
4243
"google-auth-library": "^9.15.1",
4344
"ioredis": "^5.6.1",
4445
"jsonwebtoken": "^9.0.2",
@@ -66,6 +67,7 @@
6667
"@swc/core": "^1.10.7",
6768
"@types/bcryptjs": "^2.4.6",
6869
"@types/express": "^5.0.0",
70+
"@types/geoip-lite": "^1.4.4",
6971
"@types/jest": "^29.5.14",
7072
"@types/jsonwebtoken": "^9.0.9",
7173
"@types/node": "^22.10.7",

backend/src/app.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { JwtAuthModule, JwtAuthMiddleware } from './auth/middleware/jwt-auth.mod
2020
import { REDIS_CLIENT } from './redis/redis.constants';
2121
import jwtConfig from './auth/authConfig/jwt.config';
2222
import { UsersService } from './users/providers/users.service';
23+
import { GeolocationMiddleware } from './common/middleware/geolocation.middleware';
2324
import { HealthModule } from './health/health.module';
2425

2526
// const ENV = process.env.NODE_ENV;
@@ -110,6 +111,10 @@ export class AppModule implements NestModule {
110111
* Apply the JWT Authentication Middleware to all routes except public ones.
111112
*/
112113
configure(consumer: MiddlewareConsumer) {
114+
consumer
115+
.apply(GeolocationMiddleware)
116+
.forRoutes('*');
117+
113118
consumer
114119
.apply(JwtAuthMiddleware)
115120
.exclude(
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { Module } from '@nestjs/common';
22
import { PaginationProvider } from './pagination/provider/pagination-provider';
33
import { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';
4+
import { GeolocationMiddleware } from './middleware/geolocation.middleware';
5+
import { RedisModule } from '../redis/redis.module';
46

57
@Module({
6-
providers: [PaginationProvider, CorrelationIdMiddleware],
7-
exports: [PaginationProvider, CorrelationIdMiddleware],
8+
imports: [RedisModule],
9+
providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
10+
exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
811
})
912
export class CommonModule {}
1013

1114
// Re-export public API so other modules can import from '@/common'
1215
export * from './errors';
1316
export * from './filters/http-exception.filter';
1417
export * from './middleware/correlation-id.middleware';
18+
export * from './middleware/geolocation.middleware';
19+
export * from './interfaces/geolocation.interface';
20+
export * from './decorators/geolocation.decorator';
1521

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2+
import { GeolocationData } from '../interfaces/geolocation.interface';
3+
4+
export const LocationData = createParamDecorator(
5+
(data: keyof GeolocationData | undefined, ctx: ExecutionContext) => {
6+
const request = ctx.switchToHttp().getRequest();
7+
const location = request.location;
8+
9+
if (!location) {
10+
return null;
11+
}
12+
13+
return data ? location[data] : location;
14+
},
15+
);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface GeolocationData {
2+
ip: string;
3+
country: string;
4+
region: string;
5+
city: string;
6+
timezone: string;
7+
language: string;
8+
isOverride: boolean;
9+
}
10+
11+
declare module 'express' {
12+
export interface Request {
13+
location?: GeolocationData;
14+
}
15+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { Injectable, NestMiddleware, Inject, Logger } from '@nestjs/common';
2+
import { Request, Response, NextFunction } from 'express';
3+
import * as geoip from 'geoip-lite';
4+
import { REDIS_CLIENT } from '../../redis/redis.constants';
5+
import Redis from 'ioredis';
6+
import { GeolocationData } from '../interfaces/geolocation.interface';
7+
8+
@Injectable()
9+
export class GeolocationMiddleware implements NestMiddleware {
10+
private readonly logger = new Logger(GeolocationMiddleware.name);
11+
12+
// 24 hours in seconds
13+
private readonly CACHE_TTL = 86400;
14+
15+
// Default fallback location
16+
private readonly DEFAULT_LOCATION: Partial<GeolocationData> = {
17+
country: 'US',
18+
region: 'NY',
19+
city: 'New York',
20+
timezone: 'America/New_York',
21+
};
22+
23+
constructor(
24+
@Inject(REDIS_CLIENT) private readonly redisClient: Redis,
25+
) {}
26+
27+
async use(req: Request, res: Response, next: NextFunction) {
28+
try {
29+
// 1. Detect language from Accept-Language header
30+
const acceptLanguage = req.headers['accept-language'] as string;
31+
const language = this.parseAcceptLanguage(acceptLanguage);
32+
33+
// 2. Check for manual location override via headers or query
34+
const overrideCountry = req.headers['x-override-country'] as string;
35+
const overrideCity = req.headers['x-override-city'] as string;
36+
const overrideTimezone = req.headers['x-override-timezone'] as string;
37+
38+
const ip = this.getClientIp(req);
39+
40+
if (overrideCountry || overrideCity || overrideTimezone) {
41+
req.location = {
42+
ip,
43+
country: overrideCountry || this.DEFAULT_LOCATION.country!,
44+
region: '',
45+
city: overrideCity || this.DEFAULT_LOCATION.city!,
46+
timezone: overrideTimezone || this.DEFAULT_LOCATION.timezone!,
47+
language,
48+
isOverride: true,
49+
};
50+
return next();
51+
}
52+
53+
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
54+
// Localhost access fallback
55+
req.location = {
56+
ip: ip || '127.0.0.1',
57+
country: this.DEFAULT_LOCATION.country!,
58+
region: this.DEFAULT_LOCATION.region!,
59+
city: this.DEFAULT_LOCATION.city!,
60+
timezone: this.DEFAULT_LOCATION.timezone!,
61+
language,
62+
isOverride: false,
63+
};
64+
return next();
65+
}
66+
67+
// 4. Check Cache
68+
const cacheKey = `geoip:${ip}`;
69+
const cachedData = await this.redisClient.get(cacheKey);
70+
71+
if (cachedData) {
72+
const parsed = JSON.parse(cachedData) as Partial<GeolocationData>;
73+
req.location = {
74+
ip: parsed.ip || ip,
75+
country: parsed.country!,
76+
region: parsed.region!,
77+
city: parsed.city!,
78+
timezone: parsed.timezone!,
79+
language,
80+
isOverride: false
81+
};
82+
return next();
83+
}
84+
85+
// 5. Lookup GeoIP
86+
const geo = geoip.lookup(ip);
87+
88+
if (geo) {
89+
const locationData: GeolocationData = {
90+
ip,
91+
country: geo.country,
92+
region: geo.region,
93+
city: geo.city,
94+
timezone: geo.timezone,
95+
language,
96+
isOverride: false,
97+
};
98+
99+
req.location = locationData;
100+
101+
// Cache result (store only needed parts to comply with privacy)
102+
await this.redisClient.setex(cacheKey, this.CACHE_TTL, JSON.stringify({
103+
ip: locationData.ip,
104+
country: locationData.country,
105+
region: locationData.region,
106+
city: locationData.city,
107+
timezone: locationData.timezone,
108+
}));
109+
} else {
110+
// Fallback
111+
req.location = {
112+
ip,
113+
country: this.DEFAULT_LOCATION.country!,
114+
region: this.DEFAULT_LOCATION.region!,
115+
city: this.DEFAULT_LOCATION.city!,
116+
timezone: this.DEFAULT_LOCATION.timezone!,
117+
language,
118+
isOverride: false,
119+
};
120+
}
121+
122+
next();
123+
} catch (error) {
124+
this.logger.error(`Geolocation error: ${(error as Error).message}`, (error as Error).stack);
125+
// Don't break application if geolocation fails
126+
req.location = {
127+
ip: this.getClientIp(req),
128+
country: this.DEFAULT_LOCATION.country!,
129+
region: this.DEFAULT_LOCATION.region!,
130+
city: this.DEFAULT_LOCATION.city!,
131+
timezone: this.DEFAULT_LOCATION.timezone!,
132+
language: 'en',
133+
isOverride: false,
134+
};
135+
next();
136+
}
137+
}
138+
139+
private getClientIp(req: Request): string {
140+
const xForwardedFor = req.headers['x-forwarded-for'];
141+
if (xForwardedFor) {
142+
if (Array.isArray(xForwardedFor)) {
143+
return xForwardedFor[0].split(',')[0].trim();
144+
}
145+
return xForwardedFor.split(',')[0].trim();
146+
}
147+
148+
return req.ip || req.socket.remoteAddress || '127.0.0.1';
149+
}
150+
151+
private parseAcceptLanguage(acceptLanguage?: string): string {
152+
if (!acceptLanguage) return 'en';
153+
// Example: "en-US,en;q=0.9" -> "en-US"
154+
const parsed = acceptLanguage.split(',')[0].split(';')[0].trim();
155+
return parsed || 'en';
156+
}
157+
}

0 commit comments

Comments
 (0)