Merged PR 109: Bitip project initialization - GeoIP lookup service with frontend interface

- feat: Implement GeoIP lookup service with frontend interface
- feat: Add dotenv dependency and configure environment variables; update rate limiter response handling
- refactor: Remove development Dockerfile and docker-compose for streamlined setup; update GeoIP service to use new MaxMind types
- chore: update dependencies and ESLint configuration
- feat: Add documentation for breaking changes and package updates after major version upgrades
- feat: Add environment configuration files and update module imports for ES module support
- feat: Update nodemon configuration and add register script for ES module support
- feat: Add .gitattributes file to enforce LF line endings and define text/binary file types
- feat: Implement graceful shutdown with timeout and update nodemon configuration
- feat: Update environment configuration and add detailed configuration guide
- feat: add frontend origin validation and update rate limits
- feat: add versioning arguments and detailed OCI image labels to Dockerfile
- feat: add version and release notes endpoints, update frontend to display release notes
- feat: Refactor App component to use React Router for navigation
- feat: Update navigation styles and remove unused type definitions for react-router-dom
- feat: Generate runtime configuration for frontend and serve env.js
- feat: Update dependencies, enhance ESLint configuration, and improve Vite setup
- refactor: Remove ensureTrailingSlash function and simplify basePath assignment in Vite config
This commit is contained in:
Tudor Stanciu 2025-10-02 22:59:22 +00:00
parent 397e8abbc3
commit 345ed9c68c
53 changed files with 12149 additions and 29 deletions

31
.env Normal file
View File

@ -0,0 +1,31 @@
# Environment variables for Bitip GeoIP Service
# Local Development Configuration
# API Keys
FRONTEND_API_KEY=frontend-dev-key
EXTERNAL_API_KEYS=external-dev-key-1,external-dev-key-2
# Frontend Origin Validation
FRONTEND_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175
# Server Configuration
PORT=5172
BASE_PATH=/
NODE_ENV=development
# Database Path - Local MaxMind databases location
MAXMIND_DB_PATH=D:\\tools\\maxmind-dbs
# Rate Limiting Configuration
FRONTEND_RATE_WINDOW_MS=60000
FRONTEND_RATE_MAX=30
EXTERNAL_RATE_WINDOW_MS=60000
EXTERNAL_RATE_MAX=1000
# Batch Configuration
BATCH_LIMIT=100
DEBOUNCE_MS=2000
# Seq Logging (Optional - leave commented for local dev)
# SEQ_URL=http://localhost:5341
# SEQ_API_KEY=your-seq-api-key

39
.env.example Normal file
View File

@ -0,0 +1,39 @@
# Environment variables for Bitip GeoIP Service
# Copy this file to .env and fill in your values
# API Keys (CHANGE THESE IN PRODUCTION!)
FRONTEND_API_KEY=your-secure-frontend-api-key-here
EXTERNAL_API_KEYS=your-secure-external-api-key-1,your-secure-external-api-key-2
# Frontend Origin Validation
# Comma-separated list of allowed origins for frontend API key
# In production, set this to your actual domain(s)
FRONTEND_ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com
# Server Configuration
PORT=3000
BASE_PATH=/
NODE_ENV=production
# Database Path (usually doesn't need to change)
MAXMIND_DB_PATH=/usr/share/GeoIP
# Rate Limiting Configuration
FRONTEND_RATE_WINDOW_MS=60000 # 1 minute window
FRONTEND_RATE_MAX=30 # 30 requests per minute for frontend
EXTERNAL_RATE_WINDOW_MS=60000 # 1 minute window
EXTERNAL_RATE_MAX=1000 # 1000 requests per minute for external APIs
# Batch Configuration
BATCH_LIMIT=100 # Maximum IPs per batch request
DEBOUNCE_MS=2000 # Debounce delay for frontend input
# Seq Logging (Optional)
# Uncomment and fill if you want to use Seq for structured logging
# SEQ_URL=http://your-seq-server:5341
# SEQ_API_KEY=your-seq-api-key
# Development Overrides (used in docker-compose.dev.yml)
# VITE_API_URL=http://localhost:3000/api
# VITE_API_KEY=frontend-dev-key
# VITE_DEBOUNCE_MS=1000

21
.gitattributes vendored Normal file
View File

@ -0,0 +1,21 @@
# Force LF line endings for all text files
* text=auto eol=lf
# Explicitly declare text files
*.ts text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# Denote binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary

103
.gitignore vendored Normal file
View File

@ -0,0 +1,103 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production builds
dist/
build/
# Environment variables
# .env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
.nyc_output/
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# MaxMind database files (if downloaded locally)
*.mmdb
# Docker
.dockerignore
# Seq logs (if logging locally)
seq-data/
.claude/

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "avoid",
"endOfLine": "lf"
}

96
Dockerfile Normal file
View File

@ -0,0 +1,96 @@
# Multi-stage Dockerfile for Bitip GeoIP Service
# Stage 1: Build frontend
FROM node:18-alpine as frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
COPY src/frontend/package*.json ./
RUN npm ci --only=production
# Copy frontend source
COPY src/frontend/ ./
# Build frontend
RUN npm run build
# Stage 2: Build backend
FROM node:18-alpine as backend-builder
WORKDIR /app/backend
# Copy backend package files
COPY src/backend/package*.json ./
COPY src/backend/tsconfig.json ./
RUN npm ci
# Copy backend source
COPY src/backend/ ./
# Build backend
RUN npm run build
# Stage 3: Production image
FROM node:18-alpine
# Build arguments for versioning
ARG CREATED_AT=unknown
ARG APP_VERSION=1.0.0
ARG GIT_REVISION=unknown
# OCI Image labels
LABEL org.opencontainers.image.authors="Tudor Stanciu <tudor.stanciu94@gmail.com>" \
org.opencontainers.image.vendor="Toodle HomeLab" \
org.opencontainers.image.url="https://github.com/your-username/bitip" \
org.opencontainers.image.source="https://github.com/your-username/bitip" \
org.opencontainers.image.documentation="https://github.com/your-username/bitip/blob/main/README.md" \
org.opencontainers.image.licenses="Proprietary" \
org.opencontainers.image.title="Bitip GeoIP Service" \
org.opencontainers.image.description="Modern GeoIP lookup service with REST API and interactive web interface" \
org.opencontainers.image.created="${CREATED_AT}" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${GIT_REVISION}"
# Set version environment variable (accessible at runtime)
ENV APP_VERSION=${APP_VERSION} \
CREATED_AT=${CREATED_AT} \
GIT_REVISION=${GIT_REVISION}
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create app directory
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S bitip -u 1001
# Copy backend production files
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./dist/frontend
# Create directory for GeoIP databases
RUN mkdir -p /usr/share/GeoIP && \
chown -R bitip:nodejs /usr/share/GeoIP
# Change ownership of app directory
RUN chown -R bitip:nodejs /app
# Switch to non-root user
USER bitip
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => { process.exit(1) })"
# Start the application
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]

88
LICENSE Normal file
View File

