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/apps/cloud/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AppController } from './app.controller'
import { isPrimaryNode, isPrimaryClusterNode } from './common/utils'
import { OrganisationModule } from './organisation/organisation.module'
import { RevenueModule } from './revenue/revenue.module'
import { ToolsModule } from './tools/tools.module'

const modules = [
SentryModule.forRoot(),
Expand Down Expand Up @@ -90,6 +91,7 @@ const modules = [
HealthModule,
OrganisationModule,
RevenueModule,
ToolsModule,
]

@Module({
Expand Down
48 changes: 48 additions & 0 deletions backend/apps/cloud/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,54 @@ export const getGeoDetails = (ip: string, tz?: string): IPGeoDetails => {
}
}

export interface ExtendedGeoDetails {
country: string | null
countryName: string | null
city: string | null
region: string | null
regionCode: string | null
continentCode: string | null
continentName: string | null
postalCode: string | null
latitude: number | null
longitude: number | null
timezone: string | null
}

export const getExtendedGeoDetails = (ip: string): ExtendedGeoDetails => {
const data = lookup.get(ip)

if (!data) {
return {
country: null,
countryName: null,
city: null,
region: null,
regionCode: null,
continentCode: null,
continentName: null,
postalCode: null,
latitude: null,
longitude: null,
timezone: null,
}
}

return {
country: data.country?.iso_code || null,
countryName: data.country?.names?.en || null,
city: data.city?.names?.en || null,
region: data.subdivisions?.[0]?.names?.en || null,
regionCode: data.subdivisions?.[0]?.iso_code || null,
continentCode: data.continent?.code || null,
continentName: data.continent?.names?.en || null,
postalCode: data.postal?.code || null,
latitude: data.location?.latitude || null,
longitude: data.location?.longitude || null,
timezone: data.location?.time_zone || null,
}
}

const normalise = (raw: unknown): string | null => {
if (!raw) return null
const str = String(raw)
Expand Down
56 changes: 56 additions & 0 deletions backend/apps/cloud/src/tools/dto/ip-lookup.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsOptional, IsString } from 'class-validator'

export class IpLookupQueryDto {
@ApiPropertyOptional({
description: 'IP address to look up. If not provided, uses the request IP.',
example: '8.8.8.8',
})
@IsOptional()
@IsString()
ip?: string
}

export class IpLookupResponseDto {
@ApiProperty({ description: 'The IP address that was looked up' })
ip: string

@ApiPropertyOptional({ description: 'Country ISO-3166 alpha-2 code' })
country: string | null

@ApiPropertyOptional({ description: 'Country name' })
countryName: string | null

@ApiPropertyOptional({ description: 'City name' })
city: string | null

@ApiPropertyOptional({ description: 'State/Province/Region name' })
region: string | null

@ApiPropertyOptional({ description: 'State/Province ISO-3166-2 code' })
regionCode: string | null

@ApiPropertyOptional({ description: 'Continent code' })
continentCode: string | null

@ApiPropertyOptional({ description: 'Continent name' })
continentName: string | null

@ApiPropertyOptional({ description: 'Postal/ZIP code' })
postalCode: string | null

@ApiPropertyOptional({ description: 'Latitude coordinate' })
latitude: number | null

@ApiPropertyOptional({ description: 'Longitude coordinate' })
longitude: number | null

@ApiPropertyOptional({ description: 'Timezone (IANA format)' })
timezone: string | null

@ApiPropertyOptional({ description: 'Whether the country is in the EU' })
isInEuropeanUnion: boolean

@ApiPropertyOptional({ description: 'IP version (4 or 6)' })
ipVersion: 4 | 6
}
83 changes: 83 additions & 0 deletions backend/apps/cloud/src/tools/tools.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
BadRequestException,
Controller,
Get,
Headers,
Ip,
Query,
} from '@nestjs/common'
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
import net from 'net'

import { ToolsService } from './tools.service'
import { IpLookupQueryDto, IpLookupResponseDto } from './dto/ip-lookup.dto'
import { getIPFromHeaders, checkRateLimit } from '../common/utils'
import { trackCustom } from '../common/analytics'
import { Public } from '../auth/decorators'

const IP_LOOKUP_RL_REQUESTS = 30
const IP_LOOKUP_RL_TIMEOUT = 60 // 1 minute

@ApiTags('Tools')
@Controller('tools')
export class ToolsController {
constructor(private readonly toolsService: ToolsService) {}

@Get('ip-lookup')
@Public()
@ApiOperation({
summary: 'Look up geolocation information for an IP address',
description:
'Returns detailed geolocation data including country, city, region, coordinates, timezone, and more. If no IP is provided, uses the IP of the requester.',
})
@ApiResponse({
status: 200,
description: 'IP geolocation data',
type: IpLookupResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid IP address provided',
})
async lookupIP(
@Query() query: IpLookupQueryDto,
@Headers() headers: unknown,
@Ip() reqIP: string,
): Promise<IpLookupResponseDto> {
const clientIp = getIPFromHeaders(headers) || reqIP || ''
const userAgent =
typeof headers === 'object' && headers !== null
? (headers as Record<string, string>)['user-agent'] || ''
: ''

await checkRateLimit(
clientIp,
'ip-lookup',
IP_LOOKUP_RL_REQUESTS,
IP_LOOKUP_RL_TIMEOUT,
)

let ip = query.ip || ''

// Normalise IPv6 localhost
if (ip === '::1' || ip === '::ffff:127.0.0.1') {
ip = '127.0.0.1'
}

// Validate IP format
if (!ip || !net.isIP(ip)) {
throw new BadRequestException('Please enter a valid IP address')
}

const result = this.toolsService.lookupIP(ip)

trackCustom(clientIp, userAgent, {
ev: 'IP_LOOKUP',
meta: {
ipVersion: String(result.ipVersion),
},
})

return result
}
}
10 changes: 10 additions & 0 deletions backend/apps/cloud/src/tools/tools.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { ToolsController } from './tools.controller'
import { ToolsService } from './tools.service'

@Module({
controllers: [ToolsController],
providers: [ToolsService],
exports: [ToolsService],
})
export class ToolsModule {}
59 changes: 59 additions & 0 deletions backend/apps/cloud/src/tools/tools.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common'
import net from 'net'
import { IpLookupResponseDto } from './dto/ip-lookup.dto'
import { getExtendedGeoDetails } from '../common/utils'

const EU_COUNTRIES = new Set([
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
])

@Injectable()
export class ToolsService {
lookupIP(ip: string): IpLookupResponseDto {
const ipVersion = net.isIPv4(ip) ? 4 : net.isIPv6(ip) ? 6 : 4
const geo = getExtendedGeoDetails(ip)

return {
ip,
country: geo.country,
countryName: geo.countryName,
city: geo.city,
region: geo.region,
regionCode: geo.regionCode,
continentCode: geo.continentCode,
continentName: geo.continentName,
postalCode: geo.postalCode,
latitude: geo.latitude,
longitude: geo.longitude,
timezone: geo.timezone,
isInEuropeanUnion: geo.country ? EU_COUNTRIES.has(geo.country) : false,
ipVersion,
}
}
}
42 changes: 37 additions & 5 deletions web/app/api/api.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function getApiUrl(): string {
return apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`
}

function getClientIP(request: Request): string | null {
export function getClientIP(request: Request): string | null {
const headers = request.headers

const cfIP = headers.get('cf-connecting-ip')
Expand Down Expand Up @@ -2050,10 +2050,7 @@ export async function generateSSOAuthURLServer(
})
}

export type {
SSOHashSuccessResponse,
SSOHashResponse,
} from '~/lib/models/Auth'
export type { SSOHashSuccessResponse, SSOHashResponse } from '~/lib/models/Auth'

import type { SSOHashSuccessResponse, SSOHashResponse } from '~/lib/models/Auth'

Expand Down Expand Up @@ -2375,3 +2372,38 @@ export async function getRevenueDataServer(
`log/revenue?${queryParams.toString()}`,
)
}

// ============================================================================
// MARK: Tools API
// ============================================================================

export interface IpLookupResponse {
ip: string
country: string | null
countryName: string | null
city: string | null
region: string | null
regionCode: string | null
continentCode: string | null
continentName: string | null
postalCode: string | null
latitude: number | null
longitude: number | null
timezone: string | null
isInEuropeanUnion: boolean
ipVersion: 4 | 6
}

export async function getIpLookupServer(
request: Request,
ip: string,
): Promise<ServerFetchResult<IpLookupResponse>> {
const queryParams = new URLSearchParams()
queryParams.append('ip', ip)

return serverFetch<IpLookupResponse>(
request,
`tools/ip-lookup?${queryParams.toString()}`,
{ skipAuth: true },
)
}
23 changes: 16 additions & 7 deletions web/app/components/Footer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const productionNavigation = {
{ key: 'utm', href: routesPath.utm_generator, internal: true },
{ key: 'ctr', href: routesPath.ctr_calculator, internal: true },
{ key: 'roi', href: routesPath.roi_calculator, internal: true },
{ name: 'IP Lookup', href: routesPath.ip_lookup, internal: true },
],
company: [
{ key: 'open', href: routesPath.open, internal: true },
Expand Down Expand Up @@ -120,6 +121,11 @@ const communityEditionNavigation = {
href: `https://swetrix.com${routesPath.roi_calculator}`,
internal: false,
},
{
name: 'IP Lookup',
href: `https://swetrix.com${routesPath.ip_lookup}`,
internal: false,
},
],
company: [
{ key: 'cloudEdition', href: 'https://swetrix.com', internal: false },
Expand Down Expand Up @@ -352,13 +358,16 @@ const Footer = ({ showDBIPMessage }: FooterProps) => {
<li className='mb-4 text-sm font-bold text-white uppercase'>
{t('footer.resources')}
</li>
{_map(navigation.resources, ({ key, href, internal }) => (
<li key={key} className='mb-2'>
<FooterLink href={href} internal={internal}>
{t(`footer.${key}`)}
</FooterLink>
</li>
))}
{_map(
navigation.resources,
({ key, name, href, internal }) => (
<li key={key} className='mb-2'>
<FooterLink href={href} internal={internal}>
{name || t(`footer.${key}`)}
</FooterLink>
</li>
),
)}
</ul>
</li>

Expand Down
Loading
Loading