mirror of
https://dev.azure.com/tstanciu94/PhantomMind/_git/Bitip
synced 2025-10-13 01:52:19 +03:00
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:
parent
397e8abbc3
commit
345ed9c68c
31
.env
Normal file
31
.env
Normal 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
39
.env.example
Normal 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
21
.gitattributes
vendored
Normal 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
103
.gitignore
vendored
Normal 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
10
.prettierrc
Normal 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
96
Dockerfile
Normal 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
88
LICENSE
Normal 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
235
Overview.json
Normal 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
513
README.md
@ -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
142
ReleaseNotes.json
Normal 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
77
docker-compose.yml
Normal 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
457
docs/CONFIGURATION.md
Normal 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`
|
@ -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
7156
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal 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"
|
||||
}
|
||||
}
|
50
src/backend/eslint.config.js
Normal file
50
src/backend/eslint.config.js
Normal 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
155
src/backend/index.ts
Normal 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;
|
87
src/backend/middleware/auth.ts
Normal file
87
src/backend/middleware/auth.ts
Normal 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;
|
73
src/backend/middleware/rateLimit.ts
Normal file
73
src/backend/middleware/rateLimit.ts
Normal 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
11
src/backend/nodemon.json
Normal 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
42
src/backend/package.json
Normal 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
4
src/backend/register.js
Normal 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
338
src/backend/routes/api.ts
Normal 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;
|
44
src/backend/services/config.ts
Normal file
44
src/backend/services/config.ts
Normal 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;
|
165
src/backend/services/geoip.ts
Normal file
165
src/backend/services/geoip.ts
Normal 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;
|
86
src/backend/services/logger.ts
Normal file
86
src/backend/services/logger.ts
Normal 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;
|
58
src/backend/services/runtimeConfig.ts
Normal file
58
src/backend/services/runtimeConfig.ts
Normal 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
|
||||
}
|
||||
}
|
11
src/backend/tsconfig.dev.json
Normal file
11
src/backend/tsconfig.dev.json
Normal 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
32
src/backend/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
89
src/backend/types/index.ts
Normal file
89
src/backend/types/index.ts
Normal 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
5
src/frontend/.env
Normal 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=/
|
50
src/frontend/eslint.config.js
Normal file
50
src/frontend/eslint.config.js
Normal 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
24
src/frontend/index.html
Normal 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
40
src/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
6
src/frontend/public/env.js
Normal file
6
src/frontend/public/env.js
Normal 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
130
src/frontend/src/App.css
Normal 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
89
src/frontend/src/App.tsx
Normal 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;
|
137
src/frontend/src/components/ContentSection.css
Normal file
137
src/frontend/src/components/ContentSection.css
Normal 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;
|
||||
}
|
||||
}
|
75
src/frontend/src/components/ContentSection.tsx
Normal file
75
src/frontend/src/components/ContentSection.tsx
Normal 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;
|
72
src/frontend/src/components/MapComponent.tsx
Normal file
72
src/frontend/src/components/MapComponent.tsx
Normal 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='© <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
23
src/frontend/src/main.tsx
Normal 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>
|
||||
);
|
268
src/frontend/src/pages/Home.css
Normal file
268
src/frontend/src/pages/Home.css
Normal 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;
|
||||
}
|
||||
}
|
188
src/frontend/src/pages/Home.tsx
Normal file
188
src/frontend/src/pages/Home.tsx
Normal 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;
|
77
src/frontend/src/pages/Overview.css
Normal file
77
src/frontend/src/pages/Overview.css
Normal 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;
|
||||
}
|
||||
}
|
78
src/frontend/src/pages/Overview.tsx
Normal file
78
src/frontend/src/pages/Overview.tsx
Normal 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;
|
225
src/frontend/src/pages/ReleaseNotes.css
Normal file
225
src/frontend/src/pages/ReleaseNotes.css
Normal 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;
|
||||
}
|
||||
}
|
103
src/frontend/src/pages/ReleaseNotes.tsx
Normal file
103
src/frontend/src/pages/ReleaseNotes.tsx
Normal 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;
|
112
src/frontend/src/services/api.ts
Normal file
112
src/frontend/src/services/api.ts
Normal 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;
|
110
src/frontend/src/types/index.ts
Normal file
110
src/frontend/src/types/index.ts
Normal 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
11
src/frontend/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
25
src/frontend/tsconfig.json
Normal file
25
src/frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
11
src/frontend/tsconfig.node.json
Normal file
11
src/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
26
src/frontend/vite.config.ts
Normal file
26
src/frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user