Skip to content

Commit 4e281b6

Browse files
authored
Merge pull request #7 from andrecrjr/feat/scan-network
feat: Add scan API route, modify login form, and update project depen…
2 parents 90b3ead + bb4fac2 commit 4e281b6

File tree

2 files changed

+208
-5
lines changed

2 files changed

+208
-5
lines changed

app/api/scan/route.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { NextResponse } from "next/server"
2+
import os from "os"
3+
import net from "net"
4+
5+
function checkPort(host: string, port: number, timeoutMs = 500): Promise<boolean> {
6+
return new Promise((resolve) => {
7+
const socket = new net.Socket()
8+
9+
// If the socket connects successfully, port is open
10+
socket.on('connect', () => {
11+
socket.destroy()
12+
resolve(true)
13+
})
14+
15+
// If there is an error (e.g., connection refused), port is closed
16+
socket.on('error', () => {
17+
socket.destroy()
18+
resolve(false)
19+
})
20+
21+
// If it times out, assume port is closed
22+
socket.setTimeout(timeoutMs)
23+
socket.on('timeout', () => {
24+
socket.destroy()
25+
resolve(false)
26+
})
27+
28+
// Initiate connection
29+
socket.connect(port, host)
30+
})
31+
}
32+
33+
function getLocalSubnets(): string[] {
34+
const interfaces = os.networkInterfaces()
35+
const subnets: string[] = []
36+
37+
for (const name of Object.keys(interfaces)) {
38+
const ifaceArray = interfaces[name]
39+
if (!ifaceArray) continue
40+
41+
for (const iface of ifaceArray) {
42+
// Only care about IPv4 and non-internal (non-loopback) addresses
43+
if (iface.family === "IPv4" && !iface.internal) {
44+
subnets.push(iface.address)
45+
}
46+
}
47+
}
48+
49+
return subnets
50+
}
51+
52+
function getIpsInSubnet(ipAddress: string): string[] {
53+
// Very simple implementation assuming a standard /24 subnet mask for local networks
54+
// e.g., 192.168.1.5 -> checks 192.168.1.1 to 192.168.1.254
55+
const parts = ipAddress.split('.')
56+
if (parts.length !== 4) return []
57+
58+
const baseIp = `${parts[0]}.${parts[1]}.${parts[2]}`
59+
const ips: string[] = []
60+
61+
for (let i = 1; i < 255; i++) {
62+
ips.push(`${baseIp}.${i}`)
63+
}
64+
65+
// Also include localhost for dev convenience
66+
ips.push("127.0.0.1")
67+
68+
return ips
69+
}
70+
71+
export async function GET(req: Request) {
72+
const { searchParams } = new URL(req.url)
73+
const serverUrl = searchParams.get("serverUrl")
74+
75+
if (serverUrl?.startsWith("demo://")) {
76+
return NextResponse.json({ error: "Network scanning is disabled in demo mode" }, { status: 403 })
77+
}
78+
79+
try {
80+
const subnets = getLocalSubnets()
81+
const allIpsToScan = new Set<string>()
82+
allIpsToScan.add("127.0.0.1")
83+
84+
for (const subnetIp of subnets) {
85+
const ips = getIpsInSubnet(subnetIp)
86+
for (const ip of ips) {
87+
allIpsToScan.add(ip)
88+
}
89+
}
90+
91+
const TARGET_PORT = 3923
92+
const foundIps: string[] = []
93+
94+
// We can scan IPs in parallel to speed this up significantly
95+
// However, Node.js net limit could be an issue if we spawn 255*N connections at once
96+
// So we chunk them
97+
const ipArray = Array.from(allIpsToScan)
98+
const chunkSize = 50
99+
100+
for (let i = 0; i < ipArray.length; i += chunkSize) {
101+
const chunk = ipArray.slice(i, i + chunkSize)
102+
const checks = chunk.map(async (ip) => {
103+
const isOpen = await checkPort(ip, TARGET_PORT, 200) // fast 200ms timeout
104+
if (isOpen) {
105+
foundIps.push(ip)
106+
}
107+
})
108+
109+
await Promise.all(checks)
110+
}
111+
112+
// De-duplicate if needed
113+
const uniqueFoundIps = Array.from(new Set(foundIps))
114+
115+
return NextResponse.json({ targets: uniqueFoundIps })
116+
} catch (error) {
117+
console.error("Scan error:", error)
118+
return NextResponse.json({ error: "Failed to scan networks" }, { status: 500 })
119+
}
120+
}

components/login-form.tsx

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"
77
import { Input } from "@/components/ui/input"
88
import { Label } from "@/components/ui/label"
99
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
10-
import { FolderIcon } from "lucide-react"
10+
import { FolderIcon, SearchIcon, ServerIcon, Loader2Icon, InfoIcon } from "lucide-react"
1111

