Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fast-csv": "^5.0.2",
"geoip-lite": "^1.4.10",
"google-auth-library": "^9.15.1",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
Expand Down Expand Up @@ -66,6 +67,7 @@
"@swc/core": "^1.10.7",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/geoip-lite": "^1.4.4",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.10.7",
Expand Down
5 changes: 5 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { JwtAuthModule, JwtAuthMiddleware } from './auth/middleware/jwt-auth.mod
import { REDIS_CLIENT } from './redis/redis.constants';
import jwtConfig from './auth/authConfig/jwt.config';
import { UsersService } from './users/providers/users.service';
import { GeolocationMiddleware } from './common/middleware/geolocation.middleware';
import { HealthModule } from './health/health.module';

// const ENV = process.env.NODE_ENV;
Expand Down Expand Up @@ -110,6 +111,10 @@ export class AppModule implements NestModule {
* Apply the JWT Authentication Middleware to all routes except public ones.
*/
configure(consumer: MiddlewareConsumer) {
consumer
.apply(GeolocationMiddleware)
.forRoutes('*');

consumer
.apply(JwtAuthMiddleware)
.exclude(
Expand Down
10 changes: 8 additions & 2 deletions backend/src/common/common.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { Module } from '@nestjs/common';
import { PaginationProvider } from './pagination/provider/pagination-provider';
import { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';
import { GeolocationMiddleware } from './middleware/geolocation.middleware';
import { RedisModule } from '../redis/redis.module';

@Module({
providers: [PaginationProvider, CorrelationIdMiddleware],
exports: [PaginationProvider, CorrelationIdMiddleware],
imports: [RedisModule],
providers: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
exports: [PaginationProvider, CorrelationIdMiddleware, GeolocationMiddleware],
})
export class CommonModule {}

// Re-export public API so other modules can import from '@/common'
export * from './errors';
export * from './filters/http-exception.filter';
export * from './middleware/correlation-id.middleware';
export * from './middleware/geolocation.middleware';
export * from './interfaces/geolocation.interface';
export * from './decorators/geolocation.decorator';

15 changes: 15 additions & 0 deletions backend/src/common/decorators/geolocation.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GeolocationData } from '../interfaces/geolocation.interface';

export const LocationData = createParamDecorator(
(data: keyof GeolocationData | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const location = request.location;

if (!location) {
return null;
}

return data ? location[data] : location;
},
);
15 changes: 15 additions & 0 deletions backend/src/common/interfaces/geolocation.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface GeolocationData {
ip: string;
country: string;
region: string;
city: string;
timezone: string;
language: string;
isOverride: boolean;
}

declare module 'express' {
export interface Request {
location?: GeolocationData;
}
}
157 changes: 157 additions & 0 deletions backend/src/common/middleware/geolocation.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Injectable, NestMiddleware, Inject, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as geoip from 'geoip-lite';
import { REDIS_CLIENT } from '../../redis/redis.constants';
import Redis from 'ioredis';
import { GeolocationData } from '../interfaces/geolocation.interface';

@Injectable()
export class GeolocationMiddleware implements NestMiddleware {
private readonly logger = new Logger(GeolocationMiddleware.name);

// 24 hours in seconds
private readonly CACHE_TTL = 86400;

// Default fallback location
private readonly DEFAULT_LOCATION: Partial<GeolocationData> = {
country: 'US',
region: 'NY',
city: 'New York',
timezone: 'America/New_York',
};

constructor(
@Inject(REDIS_CLIENT) private readonly redisClient: Redis,
) {}

async use(req: Request, res: Response, next: NextFunction) {
try {
// 1. Detect language from Accept-Language header
const acceptLanguage = req.headers['accept-language'] as string;
const language = this.parseAcceptLanguage(acceptLanguage);

// 2. Check for manual location override via headers or query
const overrideCountry = req.headers['x-override-country'] as string;
const overrideCity = req.headers['x-override-city'] as string;
const overrideTimezone = req.headers['x-override-timezone'] as string;

const ip = this.getClientIp(req);

if (overrideCountry || overrideCity || overrideTimezone) {
req.location = {
ip,
country: overrideCountry || this.DEFAULT_LOCATION.country!,
region: '',
city: overrideCity || this.DEFAULT_LOCATION.city!,
timezone: overrideTimezone || this.DEFAULT_LOCATION.timezone!,
language,
isOverride: true,
};
return next();
}

if (!ip || ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
// Localhost access fallback
req.location = {
ip: ip || '127.0.0.1',
country: this.DEFAULT_LOCATION.country!,
region: this.DEFAULT_LOCATION.region!,
city: this.DEFAULT_LOCATION.city!,
timezone: this.DEFAULT_LOCATION.timezone!,
language,
isOverride: false,
};
return next();
}

// 4. Check Cache
const cacheKey = `geoip:${ip}`;
const cachedData = await this.redisClient.get(cacheKey);

if (cachedData) {
const parsed = JSON.parse(cachedData) as Partial<GeolocationData>;
req.location = {
ip: parsed.ip || ip,
country: parsed.country!,
region: parsed.region!,
city: parsed.city!,
timezone: parsed.timezone!,
language,
isOverride: false
};
return next();
}

// 5. Lookup GeoIP
const geo = geoip.lookup(ip);

if (geo) {
const locationData: GeolocationData = {
ip,
country: geo.country,
region: geo.region,
city: geo.city,
timezone: geo.timezone,
language,
isOverride: false,
};

req.location = locationData;

// Cache result (store only needed parts to comply with privacy)
await this.redisClient.setex(cacheKey, this.CACHE_TTL, JSON.stringify({
ip: locationData.ip,
country: locationData.country,
region: locationData.region,
city: locationData.city,
timezone: locationData.timezone,
}));
} else {
// Fallback
req.location = {
ip,
country: this.DEFAULT_LOCATION.country!,
region: this.DEFAULT_LOCATION.region!,
city: this.DEFAULT_LOCATION.city!,
timezone: this.DEFAULT_LOCATION.timezone!,
language,
isOverride: false,
};
}

next();
} catch (error) {
this.logger.error(`Geolocation error: ${(error as Error).message}`, (error as Error).stack);
// Don't break application if geolocation fails
req.location = {
ip: this.getClientIp(req),
country: this.DEFAULT_LOCATION.country!,
region: this.DEFAULT_LOCATION.region!,
city: this.DEFAULT_LOCATION.city!,
timezone: this.DEFAULT_LOCATION.timezone!,
language: 'en',
isOverride: false,
};
next();
}
}

private getClientIp(req: Request): string {
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
if (Array.isArray(xForwardedFor)) {
return xForwardedFor[0].split(',')[0].trim();
}
return xForwardedFor.split(',')[0].trim();
}

return req.ip || req.socket.remoteAddress || '127.0.0.1';
}

private parseAcceptLanguage(acceptLanguage?: string): string {
if (!acceptLanguage) return 'en';
// Example: "en-US,en;q=0.9" -> "en-US"
const parsed = acceptLanguage.split(',')[0].split(';')[0].trim();
return parsed || 'en';
}
}
Loading
Loading