mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user