@ -0,0 +1,88 @@
PROPRIETARY SOFTWARE LICENSE AGREEMENT
Copyright (c) 2025 Tudor Stanciu. All rights reserved.
GRANT OF LICENSE
Subject to the terms and conditions of this License Agreement, Tudor Stanciu ("Licensor") hereby grants you ("Licensee") a limited, non-exclusive, non-transferable, revocable license to use the Bitip GeoIP Service software ("Software") solely for the purposes and under the conditions specified herein.
PERMITTED USES
1. EVALUATION: You may evaluate the Software for potential licensing for a period not exceeding 30 days.
2. INTERNAL USE: With explicit written approval from the Licensor, you may use the Software for internal business purposes within a single organization.
PROHIBITED USES
1. REDISTRIBUTION: You may NOT distribute, sublicense, sell, rent, lease, or otherwise transfer the Software or any portion thereof to any third party without explicit written permission from the Licensor.
2. MODIFICATION: You may NOT modify, adapt, alter, translate, or create derivative works based upon the Software without explicit written permission from the Licensor.
3. REVERSE ENGINEERING: You may NOT reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software, except to the extent expressly permitted by applicable law.
4. SERVICE PROVISION: You may NOT use the Software to provide GeoIP lookup services to third parties, whether for profit or not, without a separate commercial license.
5. COMPETITIVE USE: You may NOT use the Software to develop, market, or distribute competing products or services.
COMMERCIAL LICENSING
For commercial use, redistribution, modification, or any use beyond the permitted evaluation period, you must obtain a separate license from the Licensor. Please contact tudor.stanciu94@gmail.com to inquire about licensing terms, pricing, and approval.
APPROVAL REQUIREMENT
Any use of this Software beyond the 30-day evaluation period requires explicit written approval from Tudor Stanciu. Requests for approval should be sent to tudor.stanciu94@gmail.com with details about the intended use case, organization, and expected deployment scale.
INTELLECTUAL PROPERTY
The Software is protected by copyright laws and international copyright treaties, as well as other intellectual property laws and treaties. The Software contains proprietary and confidential information of the Licensor. The Licensor retains all rights, title, and interest in and to the Software, including all intellectual property rights therein.
ATTRIBUTION
If you use the Software under an approved license, you must:
1. Retain all copyright, trademark, and attribution notices present in the Software
2. Provide clear attribution to Tudor Stanciu in any documentation or user-facing materials where the Software is referenced
3. Not remove or alter any proprietary notices or labels on the Software
DATA AND PRIVACY
The Software relies on third-party GeoIP databases (MaxMind GeoLite2). Users are responsible for:
1. Complying with MaxMind's license terms and conditions
2. Obtaining and maintaining current GeoIP database files
3. Ensuring compliance with applicable data protection and privacy laws
SUPPORT AND UPDATES
This license does not entitle you to receive technical support, updates, upgrades, or bug fixes unless explicitly agreed upon in a separate written agreement with the Licensor.
TERMINATION
1. This license is effective until terminated.
2. The Licensor may terminate this license at any time with or without notice if you breach any term of this agreement.
3. Your rights under this license will terminate automatically without notice if you fail to comply with any term herein.
4. Upon termination, you must immediately:
- Cease all use of the Software
- Destroy all copies of the Software in your possession or control
- Certify in writing to the Licensor that all copies have been destroyed
DISCLAIMER OF WARRANTY
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS OR THAT THE OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED OR ERROR-FREE.
LIMITATION OF LIABILITY
IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) ARISING IN ANY WAY OUT OF THE USE OF OR INABILITY TO USE THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
INDEMNIFICATION
You agree to indemnify, defend, and hold harmless the Licensor from and against any and all claims, damages, liabilities, costs, and expenses (including reasonable attorneys' fees) arising from your use of the Software or your breach of this License Agreement.
EXPORT COMPLIANCE
You acknowledge that the Software may be subject to export control laws and regulations. You agree to comply with all applicable export and re-export restrictions and not to export or re-export the Software in violation of any such restrictions.
GOVERNING LAW AND JURISDICTION
This License Agreement shall be governed by and construed in accordance with the laws of Romania, without regard to its conflict of laws principles. Any disputes arising under or in connection with this License Agreement shall be subject to the exclusive jurisdiction of the courts of Romania.
ENTIRE AGREEMENT
This License Agreement constitutes the entire agreement between you and the Licensor regarding the Software and supersedes all prior or contemporaneous understandings and agreements, whether written or oral, regarding such subject matter.
SEVERABILITY
If any provision of this License Agreement is held to be unenforceable or invalid, that provision shall be enforced to the maximum extent possible, and the other provisions shall remain in full force and effect.
WAIVER
No waiver of any term or condition of this License Agreement shall be deemed a further or continuing waiver of such term or condition or any other term or condition, and any failure to assert a right or provision under this License Agreement shall not constitute a waiver of such right or provision.
ACKNOWLEDGMENT
By downloading, installing, accessing, or using the Software, you acknowledge that you have read this License Agreement, understand it, and agree to be bound by its terms and conditions. If you do not agree to these terms, do not download, install, access, or use the Software.
For licensing inquiries, approval requests, or questions:
Contact: Tudor Stanciu
Email: tudor.stanciu94@gmail.com
Last Updated: October 1, 2025

235
Overview.json Normal file
View File

@ -0,0 +1,235 @@
{
"title": "Bitip - Professional GeoIP Lookup Service",
"subtitle": "Modern GeoIP lookup service with REST API and interactive web interface",
"lastUpdated": "2025-10-01T12:00:00Z",
"sections": [
{
"title": "Overview",
"content": "Bitip is a high-performance GeoIP lookup service designed to provide accurate geolocation data for IP addresses. Built with modern web technologies, it offers both a RESTful API for programmatic access and an intuitive web interface for interactive lookups. The service is built on the reliable MaxMind GeoLite2 database and provides real-time IP geolocation with detailed information including country, city, coordinates, timezone, and postal codes."
},
{
"title": "Core Features",
"items": [
"**Single IP Lookup** - Get geolocation data for individual IP addresses with detailed information including country, city, coordinates, timezone, and postal code",
"**Batch IP Lookup** - Process up to 100 IP addresses in a single request for efficient bulk operations",
"**Dual API Access** - Separate authentication for frontend and external API consumers with different rate limiting profiles",
"**Origin Validation** - Security layer that validates request origins for frontend API keys to prevent unauthorized access",
"**Rate Limiting** - Configurable request limits per API key type (100 req/min for frontend, 1000 req/min for external)",
"**Real-time Lookup** - Instant geolocation results powered by MaxMind GeoLite2 City database",
"**Interactive Web UI** - Modern, responsive interface for manual IP lookups with visual feedback and interactive maps",
"**RESTful API** - Clean, well-documented API endpoints for easy integration into your applications",
"**Docker Support** - Containerized deployment with Docker for easy setup and scalability",
"**Production Ready** - Includes logging, error handling, security middleware, and monitoring capabilities"
]
},
{
"title": "Technology Stack",
"subsections": [
{
"subtitle": "Backend",
"items": [
"**Node.js 18+** with ES Modules (ESM) for modern JavaScript features",
"**Express 5.x** - Fast, minimalist web framework with enhanced routing capabilities",
"**TypeScript 5.x** - Type-safe development with latest language features and compile-time checking",
"**MaxMind GeoIP2 Node.js API** - Official MaxMind library for GeoLite2 database integration",
"**express-rate-limit 8.x** - Advanced rate limiting with IP tracking and customizable limits",
"**Helmet** - Security middleware that sets various HTTP headers to protect Express applications",
"**CORS** - Cross-Origin Resource Sharing support for secure cross-domain requests",
"**Joi** - Schema validation for API request validation and error handling",
"**Seq Logging** (optional) - Structured logging for production monitoring and debugging",
"**node-cache** - In-memory caching layer for improved performance"
]
},
{
"subtitle": "Frontend",
"items": [
"**React 19.x** - Modern UI library with latest features and improved performance",
"**TypeScript** - Type-safe frontend development with IntelliSense support",
"**Vite 7.x** - Next-generation frontend tooling with lightning-fast HMR and optimized builds",
"**Axios** - Promise-based HTTP client for API requests with interceptors",
"**Leaflet & React-Leaflet** - Interactive map components for visualizing IP locations",
"**React Router 7.x** - Client-side routing for single-page application navigation",
"**CSS Modules** - Scoped styling with modern CSS features"
]
},
{
"subtitle": "Infrastructure",
"items": [
"**Docker** - Container platform for consistent deployment across environments",
"**Multi-stage Docker builds** - Optimized image size with separate build and runtime stages",
"**npm workspaces** - Monorepo structure for managing frontend and backend together",
"**ESLint & Prettier** - Code quality and formatting tools for consistent code style"
]
}
]
},
{
"title": "API Capabilities",
"subsections": [
{
"subtitle": "Single IP Lookup",
"items": [
"Endpoint: `GET /api/lookup/:ip`",
"Returns comprehensive geolocation data for a single IP address",
"Includes country, region, city, coordinates, timezone, and postal code",
"Supports both IPv4 and IPv6 addresses",
"Returns detailed location hierarchy with ISO codes"
]
},
{
"subtitle": "Batch IP Lookup",
"items": [
"Endpoint: `POST /api/lookup/batch`",
"Process up to 100 IP addresses in a single request",
"Returns array of results matching input order",
"Efficient bulk processing for large-scale operations",
"Individual error handling for each IP address"
]
},
{
"subtitle": "Current IP Detection",
"items": [
"Endpoint: `GET /api/ip`",
"Automatically detects and returns the requester's IP address",
"Useful for self-lookup functionality",
"Handles proxy headers (X-Forwarded-For, X-Real-IP)",
"Returns clean IP address without additional data"
]
},
{
"subtitle": "Version Information",
"items": [
"Endpoint: `GET /api/version`",
"Returns application version, build date, and git revision",
"Useful for monitoring and debugging deployments",
"Includes service name and metadata"
]
},
{
"subtitle": "Release Notes",
"items": [
"Endpoint: `GET /api/release-notes`",
"Returns detailed release notes for all versions",
"Structured JSON format with sections and subsections",
"Includes version history, features, and changes"
]
},
{
"subtitle": "Overview",
"items": [
"Endpoint: `GET /api/overview`",
"Returns comprehensive application overview",
"Includes current features, technologies, and capabilities",
"Always up-to-date system presentation"
]
}
]
},
{
"title": "Security Features",
"items": [
"**API Key Authentication** - Separate keys for frontend and external API access with configurable permissions",
"**Origin Validation** - Validates request origins for frontend API keys to prevent unauthorized domain usage",
"**Rate Limiting** - Prevents abuse with configurable limits per API key type (100/min frontend, 1000/min external)",
"**Helmet Security Headers** - Automatically sets secure HTTP headers (X-Frame-Options, X-Content-Type-Options, etc.)",
"**CORS Configuration** - Configurable cross-origin policies with whitelist support",
"**Input Validation** - Joi schema validation for all API requests to prevent injection attacks",
"**Error Handling** - Sanitized error messages that don't expose internal system details",
"**Docker Isolation** - Containerized environment with minimal attack surface"
]
},
{
"title": "Deployment Options",
"subsections": [
{
"subtitle": "Docker Deployment",
"items": [
"Pre-built Docker image with optimized multi-stage build",
"Single command deployment: `docker run -p 3100:3100 bitip`",
"Environment-based configuration via `.env` file",
"Health check endpoints for container orchestration",
"Volume mounting for GeoLite2 database updates"
]
},
{
"subtitle": "Development Setup",
"items": [
"npm workspaces for managing monorepo structure",
"Hot module replacement with Vite for fast development",
"Nodemon for backend auto-restart on changes",
"TypeScript compilation with watch mode",
"Concurrent frontend and backend development servers"
]
},
{
"subtitle": "Production Deployment",
"items": [
"Production-optimized build with code splitting",
"Static asset serving from Express backend",
"Structured logging with Seq integration (optional)",
"Environment-specific configuration management",
"Graceful shutdown with timeout handling"
]
}
]
},
{
"title": "Use Cases",
"items": [
"**Fraud Detection** - Identify suspicious IP addresses and detect geographic anomalies in user behavior",
"**Content Localization** - Serve region-specific content based on user location",
"**Analytics & Reporting** - Track geographic distribution of website visitors and API users",
"**Access Control** - Implement geo-blocking or geo-fencing for restricted content",
"**Network Diagnostics** - Debug network issues and trace IP address origins",
"**Compliance** - Ensure data residency requirements and regional regulations are met",
"**Marketing Intelligence** - Analyze customer geographic distribution for targeted campaigns",
"**Security Monitoring** - Monitor login attempts and API requests from different locations"
]
},
{
"title": "Performance & Scalability",
"items": [
"**In-Memory Database** - MaxMind GeoLite2 loaded in memory for sub-millisecond lookups",
"**Caching Layer** - Optional result caching with configurable TTL",
"**Batch Processing** - Efficient bulk lookup with up to 100 IPs per request",
"**Lightweight Architecture** - Minimal dependencies for fast startup and low memory footprint",
"**Horizontal Scaling** - Stateless design allows multiple instances behind load balancer",
"**Docker Optimization** - Multi-stage builds with minimal production image size",
"**Zero External Dependencies** - No database connections or external service calls during lookup"
]
},
{
"title": "Configuration & Customization",
"items": [
"**Environment Variables** - All configuration via `.env` file for easy deployment",
"**Configurable Rate Limits** - Adjust per API key type based on your needs",
"**Custom API Keys** - Define multiple API keys with different access levels",
"**Origin Whitelisting** - Control which domains can use frontend API keys",
"**Port Configuration** - Customize backend and frontend ports",
"**Logging Levels** - Adjust verbosity from debug to production",
"**Seq Integration** - Optional structured logging to Seq server",
"**Database Path** - Configurable GeoLite2 database location"
]
},
{
"title": "Monitoring & Observability",
"items": [
"**Structured Logging** - JSON-formatted logs with context and correlation IDs",
"**Version Endpoint** - Check deployed version, build date, and git revision",
"**Health Checks** - Monitor service availability and database status",
"**Error Tracking** - Detailed error messages with stack traces in development",
"**Request Logging** - Log all API requests with IP, method, path, and response time",
"**Rate Limit Metrics** - Track rate limit hits and blocked requests",
"**Seq Integration** - Optional centralized logging with query and dashboard capabilities"
]
},
{
"title": "Data Sources",
"content": "Bitip uses the **MaxMind GeoLite2 City** database, which provides accurate geolocation data for millions of IP addresses worldwide. The database is updated regularly by MaxMind and includes information about countries, regions, cities, coordinates, postal codes, and time zones. While GeoLite2 is a free version with good accuracy, MaxMind also offers premium databases with higher accuracy and additional data points for commercial applications."
},
{
"title": "License",
"content": "Bitip is **proprietary software** licensed under a custom license. The software is not open source and all rights are reserved by Tudor Stanciu. A 30-day evaluation period is provided for testing purposes. Commercial use, redistribution, and modifications require explicit written approval. See the LICENSE file for complete terms and conditions, or contact tudor.stanciu94@gmail.com for licensing inquiries."
}
]
}

513
README.md
View File

@ -1,20 +1,501 @@
# Introduction
TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project.
# 🌍 Bitip - GeoIP Lookup Service
# Getting Started
TODO: Guide users through getting your code up and running on their own system. In this section you can talk about:
1. Installation process
2. Software dependencies
3. Latest releases
4. API references
Bitip is a professional GeoIP lookup service that provides IP geolocation data through a REST API and user-friendly web interface. Built with Express.js, TypeScript, React, and MaxMind GeoLite2 databases.
# Build and Test
TODO: Describe and show how to build your code and run the tests.
## ✨ Features
# Contribute
TODO: Explain how other users and developers can contribute to make your code better.
- **🔍 IP Geolocation Lookup**: Get detailed location information for any public IP address
- **🌐 Web Interface**: Clean, professional web UI with interactive maps
- **📡 REST API**: Comprehensive API for single and batch IP lookups
- **🗺️ Interactive Maps**: Static preview and interactive map popups using OpenStreetMap/Leaflet
- **🔐 API Key Authentication**: Secure access with configurable rate limiting
- **⚡ Performance**: In-memory caching and efficient database access
- **📊 Structured Logging**: Optional integration with Seq for advanced logging
- **🐳 Docker Ready**: Production deployment with Docker and geoipupdate integration
- **🔧 Configurable**: Runtime configuration via environment variables
If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files:
- [ASP.NET Core](https://github.com/aspnet/Home)
- [Visual Studio Code](https://github.com/Microsoft/vscode)
- [Chakra Core](https://github.com/Microsoft/ChakraCore)
## 🚀 Quick Start
### Local Development (No Docker Required)
#### Prerequisites
- Node.js >= 18.0.0
- npm >= 9.0.0
- MaxMind GeoLite2 databases (download from https://www.maxmind.com/en/geolite2/signup or use geoipupdate)
#### 1. Clone and Setup
```bash
git clone <repository-url>
cd Bitip
```
#### 2. Install Dependencies
```bash
npm run install:all
```
#### 3. Configure Environment
Create a `.env` file in the root directory:
```env
# API Keys (change these for production!)
FRONTEND_API_KEY=frontend-dev-key
EXTERNAL_API_KEYS=external-dev-key-1,external-dev-key-2
# Server Configuration
PORT=3000
BASE_PATH=/
NODE_ENV=development
# Database Path - Point to your MaxMind databases location
MAXMIND_DB_PATH=D:\tools\maxmind-dbs # Windows
# MAXMIND_DB_PATH=/usr/share/GeoIP # Linux/Mac
# Rate Limiting Configuration
FRONTEND_RATE_WINDOW_MS=60000
FRONTEND_RATE_MAX=30
EXTERNAL_RATE_WINDOW_MS=60000
EXTERNAL_RATE_MAX=1000
# Batch Configuration
BATCH_LIMIT=100
DEBOUNCE_MS=2000
# Seq Logging (Optional)
# SEQ_URL=http://localhost:5341
# SEQ_API_KEY=your-seq-api-key
```
#### 4. Start Development Servers
```bash
npm run dev
```
This will start:
- Backend API server on http://localhost:3000
- Frontend dev server on http://localhost:5173 (or next available port)
- Frontend proxies API requests to backend
#### 5. Access the Application
- **Web Interface**: http://localhost:5173 (or the port shown in console)
- **API Endpoint**: http://localhost:3000/api
- **Health Check**: http://localhost:3000/api/health
### Production Deployment (Docker)
#### Prerequisites
- Docker and Docker Compose
- MaxMind GeoLite2 account (free): https://www.maxmind.com/en/geolite2/signup
#### 1. Configure Environment
Edit `.env` file with your MaxMind credentials:
```env
GEOIPUPDATE_ACCOUNT_ID=your_account_id_here
GEOIPUPDATE_LICENSE_KEY=your_license_key_here
FRONTEND_API_KEY=your-secure-frontend-api-key-here
EXTERNAL_API_KEYS=your-secure-external-api-key-1,your-secure-external-api-key-2
```
#### 2. Start Services
```bash
docker-compose up -d
```
#### 3. Access the Service
- **Web Interface**: http://localhost:3000
- **API Endpoint**: http://localhost:3000/api
- **Health Check**: http://localhost:3000/api/health
## 📖 API Documentation
### Authentication
All API requests require an API key sent via header or query parameter:
```bash
# Via Header (recommended)
curl -H "X-API-Key: your-api-key" http://localhost:3000/api/lookup?ip=8.8.8.8
# Via Query Parameter
curl "http://localhost:3000/api/lookup?ip=8.8.8.8&apikey=your-api-key"
```
### Endpoints
#### Get Current IP
```http
GET /api/ip
```
Returns the client's current IP address.
**Response:**
```json
{
"ip": "203.0.113.42"
}
```
#### Simple IP Lookup
```http
GET /api/lookup?ip=8.8.8.8
```
Returns basic location information:
```json
{
"ip": "8.8.8.8",
"country": "United States",
"country_code": "US",
"region": "California",
"city": "Mountain View",
"latitude": 37.4056,
"longitude": -122.0775,
"timezone": "America/Los_Angeles",
"postal_code": "94043"
}
```
#### Detailed IP Lookup
```http
GET /api/lookup/detailed?ip=8.8.8.8
```
Returns comprehensive MaxMind database information including all available fields.
#### Batch IP Lookup
```http
POST /api/lookup/batch
Content-Type: application/json
{
"ips": ["8.8.8.8", "1.1.1.1", "208.67.222.222"]
}
```
**Response:**
```json
{
"results": [
{
"ip": "8.8.8.8",
"country": "United States",
...
},
{
"ip": "1.1.1.1",
"country": "Australia",
...
}
]
}
```
#### Health Check
```http
GET /api/health
```
Returns service health status including database connectivity.
### Error Handling
The API returns appropriate HTTP status codes:
- `400` - Bad Request (invalid IP, private IP, validation errors)
- `401` - Unauthorized (missing or invalid API key)
- `404` - Not Found (IP not in database)
- `429` - Too Many Requests (rate limit exceeded)
- `500` - Internal Server Error
- `503` - Service Unavailable (database maintenance)
## 🛠️ Development
### Project Structure
```
Bitip/
├── src/
│ ├── backend/ # Express.js API server
│ │ ├── routes/ # API route handlers
│ │ ├── services/ # Business logic (GeoIP, config, logger)
│ │ ├── middleware/ # Express middleware (auth, rate limiting)
│ │ ├── types/ # TypeScript type definitions
│ │ ├── index.ts # Main server entry point
│ │ ├── nodemon.json # Nodemon configuration
│ │ ├── tsconfig.json # TypeScript configuration
│ │ └── package.json # Backend dependencies
│ └── frontend/ # React web application
│ ├── src/
│ │ ├── components/ # React components (MapComponent)
│ │ ├── services/ # API client services
│ │ ├── types/ # TypeScript types
│ │ ├── App.tsx # Main App component
│ │ ├── App.css # Application styles
│ │ └── main.tsx # React entry point
│ ├── index.html # HTML template
│ ├── vite.config.ts # Vite configuration
│ ├── tsconfig.json # TypeScript configuration
│ └── package.json # Frontend dependencies
├── docs/ # Documentation (PRD, tech stack)
├── .env # Environment variables (not in git)
├── .env.example # Example environment file
├── Dockerfile # Production Docker image
├── docker-compose.yml # Production deployment
└── package.json # Root package configuration
```
### Available Scripts
```bash
# Development
npm run dev # Start both frontend and backend in dev mode
npm run dev:backend # Start backend development server only
npm run dev:frontend # Start frontend development server only
# Building
npm run build # Build both frontend and backend for production
npm run build:backend # Build backend only
npm run build:frontend # Build frontend only
# Production
npm start # Start production backend server (after build)
# Code Quality
npm run lint # Lint both frontend and backend
npm run lint:fix # Fix linting issues
npm run format # Format code with Prettier
# Utilities
npm run clean # Clean build artifacts
npm run install:all # Install all dependencies (root + backend + frontend)
```
### Technology Stack
**Backend:**
- Express.js 4.x with TypeScript
- @maxmind/geoip2-node 5.x - GeoIP database reader
- node-cache - In-memory caching
- express-rate-limit - Rate limiting
- helmet - Security headers
- joi - Request validation
- seq-logging - Optional structured logging
**Frontend:**
- React 18.x with TypeScript
- Vite 4.x - Build tool and dev server
- Leaflet & react-leaflet - Interactive maps
- Axios - HTTP client
- OpenStreetMap - Map tiles
**Development Tools:**
- TypeScript 5.x
- ESLint & Prettier
- nodemon - Auto-restart on changes
- ts-node - TypeScript execution
## ⚙️ Configuration
### Environment Variables
| Variable | Default | Description |
| ------------------------- | ---------------------- | -------------------------------------------- |
| `PORT` | `3000` | Server port |
| `BASE_PATH` | `/` | Application base path |
| `NODE_ENV` | `development` | Environment mode (development/production) |
| `MAXMIND_DB_PATH` | `/usr/share/GeoIP` | MaxMind database directory |
| `FRONTEND_API_KEY` | `frontend-default-key` | Frontend API key |
| `EXTERNAL_API_KEYS` | `external-default-key` | External API keys (comma-separated) |
| `FRONTEND_RATE_WINDOW_MS` | `60000` | Frontend rate limit window (milliseconds) |
| `FRONTEND_RATE_MAX` | `30` | Frontend rate limit max requests |
| `EXTERNAL_RATE_WINDOW_MS` | `60000` | External rate limit window (milliseconds) |
| `EXTERNAL_RATE_MAX` | `1000` | External rate limit max requests |
| `BATCH_LIMIT` | `100` | Maximum IPs per batch request |
| `DEBOUNCE_MS` | `2000` | Frontend input debounce delay (milliseconds) |
| `SEQ_URL` | - | Seq logging server URL (optional) |
| `SEQ_API_KEY` | - | Seq API key (optional) |
### MaxMind Database Setup
The service requires MaxMind GeoLite2-City database. You have two options:
**Option 1: Manual Download**
1. Sign up at https://www.maxmind.com/en/geolite2/signup
2. Download GeoLite2-City database (`.mmdb` file)
3. Place it in a directory (e.g., `D:\tools\maxmind-dbs` on Windows)
4. Set `MAXMIND_DB_PATH` environment variable to that directory
**Option 2: Using geoipupdate (Production)**
- Use the provided `docker-compose.yml` which includes geoipupdate service
- geoipupdate automatically downloads and updates databases
- Both services share a Docker volume for database access
### Rate Limiting
Bitip implements different rate limiting for frontend and external API consumers:
- **Frontend**: More restrictive limits for web UI usage (default: 100 req/min)
- **External**: Higher limits for programmatic API access (default: 1000 req/min)
Rate limits are fully configurable via environment variables and include standard HTTP headers in responses.
## 🐳 Docker Deployment
### Production Setup
The service is designed to run alongside MaxMind's geoipupdate service:
```bash
# Start both services
docker-compose up -d
# View logs
docker-compose logs -f bitip
# Stop services
docker-compose down
```
The `docker-compose.yml` includes:
- **geoipupdate**: Downloads and updates MaxMind databases
- **bitip**: Main application server (API + Web UI)
- **Shared volume**: For database access between services
### Health Monitoring
The service includes comprehensive health checks:
- Docker health check endpoint: `/api/health`
- Database connectivity verification
- Service status monitoring
## 🔧 Troubleshooting
### Common Issues
**1. Database Not Found**
- Ensure databases are downloaded to the path specified in `MAXMIND_DB_PATH`
- Check that `GeoLite2-City.mmdb` file exists in that directory
- Verify file permissions (read access required)
**2. TypeScript Errors**
- Run `npm run install:all` to ensure all dependencies are installed
- Check that Node.js version is >= 18.0.0
- Try deleting `node_modules` and reinstalling
**3. API Authentication Errors**
- Verify API key in `.env` file matches the one you're using
- Check `X-API-Key` header or `apikey` query parameter format
- Ensure API key doesn't contain extra spaces
**4. Rate Limiting**
- Check rate limit headers in API responses (`X-RateLimit-*`)
- Adjust rate limiting configuration if needed
- Use external API keys for higher limits
**5. Map Display Issues**
- Verify internet connectivity for OpenStreetMap tiles
- Check browser console for JavaScript errors
- Ensure Leaflet CSS is loading correctly
**6. Port Already in Use**
- Frontend dev server will auto-increment port (5173, 5174, etc.)
- Backend port can be changed via `PORT` environment variable
- Check for other services using the same ports
### Logs and Debugging
- Application logs are printed to console
- Use `npm run dev` for detailed development logs
- Configure Seq integration for structured logging
- Docker logs: `docker-compose logs -f bitip`
## 📦 Package Versions
**Current versions (as of project creation):**
- Major packages are on stable versions but not latest
- Consider updating for newer features and security fixes
- Check `npm outdated` for available updates
- Test thoroughly after major version updates
**Key outdated packages:**
- Express 4.x (5.x available but still beta)
- React 18.x (19.x available)
- Vite 4.x (7.x available - major update)
- Many dev dependencies have newer versions
## 📄 License
**Copyright © 2025 Tudor Stanciu. All rights reserved.**
This software is **proprietary** and confidential. Unauthorized use, copying, modification, distribution, or reverse engineering of this software, in whole or in part, is strictly prohibited without explicit written permission from the copyright holder.
### License Summary
- ✅ **Evaluation Period**: 30-day trial for evaluation purposes
- ✅ **Approved Use**: Requires explicit written approval from Tudor Stanciu
- ❌ **No Redistribution**: Cannot be distributed, sublicensed, or transferred to third parties
- ❌ **No Modification**: Cannot be modified or create derivative works without permission
- ❌ **No Commercial Use**: Cannot be used for commercial services without separate license
### Obtaining a License
For licensing inquiries, commercial use, or approval requests:
**Contact**: Tudor Stanciu
**Email**: tudor.stanciu94@gmail.com
Please include:
- Intended use case
- Organization name and size
- Expected deployment scale
- Duration of use
See [LICENSE](LICENSE) file for complete terms and conditions.
## 🙏 Acknowledgments
- **MaxMind** for providing GeoLite2 databases
- **OpenStreetMap** for map tiles
- **Leaflet** for interactive mapping
- All open-source contributors
---
**Bitip** - Professional GeoIP Lookup Service 🌍
Built with ❤️ using Express.js, React, and MaxMind GeoLite2

142
ReleaseNotes.json Normal file
View File

@ -0,0 +1,142 @@
{
"releases": [
{
"version": "1.0.0",
"date": "2025-10-01T12:00:00Z",
"title": "Initial Release - Bitip GeoIP Service",
"summary": "First production-ready release of Bitip, a modern GeoIP lookup service with REST API and interactive web interface.",
"sections": [
{
"title": "Overview",
"content": "Bitip is a high-performance GeoIP lookup service designed to provide accurate geolocation data for IP addresses. Built with modern web technologies, it offers both a RESTful API for programmatic access and an intuitive web interface for interactive lookups."
},
{
"title": "Core Features",
"items": [
"**Single IP Lookup** - Get geolocation data for individual IP addresses with detailed information including country, city, coordinates, timezone, and postal code",
"**Batch IP Lookup** - Process up to 100 IP addresses in a single request for efficient bulk operations",
"**Dual API Access** - Separate authentication for frontend and external API consumers with different rate limiting profiles",
"**Origin Validation** - Security layer that validates request origins for frontend API keys to prevent unauthorized access",
"**Rate Limiting** - Configurable request limits per API key type (100 req/min for frontend, 1000 req/min for external)",
"**Real-time Lookup** - Instant geolocation results powered by MaxMind GeoLite2 City database",
"**Interactive Web UI** - Modern, responsive interface for manual IP lookups with visual feedback",
"**RESTful API** - Clean, well-documented API endpoints for easy integration"
]
},
{
"title": "Technology Stack",
"subsections": [
{
"subtitle": "Backend",
"items": [
"**Node.js 18+** with ES Modules (ESM) for modern JavaScript features",
"**Express 5.x** - Fast, minimalist web framework with enhanced routing",
"**TypeScript 5.x** - Type-safe development with latest language features",
"**MaxMind GeoIP2 Node.js API** - Official MaxMind library for GeoLite2 database integration",
"**express-rate-limit 8.x** - Advanced rate limiting with IP tracking",
"**Helmet** - Security middleware for Express applications",
"**CORS** - Cross-Origin Resource Sharing support",
"**Joi** - Schema validation for API requests",
"**Seq Logging** (optional) - Structured logging for production monitoring",
"**node-cache** - In-memory caching layer"
]
},
{
"subtitle": "Frontend",
"items": [
"**React 19.x** - Modern UI library with latest features",
"**TypeScript** - Type-safe frontend development",
"**Vite 7.x** - Next-generation frontend tooling with lightning-fast HMR",
"**Axios** - Promise-based HTTP client",
"**React Leaflet** - Interactive maps for geolocation visualization",
"**ESLint & Prettier** - Code quality and formatting"
]
},
{
"subtitle": "Infrastructure",
"items": [
"**Docker** - Containerized deployment with multi-stage builds",
"**Node.js Alpine** - Lightweight production images",
"**Health Checks** - Container health monitoring",
"**Graceful Shutdown** - Clean process termination",
"**Non-root User** - Security-hardened container execution"
]
}
]
},
{
"title": "Security Features",
"items": [
"**API Key Authentication** - Two-tier authentication system (frontend + external)",
"**Origin Validation** - Validates Origin/Referer headers for frontend API key requests",
"**Rate Limiting** - Per-API-key request throttling with configurable windows",
"**CORS Protection** - Configurable cross-origin resource sharing",
"**Helmet Security Headers** - Standard HTTP security headers (CSP, HSTS, X-Frame-Options, etc.)",
"**Input Validation** - Schema-based request validation with Joi",
"**Error Sanitization** - Production mode hides sensitive error details"
]
},
{
"title": "API Endpoints",
"items": [
"`GET /api/health` - Health check endpoint for monitoring",
"`GET /api/ip` - Returns the client's public IP address",
"`GET /api/lookup?ip={ip}` - Single IP geolocation lookup (simplified response)",
"`GET /api/lookup/detailed?ip={ip}` - Detailed IP geolocation lookup (full MaxMind data)",
"`POST /api/lookup/batch` - Batch IP lookup (up to 100 IPs per request)"
]
},
{
"title": "Configuration",
"items": [
"**Environment-based Configuration** - All settings via `.env` file",
"**Flexible Port Configuration** - Configurable API port (default: 3000)",
"**Base Path Support** - Deploy under custom URL paths (e.g., `/geoip-ui`)",
"**Database Path Configuration** - Custom MaxMind database locations",
"**Rate Limit Tuning** - Separate limits for frontend and external consumers",
"**Batch Size Limits** - Configurable maximum batch request size",
"**Debounce Configuration** - Adjustable input debounce delays"
]
},
{
"title": "Development Journey",
"content": "Bitip was developed as a modern alternative to traditional GeoIP services, focusing on developer experience and deployment simplicity. The project was built from the ground up with TypeScript for type safety and maintainability. Special attention was given to security best practices, including origin validation and multi-tier API authentication. The ESM migration ensures compatibility with modern JavaScript ecosystems and future-proofs the codebase. All major dependencies were updated to their latest stable versions, with breaking changes carefully addressed and documented."
},
{
"title": "Architecture Highlights",
"items": [
"**Multi-stage Docker Build** - Separate build stages for frontend, backend, and production",
"**Graceful Shutdown** - Proper signal handling (SIGTERM, SIGINT, SIGUSR2) for zero-downtime deployments",
"**Structured Logging** - JSON-formatted logs with optional Seq integration",
"**Error Handling** - Global error handlers with environment-aware verbosity",
"**Middleware Pipeline** - Layered request processing (logging, auth, rate limiting, CORS)",
"**Type Safety** - End-to-end TypeScript for compile-time error detection"
]
},
{
"title": "Known Limitations",
"items": [
"Requires external MaxMind GeoLite2 City database (not included)",
"Frontend API key is visible in browser (mitigated by origin validation + aggressive rate limiting)",
"In-memory rate limiting (not suitable for multi-instance deployments without Redis)",
"No built-in database auto-update mechanism (requires external GeoIP update service)"
]
},
{
"title": "Documentation",
"items": [
"**README.md** - Quick start guide and overview",
"**CONFIGURATION.md** - Detailed configuration reference for all environment variables",
"**BREAKING-CHANGES-FIXED.md** - Documentation of major version upgrades and fixes",
"**PACKAGE-UPDATES.md** - Record of all dependency updates to latest versions",
"Inline code documentation and TypeScript interfaces"
]
},
{
"title": "License",
"content": "Bitip is proprietary software. Unauthorized use, distribution, or modification is strictly prohibited. Contact the author for licensing inquiries."
}
]
}
]
}

77
docker-compose.yml Normal file
View File

@ -0,0 +1,77 @@
version: '3.8'
services:
# GeoIP database updater
geoipupdate:
image: maxmindinc/geoipupdate:latest
restart: unless-stopped
environment:
- GEOIPUPDATE_ACCOUNT_ID=${GEOIPUPDATE_ACCOUNT_ID}
- GEOIPUPDATE_LICENSE_KEY=${GEOIPUPDATE_LICENSE_KEY}
- GEOIPUPDATE_EDITION_IDS=GeoLite2-City,GeoLite2-Country
- GEOIPUPDATE_FREQUENCY=168 # Update weekly (in hours)
volumes:
- geoip_data:/usr/share/GeoIP
networks:
- bitip-network
# Bitip GeoIP Service
bitip:
build: .
restart: unless-stopped
ports:
- '3000:3000'
environment:
# Server Configuration
- PORT=3000
- BASE_PATH=/
- NODE_ENV=production
# MaxMind Database Path
- MAXMIND_DB_PATH=/usr/share/GeoIP
# API Keys (Change these in production!)
- FRONTEND_API_KEY=your-frontend-api-key-here
- EXTERNAL_API_KEYS=your-external-api-key-1,your-external-api-key-2
# Rate Limiting
- FRONTEND_RATE_WINDOW_MS=60000
- FRONTEND_RATE_MAX=30
- EXTERNAL_RATE_WINDOW_MS=60000
- EXTERNAL_RATE_MAX=1000
# Batch Configuration
- BATCH_LIMIT=100
- DEBOUNCE_MS=2000
# Seq Logging (optional)
- SEQ_URL=${SEQ_URL}
- SEQ_API_KEY=${SEQ_API_KEY}
volumes:
- geoip_data:/usr/share/GeoIP:ro
depends_on:
- geoipupdate
networks:
- bitip-network
healthcheck:
test:
[
'CMD',
'wget',
'--no-verbose',
'--tries=1',
'--spider',
'http://localhost:3000/api/health',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
geoip_data:
driver: local
networks:
bitip-network:
driver: bridge

457
docs/CONFIGURATION.md Normal file
View File

@ -0,0 +1,457 @@
# Bitip GeoIP Service - Configuration Guide
This document provides detailed information about configuring the Bitip GeoIP Service using environment variables.
## Table of Contents
- [Backend Configuration](#backend-configuration)
- [Frontend Configuration](#frontend-configuration)
- [Development Setup](#development-setup)
- [Production Setup](#production-setup)
---
## Backend Configuration
The backend is configured via environment variables, typically stored in `.env` at the project root or `src/backend/.env`.
### Configuration File Locations
- **Development**: `d:\Git\Home\Bitip\.env` (project root)
- **Production**: Set via Docker environment variables or system environment
### Backend Environment Variables
#### API Keys
```env
FRONTEND_API_KEY=frontend-dev-key
EXTERNAL_API_KEYS=external-dev-key-1,external-dev-key-2
```
**`FRONTEND_API_KEY`**
- **Purpose**: API key used by the frontend application to authenticate with the backend
- **Format**: String (alphanumeric recommended)
- **Required**: Yes
- **Security**: Change this to a secure random value in production
- **Example**: `your-secure-frontend-api-key-here`
**`EXTERNAL_API_KEYS`**
- **Purpose**: Comma-separated list of API keys for external clients (non-frontend applications)
- **Format**: Comma-separated strings
- **Required**: Yes (can be empty string if no external access needed)
- **Security**: Use strong, randomly generated keys in production
- **Example**: `key1-abc123,key2-def456,key3-ghi789`
---
#### Frontend Origin Validation
```env
FRONTEND_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175
```
**`FRONTEND_ALLOWED_ORIGINS`**
- **Purpose**: Comma-separated list of allowed origins (domains) that can use the frontend API key
- **Format**: Comma-separated full URLs (including protocol and port)
- **Required**: Yes
- **Default**: `http://localhost:5173` (if not set)
- **Security**:
- Provides additional protection layer beyond rate limiting
- Validates `Origin` or `Referer` header for requests using frontend API key
- Only applies to frontend API key - external keys are not origin-restricted
- **Note**: Can be bypassed from Postman/curl, but blocks browser-based abuse from other domains
- **Development Example**: `http://localhost:5173,http://localhost:5174,http://localhost:5175`
- **Production Example**: `https://your-domain.com,https://www.your-domain.com`
- **Use Cases**:
- Prevent frontend API key theft and reuse from other websites
- Block cross-site abuse while still allowing legitimate frontend access
- Works in conjunction with CORS for defense-in-depth
- **Important Notes**:
- Must include protocol (`http://` or `https://`)
- Must include port if non-standard (e.g., `http://localhost:5173`)
- Requests without `Origin` or `Referer` header will be rejected
- Multiple origins supported for staging/production environments
---
#### Server Configuration
```env
PORT=5172
BASE_PATH=/geoip-ui
NODE_ENV=development
```
**`PORT`**
- **Purpose**: Port number on which the backend API server listens
- **Format**: Integer (1-65535)
- **Required**: Yes
- **Default**: 3000 (if not set)
- **Development**: 5172
- **Production**: Typically 3000 or 8080
- **Example**: `5172`
**`BASE_PATH`**
- **Purpose**: Base URL path for all API routes and static files
- **Format**: String starting with `/` (or just `/` for root)
- **Required**: Yes
- **Default**: `/`
- **Use Cases**:
- `/` - Application at root (e.g., `http://localhost:3000/api/health`)
- `/geoip-ui` - Application under subpath (e.g., `http://localhost:3000/geoip-ui/api/health`)
- Useful for reverse proxy scenarios or hosting multiple apps on same domain
- **Example**: `/geoip-ui`
- **Note**: All API routes are prefixed with this path automatically
**`NODE_ENV`**
- **Purpose**: Node.js environment mode
- **Format**: String
- **Required**: No (defaults to 'development')
- **Valid Values**:
- `development` - Enables verbose logging, detailed errors
- `production` - Optimized for performance, minimal logging
- **Example**: `production`
---
#### MaxMind Database Configuration
```env
MAXMIND_DB_PATH=D:\\tools\\maxmind-dbs
```
**`MAXMIND_DB_PATH`**
- **Purpose**: Absolute file system path to MaxMind GeoLite2 database files
- **Format**: Absolute path (OS-specific)
- **Required**: Yes
- **Windows Example**: `D:\\tools\\maxmind-dbs` (note double backslashes)
- **Linux Example**: `/usr/share/GeoIP`
- **Docker Example**: `/usr/share/GeoIP` (mounted volume)
- **Expected Files**:
- `GeoLite2-City.mmdb` - City-level geolocation database
- Provided by separate GeoIP update service or manual download
- **Note**: Bitip does NOT download databases - they must be provided externally
---
#### Rate Limiting Configuration
```env
FRONTEND_RATE_WINDOW_MS=60000
FRONTEND_RATE_MAX=30
EXTERNAL_RATE_WINDOW_MS=60000
EXTERNAL_RATE_MAX=1000
```
**`FRONTEND_RATE_WINDOW_MS`**
- **Purpose**: Time window (milliseconds) for frontend API key rate limiting
- **Format**: Integer (milliseconds)
- **Required**: Yes
- **Default**: 60000 (1 minute)
- **Example**: `60000` = 1 minute window
**`FRONTEND_RATE_MAX`**
- **Purpose**: Maximum number of requests allowed from frontend API key within the time window
- **Format**: Integer
- **Required**: Yes
- **Default**: 100
- **Example**: `100` = 100 requests per minute
**`EXTERNAL_RATE_WINDOW_MS`**
- **Purpose**: Time window (milliseconds) for external API keys rate limiting
- **Format**: Integer (milliseconds)
- **Required**: Yes
- **Default**: 60000 (1 minute)
- **Example**: `60000` = 1 minute window
**`EXTERNAL_RATE_MAX`**
- **Purpose**: Maximum number of requests allowed from external API keys within the time window
- **Format**: Integer
- **Required**: Yes
- **Default**: 1000
- **Example**: `1000` = 1000 requests per minute
---
#### Batch Configuration
```env
BATCH_LIMIT=100
DEBOUNCE_MS=2000
```
**`BATCH_LIMIT`**
- **Purpose**: Maximum number of IP addresses allowed in a single batch lookup request
- **Format**: Integer
- **Required**: Yes
- **Default**: 100
- **Example**: `100`
- **Use Case**: Prevents abuse and excessive memory usage
**`DEBOUNCE_MS`**
- **Purpose**: Debounce delay (milliseconds) for frontend input - delays API calls until user stops typing
- **Format**: Integer (milliseconds)
- **Required**: Yes
- **Default**: 2000 (2 seconds)
- **Example**: `2000` = 2 seconds
- **Note**: Also configured on frontend - backend value is for reference
---
#### Seq Logging Configuration (Optional)
```env
# SEQ_URL=http://localhost:5341
# SEQ_API_KEY=your-seq-api-key
```
**`SEQ_URL`**
- **Purpose**: URL of Seq structured logging server
- **Format**: HTTP/HTTPS URL
- **Required**: No (logging works without Seq)
- **Default**: Not set (Seq logging disabled)
- **Example**: `http://localhost:5341`
- **Use Case**: Centralized structured logging and monitoring
**`SEQ_API_KEY`**
- **Purpose**: API key for authenticating with Seq server
- **Format**: String
- **Required**: No (some Seq instances don't require authentication)
- **Default**: Not set
- **Example**: `your-seq-api-key`
---
## Frontend Configuration
The frontend is configured via environment variables, typically stored in `src/frontend/.env`.
### Configuration File Location
- **Development**: `src/frontend/.env`
- **Production**: Build-time environment variables or `.env.production`
### Frontend Environment Variables
```env
VITE_API_KEY=frontend-dev-key
VITE_API_URL=http://localhost:5172/geoip-ui/api
VITE_DEBOUNCE_MS=2000
```
**`VITE_API_KEY`**
- **Purpose**: API key to authenticate with the backend (must match `FRONTEND_API_KEY` in backend)
- **Format**: String
- **Required**: Yes
- **Example**: `frontend-dev-key`
- **Note**: Must match backend's `FRONTEND_API_KEY` exactly
**`VITE_API_URL`**
- **Purpose**: Full URL to the backend API (including base path and `/api` suffix)
- **Format**: HTTP/HTTPS URL
- **Required**: Yes
- **Development Example**: `http://localhost:5172/geoip-ui/api`
- **Production Example**: `https://your-domain.com/geoip-ui/api`
- **Note**: Must match backend's `PORT` and `BASE_PATH` configuration
**`VITE_DEBOUNCE_MS`**
- **Purpose**: Debounce delay (milliseconds) for IP input field - delays API calls until user stops typing
- **Format**: Integer (milliseconds)
- **Required**: Yes
- **Default**: 2000 (2 seconds)
- **Example**: `2000` = 2 seconds
- **Use Case**: Reduces API calls while user is still typing
---
## Development Setup
### Quick Start
1. **Copy `.env.example` to `.env`**:
```bash
cp .env.example .env
```
2. **Edit `.env` for development**:
```env
FRONTEND_API_KEY=frontend-dev-key
EXTERNAL_API_KEYS=external-dev-key-1,external-dev-key-2
PORT=5172
BASE_PATH=/geoip-ui
NODE_ENV=development
MAXMIND_DB_PATH=D:\\tools\\maxmind-dbs
FRONTEND_RATE_WINDOW_MS=60000
FRONTEND_RATE_MAX=30
EXTERNAL_RATE_WINDOW_MS=60000
EXTERNAL_RATE_MAX=1000
BATCH_LIMIT=100
DEBOUNCE_MS=2000
```
3. **Edit `src/frontend/.env`**:
```env
VITE_API_KEY=frontend-dev-key
VITE_API_URL=http://localhost:5172/geoip-ui/api
VITE_DEBOUNCE_MS=2000
```
4. **Ensure MaxMind databases are available**:
- Databases should be provided by separate GeoIP update service
- Place `GeoLite2-City.mmdb` in the path specified by `MAXMIND_DB_PATH`
5. **Start development servers**:
```bash
npm run dev
```
### Development URLs
- **Frontend**: http://localhost:5173/
- **Backend API**: http://localhost:5172/geoip-ui/api/
- **Health Check**: http://localhost:5172/geoip-ui/api/health
---
## Production Setup
### Environment Variables
**Backend** (via Docker or system environment):
```env
FRONTEND_API_KEY=<secure-random-key>
EXTERNAL_API_KEYS=<key1>,<key2>,<key3>
FRONTEND_ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com
PORT=3000
BASE_PATH=/
NODE_ENV=production
MAXMIND_DB_PATH=/usr/share/GeoIP
FRONTEND_RATE_WINDOW_MS=60000
FRONTEND_RATE_MAX=30
EXTERNAL_RATE_WINDOW_MS=60000
EXTERNAL_RATE_MAX=1000
BATCH_LIMIT=100
DEBOUNCE_MS=2000
```
**Frontend** (build-time `.env.production`):
```env
VITE_API_KEY=<same-as-backend-FRONTEND_API_KEY>
VITE_API_URL=https://your-domain.com/api
VITE_DEBOUNCE_MS=2000
```
### Security Recommendations
1. **Generate Strong API Keys**:
```bash
# Linux/Mac
openssl rand -hex 32
# PowerShell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
2. **Never Commit Real Keys**:
- Keep `.env` in `.gitignore`
- Use `.env.example` as template only
- Use secret management in production (Docker secrets, Kubernetes secrets, etc.)
3. **Configure Allowed Origins**:
- Set `FRONTEND_ALLOWED_ORIGINS` to your actual production domain(s)
- Include all variants (www, non-www, subdomains)
- Must use HTTPS in production: `https://your-domain.com`
- This provides defense-in-depth against frontend API key theft
4. **Use HTTPS in Production**:
- Set `VITE_API_URL` to HTTPS endpoint
- Configure reverse proxy (nginx, Traefik, etc.) for TLS termination
5. **Adjust Rate Limits**:
- Based on expected traffic patterns
- Monitor and adjust as needed
- Consider more aggressive limits for frontend key (e.g., 30-50 req/min)
6. **Understanding Security Model**:
- **Frontend API Key**: Semi-public, visible in browser
- Protected by: Origin validation + Rate limiting + CORS
- Can still be extracted and used from Postman/curl
- Origin validation blocks browser-based cross-site abuse
- **External API Keys**: Fully secret, never exposed to browser
- Protected by: Rate limiting only
- For trusted backend integrations
- **Best Practice**: Use aggressive rate limiting on frontend key to make abuse impractical
---
## Troubleshooting
### Backend Won't Start
- **Check `MAXMIND_DB_PATH`**: Ensure path exists and contains `GeoLite2-City.mmdb`
- **Check Port Conflicts**: Ensure `PORT` is not already in use
- **Check Environment Loading**: Verify `.env` file is at project root
### Frontend Can't Connect to Backend
- **Check `VITE_API_URL`**: Must match backend `PORT` and `BASE_PATH`
- **Check CORS**: Backend allows frontend origin by default
- **Check API Key**: `VITE_API_KEY` must match backend `FRONTEND_API_KEY`
### Authentication Errors
- **401 Unauthorized**: API key mismatch between frontend and backend
- **Verify Keys Match**: Frontend `VITE_API_KEY` === Backend `FRONTEND_API_KEY`
### Origin Validation Errors
- **403 Forbidden - "Origin header required"**: Request missing `Origin` or `Referer` header
- **Cause**: Typically happens when testing from Postman/curl with frontend API key
- **Solution**: Use external API key for non-browser clients, or add `Origin` header manually in testing tools
- **403 Forbidden - "Invalid origin"**: Frontend origin not in allowed list
- **Check `FRONTEND_ALLOWED_ORIGINS`**: Ensure your frontend domain is listed
- **Protocol Mismatch**: Ensure protocol matches (`http://` vs `https://`)
- **Port Mismatch**: Include port if non-standard (e.g., `http://localhost:5173`)
- **Development**: Add all Vite dev server ports (5173, 5174, 5175, etc.)
- **Production**: Add all domain variants (www, non-www, subdomains)
### Rate Limiting Issues
- **429 Too Many Requests**: Exceeded rate limits
- **Increase Limits**: Adjust `*_RATE_MAX` values in `.env`
- **Check Time Window**: Adjust `*_RATE_WINDOW_MS` values
---
## Additional Resources
- **Main README**: `/README.md`
- **Breaking Changes**: `/docs/BREAKING-CHANGES-FIXED.md`
- **Package Updates**: `/docs/PACKAGE-UPDATES.md`
- **API Documentation**: See inline code comments in `/src/backend/routes/api.ts`

View File

@ -142,19 +142,22 @@ The primary goal is to create a lightweight, containerized service that can serv
8. **Memory Usage** - Stable memory consumption under normal load
9. **Rate Limiting Effectiveness** - Protection against abuse while serving legitimate requests
## Open Questions
## Implementation Decisions
1. **Map Provider Selection** - Which specific free map provider should be implemented? (OpenStreetMap via Leaflet.js recommended)
2. **API Key Management** - Should there be an admin interface for managing API keys, or will they remain environment variable-based?
3. **Caching Strategy** - Should we implement response caching for performance optimization?
4. **Database Update Handling** - How should the service handle MaxMind database updates from the geoipupdate service?
5. **Monitoring Integration** - Beyond Seq logging, are there other monitoring tools to integrate with?
6. **IPv6 Support** - Should the service fully support IPv6 addresses from the start?
7. **API Versioning** - Should we implement API versioning for future compatibility?
Based on requirements clarification, the following decisions have been made:
1. **Map Provider**: OpenStreetMap via Leaflet.js will be used for both static preview and interactive maps
2. **API Key Management**: Environment variable-based configuration (admin interface may be added in future versions)
3. **Caching Strategy**: Simple in-memory caching will be implemented if straightforward, otherwise omitted for initial version
4. **Database Updates**: Service will return "Under maintenance, try again later" error during MaxMind database updates
5. **Monitoring**: Seq logging only for initial version
6. **IPv6 Support**: Will be included if MaxMind supports it and implementation is straightforward
7. **API Versioning**: Not implemented in initial version
---
**Document Version**: 1.0
**Document Version**: 1.1
**Created**: July 24, 2025
**Updated**: July 24, 2025
**Target Audience**: Junior Developer
**Implementation Priority**: High

7156
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "bitip",
"version": "1.0.0",
"description": "Bitip - GeoIP Lookup Service with REST API and Web Interface",
"main": "dist/backend/index.js",
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "cd src/backend && npm run dev",
"dev:frontend": "cd src/frontend && npm run dev",
"build": "npm run build:backend && npm run build:frontend",
"build:backend": "cd src/backend && npm run build",
"build:frontend": "cd src/frontend && npm run build",
"start": "cd src/backend && npm start",
"lint": "npm run lint:backend && npm run lint:frontend",
"lint:backend": "cd src/backend && npm run lint",
"lint:frontend": "cd src/frontend && npm run lint",
"lint:fix": "npm run lint:fix:backend && npm run lint:fix:frontend",
"lint:fix:backend": "cd src/backend && npm run lint:fix",
"lint:fix:frontend": "cd src/frontend && npm run lint:fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"install:all": "npm install && cd src/backend && npm install && cd ../frontend && npm install",
"clean": "rimraf dist && cd src/backend && npm run clean && cd ../frontend && npm run clean"
},
"keywords": [
"geoip",
"ip-lookup",
"geolocation",
"maxmind",
"express",
"react",
"typescript"
],
"author": "Bitip Team",
"license": "MIT",
"devDependencies": {
"concurrently": "^9.2.1",
"prettier": "^3.4.2",
"rimraf": "^6.0.1"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"workspaces": [
"src/backend",
"src/frontend"
],
"dependencies": {
"dotenv": "^17.2.3"
}
}

View File

@ -0,0 +1,50 @@
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import parser from '@typescript-eslint/parser';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
export default [
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: parser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
globals: {
console: 'readonly',
process: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
Buffer: 'readonly',
module: 'readonly',
require: 'readonly',
exports: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescript,
prettier: prettier,
},
rules: {
...typescript.configs.recommended.rules,
...prettierConfig.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
ignores: ['dist/**', 'node_modules/**', '**/*.js'],
},
];

155
src/backend/index.ts Normal file
View File

@ -0,0 +1,155 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { setTimeout } from 'timers';
import apiRoutes from './routes/api.js';
import apiKeyAuth from './middleware/auth.js';
import dynamicRateLimit from './middleware/rateLimit.js';
import config from './services/config.js';
import logger from './services/logger.js';
import { generateRuntimeConfig } from './services/runtimeConfig.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
// Security middleware
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
})
);
app.use(
cors({
origin: process.env.NODE_ENV === 'production' ? false : true,
credentials: true,
})
);
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request logging
app.use((req, res, next) => {
logger.debug('Incoming request', {
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
next();
});
// Apply base path if configured
const basePath = config.basePath.endsWith('/')
? config.basePath.slice(0, -1)
: config.basePath;
// Serve static frontend files
const frontendPath = path.join(__dirname, '../frontend');
// Generate runtime configuration (env.js) at startup
generateRuntimeConfig(frontendPath, basePath);
// API routes with authentication and rate limiting
app.use(`${basePath}/api`, apiKeyAuth, dynamicRateLimit, apiRoutes);
// Serve static frontend files
app.use(basePath, express.static(frontendPath));
// Fallback to index.html for client-side routing (Express 5 syntax)
// Named wildcard required in path-to-regexp v8: /*path matches any path
app.get(`${basePath}/*path`, (_req, res): void => {
res.sendFile(path.join(frontendPath, 'index.html'));
});
// Global error handler
app.use(
(
err: Error,
req: express.Request,
res: express.Response,
_next: express.NextFunction
) => {
logger.error('Unhandled error', err, {
method: req.method,
path: req.path,
ip: req.ip,
});
res.status(500).json({
error: 'Internal Server Error',
message:
process.env.NODE_ENV === 'development'
? err.message
: 'Something went wrong',
});
}
);
// Start server
const server = app.listen(config.port, () => {
logger.info('Bitip GeoIP Service started', {
port: config.port,
basePath: config.basePath,
environment: process.env.NODE_ENV || 'development',
dbPath: config.maxmindDbPath,
});
});
// Graceful shutdown
const gracefulShutdown = async (signal: string): Promise<void> => {
logger.info(`Received ${signal}, shutting down gracefully`);
server.close(async err => {
if (err) {
logger.error('Error closing server', err);
process.exit(1);
}
try {
await logger.flush();
process.exit(0);
} catch (error) {
logger.error('Error during graceful shutdown', error as Error);
process.exit(1);
}
});
// Force close after 10 seconds
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGUSR2', () => gracefulShutdown('SIGUSR2')); // nodemon uses SIGUSR2
// Handle server errors
server.on('error', (error: Error) => {
logger.error('Server error', error);
process.exit(1);
});
export default app;

View File

@ -0,0 +1,87 @@
import { Request, Response, NextFunction } from 'express';
import config from '../services/config.js';
import logger from '../services/logger.js';
interface AuthenticatedRequest extends Request {
apiKeyType?: 'frontend' | 'external';
}
export const apiKeyAuth = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
const apiKey = req.header('X-API-Key') || (req.query.apikey as string);
if (!apiKey) {
logger.warn('API request without API key', { ip: req.ip, path: req.path });
res.status(401).json({
error: 'Unauthorized',
message: 'API key is required',
});
return;
}
// Check frontend API key
if (apiKey === config.apiKeys.frontend) {
// Validate origin for frontend API key
const origin = req.headers.origin || req.headers.referer;
const allowedOrigins = config.frontendAllowedOrigins;
// Check if origin is present and matches allowed origins
if (!origin) {
logger.warn('Frontend API key used without origin/referer header', {
ip: req.ip,
path: req.path,
userAgent: req.headers['user-agent'],
});
res.status(403).json({
error: 'Forbidden',
message: 'Origin header required for frontend API key',
});
return;
}
const isOriginAllowed = allowedOrigins.some(allowed =>
origin.startsWith(allowed)
);
if (!isOriginAllowed) {
logger.warn('Frontend API key used from invalid origin', {
ip: req.ip,
origin: origin,
path: req.path,
allowedOrigins: allowedOrigins,
});
res.status(403).json({
error: 'Forbidden',
message: 'Invalid origin for frontend API key',
});
return;
}
req.apiKeyType = 'frontend';
next();
return;
}
// Check external API keys
if (config.apiKeys.external.includes(apiKey)) {
req.apiKeyType = 'external';
next();
return;
}
logger.warn('API request with invalid API key', {
ip: req.ip,
path: req.path,
apiKey: apiKey.substring(0, 8) + '...',
});
res.status(401).json({
error: 'Unauthorized',
message: 'Invalid API key',
});
};
export default apiKeyAuth;

View File

@ -0,0 +1,73 @@
import rateLimit from 'express-rate-limit';
import { Request, Response, NextFunction } from 'express';
import config from '../services/config.js';
import logger from '../services/logger.js';
interface AuthenticatedRequest extends Request {
apiKeyType?: 'frontend' | 'external';
}
const createRateLimiter = (
windowMs: number,
max: number,
keyType: 'frontend' | 'external'
) => {
return rateLimit({
windowMs,
max,
message: {
error: 'Too Many Requests',
message: `Rate limit exceeded for ${keyType} API. Try again later.`,
},
standardHeaders: true,
legacyHeaders: false,
// Simple keyGenerator - accepts IPv6 limitation for simplicity
keyGenerator: (req: Request) => {
const authReq = req as AuthenticatedRequest;
return `${authReq.apiKeyType || 'unknown'}_${req.ip}`;
},
// Disable strict validation to avoid IPv6 warnings
validate: false,
handler: (req: Request, res: Response) => {
const authReq = req as AuthenticatedRequest;
logger.warn('Rate limit exceeded', {
ip: req.ip,
apiKeyType: authReq.apiKeyType,
path: req.path,
});
res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded for ${keyType} API. Try again later.`,
});
},
});
};
export const frontendRateLimit = createRateLimiter(
config.rateLimits.frontend.windowMs,
config.rateLimits.frontend.max,
'frontend'
);
export const externalRateLimit = createRateLimiter(
config.rateLimits.external.windowMs,
config.rateLimits.external.max,
'external'
);
export const dynamicRateLimit = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
if (req.apiKeyType === 'frontend') {
frontendRateLimit(req, res, next);
} else if (req.apiKeyType === 'external') {
externalRateLimit(req, res, next);
} else {
// Default to stricter frontend limits for unknown keys
frontendRateLimit(req, res, next);
}
};
export default dynamicRateLimit;

11
src/backend/nodemon.json Normal file
View File

@ -0,0 +1,11 @@
{
"watch": ["."],
"ext": "ts,json",
"ignore": ["**/*.spec.ts", "**/*.test.ts", "node_modules", "dist"],
"exec": "node --import ./register.js index.ts",
"delay": 1000,
"env": {
"NODE_ENV": "development",
"NODE_OPTIONS": "--experimental-specifier-resolution=node"
}
}

42
src/backend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "bitip-backend",
"version": "1.0.0",
"description": "Bitip Backend - GeoIP REST API Service",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon",
"build": "rimraf dist && tsc",
"start": "node dist/index.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf dist"
},
"dependencies": {
"@maxmind/geoip2-node": "^6.1.0",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"helmet": "^8.1.0",
"cors": "^2.8.5",
"compression": "^1.7.4",
"joi": "^18.0.1",
"seq-logging": "^3.0.0",
"node-cache": "^5.1.2"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^22.14.1",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"nodemon": "^3.1.10",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}

4
src/backend/register.js Normal file
View File

@ -0,0 +1,4 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('ts-node/esm', pathToFileURL('./'));

338
src/backend/routes/api.ts Normal file
View File

@ -0,0 +1,338 @@
import { Router, Request, Response } from 'express';
import Joi from 'joi';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import geoIPService from '../services/geoip.js';
import logger from '../services/logger.js';
import config from '../services/config.js';
import {
BatchGeoIPRequest,
BatchGeoIPResponse,
ErrorResponse,
} from '../types/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = Router();
// Validation schemas
const ipSchema = Joi.string()
.ip({ version: ['ipv4', 'ipv6'] })
.required();
const batchSchema = Joi.object({
ips: Joi.array()
.items(Joi.string().ip({ version: ['ipv4', 'ipv6'] }))
.min(1)
.max(config.batchLimit)
.required(),
});
// Helper function to get client IP
const getClientIP = (req: Request): string => {
const forwarded = req.headers['x-forwarded-for'] as string;
const realIP = req.headers['x-real-ip'] as string;
if (forwarded) {
return (
forwarded.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown'
);
}
if (realIP) {
return realIP;
}
return req.socket.remoteAddress || 'unknown';
};
// Get current client IP
router.get('/ip', (req: Request, res: Response) => {
try {
const clientIP = getClientIP(req);
logger.debug('Client IP requested', {
ip: clientIP,
userAgent: req.headers['user-agent'],
});
res.json({ ip: clientIP });
} catch (error) {
logger.error('Failed to get client IP', error as Error);
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to determine client IP',
} as ErrorResponse);
}
});
// Single IP lookup (simple)
router.get('/lookup', async (req: Request, res: Response) => {
try {
const ip = req.query.ip as string;
if (!ip) {
res.status(400).json({
error: 'Bad Request',
message: 'IP address is required',
} as ErrorResponse);
return;
}
const { error } = ipSchema.validate(ip);
if (error) {
res.status(400).json({
error: 'Bad Request',
message: 'Invalid IP address format',
ip,
} as ErrorResponse);
return;
}
if (!geoIPService.isValidIP(ip)) {
res.status(400).json({
error: 'Bad Request',
message: 'Invalid IP address',
ip,
} as ErrorResponse);
return;
}
if (geoIPService.isPrivateIP(ip)) {
res.status(400).json({
error: 'Bad Request',
message: 'Private IP addresses are not supported',
ip,
} as ErrorResponse);
return;
}
const result = await geoIPService.lookupSimple(ip);
logger.info('Simple IP lookup completed', { ip, country: result.country });
res.json(result);
} catch (error) {
const err = error as Error;
if (err.message.includes('not found')) {
res.status(404).json({
error: 'Not Found',
message: 'IP address not found in database',
ip: req.query.ip as string,
} as ErrorResponse);
} else if (err.message.includes('maintenance')) {
res.status(503).json({
error: 'Service Unavailable',
message: 'Under maintenance, try again later',
ip: req.query.ip as string,
} as ErrorResponse);
} else {
logger.error('Simple IP lookup failed', err, { ip: req.query.ip });
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to lookup IP address',
ip: req.query.ip as string,
} as ErrorResponse);
}
}
});
// Single IP lookup (detailed)
router.get('/lookup/detailed', async (req: Request, res: Response) => {
try {
const ip = req.query.ip as string;
if (!ip) {
res.status(400).json({
error: 'Bad Request',
message: 'IP address is required',
} as ErrorResponse);
return;
}
const { error } = ipSchema.validate(ip);
if (error) {
res.status(400).json({
error: 'Bad Request',
message: 'Invalid IP address format',
ip,
} as ErrorResponse);
return;
}
if (!geoIPService.isValidIP(ip)) {
res.status(400).json({
error: 'Bad Request',
message: 'Invalid IP address',
ip,
} as ErrorResponse);
return;
}
if (geoIPService.isPrivateIP(ip)) {
res.status(400).json({
error: 'Bad Request',
message: 'Private IP addresses are not supported',
ip,
} as ErrorResponse);
return;
}
const result = await geoIPService.lookupDetailed(ip);
logger.info('Detailed IP lookup completed', { ip });
res.json(result);
} catch (error) {
const err = error as Error;
if (err.message.includes('not found')) {
res.status(404).json({
error: 'Not Found',
message: 'IP address not found in database',
ip: req.query.ip as string,
} as ErrorResponse);
} else if (err.message.includes('maintenance')) {
res.status(503).json({
error: 'Service Unavailable',
message: 'Under maintenance, try again later',
ip: req.query.ip as string,
} as ErrorResponse);
} else {
logger.error('Detailed IP lookup failed', err, { ip: req.query.ip });
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to lookup IP address',
ip: req.query.ip as string,
} as ErrorResponse);
}
}
});
// Batch IP lookup
router.post('/lookup/batch', async (req: Request, res: Response) => {
try {
const { error, value } = batchSchema.validate(req.body);
if (error) {
res.status(400).json({
error: 'Bad Request',
message: error.details[0]?.message || 'Invalid request body',
} as ErrorResponse);
return;
}
const { ips } = value as BatchGeoIPRequest;
// Filter out private IPs
const validIPs = ips.filter(ip => !geoIPService.isPrivateIP(ip));
const privateIPs = ips.filter(ip => geoIPService.isPrivateIP(ip));
const results = await geoIPService.lookupBatch(validIPs);
// Add errors for private IPs
const privateIPErrors = privateIPs.map(ip => ({
ip,
error: 'Private IP addresses are not supported',
}));
const response: BatchGeoIPResponse = {
results: [...results, ...privateIPErrors],
};
logger.info('Batch IP lookup completed', {
totalIPs: ips.length,
validIPs: validIPs.length,
privateIPs: privateIPs.length,
});
res.json(response);
} catch (error) {
logger.error('Batch IP lookup failed', error as Error, { body: req.body });
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to process batch lookup',
} as ErrorResponse);
}
});
// Health check
router.get('/health', async (req: Request, res: Response) => {
try {
const isHealthy = await geoIPService.healthCheck();
if (isHealthy) {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
service: 'Bitip GeoIP Service',
});
} else {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'Bitip GeoIP Service',
error: 'GeoIP service health check failed',
});
}
} catch (error) {
logger.error('Health check failed', error as Error);
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'Bitip GeoIP Service',
error: 'Health check endpoint failed',
});
}
});
// Get app version
router.get('/version', (_req: Request, res: Response): void => {
try {
res.json({
version: process.env.APP_VERSION || '1.0.0',
createdAt: process.env.CREATED_AT || 'unknown',
gitRevision: process.env.GIT_REVISION || 'unknown',
service: 'Bitip GeoIP Service',
});
} catch (error) {
logger.error('Version endpoint failed', error as Error);
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to retrieve version information',
});
}
});
// Get release notes
router.get('/release-notes', (_req: Request, res: Response): void => {
try {
const releaseNotesPath = join(__dirname, '../../../ReleaseNotes.json');
const releaseNotesContent = readFileSync(releaseNotesPath, 'utf-8');
const releaseNotes = JSON.parse(releaseNotesContent);
logger.debug('Release notes retrieved successfully');
res.json(releaseNotes);
} catch (error) {
logger.error('Failed to load release notes', error as Error);
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to load release notes',
});
}
});
// Get overview
router.get('/overview', (_req: Request, res: Response): void => {
try {
const overviewPath = join(__dirname, '../../../Overview.json');
const overviewContent = readFileSync(overviewPath, 'utf-8');
const overview = JSON.parse(overviewContent);
logger.debug('Overview retrieved successfully');
res.json(overview);
} catch (error) {
logger.error('Failed to load overview', error as Error);
res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to load overview',
});
}
});
export default router;

View File

@ -0,0 +1,44 @@
import { Config } from '../types/index.js';
import path from 'path';
import { config as dotenvConfig } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load .env file from root directory
dotenvConfig({ path: path.join(__dirname, '../../../.env') });
export const config: Config = {
port: parseInt(process.env.PORT || '3000', 10),
basePath: process.env.BASE_PATH || '/',
maxmindDbPath: process.env.MAXMIND_DB_PATH || '/usr/share/GeoIP',
seqUrl: process.env.SEQ_URL,
seqApiKey: process.env.SEQ_API_KEY,
apiKeys: {
frontend: process.env.FRONTEND_API_KEY || 'frontend-default-key',
external: (process.env.EXTERNAL_API_KEYS || 'external-default-key')
.split(',')
.map(key => key.trim()),
},
frontendAllowedOrigins: (
process.env.FRONTEND_ALLOWED_ORIGINS || 'http://localhost:5173'
)
.split(',')
.map(origin => origin.trim()),
rateLimits: {
frontend: {
windowMs: parseInt(process.env.FRONTEND_RATE_WINDOW_MS || '60000', 10),
max: parseInt(process.env.FRONTEND_RATE_MAX || '30', 10),
},
external: {
windowMs: parseInt(process.env.EXTERNAL_RATE_WINDOW_MS || '60000', 10),
max: parseInt(process.env.EXTERNAL_RATE_MAX || '1000', 10),
},
},
batchLimit: parseInt(process.env.BATCH_LIMIT || '100', 10),
debounceMs: parseInt(process.env.DEBOUNCE_MS || '2000', 10),
};
export default config;

View File

@ -0,0 +1,165 @@
import { Reader, ReaderModel, City } from '@maxmind/geoip2-node';
import path from 'path';
import fs from 'fs';
import NodeCache from 'node-cache';
import {
GeoIPLocation,
SimplifiedGeoIPResponse,
DetailedGeoIPResponse,
} from '../types/index.js';
import config from './config.js';
import logger from './logger.js';
class GeoIPService {
private cityReader?: ReaderModel;
private cache: NodeCache;
private dbPath: string;
private isInitialized: boolean = false;
constructor() {
this.cache = new NodeCache({ stdTTL: 300, checkperiod: 60 }); // 5 minutes cache
this.dbPath = config.maxmindDbPath;
this.initializeReader();
}
private async initializeReader(): Promise<void> {
try {
const cityDbPath = path.join(this.dbPath, 'GeoLite2-City.mmdb');
if (!fs.existsSync(cityDbPath)) {
throw new Error(`GeoIP database not found at ${cityDbPath}`);
}
this.cityReader = await Reader.open(cityDbPath);
this.isInitialized = true;
logger.info('GeoIP database initialized successfully', {
dbPath: cityDbPath,
});
} catch (error) {
logger.error('Failed to initialize GeoIP database', error as Error, {
dbPath: this.dbPath,
});
throw error;
}
}
private ensureInitialized(): void {
if (!this.isInitialized || !this.cityReader) {
throw new Error('GeoIP service not initialized');
}
}
async lookupSimple(ip: string): Promise<SimplifiedGeoIPResponse> {
this.ensureInitialized();
const cacheKey = `simple_${ip}`;
const cached = this.cache.get<SimplifiedGeoIPResponse>(cacheKey);
if (cached) {
return cached;
}
try {
const response: City = this.cityReader!.city(ip);
const result: SimplifiedGeoIPResponse = {
ip,
country: response.country?.names?.en || 'Unknown',
country_code: response.country?.isoCode || 'XX',
region: response.subdivisions?.[0]?.names?.en || 'Unknown',
city: response.city?.names?.en || 'Unknown',
latitude: response.location?.latitude || null,
longitude: response.location?.longitude || null,
timezone: response.location?.timeZone || null,
postal_code: response.postal?.code || null,
};
this.cache.set(cacheKey, result);
logger.debug('GeoIP lookup completed', { ip, result });
return result;
} catch (error) {
logger.error('GeoIP lookup failed', error as Error, { ip });
throw error;
}
}
async lookupDetailed(ip: string): Promise<DetailedGeoIPResponse> {
this.ensureInitialized();
const cacheKey = `detailed_${ip}`;
const cached = this.cache.get<DetailedGeoIPResponse>(cacheKey);
if (cached) {
return cached;
}
try {
const response: City = this.cityReader!.city(ip);
const result: DetailedGeoIPResponse = {
ip,
location: response as any as GeoIPLocation,
};
this.cache.set(cacheKey, result);
logger.debug('Detailed GeoIP lookup completed', { ip });
return result;
} catch (error) {
logger.error('Detailed GeoIP lookup failed', error as Error, { ip });
throw error;
}
}
async lookupBatch(
ips: string[]
): Promise<Array<SimplifiedGeoIPResponse | { ip: string; error: string }>> {
const results = await Promise.allSettled(
ips.map(async ip => this.lookupSimple(ip))
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
return {
ip: ips[index] || 'unknown',
error: result.reason?.message || 'Lookup failed',
};
}
});
}
isValidIP(ip: string): boolean {
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
}
isPrivateIP(ip: string): boolean {
const privateRanges = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^127\./,
/^169\.254\./,
/^::1$/,
/^fc00:/,
/^fe80:/,
];
return privateRanges.some(range => range.test(ip));
}
async healthCheck(): Promise<boolean> {
try {
await this.lookupSimple('8.8.8.8'); // Test with Google DNS
return true;
} catch (error) {
logger.error('GeoIP service health check failed', error as Error);
return false;
}
}
}
export const geoIPService = new GeoIPService();
export default geoIPService;

View File

@ -0,0 +1,86 @@
import { Logger as SeqLogger, SeqLoggerConfig } from 'seq-logging';
import config from './config.js';
class Logger {
private seqLogger?: SeqLogger;
constructor() {
if (config.seqUrl) {
const seqConfig: SeqLoggerConfig = {
serverUrl: config.seqUrl,
apiKey: config.seqApiKey,
onError: (error: Error) => {
console.error('Seq logging error:', error);
},
};
this.seqLogger = new SeqLogger(seqConfig);
}
}
info(message: string, properties?: any): void {
console.log(`[INFO] ${message}`, properties || '');
if (this.seqLogger) {
this.seqLogger.emit({
timestamp: new Date(),
level: 'Information',
messageTemplate: message,
properties: properties || {},
});
}
}
warn(message: string, properties?: any): void {
console.warn(`[WARN] ${message}`, properties || '');
if (this.seqLogger) {
this.seqLogger.emit({
timestamp: new Date(),
level: 'Warning',
messageTemplate: message,
properties: properties || {},
});
}
}
error(message: string, error?: Error, properties?: any): void {
console.error(`[ERROR] ${message}`, error || '', properties || '');
if (this.seqLogger) {
this.seqLogger.emit({
timestamp: new Date(),
level: 'Error',
messageTemplate: message,
properties: {
error: error?.message,
stack: error?.stack,
...properties,
},
});
}
}
debug(message: string, properties?: any): void {
console.debug(`[DEBUG] ${message}`, properties || '');
if (this.seqLogger) {
this.seqLogger.emit({
timestamp: new Date(),
level: 'Debug',
messageTemplate: message,
properties: properties || {},
});
}
}
async flush(): Promise<void> {
if (this.seqLogger) {
await this.seqLogger.flush();
}
}
async close(): Promise<void> {
if (this.seqLogger) {
await this.seqLogger.close();
}
}
}
export const logger = new Logger();
export default logger;

View File

@ -0,0 +1,58 @@
import { writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import logger from './logger.js';
/**
* Generates the runtime configuration file (env.js) for the frontend
* This allows the frontend to receive runtime configuration without rebuilding
*/
export function generateRuntimeConfig(
frontendPath: string,
basePath: string
): void {
try {
// In dev mode, write to public/env.js (source)
// In production, write to env.js (dist)
const isDev = process.env.NODE_ENV !== 'production';
const envJsPath = isDev
? join(frontendPath, 'public', 'env.js')
: join(frontendPath, 'env.js');
// Check if frontend directory exists
if (!existsSync(frontendPath)) {
logger.warn(
'Frontend directory not found, skipping runtime config generation',
{
frontendPath,
}
);
return;
}
// Generate env.js content
const envConfig = {
basePath: basePath || '/',
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const envJsContent = `// Runtime configuration generated at container startup
// DO NOT EDIT - This file is automatically generated
// eslint-disable-next-line no-undef
window.env = ${JSON.stringify(envConfig, null, 2)};
`;
// Write env.js file
writeFileSync(envJsPath, envJsContent, 'utf8');
logger.info('Runtime configuration generated successfully', {
envJsPath,
config: envConfig,
});
} catch (error) {
logger.error('Failed to generate runtime configuration', error as Error, {
frontendPath,
basePath,
});
// Don't throw - allow server to start even if config generation fails
}
}

View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist"
},
"ts-node": {
"transpileOnly": true,
"files": true
}
}

32
src/backend/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
"ts-node": {
"esm": true,
"transpileOnly": true,
"compilerOptions": {
"module": "ES2022"
}
}
}

View File

@ -0,0 +1,89 @@
export interface GeoIPLocation {
country?: {
iso_code?: string;
names?: { [key: string]: string };
};
city?: {
names?: { [key: string]: string };
};
subdivisions?: Array<{
iso_code?: string;
names?: { [key: string]: string };
}>;
location?: {
latitude?: number;
longitude?: number;
time_zone?: string;
};
postal?: {
code?: string;
};
continent?: {
code?: string;
names?: { [key: string]: string };
};
registered_country?: {
iso_code?: string;
names?: { [key: string]: string };
};
traits?: {
is_anonymous_proxy?: boolean;
is_satellite_provider?: boolean;
};
}
export interface SimplifiedGeoIPResponse {
ip: string;
country: string;
country_code: string;
region: string;
city: string;
latitude: number | null;
longitude: number | null;
timezone: string | null;
postal_code: string | null;
}
export interface DetailedGeoIPResponse {
ip: string;
location: GeoIPLocation;
}
export interface BatchGeoIPRequest {
ips: string[];
}
export interface BatchGeoIPResponse {
results: Array<SimplifiedGeoIPResponse | { ip: string; error: string }>;
}
export interface ErrorResponse {
error: string;
message: string;
ip?: string;
}
export interface Config {
port: number;
basePath: string;
maxmindDbPath: string;
seqUrl?: string;
seqApiKey?: string;
apiKeys: {
frontend: string;
external: string[];
};
frontendAllowedOrigins: string[];
rateLimits: {
frontend: {
windowMs: number;
max: number;
};
external: {
windowMs: number;
max: number;
};
};
batchLimit: number;
debounceMs: number;
}

5
src/frontend/.env Normal file
View File

@ -0,0 +1,5 @@
# Frontend Environment Variables for Local Development
VITE_API_KEY=frontend-dev-key
VITE_API_URL=http://localhost:5172/api
VITE_DEBOUNCE_MS=2000
VITE_BASE_PATH=/

View File

@ -0,0 +1,50 @@
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import parser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import globals from 'globals';
export default [
js.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: parser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
},
},
plugins: {
'@typescript-eslint': typescript,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
prettier: prettier,
},
rules: {
...typescript.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...prettierConfig.rules,
'prettier/prettier': 'error',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'vite.config.ts'],
},
];

24
src/frontend/index.html Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bitip - GeoIP Lookup Service</title>
<meta
name="description"
content="Professional GeoIP lookup service with interactive maps and detailed location data."
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
</head>
<body>
<div id="root"></div>
<script src="/env.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
src/frontend/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "bitip-frontend",
"version": "1.0.0",
"description": "Bitip Frontend - GeoIP Web Interface",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf dist"
},
"dependencies": {
"axios": "^1.12.2",
"leaflet": "^1.9.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.3"
},
"devDependencies": {
"@types/leaflet": "^1.9.18",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
"typescript": "^5.9.2",
"vite": "^7.1.7"
}
}

View File

@ -0,0 +1,6 @@
// Runtime configuration generated at container startup
// DO NOT EDIT - This file is automatically generated
// eslint-disable-next-line no-undef
window.env = {
"basePath": "/"
};

130
src/frontend/src/App.css Normal file
View File

@ -0,0 +1,130 @@
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
/* App Container */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
color: white;
text-align: center;
padding: 2rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.header p {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 1rem;
}
/* Navigation */
.nav {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.nav-link {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.5rem 1.2rem;
border-radius: 20px;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.nav-link.active {
background: rgba(255, 255, 255, 0.9);
color: #667eea;
border-color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.nav-link.active:hover {
background: rgba(255, 255, 255, 1);
}
/* Footer */
.footer {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
color: white;
text-align: center;
padding: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: auto;
}
.footer p {
opacity: 0.9;
font-size: 0.95rem;
margin: 0.25rem 0;
}
.version-info {
font-size: 0.85rem;
opacity: 0.8;
}
/* Responsive Design */
@media (max-width: 768px) {
.header h1 {
font-size: 2.5rem;
}
.header p {
font-size: 1rem;
}
.nav {
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
}

89
src/frontend/src/App.tsx Normal file
View File

@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
import Home from './pages/Home.js';
import Overview from './pages/Overview.js';
import ReleaseNotes from './pages/ReleaseNotes.js';
import BitipAPI from './services/api.js';
import './App.css';
interface VersionInfo {
version: string;
createdAt: string;
gitRevision: string;
service: string;
}
const App: React.FC = () => {
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
// Fetch version info on mount
useEffect(() => {
const fetchVersion = async () => {
try {
const response = await BitipAPI.getVersion();
setVersionInfo(response);
} catch (err) {
console.error('Failed to fetch version:', err);
}
};
fetchVersion();
}, []);
return (
<div className="app">
<header className="header">
<h1>🌍 Bitip</h1>
<p>Professional GeoIP Lookup Service</p>
<nav className="nav">
<NavLink
to="/"
className={({ isActive }: { isActive: boolean }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Home
</NavLink>
<NavLink
to="/overview"
className={({ isActive }: { isActive: boolean }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Overview
</NavLink>
<NavLink
to="/release-notes"
className={({ isActive }: { isActive: boolean }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Release Notes
</NavLink>
</nav>
</header>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/overview" element={<Overview />} />
<Route path="/release-notes" element={<ReleaseNotes />} />
</Routes>
<footer className="footer">
<p>
Powered by <strong>Bitip</strong> | GeoIP data provided by MaxMind |
Maps by OpenStreetMap
</p>
{versionInfo && (
<p className="version-info">
Version {versionInfo.version}
{versionInfo.createdAt !== 'unknown' &&
` | Released ${new Date(versionInfo.createdAt).toLocaleDateString()}`}
</p>
)}
</footer>
</div>
);
};
export default App;

View File

@ -0,0 +1,137 @@
.content-section {
margin-bottom: 30px;
padding-left: 15px;
}
.section-title {
font-size: 1.4em;
color: #333;
margin-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 8px;
}
.section-content {
line-height: 1.7;
color: #555;
margin-bottom: 15px;
}
.section-content p {
margin-bottom: 15px;
}
.section-content strong {
color: #333;
font-weight: 600;
}
.section-content em {
font-style: italic;
}
.section-content code {
background: #e8edf2;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
color: #d63384;
}
.section-items {
list-style: none;
padding-left: 0;
margin: 0;
}
.section-item {
padding: 10px 15px;
margin-bottom: 8px;
background: #f8f9fa;
border-left: 3px solid #4a90e2;
border-radius: 4px;
line-height: 1.6;
}
.section-item strong {
color: #333;
font-weight: 600;
}
.section-item em {
font-style: italic;
}
.section-item code {
background: #e8edf2;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
color: #d63384;
}
.section-subsections {
margin-top: 20px;
padding-left: 20px;
}
.subsection {
margin-bottom: 20px;
}
.subsection-title {
font-size: 1.2em;
color: #555;
margin-bottom: 10px;
font-weight: 600;
}
.subsection-items {
list-style: none;
padding-left: 0;
margin: 0;
}
.subsection-item {
padding: 8px 12px;
margin-bottom: 6px;
background: #fafbfc;
border-left: 2px solid #6c757d;
border-radius: 3px;
line-height: 1.5;
font-size: 0.95em;
}
.subsection-item strong {
color: #333;
font-weight: 600;
}
.subsection-item em {
font-style: italic;
}
.subsection-item code {
background: #e8edf2;
padding: 2px 5px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85em;
color: #d63384;
}
@media (max-width: 768px) {
.section-title {
font-size: 1.2em;
}
.subsection-title {
font-size: 1.1em;
}
.section-subsections {
padding-left: 10px;
}
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import './ContentSection.css';
interface ContentSubsection {
subtitle: string;
items: string[];
}
interface ContentSectionProps {
title: string;
content?: string;
items?: string[];
subsections?: ContentSubsection[];
}
const ContentSection: React.FC<ContentSectionProps> = ({
title,
content,
items,
subsections,
}) => {
const formatText = (text: string): string => {
// Convert markdown-style formatting to HTML
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>');
};
return (
<section className="content-section">
<h3 className="section-title">{title}</h3>
{content && (
<div
className="section-content"
dangerouslySetInnerHTML={{ __html: formatText(content) }}
/>
)}
{items && items.length > 0 && (
<ul className="section-items">
{items.map((item, idx) => (
<li
key={idx}
className="section-item"
dangerouslySetInnerHTML={{ __html: formatText(item) }}
/>
))}
</ul>
)}
{subsections && subsections.length > 0 && (
<div className="section-subsections">
{subsections.map((subsection, subIdx) => (
<div key={subIdx} className="subsection">
<h4 className="subsection-title">{subsection.subtitle}</h4>
<ul className="subsection-items">
{subsection.items.map((item, itemIdx) => (
<li
key={itemIdx}
className="subsection-item"
dangerouslySetInnerHTML={{ __html: formatText(item) }}
/>
))}
</ul>
</div>
))}
</div>
)}
</section>
);
};
export default ContentSection;

View File

@ -0,0 +1,72 @@
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix for default markers in react-leaflet
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl:
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl:
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
interface MapComponentProps {
latitude: number;
longitude: number;
city?: string;
country?: string;
interactive?: boolean;
height?: string;
width?: string;
}
const MapComponent: React.FC<MapComponentProps> = ({
latitude,
longitude,
city = 'Unknown',
country = 'Unknown',
interactive = true,
height = '300px',
width = '100%',
}) => {
const position: [number, number] = [latitude, longitude];
return (
<div style={{ height, width }}>
<MapContainer
center={position}
zoom={10}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={interactive}
dragging={interactive}
touchZoom={interactive}
doubleClickZoom={interactive}
boxZoom={interactive}
keyboard={interactive}
zoomControl={interactive}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>
<div>
<strong>
{city}, {country}
</strong>
<br />
Coordinates: {latitude.toFixed(4)}, {longitude.toFixed(4)}
</div>
</Popup>
</Marker>
</MapContainer>
</div>
);
};
export default MapComponent;

23
src/frontend/src/main.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx';
// Runtime config loaded from env.js (generated by backend at startup)
declare global {
interface Window {
env?: {
basePath?: string;
};
}
}
const basename = window.env?.basePath || '/';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,268 @@
/* Main Content */
.main {
flex: 1;
padding: 2rem 1rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Search Section */
.search-section {
margin-bottom: 2rem;
}
.ip-input-container {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.ip-input-container label {
display: block;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.ip-input {
width: 100%;
padding: 1rem;
font-size: 1.1rem;
border: 2px solid #e1e5e9;
border-radius: 8px;
transition: border-color 0.3s ease;
background: white;
}
.ip-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.ip-input:disabled {
background: #f8f9fa;
cursor: not-allowed;
}
.loading-indicator {
margin-top: 1rem;
padding: 0.5rem;
background: #e3f2fd;
color: #1976d2;
border-radius: 6px;
text-align: center;
font-weight: 500;
}
/* Error Message */
.error-message {
background: #ffebee;
color: #c62828;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
border-left: 4px solid #c62828;
}
/* Results Section */
.results-section {
display: grid;
gap: 2rem;
margin-bottom: 2rem;
}
.location-info {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.location-info h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.5rem;
font-weight: 600;
border-bottom: 2px solid #667eea;
padding-bottom: 0.5rem;
}
.info-grid {
display: grid;
gap: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #667eea;
}
.info-label {
font-weight: 600;
color: #555;
}
.info-value {
font-weight: 500;
color: #333;
text-align: right;
}
/* Map Section */
.map-section {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.map-section h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.5rem;
font-weight: 600;
border-bottom: 2px solid #667eea;
padding-bottom: 0.5rem;
}
.map-container {
position: relative;
}
.interactive-map-btn {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.interactive-map-btn:hover {
background: #5a6fd8;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: white;
border-radius: 12px;
width: 100%;
max-width: 900px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #e1e5e9;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
}
.modal-header h3 {
color: #333;
font-size: 1.3rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.modal-close:hover {
background: #e9ecef;
}
.modal-body {
padding: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.main {
padding: 1rem;
}
.ip-input-container,
.location-info,
.map-section {
padding: 1.5rem;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.info-value {
text-align: left;
}
.modal-content {
margin: 1rem;
max-width: calc(100vw - 2rem);
}
.modal-header {
padding: 1rem 1.5rem;
}
}
@media (min-width: 768px) {
.results-section {
grid-template-columns: 1fr 1fr;
}
.map-section {
grid-column: span 2;
}
}
@media (min-width: 1024px) {
.info-grid {
grid-template-columns: 1fr 1fr;
}
}

View File

@ -0,0 +1,188 @@
import React, { useState, useEffect, useCallback } from 'react';
import MapComponent from '../components/MapComponent';
import BitipAPI from '../services/api';
import { SimplifiedGeoIPResponse } from '../types';
import './Home.css';
const DEBOUNCE_MS =
parseInt(import.meta.env.VITE_DEBOUNCE_MS as string) || 2000;
const Home: React.FC = () => {
const [inputIP, setInputIP] = useState<string>('');
const [geoData, setGeoData] = useState<SimplifiedGeoIPResponse | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [showInteractiveMap, setShowInteractiveMap] = useState<boolean>(false);
// Debounced IP lookup
const debouncedLookup = useCallback(
debounce(async (ip: string) => {
if (!ip.trim()) return;
setLoading(true);
setError('');
try {
const result = await BitipAPI.lookupIP(ip.trim());
setGeoData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to lookup IP');
setGeoData(null);
} finally {
setLoading(false);
}
}, DEBOUNCE_MS),
[]
);
// Auto-detect current IP on mount
useEffect(() => {
const detectCurrentIP = async () => {
try {
const ip = await BitipAPI.getCurrentIP();
setInputIP(ip);
debouncedLookup(ip);
} catch (err) {
console.error('Failed to detect current IP:', err);
setError('Failed to detect your current IP address');
}
};
detectCurrentIP();
}, [debouncedLookup]);
// Handle IP input changes
const handleIPChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newIP = event.target.value;
setInputIP(newIP);
debouncedLookup(newIP);
};
const formatLocationData = (data: SimplifiedGeoIPResponse) => [
{ label: 'IP Address', value: data.ip },
{ label: 'Country', value: `${data.country} (${data.country_code})` },
{ label: 'Region', value: data.region },
{ label: 'City', value: data.city },
{
label: 'Coordinates',
value:
data.latitude && data.longitude
? `${data.latitude}, ${data.longitude}`
: 'N/A',
},
{ label: 'Timezone', value: data.timezone || 'N/A' },
{ label: 'Postal Code', value: data.postal_code || 'N/A' },
];
return (
<>
<main className="main">
<div className="search-section">
<div className="ip-input-container">
<label htmlFor="ip-input">Enter IP Address:</label>
<input
id="ip-input"
type="text"
value={inputIP}
onChange={handleIPChange}
placeholder="Enter an IP address to lookup"
className="ip-input"
disabled={loading}
/>
{loading && <div className="loading-indicator">Looking up...</div>}
</div>
</div>
{error && (
<div className="error-message">
<strong>Error:</strong> {error}
</div>
)}
{geoData && !loading && (
<div className="results-section">
<div className="location-info">
<h2>Location Information</h2>
<div className="info-grid">
{formatLocationData(geoData).map(({ label, value }) => (
<div key={label} className="info-item">
<span className="info-label">{label}:</span>
<span className="info-value">{value}</span>
</div>
))}
</div>
</div>
{geoData.latitude && geoData.longitude && (
<div className="map-section">
<h2>Location Map</h2>
<div className="map-container">
<MapComponent
latitude={geoData.latitude}
longitude={geoData.longitude}
city={geoData.city}
country={geoData.country}
interactive={false}
height="250px"
/>
<button
className="interactive-map-btn"
onClick={() => setShowInteractiveMap(true)}
>
🔍 View Interactive Map
</button>
</div>
</div>
)}
</div>
)}
</main>
{/* Interactive Map Modal */}
{showInteractiveMap && geoData?.latitude && geoData?.longitude && (
<div
className="modal-overlay"
onClick={() => setShowInteractiveMap(false)}
>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>
Interactive Map - {geoData.city}, {geoData.country}
</h3>
<button
className="modal-close"
onClick={() => setShowInteractiveMap(false)}
>
</button>
</div>
<div className="modal-body">
<MapComponent
latitude={geoData.latitude}
longitude={geoData.longitude}
city={geoData.city}
country={geoData.country}
interactive={true}
height="500px"
/>
</div>
</div>
</div>
)}
</>
);
};
// Utility function for debouncing
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export default Home;

View File

@ -0,0 +1,77 @@
.overview {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
.overview-header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.overview-header h1 {
font-size: 2.5em;
color: #333;
margin-bottom: 10px;
}
.overview-subtitle {
font-size: 1.2em;
color: #666;
margin-bottom: 10px;
}
.overview-updated {
font-size: 0.9em;
color: #888;
font-style: italic;
}
.overview-content {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.loading,
.error,
.empty {
text-align: center;
padding: 60px 20px;
font-size: 1.2em;
}
.error {
color: #dc3545;
}
.loading {
color: #6c757d;
}
.empty {
color: #adb5bd;
}
@media (max-width: 768px) {
.overview {
padding: 15px;
}
.overview-header h1 {
font-size: 2em;
}
.overview-subtitle {
font-size: 1em;
}
.overview-content {
padding: 20px;
}
}

View File

@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react';
import BitipAPI from '../services/api';
import { OverviewResponse } from '../types';
import ContentSection from '../components/ContentSection';
import './Overview.css';
const Overview: React.FC = () => {
const [overview, setOverview] = useState<OverviewResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
useEffect(() => {
const fetchOverview = async () => {
try {
const data = await BitipAPI.getOverview();
setOverview(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load overview');
} finally {
setLoading(false);
}
};
fetchOverview();
}, []);
if (loading) {
return (
<div className="overview">
<div className="loading">Loading overview...</div>
</div>
);
}
if (error) {
return (
<div className="overview">
<div className="error">Error: {error}</div>
</div>
);
}
if (!overview) {
return (
<div className="overview">
<div className="empty">No overview data available</div>
</div>
);
}
return (
<div className="overview">
<div className="overview-header">
<h1>{overview.title}</h1>
<p className="overview-subtitle">{overview.subtitle}</p>
{overview.lastUpdated && (
<p className="overview-updated">
Last updated: {new Date(overview.lastUpdated).toLocaleDateString()}
</p>
)}
</div>
<div className="overview-content">
{overview.sections.map((section, idx) => (
<ContentSection
key={idx}
title={section.title}
content={section.content}
items={section.items}
subsections={section.subsections}
/>
))}
</div>
</div>
);
};
export default Overview;

View File

@ -0,0 +1,225 @@
.release-notes {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
.rn-header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.rn-header h1 {
font-size: 2.5em;
color: #333;
margin-bottom: 10px;
}
.rn-header p {
font-size: 1.1em;
color: #666;
}
.rn-content {
display: flex;
flex-direction: column;
gap: 40px;
}
.release {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.release-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #4a90e2;
flex-wrap: wrap;
gap: 10px;
}
.release-header h2 {
font-size: 1.8em;
color: #4a90e2;
margin: 0;
}
.release-date {
font-size: 0.9em;
color: #888;
font-weight: normal;
}
.release-summary {
margin-bottom: 25px;
padding: 15px;
background: #f8f9fa;
border-left: 4px solid #4a90e2;
border-radius: 4px;
}
.release-summary p {
margin: 0;
line-height: 1.6;
color: #555;
}
.release-sections {
display: flex;
flex-direction: column;
gap: 30px;
}
.section {
padding-left: 15px;
}
.section h3 {
font-size: 1.4em;
color: #333;
margin-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 8px;
}
.section-content p {
line-height: 1.7;
color: #555;
margin-bottom: 15px;
}
.section-items {
list-style: none;
padding-left: 0;
margin: 0;
}
.section-items li {
padding: 10px 15px;
margin-bottom: 8px;
background: #f8f9fa;
border-left: 3px solid #4a90e2;
border-radius: 4px;
line-height: 1.6;
}
.section-items li strong {
color: #333;
font-weight: 600;
}
.section-items li code {
background: #e8edf2;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
color: #d63384;
}
.subsections {
margin-top: 20px;
padding-left: 20px;
}
.subsection {
margin-bottom: 20px;
}
.subsection h4 {
font-size: 1.2em;
color: #555;
margin-bottom: 10px;
font-weight: 600;
}
.subsection-items {
list-style: none;
padding-left: 0;
margin: 0;
}
.subsection-items li {
padding: 8px 12px;
margin-bottom: 6px;
background: #fafbfc;
border-left: 2px solid #6c757d;
border-radius: 3px;
line-height: 1.5;
font-size: 0.95em;
}
.subsection-items li strong {
color: #333;
font-weight: 600;
}
.subsection-items li code {
background: #e8edf2;
padding: 2px 5px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85em;
color: #d63384;
}
.loading,
.error,
.empty {
text-align: center;
padding: 60px 20px;
font-size: 1.2em;
}
.error {
color: #dc3545;
}
.loading {
color: #6c757d;
}
.empty {
color: #adb5bd;
}
@media (max-width: 768px) {
.release-notes {
padding: 15px;
}
.rn-header h1 {
font-size: 2em;
}
.release {
padding: 20px;
}
.release-header {
flex-direction: column;
align-items: flex-start;
}
.release-header h2 {
font-size: 1.5em;
}
.section h3 {
font-size: 1.2em;
}
.subsection h4 {
font-size: 1.1em;
}
}

View File

@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import BitipAPI from '../services/api';
import { ReleaseNotesResponse } from '../types';
import ContentSection from '../components/ContentSection';
import './ReleaseNotes.css';
const ReleaseNotes: React.FC = () => {
const [releaseNotes, setReleaseNotes] = useState<ReleaseNotesResponse | null>(
null
);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>('');
useEffect(() => {
const fetchReleaseNotes = async () => {
try {
const data = await BitipAPI.getReleaseNotes();
setReleaseNotes(data);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to load release notes'
);
} finally {
setLoading(false);
}
};
fetchReleaseNotes();
}, []);
if (loading) {
return (
<div className="release-notes">
<div className="loading">Loading release notes...</div>
</div>
);
}
if (error) {
return (
<div className="release-notes">
<div className="error">
<strong>Error:</strong> {error}
</div>
</div>
);
}
if (!releaseNotes || releaseNotes.releases.length === 0) {
return (
<div className="release-notes">
<div className="empty">No release notes available.</div>
</div>
);
}
return (
<div className="release-notes">
<header className="rn-header">
<h1>📋 Release Notes</h1>
<p>Version history and changelog for Bitip GeoIP Service</p>
</header>
<div className="rn-content">
{releaseNotes.releases.map(release => (
<div key={release.version} className="release">
<div className="release-header">
<h2>
Version {release.version}
{release.title && ` - ${release.title}`}
</h2>
<span className="release-date">
Released: {new Date(release.date).toLocaleDateString()}
</span>
</div>
{release.summary && (
<div className="release-summary">
<p>{release.summary}</p>
</div>
)}
{release.sections && release.sections.length > 0 && (
<div className="release-sections">
{release.sections.map((section, idx) => (
<ContentSection
key={idx}
title={section.title}
content={section.content}
items={section.items}
subsections={section.subsections}
/>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
export default ReleaseNotes;

View File

@ -0,0 +1,112 @@
import axios from 'axios';
import {
SimplifiedGeoIPResponse,
DetailedGeoIPResponse,
IPResponse,
VersionInfo,
ReleaseNotesResponse,
OverviewResponse,
} from '../types';
const API_KEY = import.meta.env.VITE_API_KEY || 'frontend-default-key';
const BASE_URL = import.meta.env.VITE_API_URL || '/api';
const apiClient = axios.create({
baseURL: BASE_URL,
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
timeout: 10000,
});
// Response interceptor for error handling
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response?.data) {
throw new Error(error.response.data.message || 'API request failed');
}
throw new Error(error.message || 'Network error');
}
);
export class BitipAPI {
static async getCurrentIP(): Promise<string> {
try {
const response = await apiClient.get<IPResponse>('/ip');
return response.data.ip;
} catch (error) {
console.error('Failed to get current IP:', error);
throw error;
}
}
static async lookupIP(ip: string): Promise<SimplifiedGeoIPResponse> {
try {
const response = await apiClient.get<SimplifiedGeoIPResponse>(
`/lookup?ip=${encodeURIComponent(ip)}`
);
return response.data;
} catch (error) {
console.error('Failed to lookup IP:', ip, error);
throw error;
}
}
static async lookupIPDetailed(ip: string): Promise<DetailedGeoIPResponse> {
try {
const response = await apiClient.get<DetailedGeoIPResponse>(
`/lookup/detailed?ip=${encodeURIComponent(ip)}`
);
return response.data;
} catch (error) {
console.error('Failed to lookup IP (detailed):', ip, error);
throw error;
}
}
static async healthCheck(): Promise<boolean> {
try {
await apiClient.get('/health');
return true;
} catch (error) {
console.error('Health check failed:', error);
return false;
}
}
static async getVersion(): Promise<VersionInfo> {
try {
const response = await apiClient.get<VersionInfo>('/version');
return response.data;
} catch (error) {
console.error('Failed to get version:', error);
throw error;
}
}
static async getReleaseNotes(): Promise<ReleaseNotesResponse> {
try {
const response = await apiClient.get<ReleaseNotesResponse>(
'/release-notes'
);
return response.data;
} catch (error) {
console.error('Failed to get release notes:', error);
throw error;
}
}
static async getOverview(): Promise<OverviewResponse> {
try {
const response = await apiClient.get<OverviewResponse>('/overview');
return response.data;
} catch (error) {
console.error('Failed to get overview:', error);
throw error;
}
}
}
export default BitipAPI;

View File

@ -0,0 +1,110 @@
export interface GeoIPLocation {
country?: {
iso_code?: string;
names?: { [key: string]: string };
};
city?: {
names?: { [key: string]: string };
};
subdivisions?: Array<{
iso_code?: string;
names?: { [key: string]: string };
}>;
location?: {
latitude?: number;
longitude?: number;
time_zone?: string;
};
postal?: {
code?: string;
};
continent?: {
code?: string;
names?: { [key: string]: string };
};
registered_country?: {
iso_code?: string;
names?: { [key: string]: string };
};
traits?: {
is_anonymous_proxy?: boolean;
is_satellite_provider?: boolean;
};
}
export interface SimplifiedGeoIPResponse {
ip: string;
country: string;
country_code: string;
region: string;
city: string;
latitude: number | null;
longitude: number | null;
timezone: string | null;
postal_code: string | null;
}
export interface DetailedGeoIPResponse {
ip: string;
location: GeoIPLocation;
}
export interface ErrorResponse {
error: string;
message: string;
ip?: string;
}
export interface IPResponse {
ip: string;
}
export interface VersionInfo {
version: string;
createdAt: string;
gitRevision: string;
service: string;
}
export interface ReleaseNoteSubsection {
subtitle: string;
items: string[];
}
export interface ReleaseNoteSection {
title: string;
content?: string;
items?: string[];
subsections?: ReleaseNoteSubsection[];
}
export interface ReleaseNote {
version: string;
date: string;
title: string;
summary: string;
sections: ReleaseNoteSection[];
}
export interface ReleaseNotesResponse {
releases: ReleaseNote[];
}
export interface OverviewSubsection {
subtitle: string;
items: string[];
}
export interface OverviewSection {
title: string;
content?: string;
items?: string[];
subsections?: OverviewSubsection[];
}
export interface OverviewResponse {
title: string;
subtitle: string;
lastUpdated: string;
sections: OverviewSection[];
}

11
src/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_KEY: string;
readonly VITE_API_URL: string;
readonly VITE_DEBOUNCE_MS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,26 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
const env = loadEnv(mode, process.cwd(), '');
const basePath = env.VITE_BASE_PATH || '/';
return {
base: basePath,
plugins: [react()],
publicDir: 'public',
build: {
outDir: '../../../dist/frontend',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5172',
changeOrigin: true,
},
},
},
};
});