feat: rebrand to Domain Scanner platform

Replace GeoExport static site with Next.js domain intelligence app:
real DNS/WHOIS/SSL/HTTP/geo scans, SSE progress, Docker stack,
updated install/uninstall scripts, and full documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-24 21:48:32 +03:00
parent 93109106bc
commit 83168af005
90 changed files with 6036 additions and 2496 deletions

36
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default async function AdminPage() {
const session = await auth();
if (!session?.user || session.user.role !== "ADMIN") {
redirect("/login");
}
const [users, scans] = await Promise.all([
prisma.user.count(),
prisma.scan.count(),
]);
return (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
<h1 className="text-4xl font-bold">Admin</h1>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">{users}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Scans</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">{scans}</CardContent>
</Card>
</div>
</div>
);
}

57
src/app/api-docs/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function ApiDocsPage() {
return (
<div className="mx-auto max-w-3xl px-4 pb-24 pt-24">
<h1 className="text-4xl font-bold">API</h1>
<p className="mt-2 text-zinc-400">REST endpoints for domain scans and exports.</p>
<div className="mt-8 space-y-4">
{[
{
method: "POST",
path: "/api/scan",
body: '{ "domain": "example.com" }',
desc: "Start a scan. Returns scan id.",
},
{
method: "GET",
path: "/api/scan/:id",
desc: "Poll scan status and result.",
},
{
method: "GET",
path: "/api/scan/:id/stream",
desc: "SSE stream for live progress.",
},
{
method: "GET",
path: "/api/export?id=&format=json|csv",
desc: "Download completed scan.",
},
{
method: "GET",
path: "/api/health",
desc: "Health check.",
},
].map((ep) => (
<Card key={ep.path}>
<CardHeader>
<CardTitle className="font-mono text-base">
<span className="text-violet-400">{ep.method}</span> {ep.path}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-zinc-400">
<p>{ep.desc}</p>
{ep.body && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs text-zinc-300">
{ep.body}
</pre>
)}
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const id = req.nextUrl.searchParams.get("id");
const format = req.nextUrl.searchParams.get("format") ?? "json";
if (!id) {
return NextResponse.json({ error: "Missing scan id" }, { status: 400 });
}
const scan = await prisma.scan.findUnique({ where: { id } });
if (!scan?.result) {
return NextResponse.json({ error: "Scan result not found" }, { status: 404 });
}
if (format === "csv") {
const result = scan.result as Record<string, unknown>;
const rows = [
["field", "value"],
["domain", scan.domain],
["scanned_at", String(result.scannedAt ?? "")],
["ssl_valid", String((result.ssl as { valid?: boolean })?.valid ?? "")],
["security_score", String((result.security as { score?: number })?.score ?? "")],
["security_grade", String((result.security as { grade?: string })?.grade ?? "")],
["uptime_reachable", String((result.uptime as { reachable?: boolean })?.reachable ?? "")],
];
const csv = rows.map((r) => r.map((c) => `"${c.replace(/"/g, '""')}"`).join(",")).join("\n");
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${scan.domain}-scan.csv"`,
},
});
}
return NextResponse.json(scan.result, {
headers: {
"Content-Disposition": `attachment; filename="${scan.domain}-scan.json"`,
},
});
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: "ok", db: true });
} catch {
return NextResponse.json({ status: "degraded", db: false }, { status: 503 });
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { normalizeDomain, isValidDomain } from "@/lib/utils";
const bodySchema = z.object({
domain: z.string(),
type: z.enum(["DNS", "SSL", "UPTIME"]),
});
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const monitors = await prisma.monitor.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ monitors });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const domain = normalizeDomain(body.domain);
if (!isValidDomain(domain)) {
return NextResponse.json({ error: "Invalid domain" }, { status: 400 });
}
const monitor = await prisma.monitor.create({
data: {
domain,
type: body.type,
userId: session.user.id,
},
});
return NextResponse.json({ monitor }, { status: 201 });
}

View File

@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const scan = await prisma.scan.findUnique({ where: { id } });
if (!scan) {
return NextResponse.json({ error: "Scan not found" }, { status: 404 });
}
return NextResponse.json(scan);
}

View File

@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
let attempts = 0;
const maxAttempts = 120;
const poll = async () => {
const scan = await prisma.scan.findUnique({ where: { id } });
if (!scan) {
send({ error: "not_found" });
controller.close();
return;
}
send({
id: scan.id,
status: scan.status,
progress: scan.progress,
domain: scan.domain,
result: scan.status === "COMPLETED" ? scan.result : undefined,
error: scan.error,
});
if (scan.status === "COMPLETED" || scan.status === "FAILED") {
controller.close();
return;
}
attempts++;
if (attempts >= maxAttempts) {
send({ error: "timeout" });
controller.close();
return;
}
setTimeout(poll, 1000);
};
await poll();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

