mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +00:00
feat: lightweight static PCA Lab site with domain groups
Replace Next.js scanner deploy with static React CDN pages: student VPN cabinet, regional setup, curated blocked-domain lists, and nginx one-line install on port 80 without touching Amnezia/Docker services. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
11
.env.example
11
.env.example
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
MONITOR_INTERVAL_MS=300000
|
||||
SEED_ADMIN_EMAIL=
|
||||
SEED_ADMIN_PASSWORD=
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
@@ -1,41 +1,7 @@
|
||||
# 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
|
||||
.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
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.next/
|
||||
*.log
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,29 +0,0 @@
|
||||
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"]
|
||||
121
README.md
121
README.md
@@ -1,107 +1,66 @@
|
||||
# Domain Scanner
|
||||
# Domain Web (PCA Lab)
|
||||
|
||||
Modern domain intelligence and infrastructure analysis platform with premium UX, real-time monitoring, and scalable self-hosted architecture.
|
||||
Лёгкий статический сайт: кабинет студента (VPN Amnezia), смена региона магазинов, кураторские списки заблокированных в РФ доменов.
|
||||
|
||||

|
||||

|
||||

|
||||
**Без поиска доменов** на сайте (crt.sh и API сканера отключены). Списки — статические пресеты.
|
||||
|
||||
## 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** — DNS change + SSL expiry checks via background worker
|
||||
- **Analytics** — 7-day charts from scan history (Recharts)
|
||||
- **Auth** — Register / sign-in (NextAuth credentials)
|
||||
- **Главная** — обложка PCA Lab (исследования AI и кибербезопасности)
|
||||
- **Кабинет студента** — пошаговое подключение WireGuard / Amnezia VPN
|
||||
- **VPN по региону** — инструкции смены региона App Store / Google Play / Microsoft Store
|
||||
- **Группы доменов** — карточки по категориям:
|
||||
- Медиа (Netflix, YouTube, …)
|
||||
- Социальные сети (Instagram, Facebook, X, …)
|
||||
- Искусственный интеллект (ChatGPT, Claude, …)
|
||||
- Мессенджеры (Telegram, WhatsApp, …)
|
||||
- Другое / VPN / Dev (GitHub, Steam, …)
|
||||
- Копирование списка доменов / IP, скачивание **Keenetic .bat** (статические маршруты)
|
||||
|
||||
## Roadmap status
|
||||
Связанный Telegram-бот (поиск доменов): [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) — на сайте только статика.
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Premium UI (glass, motion, dark/light) | Done |
|
||||
| Scan API + SSE progress + CDN/WAF heuristics | Done |
|
||||
| Analytics from DB | Done |
|
||||
| Monitor worker (DNS/SSL MVP) | Done |
|
||||
| Auth register + optional admin seed | Done |
|
||||
| Email/Slack alert delivery | Planned |
|
||||
| GraphQL public API | Planned |
|
||||
| Paid tiers / billing | Planned |
|
||||
|
||||
## 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
|
||||
## Установка на VPS (одна строка)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
On small VPS disks (<4GB free), the install script uses a **lite path**: Postgres/Redis in Docker, Next.js built on the host. For full container deploy on larger machines: `docker compose up -d && docker compose exec web npx prisma db push`.
|
||||
Переменные окружения (опционально):
|
||||
|
||||
If `npm ci` / `npm run build` OOM on a ~1GB RAM VPS, build on your Mac (`npm run build`), then rsync `.next/` and `.next/standalone/` to the server and run `node .next/standalone/server.js` (systemd unit in production). Always run `npx prisma db push` on the VPS after schema changes.
|
||||
| Переменная | По умолчанию |
|
||||
|------------|----------------|
|
||||
| `DOMAIN_WEB_ROOT` | `/var/www/domain-web` |
|
||||
| `DOMAIN_WEB_INSTALL_SRC` | `/opt/domain-web-src` |
|
||||
| `DOMAIN_WEB_BRANCH` | `main` |
|
||||
|
||||
Legacy GeoExport mirror in `site/` is **not** deployed (see `site/ARCHIVED.md`).
|
||||
Скрипт:
|
||||
|
||||
## Uninstall
|
||||
- ставит **nginx** и отдаёт каталог `web/` на **порту 80**;
|
||||
- **не останавливает** Amnezia (443), Docker и `domain-finder-bot`;
|
||||
- отключает legacy-сервис `domain-scanner` (Node), если был установлен ранее.
|
||||
|
||||
## Удаление
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
## Development
|
||||
## Локальный просмотр
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Start Postgres + Redis (docker compose up postgres redis -d)
|
||||
npm run db:push
|
||||
npm run dev
|
||||
cd web && python3 -m http.server 8080
|
||||
# http://127.0.0.1:8080/
|
||||
```
|
||||
|
||||
## Environment
|
||||
## Структура
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `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) |
|
||||
| `MONITOR_INTERVAL_MS` | Monitor worker interval (default 300000) |
|
||||
| `SEED_ADMIN_EMAIL` | Optional admin email on install |
|
||||
| `SEED_ADMIN_PASSWORD` | Optional admin password on install |
|
||||
```
|
||||
web/ — HTML + React (CDN) + JSX
|
||||
nginx/ — шаблон vhost
|
||||
install.sh — деплой на сервер
|
||||
uninstall.sh
|
||||
```
|
||||
|
||||
## API
|
||||
## Лицензия
|
||||
|
||||
See [docs/API.md](docs/API.md) and the in-app `/api-docs` page.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [API](docs/API.md)
|
||||
- [Deployment](docs/DEPLOYMENT.md)
|
||||
- [Marketing](docs/MARKETING.md)
|
||||
|
||||
## License
|
||||
|
||||
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`
|
||||
См. [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://scanner:scanner@postgres:5432/domain_scanner?schema=public
|
||||
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}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: scanner
|
||||
POSTGRES_PASSWORD: scanner
|
||||
POSTGRES_DB: domain_scanner
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U scanner -d domain_scanner"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
51
docs/API.md
51
docs/API.md
@@ -1,51 +0,0 @@
|
||||
# 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`).
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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-scanner` (lite VPS install via `install.sh`)
|
||||
Service: `domain-scanner.service` — `npm start` from repo root (not standalone `server.js` alone)
|
||||
Nginx site: `domain-scanner` on port 80 → app `:3000`, with `/_next/static/` served from disk
|
||||
|
||||
**CSS / static assets:** After `next build`, run `scripts/sync-standalone-assets.sh` (also via `postbuild`) so `.next/static` and `public` exist under `.next/standalone` for Docker. Host deploy must keep `${INSTALL_DIR}/.next/static` — never run `node .next/standalone/server.js` without that copy.
|
||||
|
||||
## Environment on server
|
||||
|
||||
Edit `/opt/domain-scanner/.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-scanner && git pull && npm ci && npm run build && systemctl restart domain-scanner nginx
|
||||
```
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
304
install.sh
304
install.sh
@@ -1,269 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# Domain Scanner — VPS install (Postgres/Redis in Docker, Next.js on host)
|
||||
# Full `docker compose up` (web image) needs ~4GB free disk; this script uses the lite path.
|
||||
# Domain Web — lightweight static site (nginx :80)
|
||||
# curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${DOMAIN_SCANNER_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
|
||||
BRANCH="${DOMAIN_SCANNER_BRANCH:-main}"
|
||||
INSTALL_DIR="${DOMAIN_SCANNER_INSTALL_DIR:-/opt/domain-scanner}"
|
||||
SERVICE_NAME="domain-scanner"
|
||||
NGINX_SITE="domain-scanner"
|
||||
APP_PORT="${DOMAIN_SCANNER_PORT:-3000}"
|
||||
LEGACY_SERVICE="geoexport-site"
|
||||
LEGACY_NGINX="geoexport-site"
|
||||
REPO_URL="${DOMAIN_WEB_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
|
||||
BRANCH="${DOMAIN_WEB_BRANCH:-main}"
|
||||
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
|
||||
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
|
||||
NGINX_SITE="domain-web"
|
||||
LEGACY_SERVICES=("domain-scanner" "geoexport-site")
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
log() { echo "[domain-scanner-install] $*"; }
|
||||
log() { echo "[domain-web] $*"; }
|
||||
|
||||
need_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Run as root: sudo bash install.sh" >&2
|
||||
echo "Run as root: sudo bash" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_packages() {
|
||||
log "Installing system packages..."
|
||||
log "Installing nginx, git, curl..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl ca-certificates git nginx openssl >/dev/null
|
||||
apt-get install -y -qq nginx git curl ca-certificates rsync >/dev/null
|
||||
}
|
||||
|
||||
install_node() {
|
||||
if command -v node >/dev/null 2>&1 && [[ "$(node -p 'process.versions.node.split(".")[0]')" -ge 20 ]]; then
|
||||
log "Node $(node -v) already installed"
|
||||
return
|
||||
fi
|
||||
log "Installing Node.js 22..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq nodejs >/dev/null
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
log "Docker already installed"
|
||||
else
|
||||
log "Installing Docker..."
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
fi
|
||||
systemctl enable docker.socket docker.service 2>/dev/null || true
|
||||
systemctl start docker.socket 2>/dev/null || true
|
||||
systemctl start docker.service 2>/dev/null || true
|
||||
}
|
||||
|
||||
stop_legacy() {
|
||||
log "Stopping legacy GeoExport mirror (if present)..."
|
||||
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"
|
||||
rm -f "/etc/nginx/sites-enabled/${LEGACY_NGINX}"
|
||||
rm -f "/etc/nginx/sites-available/${LEGACY_NGINX}"
|
||||
rm -rf /opt/domain_web 2>/dev/null || true
|
||||
stop_legacy_scanner() {
|
||||
log "Disabling legacy Node scanner site (if present)..."
|
||||
for svc in "${LEGACY_SERVICES[@]}"; do
|
||||
systemctl stop "${svc}.service" 2>/dev/null || true
|
||||
systemctl disable "${svc}.service" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/${svc}.service"
|
||||
done
|
||||
rm -f /etc/nginx/sites-enabled/domain-scanner 2>/dev/null || true
|
||||
rm -f /etc/nginx/sites-available/domain-scanner 2>/dev/null || true
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
}
|
||||
|
||||
clone_or_update() {
|
||||
log "Cloning/updating repository at ${INSTALL_DIR}..."
|
||||
if [[ -d "${INSTALL_DIR}/.git" ]]; then
|
||||
git -C "${INSTALL_DIR}" fetch origin "${BRANCH}"
|
||||
git -C "${INSTALL_DIR}" checkout "${BRANCH}"
|
||||
git -C "${INSTALL_DIR}" reset --hard "origin/${BRANCH}"
|
||||
clone_repo() {
|
||||
log "Fetching ${REPO_URL} (${BRANCH})..."
|
||||
if [[ -d "${INSTALL_SRC}/.git" ]]; then
|
||||
git -C "${INSTALL_SRC}" fetch origin "${BRANCH}"
|
||||
git -C "${INSTALL_SRC}" checkout "${BRANCH}"
|
||||
git -C "${INSTALL_SRC}" reset --hard "origin/${BRANCH}"
|
||||
else
|
||||
rm -rf "${INSTALL_DIR}"
|
||||
git clone --depth 1 --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_DIR}"
|
||||
rm -rf "${INSTALL_SRC}"
|
||||
git clone --depth 1 --branch "${BRANCH}" "${REPO_URL}" "${INSTALL_SRC}"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_env() {
|
||||
local ip
|
||||
ip="$(hostname -I | awk '{print $1}')"
|
||||
cd "${INSTALL_DIR}"
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
fi
|
||||
local secret
|
||||
secret="$(openssl rand -base64 32)"
|
||||
sed -i "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=${secret}|" .env
|
||||
sed -i "s|^NEXTAUTH_URL=.*|NEXTAUTH_URL=http://${ip}|" .env
|
||||
sed -i "s|^NEXT_PUBLIC_APP_URL=.*|NEXT_PUBLIC_APP_URL=http://${ip}|" .env
|
||||
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://scanner:scanner@127.0.0.1:5432/domain_scanner?schema=public|" .env
|
||||
sed -i "s|^REDIS_URL=.*|REDIS_URL=redis://127.0.0.1:6379|" .env
|
||||
deploy_web() {
|
||||
log "Deploying static files to ${WEB_ROOT}..."
|
||||
mkdir -p "${WEB_ROOT}"
|
||||
rsync -a --delete "${INSTALL_SRC}/web/" "${WEB_ROOT}/"
|
||||
chown -R www-data:www-data "${WEB_ROOT}" 2>/dev/null || chown -R nginx:nginx "${WEB_ROOT}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
deploy_data_services() {
|
||||
log "Starting Postgres and Redis (Docker)..."
|
||||
cd "${INSTALL_DIR}"
|
||||
docker compose up -d postgres redis
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T postgres pg_isready -U scanner -d domain_scanner >/dev/null 2>&1; then
|
||||
break
|
||||
configure_nginx() {
|
||||
log "Configuring nginx (HTTP :80)..."
|
||||
local conf_src="${INSTALL_SRC}/nginx/domain-web.conf"
|
||||
if [[ ! -f "${conf_src}" ]]; then
|
||||
echo "Missing nginx config in repo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup existing default if it listens on 80 and is not ours
|
||||
if [[ -f /etc/nginx/sites-enabled/default ]] && ! grep -q "domain-web" /etc/nginx/sites-enabled/default 2>/dev/null; then
|
||||
if grep -q "listen 80" /etc/nginx/sites-enabled/default 2>/dev/null; then
|
||||
mv /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.bak.$(date +%s) 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
build_app() {
|
||||
log "Building Next.js app on host..."
|
||||
cd "${INSTALL_DIR}"
|
||||
export NODE_ENV=production
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
npm run build
|
||||
bash scripts/sync-standalone-assets.sh "${INSTALL_DIR}"
|
||||
# Keep production deps at repo root; `next start` is more reliable than
|
||||
# standalone on small VPS (avoids incomplete traced node_modules).
|
||||
npm ci --omit=dev
|
||||
npx prisma generate
|
||||
journalctl --vacuum-size=80M 2>/dev/null || true
|
||||
apt-get clean 2>/dev/null || true
|
||||
}
|
||||
|
||||
install_monitor_worker() {
|
||||
log "Installing monitor worker (${SERVICE_NAME}-worker)..."
|
||||
cat >"/etc/systemd/system/${SERVICE_NAME}-worker.service" <<EOF
|
||||
[Unit]
|
||||
Description=Domain Scanner monitor worker
|
||||
After=network.target docker.service ${SERVICE_NAME}.service
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/npm run worker:monitors --silent
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}-worker.service"
|
||||
systemctl restart "${SERVICE_NAME}-worker.service"
|
||||
}
|
||||
|
||||
seed_admin_user() {
|
||||
if [[ -n "${SEED_ADMIN_EMAIL:-}" && -n "${SEED_ADMIN_PASSWORD:-}" ]]; then
|
||||
log "Seeding admin user..."
|
||||
cd "${INSTALL_DIR}"
|
||||
SEED_ADMIN_EMAIL="${SEED_ADMIN_EMAIL}" SEED_ADMIN_PASSWORD="${SEED_ADMIN_PASSWORD}" npm run seed:admin --silent
|
||||
fi
|
||||
}
|
||||
|
||||
install_systemd() {
|
||||
log "Installing systemd unit (${SERVICE_NAME})..."
|
||||
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=Domain Scanner (Next.js)
|
||||
After=network.target docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
Environment=HOSTNAME=0.0.0.0
|
||||
Environment=PORT=${APP_PORT}
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/npm run start --silent
|
||||
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_monitor_worker
|
||||
seed_admin_user
|
||||
}
|
||||
|
||||
install_nginx() {
|
||||
log "Configuring nginx reverse proxy..."
|
||||
cat >"/etc/nginx/sites-available/${NGINX_SITE}" <<EOF
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 32m;
|
||||
|
||||
location /_next/static/ {
|
||||
alias ${INSTALL_DIR}/.next/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:${APP_PORT};
|
||||
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;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
||||
sed "s|/var/www/domain-web|${WEB_ROOT}|g" "${conf_src}" > "/etc/nginx/sites-available/${NGINX_SITE}"
|
||||
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
||||
|
||||
nginx -t
|
||||
systemctl enable nginx
|
||||
systemctl restart nginx
|
||||
systemctl reload nginx
|
||||
}
|
||||
|
||||
verify() {
|
||||
log "Verifying deployment..."
|
||||
sleep 4
|
||||
if ! systemctl is-active --quiet "${SERVICE_NAME}.service"; then
|
||||
systemctl status "${SERVICE_NAME}.service" --no-pager || true
|
||||
journalctl -u "${SERVICE_NAME}.service" -n 30 --no-pager || true
|
||||
exit 1
|
||||
fi
|
||||
if ! curl -fsS -o /dev/null "http://127.0.0.1:${APP_PORT}/api/health"; then
|
||||
echo "Health check failed on port ${APP_PORT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
local css
|
||||
css="$(find "${INSTALL_DIR}/.next/static/css" -name '*.css' -type f 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "${css}" ]]; then
|
||||
echo "ERROR: no CSS build output in .next/static/css" >&2
|
||||
exit 1
|
||||
fi
|
||||
local css_url="/_next/static/css/$(basename "${css}")"
|
||||
if ! curl -fsSI "http://127.0.0.1${css_url}" | grep -qi 'content-type:.*css'; then
|
||||
echo "ERROR: CSS not served (${css_url})" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "CSS OK: ${css_url}"
|
||||
local title
|
||||
title="$(curl -fsS "http://127.0.0.1/" | grep -o '<title>[^<]*</title>' | head -1 || true)"
|
||||
if echo "${title}" | grep -qi geoexport; then
|
||||
echo "ERROR: GeoExport mirror still served — aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
print_done() {
|
||||
local ip
|
||||
ip="$(hostname -I | awk '{print $1}')"
|
||||
log "Done. Domain Scanner: http://${ip}/"
|
||||
log "Title: ${title:-Domain Scanner}"
|
||||
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'SERVER_IP')"
|
||||
log "Done."
|
||||
echo ""
|
||||
echo " Site root: ${WEB_ROOT}"
|
||||
echo " URL: http://${ip}/"
|
||||
echo " Pages: /connect.html /domains.html"
|
||||
echo ""
|
||||
echo " Amnezia (443) and Docker containers were not stopped."
|
||||
echo ""
|
||||
}
|
||||
|
||||
need_root
|
||||
install_packages
|
||||
install_node
|
||||
install_docker
|
||||
stop_legacy
|
||||
clone_or_update
|
||||
setup_env
|
||||
deploy_data_services
|
||||
build_app
|
||||
install_systemd
|
||||
install_nginx
|
||||
verify
|
||||
main() {
|
||||
need_root
|
||||
install_packages
|
||||
stop_legacy_scanner
|
||||
clone_repo
|
||||
deploy_web
|
||||
configure_nginx
|
||||
print_done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
24
nginx/domain-web.conf
Normal file
24
nginx/domain-web.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
# PCA Lab / Domain Web — static site on port 80
|
||||
# Installed to /etc/nginx/sites-available/domain-web
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /var/www/domain-web;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(json|jsx|html|css|js|png|jpg|svg|ico|woff2?)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Do not proxy — Amnezia and other services stay on 443 separately
|
||||
access_log /var/log/nginx/domain-web-access.log;
|
||||
error_log /var/log/nginx/domain-web-error.log;
|
||||
}
|
||||
10123
package-lock.json
generated
10123
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"name": "domain-scanner",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"postbuild": "bash scripts/sync-standalone-assets.sh",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"worker:monitors": "npx tsx scripts/monitor-worker.ts",
|
||||
"seed:admin": "npx tsx scripts/seed-admin.ts",
|
||||
"smoke": "bash scripts/smoke-test.sh"
|
||||
},
|
||||
"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",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,85 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
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)
|
||||
stage String?
|
||||
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")
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Node-native production deploy (low disk — no Docker image build)
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${DOMAIN_SCANNER_INSTALL_DIR:-/opt/domain_web}"
|
||||
SERVICE_NAME="domain-scanner"
|
||||
NGINX_SITE="domain-scanner"
|
||||
APP_PORT="${DOMAIN_SCANNER_PORT:-3000}"
|
||||
NODE_PORT="${APP_PORT}"
|
||||
|
||||
log() { echo "[domain-scanner-node] $*"; }
|
||||
|
||||
install_packages() {
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nginx git curl ca-certificates >/dev/null
|
||||
if ! command -v node >/dev/null || [[ $(node -v | cut -d. -f1 | tr -d v) -lt 20 ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq nodejs >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
setup_compose_db() {
|
||||
cd "${INSTALL_DIR}"
|
||||
if command -v docker-compose >/dev/null; then
|
||||
DC=docker-compose
|
||||
else
|
||||
DC="docker compose"
|
||||
fi
|
||||
$DC up -d postgres redis
|
||||
sleep 5
|
||||
}
|
||||
|
||||
setup_env() {
|
||||
if [[ ! -f "${INSTALL_DIR}/.env" ]]; then
|
||||
cp "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
|
||||
SECRET=$(openssl rand -base64 32 2>/dev/null || head -c 32 /dev/urandom | base64)
|
||||
cat >>"${INSTALL_DIR}/.env" <<EOF
|
||||
EOF
|
||||
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://scanner:scanner@127.0.0.1:5432/domain_scanner?schema=public|" "${INSTALL_DIR}/.env"
|
||||
sed -i "s|^REDIS_URL=.*|REDIS_URL=redis://127.0.0.1:6379|" "${INSTALL_DIR}/.env"
|
||||
sed -i "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=${SECRET}|" "${INSTALL_DIR}/.env"
|
||||
sed -i "s|^NEXTAUTH_URL=.*|NEXTAUTH_URL=http://$(hostname -I | awk '{print $1}')|" "${INSTALL_DIR}/.env"
|
||||
sed -i "s|^NEXT_PUBLIC_APP_URL=.*|NEXT_PUBLIC_APP_URL=http://$(hostname -I | awk '{print $1}')|" "${INSTALL_DIR}/.env"
|
||||
fi
|
||||
export PORT="${NODE_PORT}"
|
||||
}
|
||||
|
||||
build_app() {
|
||||
cd "${INSTALL_DIR}"
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npm run build
|
||||
bash scripts/sync-standalone-assets.sh "${INSTALL_DIR}"
|
||||
npx prisma db push --accept-data-loss
|
||||
}
|
||||
|
||||
install_systemd() {
|
||||
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=Domain Scanner (Next.js)
|
||||
After=network.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
ExecStart=/usr/bin/npm start
|
||||
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() {
|
||||
cat >"/etc/nginx/sites-available/${NGINX_SITE}" <<EOF
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
location /_next/static/ {
|
||||
alias ${INSTALL_DIR}/.next/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:${NODE_PORT};
|
||||
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 Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
EOF
|
||||
rm -f /etc/nginx/sites-enabled/geoexport-site /etc/nginx/sites-available/geoexport-site
|
||||
systemctl stop geoexport-site 2>/dev/null || true
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
||||
nginx -t && systemctl restart nginx
|
||||
}
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then echo "Run as root" >&2; exit 1; fi
|
||||
install_packages
|
||||
setup_env
|
||||
setup_compose_db
|
||||
build_app
|
||||
install_systemd
|
||||
install_nginx
|
||||
log "Done: http://$(hostname -I | awk '{print $1}')/"
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Background monitor checks (DNS change + SSL expiry MVP).
|
||||
* Run via: npm run worker:monitors
|
||||
*/
|
||||
import { runAllMonitorChecks } from "../src/lib/monitoring/check";
|
||||
|
||||
const intervalMs = Number(process.env.MONITOR_INTERVAL_MS ?? 300_000);
|
||||
|
||||
async function tick() {
|
||||
const summary = await runAllMonitorChecks();
|
||||
console.log(
|
||||
`[monitor-worker] ${new Date().toISOString()} checked=${summary.checked} with_alerts=${summary.alerts}`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await tick();
|
||||
setInterval(() => {
|
||||
tick().catch((e) => console.error("[monitor-worker]", e));
|
||||
}, intervalMs);
|
||||
console.log(`[monitor-worker] interval ${intervalMs / 1000}s`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const email = process.env.SEED_ADMIN_EMAIL?.toLowerCase();
|
||||
const password = process.env.SEED_ADMIN_PASSWORD;
|
||||
if (!email || !password) {
|
||||
console.log("[seed-admin] SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set — skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
await prisma.user.upsert({
|
||||
where: { email },
|
||||
create: { email, passwordHash, role: "ADMIN", name: "Admin" },
|
||||
update: { passwordHash, role: "ADMIN" },
|
||||
});
|
||||
console.log(`[seed-admin] admin ready: ${email}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${SMOKE_BASE_URL:-http://127.0.0.1:3000}"
|
||||
DOMAIN="${SMOKE_DOMAIN:-example.com}"
|
||||
|
||||
echo "[smoke] health"
|
||||
curl -fsS "${BASE}/api/health" | grep -q '"ok"' || { echo "health failed"; exit 1; }
|
||||
|
||||
echo "[smoke] scan POST"
|
||||
SCAN_JSON="$(curl -fsS -X POST "${BASE}/api/scan" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"domain\":\"${DOMAIN}\"}")"
|
||||
SCAN_ID="$(echo "${SCAN_JSON}" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)"
|
||||
if [[ -z "${SCAN_ID}" ]]; then
|
||||
echo "scan id missing: ${SCAN_JSON}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[smoke] wait scan ${SCAN_ID}"
|
||||
for _ in $(seq 1 90); do
|
||||
STATUS="$(curl -fsS "${BASE}/api/scan/${SCAN_ID}" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -1)"
|
||||
if [[ "${STATUS}" == "COMPLETED" || "${STATUS}" == "FAILED" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "${STATUS}" != "COMPLETED" ]]; then
|
||||
echo "scan did not complete: ${STATUS}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for path in / /dashboard /analytics /monitoring /api-docs /login; do
|
||||
echo "[smoke] GET ${path}"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "${BASE}${path}")"
|
||||
if [[ "${code}" != "200" ]]; then
|
||||
echo "page ${path} returned ${code}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[smoke] OK"
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copy Next.js static assets into standalone output (required for `node server.js`).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
STANDALONE="${ROOT}/.next/standalone"
|
||||
STATIC_SRC="${ROOT}/.next/static"
|
||||
PUBLIC_SRC="${ROOT}/public"
|
||||
|
||||
if [[ ! -d "${STANDALONE}" ]]; then
|
||||
echo "[sync-standalone-assets] No .next/standalone — skipping (non-standalone build?)" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "${STATIC_SRC}" ]]; then
|
||||
echo "[sync-standalone-assets] ERROR: missing ${STATIC_SRC} — run npm run build first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${STANDALONE}/.next"
|
||||
rm -rf "${STANDALONE}/.next/static"
|
||||
cp -a "${STATIC_SRC}" "${STANDALONE}/.next/static"
|
||||
|
||||
if [[ -d "${PUBLIC_SRC}" ]]; then
|
||||
rm -rf "${STANDALONE}/public"
|
||||
cp -a "${PUBLIC_SRC}" "${STANDALONE}/public"
|
||||
fi
|
||||
|
||||
echo "[sync-standalone-assets] OK: .next/static + public → ${STANDALONE}"
|
||||
2
site/.gitignore
vendored
2
site/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
@@ -1,7 +0,0 @@
|
||||
# Archived — GeoExport mirror
|
||||
|
||||
The `site/` directory is a **legacy static mirror** of geoexport.org. It is **not** deployed by `install.sh`.
|
||||
|
||||
Production installs use the **Domain Scanner** Next.js app at the repository root (`docker compose`).
|
||||
|
||||
Kept in git for reference only.
|
||||
@@ -1,47 +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
|
||||
```
|
||||
|
||||
## Патч экспорта
|
||||
|
||||
Перед запуском применяется `patch-bundle.mjs` (превью/скачивание, Keenetic, пустые ответы API).
|
||||
|
||||
```bash
|
||||
npm start # prestart → patch автоматически
|
||||
```
|
||||
|
||||
Карточка про Telegram-бота `@domain_searchPro_bot` — `assets/telegram-bot.js`.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Экспорт, поиск, DNS lookup и генерация routing проксируются на живой `geoexport.org` (нужен интернет).
|
||||
- Это копия UI и кэшированных справочников, не полный автономный бэкенд с базами `.dat`.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
(function () {
|
||||
const CARD =
|
||||
'<div class="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/40 shadow-sm p-4 text-sm text-slate-700 dark:text-slate-300">' +
|
||||
'<div class="font-medium text-slate-900 dark:text-slate-100 mb-1">Telegram-бот для поиска доменов</div>' +
|
||||
'<p class="text-xs leading-relaxed mb-2">' +
|
||||
"Если удобнее с телефона или нужен быстрый поиск без браузера — попробуйте " +
|
||||
'<a href="https://t.me/domain_searchPro_bot" target="_blank" rel="noopener noreferrer" class="font-mono text-blue-600 dark:text-blue-400 hover:underline">@domain_searchPro_bot</a>. ' +
|
||||
"Тот же смысл: найти домен, посмотреть IP и выгрузить списки, только в Telegram." +
|
||||
"</p>" +
|
||||
'<a href="https://t.me/domain_searchPro_bot" target="_blank" rel="noopener noreferrer" ' +
|
||||
'class="inline-flex items-center gap-1 text-xs px-3 py-1.5 rounded-xl bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white">' +
|
||||
"Открыть бота →</a></div>";
|
||||
|
||||
function inject() {
|
||||
if (document.querySelector("[data-telegram-bot-card]")) return;
|
||||
const host = document.querySelector(".max-w-3xl.mx-auto");
|
||||
if (!host) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.setAttribute("data-telegram-bot-card", "");
|
||||
wrap.className = "mt-4 mb-2";
|
||||
wrap.innerHTML = CARD;
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
const obs = new MutationObserver(() => inject());
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
inject();
|
||||
})();
|
||||
@@ -1 +0,0 @@
|
||||
{"last_update":"2026-05-24","updating":false,"sources":[{"source_slug":"runetfreedom","source_name":"RuNet Freedom","last_update":"2026-05-24","compatible":"Xray, 3x-ui, V2Ray"},{"source_slug":"loyalsoldier","source_name":"Loyalsoldier","last_update":"2026-05-24","compatible":"Xray, 3x-ui, V2Ray, Mihomo"},{"source_slug":"daniellavrushin","source_name":"DanielLavrushin (b4geoip)","last_update":"2026-05-24","compatible":"Xray, 3x-ui"},{"source_slug":"v2fly","source_name":"v2fly (официальный)","last_update":"2026-05-24","compatible":"Xray, 3x-ui, V2Ray, Sing-box"}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"presets":{"direct_sites":[{"id":"ru_sites","label":"Российские сайты","description":"Домены .ru, .рф и российские сервисы — идут напрямую без VPN","value":"geosite:category-ru","checked":true},{"id":"ru_whitelist","label":"Белый список РФ","description":"Дополнительный список российских сервисов и госсайтов","value":"geosite:whitelist","checked":true},{"id":"private","label":"Локальная сеть","description":"192.168.x.x, 10.x.x.x и другие приватные адреса — всегда напрямую","value":"geosite:private","checked":true},{"id":"apple","label":"Apple","description":"iCloud, App Store, Apple Maps — для корректной работы устройства","value":"geosite:apple","checked":true},{"id":"microsoft","label":"Microsoft","description":"Windows Update, Office, Teams — экономия трафика VPN","value":"geosite:microsoft","checked":false},{"id":"steam","label":"Steam","description":"Игры и обновления Steam — напрямую для скорости","value":"geosite:steam","checked":false},{"id":"google_cn","label":"Google (доступные сервисы)","description":"Сервисы Google доступные в РФ без VPN","value":"geosite:google@cn","checked":false}],"direct_ips":[{"id":"geoip_ru","label":"IP-адреса России","description":"Все российские IP-диапазоны — напрямую","value":"geoip:ru","checked":true},{"id":"geoip_private","label":"Приватные IP","description":"10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16","value":"geoip:private","checked":true}],"proxy_sites":[{"id":"youtube","label":"YouTube","description":"YouTube заблокирован в РФ — направляем через VPN","value":"geosite:youtube","checked":true},{"id":"telegram","label":"Telegram","description":"Серверы Telegram — через VPN для надёжности","value":"geosite:telegram","checked":true},{"id":"meta","label":"Meta (Instagram, Facebook)","description":"Instagram и Facebook заблокированы в РФ","value":"geosite:facebook","checked":true},{"id":"twitter","label":"Twitter / X","description":"Twitter заблокирован в РФ","value":"geosite:twitter","checked":true},{"id":"netflix","label":"Netflix","description":"Netflix недоступен в РФ","value":"geosite:netflix","checked":false},{"id":"spotify","label":"Spotify","description":"Spotify недоступен в РФ","value":"geosite:spotify","checked":false},{"id":"github","label":"GitHub","description":"GitHub периодически блокируется в РФ","value":"geosite:github","checked":false},{"id":"geolocation_notcn","label":"Все заблокированные (универсальный)","description":"Весь иностранный трафик не из РФ/CN — через VPN. Осторожно: большой список","value":"geosite:geolocation-!cn","checked":false}],"proxy_ips":[{"id":"geoip_telegram","label":"IP Telegram","description":"IP-диапазоны серверов Telegram","value":"geoip:telegram","checked":true}],"block_sites":[{"id":"ads","label":"Реклама","description":"Рекламные домены — блокируем полностью","value":"geosite:category-ads-all","checked":true}],"block_ips":[]},"geo_sources":{"loyalsoldier":{"label":"Loyalsoldier (рекомендуется)","geoip":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat","geosite":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"},"runetfreedom":{"label":"RuNet Freedom (РКН + Россия)","geoip":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat","geosite":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat"},"daniellavrushin":{"label":"DanielLavrushin (расширенная)","geoip":"https://github.com/DanielLavrushin/b4geoip/releases/latest/download/geoip.dat","geosite":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"},"v2fly":{"label":"v2fly (оригинальная)","geoip":"https://github.com/v2fly/geoip/releases/latest/download/geoip.dat","geosite":"https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"}},"route_orders":[{"value":"block-direct-proxy","label":"Блок → Прямой → VPN (рекомендуется)"},{"value":"block-proxy-direct","label":"Блок → VPN → Прямой"},{"value":"proxy-direct-block","label":"VPN → Прямой → Блок"}],"dns_types":[{"value":"DoH","label":"DoH (DNS over HTTPS, рекомендуется)"},{"value":"DoU","label":"DoU (DNS over UDP, быстрее)"}]}
|
||||
@@ -1 +0,0 @@
|
||||
[{"slug":"runetfreedom","name":"RuNet Freedom","description":"Российские правила блокировок, оптимизировано для обхода РКН","compatible":"Xray, 3x-ui, V2Ray","geoip_url":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat","geosite_url":"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat"},{"slug":"loyalsoldier","name":"Loyalsoldier","description":"Популярный набор правил, стандарт для большинства клиентов","compatible":"Xray, 3x-ui, V2Ray, Mihomo","geoip_url":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat","geosite_url":"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"},{"slug":"daniellavrushin","name":"DanielLavrushin (b4geoip)","description":"Расширенная база с дополнительными категориями (Akamai, CDN и др.)","compatible":"Xray, 3x-ui","geoip_url":"https://github.com/DanielLavrushin/b4geoip/releases/latest/download/geoip.dat","geosite_url":null},{"slug":"v2fly","name":"v2fly (официальный)","description":"Официальные базы проекта v2fly/domain-list-community","compatible":"Xray, 3x-ui, V2Ray, Sing-box","geoip_url":"https://github.com/v2fly/geoip/releases/latest/download/geoip.dat","geosite_url":"https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"}]
|
||||
@@ -1,14 +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>
|
||||
<script defer src="/assets/telegram-bot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +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",
|
||||
"patch": "node patch-bundle.mjs",
|
||||
"prestart": "node patch-bundle.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Patches GeoExport SPA bundle: export preview/download fixes + shared fetch helper.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const bundlePath = path.join(__dirname, "assets", "index-VQj200iM.js");
|
||||
|
||||
const HELPER = `async function _gxf(cats,type,fmt,source,label){const w=new URLSearchParams;cats.forEach(z=>w.append("category",z)),w.set("type",type);const af=fmt==="single-line"||fmt==="keenetic-ip"?"plain-lines":fmt;w.set("format",af),source&&w.set("source",source);const r=await fetch(\`/api/export?\${w}\`);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText||"Ошибка экспорта")}let t=await r.text();if(!t.trim())return"Нет данных для выбранных категорий, типа или источника. Попробуйте «Все» в фильтре источников или тип «Домены» / «IPv4».";if(fmt==="single-line")t=t.trim().split(/\\r?\\n/).join(",");if(fmt==="keenetic-ip"){const lines=t.trim().split(/\\r?\\n/).filter(x=>/^\\d/.test(x)&&x.includes("/")&&!x.includes(":"));t=lines.length?lines.map(line=>{const[ip,bits]=line.split("/"),n=parseInt(bits??"32",10),mask=n===32?"255.255.255.255":Array.from({length:4},(_,i)=>(65280>>Math.min(8,Math.max(0,n-i*8)))&255).join(".");return\`route ADD \${ip} MASK \${mask} 0.0.0.0 & rem \${label||cats[0]||"export"}\`}).join("\\n"):"Нет IPv4-подсетей для Keenetic. Выберите тип «IPv4» или «Домены + IPv4»."}return t}`;
|
||||
|
||||
const REPLACEMENTS = [
|
||||
{
|
||||
from: "function bm({svc:e,sources:t,sourceFilter:n})",
|
||||
to: `${HELPER}function bm({svc:e,sources:t,sourceFilter:n})`,
|
||||
},
|
||||
{
|
||||
from: 'p=async()=>{if(f){a(!0);try{const w=new URLSearchParams;C.forEach(z=>w.append("category",z.full_name)),w.set("type",m),w.set("format",c==="single-line"?"plain-lines":c),n!=="all"&&w.set("source",n);let P=await(await fetch(`/api/export?${w}`)).text();c==="single-line"&&(P=P.trim().split(/\\r?\\n/).join(",")),o(P)}finally{a(!1)}}}',
|
||||
to: 'p=async()=>{if(f){a(!0);try{o(await _gxf(C.map(z=>z.full_name),m,c,n!=="all"?n:"",e.name))}catch(w){o(w.message||"Ошибка экспорта")}finally{a(!1)}}}',
|
||||
},
|
||||
{
|
||||
from: 'function qm(){const[e]=hi(),t=e.getAll("category"),n=e.get("source"),[r,l]=k.useState("domains,ip4,ip6"),[s,o]=k.useState("plain-lines"),',
|
||||
to: 'function qm(){const[e]=hi(),t=e.getAll("category"),n=e.get("source"),[r,l]=k.useState(e.get("type")||"domains,ip4,ip6"),[s,o]=k.useState(e.get("format")||"plain-lines"),',
|
||||
},
|
||||
{
|
||||
from: 'const p=k.useCallback(async()=>{if(t.length){y(!0),C(null);try{const S=new URLSearchParams;t.forEach(T=>S.append("category",T)),S.set("type",r),S.set("format",s==="single-line"?"plain-lines":s),n&&S.set("source",n);const P=await fetch(`/api/export?${S}`);if(!P.ok){const T=await P.json().catch(()=>({}));C(T.detail??P.statusText),a(""),h(0);return}let z=await P.text();s==="single-line"&&(z=z.trim().split(/\\r?\\n/).join(",")),a(z),h(z.trim()?z.trim().split(/[,\\r?\\n]/).filter(Boolean).length:0)}finally{y(!1)}}},[t.join(","),r,s,n]);',
|
||||
to: 'const p=k.useCallback(async()=>{if(!t.length){a(""),C(null),h(0);return}y(!0),C(null);try{const z=await _gxf(t,r,s,n||"",t[0]);a(z),h(z.trim()&&!z.startsWith("Нет ")?z.trim().split(/[,\\r?\\n]/).filter(Boolean).length:0)}catch(S){C(S.message||"Ошибка экспорта"),a(""),h(0)}finally{y(!1)}},[t.join(","),r,s,n]);',
|
||||
},
|
||||
{
|
||||
from: 'value:g?"Загрузка…":j?"":i,readOnly:!0,className:"w-full h-80',
|
||||
to: 'value:g?"Загрузка…":j||i||(!t.length?"Выберите категории на главной и нажмите «Открыть экспорт →», либо добавьте ?category=geoip:telegram в адрес.":""),readOnly:!0,className:"w-full h-80',
|
||||
},
|
||||
];
|
||||
|
||||
let src = fs.readFileSync(bundlePath, "utf8");
|
||||
|
||||
if (src.includes("async function _gxf(")) {
|
||||
console.log("Already patched:", bundlePath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const { from, to } of REPLACEMENTS) {
|
||||
if (!src.includes(from)) {
|
||||
console.error("Patch anchor not found:", from.slice(0, 80));
|
||||
process.exit(1);
|
||||
}
|
||||
src = src.replace(from, to);
|
||||
}
|
||||
|
||||
fs.writeFileSync(bundlePath, src);
|
||||
console.log("Patched", bundlePath);
|
||||
146
site/server.mjs
146
site/server.mjs
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local GeoExport mirror: static assets + cached JSON + proxy for dynamic API.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
const UPSTREAM = "geoexport.org";
|
||||
|
||||
const LOCAL_API = new Set([
|
||||
"/api/presets",
|
||||
"/api/sources",
|
||||
"/api/last-update",
|
||||
"/api/routing/presets",
|
||||
]);
|
||||
|
||||
const DATA_MAP = {
|
||||
"/api/presets": "presets.json",
|
||||
"/api/sources": "sources.json",
|
||||
"/api/last-update": "last-update.json",
|
||||
"/api/routing/presets": "routing-presets.json",
|
||||
};
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
};
|
||||
|
||||
function send(res, status, body, headers = {}) {
|
||||
res.writeHead(status, headers);
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function serveFile(res, filePath) {
|
||||
const ext = path.extname(filePath);
|
||||
const type = MIME[ext] || "application/octet-stream";
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
send(res, 404, "Not found");
|
||||
return;
|
||||
}
|
||||
send(res, 200, data, { "Content-Type": type });
|
||||
});
|
||||
}
|
||||
|
||||
function serveLocalApi(res, pathname) {
|
||||
const file = DATA_MAP[pathname];
|
||||
if (!file) {
|
||||
send(res, 404, JSON.stringify({ error: "unknown local api" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const full = path.join(__dirname, "data", file);
|
||||
fs.readFile(full, (err, data) => {
|
||||
if (err) {
|
||||
send(res, 500, JSON.stringify({ error: err.message }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
return;
|
||||
}
|
||||
send(res, 200, data, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function proxyToUpstream(req, res) {
|
||||
const options = {
|
||||
hostname: UPSTREAM,
|
||||
port: 443,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: UPSTREAM,
|
||||
},
|
||||
};
|
||||
|
||||
const proxyReq = https.request(options, (proxyRes) => {
|
||||
const headers = { ...proxyRes.headers };
|
||||
delete headers["content-security-policy"];
|
||||
res.writeHead(proxyRes.statusCode || 502, headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
send(res, 502, JSON.stringify({ error: `Upstream error: ${err.message}` }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
function handler(req, res) {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
if (LOCAL_API.has(pathname)) {
|
||||
serveLocalApi(res, pathname);
|
||||
return;
|
||||
}
|
||||
proxyToUpstream(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/") pathname = "/index.html";
|
||||
|
||||
const safe = path.normalize(pathname).replace(/^(\.\.[/\\])+/, "");
|
||||
const filePath = path.join(__dirname, safe);
|
||||
|
||||
if (!filePath.startsWith(__dirname)) {
|
||||
send(res, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
fs.stat(filePath, (err, stat) => {
|
||||
if (err || !stat.isFile()) {
|
||||
if (!path.extname(pathname)) {
|
||||
serveFile(res, path.join(__dirname, "index.html"));
|
||||
return;
|
||||
}
|
||||
send(res, 404, "Not found");
|
||||
return;
|
||||
}
|
||||
serveFile(res, filePath);
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(handler).listen(PORT, () => {
|
||||
console.log(`GeoExport clone: http://127.0.0.1:${PORT}`);
|
||||
console.log(`Static + local JSON; export/search/lookup proxied to ${UPSTREAM}`);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { motion } from "framer-motion";
|
||||
import { Download, TrendingUp } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Stats = {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
daily: { date: string; scans: number; completed: number }[];
|
||||
topDomains: { domain: string; count: number }[];
|
||||
};
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/analytics/stats")
|
||||
.then((r) => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
}, []);
|
||||
|
||||
const exportSummary = () => {
|
||||
if (!stats) return;
|
||||
const blob = new Blob([JSON.stringify(stats, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "domain-scanner-analytics.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Analytics</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
Scan volume, success rate, and top domains — last 7 days.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={exportSummary} disabled={!stats}>
|
||||
<Download className="h-4 w-4" /> Export JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: "Total scans", value: stats?.total ?? "—" },
|
||||
{ label: "Success rate", value: stats ? `${stats.successRate}%` : "—" },
|
||||
{ label: "Failed", value: stats?.failed ?? "—" },
|
||||
].map((kpi, i) => (
|
||||
<motion.div
|
||||
key={kpi.label}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
>
|
||||
<Card className="glass">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-1 text-3xl font-semibold tabular-nums">{kpi.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="glass mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" /> Scans per day
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{stats?.daily.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.daily}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#a1a1aa", fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: "#a1a1aa", fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#18181b",
|
||||
border: "1px solid #3f3f46",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="scans" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="completed" fill="#22d3ee" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="py-16 text-center text-zinc-500">
|
||||
Run scans from the dashboard to populate charts.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{stats?.topDomains.length ? (
|
||||
<Card className="glass mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Top domains</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{stats.topDomains.map((d) => (
|
||||
<li
|
||||
key={d.domain}
|
||||
className="flex justify-between rounded-lg bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{d.domain}</span>
|
||||
<span className="text-zinc-500">{d.count} scans</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const [total, completed, failed, last7] = await Promise.all([
|
||||
prisma.scan.count(),
|
||||
prisma.scan.count({ where: { status: "COMPLETED" } }),
|
||||
prisma.scan.count({ where: { status: "FAILED" } }),
|
||||
prisma.scan.findMany({
|
||||
where: {
|
||||
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
select: { createdAt: true, status: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const byDay = new Map<string, { scans: number; completed: number }>();
|
||||
for (const row of last7) {
|
||||
const key = row.createdAt.toISOString().slice(0, 10);
|
||||
const cur = byDay.get(key) ?? { scans: 0, completed: 0 };
|
||||
cur.scans += 1;
|
||||
if (row.status === "COMPLETED") cur.completed += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
|
||||
const daily = [...byDay.entries()].map(([date, v]) => ({
|
||||
date,
|
||||
scans: v.scans,
|
||||
completed: v.completed,
|
||||
}));
|
||||
|
||||
const topDomains = await prisma.scan.groupBy({
|
||||
by: ["domain"],
|
||||
_count: { domain: true },
|
||||
orderBy: { _count: { domain: "desc" } },
|
||||
take: 8,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
successRate: total ? Math.round((completed / total) * 100) : 0,
|
||||
daily,
|
||||
topDomains: topDomains.map((d) => ({
|
||||
domain: d.domain,
|
||||
count: d._count.domain,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(8).max(128),
|
||||
name: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = body.email.toLowerCase();
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
|
||||
}
|
||||
|
||||
const seedEmail = process.env.SEED_ADMIN_EMAIL?.toLowerCase();
|
||||
const role =
|
||||
seedEmail && email === seedEmail ? ("ADMIN" as const) : ("USER" as const);
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: body.name,
|
||||
passwordHash,
|
||||
role,
|
||||
},
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ user }, { status: 201 });
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
stage: scan.stage,
|
||||
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, 450);
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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, stage) => {
|
||||
await prisma.scan.update({
|
||||
where: { id: scanId },
|
||||
data: { progress, stage },
|
||||
});
|
||||
});
|
||||
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 });
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
@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-zinc-200/80 bg-white/70 backdrop-blur-xl dark:border-white/10 dark:bg-white/5;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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";
|
||||
import { SiteFooter } from "@/components/layout/site-footer";
|
||||
|
||||
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>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
if (mode === "register") {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, name: name || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Registration failed");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await signIn("credentials", { email, password, redirect: false });
|
||||
setLoading(false);
|
||||
if (res?.error) {
|
||||
setError(mode === "login" ? "Invalid credentials" : "Registered but sign-in failed");
|
||||
return;
|
||||
}
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4 pb-24 pt-24">
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{mode === "login" ? "Sign in" : "Create account"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
{mode === "login"
|
||||
? "Access monitors and admin tools."
|
||||
: "Register to save monitors and history."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-2 rounded-xl bg-zinc-100 p-1 dark:bg-white/5">
|
||||
{(["login", "register"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition ${
|
||||
mode === m
|
||||
? "bg-white text-zinc-900 shadow dark:bg-zinc-800 dark:text-white"
|
||||
: "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{m === "login" ? "Sign in" : "Register"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||
{mode === "register" && (
|
||||
<Input
|
||||
placeholder="Name (optional)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password (min 8 chars)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Please wait…" : mode === "login" ? "Sign in" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-zinc-500">
|
||||
<Link href="/dashboard" className="text-violet-500 hover:underline">
|
||||
Continue without account
|
||||
</Link>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Bell, Plus, RefreshCw, ShieldAlert, CheckCircle2, AlertTriangle } 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 } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import type { MonitorLastResult } from "@/lib/monitoring/check";
|
||||
|
||||
type MonitorRow = {
|
||||
id: string;
|
||||
domain: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
lastChecked: string | null;
|
||||
lastResult: MonitorLastResult | null;
|
||||
};
|
||||
|
||||
function statusBadge(result: MonitorLastResult | null) {
|
||||
if (!result) return <Badge variant="warning">Pending</Badge>;
|
||||
if (result.status === "alert") return <Badge variant="danger">Alert</Badge>;
|
||||
if (result.status === "warning") return <Badge variant="warning">Warning</Badge>;
|
||||
return <Badge variant="success">OK</Badge>;
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [type, setType] = useState<"DNS" | "SSL" | "UPTIME">("DNS");
|
||||
const [monitors, setMonitors] = useState<MonitorRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/monitors");
|
||||
if (res.status === 401) {
|
||||
setMonitors([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMonitors(data.monitors ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const addMonitor = async () => {
|
||||
const res = await fetch("/api/monitors", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain, type }),
|
||||
});
|
||||
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) => [data.monitor, ...m]);
|
||||
setDomain("");
|
||||
toast.success("Monitor added — worker checks every ~5 min");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Monitoring</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
DNS change detection and SSL expiry alerts. Background worker runs on your VPS.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<Card className="glass mt-10">
|
||||
<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 sm:items-center">
|
||||
<Input
|
||||
placeholder="domain.com"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as typeof type)}
|
||||
className="h-10 rounded-xl border border-zinc-200 bg-white/80 px-3 text-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<option value="DNS">DNS</option>
|
||||
<option value="SSL">SSL</option>
|
||||
<option value="UPTIME">Uptime</option>
|
||||
</select>
|
||||
<Button onClick={addMonitor} disabled={!domain.trim()}>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{loading ? "Loading…" : `${monitors.length} monitor(s)`}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={load}>
|
||||
<RefreshCw className="h-4 w-4" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loading && 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-violet-500/10">
|
||||
<Bell className="h-8 w-8 text-violet-400" />
|
||||
</div>
|
||||
<p className="text-zinc-500">No monitors yet.</p>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
<Link href="/login" className="text-violet-500 hover:underline">
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
to track domains.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{monitors.map((m, i) => (
|
||||
<motion.li
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="glass rounded-2xl px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{m.domain}</p>
|
||||
<p className="text-sm text-zinc-500">{m.type} monitor</p>
|
||||
</div>
|
||||
{statusBadge(m.lastResult)}
|
||||
</div>
|
||||
{m.lastResult?.alerts?.length ? (
|
||||
<ul className="mt-3 space-y-1 text-sm text-amber-600 dark:text-amber-300">
|
||||
{m.lastResult.alerts.map((a) => (
|
||||
<li key={a} className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
{a}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : m.lastResult ? (
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-4 w-4" /> No issues on last check
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-zinc-500">
|
||||
<AlertTriangle className="h-4 w-4" /> Awaiting first worker run
|
||||
</p>
|
||||
)}
|
||||
{m.lastChecked && (
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
Last checked {new Date(m.lastChecked).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Features } from "@/components/landing/features";
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const envVars = [
|
||||
{
|
||||
name: "DATABASE_URL",
|
||||
desc: "PostgreSQL connection string for scans, users, and monitors.",
|
||||
},
|
||||
{
|
||||
name: "REDIS_URL",
|
||||
desc: "Redis for scan queue workers (optional on small VPS).",
|
||||
},
|
||||
{
|
||||
name: "NEXTAUTH_SECRET",
|
||||
desc: "Session encryption secret — rotate after deploy if leaked.",
|
||||
},
|
||||
{
|
||||
name: "NEXTAUTH_URL",
|
||||
desc: "Public app URL (e.g. http://your-server-ip).",
|
||||
},
|
||||
{
|
||||
name: "IPINFO_TOKEN",
|
||||
desc: "Optional token for richer geo/IP enrichment.",
|
||||
},
|
||||
{
|
||||
name: "SCAN_RATE_LIMIT_PER_HOUR",
|
||||
desc: "Per-IP scan throttle (default from .env.example).",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Settings</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
Server configuration lives in environment variables. Theme toggle is in the header.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-4">
|
||||
{envVars.map((v) => (
|
||||
<Card key={v.name} className="glass">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 font-mono text-base">
|
||||
{v.name}
|
||||
<Badge variant="default">.env</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-zinc-400">{v.desc}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="glass mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Account & monitoring</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/login">Sign in / Register</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="/monitoring">Monitors</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"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: "/analytics", label: "Analytics" },
|
||||
{ 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-zinc-200/80 bg-white/80 backdrop-blur-xl dark:border-white/5 dark:bg-zinc-950/70">
|
||||
<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-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white",
|
||||
pathname.startsWith(l.href) &&
|
||||
"bg-zinc-100 text-zinc-900 dark:bg-white/5 dark:text-white"
|
||||
)}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<ThemeToggle />
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<Link href="/login">Sign in</Link>
|
||||
</Button>
|
||||
<Button 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-zinc-200 bg-white p-4 dark:border-white/5 dark:bg-zinc-950 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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-white/5 py-12">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<p className="text-sm font-medium text-zinc-300">Domain Scanner</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">Self-hosted domain intelligence</p>
|
||||
<p className="mt-3">
|
||||
<Link
|
||||
href="https://github.com/andrey271192/Domain_web"
|
||||
className="text-sm text-violet-400 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-telegram-bot-card
|
||||
className="max-w-md rounded-2xl border border-white/10 bg-white/5 p-5 text-left backdrop-blur"
|
||||
>
|
||||
<p className="text-sm font-medium text-zinc-200">Telegram domain lookup</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Quick DNS/IP checks and list exports on the go — same idea as a scan, in chat.
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-sm">
|
||||
<Link
|
||||
href="https://t.me/domain_searchPro_bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400 hover:underline"
|
||||
>
|
||||
@domain_searchPro_bot
|
||||
</Link>
|
||||
</p>
|
||||
<Link
|
||||
href="https://t.me/domain_searchPro_bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex rounded-xl bg-gradient-to-r from-violet-600 to-cyan-500 px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Open in Telegram
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, ChevronDown, Network } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { ScanResult } from "@/lib/types";
|
||||
|
||||
type ExportKind = "ip-list" | "dns-hosts" | "infra-json";
|
||||
|
||||
function downloadText(filename: string, content: string, mime = "text/plain") {
|
||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function buildIpList(result: ScanResult): string {
|
||||
const ips = new Set<string>();
|
||||
if (result.geo?.ip) ips.add(result.geo.ip);
|
||||
for (const r of result.dns.records) {
|
||||
if (r.type === "A" || r.type === "AAAA") ips.add(r.value);
|
||||
}
|
||||
return [...ips].join("\n") + (ips.size ? "\n" : "");
|
||||
}
|
||||
|
||||
function buildHostsFile(result: ScanResult): string {
|
||||
const lines: string[] = [
|
||||
`# Infrastructure hosts — ${result.domain}`,
|
||||
`# Generated ${result.scannedAt}`,
|
||||
"",
|
||||
];
|
||||
for (const r of result.dns.records) {
|
||||
if (r.type === "A" || r.type === "AAAA") {
|
||||
lines.push(`${r.value}\t${result.domain}`);
|
||||
}
|
||||
}
|
||||
if (result.geo?.ip && !lines.some((l) => l.startsWith(result.geo!.ip))) {
|
||||
lines.push(`${result.geo.ip}\t${result.domain}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function buildInfraBundle(result: ScanResult): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
domain: result.domain,
|
||||
scannedAt: result.scannedAt,
|
||||
network: {
|
||||
ips: buildIpList(result).trim().split("\n").filter(Boolean),
|
||||
dns: result.dns.records,
|
||||
nameservers: result.dns.nameservers,
|
||||
mx: result.dns.mx,
|
||||
},
|
||||
edge: {
|
||||
cdnWaf: result.cdnWaf.detected,
|
||||
tech: result.tech,
|
||||
httpStatus: result.http.status,
|
||||
finalUrl: result.http.finalUrl,
|
||||
},
|
||||
geo: result.geo,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
const OPTIONS: { id: ExportKind; label: string; desc: string; ext: string }[] = [
|
||||
{
|
||||
id: "ip-list",
|
||||
label: "IP address list",
|
||||
desc: "Plain text — A/AAAA + resolved IP",
|
||||
ext: "txt",
|
||||
},
|
||||
{
|
||||
id: "dns-hosts",
|
||||
label: "Hosts-style DNS map",
|
||||
desc: "IP ↔ hostname lines for tooling",
|
||||
ext: "hosts",
|
||||
},
|
||||
{
|
||||
id: "infra-json",
|
||||
label: "Infrastructure bundle",
|
||||
desc: "DNS, edge, geo subset (not geoip presets)",
|
||||
ext: "json",
|
||||
},
|
||||
];
|
||||
|
||||
export function NetworkExport({ result }: { result: ScanResult }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState<ExportKind>("ip-list");
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (kind === "ip-list") return buildIpList(result).slice(0, 400);
|
||||
if (kind === "dns-hosts") return buildHostsFile(result).slice(0, 400);
|
||||
return buildInfraBundle(result).slice(0, 400);
|
||||
}, [kind, result]);
|
||||
|
||||
const exportFile = () => {
|
||||
const opt = OPTIONS.find((o) => o.id === kind)!;
|
||||
const base = `${result.domain}-network`;
|
||||
if (kind === "ip-list") {
|
||||
downloadText(`${base}-ips.${opt.ext}`, buildIpList(result));
|
||||
} else if (kind === "dns-hosts") {
|
||||
downloadText(`${base}.${opt.ext}`, buildHostsFile(result));
|
||||
} else {
|
||||
downloadText(`${base}.${opt.ext}`, buildInfraBundle(result), "application/json");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-violet-500/20">
|
||||
<CardHeader className="cursor-pointer" onClick={() => setOpen(!open)}>
|
||||
<CardTitle className="flex items-center justify-between gap-2 text-lg">
|
||||
<span className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-violet-400" />
|
||||
Network export
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-zinc-500 transition ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</CardTitle>
|
||||
<p className="text-sm font-normal text-zinc-500">
|
||||
Export resolved IPs and DNS from this scan — infrastructure report, not routing presets.
|
||||
</p>
|
||||
</CardHeader>
|
||||
{open && (
|
||||
<CardContent className="space-y-4 border-t border-white/5 pt-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setKind(o.id)}
|
||||
className={`rounded-xl border p-3 text-left transition ${
|
||||
kind === o.id
|
||||
? "border-violet-500/50 bg-violet-500/10"
|
||||
: "border-white/10 bg-white/5 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-zinc-200">{o.label}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{o.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto rounded-xl bg-black/40 p-3 font-mono text-xs text-zinc-400">
|
||||
{preview}
|
||||
{preview.length >= 400 ? "\n…" : ""}
|
||||
</pre>
|
||||
<Button type="button" onClick={exportFile}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download {OPTIONS.find((o) => o.id === kind)?.label}
|
||||
</Button>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
"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";
|
||||
import { NetworkExport } from "@/components/scan/network-export";
|
||||
|
||||
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 [stage, setStage] = useState<string | null>(null);
|
||||
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);
|
||||
setStage(null);
|
||||
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;
|
||||
stage?: string;
|
||||
result?: ScanResult;
|
||||
error?: string;
|
||||
};
|
||||
setProgress(msg.progress ?? 0);
|
||||
if (msg.stage) setStage(msg.stage);
|
||||
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">
|
||||
{stage && (
|
||||
<p className="text-sm text-zinc-500 transition-opacity">{stage}</p>
|
||||
)}
|
||||
<div className="h-2 overflow-hidden rounded-full bg-zinc-200 dark: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 className="lg:col-span-2">
|
||||
<NetworkExport result={result} />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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)} />;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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 };
|
||||
@@ -1,30 +0,0 @@
|
||||
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 };
|
||||
@@ -1,19 +0,0 @@
|
||||
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 };
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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",
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
import dns from "node:dns/promises";
|
||||
import tls from "node:tls";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Monitor } from "@prisma/client";
|
||||
|
||||
export type MonitorLastResult = {
|
||||
status: "ok" | "warning" | "alert";
|
||||
alerts: string[];
|
||||
snapshot: Record<string, unknown>;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
async function dnsFingerprint(domain: string): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
try {
|
||||
const a = await dns.resolve4(domain);
|
||||
parts.push(`A:${[...a].sort().join(",")}`);
|
||||
} catch {
|
||||
parts.push("A:");
|
||||
}
|
||||
try {
|
||||
const ns = await dns.resolveNs(domain);
|
||||
parts.push(`NS:${[...ns].sort().join(",")}`);
|
||||
} catch {
|
||||
parts.push("NS:");
|
||||
}
|
||||
try {
|
||||
const mx = await dns.resolveMx(domain);
|
||||
parts.push(`MX:${mx.map((m) => m.exchange).sort().join(",")}`);
|
||||
} catch {
|
||||
parts.push("MX:");
|
||||
}
|
||||
return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
async function sslDaysRemaining(domain: string): Promise<{ valid: boolean; daysRemaining?: number }> {
|
||||
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?.valid_to) {
|
||||
resolve({ valid: false });
|
||||
return;
|
||||
}
|
||||
const validTo = new Date(cert.valid_to);
|
||||
const daysRemaining = Math.floor((validTo.getTime() - Date.now()) / 86400000);
|
||||
resolve({ valid: daysRemaining > 0, daysRemaining });
|
||||
}
|
||||
);
|
||||
socket.on("error", () => resolve({ valid: false }));
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve({ valid: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkReachable(domain: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`https://${domain}`, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
return res.status < 500;
|
||||
} catch {
|
||||
try {
|
||||
const res = await fetch(`http://${domain}`, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
return res.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function previousSnapshot(monitor: Monitor): Record<string, unknown> | undefined {
|
||||
const lr = monitor.lastResult as MonitorLastResult | null;
|
||||
return lr?.snapshot;
|
||||
}
|
||||
|
||||
export async function runMonitorCheck(monitor: Monitor): Promise<MonitorLastResult> {
|
||||
const alerts: string[] = [];
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
let status: MonitorLastResult["status"] = "ok";
|
||||
const prev = previousSnapshot(monitor);
|
||||
|
||||
if (monitor.type === "DNS" || monitor.type === "UPTIME") {
|
||||
const dnsHash = await dnsFingerprint(monitor.domain);
|
||||
snapshot.dnsHash = dnsHash;
|
||||
if (prev?.dnsHash && prev.dnsHash !== dnsHash) {
|
||||
alerts.push("DNS records changed");
|
||||
status = "alert";
|
||||
}
|
||||
}
|
||||
|
||||
if (monitor.type === "SSL" || monitor.type === "UPTIME") {
|
||||
const ssl = await sslDaysRemaining(monitor.domain);
|
||||
snapshot.sslValid = ssl.valid;
|
||||
snapshot.sslDays = ssl.daysRemaining;
|
||||
if (!ssl.valid) {
|
||||
alerts.push("SSL certificate invalid or unreachable");
|
||||
status = "alert";
|
||||
} else if (ssl.daysRemaining !== undefined && ssl.daysRemaining <= 14) {
|
||||
alerts.push(`SSL expires in ${ssl.daysRemaining} day(s)`);
|
||||
status = status === "alert" ? "alert" : "warning";
|
||||
} else if (
|
||||
prev?.sslDays !== undefined &&
|
||||
ssl.daysRemaining !== undefined &&
|
||||
ssl.daysRemaining < (prev.sslDays as number)
|
||||
) {
|
||||
alerts.push("SSL expiry window shortened");
|
||||
if (status === "ok") status = "warning";
|
||||
}
|
||||
}
|
||||
|
||||
if (monitor.type === "UPTIME") {
|
||||
const reachable = await checkReachable(monitor.domain);
|
||||
snapshot.reachable = reachable;
|
||||
if (!reachable) {
|
||||
alerts.push("Host unreachable over HTTP(S)");
|
||||
status = "alert";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: alerts.length ? status : "ok",
|
||||
alerts,
|
||||
snapshot,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAllMonitorChecks(): Promise<{ checked: number; alerts: number }> {
|
||||
const { prisma } = await import("../prisma");
|
||||
const monitors = await prisma.monitor.findMany({ where: { enabled: true } });
|
||||
let alerts = 0;
|
||||
for (const monitor of monitors) {
|
||||
try {
|
||||
const result = await runMonitorCheck(monitor);
|
||||
if (result.alerts.length) alerts += 1;
|
||||
await prisma.monitor.update({
|
||||
where: { id: monitor.id },
|
||||
data: { lastChecked: new Date(), lastResult: result as object },
|
||||
});
|
||||
} catch {
|
||||
await prisma.monitor.update({
|
||||
where: { id: monitor.id },
|
||||
data: {
|
||||
lastChecked: new Date(),
|
||||
lastResult: {
|
||||
status: "alert",
|
||||
alerts: ["Check failed"],
|
||||
snapshot: {},
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { checked: monitors.length, alerts };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -1,31 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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 */
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
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"];
|
||||
const allKeys = Object.keys(headers).join(" ").toLowerCase();
|
||||
|
||||
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 (server.includes("caddy")) tech.push({ name: "Caddy", category: "Web Server", confidence: "high" });
|
||||
if (server.includes("openresty")) tech.push({ name: "OpenResty", category: "Web Server", confidence: "medium" });
|
||||
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 (powered.includes("php")) tech.push({ name: "PHP", category: "Runtime", 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" });
|
||||
if (headers["x-nf-request-id"]) tech.push({ name: "Netlify", category: "Hosting", confidence: "high" });
|
||||
if (headers["x-render-origin-server"]) tech.push({ name: "Render", category: "Hosting", confidence: "high" });
|
||||
if (headers["fly-request-id"]) tech.push({ name: "Fly.io", category: "Hosting", confidence: "high" });
|
||||
if (headers["x-powered-by"]?.includes("WP")) tech.push({ name: "WordPress", category: "CMS", confidence: "medium" });
|
||||
if (allKeys.includes("x-drupal")) tech.push({ name: "Drupal", category: "CMS", confidence: "low" });
|
||||
|
||||
return tech;
|
||||
}
|
||||
|
||||
function detectCdnWaf(headers: Record<string, string>): string[] {
|
||||
const detected = new Set<string>();
|
||||
const server = headers["server"]?.toLowerCase() ?? "";
|
||||
const via = headers["via"]?.toLowerCase() ?? "";
|
||||
|
||||
if (headers["cf-ray"] || server.includes("cloudflare")) detected.add("Cloudflare");
|
||||
if (headers["x-fastly-request-id"] || via.includes("fastly") || headers["x-served-by"]?.includes("fastly"))
|
||||
detected.add("Fastly");
|
||||
if (headers["x-akamai-transformed"] || server.includes("akamai")) detected.add("Akamai");
|
||||
if (headers["x-amz-cf-id"]) detected.add("AWS CloudFront");
|
||||
if (headers["x-sucuri-id"]) detected.add("Sucuri WAF");
|
||||
if (headers["x-incap-client-ip"] || headers["x-cdn"]?.includes("incapsula")) detected.add("Imperva");
|
||||
if (headers["x-azure-ref"]) detected.add("Azure Front Door");
|
||||
if (headers["x-goog-cache-control"] || headers["x-gfe-backend"]) detected.add("Google CDN");
|
||||
if (headers["x-bunnycdn"]) detected.add("BunnyCDN");
|
||||
if (headers["x-cache"]?.includes("netlify")) detected.add("Netlify Edge");
|
||||
if (headers["server"]?.includes("ddos-guard")) detected.add("DDoS-Guard");
|
||||
if (headers["x-fw-server"] || headers["x-served-by"]?.includes("fly")) detected.add("Fly.io Edge");
|
||||
|
||||
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>;
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
dns: "Resolving DNS",
|
||||
whois: "WHOIS lookup",
|
||||
ssl: "Checking SSL",
|
||||
http: "HTTP headers",
|
||||
geo: "Geo / IP",
|
||||
done: "Finalizing",
|
||||
};
|
||||
|
||||
export async function runDomainScan(
|
||||
domain: string,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ScanResult> {
|
||||
const report = async (p: number, stage: string) => {
|
||||
await onProgress?.(p, STAGE_LABELS[stage] ?? 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,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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[];
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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
20
src/types/next-auth.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
81
uninstall.sh
81
uninstall.sh
@@ -1,66 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Domain Scanner — one-command uninstall
|
||||
# Remove Domain Web nginx site (does not remove Amnezia/Docker)
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${DOMAIN_SCANNER_INSTALL_DIR:-/opt/domain-scanner}"
|
||||
SERVICE_NAME="domain-scanner"
|
||||
NGINX_SITE="domain-scanner"
|
||||
LEGACY_SERVICE="geoexport-site"
|
||||
LEGACY_NGINX="geoexport-site"
|
||||
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
|
||||
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
|
||||
NGINX_SITE="domain-web"
|
||||
|
||||
log() { echo "[domain-scanner-uninstall] $*"; }
|
||||
need_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Run as root: sudo bash uninstall.sh" >&2
|
||||
exit 1
|
||||
log() { echo "[domain-web-uninstall] $*"; }
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Run as root: sudo bash uninstall.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Removing nginx site..."
|
||||
rm -f "/etc/nginx/sites-enabled/${NGINX_SITE}"
|
||||
rm -f "/etc/nginx/sites-available/${NGINX_SITE}"
|
||||
|
||||
if [[ ! -f /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 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
fi
|
||||
|
||||
stop_docker() {
|
||||
log "Stopping Docker stack..."
|
||||
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose -f "${INSTALL_DIR}/docker-compose.yml" down -v 2>/dev/null || true
|
||||
else
|
||||
docker-compose -f "${INSTALL_DIR}/docker-compose.yml" down -v 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
stop_app() {
|
||||
systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
systemctl disable "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
}
|
||||
log "Removing web files (optional paths)..."
|
||||
rm -rf "${WEB_ROOT}" "${INSTALL_SRC}"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
remove_nginx() {
|
||||
log "Removing nginx site..."
|
||||
for site in "${NGINX_SITE}" "${LEGACY_NGINX}"; do
|
||||
rm -f "/etc/nginx/sites-enabled/${site}"
|
||||
rm -f "/etc/nginx/sites-available/${site}"
|
||||
done
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
remove_files() {
|
||||
log "Removing ${INSTALL_DIR}..."
|
||||
rm -rf "${INSTALL_DIR}"
|
||||
}
|
||||
|
||||
need_root
|
||||
stop_app
|
||||
stop_docker
|
||||
stop_legacy
|
||||
remove_nginx
|
||||
remove_files
|
||||
log "Domain Scanner removed."
|
||||
log "Uninstall complete."
|
||||
|
||||
461
web/connect-app.jsx
Normal file
461
web/connect-app.jsx
Normal file
@@ -0,0 +1,461 @@
|
||||
// VPN connection guide — Apple-light style
|
||||
|
||||
const { useState: useStateC, useEffect: useEffectC } = React;
|
||||
|
||||
// SiteNav from nav.jsx
|
||||
|
||||
// ───────── Hero ─────────
|
||||
function Hero(){
|
||||
return (
|
||||
<section style={{maxWidth:760, margin:"0 auto", padding:"56px 28px 28px", textAlign:"center"}}>
|
||||
<div style={{display:"inline-flex", alignItems:"center", gap:6,
|
||||
padding:"6px 14px", borderRadius:999, background:"#fff",
|
||||
border:"1px solid var(--line)", boxShadow:"0 1px 1px rgba(16,24,40,.03)",
|
||||
fontSize:11, fontWeight:700, color:"var(--accent-2)", letterSpacing:".12em"}}>
|
||||
<span style={{width:6, height:6, borderRadius:999, background:"var(--accent)"}}/>
|
||||
ЧАСТНЫЙ СЕРВЕР
|
||||
</div>
|
||||
<h1 style={{margin:"18px 0 14px", fontSize:54, fontWeight:800, letterSpacing:"-0.035em", lineHeight:1.04}}>
|
||||
Подключение к VPN
|
||||
</h1>
|
||||
<p style={{margin:"0 auto", maxWidth:560, fontSize:16, lineHeight:1.55, color:"var(--ink-2)"}}>
|
||||
Используйте приложение <strong>AmneziaVPN</strong>. Конфигурацию выдаёт администратор —
|
||||
импортируйте файл или ключ, который вам передали, и включите туннель. Ниже — пошаговые
|
||||
инструкции для WireGuard и Amnezia VPN.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Server info card ─────────
|
||||
function ServerCard(){
|
||||
const [copied,setCopied] = useStateC(false);
|
||||
function copy(){
|
||||
navigator.clipboard?.writeText("SERVER_IP");
|
||||
setCopied(true);
|
||||
setTimeout(()=>setCopied(false), 1600);
|
||||
}
|
||||
return (
|
||||
<Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}>
|
||||
<div style={{fontSize:11, fontWeight:700, letterSpacing:".14em", color:"var(--accent-2)",
|
||||
textTransform:"uppercase"}}>Сервер</div>
|
||||
<div style={{marginTop:10, fontSize:13, fontWeight:500, color:"var(--ink-3)"}}>Адрес</div>
|
||||
<div style={{marginTop:8, display:"flex", gap:10}}>
|
||||
<div style={{flex:1, height:54, display:"flex", alignItems:"center", padding:"0 18px",
|
||||
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
|
||||
fontFamily:"var(--mono)", fontSize:18, fontWeight:600,
|
||||
letterSpacing:"-0.005em", color:"var(--ink)"}}>
|
||||
SERVER_IP
|
||||
</div>
|
||||
<Button variant={copied?"success":"primary"} size="lg" onClick={copy}
|
||||
icon={copied?"check":"clipboard"}>{copied?"Скопировано":"Копировать"}</Button>
|
||||
</div>
|
||||
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)",
|
||||
display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}>
|
||||
<span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span>
|
||||
<Badge tone="blue" dot>XRay (TCP)</Badge>
|
||||
<Badge tone="purple" dot>AmneziaWG (UDP)</Badge>
|
||||
</div>
|
||||
<p style={{margin:"12px 0 0", fontSize:12.5, color:"var(--ink-3)"}}>
|
||||
Номера портов и тип протокола уже зашиты в конфиг Amnezia — вручную их обычно не вводят.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Big segmented control ─────────
|
||||
function BigSegment({ value, options, onChange }){
|
||||
return (
|
||||
<div style={{display:"grid", gridTemplateColumns:`repeat(${options.length},1fr)`,
|
||||
gap:8, padding:6, background:"#fff", border:"1px solid var(--line)", borderRadius:18,
|
||||
boxShadow:"0 1px 1px rgba(16,24,40,.03)"}}>
|
||||
{options.map(o=>{
|
||||
const on = value===o.id;
|
||||
return (
|
||||
<button key={o.id} onClick={()=>onChange(o.id)} style={{
|
||||
appearance:"none", border:0, height:52, padding:"0 16px",
|
||||
background: on ? "var(--accent-soft)" : "transparent",
|
||||
color: on ? "var(--accent-2)" : "var(--ink-2)",
|
||||
borderRadius:13, cursor:"pointer", fontWeight: on ? 600 : 500,
|
||||
fontSize:14.5, letterSpacing:"-0.005em",
|
||||
display:"inline-flex", alignItems:"center", justifyContent:"center", gap:10,
|
||||
transition:"all .15s ease",
|
||||
}}>
|
||||
{o.label}
|
||||
{o.tag && <Badge tone={o.tagTone || "neutral"} size="sm">{o.tag}</Badge>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── OS tabs (smaller, pill) ─────────
|
||||
function OSTabs({ value, onChange }){
|
||||
const os = [
|
||||
{id:"ios", label:"iOS"}, {id:"android", label:"Android"},
|
||||
{id:"windows", label:"Windows"}, {id:"macos", label:"macOS"}
|
||||
];
|
||||
return (
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:6,
|
||||
padding:5, background:"#fff", border:"1px solid var(--line)", borderRadius:14,
|
||||
boxShadow:"0 1px 1px rgba(16,24,40,.03)"}}>
|
||||
{os.map(o=>{
|
||||
const on = value===o.id;
|
||||
return (
|
||||
<button key={o.id} onClick={()=>onChange(o.id)} style={{
|
||||
appearance:"none", border:0, height:40,
|
||||
background: on ? "var(--ink)" : "transparent",
|
||||
color: on ? "#fff" : "var(--ink-2)",
|
||||
borderRadius:10, cursor:"pointer", fontWeight: on ? 600 : 500,
|
||||
fontSize:13.5, transition:"all .15s ease",
|
||||
boxShadow: on ? "0 2px 8px rgba(0,0,0,.18)" : "none"
|
||||
}}>{o.label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Numbered step ─────────
|
||||
function Step({ n, children }){
|
||||
return (
|
||||
<div style={{display:"flex", alignItems:"flex-start", gap:16, padding:"18px 22px",
|
||||
borderBottom:"1px solid var(--line-2)"}}>
|
||||
<div style={{
|
||||
flexShrink:0, width:32, height:32, borderRadius:999,
|
||||
background:"var(--accent-soft)", color:"var(--accent-2)",
|
||||
display:"grid", placeItems:"center", fontWeight:700, fontSize:14.5,
|
||||
fontVariantNumeric:"tabular-nums"
|
||||
}}>{n}</div>
|
||||
<div style={{flex:1, fontSize:15.5, lineHeight:1.55, color:"var(--ink)", paddingTop:4}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SuccessStep({ children }){
|
||||
return (
|
||||
<div style={{display:"flex", alignItems:"center", gap:14, padding:"18px 22px",
|
||||
background:"var(--green-soft)", borderTop:"1px solid var(--line-2)"}}>
|
||||
<div style={{
|
||||
flexShrink:0, width:30, height:30, borderRadius:999,
|
||||
background:"var(--green)", color:"#fff",
|
||||
display:"grid", placeItems:"center",
|
||||
boxShadow:"0 4px 10px rgba(52,199,89,.35)"
|
||||
}}>
|
||||
<Icon name="check" size={16} stroke={3} color="#fff"/>
|
||||
</div>
|
||||
<div style={{fontSize:15, fontWeight:500, color:"#1f7a3a"}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Steps card ─────────
|
||||
function StepsCard({ protocol, os }){
|
||||
// Headlines per protocol / OS
|
||||
const titles = {
|
||||
wg:{
|
||||
ios:{name:"WireGuard на iOS", sub:"iPhone и iPad · iOS 15 и новее", color:"linear-gradient(160deg,#5ac8fa,#0a84ff)", icon:"lock"},
|
||||
android:{name:"WireGuard на Android", sub:"Android 8 и новее", color:"linear-gradient(160deg,#7ce0a2,#2cb67d)", icon:"lock"},
|
||||
windows:{name:"WireGuard на Windows", sub:"Windows 10 / 11", color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", icon:"lock"},
|
||||
macos:{name:"WireGuard на macOS", sub:"macOS 12 и новее", color:"linear-gradient(160deg,#bdbdc0,#6e6e73)", icon:"lock"},
|
||||
},
|
||||
awg:{
|
||||
ios:{name:"Amnezia VPN на iOS", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#a385ff,#6a44f5)", icon:"shield"},
|
||||
android:{name:"Amnezia VPN на Android", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"shield"},
|
||||
windows:{name:"Amnezia VPN на Windows", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#a385ff,#6a44f5)", icon:"shield"},
|
||||
macos:{name:"Amnezia VPN на macOS", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"shield"},
|
||||
},
|
||||
};
|
||||
const t = titles[protocol][os];
|
||||
|
||||
// Steps — protocol/os specific
|
||||
let steps;
|
||||
if (protocol==="wg"){
|
||||
if (os==="ios") steps = [
|
||||
<>Откройте <strong>App Store</strong>, найдите <strong>WireGuard</strong> и нажмите «Установить»</>,
|
||||
<>Откройте приложение, нажмите <strong>+</strong> в правом верхнем углу</>,
|
||||
<>
|
||||
Выберите <strong>«Сканировать QR-код»</strong> и наведите камеру на QR — конфиг добавится автоматически
|
||||
<FineNote>Или «Создать из файла» → выберите файл <code>.conf</code></FineNote>
|
||||
</>,
|
||||
<>Придумайте имя туннелю и нажмите <strong>«Сохранить»</strong></>,
|
||||
<>Нажмите тумблер → появится запрос, нажмите <strong>«Разрешить»</strong></>,
|
||||
];
|
||||
else if (os==="android") steps = [
|
||||
<>Установите <strong>WireGuard</strong> из Google Play или скачайте <code>.apk</code> с wireguard.com</>,
|
||||
<>Откройте приложение, тапните по <strong>«+»</strong> внизу справа</>,
|
||||
<>
|
||||
Выберите <strong>«Сканировать из QR-кода»</strong> и наведите камеру на QR
|
||||
<FineNote>Или «Импорт из файла» → найдите <code>.conf</code> в загрузках</FineNote>
|
||||
</>,
|
||||
<>Дайте имя туннелю и подтвердите создание</>,
|
||||
<>Переключите тумблер вправо — разрешите создание VPN-подключения</>,
|
||||
];
|
||||
else if (os==="windows") steps = [
|
||||
<>Скачайте <strong>WireGuard for Windows</strong> с <code>wireguard.com/install</code></>,
|
||||
<>Установите и откройте приложение</>,
|
||||
<>Нажмите <strong>«Импорт туннеля из файла»</strong> и выберите <code>.conf</code></>,
|
||||
<>Подтвердите создание туннеля</>,
|
||||
<>Нажмите <strong>«Подключить»</strong> — индикатор станет зелёным</>,
|
||||
];
|
||||
else steps = [
|
||||
<>Установите <strong>WireGuard</strong> из Mac App Store</>,
|
||||
<>Откройте приложение в строке меню</>,
|
||||
<>Выберите <strong>«Импорт туннеля из файла»</strong> → <code>.conf</code></>,
|
||||
<>Подтвердите добавление профиля в Системные настройки</>,
|
||||
<>Включите тумблер и подтвердите системный запрос</>,
|
||||
];
|
||||
} else {
|
||||
// Amnezia VPN (same flow across OS — slight wording variations)
|
||||
steps = [
|
||||
<>Скачайте приложение <strong>AmneziaVPN</strong> с <code>amnezia.org</code> или из стора своей платформы</>,
|
||||
<>Запустите приложение и согласитесь с условиями использования</>,
|
||||
<>
|
||||
Нажмите <strong>«Добавить конфигурацию»</strong> → <strong>«Сканировать QR-код»</strong>
|
||||
<FineNote>Или импорт ключа: вставьте строку вида <code>vpn://…</code></FineNote>
|
||||
</>,
|
||||
<>При запросе системы — разрешите создание VPN-профиля</>,
|
||||
<>Нажмите большую кнопку <strong>«Подключиться»</strong> — индикатор станет зелёным</>,
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} style={{overflow:"hidden"}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:14, padding:"22px 22px 18px"}}>
|
||||
<GlyphTile size={48} radius={14} gradient={t.color} icon={t.icon}/>
|
||||
<div>
|
||||
<h2 style={{margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>{t.name}</h2>
|
||||
<div style={{marginTop:3, fontSize:13.5, color:"var(--ink-3)"}}>{t.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
{steps.map((s,i)=><Step key={i} n={i+1}>{s}</Step>)}
|
||||
<SuccessStep>
|
||||
Тумблер зелёный — вы подключены. Значок VPN в строке статуса.
|
||||
</SuccessStep>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FineNote({ children }){
|
||||
return (
|
||||
<div style={{marginTop:10, padding:"10px 14px", borderRadius:12,
|
||||
background:"var(--bg-tint)", border:"1px solid var(--line-2)",
|
||||
fontSize:13.5, color:"var(--ink-3)"}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Region change steps ─────────
|
||||
function RegionCard({ os }){
|
||||
const cfg = {
|
||||
ios:{name:"Смена региона App Store на iPhone", sub:"iOS · Apple ID", color:"linear-gradient(160deg,#ffd166,#ff9500)"},
|
||||
android:{name:"Смена региона Google Play", sub:"Android · Google аккаунт", color:"linear-gradient(160deg,#7ce0a2,#2cb67d)"},
|
||||
windows:{name:"Смена региона Microsoft Store", sub:"Windows 10 / 11", color:"linear-gradient(160deg,#7ec2ff,#0a84ff)"},
|
||||
macos:{name:"Смена региона Mac App Store", sub:"macOS", color:"linear-gradient(160deg,#bdbdc0,#6e6e73)"},
|
||||
};
|
||||
const t = cfg[os] || cfg.ios;
|
||||
let steps;
|
||||
if (os==="ios" || os==="macos") steps = [
|
||||
<>Подключите VPN к серверу нужной страны</>,
|
||||
<>Откройте <strong>«Настройки»</strong> → ваше имя → <strong>«Медиаматериалы и покупки»</strong></>,
|
||||
<>Нажмите <strong>«Просмотреть учётную запись»</strong> → подтвердите вход</>,
|
||||
<>Тапните <strong>«Страна или регион»</strong> → <strong>«Изменить страну или регион»</strong></>,
|
||||
<>Выберите страну, примите условия, заполните адрес и способ оплаты <strong>«Нет»</strong></>,
|
||||
];
|
||||
else if (os==="android") steps = [
|
||||
<>Подключите VPN к серверу нужной страны</>,
|
||||
<>Очистите кеш и данные <strong>Google Play Store</strong> в настройках устройства</>,
|
||||
<>Откройте Play Store → меню → <strong>«Настройки» → «Общие» → «Настройки аккаунта»</strong></>,
|
||||
<>Выберите <strong>«Страна и профили»</strong>, подтвердите смену</>,
|
||||
<>Дождитесь до 24 часов и проверьте — регион обновится</>,
|
||||
];
|
||||
else steps = [
|
||||
<>Подключите VPN к серверу нужной страны</>,
|
||||
<>Откройте <strong>«Параметры» → «Время и язык» → «Язык и регион»</strong></>,
|
||||
<>В разделе <strong>«Страна или регион»</strong> выберите нужную страну</>,
|
||||
<>Перезапустите Microsoft Store</>,
|
||||
<>Войдите заново под нужным аккаунтом</>,
|
||||
];
|
||||
return (
|
||||
<Card padding={0} style={{overflow:"hidden"}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:14, padding:"22px 22px 18px"}}>
|
||||
<GlyphTile size={48} radius={14} gradient={t.color} glyph="🛍️"/>
|
||||
<div>
|
||||
<h2 style={{margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>{t.name}</h2>
|
||||
<div style={{marginTop:3, fontSize:13.5, color:"var(--ink-3)"}}>{t.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
{steps.map((s,i)=><Step key={i} n={i+1}>{s}</Step>)}
|
||||
<SuccessStep>
|
||||
Регион изменён — теперь доступны приложения этой страны.
|
||||
</SuccessStep>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Verify section ─────────
|
||||
function VerifySection(){
|
||||
return (
|
||||
<section style={{marginTop:48, maxWidth:1020, marginLeft:"auto", marginRight:"auto", padding:"0 28px"}}>
|
||||
<h3 style={{margin:"0 0 16px", fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>
|
||||
Как проверить, что VPN работает?
|
||||
</h3>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(3,1fr)", gap:14}}>
|
||||
{[
|
||||
{n:1, text:<>Откройте <code style={cstyle()}>2ip.ru</code> или <code style={cstyle()}>whatismyip.com</code></>},
|
||||
{n:2, text:<>Страна должна измениться на страну вашего сервера</>},
|
||||
{n:3, text:<>Попробуйте открыть нужный сайт — загрузится</>},
|
||||
].map(c=>(
|
||||
<Card key={c.n} padding={18}>
|
||||
<div style={{display:"flex", alignItems:"flex-start", gap:12}}>
|
||||
<div style={{
|
||||
width:30, height:30, borderRadius:999, flexShrink:0,
|
||||
background:"var(--accent-soft)", color:"var(--accent-2)",
|
||||
display:"grid", placeItems:"center", fontWeight:700, fontSize:14
|
||||
}}>{c.n}</div>
|
||||
<div style={{fontSize:14.5, lineHeight:1.55, color:"var(--ink)"}}>{c.text}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function cstyle(){
|
||||
return { background:"var(--bg-tint)", border:"1px solid var(--line)",
|
||||
padding:"2px 8px", borderRadius:8, fontSize:12.5, color:"var(--ink-2)" };
|
||||
}
|
||||
|
||||
// ───────── Troubleshooting ─────────
|
||||
function Troubleshooting(){
|
||||
const items = [
|
||||
{ title:"VPN не подключается", icon:"refresh", color:"linear-gradient(160deg,#ff8a8a,#ff3b30)", points:[
|
||||
"Перезапустите приложение",
|
||||
"Выключите/включите Wi-Fi или мобильный интернет",
|
||||
"Удалите туннель и добавьте конфиг заново",
|
||||
"Попробуйте другую сеть",
|
||||
]},
|
||||
{ title:"VPN включён, нет интернета", icon:"globe", color:"linear-gradient(160deg,#ffd166,#ff9500)", points:[
|
||||
"Отключитесь и подключитесь снова",
|
||||
"Убедитесь, что конфиг актуальный",
|
||||
"Перезагрузите устройство",
|
||||
"Напишите в поддержку",
|
||||
]},
|
||||
{ title:"Сайты не открываются", icon:"search", color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", points:[
|
||||
"Очистите кеш браузера",
|
||||
"Смените DNS на 1.1.1.1",
|
||||
"Откройте в режиме инкогнито",
|
||||
"Попробуйте другой браузер",
|
||||
]},
|
||||
{ title:"Медленная скорость", icon:"gauge", color:"linear-gradient(160deg,#a385ff,#6a44f5)", points:[
|
||||
"Проверьте скорость без VPN",
|
||||
"Переподключитесь",
|
||||
"Закройте лишние приложения",
|
||||
"Переключитесь Wi-Fi ↔ 4G",
|
||||
]},
|
||||
];
|
||||
return (
|
||||
<section style={{marginTop:40, maxWidth:1020, marginLeft:"auto", marginRight:"auto", padding:"0 28px"}}>
|
||||
<h3 style={{margin:"0 0 16px", fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>
|
||||
Что делать, если что-то пошло не так
|
||||
</h3>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:14}}>
|
||||
{items.map(it=>(
|
||||
<Card key={it.title} padding={18}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10, marginBottom:12}}>
|
||||
<GlyphTile size={32} radius={10} gradient={it.color} icon={it.icon}/>
|
||||
<div style={{fontSize:14.5, fontWeight:700, letterSpacing:"-0.005em"}}>{it.title}</div>
|
||||
</div>
|
||||
<ul style={{margin:0, padding:0, listStyle:"none", display:"flex",
|
||||
flexDirection:"column", gap:8}}>
|
||||
{it.points.map((p,i)=>(
|
||||
<li key={i} style={{display:"flex", alignItems:"flex-start", gap:8,
|
||||
fontSize:13.5, color:"var(--ink-2)", lineHeight:1.45}}>
|
||||
<span style={{width:5, height:5, borderRadius:999, background:"var(--accent)",
|
||||
marginTop:7, flexShrink:0}}/>
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Footer ─────────
|
||||
function Footer(){
|
||||
return (
|
||||
<footer style={{maxWidth:760, margin:"56px auto 40px", padding:"0 28px", textAlign:"center"}}>
|
||||
<p style={{margin:0, fontSize:14, color:"var(--ink-2)"}}>
|
||||
Возникли проблемы с подключением — <a href="https://t.me/PCA_Amnezia_support_bot" target="_blank" rel="noreferrer" style={{color:"var(--accent-2)", fontWeight:600, display:"inline-flex", alignItems:"center", gap:6}}><Icon name="plane" size={14}/> напишите в Telegram-бот @PCA_Amnezia_support_bot</a>.
|
||||
</p>
|
||||
<p style={{margin:"10px 0 0", fontSize:12.5, color:"var(--ink-3)"}}>
|
||||
Страница только для приглашённых пользователей. Не передавайте конфиг третьим лицам.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Main App ─────────
|
||||
function ConnectApp(){
|
||||
const initialMode = (typeof URLSearchParams !== "undefined" && new URLSearchParams(location.search).get("mode") === "region")
|
||||
? "region" : "connect";
|
||||
const [mode, setMode] = useStateC(initialMode); // connect | region
|
||||
const [protocol, setProtocol] = useStateC("wg"); // wg | awg
|
||||
const [os, setOs] = useStateC("ios");
|
||||
|
||||
useEffectC(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("mode") === "region") setMode("region");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SiteNav brand="Кабинет студента" brandSub="VPN · РЕГИОН · AMNEZIA" />
|
||||
<Hero/>
|
||||
|
||||
<div style={{maxWidth:880, margin:"0 auto", padding:"0 28px"}}>
|
||||
<ServerCard/>
|
||||
|
||||
{/* Mode big segment */}
|
||||
<div style={{margin:"22px 0 14px"}}>
|
||||
<BigSegment value={mode} onChange={setMode} options={[
|
||||
{id:"connect", label:"Подключение VPN"},
|
||||
{id:"region", label:"Смена региона App Store"},
|
||||
]}/>
|
||||
</div>
|
||||
|
||||
{/* Protocol (only for connect mode) */}
|
||||
{mode==="connect" && (
|
||||
<div style={{marginBottom:14}}>
|
||||
<BigSegment value={protocol} onChange={setProtocol} options={[
|
||||
{id:"wg", label:"WireGuard", tag:"стандарт", tagTone:"blue"},
|
||||
{id:"awg", label:"Amnezia VPN", tag:"обход DPI", tagTone:"purple"},
|
||||
]}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OS */}
|
||||
<div style={{marginBottom:18}}>
|
||||
<OSTabs value={os} onChange={setOs}/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{mode==="connect"
|
||||
? <StepsCard protocol={protocol} os={os}/>
|
||||
: <RegionCard os={os}/>}
|
||||
</div>
|
||||
|
||||
<VerifySection/>
|
||||
<Troubleshooting/>
|
||||
<Footer/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<ConnectApp/>);
|
||||
71
web/connect.html
Normal file
71
web/connect.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Подключение к VPN — Amnezia</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f4f5f7;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-tint: #f9fafb;
|
||||
--ink: #1d1d1f;
|
||||
--ink-2: #424245;
|
||||
--ink-3: #6e6e73;
|
||||
--ink-4: #8e8e93;
|
||||
--line: rgba(0,0,0,0.07);
|
||||
--line-2: rgba(0,0,0,0.04);
|
||||
--accent: #7c5cff;
|
||||
--accent-2:#5e3df1;
|
||||
--accent-soft:#efebff;
|
||||
--blue:#0a84ff;
|
||||
--blue-soft:#e6f0ff;
|
||||
--green:#34c759;
|
||||
--green-soft:#e4f8ea;
|
||||
--orange:#ff9f0a;
|
||||
--orange-soft:#fff2dc;
|
||||
--red:#ff3b30;
|
||||
--red-soft:#ffe9e7;
|
||||
--radius-card: 22px;
|
||||
--radius-inner: 14px;
|
||||
--radius-pill: 999px;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,.7) inset, 0 1px 2px rgba(16,24,40,.04), 0 8px 24px rgba(16,24,40,.05);
|
||||
--shadow-pop: 0 1px 0 rgba(255,255,255,.7) inset, 0 4px 14px rgba(16,24,40,.08), 0 24px 56px rgba(16,24,40,.10);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
font-feature-settings:"ss01","cv11";}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit}
|
||||
::selection{background:var(--accent-soft);color:var(--ink)}
|
||||
code,kbd{font-family:var(--mono); font-size:.92em}
|
||||
|
||||
body{
|
||||
background:
|
||||
radial-gradient(1100px 600px at 12% -10%, #ecebff 0%, transparent 60%),
|
||||
radial-gradient(900px 500px at 100% 0%, #e7f3ff 0%, transparent 55%),
|
||||
radial-gradient(800px 600px at 50% 100%, #fff1f4 0%, transparent 60%),
|
||||
var(--bg);
|
||||
min-height:100vh;
|
||||
}
|
||||
#root{min-height:100vh}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="nav.jsx"></script>
|
||||
<script type="text/babel" src="connect-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
130
web/data/blocked-domains.json
Normal file
130
web/data/blocked-domains.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"updated": "2026-05-28",
|
||||
"groups": [
|
||||
{
|
||||
"id": "media",
|
||||
"title": "Медиа",
|
||||
"icon": "🎬",
|
||||
"description": "Стриминг, видео и музыкальные сервисы",
|
||||
"domains": [
|
||||
"netflix.com",
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"googlevideo.com",
|
||||
"ytimg.com",
|
||||
"twitch.tv",
|
||||
"spotify.com",
|
||||
"hulu.com",
|
||||
"disneyplus.com",
|
||||
"hbomax.com",
|
||||
"soundcloud.com",
|
||||
"vimeo.com"
|
||||
],
|
||||
"ips": [
|
||||
"23.246.0.0/18",
|
||||
"208.65.153.0/24",
|
||||
"142.250.0.0/15"
|
||||
],
|
||||
"v2fly_tags": ["geosite:netflix", "geosite:youtube", "geosite:twitch", "geosite:spotify"]
|
||||
},
|
||||
{
|
||||
"id": "social",
|
||||
"title": "Социальные сети",
|
||||
"icon": "🌐",
|
||||
"description": "Соцсети и платформы Meta, X и др.",
|
||||
"domains": [
|
||||
"facebook.com",
|
||||
"instagram.com",
|
||||
"cdninstagram.com",
|
||||
"x.com",
|
||||
"twitter.com",
|
||||
"twimg.com",
|
||||
"linkedin.com",
|
||||
"pinterest.com",
|
||||
"reddit.com",
|
||||
"tumblr.com",
|
||||
"threads.net"
|
||||
],
|
||||
"ips": [
|
||||
"31.13.64.0/18",
|
||||
"104.244.42.0/24"
|
||||
],
|
||||
"v2fly_tags": ["geosite:facebook", "geosite:instagram", "geosite:twitter"]
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"title": "Искусственный интеллект",
|
||||
"icon": "🤖",
|
||||
"description": "Чат-боты, LLM и AI-платформы",
|
||||
"domains": [
|
||||
"openai.com",
|
||||
"chatgpt.com",
|
||||
"chat.openai.com",
|
||||
"anthropic.com",
|
||||
"claude.ai",
|
||||
"gemini.google.com",
|
||||
"bard.google.com",
|
||||
"perplexity.ai",
|
||||
"midjourney.com",
|
||||
"huggingface.co",
|
||||
"copilot.microsoft.com",
|
||||
"poe.com"
|
||||
],
|
||||
"ips": [
|
||||
"104.18.0.0/16",
|
||||
"185.199.108.0/22"
|
||||
],
|
||||
"v2fly_tags": ["geosite:openai", "geosite:anthropic", "geosite:google-gemini"]
|
||||
},
|
||||
{
|
||||
"id": "messengers",
|
||||
"title": "Мессенджеры",
|
||||
"icon": "💬",
|
||||
"description": "Мессенджеры и голосовые чаты",
|
||||
"domains": [
|
||||
"telegram.org",
|
||||
"t.me",
|
||||
"telegram.me",
|
||||
"whatsapp.com",
|
||||
"web.whatsapp.com",
|
||||
"signal.org",
|
||||
"discord.com",
|
||||
"discordapp.com",
|
||||
"viber.com",
|
||||
"skype.com",
|
||||
"zoom.us"
|
||||
],
|
||||
"ips": [
|
||||
"91.108.4.0/22",
|
||||
"149.154.160.0/20"
|
||||
],
|
||||
"v2fly_tags": ["geosite:telegram", "geosite:whatsapp", "geosite:discord"]
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"title": "Другое / VPN / Dev",
|
||||
"icon": "🛠️",
|
||||
"description": "Игры, разработка, облака и инструменты",
|
||||
"domains": [
|
||||
"github.com",
|
||||
"githubusercontent.com",
|
||||
"gitlab.com",
|
||||
"docker.com",
|
||||
"cloudflare.com",
|
||||
"notion.so",
|
||||
"figma.com",
|
||||
"slack.com",
|
||||
"store.steampowered.com",
|
||||
"steampowered.com",
|
||||
"epicgames.com",
|
||||
"playstation.com",
|
||||
"xbox.com"
|
||||
],
|
||||
"ips": [
|
||||
"140.82.112.0/20",
|
||||
"104.16.0.0/13"
|
||||
],
|
||||
"v2fly_tags": ["geosite:github", "geosite:steam", "geosite:cloudflare"]
|
||||
}
|
||||
]
|
||||
}
|
||||
214
web/domains-app.jsx
Normal file
214
web/domains-app.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
// Curated blocked domains — static lists (no search)
|
||||
|
||||
const { useState, useEffect, useMemo } = React;
|
||||
|
||||
function cidrToMask(bits) {
|
||||
const n = parseInt(bits || "32", 10);
|
||||
if (n === 32) return "255.255.255.255";
|
||||
return Array.from({ length: 4 }, (_, i) => (65280 >> Math.min(8, Math.max(0, n - i * 8))) & 255).join(".");
|
||||
}
|
||||
|
||||
function buildKeeneticBat(group, allGroups) {
|
||||
const lines = ["@echo off", "rem Keenetic static routes — " + (group ? group.title : "все группы"), ""];
|
||||
const src = group ? [group] : allGroups;
|
||||
const seen = new Set();
|
||||
src.forEach((g) => {
|
||||
(g.ips || []).forEach((cidr) => {
|
||||
const [ip, bits] = cidr.split("/");
|
||||
if (!ip || seen.has(cidr)) return;
|
||||
seen.add(cidr);
|
||||
const mask = cidrToMask(bits);
|
||||
lines.push(`route ADD ${ip} MASK ${mask} 0.0.0.0 & rem ${g.title}`);
|
||||
});
|
||||
});
|
||||
if (lines.length <= 3) {
|
||||
lines.push("rem IP-префиксы не заданы — используйте список доменов вручную в VPN");
|
||||
}
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
function downloadText(filename, text) {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [copied, setCopied] = useState(null);
|
||||
const domainText = group.domains.join("\n");
|
||||
const ipText = (group.ips || []).join("\n");
|
||||
|
||||
async function handleCopy(kind) {
|
||||
const text = kind === "domains" ? domainText : ipText;
|
||||
if (!text) return;
|
||||
await copyText(text);
|
||||
setCopied(kind);
|
||||
onCopyDomains?.(kind);
|
||||
setTimeout(() => setCopied(null), 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} style={{ overflow: "hidden" }}>
|
||||
<button type="button" onClick={() => setOpen(!open)} style={{
|
||||
width: "100%", appearance: "none", border: 0, background: "#fff",
|
||||
padding: "20px 22px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
cursor: "pointer", textAlign: "left",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span style={{ fontSize: 28 }}>{group.icon}</span>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{group.title}</h2>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: "var(--ink-3)" }}>{group.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Badge tone="neutral" size="sm">{group.domains.length} доменов</Badge>
|
||||
<Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ borderTop: "1px solid var(--line-2)", padding: "18px 22px 22px" }}>
|
||||
{group.v2fly_tags?.length > 0 && (
|
||||
<div style={{ marginBottom: 14, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{group.v2fly_tags.map((t) => (
|
||||
<Badge key={t} tone="purple" size="sm">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 8, marginBottom: 16,
|
||||
}}>
|
||||
{group.domains.map((d) => (
|
||||
<div key={d} style={{
|
||||
padding: "10px 12px", borderRadius: 10, background: "var(--bg-tint)",
|
||||
border: "1px solid var(--line-2)", fontFamily: "var(--mono)", fontSize: 12.5,
|
||||
}}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
{(group.ips || []).length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 8 }}>IP / CIDR (префиксы)</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{group.ips.map((ip) => (
|
||||
<code key={ip} style={{
|
||||
padding: "6px 10px", borderRadius: 8, background: "var(--brand-soft)",
|
||||
fontSize: 12, fontFamily: "var(--mono)",
|
||||
}}>{ip}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
<Button variant="secondary" size="sm" icon="clipboard"
|
||||
onClick={() => handleCopy("domains")}>
|
||||
{copied === "domains" ? "Скопировано" : "Копировать домены"}
|
||||
</Button>
|
||||
{(group.ips || []).length > 0 && (
|
||||
<Button variant="secondary" size="sm" icon="clipboard"
|
||||
onClick={() => handleCopy("ips")}>
|
||||
{copied === "ips" ? "Скопировано" : "Копировать IP"}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" size="sm" icon="download"
|
||||
onClick={() => onDownloadBat(group)}>
|
||||
Keenetic .bat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainsApp() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("data/blocked-domains.json")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setGroups(data.groups || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return groups;
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
domains: g.domains.filter((d) => d.includes(q) || g.title.toLowerCase().includes(q)),
|
||||
})).filter((g) => g.domains.length > 0);
|
||||
}, [groups, filter]);
|
||||
|
||||
const allDomains = groups.flatMap((g) => g.domains).join("\n");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" />
|
||||
<section style={{ maxWidth: 960, margin: "0 auto", padding: "40px 24px 24px" }}>
|
||||
<Badge tone="blue" size="sm">Статические списки · без поиска</Badge>
|
||||
<h1 style={{ margin: "14px 0 10px", fontSize: 42, fontWeight: 800, letterSpacing: "-0.03em" }}>
|
||||
Популярные заблокированные домены
|
||||
</h1>
|
||||
<p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}>
|
||||
Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic.
|
||||
Поиск по crt.sh на сайте отключён — только готовые пресеты по категориям.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 28 }}>
|
||||
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Фильтр по домену…"
|
||||
style={{
|
||||
flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12,
|
||||
border: "1px solid var(--line)", fontSize: 14,
|
||||
}} />
|
||||
<Button variant="secondary" icon="clipboard" onClick={() => copyText(allDomains)}>
|
||||
Все домены
|
||||
</Button>
|
||||
<Button variant="primary" icon="download" onClick={() => downloadText("keenetic-all-groups.bat", buildKeeneticBat(null, groups))}>
|
||||
.bat все группы
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: "var(--ink-3)" }}>Загрузка списков…</p>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<Card padding={24}><p style={{ margin: 0, color: "var(--ink-3)" }}>Ничего не найдено по фильтру.</p></Card>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{filtered.map((g) => (
|
||||
<DomainGroupCard key={g.id} group={g}
|
||||
onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<footer style={{ maxWidth: 960, margin: "48px auto", padding: "0 24px 40px", textAlign: "center", fontSize: 13, color: "var(--ink-3)" }}>
|
||||
<a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a>
|
||||
{" · "}
|
||||
<a href="index.html">На главную</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<DomainsApp />);
|
||||
39
web/domains.html
Normal file
39
web/domains.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Группы доменов — PCA Lab</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f4f5f7; --bg-elev: #ffffff; --bg-tint: #f9fafb;
|
||||
--ink: #1d1d1f; --ink-2: #424245; --ink-3: #6e6e73;
|
||||
--line: rgba(0,0,0,0.07); --line-2: rgba(0,0,0,0.04);
|
||||
--accent: #7c5cff; --accent-2:#5e3df1; --accent-soft:#efebff;
|
||||
--brand:#0f5dff; --brand-soft:#e6f0ff;
|
||||
--green:#34c759; --radius-card: 22px;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,.7) inset, 0 8px 24px rgba(16,24,40,.05);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font)}
|
||||
body{background:radial-gradient(900px 500px at 100% 0%, #e7f0ff 0%, transparent 55%), var(--bg);min-height:100vh}
|
||||
#root{min-height:100vh}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit}
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="nav.jsx"></script>
|
||||
<script type="text/babel" src="domains-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
468
web/home-app.jsx
Normal file
468
web/home-app.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
// PCA Lab — cover site: AI + cybersecurity research/education
|
||||
|
||||
const { useState: useH, useEffect: useEH } = React;
|
||||
|
||||
// Nav from nav.jsx (SiteNav)
|
||||
|
||||
// ───────── Hero ─────────
|
||||
function Hero(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"0 auto", padding:"72px 28px 56px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1.25fr 1fr", gap:48, alignItems:"center"}}>
|
||||
<div>
|
||||
<div style={{display:"inline-flex", alignItems:"center", gap:8,
|
||||
padding:"6px 14px", borderRadius:999, background:"#fff",
|
||||
border:"1px solid var(--line)", boxShadow:"0 1px 1px rgba(16,24,40,.03)",
|
||||
fontSize:11, fontWeight:700, color:"var(--ink-2)", letterSpacing:".12em"}}>
|
||||
<span style={{width:6, height:6, borderRadius:999, background:"var(--brand)"}}/>
|
||||
ОТКРЫТЫЕ ИССЛЕДОВАНИЯ · 2026
|
||||
</div>
|
||||
<h1 style={{margin:"22px 0 18px", fontSize:68, fontWeight:800, lineHeight:0.98, letterSpacing:"-0.04em"}}>
|
||||
На стыке
|
||||
<span style={{fontFamily:"var(--serif)", fontWeight:400, fontStyle:"italic", color:"var(--brand)", letterSpacing:"-0.02em"}}>искусственного интеллекта</span>
|
||||
и <span style={{fontFamily:"var(--serif)", fontWeight:400, fontStyle:"italic"}}>кибербезопасности</span>
|
||||
</h1>
|
||||
<p style={{margin:"0 0 26px", maxWidth:540, fontSize:17, lineHeight:1.55, color:"var(--ink-2)"}}>
|
||||
PCA Lab — независимая исследовательская группа. Курсы, лонгриды и open-source инструменты
|
||||
по приватности, машинному обучению и защите сетей. Без рекламы, без слежки, без шума.
|
||||
</p>
|
||||
<div style={{display:"flex", gap:10, alignItems:"center", flexWrap:"wrap"}}>
|
||||
<Button variant="primary" size="lg" iconRight="chevron-right" style={{background:"var(--ink)", boxShadow:"0 1px 0 rgba(255,255,255,.18) inset, 0 6px 16px rgba(0,0,0,.18)"}}>Начать с курса</Button>
|
||||
<Button variant="secondary" size="lg" icon="doc">Программа</Button>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, marginLeft:8, fontSize:13, color:"var(--ink-3)"}}>
|
||||
<Icon name="check" size={14} color="var(--green)" stroke={2.5}/> 4 200+ слушателей
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero composition — research card stack */}
|
||||
<div style={{position:"relative", height:420}}>
|
||||
<Card padding={20} style={{
|
||||
position:"absolute", inset:"0 0 auto 8%", width:"92%", transform:"rotate(-2deg)",
|
||||
background:"linear-gradient(180deg,#fff 0%,#fafbff 100%)"
|
||||
}}>
|
||||
<div style={{display:"flex", justifyContent:"space-between", alignItems:"flex-start"}}>
|
||||
<Badge tone="blue" size="sm">CRYPTOGRAPHY</Badge>
|
||||
<span style={{fontSize:11.5, color:"var(--ink-3)", fontFamily:"var(--mono)"}}>2026·05</span>
|
||||
</div>
|
||||
<h4 style={{margin:"14px 0 8px", fontSize:18, fontWeight:700, letterSpacing:"-0.01em"}}>Post-quantum handshake протоколы — практический разбор</h4>
|
||||
<p style={{margin:0, fontSize:13, color:"var(--ink-3)", lineHeight:1.5}}>
|
||||
Сравниваем Kyber, Dilithium и SPHINCS+ в условиях ограниченного канала.
|
||||
</p>
|
||||
<ChartBars/>
|
||||
</Card>
|
||||
|
||||
<Card padding={18} style={{
|
||||
position:"absolute", bottom:0, left:0, width:"58%",
|
||||
background:"linear-gradient(180deg,#1d1d1f 0%, #25262b 100%)", color:"#fff",
|
||||
border:"1px solid rgba(255,255,255,.08)", boxShadow:"0 1px 0 rgba(255,255,255,.06) inset, 0 12px 32px rgba(0,0,0,.18)",
|
||||
transform:"rotate(1.5deg)"
|
||||
}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, fontSize:11, fontWeight:700, color:"rgba(255,255,255,.7)", letterSpacing:".12em"}}>
|
||||
<span style={{width:6, height:6, borderRadius:999, background:"#34c759"}}/>
|
||||
LIVE COURSE
|
||||
</div>
|
||||
<h4 style={{margin:"10px 0 6px", fontSize:16, fontWeight:700, letterSpacing:"-0.01em"}}>Adversarial ML — атаки на модели</h4>
|
||||
<p style={{margin:0, fontSize:12.5, color:"rgba(255,255,255,.6)"}}>Старт 1 июня · 6 недель</p>
|
||||
<div style={{marginTop:14, display:"flex", alignItems:"center", gap:-6}}>
|
||||
{["#7c5cff","#ff9f0a","#34c759","#ff3b8a"].map((c,i)=>(
|
||||
<div key={i} style={{
|
||||
width:26, height:26, borderRadius:999, background:c, border:"2px solid #1d1d1f",
|
||||
marginLeft: i===0?0:-8, fontSize:11, color:"#fff", display:"grid", placeItems:"center", fontWeight:700
|
||||
}}>{["A","B","M","P"][i]}</div>
|
||||
))}
|
||||
<span style={{marginLeft:12, fontSize:12, color:"rgba(255,255,255,.7)"}}>+ 218 студентов</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={16} style={{
|
||||
position:"absolute", top:"42%", right:"-2%", width:"52%",
|
||||
transform:"rotate(3deg)"
|
||||
}}>
|
||||
<div style={{fontSize:11, fontWeight:700, letterSpacing:".1em", color:"var(--amber)"}}>NETWORK SECURITY</div>
|
||||
<h4 style={{margin:"8px 0 8px", fontSize:15.5, fontWeight:700, letterSpacing:"-0.01em"}}>DPI-устойчивые туннели: обзор архитектур</h4>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10, marginTop:10}}>
|
||||
<Avatar name="K" color="linear-gradient(160deg,#7ec2ff,#0a84ff)" size={28}/>
|
||||
<span style={{fontSize:12, color:"var(--ink-3)"}}>К. Линеев · 12 мин</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ChartBars decoration
|
||||
function ChartBars(){
|
||||
const bars = [42, 68, 55, 84, 71, 90, 76, 88, 95];
|
||||
return (
|
||||
<div style={{marginTop:18, display:"flex", alignItems:"flex-end", gap:6, height:60}}>
|
||||
{bars.map((v,i)=>(
|
||||
<div key={i} style={{
|
||||
flex:1, height: v + "%", borderRadius:6,
|
||||
background: i===bars.length-1
|
||||
? "linear-gradient(180deg,#0a84ff,#0f5dff)"
|
||||
: "var(--bg-tint)",
|
||||
border: i===bars.length-1 ? "0" : "1px solid var(--line)"
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Topic grid ─────────
|
||||
function Topics(){
|
||||
const items = [
|
||||
{ tag:"AI SAFETY", title:"Безопасность языковых моделей", desc:"Prompt injection, jailbreaks, защитные слои и oversight-механизмы.",
|
||||
color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"sparkles", articles:14},
|
||||
{ tag:"NETWORK", title:"Современные сетевые протоколы", desc:"WireGuard, QUIC, HTTP/3, TLS 1.3 — как они устроены и где ломаются.",
|
||||
color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", icon:"globe", articles:22},
|
||||
{ tag:"CRYPTOGRAPHY", title:"Прикладная криптография", desc:"От симметричных шифров до post-quantum, с практическими лабами.",
|
||||
color:"linear-gradient(160deg,#1d1d1f,#3a3a3c)", icon:"lock", articles:31},
|
||||
{ tag:"PRIVACY", title:"Приватность по умолчанию", desc:"Threat modeling, цифровая гигиена, OPSEC для исследователей.",
|
||||
color:"linear-gradient(160deg,#34c759,#1f8a5b)", icon:"shield", articles:18},
|
||||
{ tag:"MACHINE LEARNING", title:"Машинное обучение с нуля", desc:"Линейная алгебра, оптимизация и трансформеры — для практиков.",
|
||||
color:"linear-gradient(160deg,#ff9f0a,#ff6b00)", icon:"brain", articles:27},
|
||||
{ tag:"OFFENSIVE", title:"Red-team & пентест", desc:"Этичный взлом: methodology, инструменты, отчёты.",
|
||||
color:"linear-gradient(160deg,#ff3b8a,#9b51e0)", icon:"bolt", articles:12},
|
||||
];
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"24px auto 0", padding:"0 28px"}}>
|
||||
<SectionHeader
|
||||
title="Направления"
|
||||
subtitle="Шесть треков, каждый сопровождается курсами, лонгридами и открытыми инструментами"
|
||||
right={<Button variant="secondary" iconRight="chevron-right">Все направления</Button>}/>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(3,1fr)", gap:18}}>
|
||||
{items.map((t,i)=>(
|
||||
<Card key={i} padding={22} hover>
|
||||
<div style={{display:"flex", alignItems:"flex-start", justifyContent:"space-between"}}>
|
||||
<GlyphTile size={44} radius={12} gradient={t.color} icon={t.icon}/>
|
||||
<span style={{
|
||||
fontSize:11, fontWeight:600, color:"var(--ink-3)", fontVariantNumeric:"tabular-nums",
|
||||
padding:"4px 10px", borderRadius:999, background:"var(--bg-tint)"
|
||||
}}>{t.articles} материалов</span>
|
||||
</div>
|
||||
<div style={{marginTop:18, fontSize:10.5, fontWeight:700, color:"var(--ink-3)", letterSpacing:".12em"}}>
|
||||
{t.tag}
|
||||
</div>
|
||||
<h3 style={{margin:"6px 0 8px", fontSize:19, fontWeight:700, letterSpacing:"-0.015em"}}>{t.title}</h3>
|
||||
<p style={{margin:0, fontSize:13.5, color:"var(--ink-3)", lineHeight:1.55}}>{t.desc}</p>
|
||||
<a href="#" style={{
|
||||
marginTop:14, display:"inline-flex", alignItems:"center", gap:5,
|
||||
fontSize:13, fontWeight:600, color:"var(--ink-2)"
|
||||
}}>Изучить трек <Icon name="chevron-right" size={13} stroke={2.2}/></a>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Featured Article + Sidebar list ─────────
|
||||
function Articles(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"72px auto 0", padding:"0 28px"}}>
|
||||
<SectionHeader
|
||||
title="Последние материалы"
|
||||
subtitle="Лонгриды, лабораторные тетради, разборы инцидентов"
|
||||
right={<Button variant="secondary" iconRight="chevron-right">Архив</Button>}/>
|
||||
|
||||
<div style={{display:"grid", gridTemplateColumns:"1.4fr 1fr", gap:18}}>
|
||||
{/* Featured */}
|
||||
<Card padding={0} hover style={{overflow:"hidden"}}>
|
||||
<div style={{
|
||||
height:280, position:"relative",
|
||||
background:"linear-gradient(135deg,#1d1d1f 0%, #2d2d33 60%, #0f5dff 100%)",
|
||||
overflow:"hidden"
|
||||
}}>
|
||||
<div style={{position:"absolute", inset:0,
|
||||
backgroundImage:"radial-gradient(circle at 20% 30%, rgba(255,255,255,.18) 0%, transparent 50%), radial-gradient(circle at 80% 70%, rgba(15,93,255,.4) 0%, transparent 50%)",
|
||||
pointerEvents:"none"}}/>
|
||||
<CodeRain/>
|
||||
<div style={{position:"absolute", top:20, left:20}}>
|
||||
<Badge tone="blue" style={{background:"rgba(255,255,255,.92)", color:"#0a4ea3"}}>FEATURED · LONGREAD</Badge>
|
||||
</div>
|
||||
<div style={{position:"absolute", bottom:20, left:20, right:20, color:"#fff"}}>
|
||||
<div style={{fontSize:11.5, fontWeight:600, opacity:.7, letterSpacing:".1em"}}>15 МАЯ 2026 · 22 МИН ЧТЕНИЯ</div>
|
||||
<h3 style={{margin:"6px 0 0", fontSize:26, fontWeight:700, letterSpacing:"-0.02em", lineHeight:1.15}}>
|
||||
Почему DPI всё чаще ошибается: разбор шести популярных эвристик
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:"20px 22px"}}>
|
||||
<p style={{margin:0, fontSize:14, color:"var(--ink-2)", lineHeight:1.55}}>
|
||||
Современные системы глубокой инспекции пакетов проигрывают не из-за слабого железа,
|
||||
а из-за переусложнённой эвристики. Разбираем причины и считаем false-positive rate на открытых датасетах.
|
||||
</p>
|
||||
<div style={{marginTop:14, display:"flex", alignItems:"center", gap:10}}>
|
||||
<Avatar name="K" color="linear-gradient(160deg,#7ec2ff,#0a84ff)" size={32}/>
|
||||
<div style={{lineHeight:1.2}}>
|
||||
<div style={{fontSize:13.5, fontWeight:600}}>Константин Линеев</div>
|
||||
<div style={{fontSize:12, color:"var(--ink-3)"}}>Senior researcher, Network team</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* List of small articles */}
|
||||
<div style={{display:"flex", flexDirection:"column", gap:14}}>
|
||||
{[
|
||||
{tag:"AI SAFETY", date:"12 мая", read:"9 мин", title:"Constitutional AI: как обучают модели по тексту правил", color:"linear-gradient(160deg,#9b82ff,#6a44f5)", author:"А. Морозова"},
|
||||
{tag:"PRIVACY", date:"08 мая", read:"6 мин", title:"Threat modeling для журналистов и активистов", color:"linear-gradient(160deg,#34c759,#1f8a5b)", author:"М. Петренко"},
|
||||
{tag:"CRYPTO", date:"03 мая", read:"14 мин", title:"Подписи Ed25519 — что в них особенного", color:"linear-gradient(160deg,#1d1d1f,#3a3a3c)", author:"Д. Богданов"},
|
||||
{tag:"OFFENSIVE", date:"29 апр", read:"11 мин", title:"Lateral movement в облачных средах: AWS edition", color:"linear-gradient(160deg,#ff3b8a,#9b51e0)", author:"Е. Лазарев"},
|
||||
].map((a,i)=>(
|
||||
<Card key={i} padding={16} hover>
|
||||
<div style={{display:"flex", gap:14, alignItems:"flex-start"}}>
|
||||
<div style={{
|
||||
width:48, height:48, borderRadius:12, flexShrink:0,
|
||||
background:a.color, position:"relative", overflow:"hidden"
|
||||
}}>
|
||||
<div style={{position:"absolute", inset:0, opacity:.4,
|
||||
backgroundImage:"radial-gradient(circle at 30% 30%, rgba(255,255,255,.4), transparent 60%)"}}/>
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:0}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, fontSize:10.5, color:"var(--ink-3)"}}>
|
||||
<span style={{fontWeight:700, letterSpacing:".1em"}}>{a.tag}</span>
|
||||
<span style={{opacity:.4}}>·</span>
|
||||
<span>{a.date}</span>
|
||||
<span style={{opacity:.4}}>·</span>
|
||||
<span>{a.read}</span>
|
||||
</div>
|
||||
<h4 style={{margin:"5px 0 4px", fontSize:15, fontWeight:700, letterSpacing:"-0.01em", lineHeight:1.25}}>{a.title}</h4>
|
||||
<div style={{fontSize:12, color:"var(--ink-3)"}}>{a.author}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeRain(){
|
||||
// Static "code lines" decoration
|
||||
const lines = [
|
||||
"$ openssl genpkey -algorithm Ed25519",
|
||||
"→ key.pem · 64 bytes",
|
||||
"$ sha256sum key.pem",
|
||||
"8a3f…d2c1 key.pem",
|
||||
"$ ./detect --input pcap/sample.pcap",
|
||||
"matched: 0 · false-positive: 12%",
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
position:"absolute", right:18, bottom:80, fontFamily:"var(--mono)",
|
||||
fontSize:11, color:"rgba(255,255,255,.42)", lineHeight:1.6,
|
||||
maxWidth:"50%", textAlign:"right"
|
||||
}}>
|
||||
{lines.map((l,i)=><div key={i}>{l}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Courses CTA ─────────
|
||||
function Courses(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"72px auto 0", padding:"0 28px"}}>
|
||||
<SectionHeader title="Курсы — открытый набор"
|
||||
subtitle="Записываемся бесплатно, материалы остаются у вас навсегда"/>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:18}}>
|
||||
{[
|
||||
{weeks:8, name:"Введение в нейросети", lvl:"beginner", lvlT:"green", n:1240, color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"brain"},
|
||||
{weeks:6, name:"Cybersec для разработчиков", lvl:"intermediate", lvlT:"blue", n:864, color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", icon:"shield"},
|
||||
{weeks:12, name:"Криптография в продакшене", lvl:"advanced", lvlT:"purple", n:412, color:"linear-gradient(160deg,#1d1d1f,#3a3a3c)", icon:"lock"},
|
||||
{weeks:4, name:"OPSEC и приватность", lvl:"beginner", lvlT:"green", n:1820, color:"linear-gradient(160deg,#34c759,#1f8a5b)", icon:"shield"},
|
||||
].map((c,i)=>(
|
||||
<Card key={i} padding={20} hover>
|
||||
<GlyphTile size={42} radius={12} gradient={c.color} icon={c.icon}/>
|
||||
<h4 style={{margin:"14px 0 6px", fontSize:16, fontWeight:700, letterSpacing:"-0.01em", minHeight:42, lineHeight:1.3}}>{c.name}</h4>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, fontSize:12, color:"var(--ink-3)"}}>
|
||||
<Icon name="clock" size={13}/> {c.weeks} недель
|
||||
<span style={{opacity:.4}}>·</span>
|
||||
<Icon name="users" size={13}/> {c.n}
|
||||
</div>
|
||||
<div style={{marginTop:14, paddingTop:14, borderTop:"1px solid var(--line-2)",
|
||||
display:"flex", alignItems:"center", justifyContent:"space-between"}}>
|
||||
<Badge tone={c.lvlT} size="sm">{c.lvl}</Badge>
|
||||
<a href="#" style={{fontSize:13, fontWeight:600, color:"var(--ink-2)",
|
||||
display:"inline-flex", alignItems:"center", gap:4}}>
|
||||
Записаться <Icon name="chevron-right" size={13} stroke={2.2}/>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Newsletter / CTA ─────────
|
||||
function Newsletter(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"72px auto 0", padding:"0 28px"}}>
|
||||
<div style={{
|
||||
position:"relative", overflow:"hidden",
|
||||
borderRadius:"calc(var(--radius-card) + 6px)",
|
||||
background:"linear-gradient(135deg,#0a0a0b 0%, #1d1d1f 60%, #0f5dff 200%)",
|
||||
color:"#fff", padding:"48px 48px", border:"1px solid rgba(255,255,255,.06)",
|
||||
boxShadow:"0 1px 0 rgba(255,255,255,.06) inset, 0 18px 50px rgba(15,93,255,.18)"
|
||||
}}>
|
||||
<div style={{position:"absolute", inset:0, opacity:.6,
|
||||
background:"radial-gradient(500px 280px at 100% 100%, rgba(15,93,255,.4), transparent 60%), radial-gradient(400px 220px at 0% 0%, rgba(155,82,224,.18), transparent 60%)"}}/>
|
||||
<div style={{position:"relative", display:"grid", gridTemplateColumns:"1.2fr 1fr", gap:36, alignItems:"center"}}>
|
||||
<div>
|
||||
<div style={{fontSize:11, fontWeight:700, color:"rgba(255,255,255,.6)", letterSpacing:".14em"}}>
|
||||
· NEWSLETTER · ПО ЧЕТВЕРГАМ ·
|
||||
</div>
|
||||
<h2 style={{margin:"12px 0 10px", fontSize:34, fontWeight:700, letterSpacing:"-0.02em", lineHeight:1.1}}>
|
||||
Раз в неделю — лучшие материалы и одна короткая мысль
|
||||
</h2>
|
||||
<p style={{margin:0, fontSize:14.5, color:"rgba(255,255,255,.7)", maxWidth:480, lineHeight:1.55}}>
|
||||
Без рекламы и продаж курсов. Только то, что мы сами читаем и используем в работе.
|
||||
</p>
|
||||
</div>
|
||||
<form style={{display:"flex", gap:10}} onSubmit={(e)=>e.preventDefault()}>
|
||||
<input type="email" placeholder="your@email.com" style={{
|
||||
flex:1, height:48, padding:"0 18px",
|
||||
border:"1px solid rgba(255,255,255,.14)", background:"rgba(255,255,255,.06)",
|
||||
backdropFilter:"blur(10px)", borderRadius:14, color:"#fff",
|
||||
fontSize:14.5, outline:"none"
|
||||
}}/>
|
||||
<button style={{
|
||||
appearance:"none", border:0, height:48, padding:"0 22px",
|
||||
background:"#fff", color:"#0a0a0b", fontSize:14, fontWeight:700,
|
||||
borderRadius:14, cursor:"pointer"
|
||||
}}>Подписаться →</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Footer ─────────
|
||||
function Footer(){
|
||||
return (
|
||||
<footer style={{maxWidth:1200, margin:"72px auto 0", padding:"32px 28px 48px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1.5fr 1fr 1fr 1fr 1fr", gap:32, paddingBottom:32, borderBottom:"1px solid var(--line-2)"}}>
|
||||
<div>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10}}>
|
||||
<div style={{width:32, height:32, borderRadius:10, background:"linear-gradient(160deg,#1d1d1f,#2d2d33)",
|
||||
display:"grid", placeItems:"center", color:"#fff"}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.6"/>
|
||||
<path d="M8 7l4 10 4-10" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{fontSize:15, fontWeight:700, letterSpacing:"-0.01em"}}>PCA Lab</div>
|
||||
</div>
|
||||
<p style={{margin:"14px 0 14px", fontSize:13, color:"var(--ink-3)", maxWidth:280, lineHeight:1.55}}>
|
||||
Независимая исследовательская группа. Курсы, открытые статьи и инструменты
|
||||
по приватности и AI с 2022 года.
|
||||
</p>
|
||||
<div style={{display:"flex", gap:8}}>
|
||||
{[
|
||||
{icon:"github", l:"GitHub"},
|
||||
{icon:"plane", l:"Telegram"},
|
||||
{icon:"doc", l:"RSS"},
|
||||
].map(s=>(
|
||||
<a key={s.l} href="#" title={s.l} style={{
|
||||
width:34, height:34, borderRadius:10, background:"#fff",
|
||||
border:"1px solid var(--line)", display:"grid", placeItems:"center",
|
||||
color:"var(--ink-2)"
|
||||
}}><Icon name={s.icon} size={15}/></a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FootCol title="Контент" links={["Главная","Курсы","Исследования","Блог","Авторы"]}/>
|
||||
<FootCol title="Темы" links={["AI Safety","Network","Cryptography","Privacy","ML","Red-team"]}/>
|
||||
<FootCol title="Лаборатория" links={["О нас","Резиденты","Партнёры","Контакты","Открытые позиции"]}/>
|
||||
<FootCol title="Студентам" links={[
|
||||
{ l:"Программа курса" },
|
||||
{ l:"FAQ" },
|
||||
{ l:"Помощь техподдержки" },
|
||||
{ l:"Кабинет студента", href:"connect.html", highlight:true },
|
||||
{ l:"Группы доменов", href:"domains.html" },
|
||||
{ l:"VPN по региону", href:"connect.html?mode=region" },
|
||||
]}/>
|
||||
</div>
|
||||
|
||||
<div style={{paddingTop:24, display:"flex", justifyContent:"space-between", alignItems:"center", fontSize:12.5, color:"var(--ink-3)", flexWrap:"wrap", gap:14}}>
|
||||
<div>© 2022–2026 PCA Lab · Все материалы — под лицензией CC BY-SA 4.0</div>
|
||||
<div style={{display:"flex", gap:18}}>
|
||||
<a href="#" style={{color:"var(--ink-3)"}}>Конфиденциальность</a>
|
||||
<a href="#" style={{color:"var(--ink-3)"}}>Условия</a>
|
||||
<a href="#" style={{color:"var(--ink-3)"}}>RSS</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
function FootCol({ title, links }){
|
||||
return (
|
||||
<div>
|
||||
<div style={{fontSize:11, fontWeight:700, color:"var(--ink-3)", letterSpacing:".14em", textTransform:"uppercase", marginBottom:14}}>{title}</div>
|
||||
<ul style={{margin:0, padding:0, listStyle:"none", display:"flex", flexDirection:"column", gap:10}}>
|
||||
{links.map((l,i)=>{
|
||||
const item = typeof l === "string" ? { l } : l;
|
||||
return (
|
||||
<li key={i}>
|
||||
<a href={item.href || "#"} style={{
|
||||
fontSize:13.5, color: item.highlight ? "var(--ink)" : "var(--ink-2)",
|
||||
fontWeight: item.highlight ? 600 : 500,
|
||||
display:"inline-flex", alignItems:"center", gap:6,
|
||||
}}>
|
||||
{item.l}
|
||||
{item.highlight && <Icon name="chevron-right" size={12} stroke={2.2} color="var(--ink-3)"/>}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Stats strip ─────────
|
||||
function StatsStrip(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"40px auto 0", padding:"0 28px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4, 1fr)", gap:0,
|
||||
background:"#fff", border:"1px solid var(--line)", borderRadius:18,
|
||||
boxShadow:"var(--shadow-card)", overflow:"hidden"}}>
|
||||
{[
|
||||
{v:"124", l:"открытых статей"},
|
||||
{v:"12", l:"живых курсов"},
|
||||
{v:"4 200+", l:"слушателей"},
|
||||
{v:"32", l:"open-source проекта"},
|
||||
].map((s,i)=>(
|
||||
<div key={i} style={{padding:"22px 24px", borderLeft: i===0 ? "0" : "1px solid var(--line-2)"}}>
|
||||
<div style={{fontSize:30, fontWeight:700, letterSpacing:"-0.02em", fontVariantNumeric:"tabular-nums"}}>{s.v}</div>
|
||||
<div style={{marginTop:2, fontSize:13, color:"var(--ink-3)"}}>{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── App ─────────
|
||||
function HomeApp(){
|
||||
return (
|
||||
<div>
|
||||
<SiteNav/>
|
||||
<Hero/>
|
||||
<StatsStrip/>
|
||||
<Topics/>
|
||||
<Articles/>
|
||||
<Courses/>
|
||||
<Newsletter/>
|
||||
<Footer/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<HomeApp/>);
|
||||
67
web/index.html
Normal file
67
web/index.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>PCA Lab — Исследования в области AI и кибербезопасности</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&family=Instrument+Serif&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f4f5f7;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-tint: #f9fafb;
|
||||
--ink: #1d1d1f;
|
||||
--ink-2: #424245;
|
||||
--ink-3: #6e6e73;
|
||||
--ink-4: #8e8e93;
|
||||
--line: rgba(0,0,0,0.07);
|
||||
--line-2: rgba(0,0,0,0.04);
|
||||
--accent: #1d1d1f; /* serious editorial black */
|
||||
--accent-2:#0a0a0b;
|
||||
--accent-soft:#f1f2f6;
|
||||
--brand:#0f5dff; /* tech blue */
|
||||
--brand-soft:#e6f0ff;
|
||||
--green:#1f7a3a;
|
||||
--green-soft:#e4f8ea;
|
||||
--amber:#a36a00;
|
||||
--amber-soft:#fff2dc;
|
||||
--radius-card: 22px;
|
||||
--radius-inner: 14px;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,.7) inset, 0 1px 2px rgba(16,24,40,.04), 0 8px 24px rgba(16,24,40,.05);
|
||||
--shadow-pop: 0 1px 0 rgba(255,255,255,.7) inset, 0 4px 14px rgba(16,24,40,.08), 0 24px 56px rgba(16,24,40,.10);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
||||
--serif: "Instrument Serif", "Times New Roman", Georgia, serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
font-feature-settings:"ss01","cv11";}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit}
|
||||
::selection{background:var(--accent-soft);color:var(--ink)}
|
||||
|
||||
body{
|
||||
background:
|
||||
radial-gradient(900px 500px at 110% -10%, #e7f0ff 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at -10% 30%, #f1eaff 0%, transparent 55%),
|
||||
var(--bg);
|
||||
min-height:100vh;
|
||||
}
|
||||
#root{min-height:100vh}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="nav.jsx"></script>
|
||||
<script type="text/babel" src="home-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
57
web/nav.jsx
Normal file
57
web/nav.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// Shared site navigation
|
||||
|
||||
const SITE_NAV = [
|
||||
{ href: "index.html", label: "Главная", match: (p) => /index\.html$|^\/$/.test(p) },
|
||||
{ href: "connect.html", label: "Кабинет студента", match: (p) => /connect\.html$/.test(p) && !/mode=region/.test(location.search) },
|
||||
{ href: "connect.html?mode=region", label: "VPN по региону", match: (p) => /connect\.html/.test(p) && /mode=region/.test(location.search) },
|
||||
{ href: "domains.html", label: "Группы доменов", match: (p) => /domains\.html$/.test(p) },
|
||||
];
|
||||
|
||||
function SiteNav({ brand = "PCA Lab", brandSub = "RESEARCH · EDUCATION" }) {
|
||||
const path = typeof location !== "undefined" ? (location.pathname + (location.search || "")) : "";
|
||||
return (
|
||||
<header style={{
|
||||
position: "sticky", top: 0, zIndex: 50,
|
||||
backdropFilter: "saturate(180%) blur(20px)",
|
||||
WebkitBackdropFilter: "saturate(180%) blur(20px)",
|
||||
background: "rgba(244,245,247,0.78)",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1200, margin: "0 auto", padding: "12px 20px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<a href="index.html" style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 11,
|
||||
background: "linear-gradient(160deg,#1d1d1f 0%, #2d2d33 100%)",
|
||||
display: "grid", placeItems: "center", color: "#fff",
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.6" />
|
||||
<path d="M8 7l4 10 4-10" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.05 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{brand}</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--ink-3)", letterSpacing: ".14em", fontWeight: 600 }}>{brandSub}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<nav style={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{SITE_NAV.map((item, i) => {
|
||||
const active = item.match(path + (location.search || ""));
|
||||
return (
|
||||
<a key={i} href={item.href} style={{
|
||||
padding: "8px 12px", fontSize: 13, fontWeight: active ? 600 : 500,
|
||||
color: active ? "var(--ink)" : "var(--ink-2)",
|
||||
borderRadius: 999, background: active ? "#fff" : "transparent",
|
||||
border: active ? "1px solid var(--line)" : "1px solid transparent",
|
||||
}}>{item.label}</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
281
web/ui.jsx
Normal file
281
web/ui.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
// Shared UI primitives — Apple-inspired light system
|
||||
|
||||
const { useState, useEffect, useRef, useMemo } = React;
|
||||
|
||||
// ───────── Icons (line, 1.6 stroke, rounded) ─────────
|
||||
function Icon({ name, size = 18, stroke = 1.6, color = "currentColor" }) {
|
||||
const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none",
|
||||
stroke: color, strokeWidth: stroke, strokeLinecap: "round", strokeLinejoin: "round" };
|
||||
switch (name) {
|
||||
case "server": return (<svg {...props}><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r=".6" fill={color}/><circle cx="7" cy="16.5" r=".6" fill={color}/></svg>);
|
||||
case "users": return (<svg {...props}><circle cx="9" cy="8" r="3.5"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 11.5a3 3 0 0 0 0-6"/><path d="M21 20c0-2.5-1.8-4.6-4.2-5"/></svg>);
|
||||
case "settings": return (<svg {...props}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>);
|
||||
case "link": return (<svg {...props}><path d="M10 14a5 5 0 0 0 7.07 0l3-3a5 5 0 1 0-7.07-7.07L11.5 5.5"/><path d="M14 10a5 5 0 0 0-7.07 0l-3 3a5 5 0 1 0 7.07 7.07L12.5 18.5"/></svg>);
|
||||
case "lock": return (<svg {...props}><rect x="4" y="11" width="16" height="10" rx="2.5"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></svg>);
|
||||
case "tools": return (<svg {...props}><path d="M14.7 6.3a4 4 0 0 0 5 5L21 12.7a8 8 0 0 1-9.4 1.4l-6.3 6.3a2.1 2.1 0 1 1-3-3l6.3-6.3A8 8 0 0 1 10 1.7L11.3 3a4 4 0 0 0 3.4 3.3z"/></svg>);
|
||||
case "gauge": return (<svg {...props}><path d="M12 14l4-4"/><circle cx="12" cy="14" r="9"/><path d="M3 14a9 9 0 0 1 18 0"/></svg>);
|
||||
case "check": return (<svg {...props}><polyline points="4 12 10 18 20 6"/></svg>);
|
||||
case "plus": return (<svg {...props}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>);
|
||||
case "chevron-down": return (<svg {...props}><polyline points="6 9 12 15 18 9"/></svg>);
|
||||
case "chevron-right": return (<svg {...props}><polyline points="9 6 15 12 9 18"/></svg>);
|
||||
case "back": return (<svg {...props}><line x1="20" y1="12" x2="4" y2="12"/><polyline points="10 18 4 12 10 6"/></svg>);
|
||||
case "trash": return (<svg {...props}><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg>);
|
||||
case "config": return (<svg {...props}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>);
|
||||
case "stop": return (<svg {...props}><rect x="6" y="6" width="12" height="12" rx="2"/></svg>);
|
||||
case "play": return (<svg {...props}><polygon points="6 4 20 12 6 20 6 4"/></svg>);
|
||||
case "qr": return (<svg {...props}><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M14 14h3v3h-3zM20 14v3M14 20h3M20 20v1"/></svg>);
|
||||
case "calendar": return (<svg {...props}><rect x="3" y="5" width="18" height="16" rx="2.5"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="3" x2="8" y2="7"/><line x1="16" y1="3" x2="16" y2="7"/></svg>);
|
||||
case "clock": return (<svg {...props}><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>);
|
||||
case "download": return (<svg {...props}><path d="M12 4v12"/><polyline points="7 11 12 16 17 11"/><path d="M5 20h14"/></svg>);
|
||||
case "upload": return (<svg {...props}><path d="M12 20V8"/><polyline points="7 13 12 8 17 13"/><path d="M5 4h14"/></svg>);
|
||||
case "cpu": return (<svg {...props}><rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M10 2v3M14 2v3M10 19v3M14 19v3M2 10h3M2 14h3M19 10h3M19 14h3"/></svg>);
|
||||
case "ram": return (<svg {...props}><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 7v10M11 7v10M15 7v10M19 7v10"/></svg>);
|
||||
case "disk": return (<svg {...props}><rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r=".8" fill={color}/></svg>);
|
||||
case "globe": return (<svg {...props}><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18"/><path d="M12 3a14 14 0 0 0 0 18"/></svg>);
|
||||
case "refresh": return (<svg {...props}><polyline points="20 4 20 10 14 10"/><polyline points="4 20 4 14 10 14"/><path d="M20 10a8 8 0 0 0-14.9-2"/><path d="M4 14a8 8 0 0 0 14.9 2"/></svg>);
|
||||
case "logout": return (<svg {...props}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>);
|
||||
case "sun": return (<svg {...props}><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"/></svg>);
|
||||
case "moon": return (<svg {...props}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>);
|
||||
case "key": return (<svg {...props}><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/></svg>);
|
||||
case "sparkles": return (<svg {...props}><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>);
|
||||
case "satellite":return (<svg {...props}><path d="M5 19l5-5"/><path d="M14 4l6 6-5 5-6-6z"/><path d="M9 9l-3 3 3 3 3-3"/><path d="M17 17a4 4 0 0 0-4-4"/><path d="M20 17a7 7 0 0 0-7-7"/></svg>);
|
||||
case "bolt": return (<svg {...props}><polygon points="13 2 4 14 11 14 10 22 20 9 13 9 13 2"/></svg>);
|
||||
case "plane": return (<svg {...props}><path d="M21 14l-7-1-3 8-2-1 1-7-7-3 8-3-1-6 2-1 3 7 7-1 1 2-6 3 4 5z"/></svg>);
|
||||
case "shield": return (<svg {...props}><path d="M12 3l8 3v6c0 4.5-3.3 8.7-8 9-4.7-.3-8-4.5-8-9V6z"/></svg>);
|
||||
case "wand": return (<svg {...props}><path d="M3 21l14-14"/><path d="M17 3l1 2 2 1-2 1-1 2-1-2-2-1 2-1z"/><path d="M5 7l.6 1.4L7 9l-1.4.6L5 11l-.6-1.4L3 9l1.4-.6z"/></svg>);
|
||||
case "brain": return (<svg {...props}><path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-1 5.7 3 3 0 0 0 1 4.6A3 3 0 0 0 9 20a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 1 5.7 3 3 0 0 1-1 4.6 3 3 0 0 1-3 2.7 2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>);
|
||||
case "star": return (<svg {...props}><polygon points="12 3 14.5 9 21 9.5 16 14 17.5 21 12 17.5 6.5 21 8 14 3 9.5 9.5 9"/></svg>);
|
||||
case "github": return (<svg {...props}><path d="M9 19c-4 1.5-4-2-6-2.5M15 22v-3.9a3.4 3.4 0 0 0-.9-2.6c3 0 6-2 6-5.5a4.3 4.3 0 0 0-1.2-3 4 4 0 0 0-.1-3.1S17.2 3 15 4.4a13 13 0 0 0-7 0C5.8 3 4.7 3.4 4.7 3.4a4 4 0 0 0-.1 3.1A4.3 4.3 0 0 0 3.5 9.5C3.5 13 6.5 15 9.5 15a3.4 3.4 0 0 0-.9 2.6V22"/></svg>);
|
||||
case "doc": return (<svg {...props}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>);
|
||||
case "edit": return (<svg {...props}><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>);
|
||||
case "more": return (<svg {...props}><circle cx="5" cy="12" r="1.3" fill={color}/><circle cx="12" cy="12" r="1.3" fill={color}/><circle cx="19" cy="12" r="1.3" fill={color}/></svg>);
|
||||
case "search": return (<svg {...props}><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>);
|
||||
case "heart": return (<svg {...props}><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 1 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>);
|
||||
case "qr-scan": return (<svg {...props}><path d="M4 8V5a1 1 0 0 1 1-1h3"/><path d="M16 4h3a1 1 0 0 1 1 1v3"/><path d="M20 16v3a1 1 0 0 1-1 1h-3"/><path d="M8 20H5a1 1 0 0 1-1-1v-3"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>);
|
||||
case "clipboard":return (<svg {...props}><rect x="6" y="4" width="12" height="18" rx="2"/><rect x="9" y="2" width="6" height="4" rx="1"/></svg>);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── Buttons ─────────
|
||||
function Button({ children, variant = "primary", size = "md", icon, iconRight, onClick, type, disabled, style, full }) {
|
||||
const sizes = {
|
||||
sm: { h: 30, px: 12, fs: 13, gap: 6, r: 10 },
|
||||
md: { h: 38, px: 16, fs: 14, gap: 8, r: 12 },
|
||||
lg: { h: 46, px: 22, fs: 15, gap: 10, r: 14 },
|
||||
}[size];
|
||||
const variants = {
|
||||
primary: { bg: "var(--accent)", color: "#fff", border: "transparent",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.18) inset, 0 6px 16px rgba(124,92,255,.35)" },
|
||||
primaryGrad: { bg: "linear-gradient(180deg, #8a6cff, #6a44f5)", color:"#fff", border:"transparent",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.22) inset, 0 8px 22px rgba(106,68,245,.35)" },
|
||||
secondary: { bg: "#fff", color:"var(--ink)", border: "var(--line)",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.6) inset, 0 1px 1px rgba(16,24,40,.04), 0 3px 8px rgba(16,24,40,.05)" },
|
||||
ghost: { bg: "transparent", color:"var(--ink-2)", border:"transparent", shadow:"none" },
|
||||
danger: { bg: "#fff", color:"var(--red)", border:"rgba(255,59,48,.22)",
|
||||
shadow:"0 1px 1px rgba(16,24,40,.03)" },
|
||||
dangerSolid:{ bg:"var(--red)", color:"#fff", border:"transparent",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.18) inset, 0 6px 14px rgba(255,59,48,.3)" },
|
||||
success: { bg:"#fff", color:"var(--green)", border:"rgba(52,199,89,.25)" , shadow:"0 1px 1px rgba(16,24,40,.03)"},
|
||||
};
|
||||
const v = variants[variant] || variants.primary;
|
||||
return (
|
||||
<button onClick={onClick} type={type || "button"} disabled={disabled}
|
||||
style={{
|
||||
appearance:"none", height: sizes.h, padding:`0 ${sizes.px}px`, gap: sizes.gap,
|
||||
display:"inline-flex", alignItems:"center", justifyContent:"center",
|
||||
fontSize: sizes.fs, fontWeight:600, letterSpacing:"-0.005em",
|
||||
borderRadius: sizes.r, background: v.bg, color: v.color,
|
||||
border:`1px solid ${v.border}`, boxShadow:v.shadow,
|
||||
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .55 : 1,
|
||||
transition:"transform .12s ease, box-shadow .18s ease, background .18s ease",
|
||||
width: full ? "100%" : "auto", ...style,
|
||||
}}
|
||||
onMouseDown={(e)=>e.currentTarget.style.transform="scale(0.985)"}
|
||||
onMouseUp={(e)=>e.currentTarget.style.transform="scale(1)"}
|
||||
onMouseLeave={(e)=>e.currentTarget.style.transform="scale(1)"}
|
||||
>
|
||||
{icon && <Icon name={icon} size={sizes.fs + 2}/>}
|
||||
{children}
|
||||
{iconRight && <Icon name={iconRight} size={sizes.fs + 2}/>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Card ─────────
|
||||
function Card({ children, padding = 22, radius, hover, style, onClick }) {
|
||||
const [h, setH] = useState(false);
|
||||
return (
|
||||
<div onClick={onClick}
|
||||
onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
|
||||
style={{
|
||||
background:"var(--bg-elev)", borderRadius: radius || "var(--radius-card)",
|
||||
border:"1px solid var(--line)", boxShadow:"var(--shadow-card)",
|
||||
padding, transition:"transform .2s ease, box-shadow .25s ease",
|
||||
transform: hover && h ? "translateY(-2px)" : "translateY(0)",
|
||||
boxShadow: hover && h ? "var(--shadow-pop)" : "var(--shadow-card)",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Badge ─────────
|
||||
function Badge({ children, tone = "neutral", dot, size="md", style }) {
|
||||
const tones = {
|
||||
neutral:{ bg:"#f1f2f6", color:"#3a3a3c", dot:"#8e8e93"},
|
||||
green: { bg:"var(--green-soft)", color:"#1f7a3a", dot:"var(--green)" },
|
||||
blue: { bg:"var(--blue-soft)", color:"#0a4ea3", dot:"var(--blue)" },
|
||||
purple: { bg:"var(--accent-soft)", color:"#4a2fcc", dot:"var(--accent)" },
|
||||
orange: { bg:"var(--orange-soft)", color:"#a05a00", dot:"var(--orange)" },
|
||||
red: { bg:"var(--red-soft)", color:"#a8261d", dot:"var(--red)" },
|
||||
};
|
||||
const t = tones[tone] || tones.neutral;
|
||||
const sizes = { sm:{h:20, fs:10.5, px:7}, md:{h:24, fs:11.5, px:9}, lg:{h:28, fs:12.5, px:11}};
|
||||
const s = sizes[size] || sizes.md;
|
||||
return (
|
||||
<span style={{
|
||||
display:"inline-flex", alignItems:"center", gap:6,
|
||||
height:s.h, padding:`0 ${s.px}px`, fontSize:s.fs, fontWeight:600,
|
||||
letterSpacing:".02em", textTransform:"uppercase",
|
||||
borderRadius:999, background:t.bg, color:t.color, ...style,
|
||||
}}>
|
||||
{dot && <span style={{width:6, height:6, borderRadius:999, background:t.dot}}/>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Glyph tile (app-icon style with gradient) ─────────
|
||||
function GlyphTile({ icon, size = 44, gradient, radius = 12, glyph }) {
|
||||
return (
|
||||
<div style={{
|
||||
width:size, height:size, borderRadius:radius, flexShrink:0,
|
||||
display:"grid", placeItems:"center", color:"#fff",
|
||||
background: gradient, fontSize: size*0.5,
|
||||
boxShadow:"0 1px 0 rgba(255,255,255,.35) inset, 0 4px 10px rgba(16,24,40,.10)",
|
||||
letterSpacing:0,
|
||||
}}>
|
||||
{glyph ? <span style={{fontSize:size*0.52, lineHeight:1, filter:"drop-shadow(0 1px 1px rgba(0,0,0,.15))"}}>{glyph}</span> : <Icon name={icon} size={size*0.5} stroke={1.8}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Stat chip (used in server header) ─────────
|
||||
function Stat({ icon, label, value, tone="neutral" }) {
|
||||
const tones = {
|
||||
neutral:{c:"#3a3a3c", bg:"#f4f5f7"},
|
||||
green:{c:"#1f7a3a", bg:"var(--green-soft)"},
|
||||
orange:{c:"#a05a00", bg:"var(--orange-soft)"},
|
||||
blue:{c:"#0a4ea3", bg:"var(--blue-soft)"},
|
||||
};
|
||||
const t = tones[tone] || tones.neutral;
|
||||
return (
|
||||
<div style={{display:"flex",alignItems:"center", gap:8, padding:"8px 12px",
|
||||
borderRadius:14, background:t.bg, color:t.c}}>
|
||||
<Icon name={icon} size={14} stroke={1.8}/>
|
||||
<div style={{display:"flex", flexDirection:"column", lineHeight:1.1}}>
|
||||
<span style={{fontSize:10.5, opacity:.7, fontWeight:600, letterSpacing:".04em", textTransform:"uppercase"}}>{label}</span>
|
||||
<span style={{fontSize:13, fontWeight:600, fontVariantNumeric:"tabular-nums"}}>{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Avatar (initial) ─────────
|
||||
function Avatar({ name, color = "var(--accent)", size = 36 }) {
|
||||
const letter = (name || "?").trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<div style={{
|
||||
width:size, height:size, borderRadius: size*0.32,
|
||||
background: color, color:"#fff",
|
||||
display:"grid", placeItems:"center", fontWeight:700, fontSize:size*0.42,
|
||||
boxShadow:"0 1px 0 rgba(255,255,255,.3) inset, 0 2px 6px rgba(16,24,40,.10)",
|
||||
flexShrink:0,
|
||||
}}>{letter}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Toggle ─────────
|
||||
function Toggle({ on, onChange }) {
|
||||
return (
|
||||
<button onClick={()=>onChange && onChange(!on)}
|
||||
style={{ appearance:"none", border:0, width:42, height:25, padding:2,
|
||||
borderRadius:999, background: on ? "var(--green)" : "#d1d1d6",
|
||||
position:"relative", cursor:"pointer", transition:"background .2s ease"}}>
|
||||
<span style={{
|
||||
position:"absolute", top:2, left: on ? 19 : 2,
|
||||
width:21, height:21, borderRadius:999, background:"#fff",
|
||||
boxShadow:"0 1px 2px rgba(0,0,0,.2), 0 2px 4px rgba(0,0,0,.1)",
|
||||
transition:"left .2s ease"
|
||||
}}/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Input ─────────
|
||||
function Input({ label, value, onChange, placeholder, mono, type="text", suffix, hint }) {
|
||||
return (
|
||||
<label style={{display:"flex", flexDirection:"column", gap:6}}>
|
||||
{label && <span style={{fontSize:10.5, fontWeight:600, letterSpacing:".06em",
|
||||
textTransform:"uppercase", color:"var(--ink-3)"}}>{label}</span>}
|
||||
<div style={{position:"relative"}}>
|
||||
<input type={type} value={value} placeholder={placeholder}
|
||||
onChange={(e)=>onChange && onChange(e.target.value)}
|
||||
style={{
|
||||
width:"100%", height:42, padding:`0 ${suffix?44:14}px 0 14px`,
|
||||
border:"1px solid var(--line)", borderRadius:12, background:"#fff",
|
||||
fontFamily: mono ? "var(--mono)" : "var(--font)", fontSize:14,
|
||||
color:"var(--ink)", outline:"none",
|
||||
boxShadow:"0 1px 1px rgba(16,24,40,.03)",
|
||||
transition:"border-color .15s ease, box-shadow .15s ease",
|
||||
}}
|
||||
onFocus={(e)=>{e.target.style.borderColor="var(--accent)"; e.target.style.boxShadow="0 0 0 3px rgba(124,92,255,.18)"}}
|
||||
onBlur={(e)=>{e.target.style.borderColor="var(--line)"; e.target.style.boxShadow="0 1px 1px rgba(16,24,40,.03)"}}
|
||||
/>
|
||||
{suffix && <div style={{position:"absolute", right:12, top:"50%", transform:"translateY(-50%)",
|
||||
color:"var(--ink-3)", fontSize:12, fontWeight:500}}>{suffix}</div>}
|
||||
</div>
|
||||
{hint && <span style={{fontSize:12, color:"var(--ink-3)"}}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Checkbox ─────────
|
||||
function Checkbox({ checked, onChange, label }) {
|
||||
return (
|
||||
<label style={{display:"inline-flex", alignItems:"center", gap:10, cursor:"pointer", userSelect:"none"}}>
|
||||
<span style={{
|
||||
width:20, height:20, borderRadius:6,
|
||||
background: checked ? "var(--accent)" : "#fff",
|
||||
border: checked ? "1px solid var(--accent)" : "1px solid var(--line)",
|
||||
display:"grid", placeItems:"center", transition:"all .15s ease",
|
||||
boxShadow: checked ? "0 2px 6px rgba(124,92,255,.3)" : "0 1px 1px rgba(16,24,40,.03)"
|
||||
}}>
|
||||
{checked && <Icon name="check" size={13} stroke={3} color="#fff"/>}
|
||||
</span>
|
||||
<input type="checkbox" checked={!!checked} onChange={(e)=>onChange && onChange(e.target.checked)} style={{display:"none"}}/>
|
||||
<span style={{fontSize:14, color:"var(--ink)"}}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Section header ─────────
|
||||
function SectionHeader({ title, subtitle, right }) {
|
||||
return (
|
||||
<div style={{display:"flex", alignItems:"end", justifyContent:"space-between", gap:16, marginBottom:14}}>
|
||||
<div>
|
||||
<h2 style={{margin:0, fontSize:24, fontWeight:700, letterSpacing:"-0.02em", color:"var(--ink)"}}>{title}</h2>
|
||||
{subtitle && <p style={{margin:"4px 0 0", fontSize:14, color:"var(--ink-3)"}}>{subtitle}</p>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// expose globally
|
||||
Object.assign(window, {
|
||||
Icon, Button, Card, Badge, GlyphTile, Stat, Avatar, Toggle, Input, Checkbox, SectionHeader,
|
||||
});
|
||||
BIN
web/uploads/pasted-1779726646439-0.png
Normal file
BIN
web/uploads/pasted-1779726646439-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
web/uploads/pasted-1779726706299-0.png
Normal file
BIN
web/uploads/pasted-1779726706299-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
web/uploads/pasted-1779726828382-0.png
Normal file
BIN
web/uploads/pasted-1779726828382-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 729 KiB |
BIN
web/uploads/pasted-1779726864755-0.png
Normal file
BIN
web/uploads/pasted-1779726864755-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
BIN
web/uploads/pasted-1779727187856-0.png
Normal file
BIN
web/uploads/pasted-1779727187856-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
web/uploads/pasted-1779729711186-0.png
Normal file
BIN
web/uploads/pasted-1779729711186-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
Reference in New Issue
Block a user