mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-21 14:51:57 +00:00
feat: rebrand to Domain Scanner platform
Replace GeoExport static site with Next.js domain intelligence app: real DNS/WHOIS/SSL/HTTP/geo scans, SSE progress, Docker stack, updated install/uninstall scripts, and full documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
36
src/app/admin/page.tsx
Normal file
36
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const [users, scans] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.scan.count(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold">Admin</h1>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{users}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Scans</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{scans}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/app/api-docs/page.tsx
Normal file
57
src/app/api-docs/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold">API</h1>
|
||||
<p className="mt-2 text-zinc-400">REST endpoints for domain scans and exports.</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{[
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/scan",
|
||||
body: '{ "domain": "example.com" }',
|
||||
desc: "Start a scan. Returns scan id.",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/scan/:id",
|
||||
desc: "Poll scan status and result.",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/scan/:id/stream",
|
||||
desc: "SSE stream for live progress.",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/export?id=&format=json|csv",
|
||||
desc: "Download completed scan.",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/health",
|
||||
desc: "Health check.",
|
||||
},
|
||||
].map((ep) => (
|
||||
<Card key={ep.path}>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-mono text-base">
|
||||
<span className="text-violet-400">{ep.method}</span> {ep.path}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-zinc-400">
|
||||
<p>{ep.desc}</p>
|
||||
{ep.body && (
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs text-zinc-300">
|
||||
{ep.body}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
42
src/app/api/export/route.ts
Normal file
42
src/app/api/export/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
const format = req.nextUrl.searchParams.get("format") ?? "json";
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing scan id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const scan = await prisma.scan.findUnique({ where: { id } });
|
||||
if (!scan?.result) {
|
||||
return NextResponse.json({ error: "Scan result not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (format === "csv") {
|
||||
const result = scan.result as Record<string, unknown>;
|
||||
const rows = [
|
||||
["field", "value"],
|
||||
["domain", scan.domain],
|
||||
["scanned_at", String(result.scannedAt ?? "")],
|
||||
["ssl_valid", String((result.ssl as { valid?: boolean })?.valid ?? "")],
|
||||
["security_score", String((result.security as { score?: number })?.score ?? "")],
|
||||
["security_grade", String((result.security as { grade?: string })?.grade ?? "")],
|
||||
["uptime_reachable", String((result.uptime as { reachable?: boolean })?.reachable ?? "")],
|
||||
];
|
||||
const csv = rows.map((r) => r.map((c) => `"${c.replace(/"/g, '""')}"`).join(",")).join("\n");
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="${scan.domain}-scan.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(scan.result, {
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${scan.domain}-scan.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
11
src/app/api/health/route.ts
Normal file
11
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ status: "ok", db: true });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "degraded", db: false }, { status: 503 });
|
||||
}
|
||||
}
|
||||
51
src/app/api/monitors/route.ts
Normal file
51
src/app/api/monitors/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { normalizeDomain, isValidDomain } from "@/lib/utils";
|
||||
|
||||
const bodySchema = z.object({
|
||||
domain: z.string(),
|
||||
type: z.enum(["DNS", "SSL", "UPTIME"]),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const monitors = await prisma.monitor.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json({ monitors });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const domain = normalizeDomain(body.domain);
|
||||
if (!isValidDomain(domain)) {
|
||||
return NextResponse.json({ error: "Invalid domain" }, { status: 400 });
|
||||
}
|
||||
|
||||
const monitor = await prisma.monitor.create({
|
||||
data: {
|
||||
domain,
|
||||
type: body.type,
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ monitor }, { status: 201 });
|
||||
}
|
||||
14
src/app/api/scan/[id]/route.ts
Normal file
14
src/app/api/scan/[id]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const scan = await prisma.scan.findUnique({ where: { id } });
|
||||
if (!scan) {
|
||||
return NextResponse.json({ error: "Scan not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(scan);
|
||||
}
|
||||
65
src/app/api/scan/[id]/stream/route.ts
Normal file
65
src/app/api/scan/[id]/stream/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = (data: object) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 120;
|
||||
|
||||
const poll = async () => {
|
||||
const scan = await prisma.scan.findUnique({ where: { id } });
|
||||
if (!scan) {
|
||||
send({ error: "not_found" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
id: scan.id,
|
||||
status: scan.status,
|
||||
progress: scan.progress,
|
||||
domain: scan.domain,
|
||||
result: scan.status === "COMPLETED" ? scan.result : undefined,
|
||||
error: scan.error,
|
||||
});
|
||||
|
||||
if (scan.status === "COMPLETED" || scan.status === "FAILED") {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
send({ error: "timeout" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
98
src/app/api/scan/route.ts
Normal file
98
src/app/api/scan/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { normalizeDomain, isValidDomain } from "@/lib/utils";
|
||||
import { runDomainScan } from "@/lib/scanner";
|
||||
import { cacheGet, cacheSet } from "@/lib/redis";
|
||||
|
||||
const bodySchema = z.object({
|
||||
domain: z.string().min(1).max(253),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anonymous";
|
||||
const rate = await checkRateLimit(`scan:${ip}`);
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const domain = normalizeDomain(body.domain);
|
||||
if (!isValidDomain(domain)) {
|
||||
return NextResponse.json({ error: "Invalid domain name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cacheKey = `scan:result:${domain}`;
|
||||
const cached = await cacheGet<object>(cacheKey);
|
||||
if (cached) {
|
||||
const scan = await prisma.scan.create({
|
||||
data: {
|
||||
domain,
|
||||
status: "COMPLETED",
|
||||
progress: 100,
|
||||
result: cached,
|
||||
ip,
|
||||
userAgent: req.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ id: scan.id, domain, cached: true });
|
||||
}
|
||||
|
||||
const scan = await prisma.scan.create({
|
||||
data: {
|
||||
domain,
|
||||
status: "RUNNING",
|
||||
progress: 0,
|
||||
ip,
|
||||
userAgent: req.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
runScanAsync(scan.id, domain, cacheKey);
|
||||
|
||||
return NextResponse.json({ id: scan.id, domain });
|
||||
}
|
||||
|
||||
async function runScanAsync(scanId: string, domain: string, cacheKey: string) {
|
||||
try {
|
||||
const result = await runDomainScan(domain, async (progress) => {
|
||||
await prisma.scan.update({ where: { id: scanId }, data: { progress } });
|
||||
});
|
||||
await prisma.scan.update({
|
||||
where: { id: scanId },
|
||||
data: { status: "COMPLETED", progress: 100, result: result as object },
|
||||
});
|
||||
await cacheSet(cacheKey, result, 3600);
|
||||
} catch (e) {
|
||||
await prisma.scan.update({
|
||||
where: { id: scanId },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
error: e instanceof Error ? e.message : "Scan failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 20), 50);
|
||||
const scans = await prisma.scan.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
domain: true,
|
||||
status: true,
|
||||
progress: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ scans });
|
||||
}
|
||||
19
src/app/dashboard/page.tsx
Normal file
19
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import { ScanPanel } from "@/components/scan/scan-panel";
|
||||
import { Skeleton } from "@/components/ui/badge";
|
||||
|
||||
export const metadata = {
|
||||
title: "Dashboard",
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
|
||||
<h1 className="mb-2 text-4xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="mb-8 text-zinc-400">Run a domain scan and export results.</p>
|
||||
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||
<ScanPanel />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/domain/[domain]/not-found.tsx
Normal file
11
src/app/domain/[domain]/not-found.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function DomainNotFound() {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-32 text-center">
|
||||
<h1 className="text-2xl font-bold">No scan found</h1>
|
||||
<p className="mt-2 text-zinc-400">Run a scan from the dashboard first.</p>
|
||||
<a href="/dashboard" className="mt-6 inline-block text-violet-400 hover:underline">
|
||||
Go to dashboard
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/app/domain/[domain]/page.tsx
Normal file
85
src/app/domain/[domain]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ScanResult } from "@/lib/types";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ domain: string }> }) {
|
||||
const { domain } = await params;
|
||||
return { title: domain };
|
||||
}
|
||||
|
||||
export default async function DomainDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ domain: string }>;
|
||||
}) {
|
||||
const { domain: raw } = await params;
|
||||
const domain = decodeURIComponent(raw);
|
||||
|
||||
const scan = await prisma.scan.findFirst({
|
||||
where: { domain, status: "COMPLETED" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!scan?.result) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const result = scan.result as unknown as ScanResult;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold">{result.domain}</h1>
|
||||
<p className="mt-2 text-zinc-400">Full analysis report</p>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>WHOIS</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-96 overflow-auto font-mono text-xs text-zinc-300">
|
||||
{Object.entries(result.whois).slice(0, 30).map(([k, v]) => (
|
||||
<div key={k} className="border-b border-white/5 py-1">
|
||||
<span className="text-zinc-500">{k}: </span>
|
||||
{v}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SSL certificate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<p>
|
||||
Status:{" "}
|
||||
<Badge variant={result.ssl.valid ? "success" : "danger"}>
|
||||
{result.ssl.valid ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
</p>
|
||||
{result.ssl.issuer && <p>Issuer: {result.ssl.issuer}</p>}
|
||||
{result.ssl.validTo && <p>Expires: {result.ssl.validTo}</p>}
|
||||
{result.ssl.daysRemaining !== undefined && (
|
||||
<p>Days remaining: {result.ssl.daysRemaining}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>HTTP headers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-64 overflow-auto font-mono text-xs">
|
||||
{Object.entries(result.http.headers).map(([k, v]) => (
|
||||
<div key={k} className="py-0.5">
|
||||
<span className="text-violet-400">{k}</span>: {v}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/globals.css
Normal file
47
src/app/globals.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #fafafa;
|
||||
--foreground: #09090b;
|
||||
--muted: #71717a;
|
||||
--accent: #7c3aed;
|
||||
--accent-2: #06b6d4;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #030712;
|
||||
--foreground: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply border border-white/10 bg-white/5 backdrop-blur-xl;
|
||||
}
|
||||
|
||||
.gradient-mesh {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(124, 58, 237, 0.35), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(6, 182, 212, 0.15), transparent),
|
||||
var(--background);
|
||||
}
|
||||
52
src/app/layout.tsx
Normal file
52
src/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Domain Scanner — Domain intelligence platform",
|
||||
template: "%s | Domain Scanner",
|
||||
},
|
||||
description:
|
||||
"Modern domain intelligence and infrastructure analysis. DNS, WHOIS, SSL, geo, security headers, and monitoring.",
|
||||
keywords: [
|
||||
"domain scanner",
|
||||
"dns",
|
||||
"whois",
|
||||
"ssl",
|
||||
"cybersecurity",
|
||||
"infrastructure",
|
||||
],
|
||||
openGraph: {
|
||||
title: "Domain Scanner",
|
||||
description: "Advanced domain intelligence with premium UX",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<Providers>
|
||||
<div className="gradient-mesh min-h-screen">
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
48
src/app/login/page.tsx
Normal file
48
src/app/login/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = await signIn("credentials", { email, password, redirect: false });
|
||||
if (res?.error) {
|
||||
setError("Invalid credentials");
|
||||
return;
|
||||
}
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] max-w-md flex-col justify-center px-4">
|
||||
<h1 className="text-3xl font-bold">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-sm text-rose-400">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/app/monitoring/page.tsx
Normal file
79
src/app/monitoring/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Bell, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [monitors, setMonitors] = useState<
|
||||
{ id: string; domain: string; type: string }[]
|
||||
>([]);
|
||||
|
||||
const addMonitor = async () => {
|
||||
const res = await fetch("/api/monitors", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain, type: "DNS" }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
toast.error("Sign in to add monitors");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error("Could not create monitor");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setMonitors((m) => [...m, data.monitor]);
|
||||
setDomain("");
|
||||
toast.success("Monitor created (cron worker: roadmap)");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold">Monitoring</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
DNS and SSL change alerts — queue workers ship in v1.1.
|
||||
</p>
|
||||
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" /> Add monitor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
placeholder="domain.com"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
<Button onClick={addMonitor}>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{monitors.length === 0 ? (
|
||||
<div className="mt-16 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
||||
<Bell className="h-8 w-8 text-zinc-600" />
|
||||
</div>
|
||||
<p className="text-zinc-500">No monitors yet. Add one to track DNS/SSL changes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-8 space-y-3">
|
||||
{monitors.map((m) => (
|
||||
<li key={m.id} className="glass rounded-xl px-4 py-3">
|
||||
{m.domain} — {m.type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/app/page.tsx
Normal file
19
src/app/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Features } from "@/components/landing/features";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
<footer className="border-t border-white/5 py-12 text-center text-sm text-zinc-500">
|
||||
<p>Domain Scanner — self-hosted domain intelligence</p>
|
||||
<p className="mt-2">
|
||||
<a href="https://github.com/andrey271192/Domain_web" className="text-violet-400 hover:underline">
|
||||
GitHub
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/app/settings/page.tsx
Normal file
11
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 pb-24 pt-24">
|
||||
<h1 className="text-4xl font-bold">Settings</h1>
|
||||
<p className="mt-4 text-zinc-400">
|
||||
Configure IPINFO_TOKEN, SCAN_RATE_LIMIT_PER_HOUR, and database URLs via environment
|
||||
variables on your server. Theme toggle is in the header.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/landing/features.tsx
Normal file
57
src/components/landing/features.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Database, Lock, Radio, Server } from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Database,
|
||||
title: "DNS & WHOIS depth",
|
||||
desc: "A/AAAA, MX, TXT, NS resolution plus registrar metadata in one pass.",
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
title: "SSL & security grade",
|
||||
desc: "Certificate validity, issuer chain, and security header scoring.",
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
title: "Live scan stream",
|
||||
desc: "SSE progress events while workers resolve each infrastructure layer.",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: "Self-hosted stack",
|
||||
desc: "PostgreSQL, Redis, Docker — your data stays on your VPS.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl px-4 py-24">
|
||||
<h2 className="mb-4 text-center text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Built for operators
|
||||
</h2>
|
||||
<p className="mx-auto mb-16 max-w-2xl text-center text-zinc-400">
|
||||
Competitive tools scatter checks across tabs. Domain Scanner unifies infrastructure
|
||||
signals with export-ready reports and monitoring hooks.
|
||||
</p>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{features.map((f, i) => (
|
||||
<motion.div
|
||||
key={f.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="glass rounded-2xl p-8"
|
||||
>
|
||||
<f.icon className="mb-4 h-8 w-8 text-violet-400" />
|
||||
<h3 className="text-xl font-semibold">{f.title}</h3>
|
||||
<p className="mt-2 text-zinc-400">{f.desc}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
113
src/components/landing/hero.tsx
Normal file
113
src/components/landing/hero.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Search, Shield, Radar, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { normalizeDomain } from "@/lib/utils";
|
||||
|
||||
export function Hero() {
|
||||
const router = useRouter();
|
||||
const [domain, setDomain] = useState("");
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const d = normalizeDomain(domain);
|
||||
if (d) router.push(`/dashboard?domain=${encodeURIComponent(d)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-[90vh] flex-col items-center justify-center px-4 pt-24 text-center">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 left-1/2 h-[500px] w-[800px] -translate-x-1/2 rounded-full bg-violet-600/30 blur-[120px]" />
|
||||
<div className="absolute top-1/3 right-0 h-[400px] w-[400px] rounded-full bg-cyan-500/20 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 text-sm font-medium uppercase tracking-[0.2em] text-violet-300"
|
||||
>
|
||||
Domain intelligence platform
|
||||
</motion.p>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="max-w-4xl text-5xl font-bold leading-[1.05] tracking-tight sm:text-7xl lg:text-8xl"
|
||||
>
|
||||
See every layer
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-violet-300 via-white to-cyan-300 bg-clip-text text-transparent">
|
||||
of your domain
|
||||
</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="mt-6 max-w-2xl text-lg text-zinc-400 sm:text-xl"
|
||||
>
|
||||
DNS, WHOIS, SSL, headers, geo, CDN detection, and security scoring — one scan,
|
||||
exportable reports, monitoring hooks for production teams.
|
||||
</motion.p>
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
onSubmit={submit}
|
||||
className="mt-10 flex w-full max-w-xl flex-col gap-3 sm:flex-row"
|
||||
>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
className="flex-1 text-left"
|
||||
/>
|
||||
<Button type="submit" size="lg" className="shrink-0">
|
||||
<Search className="h-4 w-4" />
|
||||
Analyze
|
||||
</Button>
|
||||
</motion.form>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="mt-16 grid w-full max-w-4xl grid-cols-2 gap-4 sm:grid-cols-4"
|
||||
>
|
||||
{[
|
||||
{ icon: Radar, label: "DNS & WHOIS" },
|
||||
{ icon: Shield, label: "SSL & headers" },
|
||||
{ icon: Zap, label: "Live progress" },
|
||||
{ icon: Search, label: "Export JSON/CSV" },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur"
|
||||
>
|
||||
<Icon className="mb-2 h-5 w-5 text-violet-400" />
|
||||
<p className="text-sm text-zinc-300">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="mt-12"
|
||||
>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="/dashboard">Open dashboard</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
83
src/components/layout/header.tsx
Normal file
83
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Globe2, Menu, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/monitoring", label: "Monitoring" },
|
||||
{ href: "/api-docs", label: "API" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 z-50 w-full border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400">
|
||||
<Globe2 className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-semibold tracking-tight">Domain Scanner</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm text-zinc-400 transition hover:text-white",
|
||||
pathname.startsWith(l.href) && "bg-white/5 text-white"
|
||||
)}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<ThemeToggle />
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<Link href="/dashboard">Start scan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button className="md:hidden" onClick={() => setOpen(!open)} aria-label="Menu">
|
||||
{open ? <X /> : <Menu />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border-t border-white/5 bg-zinc-950 p-4 md:hidden"
|
||||
>
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="block py-2 text-zinc-300"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/dashboard" className="mt-2 block">
|
||||
<Button className="w-full">Start scan</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
13
src/components/providers.tsx
Normal file
13
src/components/providers.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
288
src/components/scan/scan-panel.tsx
Normal file
288
src/components/scan/scan-panel.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Download, Loader2, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge, Skeleton } from "@/components/ui/badge";
|
||||
import type { ScanResult } from "@/lib/types";
|
||||
import Link from "next/link";
|
||||
|
||||
export function ScanPanel() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [domain, setDomain] = useState(searchParams.get("domain") ?? "");
|
||||
const [scanId, setScanId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [status, setStatus] = useState<string>("idle");
|
||||
const [result, setResult] = useState<ScanResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startScan = useCallback(async (d: string) => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setStatus("starting");
|
||||
|
||||
const res = await fetch("/api/scan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain: d }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Scan failed");
|
||||
setStatus("failed");
|
||||
return;
|
||||
}
|
||||
|
||||
setScanId(data.id);
|
||||
setStatus("running");
|
||||
router.replace(`/dashboard?domain=${encodeURIComponent(d)}`);
|
||||
|
||||
if (data.cached) {
|
||||
const full = await fetch(`/api/scan/${data.id}`);
|
||||
const scan = await full.json();
|
||||
setResult(scan.result as ScanResult);
|
||||
setProgress(100);
|
||||
setStatus("completed");
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/scan/${data.id}/stream`);
|
||||
es.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data) as {
|
||||
status: string;
|
||||
progress: number;
|
||||
result?: ScanResult;
|
||||
error?: string;
|
||||
};
|
||||
setProgress(msg.progress ?? 0);
|
||||
if (msg.status === "COMPLETED" && msg.result) {
|
||||
setResult(msg.result);
|
||||
setStatus("completed");
|
||||
es.close();
|
||||
}
|
||||
if (msg.status === "FAILED") {
|
||||
setError(msg.error ?? "Scan failed");
|
||||
setStatus("failed");
|
||||
es.close();
|
||||
}
|
||||
};
|
||||
es.onerror = () => es.close();
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
const d = searchParams.get("domain");
|
||||
if (d && status === "idle") {
|
||||
setDomain(d);
|
||||
startScan(d);
|
||||
}
|
||||
}, [searchParams, status, startScan]);
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (domain.trim()) startScan(domain.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
placeholder="Enter domain — stripe.com"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
disabled={status === "running"}
|
||||
/>
|
||||
<Button type="submit" disabled={status === "running"}>
|
||||
{status === "running" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
{status === "running" ? `Scanning ${progress}%` : "Run scan"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "running" && !result && (
|
||||
<div className="space-y-4">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-violet-500 to-cyan-400"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<ScanResults result={result} scanId={scanId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanResults({ result, scanId }: { result: ScanResult; scanId: string | null }) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">{result.domain}</h2>
|
||||
<p className="text-sm text-zinc-500">Scanned {new Date(result.scannedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{scanId && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<a href={`/api/export?id=${scanId}&format=json`}>
|
||||
<Download className="h-4 w-4" /> JSON
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<a href={`/api/export?id=${scanId}&format=csv`}>
|
||||
<Download className="h-4 w-4" /> CSV
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<Link href={`/domain/${result.domain}`}>Full report</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard title="SSL" value={result.ssl.valid ? "Valid" : "Invalid"} ok={result.ssl.valid} />
|
||||
<MetricCard title="Security" value={result.security.grade} ok={result.security.score >= 60} />
|
||||
<MetricCard title="Uptime" value={result.uptime.reachable ? "Up" : "Down"} ok={result.uptime.reachable} />
|
||||
<MetricCard
|
||||
title="Latency"
|
||||
value={result.uptime.latencyMs ? `${result.uptime.latencyMs}ms` : "—"}
|
||||
ok={!!result.uptime.latencyMs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DNS records</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-64 overflow-auto text-sm">
|
||||
{result.dns.records.length === 0 ? (
|
||||
<p className="text-zinc-500">No records found</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{result.dns.records.map((r, i) => (
|
||||
<li key={i} className="font-mono text-xs text-zinc-300">
|
||||
<Badge className="mr-2">{r.type}</Badge>
|
||||
{r.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security headers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-4xl font-bold">{result.security.score}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.security.present.map((h) => (
|
||||
<Badge key={h} variant="success">
|
||||
{h}
|
||||
</Badge>
|
||||
))}
|
||||
{result.security.missing.slice(0, 4).map((h) => (
|
||||
<Badge key={h} variant="warning">
|
||||
missing: {h}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geo / IP</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-zinc-300">
|
||||
{result.geo ? (
|
||||
<dl className="space-y-1">
|
||||
<div>
|
||||
<dt className="text-zinc-500">IP</dt>
|
||||
<dd className="font-mono">{result.geo.ip}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Location</dt>
|
||||
<dd>
|
||||
{[result.geo.city, result.geo.region, result.geo.country].filter(Boolean).join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
{result.geo.isp && (
|
||||
<div>
|
||||
<dt className="text-zinc-500">ISP</dt>
|
||||
<dd>{result.geo.isp}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-zinc-500">Geo lookup unavailable</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tech & CDN</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.tech.map((t) => (
|
||||
<Badge key={t.name}>{t.name}</Badge>
|
||||
))}
|
||||
{result.cdnWaf.detected.map((c) => (
|
||||
<Badge key={c} variant="success">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
{result.tech.length === 0 && result.cdnWaf.detected.length === 0 && (
|
||||
<p className="text-zinc-500">No strong signals detected</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, ok }: { title: string; value: string; ok: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-zinc-500">{title}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${ok ? "text-emerald-400" : "text-amber-400"}`}>
|
||||
{value}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
20
src/components/theme-toggle.tsx
Normal file
20
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/badge.tsx
Normal file
30
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: "default" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
const variants = {
|
||||
default: "bg-violet-500/20 text-violet-200 border-violet-500/30",
|
||||
success: "bg-emerald-500/20 text-emerald-200 border-emerald-500/30",
|
||||
warning: "bg-amber-500/20 text-amber-200 border-amber-500/30",
|
||||
danger: "bg-rose-500/20 text-rose-200 border-rose-500/30",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("animate-pulse rounded-lg bg-white/10", className)} />;
|
||||
}
|
||||
44
src/components/ui/button.tsx
Normal file
44
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-white text-zinc-950 hover:bg-zinc-100 shadow-lg shadow-white/10",
|
||||
secondary:
|
||||
"bg-white/10 text-white border border-white/20 hover:bg-white/15 backdrop-blur",
|
||||
ghost: "hover:bg-white/10 text-zinc-300",
|
||||
outline: "border border-zinc-700 bg-transparent hover:bg-zinc-800",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-6",
|
||||
sm: "h-9 px-4 text-xs",
|
||||
lg: "h-13 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
30
src/components/ui/card.tsx
Normal file
30
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl shadow-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col gap-1.5 p-6 pb-0", className)} {...props} />
|
||||
);
|
||||
|
||||
const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className={cn("text-lg font-semibold tracking-tight", className)} {...props} />
|
||||
);
|
||||
|
||||
const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("p-6", className)} {...props} />
|
||||
);
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent };
|
||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-12 w-full rounded-xl border border-white/15 bg-white/5 px-4 text-base text-white placeholder:text-zinc-500 backdrop-blur focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/40",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
42
src/lib/auth.ts
Normal file
42
src/lib/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
const email = String(credentials.email).toLowerCase();
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
const ok = await bcrypt.compare(String(credentials.password), user.passwordHash);
|
||||
if (!ok) return null;
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: { strategy: "jwt" },
|
||||
pages: { signIn: "/login" },
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = (user as { role?: string }).role;
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
20
src/lib/graphql/schema.ts
Normal file
20
src/lib/graphql/schema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const typeDefs = `#graphql
|
||||
type ScanResult {
|
||||
domain: String!
|
||||
scannedAt: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
scan(domain: String!): ScanResult
|
||||
}
|
||||
`;
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
scan: async (_: unknown, { domain }: { domain: string }) => ({
|
||||
domain,
|
||||
scannedAt: new Date().toISOString(),
|
||||
note: "GraphQL endpoint placeholder — use REST /api/scan for full results",
|
||||
}),
|
||||
},
|
||||
};
|
||||
11
src/lib/prisma.ts
Normal file
11
src/lib/prisma.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
31
src/lib/rate-limit.ts
Normal file
31
src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
const LIMIT = Number(process.env.SCAN_RATE_LIMIT_PER_HOUR ?? 30);
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export async function checkRateLimit(key: string): Promise<{ ok: boolean; remaining: number }> {
|
||||
const now = new Date();
|
||||
const windowEnd = new Date(now.getTime() + WINDOW_MS);
|
||||
|
||||
try {
|
||||
const row = await prisma.apiRateLimit.findUnique({ where: { key } });
|
||||
if (!row || row.windowEnd < now) {
|
||||
await prisma.apiRateLimit.upsert({
|
||||
where: { key },
|
||||
create: { key, count: 1, windowEnd },
|
||||
update: { count: 1, windowEnd },
|
||||
});
|
||||
return { ok: true, remaining: LIMIT - 1 };
|
||||
}
|
||||
if (row.count >= LIMIT) {
|
||||
return { ok: false, remaining: 0 };
|
||||
}
|
||||
await prisma.apiRateLimit.update({
|
||||
where: { key },
|
||||
data: { count: row.count + 1 },
|
||||
});
|
||||
return { ok: true, remaining: LIMIT - row.count - 1 };
|
||||
} catch {
|
||||
return { ok: true, remaining: LIMIT };
|
||||
}
|
||||
}
|
||||
33
src/lib/redis.ts
Normal file
33
src/lib/redis.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
let redis: Redis | null = null;
|
||||
|
||||
export function getRedis(): Redis | null {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
if (!redis) {
|
||||
redis = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: true });
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
|
||||
export async function cacheGet<T>(key: string): Promise<T | null> {
|
||||
const r = getRedis();
|
||||
if (!r) return null;
|
||||
try {
|
||||
const val = await r.get(key);
|
||||
return val ? (JSON.parse(val) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheSet(key: string, value: unknown, ttlSec = 3600) {
|
||||
const r = getRedis();
|
||||
if (!r) return;
|
||||
try {
|
||||
await r.setex(key, ttlSec, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
332
src/lib/scanner/index.ts
Normal file
332
src/lib/scanner/index.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import dns from "node:dns/promises";
|
||||
import tls from "node:tls";
|
||||
import * as whois from "whois";
|
||||
import { promisify } from "node:util";
|
||||
import type { ScanResult, DnsRecord, SslInfo, SecurityHeaders, TechHint, GeoInfo } from "../types";
|
||||
|
||||
const whoisLookup = promisify(whois.lookup.bind(whois));
|
||||
|
||||
const SECURITY_HEADER_KEYS = [
|
||||
"strict-transport-security",
|
||||
"content-security-policy",
|
||||
"x-frame-options",
|
||||
"x-content-type-options",
|
||||
"referrer-policy",
|
||||
"permissions-policy",
|
||||
"cross-origin-opener-policy",
|
||||
"cross-origin-resource-policy",
|
||||
];
|
||||
|
||||
function parseWhois(raw: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx > 0) {
|
||||
const k = line.slice(0, idx).trim().toLowerCase();
|
||||
const v = line.slice(idx + 1).trim();
|
||||
if (k && v && !k.startsWith("%")) out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function lookupDns(domain: string) {
|
||||
const records: DnsRecord[] = [];
|
||||
const nameservers: string[] = [];
|
||||
const mx: string[] = [];
|
||||
const txt: string[] = [];
|
||||
|
||||
const types: Array<["A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS", () => Promise<unknown>]> = [
|
||||
["A", () => dns.resolve4(domain)],
|
||||
["AAAA", () => dns.resolve6(domain)],
|
||||
["CNAME", () => dns.resolveCname(domain)],
|
||||
["MX", () => dns.resolveMx(domain)],
|
||||
["TXT", () => dns.resolveTxt(domain)],
|
||||
["NS", () => dns.resolveNs(domain)],
|
||||
];
|
||||
|
||||
for (const [type, fn] of types) {
|
||||
try {
|
||||
const res = (await fn()) as unknown;
|
||||
if (type === "MX" && Array.isArray(res)) {
|
||||
for (const r of res as { exchange: string; priority: number }[]) {
|
||||
mx.push(`${r.priority} ${r.exchange}`);
|
||||
records.push({ type: "MX", value: `${r.priority} ${r.exchange}` });
|
||||
}
|
||||
} else if (type === "TXT" && Array.isArray(res)) {
|
||||
for (const r of res as string[][]) {
|
||||
const v = r.join("");
|
||||
txt.push(v);
|
||||
records.push({ type: "TXT", value: v });
|
||||
}
|
||||
} else if (type === "NS" && Array.isArray(res)) {
|
||||
for (const r of res as string[]) {
|
||||
nameservers.push(r);
|
||||
records.push({ type: "NS", value: r });
|
||||
}
|
||||
} else if (Array.isArray(res)) {
|
||||
for (const r of res as string[]) {
|
||||
records.push({ type, value: r });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* record type may not exist */
|
||||
}
|
||||
}
|
||||
|
||||
return { records, nameservers, mx, txt };
|
||||
}
|
||||
|
||||
async function checkSsl(domain: string): Promise<SslInfo> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = tls.connect(
|
||||
{ host: domain, port: 443, servername: domain, rejectUnauthorized: false, timeout: 10000 },
|
||||
() => {
|
||||
const cert = socket.getPeerCertificate();
|
||||
socket.end();
|
||||
if (!cert || !cert.valid_to) {
|
||||
resolve({ valid: false, error: "No certificate" });
|
||||
return;
|
||||
}
|
||||
const validTo = new Date(cert.valid_to);
|
||||
const validFrom = new Date(cert.valid_from);
|
||||
const daysRemaining = Math.floor((validTo.getTime() - Date.now()) / 86400000);
|
||||
const issuer = cert.issuer?.O ?? cert.issuer?.CN;
|
||||
const subject = cert.subject?.CN;
|
||||
resolve({
|
||||
valid: daysRemaining > 0,
|
||||
issuer: Array.isArray(issuer) ? issuer[0] : issuer,
|
||||
subject: Array.isArray(subject) ? subject[0] : subject,
|
||||
validFrom: validFrom.toISOString(),
|
||||
validTo: validTo.toISOString(),
|
||||
daysRemaining,
|
||||
protocol: socket.getProtocol?.() ?? undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
socket.on("error", (e) => resolve({ valid: false, error: e.message }));
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve({ valid: false, error: "Timeout" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHttp(domain: string) {
|
||||
const redirectChain: string[] = [];
|
||||
let url = `https://${domain}`;
|
||||
let status: number | undefined;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: { "User-Agent": "DomainScanner/1.0 (+https://github.com/andrey271192/Domain_web)" },
|
||||
});
|
||||
status = res.status;
|
||||
res.headers.forEach((v, k) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
});
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get("location");
|
||||
if (!loc) break;
|
||||
redirectChain.push(loc);
|
||||
url = loc.startsWith("http") ? loc : new URL(loc, url).href;
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
status,
|
||||
finalUrl: url,
|
||||
redirectChain,
|
||||
headers,
|
||||
server: headers["server"],
|
||||
poweredBy: headers["x-powered-by"],
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
} catch {
|
||||
try {
|
||||
url = `http://${domain}`;
|
||||
const res = await fetch(url, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
status = res.status;
|
||||
res.headers.forEach((v, k) => {
|
||||
headers[k.toLowerCase()] = v;
|
||||
});
|
||||
return {
|
||||
status,
|
||||
finalUrl: res.url,
|
||||
redirectChain,
|
||||
headers,
|
||||
server: headers["server"],
|
||||
poweredBy: headers["x-powered-by"],
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
} catch {
|
||||
return { redirectChain, headers, reachable: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status, finalUrl: url, redirectChain, headers };
|
||||
}
|
||||
|
||||
function scoreSecurityHeaders(headers: Record<string, string>): SecurityHeaders {
|
||||
const present: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const key of SECURITY_HEADER_KEYS) {
|
||||
if (headers[key]) present.push(key);
|
||||
else missing.push(key);
|
||||
}
|
||||
const score = Math.round((present.length / SECURITY_HEADER_KEYS.length) * 100);
|
||||
const grade =
|
||||
score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
||||
return { score, grade, present, missing, headers };
|
||||
}
|
||||
|
||||
function detectTech(headers: Record<string, string>): TechHint[] {
|
||||
const tech: TechHint[] = [];
|
||||
const server = headers["server"]?.toLowerCase() ?? "";
|
||||
const powered = headers["x-powered-by"]?.toLowerCase() ?? "";
|
||||
const via = headers["via"]?.toLowerCase() ?? "";
|
||||
const cf = headers["cf-ray"];
|
||||
|
||||
if (cf) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" });
|
||||
if (server.includes("nginx")) tech.push({ name: "nginx", category: "Web Server", confidence: "high" });
|
||||
if (server.includes("apache")) tech.push({ name: "Apache", category: "Web Server", confidence: "high" });
|
||||
if (server.includes("cloudflare")) tech.push({ name: "Cloudflare", category: "CDN", confidence: "high" });
|
||||
if (powered.includes("next")) tech.push({ name: "Next.js", category: "Framework", confidence: "medium" });
|
||||
if (powered.includes("express")) tech.push({ name: "Express", category: "Framework", confidence: "medium" });
|
||||
if (via.includes("varnish")) tech.push({ name: "Varnish", category: "Cache", confidence: "medium" });
|
||||
if (headers["x-vercel-id"]) tech.push({ name: "Vercel", category: "Hosting", confidence: "high" });
|
||||
if (headers["x-amz-cf-id"]) tech.push({ name: "AWS CloudFront", category: "CDN", confidence: "high" });
|
||||
|
||||
const cdnWaf: string[] = [];
|
||||
if (cf) cdnWaf.push("Cloudflare");
|
||||
if (headers["x-served-by"]?.includes("fastly")) cdnWaf.push("Fastly");
|
||||
if (headers["server"]?.includes("Akamai")) cdnWaf.push("Akamai");
|
||||
|
||||
return tech;
|
||||
}
|
||||
|
||||
function detectCdnWaf(headers: Record<string, string>): string[] {
|
||||
const detected: string[] = [];
|
||||
if (headers["cf-ray"]) detected.push("Cloudflare");
|
||||
if (headers["x-fastly-request-id"]) detected.push("Fastly");
|
||||
if (headers["x-akamai-transformed"]) detected.push("Akamai");
|
||||
if (headers["x-amz-cf-id"]) detected.push("AWS CloudFront");
|
||||
if (headers["x-sucuri-id"]) detected.push("Sucuri WAF");
|
||||
return detected;
|
||||
}
|
||||
|
||||
async function geoLookup(domain: string): Promise<GeoInfo | null> {
|
||||
try {
|
||||
const ips = await dns.resolve4(domain);
|
||||
const ip = ips[0];
|
||||
if (!ip) return null;
|
||||
|
||||
const token = process.env.IPINFO_TOKEN;
|
||||
const url = token
|
||||
? `https://ipinfo.io/${ip}?token=${token}`
|
||||
: `http://ip-api.com/json/${ip}?fields=status,country,regionName,city,isp,org,timezone,lat,lon,query`;
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
if (token) {
|
||||
const loc = String(data.loc ?? "").split(",");
|
||||
return {
|
||||
ip,
|
||||
country: data.country as string,
|
||||
region: data.region as string,
|
||||
city: data.city as string,
|
||||
org: data.org as string,
|
||||
timezone: data.timezone as string,
|
||||
lat: loc[0] ? Number(loc[0]) : undefined,
|
||||
lon: loc[1] ? Number(loc[1]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (data.status === "success") {
|
||||
return {
|
||||
ip: String(data.query ?? ip),
|
||||
country: data.country as string,
|
||||
region: data.regionName as string,
|
||||
city: data.city as string,
|
||||
isp: data.isp as string,
|
||||
org: data.org as string,
|
||||
timezone: data.timezone as string,
|
||||
lat: data.lat as number,
|
||||
lon: data.lon as number,
|
||||
};
|
||||
}
|
||||
return { ip };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ScanProgressCallback = (progress: number, stage: string) => void | Promise<void>;
|
||||
|
||||
export async function runDomainScan(
|
||||
domain: string,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ScanResult> {
|
||||
const report = async (p: number, stage: string) => {
|
||||
await onProgress?.(p, stage);
|
||||
};
|
||||
|
||||
await report(5, "dns");
|
||||
const dnsResult = await lookupDns(domain);
|
||||
|
||||
await report(25, "whois");
|
||||
let whoisData: Record<string, string> = {};
|
||||
try {
|
||||
const raw = await whoisLookup(domain);
|
||||
whoisData = parseWhois(String(raw));
|
||||
} catch (e) {
|
||||
whoisData = { error: e instanceof Error ? e.message : "WHOIS failed" };
|
||||
}
|
||||
|
||||
await report(45, "ssl");
|
||||
const ssl = await checkSsl(domain);
|
||||
|
||||
await report(65, "http");
|
||||
const httpRaw = await fetchHttp(domain);
|
||||
const headers = httpRaw.headers ?? {};
|
||||
const security = scoreSecurityHeaders(headers);
|
||||
const tech = detectTech(headers);
|
||||
const cdnWaf = { detected: detectCdnWaf(headers) };
|
||||
|
||||
await report(85, "geo");
|
||||
const geo = await geoLookup(domain);
|
||||
|
||||
await report(100, "done");
|
||||
|
||||
return {
|
||||
domain,
|
||||
scannedAt: new Date().toISOString(),
|
||||
dns: dnsResult,
|
||||
whois: whoisData,
|
||||
ssl,
|
||||
http: {
|
||||
status: httpRaw.status,
|
||||
finalUrl: httpRaw.finalUrl,
|
||||
redirectChain: httpRaw.redirectChain ?? [],
|
||||
headers,
|
||||
server: httpRaw.server,
|
||||
poweredBy: httpRaw.poweredBy,
|
||||
},
|
||||
geo,
|
||||
security,
|
||||
tech,
|
||||
uptime: {
|
||||
reachable: typeof httpRaw.status === "number" && httpRaw.status < 500,
|
||||
latencyMs: "latencyMs" in httpRaw ? httpRaw.latencyMs : undefined,
|
||||
},
|
||||
cdnWaf,
|
||||
};
|
||||
}
|
||||
73
src/lib/types.ts
Normal file
73
src/lib/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface DnsRecord {
|
||||
type: string;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface SslInfo {
|
||||
valid: boolean;
|
||||
issuer?: string;
|
||||
subject?: string;
|
||||
validFrom?: string;
|
||||
validTo?: string;
|
||||
daysRemaining?: number;
|
||||
protocol?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SecurityHeaders {
|
||||
score: number;
|
||||
grade: string;
|
||||
present: string[];
|
||||
missing: string[];
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GeoInfo {
|
||||
ip: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
isp?: string;
|
||||
org?: string;
|
||||
timezone?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface TechHint {
|
||||
name: string;
|
||||
category: string;
|
||||
confidence: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
domain: string;
|
||||
scannedAt: string;
|
||||
dns: {
|
||||
records: DnsRecord[];
|
||||
nameservers: string[];
|
||||
mx: string[];
|
||||
txt: string[];
|
||||
};
|
||||
whois: Record<string, string>;
|
||||
ssl: SslInfo;
|
||||
http: {
|
||||
status?: number;
|
||||
finalUrl?: string;
|
||||
redirectChain: string[];
|
||||
headers: Record<string, string>;
|
||||
server?: string;
|
||||
poweredBy?: string;
|
||||
};
|
||||
geo: GeoInfo | null;
|
||||
security: SecurityHeaders;
|
||||
tech: TechHint[];
|
||||
uptime: {
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
};
|
||||
cdnWaf: {
|
||||
detected: string[];
|
||||
};
|
||||
}
|
||||
18
src/lib/utils.ts
Normal file
18
src/lib/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function normalizeDomain(input: string): string {
|
||||
let d = input.trim().toLowerCase();
|
||||
d = d.replace(/^https?:\/\//, "");
|
||||
d = d.replace(/\/.*$/, "");
|
||||
d = d.replace(/^www\./, "");
|
||||
return d;
|
||||
}
|
||||
|
||||
export function isValidDomain(domain: string): boolean {
|
||||
return /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain);
|
||||
}
|
||||
20
src/types/next-auth.d.ts
vendored
Normal file
20
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import "next-auth";
|
||||
import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
role?: string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user