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:
Андрей Бобырев
2026-05-24 21:48:32 +03:00
parent 93109106bc
commit 83168af005
90 changed files with 6036 additions and 2496 deletions

8
.env.example Normal file
View 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
View File

@@ -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
View 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"]

View File

@@ -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
View File

@@ -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.
## Быстрая установка (одна команда) ![Domain Scanner](https://img.shields.io/badge/Next.js-15-black?style=flat-square)
![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?style=flat-square)
![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square)
## 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)
```

View File

@@ -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
View 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
View 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
View 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
View 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
View 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;

View File

@@ -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
View 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;

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View 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
View File

@@ -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

View File

@@ -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 -->

View File

@@ -1 +0,0 @@
@AGENTS.md

View File

@@ -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"]

View File

@@ -1,261 +0,0 @@
# GeoExport
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=next.js)](https://nextjs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript)](https://www.typescriptlang.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker)](docker-compose.yml)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](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

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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).

View File

@@ -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 |

View File

@@ -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 (1520)
```
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.

View File

@@ -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.

View File

@@ -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;

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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
View 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
View File

@@ -1,2 +0,0 @@
node_modules/
.DS_Store

View File

@@ -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

View File

@@ -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

View File

@@ -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, быстрее)"}]}

View File

@@ -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"}]

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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
View 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
View 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>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View 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"`,
},
});
}

View 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 });
}
}

View 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 });
}

View 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);
}

View 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
View 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 });
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)} />;
}

View 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 };

View 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 };

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View File

@@ -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"]
} }

View File

@@ -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."