98
src/app/api/scan/route.ts Normal file
View File

@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { checkRateLimit } from "@/lib/rate-limit";
import { normalizeDomain, isValidDomain } from "@/lib/utils";
import { runDomainScan } from "@/lib/scanner";
import { cacheGet, cacheSet } from "@/lib/redis";
const bodySchema = z.object({
domain: z.string().min(1).max(253),
});
export async function POST(req: NextRequest) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anonymous";
const rate = await checkRateLimit(`scan:${ip}`);
if (!rate.ok) {
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const domain = normalizeDomain(body.domain);
if (!isValidDomain(domain)) {
return NextResponse.json({ error: "Invalid domain name" }, { status: 400 });
}
const cacheKey = `scan:result:${domain}`;
const cached = await cacheGet<object>(cacheKey);
if (cached) {
const scan = await prisma.scan.create({
data: {
domain,
status: "COMPLETED",
progress: 100,
result: cached,
ip,
userAgent: req.headers.get("user-agent") ?? undefined,
},
});
return NextResponse.json({ id: scan.id, domain, cached: true });
}
const scan = await prisma.scan.create({
data: {
domain,
status: "RUNNING",
progress: 0,
ip,
userAgent: req.headers.get("user-agent") ?? undefined,
},
});
runScanAsync(scan.id, domain, cacheKey);
return NextResponse.json({ id: scan.id, domain });
}
async function runScanAsync(scanId: string, domain: string, cacheKey: string) {
try {
const result = await runDomainScan(domain, async (progress) => {
await prisma.scan.update({ where: { id: scanId }, data: { progress } });
});
await prisma.scan.update({
where: { id: scanId },
data: { status: "COMPLETED", progress: 100, result: result as object },
});
await cacheSet(cacheKey, result, 3600);
} catch (e) {
await prisma.scan.update({
where: { id: scanId },
data: {
status: "FAILED",
error: e instanceof Error ? e.message : "Scan failed",
},
});
}
}
export async function GET(req: NextRequest) {
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 20), 50);
const scans = await prisma.scan.findMany({
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
domain: true,
status: true,
progress: true,
createdAt: true,
},
});
return NextResponse.json({ scans });
}

View File

