mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +00:00
feat: monitors worker, auth register, scan polish
Add background DNS/SSL monitor checks, registration API, smoother SSE with stage labels, improved CDN/WAF detection, light-theme UI polish, and README roadmap updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,3 +6,6 @@ NEXTAUTH_SECRET=change-me-to-a-long-random-string
|
|||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
IPINFO_TOKEN=
|
IPINFO_TOKEN=
|
||||||
SCAN_RATE_LIMIT_PER_HOUR=30
|
SCAN_RATE_LIMIT_PER_HOUR=30
|
||||||
|
MONITOR_INTERVAL_MS=300000
|
||||||
|
SEED_ADMIN_EMAIL=
|
||||||
|
SEED_ADMIN_PASSWORD=
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -17,7 +17,22 @@ Modern domain intelligence and infrastructure analysis platform with premium UX,
|
|||||||
- **Tech/CDN** — Server, Cloudflare, Vercel signals
|
- **Tech/CDN** — Server, Cloudflare, Vercel signals
|
||||||
- **Export** — JSON and CSV downloads
|
- **Export** — JSON and CSV downloads
|
||||||
- **Live progress** — Server-Sent Events during scans
|
- **Live progress** — Server-Sent Events during scans
|
||||||
- **Monitoring** — DB schema + API for DNS/SSL monitors (workers: roadmap)
|
- **Monitoring** — DNS change + SSL expiry checks via background worker
|
||||||
|
- **Analytics** — 7-day charts from scan history (Recharts)
|
||||||
|
- **Auth** — Register / sign-in (NextAuth credentials)
|
||||||
|
|
||||||
|
## Roadmap status
|
||||||
|
|
||||||
|
| 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)
|
## Quick start (Docker)
|
||||||
|
|
||||||
@@ -66,6 +81,9 @@ npm run dev
|
|||||||
| `NEXTAUTH_URL` | Public app URL |
|
| `NEXTAUTH_URL` | Public app URL |
|
||||||
| `IPINFO_TOKEN` | Optional IPinfo token |
|
| `IPINFO_TOKEN` | Optional IPinfo token |
|
||||||
| `SCAN_RATE_LIMIT_PER_HOUR` | Per-IP scan limit (default 30) |
|
| `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 |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
35
install.sh
35
install.sh
@@ -118,6 +118,39 @@ build_app() {
|
|||||||
apt-get clean 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() {
|
install_systemd() {
|
||||||
log "Installing systemd unit (${SERVICE_NAME})..."
|
log "Installing systemd unit (${SERVICE_NAME})..."
|
||||||
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||||
@@ -143,6 +176,8 @@ EOF
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "${SERVICE_NAME}.service"
|
systemctl enable "${SERVICE_NAME}.service"
|
||||||
systemctl restart "${SERVICE_NAME}.service"
|
systemctl restart "${SERVICE_NAME}.service"
|
||||||
|
install_monitor_worker
|
||||||
|
seed_admin_user
|
||||||
}
|
}
|
||||||
|
|
||||||
install_nginx() {
|
install_nginx() {
|
||||||
|
|||||||
519
package-lock.json
generated
519
package-lock.json
generated
@@ -49,6 +49,7 @@
|
|||||||
"eslint-config-next": "^15.3.3",
|
"eslint-config-next": "^15.3.3",
|
||||||
"prisma": "^6.9.0",
|
"prisma": "^6.9.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -136,6 +137,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
@@ -5257,6 +5700,48 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -5890,6 +6375,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -9077,6 +9577,25 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||||
|
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:studio": "prisma studio"
|
"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": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.9.0",
|
"@prisma/client": "^6.9.0",
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
"eslint-config-next": "^15.3.3",
|
"eslint-config-next": "^15.3.3",
|
||||||
"prisma": "^6.9.0",
|
"prisma": "^6.9.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ model Scan {
|
|||||||
domain String
|
domain String
|
||||||
status ScanStatus @default(PENDING)
|
status ScanStatus @default(PENDING)
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
|
stage String?
|
||||||
result Json?
|
result Json?
|
||||||
error String?
|
error String?
|
||||||
userId String?
|
userId String?
|
||||||
|
|||||||
27
scripts/monitor-worker.ts
Normal file
27
scripts/monitor-worker.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
28
scripts/seed-admin.ts
Normal file
28
scripts/seed-admin.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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());
|
||||||
42
scripts/smoke-test.sh
Normal file
42
scripts/smoke-test.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/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"
|
||||||
42
src/app/api/auth/register/route.ts
Normal file
42
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export async function GET(
|
|||||||
id: scan.id,
|
id: scan.id,
|
||||||
status: scan.status,
|
status: scan.status,
|
||||||
progress: scan.progress,
|
progress: scan.progress,
|
||||||
|
stage: scan.stage,
|
||||||
domain: scan.domain,
|
domain: scan.domain,
|
||||||
result: scan.status === "COMPLETED" ? scan.result : undefined,
|
result: scan.status === "COMPLETED" ? scan.result : undefined,
|
||||||
error: scan.error,
|
error: scan.error,
|
||||||
@@ -48,7 +49,7 @@ export async function GET(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(poll, 1000);
|
setTimeout(poll, 450);
|
||||||
};
|
};
|
||||||
|
|
||||||
await poll();
|
await poll();
|
||||||
|
|||||||
@@ -62,8 +62,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
async function runScanAsync(scanId: string, domain: string, cacheKey: string) {
|
async function runScanAsync(scanId: string, domain: string, cacheKey: string) {
|
||||||
try {
|
try {
|
||||||
const result = await runDomainScan(domain, async (progress) => {
|
const result = await runDomainScan(domain, async (progress, stage) => {
|
||||||
await prisma.scan.update({ where: { id: scanId }, data: { progress } });
|
await prisma.scan.update({
|
||||||
|
where: { id: scanId },
|
||||||
|
data: { progress, stage },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await prisma.scan.update({
|
await prisma.scan.update({
|
||||||
where: { id: scanId },
|
where: { id: scanId },
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass {
|
.glass {
|
||||||
@apply border border-white/10 bg-white/5 backdrop-blur-xl;
|
@apply border border-zinc-200/80 bg-white/70 backdrop-blur-xl dark:border-white/10 dark:bg-white/5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-mesh {
|
.gradient-mesh {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -44,6 +45,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="gradient-mesh min-h-screen">
|
<div className="gradient-mesh min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,47 +2,113 @@
|
|||||||
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 });
|
const res = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
setLoading(false);
|
||||||
if (res?.error) {
|
if (res?.error) {
|
||||||
setError("Invalid credentials");
|
setError(mode === "login" ? "Invalid credentials" : "Registered but sign-in failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4">
|
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4 pb-24 pt-24">
|
||||||
<h1 className="text-3xl font-bold">Sign in</h1>
|
<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">
|
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||||
|
{mode === "register" && (
|
||||||
|
<Input
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder="Password (min 8 chars)"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-sm text-rose-400">{error}</p>}
|
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
Sign in
|
{loading ? "Please wait…" : mode === "login" ? "Sign in" : "Create account"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,61 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Bell, Plus } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
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() {
|
export default function MonitoringPage() {
|
||||||
const [domain, setDomain] = useState("");
|
const [domain, setDomain] = useState("");
|
||||||
const [monitors, setMonitors] = useState<
|
const [type, setType] = useState<"DNS" | "SSL" | "UPTIME">("DNS");
|
||||||
{ id: string; domain: string; type: string }[]
|
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 addMonitor = async () => {
|
||||||
const res = await fetch("/api/monitors", {
|
const res = await fetch("/api/monitors", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ domain, type: "DNS" }),
|
body: JSON.stringify({ domain, type }),
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
toast.error("Sign in to add monitors");
|
toast.error("Sign in to add monitors");
|
||||||
@@ -28,52 +66,114 @@ export default function MonitoringPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMonitors((m) => [...m, data.monitor]);
|
setMonitors((m) => [data.monitor, ...m]);
|
||||||
setDomain("");
|
setDomain("");
|
||||||
toast.success("Monitor created (cron worker: roadmap)");
|
toast.success("Monitor added — worker checks every ~5 min");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||||
<h1 className="text-4xl font-bold">Monitoring</h1>
|
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<p className="mt-2 text-zinc-400">
|
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Monitoring</h1>
|
||||||
DNS and SSL change alerts — queue workers ship in v1.1.
|
<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>
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<Card className="mt-8">
|
<Card className="glass mt-10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Bell className="h-5 w-5" /> Add monitor
|
<Bell className="h-5 w-5" /> Add monitor
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 sm:flex-row">
|
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder="domain.com"
|
placeholder="domain.com"
|
||||||
value={domain}
|
value={domain}
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button onClick={addMonitor}>
|
<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
|
<Plus className="h-4 w-4" /> Add
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{monitors.length === 0 ? (
|
<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="mt-16 text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
<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-zinc-600" />
|
<Bell className="h-8 w-8 text-violet-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-500">No monitors yet. Add one to track DNS/SSL changes.</p>
|
<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>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<ul className="mt-8 space-y-3">
|
|
||||||
{monitors.map((m) => (
|
<ul className="mt-6 space-y-3">
|
||||||
<li key={m.id} className="glass rounded-xl px-4 py-3">
|
{monitors.map((m, i) => (
|
||||||
{m.domain} — {m.type}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { Features } from "@/components/landing/features";
|
import { Features } from "@/components/landing/features";
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Features />
|
<Features />
|
||||||
<SiteFooter />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function Header() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 z-50 w-full border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
<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">
|
<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">
|
<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">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400">
|
||||||
@@ -37,8 +37,9 @@ export function Header() {
|
|||||||
key={l.href}
|
key={l.href}
|
||||||
href={l.href}
|
href={l.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg px-3 py-2 text-sm text-zinc-400 transition hover:text-white",
|
"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-white/5 text-white"
|
pathname.startsWith(l.href) &&
|
||||||
|
"bg-zinc-100 text-zinc-900 dark:bg-white/5 dark:text-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{l.label}
|
{l.label}
|
||||||
@@ -49,6 +50,9 @@ export function Header() {
|
|||||||
<div className="hidden items-center gap-2 md:flex">
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button variant="secondary" size="sm" asChild>
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<Link href="/login">Sign in</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" asChild>
|
||||||
<Link href="/dashboard">Start scan</Link>
|
<Link href="/dashboard">Start scan</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +66,7 @@ export function Header() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -8 }}
|
initial={{ opacity: 0, y: -8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="border-t border-white/5 bg-zinc-950 p-4 md:hidden"
|
className="border-t border-zinc-200 bg-white p-4 dark:border-white/5 dark:bg-zinc-950 md:hidden"
|
||||||
>
|
>
|
||||||
{links.map((l) => (
|
{links.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function ScanPanel() {
|
|||||||
const [domain, setDomain] = useState(searchParams.get("domain") ?? "");
|
const [domain, setDomain] = useState(searchParams.get("domain") ?? "");
|
||||||
const [scanId, setScanId] = useState<string | null>(null);
|
const [scanId, setScanId] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [stage, setStage] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<string>("idle");
|
const [status, setStatus] = useState<string>("idle");
|
||||||
const [result, setResult] = useState<ScanResult | null>(null);
|
const [result, setResult] = useState<ScanResult | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -26,6 +27,7 @@ export function ScanPanel() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
setStage(null);
|
||||||
setStatus("starting");
|
setStatus("starting");
|
||||||
|
|
||||||
const res = await fetch("/api/scan", {
|
const res = await fetch("/api/scan", {
|
||||||
@@ -58,10 +60,12 @@ export function ScanPanel() {
|
|||||||
const msg = JSON.parse(ev.data) as {
|
const msg = JSON.parse(ev.data) as {
|
||||||
status: string;
|
status: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
stage?: string;
|
||||||
result?: ScanResult;
|
result?: ScanResult;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
setProgress(msg.progress ?? 0);
|
setProgress(msg.progress ?? 0);
|
||||||
|
if (msg.stage) setStage(msg.stage);
|
||||||
if (msg.status === "COMPLETED" && msg.result) {
|
if (msg.status === "COMPLETED" && msg.result) {
|
||||||
setResult(msg.result);
|
setResult(msg.result);
|
||||||
setStatus("completed");
|
setStatus("completed");
|
||||||
@@ -116,7 +120,10 @@ export function ScanPanel() {
|
|||||||
|
|
||||||
{status === "running" && !result && (
|
{status === "running" && !result && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
{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
|
<motion.div
|
||||||
className="h-full bg-gradient-to-r from-violet-500 to-cyan-400"
|
className="h-full bg-gradient-to-r from-violet-500 to-cyan-400"
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
|
|||||||
167
src/lib/monitoring/check.ts
Normal file
167
src/lib/monitoring/check.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -193,33 +193,49 @@ function detectTech(headers: Record<string, string>): TechHint[] {
|
|||||||
const powered = headers["x-powered-by"]?.toLowerCase() ?? "";
|
const powered = headers["x-powered-by"]?.toLowerCase() ?? "";
|
||||||
const via = headers["via"]?.toLowerCase() ?? "";
|
const via = headers["via"]?.toLowerCase() ?? "";
|
||||||
const cf = headers["cf-ray"];
|
const cf = headers["cf-ray"];
|
||||||
|
const allKeys = Object.keys(headers).join(" ").toLowerCase();
|
||||||
|
|
||||||
if (cf) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" });
|
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("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("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("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("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("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 (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-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-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" });
|
||||||
const cdnWaf: string[] = [];
|
if (headers["x-render-origin-server"]) tech.push({ name: "Render", category: "Hosting", confidence: "high" });
|
||||||
if (cf) cdnWaf.push("Cloudflare");
|
if (headers["fly-request-id"]) tech.push({ name: "Fly.io", category: "Hosting", confidence: "high" });
|
||||||
if (headers["x-served-by"]?.includes("fastly")) cdnWaf.push("Fastly");
|
if (headers["x-powered-by"]?.includes("WP")) tech.push({ name: "WordPress", category: "CMS", confidence: "medium" });
|
||||||
if (headers["server"]?.includes("Akamai")) cdnWaf.push("Akamai");
|
if (allKeys.includes("x-drupal")) tech.push({ name: "Drupal", category: "CMS", confidence: "low" });
|
||||||
|
|
||||||
return tech;
|
return tech;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectCdnWaf(headers: Record<string, string>): string[] {
|
function detectCdnWaf(headers: Record<string, string>): string[] {
|
||||||
const detected: string[] = [];
|
const detected = new Set<string>();
|
||||||
if (headers["cf-ray"]) detected.push("Cloudflare");
|
const server = headers["server"]?.toLowerCase() ?? "";
|
||||||
if (headers["x-fastly-request-id"]) detected.push("Fastly");
|
const via = headers["via"]?.toLowerCase() ?? "";
|
||||||
if (headers["x-akamai-transformed"]) detected.push("Akamai");
|
|
||||||
if (headers["x-amz-cf-id"]) detected.push("AWS CloudFront");
|
if (headers["cf-ray"] || server.includes("cloudflare")) detected.add("Cloudflare");
|
||||||
if (headers["x-sucuri-id"]) detected.push("Sucuri WAF");
|
if (headers["x-fastly-request-id"] || via.includes("fastly") || headers["x-served-by"]?.includes("fastly"))
|
||||||
return detected;
|
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> {
|
async function geoLookup(domain: string): Promise<GeoInfo | null> {
|
||||||
@@ -271,12 +287,21 @@ async function geoLookup(domain: string): Promise<GeoInfo | null> {
|
|||||||
|
|
||||||
export type ScanProgressCallback = (progress: number, stage: string) => void | Promise<void>;
|
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(
|
export async function runDomainScan(
|
||||||
domain: string,
|
domain: string,
|
||||||
onProgress?: ScanProgressCallback
|
onProgress?: ScanProgressCallback
|
||||||
): Promise<ScanResult> {
|
): Promise<ScanResult> {
|
||||||
const report = async (p: number, stage: string) => {
|
const report = async (p: number, stage: string) => {
|
||||||
await onProgress?.(p, stage);
|
await onProgress?.(p, STAGE_LABELS[stage] ?? stage);
|
||||||
};
|
};
|
||||||
|
|
||||||
await report(5, "dns");
|
await report(5, "dns");
|
||||||
|
|||||||
Reference in New Issue
Block a user