mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +00:00
feat: rebrand to Domain Scanner platform
Replace GeoExport static site with Next.js domain intelligence app: real DNS/WHOIS/SSL/HTTP/geo scans, SSE progress, Docker stack, updated install/uninstall scripts, and full documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Domain Scanner
|
||||||
|
DATABASE_URL=postgresql://scanner:scanner@localhost:5432/domain_scanner?schema=public
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=change-me-to-a-long-random-string
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
IPINFO_TOKEN=
|
||||||
|
SCAN_RATE_LIMIT_PER_HOUR=30
|
||||||
43
.gitignore
vendored
43
.gitignore
vendored
@@ -1,6 +1,41 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.pem
|
||||||
premium/node_modules/
|
|
||||||
premium/.next/
|
|
||||||
premium/.env
|
|
||||||
.env
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
prisma/*.db
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npx prisma generate && npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 GeoExport Contributors
|
Copyright (c) 2026 Domain Scanner contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
122
README.md
122
README.md
@@ -1,89 +1,83 @@
|
|||||||
# Domain_web — зеркало GeoExport
|
# Domain Scanner
|
||||||
|
|
||||||
Статическая копия интерфейса [geoexport.org](https://geoexport.org/) для развёртывания на VPS (nginx + Node.js).
|
Modern domain intelligence and infrastructure analysis platform with premium UX, real-time monitoring, and scalable self-hosted architecture.
|
||||||
|
|
||||||
## Быстрая установка (одна команда)
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **DNS** — A, AAAA, CNAME, MX, TXT, NS resolution
|
||||||
|
- **WHOIS** — Registrar and domain metadata
|
||||||
|
- **SSL** — Certificate validity, issuer, expiry
|
||||||
|
- **HTTP** — Status, redirects, response headers
|
||||||
|
- **Geo/IP** — Location via ip-api.com (or IPinfo with token)
|
||||||
|
- **Security** — Header scoring and grade
|
||||||
|
- **Tech/CDN** — Server, Cloudflare, Vercel signals
|
||||||
|
- **Export** — JSON and CSV downloads
|
||||||
|
- **Live progress** — Server-Sent Events during scans
|
||||||
|
- **Monitoring** — DB schema + API for DNS/SSL monitors (workers: roadmap)
|
||||||
|
|
||||||
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/andrey271192/Domain_web.git
|
||||||
|
cd Domain_web
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
docker compose exec web npx prisma db push
|
||||||
|
open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## One-line VPS install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**URL скрипта установки:**
|
## Uninstall
|
||||||
https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh
|
|
||||||
|
|
||||||
## Удаление (одна команда)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**URL скрипта удаления:**
|
## Development
|
||||||
https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh
|
|
||||||
|
|
||||||
## Что устанавливается
|
|
||||||
|
|
||||||
| Компонент | Путь / имя |
|
|
||||||
|-----------|------------|
|
|
||||||
| Клон репозитория | `/opt/domain_web` |
|
|
||||||
| Файлы сайта | `/opt/domain_web/site/` |
|
|
||||||
| Node-сервер (API + статика) | systemd `geoexport-site`, порт `127.0.0.1:4173` |
|
|
||||||
| Публичный доступ | nginx на порту **80** → прокси на Node |
|
|
||||||
|
|
||||||
Скрипт установки:
|
|
||||||
|
|
||||||
- ставит `nginx`, `git`, `curl`, `nodejs` (если нет);
|
|
||||||
- клонирует или обновляет этот репозиторий;
|
|
||||||
- поднимает `geoexport-site.service`;
|
|
||||||
- настраивает nginx как reverse proxy.
|
|
||||||
|
|
||||||
Скрипт удаления:
|
|
||||||
|
|
||||||
- останавливает и удаляет unit `geoexport-site`;
|
|
||||||
- убирает конфиг nginx `geoexport-site`;
|
|
||||||
- удаляет каталог `/opt/domain_web`;
|
|
||||||
- **не** удаляет nginx, node и остальные сервисы сервера.
|
|
||||||
|
|
||||||
## Ручная установка
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/andrey271192/Domain_web.git /opt/domain_web
|
npm install
|
||||||
cd /opt/domain_web
|
cp .env.example .env
|
||||||
sudo bash install.sh
|
# Start Postgres + Redis (docker compose up postgres redis -d)
|
||||||
|
npm run db:push
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Локальный запуск (без VPS)
|
## Environment
|
||||||
|
|
||||||
```bash
|
| Variable | Description |
|
||||||
cd site
|
|----------|-------------|
|
||||||
npm start
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
# http://127.0.0.1:4173
|
| `REDIS_URL` | Redis for scan cache |
|
||||||
```
|
| `NEXTAUTH_SECRET` | Auth signing secret |
|
||||||
|
| `NEXTAUTH_URL` | Public app URL |
|
||||||
|
| `IPINFO_TOKEN` | Optional IPinfo token |
|
||||||
|
| `SCAN_RATE_LIMIT_PER_HOUR` | Per-IP scan limit (default 30) |
|
||||||
|
|
||||||
## Обновление данных на сервере
|
## API
|
||||||
|
|
||||||
```bash
|
See [docs/API.md](docs/API.md) and the in-app `/api-docs` page.
|
||||||
cd /opt/domain_web/site
|
|
||||||
curl -fsSL https://geoexport.org/api/presets -o data/presets.json
|
|
||||||
curl -fsSL https://geoexport.org/api/sources -o data/sources.json
|
|
||||||
curl -fsSL https://geoexport.org/api/last-update -o data/last-update.json
|
|
||||||
curl -fsSL https://geoexport.org/api/routing/presets -o data/routing-presets.json
|
|
||||||
systemctl restart geoexport-site
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ограничения
|
## Docs
|
||||||
|
|
||||||
- Экспорт, поиск и часть API проксируются на живой `geoexport.org` (нужен интернет на VPS).
|
- [Architecture](docs/ARCHITECTURE.md)
|
||||||
- Это зеркало UI и кэшированных справочников, не полный автономный бэкенд.
|
- [API](docs/API.md)
|
||||||
|
- [Deployment](docs/DEPLOYMENT.md)
|
||||||
|
- [Marketing](docs/MARKETING.md)
|
||||||
|
|
||||||
## Premium (Next.js roadmap)
|
## License
|
||||||
|
|
||||||
Каталог `premium/` — Next.js 16 scaffold (Docker, docs, `docker-compose.yml`). Продакшен на VPS сейчас использует **статическое зеркало** в `site/` (nginx + Node). Полный Next.js-стек — по `premium/docs/DEPLOYMENT.md`.
|
MIT — see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Структура репозитория
|
## Topics
|
||||||
|
|
||||||
```
|
`domain-scanner` `dns` `whois` `ssl` `networking` `cybersecurity` `nextjs` `typescript` `self-hosted` `monitoring` `analytics` `infrastructure` `devtools` `open-source` `docker` `postgresql` `redis` `webapp` `dashboard` `security`
|
||||||
install.sh — установка на VPS
|
|
||||||
uninstall.sh — удаление с VPS
|
|
||||||
site/ — index.html, assets/, data/, server.mjs (production)
|
|
||||||
premium/ — Next.js rewrite, Docker, документация (roadmap)
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
# GeoExport — local / single-node production stack
|
|
||||||
# Prisma migrations and geo workers: see docs/DEPLOYMENT.md (roadmap)
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build: .
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://geoexport:geoexport@postgres:5432/geoexport?schema=public
|
DATABASE_URL: postgresql://scanner:scanner@postgres:5432/domain_scanner?schema=public
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-me-in-production}
|
||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||||
GEOEXPORT_UPSTREAM: ${GEOEXPORT_UPSTREAM:-https://geoexport.org}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -24,15 +20,13 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: geoexport
|
POSTGRES_USER: scanner
|
||||||
POSTGRES_PASSWORD: geoexport
|
POSTGRES_PASSWORD: scanner
|
||||||
POSTGRES_DB: geoexport
|
POSTGRES_DB: domain_scanner
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U geoexport -d geoexport"]
|
test: ["CMD-SHELL", "pg_isready -U scanner -d domain_scanner"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -43,8 +37,6 @@ services:
|
|||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
51
docs/API.md
Normal file
51
docs/API.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Base URL: your deployment origin (e.g. `http://SERVER_IP`)
|
||||||
|
|
||||||
|
## POST /api/scan
|
||||||
|
|
||||||
|
Start a domain scan.
|
||||||
|
|
||||||
|
**Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "domain": "example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "clx...", "domain": "example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET /api/scan/:id
|
||||||
|
|
||||||
|
Poll scan status and full result.
|
||||||
|
|
||||||
|
## GET /api/scan/:id/stream
|
||||||
|
|
||||||
|
Server-Sent Events. Each event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "...", "status": "RUNNING", "progress": 45, "domain": "example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET /api/export?id=:scanId&format=json|csv
|
||||||
|
|
||||||
|
Download completed scan.
|
||||||
|
|
||||||
|
## GET /api/scan
|
||||||
|
|
||||||
|
List recent scans (public metadata).
|
||||||
|
|
||||||
|
## POST /api/monitors
|
||||||
|
|
||||||
|
Requires session. Body: `{ "domain": "example.com", "type": "DNS" }`
|
||||||
|
|
||||||
|
## GET /api/health
|
||||||
|
|
||||||
|
Database connectivity check.
|
||||||
|
|
||||||
|
## Rate limits
|
||||||
|
|
||||||
|
Default 30 scans/hour per IP (`SCAN_RATE_LIMIT_PER_HOUR`).
|
||||||
44
docs/ARCHITECTURE.md
Normal file
44
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Domain Scanner is a Next.js 15 application with PostgreSQL (Prisma) and optional Redis caching. Scans run in-process on the web server using Node.js built-ins and public APIs.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ Browser │────▶│ Next.js App │────▶│ PostgreSQL │
|
||||||
|
│ (SSE/REST) │ │ scan worker │ │ scans │
|
||||||
|
└─────────────┘ └──────┬───────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────┐
|
||||||
|
│ Redis │
|
||||||
|
│ scan cache │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scan pipeline
|
||||||
|
|
||||||
|
1. `POST /api/scan` validates domain, checks rate limit, creates `Scan` row
|
||||||
|
2. Async `runDomainScan()` updates progress in DB
|
||||||
|
3. Client subscribes to `GET /api/scan/:id/stream` (SSE)
|
||||||
|
4. Completed results cached in Redis (1h) and stored as JSON in Postgres
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
- **User** — credentials auth (NextAuth)
|
||||||
|
- **Scan** — domain, status, progress, result JSON
|
||||||
|
- **Monitor** — per-user DNS/SSL/uptime watch (cron stub)
|
||||||
|
- **ApiRateLimit** — hourly per-IP counters
|
||||||
|
|
||||||
|
## Competitive positioning
|
||||||
|
|
||||||
|
Tools like DNSChecker and SecurityTrails focus on single-record lookups or enterprise datasets. Domain Scanner targets unified infrastructure snapshots with export and self-hosting — not a clone of their UI patterns.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- BullMQ worker container for monitors
|
||||||
|
- GraphQL server (schema placeholder in `src/lib/graphql/schema.ts`)
|
||||||
|
- Blacklist checks (Spamhaus, etc.)
|
||||||
|
- DNS diff history
|
||||||
43
docs/DEPLOYMENT.md
Normal file
43
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
## Docker (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec web npx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
## VPS one-liner
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Install path: `/opt/domain_web`
|
||||||
|
Service: Docker Compose (`web`, `postgres`, `redis`)
|
||||||
|
Nginx site: `domain-scanner` on port 80 → app `:3000`
|
||||||
|
|
||||||
|
## Environment on server
|
||||||
|
|
||||||
|
Edit `/opt/domain_web/.env`:
|
||||||
|
|
||||||
|
- `NEXTAUTH_SECRET` — auto-generated on first install
|
||||||
|
- `NEXTAUTH_URL` / `NEXT_PUBLIC_APP_URL` — public IP or domain
|
||||||
|
|
||||||
|
## Manual Node deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci && npm run build
|
||||||
|
DATABASE_URL=... npx prisma db push
|
||||||
|
PORT=3000 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSL / HTTPS
|
||||||
|
|
||||||
|
Place certbot in front of nginx or terminate TLS at a reverse proxy.
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domain_web && git pull && docker compose up -d --build
|
||||||
|
```
|
||||||
32
docs/MARKETING.md
Normal file
32
docs/MARKETING.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Marketing
|
||||||
|
|
||||||
|
## GitHub About
|
||||||
|
|
||||||
|
Modern domain intelligence and infrastructure analysis platform with premium UX, real-time monitoring, and scalable self-hosted architecture.
|
||||||
|
|
||||||
|
## Tagline
|
||||||
|
|
||||||
|
See every layer of your domain.
|
||||||
|
|
||||||
|
## Product Hunt (draft)
|
||||||
|
|
||||||
|
**Title:** Domain Scanner — self-hosted domain intelligence
|
||||||
|
**Subtitle:** DNS, WHOIS, SSL, security headers, and exports in one premium dashboard.
|
||||||
|
|
||||||
|
**First comment outline:** Built for SREs and indie hackers who want SecurityTrails-grade signals without SaaS lock-in. Docker + Postgres + real scans via Node.
|
||||||
|
|
||||||
|
## Twitter / X
|
||||||
|
|
||||||
|
Shipped Domain Scanner — open-source domain intel with cinematic UI, live SSE scans, JSON/CSV export. Self-host on your VPS in one command.
|
||||||
|
|
||||||
|
## Dev.to outline
|
||||||
|
|
||||||
|
1. Why unified domain scans beat tab-hopping
|
||||||
|
2. Architecture: Next.js + Prisma + Redis
|
||||||
|
3. Implementing SSL checks with Node tls
|
||||||
|
4. SSE for scan progress
|
||||||
|
5. One-line VPS deploy
|
||||||
|
|
||||||
|
## SEO keywords
|
||||||
|
|
||||||
|
domain scanner, dns lookup tool, ssl checker, whois api, self-hosted security tools
|
||||||
14
eslint.config.mjs
Normal file
14
eslint.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
110
install.sh
110
install.sh
@@ -1,33 +1,34 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# GeoExport site — one-command install (Ubuntu/Debian VPS)
|
# Domain Scanner — one-command install (Ubuntu/Debian VPS)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_URL="${GEOEXPORT_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
|
REPO_URL="${DOMAIN_SCANNER_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
|
||||||
BRANCH="${GEOEXPORT_BRANCH:-main}"
|
BRANCH="${DOMAIN_SCANNER_BRANCH:-main}"
|
||||||
INSTALL_DIR="${GEOEXPORT_INSTALL_DIR:-/opt/domain_web}"
|
INSTALL_DIR="${DOMAIN_SCANNER_INSTALL_DIR:-/opt/domain_web}"
|
||||||
SITE_DIR="${INSTALL_DIR}/site"
|
SERVICE_NAME="domain-scanner"
|
||||||
SERVICE_NAME="geoexport-site"
|
NGINX_SITE="domain-scanner"
|
||||||
NGINX_SITE="geoexport-site"
|
APP_PORT="${DOMAIN_SCANNER_PORT:-3000}"
|
||||||
NODE_PORT="${GEOEXPORT_PORT:-4173}"
|
|
||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
log() { echo "[geoexport-install] $*"; }
|
log() { echo "[domain-scanner-install] $*"; }
|
||||||
need_root() {
|
need_root() {
|
||||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||||
echo "Запустите от root: sudo bash install.sh" >&2
|
echo "Run as root: sudo bash install.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_packages() {
|
install_packages() {
|
||||||
log "Установка пакетов (nginx, git, curl, nodejs)..."
|
log "Installing nginx, git, curl, docker..."
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq nginx git curl ca-certificates nodejs npm >/dev/null
|
apt-get install -y -qq nginx git curl ca-certificates docker.io docker-compose-plugin >/dev/null
|
||||||
|
systemctl enable docker
|
||||||
|
systemctl start docker
|
||||||
}
|
}
|
||||||
|
|
||||||
clone_or_update() {
|
clone_or_update() {
|
||||||
log "Клонирование/обновление репозитория в ${INSTALL_DIR}..."
|
log "Cloning/updating ${INSTALL_DIR}..."
|
||||||
if [[ -d "${INSTALL_DIR}/.git" ]]; then
|
if [[ -d "${INSTALL_DIR}/.git" ]]; then
|
||||||
git -C "${INSTALL_DIR}" fetch origin "${BRANCH}"
|
git -C "${INSTALL_DIR}" fetch origin "${BRANCH}"
|
||||||
git -C "${INSTALL_DIR}" checkout "${BRANCH}"
|
git -C "${INSTALL_DIR}" checkout "${BRANCH}"
|
||||||
@@ -36,57 +37,58 @@ clone_or_update() {
|
|||||||
rm -rf "${INSTALL_DIR}"
|
rm -rf "${INSTALL_DIR}"
|
||||||
git clone --depth 1 --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}"
|
git clone --depth 1 --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}"
|
||||||
fi
|
fi
|
||||||
if [[ ! -f "${SITE_DIR}/server.mjs" ]]; then
|
}
|
||||||
echo "Ошибка: не найден ${SITE_DIR}/server.mjs" >&2
|
|
||||||
exit 1
|
setup_env() {
|
||||||
|
log "Writing .env..."
|
||||||
|
if [[ ! -f "${INSTALL_DIR}/.env" ]]; then
|
||||||
|
SECRET=$(openssl rand -base64 32 2>/dev/null || head -c 32 /dev/urandom | base64)
|
||||||
|
cat >"${INSTALL_DIR}/.env" <<EOF
|
||||||
|
NEXTAUTH_SECRET=${SECRET}
|
||||||
|
NEXTAUTH_URL=http://$(hostname -I | awk '{print $1}')
|
||||||
|
NEXT_PUBLIC_APP_URL=http://$(hostname -I | awk '{print $1}')
|
||||||
|
PORT=${APP_PORT}
|
||||||
|
EOF
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_systemd() {
|
deploy_docker() {
|
||||||
log "Настройка systemd (${SERVICE_NAME})..."
|
log "Building and starting Docker stack..."
|
||||||
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
cd "${INSTALL_DIR}"
|
||||||
[Unit]
|
docker compose build --quiet
|
||||||
Description=GeoExport static mirror (Node)
|
docker compose up -d
|
||||||
After=network.target
|
sleep 8
|
||||||
|
docker compose exec -T web npx prisma db push 2>/dev/null || true
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=${SITE_DIR}
|
|
||||||
Environment=PORT=${NODE_PORT}
|
|
||||||
ExecStart=/usr/bin/node ${SITE_DIR}/server.mjs
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable "${SERVICE_NAME}.service"
|
|
||||||
systemctl restart "${SERVICE_NAME}.service"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
install_nginx() {
|
install_nginx() {
|
||||||
log "Настройка nginx (${NGINX_SITE})..."
|
log "Configuring nginx..."
|
||||||
cat >"/etc/nginx/sites-available/${NGINX_SITE}" <<EOF
|
cat >"/etc/nginx/sites-available/${NGINX_SITE}" <<EOF
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
client_max_body_size 32m;
|
||||||
client_max_body_size 64m;
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:${NODE_PORT};
|
proxy_pass http://127.0.0.1:${APP_PORT};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host \$host;
|
proxy_set_header Host \$host;
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
rm -f /etc/nginx/sites-enabled/geoexport-site 2>/dev/null || true
|
||||||
|
rm -f /etc/nginx/sites-available/geoexport-site 2>/dev/null || true
|
||||||
|
systemctl stop geoexport-site.service 2>/dev/null || true
|
||||||
|
systemctl disable geoexport-site.service 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/geoexport-site.service 2>/dev/null || true
|
||||||
|
systemctl daemon-reload
|
||||||
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
||||||
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
||||||
nginx -t
|
nginx -t
|
||||||
@@ -95,26 +97,24 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
verify() {
|
verify() {
|
||||||
log "Проверка..."
|
log "Verifying..."
|
||||||
sleep 2
|
sleep 3
|
||||||
if ! systemctl is-active --quiet "${SERVICE_NAME}.service"; then
|
if ! curl -fsS -o /dev/null "http://127.0.0.1:${APP_PORT}/"; then
|
||||||
systemctl status "${SERVICE_NAME}.service" --no-pager || true
|
echo "App not responding on port ${APP_PORT}" >&2
|
||||||
|
docker compose -f "${INSTALL_DIR}/docker-compose.yml" logs --tail=50 web || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! curl -fsS -o /dev/null "http://127.0.0.1:${NODE_PORT}/"; then
|
TITLE=$(curl -fsS "http://127.0.0.1/" | head -c 2000 | grep -oi 'Domain Scanner' | head -1 || true)
|
||||||
echo "Node-сервер не отвечает на порту ${NODE_PORT}" >&2
|
if [[ -z "${TITLE}" ]]; then
|
||||||
exit 1
|
log "Warning: page title may not include Domain Scanner yet"
|
||||||
fi
|
fi
|
||||||
if ! curl -fsS -o /dev/null "http://127.0.0.1/"; then
|
log "Done. Open http://$(hostname -I | awk '{print $1}')/"
|
||||||
echo "nginx не отдаёт сайт на :80" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
log "Готово. Сайт доступен по http://$(hostname -I | awk '{print $1}')/"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
need_root
|
need_root
|
||||||
install_packages
|
install_packages
|
||||||
clone_or_update
|
clone_or_update
|
||||||
install_systemd
|
setup_env
|
||||||
|
deploy_docker
|
||||||
install_nginx
|
install_nginx
|
||||||
verify
|
verify
|
||||||
|
|||||||
24
next.config.ts
Normal file
24
next.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
poweredByHeader: false,
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "camera=(), microphone=(), geolocation=()",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
4017
premium/package-lock.json → package-lock.json
generated
4017
premium/package-lock.json → package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "domain-scanner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.9.0",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"bullmq": "^5.56.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dns2": "^2.1.0",
|
||||||
|
"framer-motion": "^12.16.0",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"lucide-react": "^0.513.0",
|
||||||
|
"next": "^15.3.3",
|
||||||
|
"next-auth": "^5.0.0-beta.28",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
|
"sonner": "^2.0.5",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"whois": "^2.14.2",
|
||||||
|
"zod": "^3.25.51"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^15.3.3",
|
||||||
|
"prisma": "^6.9.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
premium/.gitignore
vendored
41
premium/.gitignore
vendored
@@ -1,41 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# GeoExport — production image (Next.js)
|
|
||||||
FROM node:22-alpine AS base
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/.next ./.next
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
EXPOSE 3000
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# GeoExport
|
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://nextjs.org)
|
|
||||||
[](https://www.typescriptlang.org)
|
|
||||||
[](docker-compose.yml)
|
|
||||||
[](docs/CONTRIBUTING.md)
|
|
||||||
|
|
||||||
Self-hosted platform to browse, export, and generate routing rules from **geoip** and **geosite** databases — built for VPN stacks: **Xray**, **3x-ui**, **V2Ray**, **Mihomo**, **Sing-box**.
|
|
||||||
|
|
||||||
GeoExport is an open-source, API-first alternative to hosted geo list tools: fast UI, privacy-friendly defaults, and a deployment model you control.
|
|
||||||
|
|
||||||
> **Status:** This repository is the **premium Next.js rewrite** (`geoexport-premium`). The UI scaffold is active; PostgreSQL, Prisma, Redis, and full API routes are on the roadmap. For a working static mirror today, see [andrey271192/Domain_web](https://github.com/andrey271192/Domain_web).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of contents
|
|
||||||
|
|
||||||
- [Why GeoExport](#why-geoexport)
|
|
||||||
- [Features](#features)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Screenshots](#screenshots)
|
|
||||||
- [Quick start](#quick-start)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Docker](#docker)
|
|
||||||
- [API](#api)
|
|
||||||
- [Deployment](#deployment)
|
|
||||||
- [Security](#security)
|
|
||||||
- [Performance](#performance)
|
|
||||||
- [Roadmap](#roadmap)
|
|
||||||
- [Contributing](#contributing)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why GeoExport
|
|
||||||
|
|
||||||
| | Hosted geo tools | GeoExport |
|
|
||||||
|---|------------------|-----------|
|
|
||||||
| **Data location** | Third-party SaaS | Your VPS / private cloud |
|
|
||||||
| **API access** | Opaque or limited | REST-first, documented |
|
|
||||||
| **UI** | Functional | Linear-grade layout, dark/light |
|
|
||||||
| **Stack** | Unknown | Next.js, TypeScript, Prisma, Redis |
|
|
||||||
| **Scale** | Shared tenancy | Horizontal app + cache layer |
|
|
||||||
|
|
||||||
GeoExport does not replace community rule repos (Loyalsoldier, RuNet Freedom, v2fly, etc.) — it **indexes** them, maps services to categories, and gives you export and routing helpers in one place.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Presets & sources
|
|
||||||
|
|
||||||
- **9 preset groups**, **59 curated services** (messengers, video, social, AI, games, work, CDN, RU-blocked lists, …)
|
|
||||||
- **4 rule sources**: RuNet Freedom, Loyalsoldier, DanielLavrushin (b4geoip), v2fly — with per-source `geoip.dat` / `geosite.dat` links
|
|
||||||
|
|
||||||
### Discovery & export
|
|
||||||
|
|
||||||
- Full-text **search** across geoip/geosite categories
|
|
||||||
- **DNS lookup**: resolve domains to IPs/subdomains and cross-check against loaded databases
|
|
||||||
- One-click **export** to `.txt` for client import
|
|
||||||
- **Routing generator** with saved routing presets (Xray / Mihomo / Sing-box oriented)
|
|
||||||
|
|
||||||
### Platform
|
|
||||||
|
|
||||||
- **Dark / light** theme
|
|
||||||
- **REST API** for automation (GraphQL optional on roadmap)
|
|
||||||
- **Self-hosted** via Docker or bare Node
|
|
||||||
- **Edge-ready** Next.js deployment (Vercel, Cloudflare, or nginx reverse proxy)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
High-level layout:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
||||||
│ Browser │────▶│ Next.js │────▶│ PostgreSQL │
|
|
||||||
│ (React) │ │ App Router │ │ (Prisma) │
|
|
||||||
└─────────────┘ │ + API routes│ └─────────────────┘
|
|
||||||
│ │────▶┌─────────────────┐
|
|
||||||
│ │ │ Redis (cache) │
|
|
||||||
└──────┬───────┘ └─────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ Geo workers │──▶ upstream .dat / GitHub releases
|
|
||||||
└──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
||||||
|
|
||||||
### Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|------------|
|
|
||||||
| Frontend | Next.js 16, React 19, TypeScript, Tailwind CSS 4, Framer Motion, shadcn/ui |
|
|
||||||
| API | Next.js Route Handlers (REST); GraphQL optional later |
|
|
||||||
| Data | PostgreSQL + Prisma |
|
|
||||||
| Cache | Redis |
|
|
||||||
| Ops | Docker, docker-compose, nginx (production) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
| Presets browser | DNS lookup | Routing generator |
|
|
||||||
|-----------------|------------|-------------------|
|
|
||||||
| _Coming soon — add `docs/images/presets.png`_ | _Coming soon_ | _Coming soon_ |
|
|
||||||
|
|
||||||
Place assets under `docs/images/` and reference them here before release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- **Node.js** 20+ (22 LTS recommended)
|
|
||||||
- **npm**, **pnpm**, or **bun**
|
|
||||||
|
|
||||||
### Local development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/andrey271192/geoexport.git
|
|
||||||
cd geoexport # or geoexport-premium while the repo is being renamed
|
|
||||||
|
|
||||||
cp .env.example .env
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000).
|
|
||||||
|
|
||||||
### Production build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lint
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and adjust:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string (Prisma) |
|
|
||||||
| `REDIS_URL` | Redis for API/cache layers |
|
|
||||||
| `NEXT_PUBLIC_APP_URL` | Public URL (CORS, auth callbacks) |
|
|
||||||
| `GEOEXPORT_UPSTREAM` | Upstream host for mirror/proxy mode during migration |
|
|
||||||
|
|
||||||
Full reference: [.env.example](.env.example)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
Run the full stack (app + PostgreSQL + Redis):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
| Service | Port (host) | Notes |
|
|
||||||
|---------|-------------|--------|
|
|
||||||
| `web` | 3000 | Next.js (`npm run start` in container) |
|
|
||||||
| `postgres` | 5432 | Persistent volume `postgres_data` |
|
|
||||||
| `redis` | 6379 | Cache / rate limit |
|
|
||||||
|
|
||||||
Health check: `curl -f http://localhost:3000`
|
|
||||||
|
|
||||||
> Prisma migrations and geo data seeding are not wired in Docker yet — track progress in [Roadmap](#roadmap).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
Base path: `/api`
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `GET` | `/api/presets` | Service preset groups |
|
|
||||||
| `GET` | `/api/sources` | Rule source metadata |
|
|
||||||
| `GET` | `/api/last-update` | Last database refresh timestamp |
|
|
||||||
| `GET` | `/api/routing/presets` | Saved routing templates |
|
|
||||||
| `POST` | `/api/routing/generate` | Generate routing config |
|
|
||||||
| `GET` | `/api/search?q=` | Search categories |
|
|
||||||
| `GET` | `/api/export?...` | Export list as plain text |
|
|
||||||
| `GET` | `/api/lookup?domain=` | DNS + geodb cross-check |
|
|
||||||
| `GET` | `/api/update/status` | Background update job status |
|
|
||||||
|
|
||||||
Contract details: [docs/API.md](docs/API.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
- **Docker Compose** — single VPS, see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
|
|
||||||
- **Vercel / edge** — frontend + serverless API routes (Redis/Postgres via managed providers)
|
|
||||||
- **nginx** — terminate TLS, proxy to `127.0.0.1:3000` (pattern used in [Domain_web](https://github.com/andrey271192/Domain_web))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- Run behind HTTPS in production
|
|
||||||
- Do not commit `.env` or API keys
|
|
||||||
- Rate-limit public `/api/*` when exposed to the internet
|
|
||||||
- Report issues: [docs/SECURITY.md](docs/SECURITY.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- Static preset JSON can be CDN-cached (`Cache-Control` on read-only catalog endpoints)
|
|
||||||
- Redis backs hot search/export paths
|
|
||||||
- Next.js App Router + edge caching for public pages
|
|
||||||
- Geo `.dat` parsing runs in workers — not on the request thread
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- [ ] Port Domain_web UI into Next.js App Router
|
|
||||||
- [ ] Prisma schema + PostgreSQL seed from geoexport.org catalogs
|
|
||||||
- [ ] Self-contained geo workers (no upstream proxy)
|
|
||||||
- [ ] API keys + rate limiting
|
|
||||||
- [ ] GraphQL read API (optional)
|
|
||||||
- [ ] Helm chart / Terraform module
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md). Marketing copy and launch templates: [docs/MARKETING.md](docs/MARKETING.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related projects
|
|
||||||
|
|
||||||
| Repo | Role |
|
|
||||||
|------|------|
|
|
||||||
| [andrey271192/Domain_web](https://github.com/andrey271192/Domain_web) | Production VPS mirror (nginx + Node static server) |
|
|
||||||
| [geoexport-clone](https://github.com/andrey271192/geoexport-clone) | Local dev mirror of geoexport.org UI |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE) — Copyright (c) 2026 GeoExport Contributors
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
# API reference
|
|
||||||
|
|
||||||
GeoExport exposes a **REST** API under `/api`. The contract matches [geoexport.org](https://geoexport.org) so existing clients and mirrors stay compatible.
|
|
||||||
|
|
||||||
**Base URL:** `https://your-instance.example.com`
|
|
||||||
**Content-Type:** `application/json` unless noted
|
|
||||||
**Errors:** JSON body `{ "error": "message" }` with appropriate HTTP status
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Catalog (cacheable)
|
|
||||||
|
|
||||||
### `GET /api/presets`
|
|
||||||
|
|
||||||
Returns preset groups with nested services and geoip/geosite category mappings.
|
|
||||||
|
|
||||||
**Response:** `200` — JSON array
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"group": "Мессенджеры",
|
|
||||||
"icon": "💬",
|
|
||||||
"slug": "messengers",
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"name": "Telegram",
|
|
||||||
"domain": "telegram.org",
|
|
||||||
"keywords": ["telegram"],
|
|
||||||
"categories": [
|
|
||||||
{
|
|
||||||
"full_name": "geosite:telegram",
|
|
||||||
"name": "telegram",
|
|
||||||
"type": "geosite",
|
|
||||||
"source_slug": "loyalsoldier",
|
|
||||||
"source_name": "Loyalsoldier"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /api/sources`
|
|
||||||
|
|
||||||
Rule database sources (download URLs, compatibility).
|
|
||||||
|
|
||||||
**Response:** `200` — JSON array
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"slug": "loyalsoldier",
|
|
||||||
"name": "Loyalsoldier",
|
|
||||||
"description": "…",
|
|
||||||
"compatible": "Xray, 3x-ui, V2Ray, Mihomo",
|
|
||||||
"geoip_url": "https://github.com/.../geoip.dat",
|
|
||||||
"geosite_url": "https://github.com/.../geosite.dat"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /api/last-update`
|
|
||||||
|
|
||||||
Timestamp of the last successful geo database refresh.
|
|
||||||
|
|
||||||
**Response:** `200`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"updated_at": "2026-05-24T12:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /api/routing/presets`
|
|
||||||
|
|
||||||
Saved routing templates for the generator UI.
|
|
||||||
|
|
||||||
**Response:** `200` — JSON array of preset objects (structure varies by client target).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Search & export
|
|
||||||
|
|
||||||
### `GET /api/search`
|
|
||||||
|
|
||||||
Search geoip/geosite categories.
|
|
||||||
|
|
||||||
**Query parameters**
|
|
||||||
|
|
||||||
| Param | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `q` | yes | Search string |
|
|
||||||
| `source` | no | Filter by `source_slug` |
|
|
||||||
| `type` | no | `geoip` or `geosite` |
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/search?q=telegram&source=loyalsoldier
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200` — JSON array of matching categories
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /api/export`
|
|
||||||
|
|
||||||
Export resolved entries as **plain text** (one category or domain per line depending on mode).
|
|
||||||
|
|
||||||
**Query parameters**
|
|
||||||
|
|
||||||
| Param | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `category` | conditional | Full name, e.g. `geosite:telegram` |
|
|
||||||
| `source` | no | Source slug |
|
|
||||||
| `format` | no | Output variant (client-specific) |
|
|
||||||
|
|
||||||
**Response:** `200` — `text/plain`
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s 'https://your-instance/api/export?category=geosite:telegram&source=loyalsoldier' -o telegram.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DNS lookup
|
|
||||||
|
|
||||||
### `GET /api/lookup`
|
|
||||||
|
|
||||||
Resolve a domain and cross-check against loaded geo databases.
|
|
||||||
|
|
||||||
**Query parameters**
|
|
||||||
|
|
||||||
| Param | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `domain` | yes | FQDN, e.g. `example.com` |
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/lookup?domain=anydesk.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200` — JSON with IPs, subdomains, and geodb hits (exact schema mirrors upstream).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routing generator
|
|
||||||
|
|
||||||
### `POST /api/routing/generate`
|
|
||||||
|
|
||||||
Generate a routing configuration snippet from selected rules and a template.
|
|
||||||
|
|
||||||
**Request body:** `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"preset_id": "xray-default",
|
|
||||||
"rules": ["geosite:telegram", "geoip:netflix"],
|
|
||||||
"outbound": "proxy",
|
|
||||||
"client": "xray"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200` — JSON or plain text snippet (depends on `client`)
|
|
||||||
|
|
||||||
**Errors:** `400` validation, `422` unsupported combination
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Operations
|
|
||||||
|
|
||||||
### `GET /api/update/status`
|
|
||||||
|
|
||||||
Background geo database refresh status (worker jobs).
|
|
||||||
|
|
||||||
**Response:** `200`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "idle",
|
|
||||||
"last_run": "2026-05-24T06:00:00Z",
|
|
||||||
"progress": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rate limiting (planned)
|
|
||||||
|
|
||||||
Public instances should enforce per-IP limits on:
|
|
||||||
|
|
||||||
- `/api/lookup`
|
|
||||||
- `/api/search`
|
|
||||||
- `/api/export`
|
|
||||||
|
|
||||||
Recommended defaults: **120 req/min** per IP (configurable via `GEOEXPORT_RATE_LIMIT_PER_MINUTE`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication (planned)
|
|
||||||
|
|
||||||
Optional header:
|
|
||||||
|
|
||||||
```http
|
|
||||||
Authorization: Bearer <GEOEXPORT_API_KEY>
|
|
||||||
```
|
|
||||||
|
|
||||||
Required only when `GEOEXPORT_API_KEY` is set server-side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Compatibility notes
|
|
||||||
|
|
||||||
| Endpoint | Local JSON (mirror) | Full backend |
|
|
||||||
|----------|---------------------|--------------|
|
|
||||||
| `/api/presets` | Served from `data/presets.json` | PostgreSQL or static cache |
|
|
||||||
| `/api/sources` | `data/sources.json` | Same |
|
|
||||||
| `/api/last-update` | `data/last-update.json` | Same |
|
|
||||||
| `/api/routing/presets` | `data/routing-presets.json` | Same |
|
|
||||||
| `/api/search`, `/api/export`, `/api/lookup`, `/api/routing/generate` | Proxied upstream | Native handlers |
|
|
||||||
|
|
||||||
Mirror behavior is implemented in [Domain_web `server.mjs`](https://github.com/andrey271192/Domain_web/blob/main/site/server.mjs).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GraphQL (roadmap)
|
|
||||||
|
|
||||||
A read-only GraphQL layer may expose `presets`, `sources`, and `search` for panel integrations. REST remains the stable contract.
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
GeoExport is a full-stack web application for browsing geoip/geosite rule catalogs, exporting plain-text lists, and generating client routing snippets. This document describes the **target** architecture for `geoexport-premium` and how it relates to the existing static mirror.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. **Self-hosted** — operators run their own instance; no mandatory third-party backend.
|
|
||||||
2. **API-first** — every UI action has a REST equivalent for scripts and panels.
|
|
||||||
3. **Fast catalog UX** — presets and sources are served from cache/DB, not parsed on each page view.
|
|
||||||
4. **Heavy work off the hot path** — `.dat` ingestion and DNS resolution run in workers or upstream services.
|
|
||||||
|
|
||||||
## System context
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph clients [Clients]
|
|
||||||
Browser[Web UI]
|
|
||||||
CLI[Scripts / panels]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph geoexport [GeoExport]
|
|
||||||
Next[Next.js App Router]
|
|
||||||
API[REST /api]
|
|
||||||
Worker[Geo workers]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph data [Data plane]
|
|
||||||
PG[(PostgreSQL)]
|
|
||||||
RD[(Redis)]
|
|
||||||
GH[GitHub rule releases]
|
|
||||||
end
|
|
||||||
|
|
||||||
Browser --> Next
|
|
||||||
CLI --> API
|
|
||||||
Next --> API
|
|
||||||
API --> PG
|
|
||||||
API --> RD
|
|
||||||
Worker --> PG
|
|
||||||
Worker --> GH
|
|
||||||
API --> Worker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layers
|
|
||||||
|
|
||||||
### Presentation (`src/app`, `src/components`)
|
|
||||||
|
|
||||||
- **Next.js App Router** — server components for catalog pages; client components for search, export, DNS lookup, routing builder.
|
|
||||||
- **Tailwind CSS 4** + **shadcn/ui** — design system aligned with Linear/Vercel density.
|
|
||||||
- **Framer Motion** — transitions for panels, modals, and theme toggle.
|
|
||||||
|
|
||||||
### API (`src/app/api` — planned)
|
|
||||||
|
|
||||||
Route handlers mirror the public contract documented in [API.md](./API.md):
|
|
||||||
|
|
||||||
| Concern | Implementation |
|
|
||||||
|---------|----------------|
|
|
||||||
| Catalog | Read presets/sources from PostgreSQL or baked JSON during bootstrap |
|
|
||||||
| Search | Redis-backed index or PostgreSQL full-text |
|
|
||||||
| Export | Stream plain text from resolved category lists |
|
|
||||||
| Lookup | DNS resolver + geodb membership check |
|
|
||||||
| Routing | Template engine for Xray / Mihomo / Sing-box snippets |
|
|
||||||
|
|
||||||
During migration, some routes may **proxy** to `GEOEXPORT_UPSTREAM` (default `https://geoexport.org`), matching behavior in [Domain_web](https://github.com/andrey271192/Domain_web) `server.mjs`.
|
|
||||||
|
|
||||||
### Data (`prisma/` — planned)
|
|
||||||
|
|
||||||
| Model area | Purpose |
|
|
||||||
|------------|---------|
|
|
||||||
| `Source` | Rule repo metadata (slug, URLs, compatibility) |
|
|
||||||
| `PresetGroup` / `Service` | Curated UI presets |
|
|
||||||
| `Category` | geoip:/geosite: entries per source |
|
|
||||||
| `RoutingPreset` | Saved routing templates |
|
|
||||||
| `UpdateJob` | Refresh status for `.dat` pulls |
|
|
||||||
|
|
||||||
### Cache (`redis`)
|
|
||||||
|
|
||||||
- Catalog responses (`presets`, `sources`, `last-update`)
|
|
||||||
- Search result pages
|
|
||||||
- Rate limiting counters for public API
|
|
||||||
|
|
||||||
### Workers (planned)
|
|
||||||
|
|
||||||
Background jobs:
|
|
||||||
|
|
||||||
1. Download `geoip.dat` / `geosite.dat` from configured GitHub releases.
|
|
||||||
2. Parse and upsert category index into PostgreSQL.
|
|
||||||
3. Expose progress via `GET /api/update/status`.
|
|
||||||
|
|
||||||
## Deployment topologies
|
|
||||||
|
|
||||||
### A — Single VPS (Docker Compose)
|
|
||||||
|
|
||||||
`web` + `postgres` + `redis` on one host. nginx terminates TLS and proxies to port 3000.
|
|
||||||
|
|
||||||
### B — Split managed
|
|
||||||
|
|
||||||
- Next.js on Vercel / Cloudflare Pages
|
|
||||||
- Neon / RDS for PostgreSQL
|
|
||||||
- Upstash for Redis
|
|
||||||
|
|
||||||
### C — Mirror-only (legacy)
|
|
||||||
|
|
||||||
[Domain_web](https://github.com/andrey271192/Domain_web): static SPA + Node proxy on port 4173. No PostgreSQL. Suitable until the Next.js port is feature-complete.
|
|
||||||
|
|
||||||
## Repository layout (target)
|
|
||||||
|
|
||||||
```
|
|
||||||
geoexport-premium/
|
|
||||||
├── src/
|
|
||||||
│ ├── app/ # routes, layouts, API handlers
|
|
||||||
│ ├── components/ # UI primitives (shadcn)
|
|
||||||
│ └── lib/ # db, redis, geo parsers
|
|
||||||
├── prisma/
|
|
||||||
│ └── schema.prisma
|
|
||||||
├── public/
|
|
||||||
├── docs/
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
└── .env.example
|
|
||||||
```
|
|
||||||
|
|
||||||
## Compatibility matrix
|
|
||||||
|
|
||||||
| Client | Import format | Notes |
|
|
||||||
|--------|---------------|-------|
|
|
||||||
| Xray | geoip.dat, geosite.dat, routing rules | Primary target |
|
|
||||||
| 3x-ui | Same as Xray | Panel import |
|
|
||||||
| V2Ray | `.dat` lists | v2fly source |
|
|
||||||
| Mihomo | YAML routing | Generator output |
|
|
||||||
| Sing-box | JSON / rule-set | Generator output |
|
|
||||||
|
|
||||||
## Security boundaries
|
|
||||||
|
|
||||||
- Public read APIs are unauthenticated by default; operators should place GeoExport behind VPN or enable API keys (roadmap).
|
|
||||||
- DNS lookup must be rate-limited to prevent abuse as an open resolver.
|
|
||||||
- Upstream proxy mode forwards only whitelisted `/api/*` paths — never arbitrary SSRF targets.
|
|
||||||
|
|
||||||
## Evolution from Domain_web
|
|
||||||
|
|
||||||
| Domain_web | geoexport-premium |
|
|
||||||
|------------|-------------------|
|
|
||||||
| Static `index.html` + `server.mjs` | Next.js SSR/ISR |
|
|
||||||
| JSON files in `data/` | PostgreSQL + optional JSON seed |
|
|
||||||
| Proxied dynamic API | Native handlers + workers |
|
|
||||||
| systemd + nginx install script | Docker Compose + generic reverse proxy |
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Contributing to GeoExport
|
|
||||||
|
|
||||||
Thanks for helping improve GeoExport. This project aims for production-grade quality — clear PRs, tested behavior, and docs that match the code.
|
|
||||||
|
|
||||||
## Ways to contribute
|
|
||||||
|
|
||||||
- Report bugs and gaps in [GitHub Issues](https://github.com/andrey271192/geoexport/issues)
|
|
||||||
- Improve docs (README, `docs/`, API examples)
|
|
||||||
- Port UI features from [Domain_web](https://github.com/andrey271192/Domain_web) into Next.js
|
|
||||||
- Implement API routes and Prisma models
|
|
||||||
- Add tests for parsers, export formatters, and routing generator
|
|
||||||
|
|
||||||
## Development setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/andrey271192/geoexport.git
|
|
||||||
cd geoexport
|
|
||||||
cp .env.example .env
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open http://localhost:3000
|
|
||||||
|
|
||||||
### With Docker data services only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
# Run app on host:
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Branching
|
|
||||||
|
|
||||||
- `main` — stable, deployable
|
|
||||||
- `feat/*` — features
|
|
||||||
- `fix/*` — bug fixes
|
|
||||||
- `docs/*` — documentation only
|
|
||||||
|
|
||||||
## Pull request checklist
|
|
||||||
|
|
||||||
- [ ] `npm run lint` passes
|
|
||||||
- [ ] `npm run build` succeeds
|
|
||||||
- [ ] User-facing changes documented in README or `docs/`
|
|
||||||
- [ ] No secrets, `.env`, or large binary blobs committed
|
|
||||||
- [ ] PR description explains **why**, not only **what**
|
|
||||||
|
|
||||||
## Code style
|
|
||||||
|
|
||||||
- **TypeScript** strict mode
|
|
||||||
- **React** — functional components, hooks
|
|
||||||
- **Tailwind** — utility-first; use shadcn patterns for forms/dialogs
|
|
||||||
- **API routes** — validate input, return consistent JSON errors
|
|
||||||
- Keep diffs focused; avoid drive-by refactors
|
|
||||||
|
|
||||||
## Commit messages
|
|
||||||
|
|
||||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(api): add lookup rate limiter
|
|
||||||
fix(export): handle empty category
|
|
||||||
docs: update Docker deployment steps
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
Do not open public issues for vulnerabilities. See [SECURITY.md](./SECURITY.md).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your contributions are licensed under the [MIT License](../LICENSE).
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# Deployment
|
|
||||||
|
|
||||||
This guide covers deploying **GeoExport** (`geoexport-premium`) on a VPS or container platform. For the **static mirror** that is production-ready today, use [andrey271192/Domain_web](https://github.com/andrey271192/Domain_web).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
| Resource | Minimum | Recommended |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| CPU | 1 vCPU | 2 vCPU |
|
|
||||||
| RAM | 1 GB | 2 GB+ |
|
|
||||||
| Disk | 10 GB | 20 GB+ (geo databases grow) |
|
|
||||||
| OS | Linux (Debian/Ubuntu/Alpine) | Ubuntu 22.04 LTS |
|
|
||||||
|
|
||||||
Software:
|
|
||||||
|
|
||||||
- Docker 24+ and Docker Compose v2, **or**
|
|
||||||
- Node.js 20+ with PostgreSQL 16 and Redis 7
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 1 — Docker Compose (recommended)
|
|
||||||
|
|
||||||
### 1. Clone and configure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/andrey271192/geoexport.git geoexport
|
|
||||||
cd geoexport
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit DATABASE_URL / secrets if not using compose defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start stack
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Services:
|
|
||||||
|
|
||||||
| Name | Image | Host port |
|
|
||||||
|------|-------|-----------|
|
|
||||||
| `web` | Built from `Dockerfile` | `3000` |
|
|
||||||
| `postgres` | `postgres:16-alpine` | `5432` |
|
|
||||||
| `redis` | `redis:7-alpine` | `6379` |
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -f http://localhost:3000
|
|
||||||
docker compose logs -f web
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Reverse proxy (nginx)
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name geo.example.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add TLS with Certbot or Caddy. Set `NEXT_PUBLIC_APP_URL=https://geo.example.com` in `.env` and restart `web`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 2 — Bare Node.js
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/andrey271192/geoexport.git geoexport
|
|
||||||
cd geoexport
|
|
||||||
cp .env.example .env
|
|
||||||
# Point DATABASE_URL and REDIS_URL to your instances
|
|
||||||
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
NODE_ENV=production npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
Use **pm2** or **systemd** for process supervision:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# /etc/systemd/system/geoexport.service
|
|
||||||
[Unit]
|
|
||||||
Description=GeoExport Next.js
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=geoexport
|
|
||||||
WorkingDirectory=/opt/geoexport
|
|
||||||
EnvironmentFile=/opt/geoexport/.env
|
|
||||||
ExecStart=/usr/bin/npm run start
|
|
||||||
Restart=on-failure
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 3 — Domain_web mirror (available now)
|
|
||||||
|
|
||||||
One-line install on a fresh VPS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
- Site files: `/opt/domain_web/site/`
|
|
||||||
- Node service: `geoexport-site` on `127.0.0.1:4173`
|
|
||||||
- nginx on port 80
|
|
||||||
|
|
||||||
Update cached JSON:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/domain_web/site
|
|
||||||
curl -fsSL https://geoexport.org/api/presets -o data/presets.json
|
|
||||||
curl -fsSL https://geoexport.org/api/sources -o data/sources.json
|
|
||||||
curl -fsSL https://geoexport.org/api/last-update -o data/last-update.json
|
|
||||||
curl -fsSL https://geoexport.org/api/routing/presets -o data/routing-presets.json
|
|
||||||
sudo systemctl restart geoexport-site
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
See [.env.example](../.env.example). Critical production values:
|
|
||||||
|
|
||||||
| Variable | Notes |
|
|
||||||
|----------|-------|
|
|
||||||
| `NEXT_PUBLIC_APP_URL` | Canonical public URL |
|
|
||||||
| `DATABASE_URL` | Required when Prisma is enabled |
|
|
||||||
| `REDIS_URL` | Required for cache/rate limits |
|
|
||||||
| `GEOEXPORT_UPSTREAM` | Set only for hybrid/mirror mode |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database migrations (roadmap)
|
|
||||||
|
|
||||||
When Prisma lands in this repo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate deploy
|
|
||||||
npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
Until then, Docker Postgres is provisioned but unused by the app.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
**Docker:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
**Domain_web:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/domain_web && sudo git pull && sudo systemctl restart geoexport-site
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Health checks
|
|
||||||
|
|
||||||
| Check | Command |
|
|
||||||
|-------|---------|
|
|
||||||
| HTTP | `curl -sf http://localhost:3000` |
|
|
||||||
| Postgres | `docker compose exec postgres pg_isready -U geoexport` |
|
|
||||||
| Redis | `docker compose exec redis redis-cli ping` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Likely cause | Fix |
|
|
||||||
|---------|--------------|-----|
|
|
||||||
| Export/search 502 | Upstream unreachable | Check `GEOEXPORT_UPSTREAM`, outbound HTTPS |
|
|
||||||
| Empty presets | DB not seeded | Use cached JSON or run seed (roadmap) |
|
|
||||||
| Docker build fails | Missing lockfile | `npm install` locally, commit `package-lock.json` |
|
|
||||||
| Port 3000 in use | Conflict | Set `PORT=3001` in `.env` and compose |
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
# Marketing & launch copy
|
|
||||||
|
|
||||||
Repository: **GeoExport** (`geoexport`) — open-source geoip/geosite export platform.
|
|
||||||
English README: [../README.md](../README.md). Russian snippets below where noted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GitHub short description (≤350 chars)
|
|
||||||
|
|
||||||
```
|
|
||||||
Self-hosted geoip/geosite export & routing platform for Xray, 3x-ui, V2Ray, Mihomo, Sing-box. 59 service presets, DNS lookup, routing generator, REST API. Next.js, PostgreSQL, Redis, Docker. Privacy-friendly alternative to hosted geo list tools.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GitHub About section
|
|
||||||
|
|
||||||
**Website:** `https://github.com/andrey271192/geoexport` (or your docs URL when live)
|
|
||||||
|
|
||||||
**Description:** (same as short description above)
|
|
||||||
|
|
||||||
**Topics:** see [Topics list](#github-topics-15-20)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GitHub Topics (15–20)
|
|
||||||
|
|
||||||
```
|
|
||||||
geoip
|
|
||||||
geosite
|
|
||||||
v2ray
|
|
||||||
xray
|
|
||||||
sing-box
|
|
||||||
mihomo
|
|
||||||
3x-ui
|
|
||||||
vpn
|
|
||||||
routing
|
|
||||||
proxy
|
|
||||||
self-hosted
|
|
||||||
nextjs
|
|
||||||
typescript
|
|
||||||
docker
|
|
||||||
postgresql
|
|
||||||
open-source
|
|
||||||
privacy
|
|
||||||
networking
|
|
||||||
devtools
|
|
||||||
russia-vpn
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository structure tree
|
|
||||||
|
|
||||||
```
|
|
||||||
geoexport/
|
|
||||||
├── src/
|
|
||||||
│ ├── app/ # Next.js App Router (pages + API)
|
|
||||||
│ │ ├── layout.tsx
|
|
||||||
│ │ ├── page.tsx
|
|
||||||
│ │ └── api/ # REST handlers (planned)
|
|
||||||
│ ├── components/ # UI (shadcn/ui) — planned
|
|
||||||
│ └── lib/ # db, redis, geo utils — planned
|
|
||||||
├── prisma/ # schema & migrations — planned
|
|
||||||
├── public/ # static assets
|
|
||||||
├── docs/
|
|
||||||
│ ├── ARCHITECTURE.md
|
|
||||||
│ ├── API.md
|
|
||||||
│ ├── DEPLOYMENT.md
|
|
||||||
│ ├── CONTRIBUTING.md
|
|
||||||
│ ├── SECURITY.md
|
|
||||||
│ └── MARKETING.md # this file
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .env.example
|
|
||||||
├── LICENSE
|
|
||||||
├── package.json
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SEO meta description (~155 chars)
|
|
||||||
|
|
||||||
```
|
|
||||||
GeoExport — self-hosted geoip & geosite lists for Xray, V2Ray, Mihomo. Export .txt, DNS lookup, routing rules. Open source, Docker, REST API.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Product Hunt tagline & blurb
|
|
||||||
|
|
||||||
**Tagline:** Self-hosted geo lists for your VPN stack
|
|
||||||
|
|
||||||
**Description:**
|
|
||||||
|
|
||||||
GeoExport helps you browse, search, and export geoip/geosite categories from Loyalsoldier, RuNet Freedom, v2fly, and b4geoip — without relying on a closed SaaS.
|
|
||||||
|
|
||||||
- 59 service presets across 9 groups
|
|
||||||
- DNS lookup with geodb cross-check
|
|
||||||
- Routing snippet generator for Xray / Mihomo / Sing-box
|
|
||||||
- REST API + Docker deploy
|
|
||||||
|
|
||||||
Open source. Run it on your VPS in minutes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Telegram post (RU)
|
|
||||||
|
|
||||||
```
|
|
||||||
🌍 GeoExport — open-source замена geoexport.org на своём сервере
|
|
||||||
|
|
||||||
Экспорт geoip/geosite для Xray, 3x-ui, V2Ray, Mihomo, Sing-box:
|
|
||||||
• 9 групп пресетов, 59 сервисов
|
|
||||||
• поиск по категориям
|
|
||||||
• DNS lookup + проверка в базах
|
|
||||||
• генератор routing-правил
|
|
||||||
• экспорт .txt
|
|
||||||
• REST API + Docker
|
|
||||||
|
|
||||||
Стек: Next.js, PostgreSQL, Redis.
|
|
||||||
Код: github.com/andrey271192/geoexport
|
|
||||||
|
|
||||||
Звезда на GitHub = поддержка проекта ⭐
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Twitter / X launch
|
|
||||||
|
|
||||||
```
|
|
||||||
We shipped GeoExport — self-hosted geoip/geosite export for Xray, V2Ray, Mihomo, Sing-box.
|
|
||||||
|
|
||||||
59 presets · DNS lookup · routing generator · REST API · Docker
|
|
||||||
|
|
||||||
Open source. Your VPS. Your data.
|
|
||||||
|
|
||||||
⭐ github.com/andrey271192/geoexport
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hacker News — Show HN
|
|
||||||
|
|
||||||
**Title:** Show HN: GeoExport – self-hosted geoip/geosite export for Xray/V2Ray/Mihomo
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
|
|
||||||
I built GeoExport, an open-source platform to browse and export geoip/geosite rule lists used by VPN clients (Xray, 3x-ui, V2Ray, Mihomo, Sing-box).
|
|
||||||
|
|
||||||
It indexes categories from Loyalsoldier, RuNet Freedom, v2fly, and DanielLavrushin b4geoip, with 59 curated service presets (Telegram, YouTube, Steam, RU-blocked lists, etc.).
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Search and export plain-text lists
|
|
||||||
- DNS lookup with geodb cross-check
|
|
||||||
- Routing config generator
|
|
||||||
- REST API
|
|
||||||
- Docker Compose deploy (Next.js + Postgres + Redis)
|
|
||||||
|
|
||||||
Motivation: operators often depend on hosted geo tools with no self-host path. GeoExport is API-first and designed to run on a cheap VPS.
|
|
||||||
|
|
||||||
Repo: https://github.com/andrey271192/geoexport
|
|
||||||
|
|
||||||
A static mirror installer already exists (Domain_web); this repo is the full Next.js rewrite.
|
|
||||||
|
|
||||||
Feedback on API shape and self-hosted geo DB ingestion is especially welcome.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dev.to post outline
|
|
||||||
|
|
||||||
**Title:** Self-hosting geoip/geosite lists with GeoExport
|
|
||||||
|
|
||||||
1. **Problem** — VPN panels need fresh geoip/geosite data; hosted tools are convenient but opaque.
|
|
||||||
2. **What GeoExport does** — catalog UI, export, lookup, routing generator.
|
|
||||||
3. **Architecture sketch** — Next.js, Postgres, Redis, workers (diagram from ARCHITECTURE.md).
|
|
||||||
4. **Quick start** — `docker compose up`, open localhost:3000.
|
|
||||||
5. **API examples** — `curl` for presets, search, export.
|
|
||||||
6. **Comparison** — mirror mode (Domain_web) vs full stack.
|
|
||||||
7. **Roadmap** — Prisma, native workers, API keys.
|
|
||||||
8. **CTA** — star the repo, contribute via CONTRIBUTING.md.
|
|
||||||
|
|
||||||
Tags: `#opensource` `#vpn` `#nextjs` `#docker` `#devtools`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested repo naming
|
|
||||||
|
|
||||||
| Use case | Name |
|
|
||||||
|----------|------|
|
|
||||||
| Product | **GeoExport** |
|
|
||||||
| Premium fork folder | `geoexport-premium` |
|
|
||||||
| GitHub repo (recommended) | `geoexport` |
|
|
||||||
| Legacy mirror | `Domain_web` (keep for install scripts) |
|
|
||||||
|
|
||||||
Rename path when publishing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example: push premium tree to new repo
|
|
||||||
git remote add origin git@github.com:andrey271192/geoexport.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
Or continue on `andrey271192/Domain_web` by merging `geoexport-premium` into `site/` when the Next port is ready.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## One-line pitch (EN)
|
|
||||||
|
|
||||||
**GeoExport:** run your own geoip/geosite export API and UI for Xray-class clients — open source, Docker-ready.
|
|
||||||
|
|
||||||
## One-line pitch (RU)
|
|
||||||
|
|
||||||
**GeoExport:** свой сервер для экспорта geoip/geosite и routing-правил под Xray и Mihomo — open source, Docker.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Security policy
|
|
||||||
|
|
||||||
## Supported versions
|
|
||||||
|
|
||||||
| Version | Supported |
|
|
||||||
|---------|-----------|
|
|
||||||
| `main` (latest) | Yes |
|
|
||||||
| Older tags | Best effort |
|
|
||||||
|
|
||||||
## Reporting a vulnerability
|
|
||||||
|
|
||||||
**Please do not** file public GitHub issues for security problems.
|
|
||||||
|
|
||||||
1. Email or DM the maintainer with a description and reproduction steps.
|
|
||||||
2. Allow up to **90 days** for a fix before public disclosure.
|
|
||||||
3. We will acknowledge receipt within **72 hours** when possible.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- Affected endpoints or components
|
|
||||||
- Impact (data leak, SSRF, RCE, etc.)
|
|
||||||
- Proof of concept if available
|
|
||||||
- Suggested fix (optional)
|
|
||||||
|
|
||||||
## Threat model (self-hosted)
|
|
||||||
|
|
||||||
GeoExport is typically deployed on a private VPS or internal network. Common risks:
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|------|------------|
|
|
||||||
| Open DNS lookup abuse | Rate-limit `/api/lookup`; block private IP ranges in resolver |
|
|
||||||
| SSRF via upstream proxy | Whitelist paths; fixed `GEOEXPORT_UPSTREAM` host only |
|
|
||||||
| API scraping / DoS | Redis rate limits; nginx `limit_req` |
|
|
||||||
| Leaked `.env` | Never commit secrets; rotate DB passwords on deploy |
|
|
||||||
| Outdated geo databases | Monitor `/api/last-update`; automate refresh jobs |
|
|
||||||
|
|
||||||
## Hardening checklist (production)
|
|
||||||
|
|
||||||
- [ ] HTTPS only (TLS 1.2+)
|
|
||||||
- [ ] `NEXT_PUBLIC_APP_URL` matches real hostname
|
|
||||||
- [ ] Strong PostgreSQL password (not compose defaults)
|
|
||||||
- [ ] Redis bound to localhost or private network
|
|
||||||
- [ ] Firewall: expose only 80/443
|
|
||||||
- [ ] Disable directory listing on nginx
|
|
||||||
- [ ] Keep Node.js and base images patched (`docker compose pull`)
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Run `npm audit` before releases
|
|
||||||
- Pin Docker image digests in production compose overrides
|
|
||||||
- Subscribe to GitHub security advisories for this repo
|
|
||||||
|
|
||||||
## Data privacy
|
|
||||||
|
|
||||||
GeoExport processes **domains and IPs** users submit for lookup. Operators should:
|
|
||||||
|
|
||||||
- Document retention (if logging lookups)
|
|
||||||
- Avoid shipping lookup logs to third parties without consent
|
|
||||||
- Prefer self-hosted rule databases over permanent upstream proxy when feasible
|
|
||||||
|
|
||||||
## Safe defaults
|
|
||||||
|
|
||||||
- No default API keys in repository
|
|
||||||
- Example compose passwords are for **local dev only** — change before internet exposure
|
|
||||||
- CSP headers on Next.js responses (planned)
|
|
||||||
|
|
||||||
## Recognition
|
|
||||||
|
|
||||||
We credit reporters in release notes when they agree to be named.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
import nextTs from "eslint-config-next/typescript";
|
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "geoexport-premium",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "eslint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"next": "16.2.6",
|
|
||||||
"react": "19.2.4",
|
|
||||||
"react-dom": "19.2.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "16.2.6",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,26 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Next App",
|
|
||||||
description: "Generated by create next app",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html
|
|
||||||
lang="en"
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
||||||
>
|
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
83
prisma/schema.prisma
Normal file
83
prisma/schema.prisma
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScanStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MonitorType {
|
||||||
|
DNS
|
||||||
|
SSL
|
||||||
|
UPTIME
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
passwordHash String
|
||||||
|
role Role @default(USER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
scans Scan[]
|
||||||
|
monitors Monitor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Scan {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
domain String
|
||||||
|
status ScanStatus @default(PENDING)
|
||||||
|
progress Int @default(0)
|
||||||
|
result Json?
|
||||||
|
error String?
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
ip String?
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([domain])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("scans")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Monitor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
domain String
|
||||||
|
type MonitorType
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
lastChecked DateTime?
|
||||||
|
lastResult Json?
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("monitors")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiRateLimit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
count Int @default(0)
|
||||||
|
windowEnd DateTime
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("api_rate_limits")
|
||||||
|
}
|
||||||
2
site/.gitignore
vendored
2
site/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# GeoExport — локальная копия
|
|
||||||
|
|
||||||
Зеркало интерфейса [geoexport.org](https://geoexport.org/): экспорт списков geoip/geosite для VPN-клиентов (Xray, 3x-ui, V2Ray).
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/geoexport-clone
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Откройте в браузере: **http://127.0.0.1:4173**
|
|
||||||
|
|
||||||
Другой порт: `PORT=8080 npm start`
|
|
||||||
|
|
||||||
## Состав
|
|
||||||
|
|
||||||
| Путь | Назначение |
|
|
||||||
|------|------------|
|
|
||||||
| `index.html` | Точка входа SPA |
|
|
||||||
| `assets/` | Собранный фронтенд (React + Tailwind) с оригинала |
|
|
||||||
| `data/*.json` | Кэш API: пресеты, источники, дата обновления |
|
|
||||||
| `server.mjs` | Локальный сервер: статика + JSON + прокси динамических API |
|
|
||||||
|
|
||||||
## Обновление данных
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sL https://geoexport.org/api/presets -o data/presets.json
|
|
||||||
curl -sL https://geoexport.org/api/sources -o data/sources.json
|
|
||||||
curl -sL https://geoexport.org/api/last-update -o data/last-update.json
|
|
||||||
curl -sL https://geoexport.org/api/routing/presets -o data/routing-presets.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Экспорт, поиск, DNS lookup и генерация routing проксируются на живой `geoexport.org` (нужен интернет).
|
|
||||||
- Это копия UI и кэшированных справочников, не полный автономный бэкенд с базами `.dat`.
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"last_update":"2026-05-24","updating":false,"sources":[{"source_slug":"runetfreedom","source_name":"RuNet Freedom","last_update":"2026-05-24","compatible":"Xray, 3x-ui, V2Ray"},{"source_slug":"loyalsoldier","source_name":"Loyalsoldier","last_update":"2026-05-24","compatible":"Xray, 3x-ui, V2Ray, Mihomo"},{"source_slug":"daniellavrushin","source_name":"DanielLavrushin (b4geoip)","last_update":"2026-05-24","compatible":"Xray, 3x-ui"},{"source_slug":"v2fly","source_name":"v2fly (официальный)","last_update":"2026-05-24","compatible":"Xray, 3x-ui, V2Ray, Sing-box"}]}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"presets":{"direct_sites":[{"id":"ru_sites","label":"Российские сайты","description":"Домены .ru, .рф и российские сервисы — идут напрямую без VPN","value":"geosite:category-ru","checked":true},{"id":"ru_whitelist","label":"Белый список РФ","description":"Дополнительный список российских сервисов и госсайтов","value":"geosite:whitelist","checked":true},{"id":"private","label":"Локальная сеть","description":"192.168.x.x, 10.x.x.x и другие приватные адреса — всегда напрямую","value":"geosite:private","checked":true},{"id":"apple","label":"Apple","description":"iCloud, App Store, Apple Maps — для корректной работы устройства","value":"geosite:apple","checked":true},{"id":"microsoft","label":"Microsoft","description":"Windows Update, Office, Teams — экономия трафика VPN","value":"geosite:microsoft","checked":false},{"id":"steam","label":"Steam","description":"Игры и обновления Steam — напрямую для скорости","value":"geosite:steam","checked":false},{"id":"google_cn","label":"Google (доступные сервисы)","description":"Сервисы Google доступные в РФ без VPN","value":"geosite:google@cn","checked":false}],"direct_ips":[{"id":"geoip_ru","label":"IP-адреса России","description":"Все российские IP-диапазоны — напрямую","value":"geoip:ru","checked":true},{"id":"geoip_private","label":"Приватные IP","description":"10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16","value":"geoip:private","checked":true}],"proxy_sites":[{"id":"youtube","label":"YouTube","description":"YouTube заблокирован в РФ — направляем через VPN","value":"geosite:youtube","checked":true},{"id":"telegram","label":"Telegram","description":"Серверы Telegram — через VPN для надёжности","value":"geosite:telegram","checked":true},{"id":"meta","label":"Meta (Instagram, Facebook)","description":"Instagram и Facebook заблокированы в РФ","value":"geosite:facebook","checked":true},{"id":"twitter","label":"Twitter / X","description":"Twitter заблокирован в РФ","value":"geosite:twitter","checked":true},{"id":"netflix","label":"Netflix","description":"Netflix недоступен в РФ","value":"geosite:netflix","checked":false},{"id":"spotify","label":"Spotify","description":"Spotify недоступен в РФ","value":"geosite:spotify","checked":false},{"id":"github","label":"GitHub","description":"GitHub периодически блокируется в РФ","value":"geosite:github","checked":false},{"id":"geolocation_notcn","label":"Все заблокированные (универсальный)","description":"Весь иностранный трафик не из РФ/CN — через VPN. Осторожно: большой список","value":"geosite:geolocation-!cn","checked":false}],"proxy_ips":[{"id":"geoip_telegram","label":"IP Telegram","description":"IP-диапазоны серверов Telegram","value":"geoip:telegram","checked":true}],"block_sites":[{"id":"ads","label":"Реклама","description":"Рекламные домены — блокируем полностью","value":"geosite:category-ads-all","checked":true}],"block_ips":[]},"geo_sources":{"loyalsoldier":{"label":"Loyalsoldier (рекомендуется)","geoip":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat","geosite":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"},"runetfreedom":{"label":"RuNet Freedom (РКН + Россия)","geoip":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat","geosite":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat"},"daniellavrushin":{"label":"DanielLavrushin (расширенная)","geoip":"https://github.com/DanielLavrushin/b4geoip/releases/latest/download/geoip.dat","geosite":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"},"v2fly":{"label":"v2fly (оригинальная)","geoip":"https://github.com/v2fly/geoip/releases/latest/download/geoip.dat","geosite":"https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"}},"route_orders":[{"value":"block-direct-proxy","label":"Блок → Прямой → VPN (рекомендуется)"},{"value":"block-proxy-direct","label":"Блок → VPN → Прямой"},{"value":"proxy-direct-block","label":"VPN → Прямой → Блок"}],"dns_types":[{"value":"DoH","label":"DoH (DNS over HTTPS, рекомендуется)"},{"value":"DoU","label":"DoU (DNS over UDP, быстрее)"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[{"slug":"runetfreedom","name":"RuNet Freedom","description":"Российские правила блокировок, оптимизировано для обхода РКН","compatible":"Xray, 3x-ui, V2Ray","geoip_url":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat","geosite_url":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat"},{"slug":"loyalsoldier","name":"Loyalsoldier","description":"Популярный набор правил, стандарт для большинства клиентов","compatible":"Xray, 3x-ui, V2Ray, Mihomo","geoip_url":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat","geosite_url":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"},{"slug":"daniellavrushin","name":"DanielLavrushin (b4geoip)","description":"Расширенная база с дополнительными категориями (Akamai, CDN и др.)","compatible":"Xray, 3x-ui","geoip_url":"https://github.com/DanielLavrushin/b4geoip/releases/latest/download/geoip.dat","geosite_url":null},{"slug":"v2fly","name":"v2fly (официальный)","description":"Официальные базы проекта v2fly/domain-list-community","compatible":"Xray, 3x-ui, V2Ray, Sing-box","geoip_url":"https://github.com/v2fly/geoip/releases/latest/download/geoip.dat","geosite_url":"https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"}]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>GeoExport</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-VQj200iM.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-uS40y9LP.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "geoexport-clone",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Local mirror of https://geoexport.org/",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.mjs",
|
|
||||||
"dev": "node server.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
146
site/server.mjs
146
site/server.mjs
@@ -1,146 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Local GeoExport mirror: static assets + cached JSON + proxy for dynamic API.
|
|
||||||
*/
|
|
||||||
import http from "node:http";
|
|
||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const PORT = Number(process.env.PORT) || 4173;
|
|
||||||
const UPSTREAM = "geoexport.org";
|
|
||||||
|
|
||||||
const LOCAL_API = new Set([
|
|
||||||
"/api/presets",
|
|
||||||
"/api/sources",
|
|
||||||
"/api/last-update",
|
|
||||||
"/api/routing/presets",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const DATA_MAP = {
|
|
||||||
"/api/presets": "presets.json",
|
|
||||||
"/api/sources": "sources.json",
|
|
||||||
"/api/last-update": "last-update.json",
|
|
||||||
"/api/routing/presets": "routing-presets.json",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIME = {
|
|
||||||
".html": "text/html; charset=utf-8",
|
|
||||||
".js": "text/javascript; charset=utf-8",
|
|
||||||
".css": "text/css; charset=utf-8",
|
|
||||||
".json": "application/json; charset=utf-8",
|
|
||||||
".ico": "image/x-icon",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".png": "image/png",
|
|
||||||
};
|
|
||||||
|
|
||||||
function send(res, status, body, headers = {}) {
|
|
||||||
res.writeHead(status, headers);
|
|
||||||
res.end(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serveFile(res, filePath) {
|
|
||||||
const ext = path.extname(filePath);
|
|
||||||
const type = MIME[ext] || "application/octet-stream";
|
|
||||||
fs.readFile(filePath, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
send(res, 404, "Not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
send(res, 200, data, { "Content-Type": type });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function serveLocalApi(res, pathname) {
|
|
||||||
const file = DATA_MAP[pathname];
|
|
||||||
if (!file) {
|
|
||||||
send(res, 404, JSON.stringify({ error: "unknown local api" }), {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const full = path.join(__dirname, "data", file);
|
|
||||||
fs.readFile(full, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
send(res, 500, JSON.stringify({ error: err.message }), {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
send(res, 200, data, {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function proxyToUpstream(req, res) {
|
|
||||||
const options = {
|
|
||||||
hostname: UPSTREAM,
|
|
||||||
port: 443,
|
|
||||||
path: req.url,
|
|
||||||
method: req.method,
|
|
||||||
headers: {
|
|
||||||
...req.headers,
|
|
||||||
host: UPSTREAM,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const proxyReq = https.request(options, (proxyRes) => {
|
|
||||||
const headers = { ...proxyRes.headers };
|
|
||||||
delete headers["content-security-policy"];
|
|
||||||
res.writeHead(proxyRes.statusCode || 502, headers);
|
|
||||||
proxyRes.pipe(res);
|
|
||||||
});
|
|
||||||
|
|
||||||
proxyReq.on("error", (err) => {
|
|
||||||
send(res, 502, JSON.stringify({ error: `Upstream error: ${err.message}` }), {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.pipe(proxyReq);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handler(req, res) {
|
|
||||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
|
||||||
let pathname = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
if (pathname.startsWith("/api/")) {
|
|
||||||
if (LOCAL_API.has(pathname)) {
|
|
||||||
serveLocalApi(res, pathname);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
proxyToUpstream(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/") pathname = "/index.html";
|
|
||||||
|
|
||||||
const safe = path.normalize(pathname).replace(/^(\.\.[/\\])+/, "");
|
|
||||||
const filePath = path.join(__dirname, safe);
|
|
||||||
|
|
||||||
if (!filePath.startsWith(__dirname)) {
|
|
||||||
send(res, 403, "Forbidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.stat(filePath, (err, stat) => {
|
|
||||||
if (err || !stat.isFile()) {
|
|
||||||
if (!path.extname(pathname)) {
|
|
||||||
serveFile(res, path.join(__dirname, "index.html"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
send(res, 404, "Not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
serveFile(res, filePath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
http.createServer(handler).listen(PORT, () => {
|
|
||||||
console.log(`GeoExport clone: http://127.0.0.1:${PORT}`);
|
|
||||||
console.log(`Static + local JSON; export/search/lookup proxied to ${UPSTREAM}`);
|
|
||||||
});
|
|
||||||
36
src/app/admin/page.tsx
Normal file
36
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, scans] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.scan.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||||
|
<h1 className="text-4xl font-bold">Admin</h1>
|
||||||
|
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-3xl font-bold">{users}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scans</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-3xl font-bold">{scans}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/api-docs/page.tsx
Normal file
57
src/app/api-docs/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function ApiDocsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 pb-24 pt-24">
|
||||||
|
<h1 className="text-4xl font-bold">API</h1>
|
||||||
|
<p className="mt-2 text-zinc-400">REST endpoints for domain scans and exports.</p>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/scan",
|
||||||
|
body: '{ "domain": "example.com" }',
|
||||||
|
desc: "Start a scan. Returns scan id.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/scan/:id",
|
||||||
|
desc: "Poll scan status and result.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/scan/:id/stream",
|
||||||
|
desc: "SSE stream for live progress.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/export?id=&format=json|csv",
|
||||||
|
desc: "Download completed scan.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/health",
|
||||||
|
desc: "Health check.",
|
||||||
|
},
|
||||||
|
].map((ep) => (
|
||||||
|
<Card key={ep.path}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-base">
|
||||||
|
<span className="text-violet-400">{ep.method}</span> {ep.path}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-zinc-400">
|
||||||
|
<p>{ep.desc}</p>
|
||||||
|
{ep.body && (
|
||||||
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs text-zinc-300">
|
||||||
|
{ep.body}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
42
src/app/api/export/route.ts
Normal file
42
src/app/api/export/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const id = req.nextUrl.searchParams.get("id");
|
||||||
|
const format = req.nextUrl.searchParams.get("format") ?? "json";
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Missing scan id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = await prisma.scan.findUnique({ where: { id } });
|
||||||
|
if (!scan?.result) {
|
||||||
|
return NextResponse.json({ error: "Scan result not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "csv") {
|
||||||
|
const result = scan.result as Record<string, unknown>;
|
||||||
|
const rows = [
|
||||||
|
["field", "value"],
|
||||||
|
["domain", scan.domain],
|
||||||
|
["scanned_at", String(result.scannedAt ?? "")],
|
||||||
|
["ssl_valid", String((result.ssl as { valid?: boolean })?.valid ?? "")],
|
||||||
|
["security_score", String((result.security as { score?: number })?.score ?? "")],
|
||||||
|
["security_grade", String((result.security as { grade?: string })?.grade ?? "")],
|
||||||
|
["uptime_reachable", String((result.uptime as { reachable?: boolean })?.reachable ?? "")],
|
||||||
|
];
|
||||||
|
const csv = rows.map((r) => r.map((c) => `"${c.replace(/"/g, '""')}"`).join(",")).join("\n");
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv",
|
||||||
|
"Content-Disposition": `attachment; filename="${scan.domain}-scan.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(scan.result, {
|
||||||
|
headers: {
|
||||||
|
"Content-Disposition": `attachment; filename="${scan.domain}-scan.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/app/api/health/route.ts
Normal file
11
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return NextResponse.json({ status: "ok", db: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ status: "degraded", db: false }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/api/monitors/route.ts
Normal file
51
src/app/api/monitors/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeDomain, isValidDomain } from "@/lib/utils";
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
type: z.enum(["DNS", "SSL", "UPTIME"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const monitors = await prisma.monitor.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ monitors });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: z.infer<typeof bodySchema>;
|
||||||
|
try {
|
||||||
|
body = bodySchema.parse(await req.json());
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = normalizeDomain(body.domain);
|
||||||
|
if (!isValidDomain(domain)) {
|
||||||
|
return NextResponse.json({ error: "Invalid domain" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const monitor = await prisma.monitor.create({
|
||||||
|
data: {
|
||||||
|
domain,
|
||||||
|
type: body.type,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ monitor }, { status: 201 });
|
||||||
|
}
|
||||||
14
src/app/api/scan/[id]/route.ts
Normal file
14
src/app/api/scan/[id]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const scan = await prisma.scan.findUnique({ where: { id } });
|
||||||
|
if (!scan) {
|
||||||
|
return NextResponse.json({ error: "Scan not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(scan);
|
||||||
|
}
|
||||||
65
src/app/api/scan/[id]/stream/route.ts
Normal file
65
src/app/api/scan/[id]/stream/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const send = (data: object) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 120;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
const scan = await prisma.scan.findUnique({ where: { id } });
|
||||||
|
if (!scan) {
|
||||||
|
send({ error: "not_found" });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send({
|
||||||
|
id: scan.id,
|
||||||
|
status: scan.status,
|
||||||
|
progress: scan.progress,
|
||||||
|
domain: scan.domain,
|
||||||
|
result: scan.status === "COMPLETED" ? scan.result : undefined,
|
||||||
|
error: scan.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scan.status === "COMPLETED" || scan.status === "FAILED") {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
send({ error: "timeout" });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(poll, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
await poll();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
98
src/app/api/scan/route.ts
Normal file
98
src/app/api/scan/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { normalizeDomain, isValidDomain } from "@/lib/utils";
|
||||||
|
import { runDomainScan } from "@/lib/scanner";
|
||||||
|
import { cacheGet, cacheSet } from "@/lib/redis";
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
domain: z.string().min(1).max(253),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anonymous";
|
||||||
|
const rate = await checkRateLimit(`scan:${ip}`);
|
||||||
|
if (!rate.ok) {
|
||||||
|
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: z.infer<typeof bodySchema>;
|
||||||
|
try {
|
||||||
|
body = bodySchema.parse(await req.json());
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = normalizeDomain(body.domain);
|
||||||
|
if (!isValidDomain(domain)) {
|
||||||
|
return NextResponse.json({ error: "Invalid domain name" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `scan:result:${domain}`;
|
||||||
|
const cached = await cacheGet<object>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const scan = await prisma.scan.create({
|
||||||
|
data: {
|
||||||
|
domain,
|
||||||
|
status: "COMPLETED",
|
||||||
|
progress: 100,
|
||||||
|
result: cached,
|
||||||
|
ip,
|
||||||
|
userAgent: req.headers.get("user-agent") ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ id: scan.id, domain, cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = await prisma.scan.create({
|
||||||
|
data: {
|
||||||
|
domain,
|
||||||
|
status: "RUNNING",
|
||||||
|
progress: 0,
|
||||||
|
ip,
|
||||||
|
userAgent: req.headers.get("user-agent") ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runScanAsync(scan.id, domain, cacheKey);
|
||||||
|
|
||||||
|
return NextResponse.json({ id: scan.id, domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScanAsync(scanId: string, domain: string, cacheKey: string) {
|
||||||
|
try {
|
||||||
|
const result = await runDomainScan(domain, async (progress) => {
|
||||||
|
await prisma.scan.update({ where: { id: scanId }, data: { progress } });
|
||||||
|
});
|
||||||
|
await prisma.scan.update({
|
||||||
|
where: { id: scanId },
|
||||||
|
data: { status: "COMPLETED", progress: 100, result: result as object },
|
||||||
|
});
|
||||||
|
await cacheSet(cacheKey, result, 3600);
|
||||||
|
} catch (e) {
|
||||||
|
await prisma.scan.update({
|
||||||
|
where: { id: scanId },
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
error: e instanceof Error ? e.message : "Scan failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 20), 50);
|
||||||
|
const scans = await prisma.scan.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
domain: true,
|
||||||
|
status: true,
|
||||||
|
progress: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scans });
|
||||||
|
}
|
||||||
19
src/app/dashboard/page.tsx
Normal file
19
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { ScanPanel } from "@/components/scan/scan-panel";
|
||||||
|
import { Skeleton } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Dashboard",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
|
||||||
|
<h1 className="mb-2 text-4xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="mb-8 text-zinc-400">Run a domain scan and export results.</p>
|
||||||
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||||
|
<ScanPanel />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/domain/[domain]/not-found.tsx
Normal file
11
src/app/domain/[domain]/not-found.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function DomainNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
||||||
|
<h1 className="text-2xl font-bold">No scan found</h1>
|
||||||
|
<p className="mt-2 text-zinc-400">Run a scan from the dashboard first.</p>
|
||||||
|
<a href="/dashboard" className="mt-6 inline-block text-violet-400 hover:underline">
|
||||||
|
Go to dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/app/domain/[domain]/page.tsx
Normal file
85
src/app/domain/[domain]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { ScanResult } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ domain: string }> }) {
|
||||||
|
const { domain } = await params;
|
||||||
|
return { title: domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DomainDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ domain: string }>;
|
||||||
|
}) {
|
||||||
|
const { domain: raw } = await params;
|
||||||
|
const domain = decodeURIComponent(raw);
|
||||||
|
|
||||||
|
const scan = await prisma.scan.findFirst({
|
||||||
|
where: { domain, status: "COMPLETED" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scan?.result) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = scan.result as unknown as ScanResult;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
|
||||||
|
<h1 className="text-4xl font-bold">{result.domain}</h1>
|
||||||
|
<p className="mt-2 text-zinc-400">Full analysis report</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>WHOIS</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-96 overflow-auto font-mono text-xs text-zinc-300">
|
||||||
|
{Object.entries(result.whois).slice(0, 30).map(([k, v]) => (
|
||||||
|
<div key={k} className="border-b border-white/5 py-1">
|
||||||
|
<span className="text-zinc-500">{k}: </span>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>SSL certificate</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
Status:{" "}
|
||||||
|
<Badge variant={result.ssl.valid ? "success" : "danger"}>
|
||||||
|
{result.ssl.valid ? "Valid" : "Invalid"}
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
{result.ssl.issuer && <p>Issuer: {result.ssl.issuer}</p>}
|
||||||
|
{result.ssl.validTo && <p>Expires: {result.ssl.validTo}</p>}
|
||||||
|
{result.ssl.daysRemaining !== undefined && (
|
||||||
|
<p>Days remaining: {result.ssl.daysRemaining}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>HTTP headers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-64 overflow-auto font-mono text-xs">
|
||||||
|
{Object.entries(result.http.headers).map(([k, v]) => (
|
||||||
|
<div key={k} className="py-0.5">
|
||||||
|
<span className="text-violet-400">{k}</span>: {v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/app/globals.css
Normal file
47
src/app/globals.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #fafafa;
|
||||||
|
--foreground: #09090b;
|
||||||
|
--muted: #71717a;
|
||||||
|
--accent: #7c3aed;
|
||||||
|
--accent-2: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #030712;
|
||||||
|
--foreground: #fafafa;
|
||||||
|
--muted: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
@apply border border-white/10 bg-white/5 backdrop-blur-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-mesh {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(124, 58, 237, 0.35), transparent),
|
||||||
|
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(6, 182, 212, 0.15), transparent),
|
||||||
|
var(--background);
|
||||||
|
}
|
||||||
52
src/app/layout.tsx
Normal file
52
src/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/components/providers";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "Domain Scanner — Domain intelligence platform",
|
||||||
|
template: "%s | Domain Scanner",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Modern domain intelligence and infrastructure analysis. DNS, WHOIS, SSL, geo, security headers, and monitoring.",
|
||||||
|
keywords: [
|
||||||
|
"domain scanner",
|
||||||
|
"dns",
|
||||||
|
"whois",
|
||||||
|
"ssl",
|
||||||
|
"cybersecurity",
|
||||||
|
"infrastructure",
|
||||||
|
],
|
||||||
|
openGraph: {
|
||||||
|
title: "Domain Scanner",
|
||||||
|
description: "Advanced domain intelligence with premium UX",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
<Providers>
|
||||||
|
<div className="gradient-mesh min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/login/page.tsx
Normal file
48
src/app/login/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const res = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
if (res?.error) {
|
||||||
|
setError("Invalid credentials");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4">
|
||||||
|
<h1 className="text-3xl font-bold">Sign in</h1>
|
||||||
|
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-rose-400">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/app/monitoring/page.tsx
Normal file
79
src/app/monitoring/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Bell, Plus } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function MonitoringPage() {
|
||||||
|
const [domain, setDomain] = useState("");
|
||||||
|
const [monitors, setMonitors] = useState<
|
||||||
|
{ id: string; domain: string; type: string }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const addMonitor = async () => {
|
||||||
|
const res = await fetch("/api/monitors", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domain, type: "DNS" }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
toast.error("Sign in to add monitors");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error("Could not create monitor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setMonitors((m) => [...m, data.monitor]);
|
||||||
|
setDomain("");
|
||||||
|
toast.success("Monitor created (cron worker: roadmap)");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||||
|
<h1 className="text-4xl font-bold">Monitoring</h1>
|
||||||
|
<p className="mt-2 text-zinc-400">
|
||||||
|
DNS and SSL change alerts — queue workers ship in v1.1.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5" /> Add monitor
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
placeholder="domain.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={addMonitor}>
|
||||||
|
<Plus className="h-4 w-4" /> Add
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{monitors.length === 0 ? (
|
||||||
|
<div className="mt-16 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
||||||
|
<Bell className="h-8 w-8 text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-500">No monitors yet. Add one to track DNS/SSL changes.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-8 space-y-3">
|
||||||
|
{monitors.map((m) => (
|
||||||
|
<li key={m.id} className="glass rounded-xl px-4 py-3">
|
||||||
|
{m.domain} — {m.type}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/page.tsx
Normal file
19
src/app/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hero } from "@/components/landing/hero";
|
||||||
|
import { Features } from "@/components/landing/features";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<Features />
|
||||||
|
<footer className="border-t border-white/5 py-12 text-center text-sm text-zinc-500">
|
||||||
|
<p>Domain Scanner — self-hosted domain intelligence</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<a href="https://github.com/andrey271192/Domain_web" className="text-violet-400 hover:underline">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/settings/page.tsx
Normal file
11
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 pb-24 pt-24">
|
||||||
|
<h1 className="text-4xl font-bold">Settings</h1>
|
||||||
|
<p className="mt-4 text-zinc-400">
|
||||||
|
Configure IPINFO_TOKEN, SCAN_RATE_LIMIT_PER_HOUR, and database URLs via environment
|
||||||
|
variables on your server. Theme toggle is in the header.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/landing/features.tsx
Normal file
57
src/components/landing/features.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Database, Lock, Radio, Server } from "lucide-react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Database,
|
||||||
|
title: "DNS & WHOIS depth",
|
||||||
|
desc: "A/AAAA, MX, TXT, NS resolution plus registrar metadata in one pass.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Lock,
|
||||||
|
title: "SSL & security grade",
|
||||||
|
desc: "Certificate validity, issuer chain, and security header scoring.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
title: "Live scan stream",
|
||||||
|
desc: "SSE progress events while workers resolve each infrastructure layer.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Server,
|
||||||
|
title: "Self-hosted stack",
|
||||||
|
desc: "PostgreSQL, Redis, Docker — your data stays on your VPS.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Features() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-6xl px-4 py-24">
|
||||||
|
<h2 className="mb-4 text-center text-4xl font-bold tracking-tight sm:text-5xl">
|
||||||
|
Built for operators
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mb-16 max-w-2xl text-center text-zinc-400">
|
||||||
|
Competitive tools scatter checks across tabs. Domain Scanner unifies infrastructure
|
||||||
|
signals with export-ready reports and monitoring hooks.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={f.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
className="glass rounded-2xl p-8"
|
||||||
|
>
|
||||||
|
<f.icon className="mb-4 h-8 w-8 text-violet-400" />
|
||||||
|
<h3 className="text-xl font-semibold">{f.title}</h3>
|
||||||
|
<p className="mt-2 text-zinc-400">{f.desc}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/landing/hero.tsx
Normal file
113
src/components/landing/hero.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Search, Shield, Radar, Zap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { normalizeDomain } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [domain, setDomain] = useState("");
|
||||||
|
|
||||||
|
const submit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const d = normalizeDomain(domain);
|
||||||
|
if (d) router.push(`/dashboard?domain=${encodeURIComponent(d)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative flex min-h-[90vh] flex-col items-center justify-center px-4 pt-24 text-center">
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -top-40 left-1/2 h-[500px] w-[800px] -translate-x-1/2 rounded-full bg-violet-600/30 blur-[120px]" />
|
||||||
|
<div className="absolute top-1/3 right-0 h-[400px] w-[400px] rounded-full bg-cyan-500/20 blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-6 text-sm font-medium uppercase tracking-[0.2em] text-violet-300"
|
||||||
|
>
|
||||||
|
Domain intelligence platform
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
className="max-w-4xl text-5xl font-bold leading-[1.05] tracking-tight sm:text-7xl lg:text-8xl"
|
||||||
|
>
|
||||||
|
See every layer
|
||||||
|
<br />
|
||||||
|
<span className="bg-gradient-to-r from-violet-300 via-white to-cyan-300 bg-clip-text text-transparent">
|
||||||
|
of your domain
|
||||||
|
</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="mt-6 max-w-2xl text-lg text-zinc-400 sm:text-xl"
|
||||||
|
>
|
||||||
|
DNS, WHOIS, SSL, headers, geo, CDN detection, and security scoring — one scan,
|
||||||
|
exportable reports, monitoring hooks for production teams.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.form
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.15 }}
|
||||||
|
onSubmit={submit}
|
||||||
|
className="mt-10 flex w-full max-w-xl flex-col gap-3 sm:flex-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="example.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
className="flex-1 text-left"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="lg" className="shrink-0">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Analyze
|
||||||
|
</Button>
|
||||||
|
</motion.form>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.25 }}
|
||||||
|
className="mt-16 grid w-full max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ icon: Radar, label: "DNS & WHOIS" },
|
||||||
|
{ icon: Shield, label: "SSL & headers" },
|
||||||
|
{ icon: Zap, label: "Live progress" },
|
||||||
|
{ icon: Search, label: "Export JSON/CSV" },
|
||||||
|
].map(({ icon: Icon, label }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur"
|
||||||
|
>
|
||||||
|
<Icon className="mb-2 h-5 w-5 text-violet-400" />
|
||||||
|
<p className="text-sm text-zinc-300">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.35 }}
|
||||||
|
className="mt-12"
|
||||||
|
>
|
||||||
|
<Button variant="secondary" asChild>
|
||||||
|
<Link href="/dashboard">Open dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/layout/header.tsx
Normal file
83
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Globe2, Menu, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
|
{ href: "/monitoring", label: "Monitoring" },
|
||||||
|
{ href: "/api-docs", label: "API" },
|
||||||
|
{ href: "/settings", label: "Settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 z-50 w-full border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400">
|
||||||
|
<Globe2 className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold tracking-tight">Domain Scanner</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden items-center gap-1 md:flex">
|
||||||
|
{links.map((l) => (
|
||||||
|
<Link
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-3 py-2 text-sm text-zinc-400 transition hover:text-white",
|
||||||
|
pathname.startsWith(l.href) && "bg-white/5 text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<ThemeToggle />
|
||||||
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<Link href="/dashboard">Start scan</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="md:hidden" onClick={() => setOpen(!open)} aria-label="Menu">
|
||||||
|
{open ? <X /> : <Menu />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border-t border-white/5 bg-zinc-950 p-4 md:hidden"
|
||||||
|
>
|
||||||
|
{links.map((l) => (
|
||||||
|
<Link
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
className="block py-2 text-zinc-300"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Link href="/dashboard" className="mt-2 block">
|
||||||
|
<Button className="w-full">Start scan</Button>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/providers.tsx
Normal file
13
src/components/providers.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||||
|
{children}
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/components/scan/scan-panel.tsx
Normal file
288
src/components/scan/scan-panel.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Download, Loader2, Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge, Skeleton } from "@/components/ui/badge";
|
||||||
|
import type { ScanResult } from "@/lib/types";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function ScanPanel() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [domain, setDomain] = useState(searchParams.get("domain") ?? "");
|
||||||
|
const [scanId, setScanId] = useState<string | null>(null);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [status, setStatus] = useState<string>("idle");
|
||||||
|
const [result, setResult] = useState<ScanResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const startScan = useCallback(async (d: string) => {
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setProgress(0);
|
||||||
|
setStatus("starting");
|
||||||
|
|
||||||
|
const res = await fetch("/api/scan", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domain: d }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "Scan failed");
|
||||||
|
setStatus("failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScanId(data.id);
|
||||||
|
setStatus("running");
|
||||||
|
router.replace(`/dashboard?domain=${encodeURIComponent(d)}`);
|
||||||
|
|
||||||
|
if (data.cached) {
|
||||||
|
const full = await fetch(`/api/scan/${data.id}`);
|
||||||
|
const scan = await full.json();
|
||||||
|
setResult(scan.result as ScanResult);
|
||||||
|
setProgress(100);
|
||||||
|
setStatus("completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(`/api/scan/${data.id}/stream`);
|
||||||
|
es.onmessage = (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data) as {
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
result?: ScanResult;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
setProgress(msg.progress ?? 0);
|
||||||
|
if (msg.status === "COMPLETED" && msg.result) {
|
||||||
|
setResult(msg.result);
|
||||||
|
setStatus("completed");
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
if (msg.status === "FAILED") {
|
||||||
|
setError(msg.error ?? "Scan failed");
|
||||||
|
setStatus("failed");
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
es.onerror = () => es.close();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const d = searchParams.get("domain");
|
||||||
|
if (d && status === "idle") {
|
||||||
|
setDomain(d);
|
||||||
|
startScan(d);
|
||||||
|
}
|
||||||
|
}, [searchParams, status, startScan]);
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (domain.trim()) startScan(domain.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter domain — stripe.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
disabled={status === "running"}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={status === "running"}>
|
||||||
|
{status === "running" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{status === "running" ? `Scanning ${progress}%` : "Run scan"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-rose-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "running" && !result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-gradient-to-r from-violet-500 to-cyan-400"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<ScanResults result={result} scanId={scanId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanResults({ result, scanId }: { result: ScanResult; scanId: string | null }) {
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">{result.domain}</h2>
|
||||||
|
<p className="text-sm text-zinc-500">Scanned {new Date(result.scannedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{scanId && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<a href={`/api/export?id=${scanId}&format=json`}>
|
||||||
|
<Download className="h-4 w-4" /> JSON
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<a href={`/api/export?id=${scanId}&format=csv`}>
|
||||||
|
<Download className="h-4 w-4" /> CSV
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<Link href={`/domain/${result.domain}`}>Full report</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<MetricCard title="SSL" value={result.ssl.valid ? "Valid" : "Invalid"} ok={result.ssl.valid} />
|
||||||
|
<MetricCard title="Security" value={result.security.grade} ok={result.security.score >= 60} />
|
||||||
|
<MetricCard title="Uptime" value={result.uptime.reachable ? "Up" : "Down"} ok={result.uptime.reachable} />
|
||||||
|
<MetricCard
|
||||||
|
title="Latency"
|
||||||
|
value={result.uptime.latencyMs ? `${result.uptime.latencyMs}ms` : "—"}
|
||||||
|
ok={!!result.uptime.latencyMs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>DNS records</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-64 overflow-auto text-sm">
|
||||||
|
{result.dns.records.length === 0 ? (
|
||||||
|
<p className="text-zinc-500">No records found</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{result.dns.records.map((r, i) => (
|
||||||
|
<li key={i} className="font-mono text-xs text-zinc-300">
|
||||||
|
<Badge className="mr-2">{r.type}</Badge>
|
||||||
|
{r.value}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security headers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-4 text-4xl font-bold">{result.security.score}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{result.security.present.map((h) => (
|
||||||
|
<Badge key={h} variant="success">
|
||||||
|
{h}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{result.security.missing.slice(0, 4).map((h) => (
|
||||||
|
<Badge key={h} variant="warning">
|
||||||
|
missing: {h}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Geo / IP</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-zinc-300">
|
||||||
|
{result.geo ? (
|
||||||
|
<dl className="space-y-1">
|
||||||
|
<div>
|
||||||
|
<dt className="text-zinc-500">IP</dt>
|
||||||
|
<dd className="font-mono">{result.geo.ip}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-zinc-500">Location</dt>
|
||||||
|
<dd>
|
||||||
|
{[result.geo.city, result.geo.region, result.geo.country].filter(Boolean).join(", ")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{result.geo.isp && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-zinc-500">ISP</dt>
|
||||||
|
<dd>{result.geo.isp}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="text-zinc-500">Geo lookup unavailable</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tech & CDN</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{result.tech.map((t) => (
|
||||||
|
<Badge key={t.name}>{t.name}</Badge>
|
||||||
|
))}
|
||||||
|
{result.cdnWaf.detected.map((c) => (
|
||||||
|
<Badge key={c} variant="success">
|
||||||
|
{c}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{result.tech.length === 0 && result.cdnWaf.detected.length === 0 && (
|
||||||
|
<p className="text-zinc-500">No strong signals detected</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ title, value, ok }: { title: string; value: string; ok: boolean }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-zinc-500">{title}</p>
|
||||||
|
<p className={`mt-1 text-2xl font-bold ${ok ? "text-emerald-400" : "text-amber-400"}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/theme-toggle.tsx
Normal file
20
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition dark:rotate-0 dark:scale-100" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/ui/badge.tsx
Normal file
30
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement> & {
|
||||||
|
variant?: "default" | "success" | "warning" | "danger";
|
||||||
|
}) {
|
||||||
|
const variants = {
|
||||||
|
default: "bg-violet-500/20 text-violet-200 border-violet-500/30",
|
||||||
|
success: "bg-emerald-500/20 text-emerald-200 border-emerald-500/30",
|
||||||
|
warning: "bg-amber-500/20 text-amber-200 border-amber-500/30",
|
||||||
|
danger: "bg-rose-500/20 text-rose-200 border-rose-500/30",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
variants[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: { className?: string }) {
|
||||||
|
return <div className={cn("animate-pulse rounded-lg bg-white/10", className)} />;
|
||||||
|
}
|
||||||
44
src/components/ui/button.tsx
Normal file
44
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/50 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-white text-zinc-950 hover:bg-zinc-100 shadow-lg shadow-white/10",
|
||||||
|
secondary:
|
||||||
|
"bg-white/10 text-white border border-white/20 hover:bg-white/15 backdrop-blur",
|
||||||
|
ghost: "hover:bg-white/10 text-zinc-300",
|
||||||
|
outline: "border border-zinc-700 bg-transparent hover:bg-zinc-800",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-11 px-6",
|
||||||
|
sm: "h-9 px-4 text-xs",
|
||||||
|
lg: "h-13 px-8 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
30
src/components/ui/card.tsx
Normal file
30
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col gap-1.5 p-6 pb-0", className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
|
<h3 className={cn("text-lg font-semibold tracking-tight", className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("p-6", className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardContent };
|
||||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-full rounded-xl border border-white/15 bg-white/5 px-4 text-base text-white placeholder:text-zinc-500 backdrop-blur focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
42
src/lib/auth.ts
Normal file
42
src/lib/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
const email = String(credentials.email).toLowerCase();
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user) return null;
|
||||||
|
const ok = await bcrypt.compare(String(credentials.password), user.passwordHash);
|
||||||
|
if (!ok) return null;
|
||||||
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: { signIn: "/login" },
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = (user as { role?: string }).role;
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.role = token.role as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
20
src/lib/graphql/schema.ts
Normal file
20
src/lib/graphql/schema.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export const typeDefs = `#graphql
|
||||||
|
type ScanResult {
|
||||||
|
domain: String!
|
||||||
|
scannedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
scan(domain: String!): ScanResult
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const resolvers = {
|
||||||
|
Query: {
|
||||||
|
scan: async (_: unknown, { domain }: { domain: string }) => ({
|
||||||
|
domain,
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
note: "GraphQL endpoint placeholder — use REST /api/scan for full results",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
11
src/lib/prisma.ts
Normal file
11
src/lib/prisma.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
31
src/lib/rate-limit.ts
Normal file
31
src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const LIMIT = Number(process.env.SCAN_RATE_LIMIT_PER_HOUR ?? 30);
|
||||||
|
const WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function checkRateLimit(key: string): Promise<{ ok: boolean; remaining: number }> {
|
||||||
|
const now = new Date();
|
||||||
|
const windowEnd = new Date(now.getTime() + WINDOW_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await prisma.apiRateLimit.findUnique({ where: { key } });
|
||||||
|
if (!row || row.windowEnd < now) {
|
||||||
|
await prisma.apiRateLimit.upsert({
|
||||||
|
where: { key },
|
||||||
|
create: { key, count: 1, windowEnd },
|
||||||
|
update: { count: 1, windowEnd },
|
||||||
|
});
|
||||||
|
return { ok: true, remaining: LIMIT - 1 };
|
||||||
|
}
|
||||||
|
if (row.count >= LIMIT) {
|
||||||
|
return { ok: false, remaining: 0 };
|
||||||
|
}
|
||||||
|
await prisma.apiRateLimit.update({
|
||||||
|
where: { key },
|
||||||
|
data: { count: row.count + 1 },
|
||||||
|
});
|
||||||
|
return { ok: true, remaining: LIMIT - row.count - 1 };
|
||||||
|
} catch {
|
||||||
|
return { ok: true, remaining: LIMIT };
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/lib/redis.ts
Normal file
33
src/lib/redis.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
let redis: Redis | null = null;
|
||||||
|
|
||||||
|
export function getRedis(): Redis | null {
|
||||||
|
const url = process.env.REDIS_URL;
|
||||||
|
if (!url) return null;
|
||||||
|
if (!redis) {
|
||||||
|
redis = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: true });
|
||||||
|
}
|
||||||
|
return redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheGet<T>(key: string): Promise<T | null> {
|
||||||
|
const r = getRedis();
|
||||||
|
if (!r) return null;
|
||||||
|
try {
|
||||||
|
const val = await r.get(key);
|
||||||
|
return val ? (JSON.parse(val) as T) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheSet(key: string, value: unknown, ttlSec = 3600) {
|
||||||
|
const r = getRedis();
|
||||||
|
if (!r) return;
|
||||||
|
try {
|
||||||
|
await r.setex(key, ttlSec, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/lib/scanner/index.ts
Normal file
332
src/lib/scanner/index.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import dns from "node:dns/promises";
|
||||||
|
import tls from "node:tls";
|
||||||
|
import * as whois from "whois";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { ScanResult, DnsRecord, SslInfo, SecurityHeaders, TechHint, GeoInfo } from "../types";
|
||||||
|
|
||||||
|
const whoisLookup = promisify(whois.lookup.bind(whois));
|
||||||
|
|
||||||
|
const SECURITY_HEADER_KEYS = [
|
||||||
|
"strict-transport-security",
|
||||||
|
"content-security-policy",
|
||||||
|
"x-frame-options",
|
||||||
|
"x-content-type-options",
|
||||||
|
"referrer-policy",
|
||||||
|
"permissions-policy",
|
||||||
|
"cross-origin-opener-policy",
|
||||||
|
"cross-origin-resource-policy",
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseWhois(raw: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const idx = line.indexOf(":");
|
||||||
|
if (idx > 0) {
|
||||||
|
const k = line.slice(0, idx).trim().toLowerCase();
|
||||||
|
const v = line.slice(idx + 1).trim();
|
||||||
|
if (k && v && !k.startsWith("%")) out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupDns(domain: string) {
|
||||||
|
const records: DnsRecord[] = [];
|
||||||
|
const nameservers: string[] = [];
|
||||||
|
const mx: string[] = [];
|
||||||
|
const txt: string[] = [];
|
||||||
|
|
||||||
|
const types: Array<["A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS", () => Promise<unknown>]> = [
|
||||||
|
["A", () => dns.resolve4(domain)],
|
||||||
|
["AAAA", () => dns.resolve6(domain)],
|
||||||
|
["CNAME", () => dns.resolveCname(domain)],
|
||||||
|
["MX", () => dns.resolveMx(domain)],
|
||||||
|
["TXT", () => dns.resolveTxt(domain)],
|
||||||
|
["NS", () => dns.resolveNs(domain)],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [type, fn] of types) {
|
||||||
|
try {
|
||||||
|
const res = (await fn()) as unknown;
|
||||||
|
if (type === "MX" && Array.isArray(res)) {
|
||||||
|
for (const r of res as { exchange: string; priority: number }[]) {
|
||||||
|
mx.push(`${r.priority} ${r.exchange}`);
|
||||||
|
records.push({ type: "MX", value: `${r.priority} ${r.exchange}` });
|
||||||
|
}
|
||||||
|
} else if (type === "TXT" && Array.isArray(res)) {
|
||||||
|
for (const r of res as string[][]) {
|
||||||
|
const v = r.join("");
|
||||||
|
txt.push(v);
|
||||||
|
records.push({ type: "TXT", value: v });
|
||||||
|
}
|
||||||
|
} else if (type === "NS" && Array.isArray(res)) {
|
||||||
|
for (const r of res as string[]) {
|
||||||
|
nameservers.push(r);
|
||||||
|
records.push({ type: "NS", value: r });
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(res)) {
|
||||||
|
for (const r of res as string[]) {
|
||||||
|
records.push({ type, value: r });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* record type may not exist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { records, nameservers, mx, txt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSsl(domain: string): Promise<SslInfo> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = tls.connect(
|
||||||
|
{ host: domain, port: 443, servername: domain, rejectUnauthorized: false, timeout: 10000 },
|
||||||
|
() => {
|
||||||
|
const cert = socket.getPeerCertificate();
|
||||||
|
socket.end();
|
||||||
|
if (!cert || !cert.valid_to) {
|
||||||
|
resolve({ valid: false, error: "No certificate" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validTo = new Date(cert.valid_to);
|
||||||
|
const validFrom = new Date(cert.valid_from);
|
||||||
|
const daysRemaining = Math.floor((validTo.getTime() - Date.now()) / 86400000);
|
||||||
|
const issuer = cert.issuer?.O ?? cert.issuer?.CN;
|
||||||
|
const subject = cert.subject?.CN;
|
||||||
|
resolve({
|
||||||
|
valid: daysRemaining > 0,
|
||||||
|
issuer: Array.isArray(issuer) ? issuer[0] : issuer,
|
||||||
|
subject: Array.isArray(subject) ? subject[0] : subject,
|
||||||
|
validFrom: validFrom.toISOString(),
|
||||||
|
validTo: validTo.toISOString(),
|
||||||
|
daysRemaining,
|
||||||
|
protocol: socket.getProtocol?.() ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
socket.on("error", (e) => resolve({ valid: false, error: e.message }));
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ valid: false, error: "Timeout" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHttp(domain: string) {
|
||||||
|
const redirectChain: string[] = [];
|
||||||
|
let url = `https://${domain}`;
|
||||||
|
let status: number | undefined;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
headers: { "User-Agent": "DomainScanner/1.0 (+https://github.com/andrey271192/Domain_web)" },
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
res.headers.forEach((v, k) => {
|
||||||
|
headers[k.toLowerCase()] = v;
|
||||||
|
});
|
||||||
|
if (res.status >= 300 && res.status < 400) {
|
||||||
|
const loc = res.headers.get("location");
|
||||||
|
if (!loc) break;
|
||||||
|
redirectChain.push(loc);
|
||||||
|
url = loc.startsWith("http") ? loc : new URL(loc, url).href;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
finalUrl: url,
|
||||||
|
redirectChain,
|
||||||
|
headers,
|
||||||
|
server: headers["server"],
|
||||||
|
poweredBy: headers["x-powered-by"],
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
url = `http://${domain}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
res.headers.forEach((v, k) => {
|
||||||
|
headers[k.toLowerCase()] = v;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
finalUrl: res.url,
|
||||||
|
redirectChain,
|
||||||
|
headers,
|
||||||
|
server: headers["server"],
|
||||||
|
poweredBy: headers["x-powered-by"],
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { redirectChain, headers, reachable: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status, finalUrl: url, redirectChain, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreSecurityHeaders(headers: Record<string, string>): SecurityHeaders {
|
||||||
|
const present: string[] = [];
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const key of SECURITY_HEADER_KEYS) {
|
||||||
|
if (headers[key]) present.push(key);
|
||||||
|
else missing.push(key);
|
||||||
|
}
|
||||||
|
const score = Math.round((present.length / SECURITY_HEADER_KEYS.length) * 100);
|
||||||
|
const grade =
|
||||||
|
score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
||||||
|
return { score, grade, present, missing, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectTech(headers: Record<string, string>): TechHint[] {
|
||||||
|
const tech: TechHint[] = [];
|
||||||
|
const server = headers["server"]?.toLowerCase() ?? "";
|
||||||
|
const powered = headers["x-powered-by"]?.toLowerCase() ?? "";
|
||||||
|
const via = headers["via"]?.toLowerCase() ?? "";
|
||||||
|
const cf = headers["cf-ray"];
|
||||||
|
|
||||||
|
if (cf) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" });
|
||||||
|
if (server.includes("nginx")) tech.push({ name: "nginx", category: "Web Server", confidence: "high" });
|
||||||
|
if (server.includes("apache")) tech.push({ name: "Apache", category: "Web Server", confidence: "high" });
|
||||||
|
if (server.includes("cloudflare")) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" });
|
||||||
|
if (powered.includes("next")) tech.push({ name: "Next.js", category: "Framework", confidence: "medium" });
|
||||||
|
if (powered.includes("express")) tech.push({ name: "Express", category: "Framework", confidence: "medium" });
|
||||||
|
if (via.includes("varnish")) tech.push({ name: "Varnish", category: "Cache", confidence: "medium" });
|
||||||
|
if (headers["x-vercel-id"]) tech.push({ name: "Vercel", category: "Hosting", confidence: "high" });
|
||||||
|
if (headers["x-amz-cf-id"]) tech.push({ name: "AWS CloudFront", category: "CDN", confidence: "high" });
|
||||||
|
|
||||||
|
const cdnWaf: string[] = [];
|
||||||
|
if (cf) cdnWaf.push("Cloudflare");
|
||||||
|
if (headers["x-served-by"]?.includes("fastly")) cdnWaf.push("Fastly");
|
||||||
|
if (headers["server"]?.includes("Akamai")) cdnWaf.push("Akamai");
|
||||||
|
|
||||||
|
return tech;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCdnWaf(headers: Record<string, string>): string[] {
|
||||||
|
const detected: string[] = [];
|
||||||
|
if (headers["cf-ray"]) detected.push("Cloudflare");
|
||||||
|
if (headers["x-fastly-request-id"]) detected.push("Fastly");
|
||||||
|
if (headers["x-akamai-transformed"]) detected.push("Akamai");
|
||||||
|
if (headers["x-amz-cf-id"]) detected.push("AWS CloudFront");
|
||||||
|
if (headers["x-sucuri-id"]) detected.push("Sucuri WAF");
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function geoLookup(domain: string): Promise<GeoInfo | null> {
|
||||||
|
try {
|
||||||
|
const ips = await dns.resolve4(domain);
|
||||||
|
const ip = ips[0];
|
||||||
|
if (!ip) return null;
|
||||||
|
|
||||||
|
const token = process.env.IPINFO_TOKEN;
|
||||||
|
const url = token
|
||||||
|
? `https://ipinfo.io/${ip}?token=${token}`
|
||||||
|
: `http://ip-api.com/json/${ip}?fields=status,country,regionName,city,isp,org,timezone,lat,lon,query`;
|
||||||
|
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data = (await res.json()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const loc = String(data.loc ?? "").split(",");
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
country: data.country as string,
|
||||||
|
region: data.region as string,
|
||||||
|
city: data.city as string,
|
||||||
|
org: data.org as string,
|
||||||
|
timezone: data.timezone as string,
|
||||||
|
lat: loc[0] ? Number(loc[0]) : undefined,
|
||||||
|
lon: loc[1] ? Number(loc[1]) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
return {
|
||||||
|
ip: String(data.query ?? ip),
|
||||||
|
country: data.country as string,
|
||||||
|
region: data.regionName as string,
|
||||||
|
city: data.city as string,
|
||||||
|
isp: data.isp as string,
|
||||||
|
org: data.org as string,
|
||||||
|
timezone: data.timezone as string,
|
||||||
|
lat: data.lat as number,
|
||||||
|
lon: data.lon as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ip };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanProgressCallback = (progress: number, stage: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
export async function runDomainScan(
|
||||||
|
domain: string,
|
||||||
|
onProgress?: ScanProgressCallback
|
||||||
|
): Promise<ScanResult> {
|
||||||
|
const report = async (p: number, stage: string) => {
|
||||||
|
await onProgress?.(p, stage);
|
||||||
|
};
|
||||||
|
|
||||||
|
await report(5, "dns");
|
||||||
|
const dnsResult = await lookupDns(domain);
|
||||||
|
|
||||||
|
await report(25, "whois");
|
||||||
|
let whoisData: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
const raw = await whoisLookup(domain);
|
||||||
|
whoisData = parseWhois(String(raw));
|
||||||
|
} catch (e) {
|
||||||
|
whoisData = { error: e instanceof Error ? e.message : "WHOIS failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await report(45, "ssl");
|
||||||
|
const ssl = await checkSsl(domain);
|
||||||
|
|
||||||
|
await report(65, "http");
|
||||||
|
const httpRaw = await fetchHttp(domain);
|
||||||
|
const headers = httpRaw.headers ?? {};
|
||||||
|
const security = scoreSecurityHeaders(headers);
|
||||||
|
const tech = detectTech(headers);
|
||||||
|
const cdnWaf = { detected: detectCdnWaf(headers) };
|
||||||
|
|
||||||
|
await report(85, "geo");
|
||||||
|
const geo = await geoLookup(domain);
|
||||||
|
|
||||||
|
await report(100, "done");
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
dns: dnsResult,
|
||||||
|
whois: whoisData,
|
||||||
|
ssl,
|
||||||
|
http: {
|
||||||
|
status: httpRaw.status,
|
||||||
|
finalUrl: httpRaw.finalUrl,
|
||||||
|
redirectChain: httpRaw.redirectChain ?? [],
|
||||||
|
headers,
|
||||||
|
server: httpRaw.server,
|
||||||
|
poweredBy: httpRaw.poweredBy,
|
||||||
|
},
|
||||||
|
geo,
|
||||||
|
security,
|
||||||
|
tech,
|
||||||
|
uptime: {
|
||||||
|
reachable: typeof httpRaw.status === "number" && httpRaw.status < 500,
|
||||||
|
latencyMs: "latencyMs" in httpRaw ? httpRaw.latencyMs : undefined,
|
||||||
|
},
|
||||||
|
cdnWaf,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
src/lib/types.ts
Normal file
73
src/lib/types.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export interface DnsRecord {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SslInfo {
|
||||||
|
valid: boolean;
|
||||||
|
issuer?: string;
|
||||||
|
subject?: string;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
daysRemaining?: number;
|
||||||
|
protocol?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityHeaders {
|
||||||
|
score: number;
|
||||||
|
grade: string;
|
||||||
|
present: string[];
|
||||||
|
missing: string[];
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoInfo {
|
||||||
|
ip: string;
|
||||||
|
country?: string;
|
||||||
|
region?: string;
|
||||||
|
city?: string;
|
||||||
|
isp?: string;
|
||||||
|
org?: string;
|
||||||
|
timezone?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TechHint {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
confidence: "low" | "medium" | "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
domain: string;
|
||||||
|
scannedAt: string;
|
||||||
|
dns: {
|
||||||
|
records: DnsRecord[];
|
||||||
|
nameservers: string[];
|
||||||
|
mx: string[];
|
||||||
|
txt: string[];
|
||||||
|
};
|
||||||
|
whois: Record<string, string>;
|
||||||
|
ssl: SslInfo;
|
||||||
|
http: {
|
||||||
|
status?: number;
|
||||||
|
finalUrl?: string;
|
||||||
|
redirectChain: string[];
|
||||||
|
headers: Record<string, string>;
|
||||||
|
server?: string;
|
||||||
|
poweredBy?: string;
|
||||||
|
};
|
||||||
|
geo: GeoInfo | null;
|
||||||
|
security: SecurityHeaders;
|
||||||
|
tech: TechHint[];
|
||||||
|
uptime: {
|
||||||
|
reachable: boolean;
|
||||||
|
latencyMs?: number;
|
||||||
|
};
|
||||||
|
cdnWaf: {
|
||||||
|
detected: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/lib/utils.ts
Normal file
18
src/lib/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDomain(input: string): string {
|
||||||
|
let d = input.trim().toLowerCase();
|
||||||
|
d = d.replace(/^https?:\/\//, "");
|
||||||
|
d = d.replace(/\/.*$/, "");
|
||||||
|
d = d.replace(/^www\./, "");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDomain(domain: string): boolean {
|
||||||
|
return /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain);
|
||||||
|
}
|
||||||
20
src/types/next-auth.d.ts
vendored
Normal file
20
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import "next-auth";
|
||||||
|
import "next-auth/jwt";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,24 +11,11 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [{ "name": "next" }],
|
||||||
{
|
"paths": { "@/*": ["./src/*"] }
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
50
uninstall.sh
50
uninstall.sh
@@ -1,48 +1,54 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# GeoExport site — one-command uninstall (does not wipe the whole server)
|
# Domain Scanner — one-command uninstall
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="${GEOEXPORT_INSTALL_DIR:-/opt/domain_web}"
|
INSTALL_DIR="${DOMAIN_SCANNER_INSTALL_DIR:-/opt/domain_web}"
|
||||||
SERVICE_NAME="geoexport-site"
|
SERVICE_NAME="domain-scanner"
|
||||||
NGINX_SITE="geoexport-site"
|
NGINX_SITE="domain-scanner"
|
||||||
|
LEGACY_SERVICE="geoexport-site"
|
||||||
|
LEGACY_NGINX="geoexport-site"
|
||||||
|
|
||||||
log() { echo "[geoexport-uninstall] $*"; }
|
log() { echo "[domain-scanner-uninstall] $*"; }
|
||||||
need_root() {
|
need_root() {
|
||||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||||
echo "Запустите от root: sudo bash uninstall.sh" >&2
|
echo "Run as root: sudo bash uninstall.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
stop_services() {
|
stop_docker() {
|
||||||
log "Остановка ${SERVICE_NAME}..."
|
log "Stopping Docker stack..."
|
||||||
systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
|
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
|
||||||
systemctl disable "${SERVICE_NAME}.service" 2>/dev/null || true
|
docker compose -f "${INSTALL_DIR}/docker-compose.yml" down -v 2>/dev/null || true
|
||||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_legacy() {
|
||||||
|
systemctl stop "${LEGACY_SERVICE}.service" 2>/dev/null || true
|
||||||
|
systemctl disable "${LEGACY_SERVICE}.service" 2>/dev/null || true
|
||||||
|
rm -f "/etc/systemd/system/${LEGACY_SERVICE}.service"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_nginx() {
|
remove_nginx() {
|
||||||
log "Удаление конфигурации nginx..."
|
log "Removing nginx site..."
|
||||||
rm -f "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
for site in "${NGINX_SITE}" "${LEGACY_NGINX}"; do
|
||||||
rm -f "/etc/nginx/sites-available/${NGINX_SITE}"
|
rm -f "/etc/nginx/sites-enabled/${site}"
|
||||||
|
rm -f "/etc/nginx/sites-available/${site}"
|
||||||
|
done
|
||||||
if command -v nginx >/dev/null 2>&1; then
|
if command -v nginx >/dev/null 2>&1; then
|
||||||
if [[ -d /etc/nginx/sites-available ]] && [[ ! -e /etc/nginx/sites-enabled/default ]]; then
|
|
||||||
if [[ -f /etc/nginx/sites-available/default ]]; then
|
|
||||||
ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true
|
nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_files() {
|
remove_files() {
|
||||||
log "Удаление файлов в ${INSTALL_DIR}..."
|
log "Removing ${INSTALL_DIR}..."
|
||||||
rm -rf "${INSTALL_DIR}"
|
rm -rf "${INSTALL_DIR}"
|
||||||
}
|
}
|
||||||
|
|
||||||
need_root
|
need_root
|
||||||
stop_services
|
stop_docker
|
||||||
|
stop_legacy
|
||||||
remove_nginx
|
remove_nginx
|
||||||
remove_files
|
remove_files
|
||||||
log "Сайт GeoExport удалён. nginx/node/git на сервере не удалялись."
|
log "Domain Scanner removed."
|
||||||
|
|||||||
Reference in New Issue
Block a user