@@ -0,0 +1,19 @@
import { Suspense } from "react";
import { ScanPanel } from "@/components/scan/scan-panel";
import { Skeleton } from "@/components/ui/badge";
export const metadata = {
title: "Dashboard",
};
export default function DashboardPage() {
return (
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
<h1 className="mb-2 text-4xl font-bold tracking-tight">Dashboard</h1>
<p className="mb-8 text-zinc-400">Run a domain scan and export results.</p>
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<ScanPanel />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function DomainNotFound() {
return (
<div className="mx-auto max-w-lg px-4 py-32 text-center">
<h1 className="text-2xl font-bold">No scan found</h1>
<p className="mt-2 text-zinc-400">Run a scan from the dashboard first.</p>
<a href="/dashboard" className="mt-6 inline-block text-violet-400 hover:underline">
Go to dashboard
</a>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { ScanResult } from "@/lib/types";
export async function generateMetadata({ params }: { params: Promise<{ domain: string }> }) {
const { domain } = await params;
return { title: domain };
}
export default async function DomainDetailPage({
params,
}: {
params: Promise<{ domain: string }>;
}) {
const { domain: raw } = await params;
const domain = decodeURIComponent(raw);
const scan = await prisma.scan.findFirst({
where: { domain, status: "COMPLETED" },
orderBy: { createdAt: "desc" },
});
if (!scan?.result) {
notFound();
}
const result = scan.result as unknown as ScanResult;
return (
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
<h1 className="text-4xl font-bold">{result.domain}</h1>
<p className="mt-2 text-zinc-400">Full analysis report</p>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>WHOIS</CardTitle>
</CardHeader>
<CardContent className="max-h-96 overflow-auto font-mono text-xs text-zinc-300">
{Object.entries(result.whois).slice(0, 30).map(([k, v]) => (
<div key={k} className="border-b border-white/5 py-1">
<span className="text-zinc-500">{k}: </span>
{v}
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>SSL certificate</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>
Status:{" "}
<Badge variant={result.ssl.valid ? "success" : "danger"}>
{result.ssl.valid ? "Valid" : "Invalid"}
</Badge>
</p>
{result.ssl.issuer && <p>Issuer: {result.ssl.issuer}</p>}
{result.ssl.validTo && <p>Expires: {result.ssl.validTo}</p>}
{result.ssl.daysRemaining !== undefined && (
<p>Days remaining: {result.ssl.daysRemaining}</p>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>HTTP headers</CardTitle>
</CardHeader>
<CardContent className="max-h-64 overflow-auto font-mono text-xs">
{Object.entries(result.http.headers).map(([k, v]) => (
<div key={k} className="py-0.5">
<span className="text-violet-400">{k}</span>: {v}
</div>
))}
</CardContent>
</Card>
</div>
</div>
);
}

47
src/app/globals.css Normal file
View File

@@ -0,0 +1,47 @@
@import "tailwindcss";
:root {
--background: #fafafa;
--foreground: #09090b;
--muted: #71717a;
--accent: #7c3aed;
--accent-2: #06b6d4;
}
.dark {
--background: #030712;
--foreground: #fafafa;
--muted: #a1a1aa;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), system-ui, sans-serif;
min-height: 100vh;
}
.glass {
@apply border border-white/10 bg-white/5 backdrop-blur-xl;
}
.gradient-mesh {
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(124, 58, 237, 0.35), transparent),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(6, 182, 212, 0.15), transparent),
var(--background);
}

52
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Header } from "@/components/layout/header";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Domain Scanner — Domain intelligence platform",
template: "%s | Domain Scanner",
},
description:
"Modern domain intelligence and infrastructure analysis. DNS, WHOIS, SSL, geo, security headers, and monitoring.",
keywords: [
"domain scanner",
"dns",
"whois",
"ssl",
"cybersecurity",
"infrastructure",
],
openGraph: {
title: "Domain Scanner",
description: "Advanced domain intelligence with premium UX",
type: "website",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>
<div className="gradient-mesh min-h-screen">
<Header />
<main>{children}</main>
</div>
</Providers>
</body>
</html>
);
}

48
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,48 @@
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await signIn("credentials", { email, password, redirect: false });
if (res?.error) {
setError("Invalid credentials");
return;
}
router.push("/dashboard");
};
return (
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4">
<h1 className="text-3xl font-bold">Sign in</h1>
<form onSubmit={submit} className="mt-8 space-y-4">
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="text-sm text-rose-400">{error}</p>}
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Bell, Plus } from "lucide-react";
import { toast } from "sonner";
export default function MonitoringPage() {
const [domain, setDomain] = useState("");
const [monitors, setMonitors] = useState<
{ id: string; domain: string; type: string }[]
>([]);
const addMonitor = async () => {
const res = await fetch("/api/monitors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, type: "DNS" }),
});
if (res.status === 401) {
toast.error("Sign in to add monitors");
return;
}
if (!res.ok) {
toast.error("Could not create monitor");
return;
}
const data = await res.json();
setMonitors((m) => [...m, data.monitor]);
setDomain("");
toast.success("Monitor created (cron worker: roadmap)");
};
return (
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
<h1 className="text-4xl font-bold">Monitoring</h1>
<p className="mt-2 text-zinc-400">
DNS and SSL change alerts queue workers ship in v1.1.
</p>
<Card className="mt-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" /> Add monitor
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row">
<Input
placeholder="domain.com"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
<Button onClick={addMonitor}>
<Plus className="h-4 w-4" /> Add
</Button>
</CardContent>
</Card>
{monitors.length === 0 ? (
<div className="mt-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Bell className="h-8 w-8 text-zinc-600" />
</div>
<p className="text-zinc-500">No monitors yet. Add one to track DNS/SSL changes.</p>
</div>
) : (
<ul className="mt-8 space-y-3">
{monitors.map((m) => (
<li key={m.id} className="glass rounded-xl px-4 py-3">
{m.domain} {m.type}
</li>
))}
</ul>
)}
</div>
);
}

19
src/app/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Hero } from "@/components/landing/hero";
import { Features } from "@/components/landing/features";
export default function HomePage() {
return (
<>
<Hero />
<Features />
<footer className="border-t border-white/5 py-12 text-center text-sm text-zinc-500">
<p>Domain Scanner self-hosted domain intelligence</p>
<p className="mt-2">
<a href="https://github.com/andrey271192/Domain_web" className="text-violet-400 hover:underline">
GitHub
</a>
</p>
</footer>
</>
);
}

11
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
export default function SettingsPage() {
return (
<div className="mx-auto max-w-2xl px-4 pb-24 pt-24">
<h1 className="text-4xl font-bold">Settings</h1>
<p className="mt-4 text-zinc-400">
Configure IPINFO_TOKEN, SCAN_RATE_LIMIT_PER_HOUR, and database URLs via environment
variables on your server. Theme toggle is in the header.
</p>
</div>
);
}