1212
interface LoginFormProps {
1313
onLogin: (serverUrl: string) => void
@@ -18,6 +18,36 @@ export function LoginForm({ onLogin }: LoginFormProps) {
1818
const [password, setPassword] = useState("")
1919
const [isLoading, setIsLoading] = useState(false)
2020
const [error, setError] = useState("")
21+
const [isScanning, setIsScanning] = useState(false)
22+
const [scannedServers, setScannedServers] = useState<string[]>([])
23+
const [hasScanned, setHasScanned] = useState(false)
24+
25+
const isDemo = serverUrl.startsWith("demo://")
26+
27+
const handleScan = async () => {
28+
if (isDemo) return
29+
setIsScanning(true)
30+
setError("")
31+
try {
32+
const res = await fetch(`/api/scan?serverUrl=${encodeURIComponent(serverUrl)}`)
33+
if (res.ok) {
34+
const data = await res.json()
35+
setScannedServers(data.targets || [])
36+
setHasScanned(true)
37+
} else {
38+
const data = await res.json().catch(() => ({}))
39+
setError(data.error || "Failed to scan local network.")
40+
}
41+
} catch (err) {
42+
setError("Error scanning local network.")
43+
} finally {
44+
setIsScanning(false)
45+
}
46+
}
47+
48+
const handleSelectServer = (ip: string) => {
49+
setServerUrl(`http://${ip}:3923`)
50+
}
2151

2252
const handleSubmit = async (e: React.FormEvent) => {
2353
e.preventDefault()
@@ -65,18 +95,71 @@ export function LoginForm({ onLogin }: LoginFormProps) {
6595
<CardContent>
6696
<form onSubmit={handleSubmit} className="space-y-4">
6797
<div className="space-y-2">
68-
<Label htmlFor="serverUrl" className="text-sm font-medium">
69-
Server URL
70-
</Label>
98+
<div className="flex items-center justify-between">
99+
<Label htmlFor="serverUrl" className="text-sm font-medium">
100+
Server URL
101+
</Label>
102+
<Button
103+
type="button"
104+
variant="ghost"
105+
size="sm"
106+
onClick={handleScan}
107+
disabled={isScanning || isDemo}
108+
className="h-8 px-2 text-xs"
109+
>
110+
{isScanning ? (
111+
<Loader2Icon className="h-3 w-3 mr-1 animate-spin" />
112+
) : (
113+
<SearchIcon className="h-3 w-3 mr-1" />
114+
)}
115+
{isScanning ? "Scanning..." : "Scan Network"}
116+
</Button>
117+
</div>
71118
<Input
72119
id="serverUrl"
73120
type="text"
74-
placeholder="https://127.0.0.1:3923"
121+
placeholder="http://127.0.0.1:3923"
75122
value={serverUrl}
76123
onChange={(e) => setServerUrl(e.target.value)}
77124
required
78125
className="bg-background"
79126
/>
127+
128+
{isDemo && (
129+
<div className="mt-2 text-xs text-muted-foreground flex items-center gap-1.5 px-3 py-2 bg-yellow-500/10 text-yellow-500 rounded-md border border-yellow-500/20">
130+
<InfoIcon className="h-3.5 w-3.5" />
131+
<span>Network scanning is disabled for demo targets.</span>
132+
</div>
133+
)}
134+
135+
{hasScanned && !isDemo && (
136+
<div className="mt-2 text-sm border rounded-md overflow-hidden bg-muted/30">
137+
<div className="bg-muted px-3 py-1.5 text-xs font-semibold flex items-center justify-between">
138+
<span>Local Servers Found</span>
139+
<span className="text-muted-foreground">{scannedServers.length}</span>
140+
</div>
141+
{scannedServers.length > 0 ? (
142+
<ul className="divide-y max-h-32 overflow-y-auto">
143+
{scannedServers.map((ip) => (
144+
<li key={ip}>
145+
<button
146+
type="button"
147+
onClick={() => handleSelectServer(ip)}
148+
className="w-full text-left px-3 py-2 hover:bg-accent hover:text-accent-foreground flex items-center gap-2 text-xs transition-colors"
149+
>
150+
<ServerIcon className="h-3 w-3 text-primary" />
151+
{ip}:3923
152+
</button>
153+
</li>
154+
))}
155+
</ul>
156+
) : (
157+
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
158+
No CopyParty servers found on port 3923.
159+
</div>
160+
)}
161+
</div>
162+
)}
80163
</div>
81164
<div className="space-y-2"></div>
82165
<div className="space-y-2">

0 commit comments

Comments
 (0)