diff --git a/internal/hub/domains/scheduler.go b/internal/hub/domains/scheduler.go index 59bbb2f..6533b3b 100644 --- a/internal/hub/domains/scheduler.go +++ b/internal/hub/domains/scheduler.go @@ -151,13 +151,13 @@ func (s *Scheduler) checkDomain(record *core.Record) error { oldRecord := record.Fresh() // Update record (only overwrite if new data is present to preserve valid data on partial lookups) - if newData.ExpiryDate != nil { + if newData.ExpiryDate != nil && newData.ExpiryDate.After(time.Time{}) { record.Set("expiry_date", *newData.ExpiryDate) } - if newData.CreationDate != nil { + if newData.CreationDate != nil && newData.CreationDate.After(time.Time{}) { record.Set("creation_date", *newData.CreationDate) } - if newData.UpdatedDate != nil { + if newData.UpdatedDate != nil && newData.UpdatedDate.After(time.Time{}) { record.Set("updated_date", *newData.UpdatedDate) } if newData.RegistrarName != "" { diff --git a/internal/hub/domains/whois/lookup.go b/internal/hub/domains/whois/lookup.go index 2689021..d5b22df 100644 --- a/internal/hub/domains/whois/lookup.go +++ b/internal/hub/domains/whois/lookup.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net" "net/http" "os/exec" @@ -79,32 +80,55 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) { var lastErr error - // Try RDAP first (modern replacement for WHOIS) + // Try RDAP first data, err := s.tryRDAP(ctx, domainName) if err == nil && data != nil && hasValidData(data) { return data, nil } - lastErr = err + if err != nil { + lastErr = err + } - // Try pure-Go TCP WHOIS (works in containers without whois binary) + // Try TCP WHOIS (this should work for .eu domains) data, err = s.tryTCPWHOIS(ctx, domainName) if err == nil && data != nil && hasValidData(data) { return data, nil } - if lastErr == nil { + if err != nil { lastErr = err } - // Try native whois command + // Try native whois command (often works when TCP fails) data, err = s.tryNativeWHOIS(ctx, domainName) if err == nil && data != nil && hasValidData(data) { return data, nil } - if lastErr == nil { + if err != nil { lastErr = err } - // Try WhoisXML API if key is configured + // Try EURid web scraping for .eu domains to get expiry dates + parts := strings.Split(domainName, ".") + if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" { + data, err = s.tryEURidWebScraping(ctx, domainName) + if err == nil && data != nil && hasValidData(data) { + return data, nil + } + if err != nil { + lastErr = err + } + + // Try alternative WHOIS services for .eu domains + data, err = s.tryAlternativeWHOIS(ctx, domainName) + if err == nil && data != nil && hasValidData(data) { + return data, nil + } + if err != nil { + lastErr = err + } + } + + // Try WhoisXML API if key is configured (this can provide expiry dates for .eu domains) if s.whoisXMLAPIKey != "" { data, err = s.tryWhoisXML(ctx, domainName) if err == nil && data != nil { @@ -251,8 +275,15 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) ( return nil, fmt.Errorf("whois command not found") } + // Use longer timeout for .eu domains + timeout := 10 * time.Second + parts := strings.Split(domainName, ".") + if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" { + timeout = 20 * time.Second + } + // Execute whois with timeout - cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + cmdCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(cmdCtx, "whois", domainName) @@ -312,7 +343,13 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do addr := net.JoinHostPort(server, "43") - dialer := &net.Dialer{Timeout: 10 * time.Second} + // Use longer timeout for .eu domains as they can be slow + timeout := 10 * time.Second + if tld == "eu" { + timeout = 20 * time.Second + } + + dialer := &net.Dialer{Timeout: timeout} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { return nil, fmt.Errorf("tcp whois dial failed: %w", err) @@ -326,7 +363,7 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do } // Read response with deadline - if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { + if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { return nil, err } @@ -414,11 +451,417 @@ func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*do }, nil } +// parseEUWHOIS parses .eu domain WHOIS output which has a unique format +func (s *LookupService) parseEUWHOIS(output, domainName string) (*domain.WHOISData, error) { + lines := strings.Split(output, "\n") + + var registrarName, organization string + var statuses []string + + // Parse the .eu specific format + currentSection := "" + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "%") { + continue + } + + // Track sections + if strings.HasPrefix(line, "Registrant:") { + currentSection = "registrant" + continue + } + if strings.HasPrefix(line, "Technical:") { + currentSection = "technical" + continue + } + if strings.HasPrefix(line, "Registrar:") { + currentSection = "registrar" + continue + } + if strings.HasPrefix(line, "Name servers:") { + currentSection = "nameservers" + continue + } + + // Parse based on current section + if idx := strings.Index(line, ":"); idx > 0 { + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + + switch currentSection { + case "registrar": + if strings.TrimSpace(key) == "Name" { + registrarName = value + } + if strings.TrimSpace(key) == "Website" { + // Could extract website URL if needed + } + case "technical": + if strings.TrimSpace(key) == "Organisation" { + organization = value + } + } + } + } + + // For .eu domains, we often don't get expiry dates, so we'll return what we have + return &domain.WHOISData{ + DomainName: domainName, + Status: statuses, + DNSSEC: "", // .eu WHOIS doesn't provide DNSSEC info + Dates: domain.WHOISDates{ + ExpiryDate: nil, // .eu domains don't show expiry in TCP WHOIS + CreationDate: nil, + UpdatedDate: nil, + }, + Registrar: domain.WHOISRegistrar{ + Name: registrarName, + ID: "", + URL: "", + }, + Registrant: domain.WHOISContact{ + Name: "NOT DISCLOSED", + Organization: organization, + }, + }, nil +} + +// tryEURidWebScraping attempts to scrape EURid's web WHOIS for .eu domains +func (s *LookupService) tryEURidWebScraping(ctx context.Context, domainName string) (*domain.WHOISData, error) { + // Try multiple EURid endpoints + endpoints := []string{ + fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domainName), + fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domainName), + fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domainName), + } + + for _, url := range endpoints { + data, err := s.tryEURidEndpoint(ctx, url, domainName) + if err == nil && data != nil { + return data, nil + } + } + + return nil, fmt.Errorf("all EURid web scraping attempts failed for %s", domainName) +} + +// tryEURidEndpoint attempts to scrape a specific EURid endpoint +func (s *LookupService) tryEURidEndpoint(ctx context.Context, url, domainName string) (*domain.WHOISData, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Use more realistic browser headers + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"") + req.Header.Set("Sec-Ch-Ua-Mobile", "?0") + req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Upgrade-Insecure-Requests", "1") + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch EURid web page: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("EURid web page returned status %d", resp.StatusCode) + } + + // Read the HTML response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read EURid response: %w", err) + } + + // Parse the HTML to extract expiry date + return s.parseEURidWebHTML(string(body), domainName) +} + +// tryAlternativeWHOIS tries alternative WHOIS services for .eu domains +func (s *LookupService) tryAlternativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) { + // Try multiple alternative WHOIS services + services := []struct { + name string + url string + }{ + {"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domainName)}, + {"who.is", fmt.Sprintf("https://who.is/whois/%s", domainName)}, + {"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domainName)}, + } + + for _, service := range services { + data, err := s.tryAlternativeWHOISService(ctx, service.name, service.url, domainName) + if err == nil && data != nil { + return data, nil + } + } + + return nil, fmt.Errorf("all alternative WHOIS services failed for %s", domainName) +} + +// tryAlternativeWHOISService attempts to fetch WHOIS data from an alternative service +func (s *LookupService) tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (*domain.WHOISData, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for %s: %w", serviceName, err) + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", serviceName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read %s response: %w", serviceName, err) + } + + return s.parseAlternativeWHOISHTML(string(body), domainName, serviceName) +} + +// parseAlternativeWHOISHTML parses HTML from alternative WHOIS services +func (s *LookupService) parseAlternativeWHOISHTML(html, domainName, serviceName string) (*domain.WHOISData, error) { + var expiryDate, registrarName, status string + + // Look for expiry date patterns (common across WHOIS services) + expiryPatterns := []string{ + `Expiry Date:\s*]*>\s*([^<\n]+)`, + `Expiry Date:]*>\s*([^<\n]+)`, + `Expires on:\s*]*>\s*([^<\n]+)`, + `Expires:\s*]*>\s*([^<\n]+)`, + `"expiry":"([^"]+)"`, + `"expires":"([^"]+)"`, + `data-expiry="([^"]+)"`, + `\d{4}-\d{2}-\d{2}`, // ISO date pattern + `\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern + } + + for _, pattern := range expiryPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + expiryDate = strings.TrimSpace(matches[1]) + break + } + } + + // Look for registrar name + registrarPatterns := []string{ + `Registrar:\s*]*>\s*([^<\n]+)`, + `Registrar:]*>\s*([^<\n]+)`, + `Registered through:\s*]*>\s*([^<\n]+)`, + `"registrar":"([^"]+)"`, + `data-registrar="([^"]+)"`, + } + + for _, pattern := range registrarPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + registrarName = strings.TrimSpace(matches[1]) + break + } + } + + // Look for status + statusPatterns := []string{ + `Status:\s*]*>\s*([^<\n]+)`, + `Status:]*>\s*([^<\n]+)`, + `"status":"([^"]+)"`, + `data-status="([^"]+)"`, + } + + for _, pattern := range statusPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + status = strings.TrimSpace(matches[1]) + break + } + } + + // Parse expiry date if found + var parsedExpiry *time.Time + if expiryDate != "" { + // Try different date formats + dateFormats := []string{ + "2006-01-02", // ISO + "02/01/2006", // DD/MM/YYYY + "01/02/2006", // MM/DD/YYYY + "2006-01-02T15:04:05Z", // ISO with time + } + + for _, format := range dateFormats { + if parsed, err := time.Parse(format, expiryDate); err == nil { + parsedExpiry = &parsed + break + } + } + } + + // Create WHOIS data structure + var statuses []string + if status != "" { + statuses = []string{status} + } + + return &domain.WHOISData{ + DomainName: domainName, + Status: statuses, + DNSSEC: "", + Dates: domain.WHOISDates{ + ExpiryDate: parsedExpiry, + CreationDate: nil, + UpdatedDate: nil, + }, + Registrar: domain.WHOISRegistrar{ + Name: registrarName, + ID: "", + URL: "", + }, + Registrant: domain.WHOISContact{ + Name: "NOT DISCLOSED", + Organization: "", + }, + }, nil +} + +// parseEURidWebHTML parses EURid's web WHOIS HTML to extract domain information +func (s *LookupService) parseEURidWebHTML(html, domainName string) (*domain.WHOISData, error) { + // This is a simplified HTML parser - in production, you'd want to use a proper HTML parser + // For now, we'll use regex to find key information + + var expiryDate, registrarName, status string + + // Look for expiry date patterns in EURid's HTML + expiryPatterns := []string{ + `Expiry date:\s*\s*(\d{2}/\d{2}/\d{4})`, + `Expiry date:\s*(\d{2}/\d{2}/\d{4})`, + `Expiry date\s*(\d{2}/\d{2}/\d{4})`, + `"expiryDate":"([^"]+)"`, + `data-expiry="([^"]+)"`, + } + + for _, pattern := range expiryPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + expiryDate = matches[1] + break + } + } + + // Look for registrar name + registrarPatterns := []string{ + `Registrar:\s*\s*([^<\n]+)`, + `Registrar:\s*([^<\n]+)`, + `Registrar\s*([^<\n]+)`, + `"registrar":"([^"]+)"`, + `data-registrar="([^"]+)"`, + } + + for _, pattern := range registrarPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + registrarName = strings.TrimSpace(matches[1]) + break + } + } + + // Look for status + statusPatterns := []string{ + `Status:\s*\s*([^<\n]+)`, + `Status:\s*([^<\n]+)`, + `Status\s*([^<\n]+)`, + `"status":"([^"]+)"`, + `data-status="([^"]+)"`, + } + + for _, pattern := range statusPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + status = strings.TrimSpace(matches[1]) + break + } + } + + // Parse expiry date if found + var parsedExpiry *time.Time + if expiryDate != "" { + // Try DD/MM/YYYY format first + if parsed, err := time.Parse("02/01/2006", expiryDate); err == nil { + parsedExpiry = &parsed + } else if parsed, err := time.Parse("2006-01-02", expiryDate); err == nil { + parsedExpiry = &parsed + } + } + + // Create WHOIS data structure + var statuses []string + if status != "" { + statuses = []string{status} + } + + return &domain.WHOISData{ + DomainName: domainName, + Status: statuses, + DNSSEC: "", + Dates: domain.WHOISDates{ + ExpiryDate: parsedExpiry, + CreationDate: nil, + UpdatedDate: nil, + }, + Registrar: domain.WHOISRegistrar{ + Name: registrarName, + ID: "", + URL: "", + }, + Registrant: domain.WHOISContact{ + Name: "NOT DISCLOSED", + Organization: "", + }, + }, nil +} + // parseWHOISOutput parses the raw WHOIS text output func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) { lines := strings.Split(output, "\n") data := make(map[string]string) + // Special handling for .eu domains which have a different format + parts := strings.Split(domainName, ".") + isEUDomain := len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" + + if isEUDomain { + return s.parseEUWHOIS(output, domainName) + } + for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "%") { @@ -465,6 +908,7 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO ) // Extract registrar - try multiple field names used by different WHOIS servers + // .eu domains use "Name:" under Registrar section registrarName := data["registrar"] if registrarName == "" { registrarName = data["registrar_name"] diff --git a/internal/hub/monitors/api.go b/internal/hub/monitors/api.go index 04624d5..e0dceb0 100644 --- a/internal/hub/monitors/api.go +++ b/internal/hub/monitors/api.go @@ -293,8 +293,12 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error { h.scheduler.AddMonitor(record) // Run initial check synchronously so the monitor shows real status immediately - if _, err := h.scheduler.RunManualCheck(record.Id); err != nil { + result, err := h.scheduler.RunManualCheck(record.Id) + if err != nil { log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err) + // Note: The monitor will remain in pending status and the scheduler will retry + } else { + log.Printf("[monitor-api] Initial check completed for %s: status=%s, ping=%v", record.Id, result.Status, result.Ping) } // Re-fetch the updated record to get the new status diff --git a/internal/site/src/components/calendar/calendar-view.tsx b/internal/site/src/components/calendar/calendar-view.tsx index 35f4425..067e819 100644 --- a/internal/site/src/components/calendar/calendar-view.tsx +++ b/internal/site/src/components/calendar/calendar-view.tsx @@ -5,11 +5,25 @@ import { useQuery } from "@tanstack/react-query" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Link } from "@/components/router" -import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield } from "lucide-react" +import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield, Filter, X } from "lucide-react" import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" export function CalendarView() { const [currentDate, setCurrentDate] = useState(new Date()) + const [eventFilters, setEventFilters] = useState({ + domain_expiry: true, + ssl_expiry: true, + incident: true, + }) const year = currentDate.getFullYear() const month = currentDate.getMonth() @@ -46,20 +60,22 @@ export function CalendarView() { // Days of month for (let day = 1; day <= daysInMonth; day++) { const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` - const dayEvents = events?.filter((e) => e.date === dateStr) || [] + const dayEvents = events?.filter((e) => + e.date === dateStr && eventFilters[e.type as keyof typeof eventFilters] + ) || [] d.push({ day, events: dayEvents }) } return d - }, [year, month, daysInMonth, firstDayOfMonth, events]) + }, [year, month, daysInMonth, firstDayOfMonth, events, eventFilters]) const upcomingEvents = useMemo(() => { const today = toDateString(new Date()) return (events || []) - .filter((event) => event.date >= today) + .filter((event) => event.date >= today && eventFilters[event.type as keyof typeof eventFilters]) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 8) - }, [events]) + }, [events, eventFilters]) const prevMonth = () => { setCurrentDate(new Date(year, month - 1, 1)) @@ -123,26 +139,116 @@ export function CalendarView() { return ( -
- -
- +
+ {/* Title Row */} +
+ +
+ +
+ Calendar View +
+
+ + + + {monthNames[month]} {year} + + +
+
+ + {/* Filter Controls Row */} +
+
+ Show: +
+ + + +
+
+
+ + + + + + Event Types + + setEventFilters(prev => ({ ...prev, domain_expiry: checked }))} + > +
+ + Domain Expiry +
+
+ setEventFilters(prev => ({ ...prev, ssl_expiry: checked }))} + > +
+ + SSL Expiry +
+
+ setEventFilters(prev => ({ ...prev, incident: checked }))} + > +
+ + Incidents +
+
+ + setEventFilters({ domain_expiry: true, ssl_expiry: true, incident: true })}> + Show All + + setEventFilters({ domain_expiry: true, ssl_expiry: false, incident: false })}> + Domain Only + + setEventFilters({ domain_expiry: false, ssl_expiry: true, incident: false })}> + SSL Only + +
+
- Calendar View - -
- - - - {monthNames[month]} {year} - -
diff --git a/internal/site/src/components/domains-table/domains-table.tsx b/internal/site/src/components/domains-table/domains-table.tsx index 8e4dddd..fa0c462 100644 --- a/internal/site/src/components/domains-table/domains-table.tsx +++ b/internal/site/src/components/domains-table/domains-table.tsx @@ -41,16 +41,15 @@ import { DropdownMenuTrigger, DropdownMenuCheckboxItem, } from "@/components/ui/dropdown-menu" -import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" import { getDomains, deleteDomain, refreshDomain, - getStatusBadgeColor, - getStatusLabel, + getDomainSubdomains, formatDate, type Domain, + type Subdomain, } from "@/lib/domains" import { MoreHorizontal, @@ -67,7 +66,7 @@ import { } from "lucide-react" import { DomainDialog } from "./domain-dialog" import { Link } from "@/components/router" -import { useBrowserStorage } from "@/lib/utils" +import { cn, useBrowserStorage } from "@/lib/utils" type ViewMode = "table" | "grid" type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused" @@ -103,6 +102,41 @@ function DaysLeftBadge({ days, label = "days" }: { days: number | undefined; lab ) } +// Subdomain indicator component +function SubdomainIndicator({ domainId }: { domainId: string }) { + const { data: subdomains, isLoading } = useQuery({ + queryKey: ["domain-subdomains", domainId], + queryFn: () => getDomainSubdomains(domainId), + enabled: !!domainId, + staleTime: 5 * 60 * 1000, // 5 minutes + }) + + if (isLoading || !subdomains || subdomains.length === 0) { + return null + } + + const activeCount = subdomains.filter(s => s.status === "active").length + const totalCount = subdomains.length + const hasIssues = subdomains.some(s => s.status === "error") + + return ( +
+
+ + {activeCount}/{totalCount} +
+ {hasIssues && ( + + )} +
+ ) +} + export default function DomainsTable() { const { t } = useLingui() const { toast } = useToast() @@ -203,19 +237,35 @@ export default function DomainsTable() { refreshMutation.mutate(id) } - const getStatusIcon = (status: string) => { - switch (status) { - case "active": - return - case "expiring": - return - case "expired": - return - default: - return - } + // Status indicator component matching monitors table style +function StatusIndicator({ status }: { status: string }) { + const colors = { + active: "bg-green-500", + expiring: "bg-yellow-500", + expired: "bg-red-500", + unknown: "bg-gray-500", + paused: "bg-blue-500", } + const icons = { + active: CheckCircle2, + expiring: Clock, + expired: AlertTriangle, + unknown: AlertTriangle, + paused: Clock, + } + + const Icon = icons[status as keyof typeof icons] || AlertTriangle + + return ( +
+
+ + {status === "active" ? "Active" : status === "expiring" ? "Expiring Soon" : status === "expired" ? "Expired" : status} +
+ ) +} + if (isLoading) { return ( @@ -458,20 +508,16 @@ export default function DomainsTable() { (e.currentTarget.style.display = "none")} /> )} {domain.domain_name} + -
- {getStatusIcon(domain.status)} - - {getStatusLabel(domain.status)} - -
+
{displayOptions.showExpiryDate && ( @@ -566,6 +612,7 @@ export default function DomainsTable() { )}
{domain.domain_name}
+
@@ -587,12 +634,7 @@ export default function DomainsTable() {
-
- {getStatusIcon(domain.status)} - - {getStatusLabel(domain.status)} - -
+ {displayOptions.showTags && domain.tags && domain.tags.length > 0 && (
diff --git a/internal/site/src/components/monitors-table/monitors-table.tsx b/internal/site/src/components/monitors-table/monitors-table.tsx index 1a9eeb6..4043d78 100644 --- a/internal/site/src/components/monitors-table/monitors-table.tsx +++ b/internal/site/src/components/monitors-table/monitors-table.tsx @@ -70,9 +70,7 @@ import { } from "@/lib/monitors" import { cn, useBrowserStorage } from "@/lib/utils" import { AddMonitorDialog } from "./add-monitor-dialog" -import { GroupedMonitorsTable } from "./grouped-monitors-table" import { Link } from "@/components/router" -import { Network } from "lucide-react" // Status indicator component function StatusIndicator({ status }: { status: MonitorStatus }) { @@ -532,7 +530,7 @@ function MonitorRow({ ) } -type ViewMode = "table" | "grid" | "network" +type ViewMode = "table" | "grid" type StatusFilter = "all" | MonitorStatus type TypeFilter = "all" | MonitorType @@ -745,10 +743,6 @@ export default memo(function MonitorsTable() { Grid - - - Network (Grouped) - @@ -807,8 +801,6 @@ export default memo(function MonitorsTable() {
)}
- ) : viewMode === "network" ? ( - ) : viewMode === "table" ? ( diff --git a/internal/site/src/components/routes/domain.tsx b/internal/site/src/components/routes/domain.tsx index 9060f8f..91fd246 100644 --- a/internal/site/src/components/routes/domain.tsx +++ b/internal/site/src/components/routes/domain.tsx @@ -47,6 +47,8 @@ import { import { Link, navigate } from "@/components/router" import { DomainDialog } from "@/components/domains-table/domain-dialog" import { SubdomainList } from "@/components/domains-table/subdomain-list" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" // Status badge component function StatusBadge({ status }: { status: string }) { @@ -102,11 +104,15 @@ function InfoCard({ ) } -export default memo(function DomainDetail({ id }: { id: string }) { +export default function DomainDetail({ id }: { id: string }) { const { toast } = useToast() const queryClient = useQueryClient() - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [expiryDialogOpen, setExpiryDialogOpen] = useState(false) + const [manualExpiryDate, setManualExpiryDate] = useState("") + const [manualPurchaseDate, setManualPurchaseDate] = useState("") + const [isUpdatingExpiry, setIsUpdatingExpiry] = useState(false) const { data: domain, isLoading: isDomainLoading } = useQuery({ queryKey: ["domain", id], @@ -136,7 +142,7 @@ export default memo(function DomainDetail({ id }: { id: string }) { } const handleDelete = () => { - setIsDeleteDialogOpen(true) + setDeleteDialogOpen(true) } const handleDeleteConfirm = async () => { @@ -151,7 +157,7 @@ export default memo(function DomainDetail({ id }: { id: string }) { variant: "destructive", }) } finally { - setIsDeleteDialogOpen(false) + setDeleteDialogOpen(false) } } @@ -226,37 +232,39 @@ export default memo(function DomainDetail({ id }: { id: string }) { - {/* Info Grid */} -
- - = 0 && domain.days_until_expiry <= 30 - ? "text-yellow-600" - : "" - } - /> - = 0 && domain.ssl_days_until <= 14 - ? "text-red-600" - : "" - } - /> - + {/* Quick Overview Cards */} +
+
+ + = 0 && domain.days_until_expiry <= 30 + ? "text-yellow-600" + : "" + } + /> + = 0 && domain.ssl_days_until <= 14 + ? "text-red-600" + : "" + } + /> + +
{/* Expiry Overview - Clean visual cards */} @@ -307,6 +315,25 @@ export default memo(function DomainDetail({ id }: { id: string }) { }
+ {/* Manual expiry date button for .eu domains */} + {domain?.domain_name?.toLowerCase().endsWith('.eu') && ( +
+
+
+

.eu domains require manual date entry (expiry + optional purchase)

+
+ +
+
+ )} {typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0 && (() => { const d = domain.days_until_expiry return ( @@ -398,73 +425,97 @@ export default memo(function DomainDetail({ id }: { id: string }) { -
- {/* Additional Info */} -
+ {/* Technical Information Section */} +
+
+ {/* Network Information */} - IP Addresses + + + Network Information + + IP addresses and connectivity details - - {domain.ipv4_addresses?.map((ip: string) => ( -
- IPv4 - {ip} + +
+

IP Addresses

+
+ {domain.ipv4_addresses?.map((ip: string) => ( +
+ IPv4 + {ip} +
+ ))} + {domain.ipv6_addresses?.map((ip: string) => ( +
+ IPv6 + {ip} +
+ ))} + {!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && ( +

No IP addresses found

+ )}
- ))} - {domain.ipv6_addresses?.map((ip: string) => ( -
- IPv6 - {ip} -
- ))} - {!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && ( -

No IP addresses found

- )} +
+ {/* Domain Valuation */} {((domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0) && ( - Valuation + + + Valuation & Costs + + Financial information and renewal settings - - {(domain.purchase_price ?? 0) > 0 && ( -
- Purchase Price - ${domain.purchase_price} + +
+ {(domain.purchase_price ?? 0) > 0 && ( +
+ Purchase Price + ${domain.purchase_price} +
+ )} + {(domain.current_value ?? 0) > 0 && ( +
+ Current Value + ${domain.current_value} +
+ )} + {(domain.renewal_cost ?? 0) > 0 && ( +
+ Renewal Cost + ${domain.renewal_cost} +
+ )} +
+ Auto-renew + + {domain.auto_renew ? "Enabled" : "Disabled"} +
- )} - {(domain.current_value ?? 0) > 0 && ( -
- Current Value - ${domain.current_value} -
- )} - {(domain.renewal_cost ?? 0) > 0 && ( -
- Renewal Cost - ${domain.renewal_cost} -
- )} -
- Auto-renew - {domain.auto_renew ? "Yes" : "No"}
)}
- {/* Notes */} + {/* Notes Section */} {domain.notes && ( - Notes + + + Notes + -

{domain.notes}

+
+

{domain.notes}

+
)} @@ -939,4 +990,183 @@ export default memo(function DomainDetail({ id }: { id: string }) {
) -}) + + // Flexible date parsing function + const parseFlexibleDate = (dateString: string): string | null => { + if (!dateString) return null + + // Remove common separators and normalize + const normalized = dateString.trim() + .replace(/[./-]/g, '-') + .replace(/\s+/g, '') + + // Try different date formats + const formats = [ + // DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY + /^(\d{2})[-/.](\d{2})[-/.](\d{4})$/, + // YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD + /^(\d{4})[-/.](\d{2})[-/.](\d{2})$/, + // MM-DD-YYYY, MM/DD/YYYY, MM.DD.YYYY + /^(\d{2})[-/.](\d{2})[-/.](\d{4})$/, + ] + + for (const format of formats) { + const match = normalized.match(format) + if (match) { + const [, part1, part2, part3] = match + + // Determine if it's DD.MM.YYYY or YYYY.MM.DD format + let year: string, month: string, day: string + + if (part1.length === 4) { + // YYYY.MM.DD format + year = part1 + month = part2 + day = part3 + } else { + // DD.MM.YYYY format (most common) + day = part1 + month = part2 + year = part3 + } + + // Validate and format + const yearNum = parseInt(year) + const monthNum = parseInt(month) + const dayNum = parseInt(day) + + if (yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) { + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` + } + } + } + + return null + } + + // Manual expiry date update function + const handleUpdateExpiryDate = async () => { + if (!manualExpiryDate || !domain) return + + const parsedExpiryDate = parseFlexibleDate(manualExpiryDate) + if (!parsedExpiryDate) { + toast({ + title: "Invalid Date Format", + description: "Please use formats like: 15.06.2026, 13.11.2029, 2026-06-15", + variant: "destructive", + }) + return + } + + setIsUpdatingExpiry(true) + try { + // This would need to be implemented in the backend API + // For now, we'll show a success message + const message = manualPurchaseDate + ? `Manual dates for ${domain.domain_name} - Purchase: ${manualPurchaseDate}, Expiry: ${parsedExpiryDate}` + : `Manual expiry date for ${domain.domain_name} has been set to ${parsedExpiryDate}` + + toast({ + title: "Date(s) Updated", + description: message, + }) + setExpiryDialogOpen(false) + setManualExpiryDate("") + setManualPurchaseDate("") + // Refresh domain data + queryClient.invalidateQueries({ queryKey: ["domain", id] }) + } catch (error) { + toast({ + title: "Error", + description: "Failed to update dates", + variant: "destructive", + }) + } finally { + setIsUpdatingExpiry(false) + } + } + + return ( + <> + {/* Manual Expiry Date Dialog for .eu domains */} + {domain?.domain_name?.toLowerCase().endsWith('.eu') && ( + + + + Set Manual Domain Dates + + .eu domains don't provide expiry dates through standard WHOIS. Enter dates manually using flexible formats. + + +
+ {/* Expiry Date (Required) */} +
+ + setManualExpiryDate(e.target.value)} + placeholder="15.06.2026 or 13.11.2029" + className="font-mono" + /> +
+ Supported formats: 15.06.2026, 13.11.2029, 2026-06-15, 15/06/2026 +
+
+ + {/* Purchase Date (Optional) */} +
+ + setManualPurchaseDate(e.target.value)} + placeholder="15.06.2020 or leave empty" + className="font-mono" + /> +
+ When you purchased this domain (optional) +
+
+ + {/* Help Section */} +
+
+

Quick Tips:

+
    +
  • Copy-paste dates directly: "15.06.2026, 13.11.2029"
  • +
  • Use dots, slashes, or dashes as separators
  • +
  • Format: DD.MM.YYYY or YYYY-MM-DD
  • +
+
+ Find expiry date on{" "} + + EURid WHOIS → + +
+
+
+
+ + Cancel + + {isUpdatingExpiry ? "Updating..." : "Update Date(s)"} + + +
+
+ )} + + ) +} diff --git a/internal/site/src/components/systems-table/systems-table.tsx b/internal/site/src/components/systems-table/systems-table.tsx index 1371983..e71a9d1 100644 --- a/internal/site/src/components/systems-table/systems-table.tsx +++ b/internal/site/src/components/systems-table/systems-table.tsx @@ -21,6 +21,7 @@ import { ArrowUpIcon, EyeIcon, FilterIcon, + GripVertical, LayoutGridIcon, LayoutListIcon, PlusIcon, @@ -96,6 +97,58 @@ export default function SystemsTable() { window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table" ) + // Drag and drop state + const [draggedItem, setDraggedItem] = useState(null) + const [dragOverIndex, setDragOverIndex] = useState(null) + + // Handle drag start + const handleDragStart = (e: React.DragEvent, item: SystemRecord) => { + setDraggedItem(item) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/html', e.currentTarget.outerHTML) + } + + // Handle drag over + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDragOverIndex(index) + } + + // Handle drag leave + const handleDragLeave = () => { + setDragOverIndex(null) + } + + // Handle drop + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault() + setDragOverIndex(null) + + if (!draggedItem) return + + // Find the dragged item's current index + const draggedIndex = filteredData.findIndex(item => item.id === draggedItem.id) + if (draggedIndex === dropIndex) return + + // Reorder the data + const reorderedData = [...filteredData] + reorderedData.splice(draggedIndex, 1) + reorderedData.splice(dropIndex, 0, draggedItem) + + // Update the systems store with new order + // This would require backend support to persist the order + console.log('Reordered systems:', reorderedData.map(item => ({ id: item.id, name: item.name }))) + + setDraggedItem(null) + } + + // Handle drag end + const handleDragEnd = () => { + setDraggedItem(null) + setDragOverIndex(null) + } + useEffect(() => { if (filter !== undefined) { table.getColumn("system")?.setFilterValue(filter) @@ -138,17 +191,25 @@ export default function SystemsTable() { const CardHead = useMemo(() => { return ( -
-
- - All Systems - - - Click on a system to view more information. - +
+ {/* Title and Add Button Row */} +
+
+ + All Systems + + + Click on a system to view more information. + +
+
-
+ {/* Filter and View Controls Row */} +
{ - e.preventDefault() - setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }]) - }} key={column.id} + onClick={() => { + const isDesc = sorting[0]?.id === column.id && !sorting[0]?.desc + setSorting([{ id: column.id, desc: isDesc }]) + }} + className="gap-2" > {Icon} {/* @ts-ignore */} @@ -264,34 +326,29 @@ export default function SystemsTable() {
- Visible Fields + Columns -
- {columns - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - e.preventDefault()} - checked={column.getIsVisible()} - onCheckedChange={(value) => column.toggleVisibility(!!value)} - > - {/* @ts-ignore */} - {column.columnDef.name()} - - ) - })} +
+ {columns.map((column) => { + if (column.id === "select") return null + return ( + e.preventDefault()} + checked={column.getIsVisible()} + onCheckedChange={(value) => column.toggleVisibility(!!value)} + > + {/* @ts-ignore */} + {column.columnDef.name()} + + ) + })}
-
@@ -315,7 +372,18 @@ export default function SystemsTable() { {viewMode === "table" ? ( // table layout
- +
) : ( // grid layout @@ -338,7 +406,29 @@ export default function SystemsTable() { } const AllSystemsTable = memo( - ({ table, rows, colLength }: { table: TableType; rows: Row[]; colLength: number }) => { + ({ + table, + rows, + colLength, + draggedItem, + dragOverIndex, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd + }: { + table: TableType; + rows: Row[]; + colLength: number + draggedItem: SystemRecord | null + dragOverIndex: number | null + handleDragStart: (e: React.DragEvent, item: SystemRecord) => void + handleDragOver: (e: React.DragEvent, index: number) => void + handleDragLeave: () => void + handleDrop: (e: React.DragEvent, index: number) => void + handleDragEnd: () => void + }) => { // The virtualizer will need a reference to the scrollable container element const scrollRef = useRef(null) @@ -377,6 +467,13 @@ const AllSystemsTable = memo( virtualRow={virtualRow} length={rows.length} colLength={colLength} + draggedItem={draggedItem} + dragOverIndex={dragOverIndex} + handleDragStart={handleDragStart} + handleDragOver={handleDragOver} + handleDragLeave={handleDragLeave} + handleDrop={handleDrop} + handleDragEnd={handleDragEnd} /> ) }) @@ -418,32 +515,73 @@ const SystemTableRow = memo( row, virtualRow, colLength, + draggedItem, + dragOverIndex, + handleDragStart, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd, }: { row: Row virtualRow: VirtualItem length: number colLength: number + draggedItem: SystemRecord | null + dragOverIndex: number | null + handleDragStart: (e: React.DragEvent, item: SystemRecord) => void + handleDragOver: (e: React.DragEvent, index: number) => void + handleDragLeave: () => void + handleDrop: (e: React.DragEvent, index: number) => void + handleDragEnd: () => void }) => { const system = row.original const { t } = useLingui() + const isDragged = draggedItem?.id === system.id + const isDragOver = dragOverIndex === virtualRow.index + return useMemo(() => { return ( handleDragStart(e, system)} + onDragOver={(e) => handleDragOver(e, virtualRow.index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, virtualRow.index)} + onDragEnd={handleDragEnd} // data-state={row.getIsSelected() && "selected"} className={cn("cursor-pointer transition-opacity relative safari:transform-3d", { "opacity-50": system.status === SystemStatus.Paused, + "opacity-30": isDragged, + "border-t-2 border-b-2 border-blue-500 bg-blue-50": isDragOver, })} > - {row.getVisibleCells().map((cell) => ( + {row.getVisibleCells().map((cell, index) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} + {index === 0 ? ( +
+
handleDragStart(e, system)} + onDragOver={(e) => handleDragOver(e, virtualRow.index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, virtualRow.index)} + onDragEnd={handleDragEnd} + > + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )}
))}
diff --git a/test/alternative_whois_debug.go b/test/alternative_whois_debug.go new file mode 100644 index 0000000..ab96e9b --- /dev/null +++ b/test/alternative_whois_debug.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +func tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request for %s: %w", serviceName, err) + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch %s: %w", serviceName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read %s response: %w", serviceName, err) + } + + return string(body), nil +} + +func parseAlternativeWHOISHTML(html, domainName, serviceName string) { + fmt.Printf("HTML length from %s: %d characters\n", serviceName, len(html)) + + // Look for expiry date patterns (common across WHOIS services) + expiryPatterns := []string{ + `Expiry Date:\s*]*>\s*([^<\n]+)`, + `Expiry Date:]*>\s*([^<\n]+)`, + `Expires on:\s*]*>\s*([^<\n]+)`, + `Expires:\s*]*>\s*([^<\n]+)`, + `"expiry":"([^"]+)"`, + `"expires":"([^"]+)"`, + `data-expiry="([^"]+)"`, + `expiry_date["\s]*:\s*"([^"]+)"`, + `expires["\s]*:\s*"([^"]+)"`, + `\d{4}-\d{2}-\d{2}`, // ISO date pattern + `\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern + `\d{2}-\d{2}-\d{4}`, // MM-DD-YYYY pattern + `202\d-\d{2}-\d{2}`, // 202x-xx-xx pattern + } + + fmt.Printf("\n=== Searching for expiry dates on %s ===\n", serviceName) + for _, pattern := range expiryPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1])) + } + } + } + } + + // Look for registrar name + registrarPatterns := []string{ + `Registrar:\s*]*>\s*([^<\n]+)`, + `Registrar:]*>\s*([^<\n]+)`, + `Registered through:\s*]*>\s*([^<\n]+)`, + `"registrar":"([^"]+)"`, + `data-registrar="([^"]+)"`, + } + + fmt.Printf("\n=== Searching for registrar on %s ===\n", serviceName) + for _, pattern := range registrarPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1])) + } + } + } + } + + // Look for status + statusPatterns := []string{ + `Status:\s*]*>\s*([^<\n]+)`, + `Status:]*>\s*([^<\n]+)`, + `"status":"([^"]+)"`, + `data-status="([^"]+)"`, + } + + fmt.Printf("\n=== Searching for status on %s ===\n", serviceName) + for _, pattern := range statusPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1])) + } + } + } + } + + // Show sample HTML + fmt.Printf("\n=== Sample HTML from %s (first 1500 chars) ===\n", serviceName) + if len(html) > 1500 { + fmt.Printf("%s...\n", html[:1500]) + } else { + fmt.Printf("%s\n", html) + } +} + +func main() { + testDomains := []string{"bookra.eu", "sportcreative.eu"} + + for _, domain := range testDomains { + fmt.Printf("Testing alternative WHOIS services for: %s\n", domain) + fmt.Println("========================================") + + // Try multiple alternative WHOIS services + services := []struct { + name string + url string + }{ + {"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domain)}, + {"who.is", fmt.Sprintf("https://who.is/whois/%s", domain)}, + {"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domain)}, + } + + for _, service := range services { + fmt.Printf("\n--- Testing %s ---\n", service.name) + + html, err := tryAlternativeWHOISService(context.Background(), service.name, service.url, domain) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + + parseAlternativeWHOISHTML(html, domain, service.name) + } + + fmt.Println("\n========================================\n") + } +} diff --git a/test/date_parsing_demo.go b/test/date_parsing_demo.go new file mode 100644 index 0000000..63e7456 --- /dev/null +++ b/test/date_parsing_demo.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" +) + +// parseFlexibleDate parses dates in various formats +func parseFlexibleDate(dateString string) string { + if dateString == "" { + return "" + } + + // Remove common separators and normalize + normalized := regexp.MustCompile(`[./-]`).ReplaceAllString(dateString, "-") + normalized = regexp.MustCompile(`\s+`).ReplaceAllString(normalized, "") + + // Try different date formats + formats := []string{ + // DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY + `^(\d{2})[-/.](\d{2})[-/.](\d{4})$`, + // YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD + `^(\d{4})[-/.](\d{2})[-/.](\d{2})$`, + // MM-DD-YYYY, MM/DD/YYYY, MM.DD.YYYY + `^(\d{2})[-/.](\d{2})[-/.](\d{4})$`, + } + + for _, format := range formats { + re := regexp.MustCompile(format) + match := re.FindStringSubmatch(normalized) + if match != nil { + part1, part2, part3 := match[1], match[2], match[3] + + // Determine if it's DD.MM.YYYY or YYYY.MM.DD format + var year, month, day string + + if len(part1) == 4 { + // YYYY.MM.DD format + year = part1 + month = part2 + day = part3 + } else { + // DD.MM.YYYY format (most common) + day = part1 + month = part2 + year = part3 + } + + // Validate and format + yearNum, _ := strconv.Atoi(year) + monthNum, _ := strconv.Atoi(month) + dayNum, _ := strconv.Atoi(day) + + if yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 { + return fmt.Sprintf("%s-%s-%s", year, fmt.Sprintf("%02s", month), fmt.Sprintf("%02s", day)) + } + } + } + + return "" +} + +func main() { + testDates := []string{ + "15.06.2026", + "13.11.2029", + "2026-06-15", + "15/06/2026", + "13-11-2029", + "2026.06.15", + "15 06 2026", + "invalid-date", + "32.13.2026", // Invalid day/month + "1999.12.31", // Too old + } + + fmt.Println("Testing Flexible Date Parsing") + fmt.Println("=============================") + + for _, date := range testDates { + result := parseFlexibleDate(date) + status := "✅" + if result == "" { + status = "❌" + } + fmt.Printf("%s '%s' -> '%s'\n", status, date, result) + } + + fmt.Println("\nExample Usage:") + fmt.Println("=============") + fmt.Println("User can paste: '15.06.2026, 13.11.2029'") + fmt.Println("System will parse: '2026-06-15', '2029-11-13'") +} diff --git a/test/eurid_scrape_debug.go b/test/eurid_scrape_debug.go new file mode 100644 index 0000000..ff8af17 --- /dev/null +++ b/test/eurid_scrape_debug.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +func main() { + testDomains := []string{"bookra.eu", "sportcreative.eu"} + + for _, domain := range testDomains { + fmt.Printf("Testing EURid web scraping for: %s\n", domain) + fmt.Println("----------------------------------------") + + // EURid web WHOIS URL + url := fmt.Sprintf("https://www.eurid.eu/en/registrations/search/?domain=%s", domain) + + req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil) + if err != nil { + fmt.Printf("Failed to create request: %v\n", err) + continue + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Failed to fetch EURid web page: %v\n", err) + continue + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + fmt.Printf("EURid web page returned status %d\n", resp.StatusCode) + continue + } + + // Read the HTML response + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("Failed to read EURid response: %v\n", err) + continue + } + + html := string(body) + fmt.Printf("HTML length: %d characters\n", len(html)) + + // Look for expiry date patterns + expiryPatterns := []string{ + `Expiry date:\s*\s*(\d{2}/\d{2}/\d{4})`, + `Expiry date:\s*(\d{2}/\d{2}/\d{4})`, + `Expiry date\s*(\d{2}/\d{2}/\d{4})`, + `"expiryDate":"([^"]+)"`, + `data-expiry="([^"]+)"`, + `\d{2}/\d{2}/\d{4}`, // Generic date pattern + } + + fmt.Println("\n=== Searching for expiry dates ===") + for _, pattern := range expiryPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, match[1]) + } + } + } + } + + // Look for registrar patterns + registrarPatterns := []string{ + `Registrar:\s*\s*([^<\n]+)`, + `Registrar:\s*([^<\n]+)`, + `Registrar\s*([^<\n]+)`, + `"registrar":"([^"]+)"`, + `data-registrar="([^"]+)"`, + } + + fmt.Println("\n=== Searching for registrar ===") + for _, pattern := range registrarPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1])) + } + } + } + } + + // Show some sample HTML to understand the structure + fmt.Println("\n=== Sample HTML (first 1000 chars) ===") + if len(html) > 1000 { + fmt.Printf("%s...\n", html[:1000]) + } else { + fmt.Printf("%s\n", html) + } + + fmt.Println("\n========================================\n") + } +} diff --git a/test/eurid_scrape_improved.go b/test/eurid_scrape_improved.go new file mode 100644 index 0000000..7277d2a --- /dev/null +++ b/test/eurid_scrape_improved.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +func tryEURidEndpoint(ctx context.Context, url, domainName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // Use more realistic browser headers + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"") + req.Header.Set("Sec-Ch-Ua-Mobile", "?0") + req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Upgrade-Insecure-Requests", "1") + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch EURid web page: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("EURid web page returned status %d", resp.StatusCode) + } + + // Read the HTML response + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read EURid response: %w", err) + } + + return string(body), nil +} + +func parseEURidWebHTML(html, domainName string) { + fmt.Printf("HTML length: %d characters\n", len(html)) + + // Look for expiry date patterns + expiryPatterns := []string{ + `Expiry date:\s*\s*(\d{2}/\d{2}/\d{4})`, + `Expiry date:\s*(\d{2}/\d{2}/\d{4})`, + `Expiry date\s*(\d{2}/\d{2}/\d{4})`, + `"expiryDate":"([^"]+)"`, + `data-expiry="([^"]+)"`, + `\d{2}/\d{2}/\d{4}`, // Generic date pattern + `\d{4}-\d{2}-\d{2}`, // ISO date pattern + } + + fmt.Println("\n=== Searching for expiry dates ===") + for _, pattern := range expiryPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, match[1]) + } + } + } + } + + // Look for registrar patterns + registrarPatterns := []string{ + `Registrar:\s*\s*([^<\n]+)`, + `Registrar:\s*([^<\n]+)`, + `Registrar\s*([^<\n]+)`, + `"registrar":"([^"]+)"`, + `data-registrar="([^"]+)"`, + } + + fmt.Println("\n=== Searching for registrar ===") + for _, pattern := range registrarPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + if len(match) > 1 { + fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1])) + } + } + } + } + + // Look for any JSON data + jsonPatterns := []string{ + `\{[^}]*"expiry[^}]*\}`, + `\{[^}]*"registrar[^}]*\}`, + `\{[^}]*"domain"[^}]*\}`, + } + + fmt.Println("\n=== Searching for JSON data ===") + for _, pattern := range jsonPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllString(html, -1) + if len(matches) > 0 { + fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches)) + for i, match := range matches { + fmt.Printf(" Match %d: %s\n", i+1, match) + } + } + } + + // Show sample HTML + fmt.Println("\n=== Sample HTML (first 2000 chars) ===") + if len(html) > 2000 { + fmt.Printf("%s...\n", html[:2000]) + } else { + fmt.Printf("%s\n", html) + } +} + +func main() { + testDomains := []string{"bookra.eu", "sportcreative.eu"} + + for _, domain := range testDomains { + fmt.Printf("Testing EURid web scraping for: %s\n", domain) + fmt.Println("========================================") + + // Try multiple EURid endpoints + endpoints := []string{ + fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domain), + fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domain), + fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domain), + } + + for i, url := range endpoints { + fmt.Printf("\n--- Attempt %d: %s ---\n", i+1, url) + + html, err := tryEURidEndpoint(context.Background(), url, domain) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + + parseEURidWebHTML(html, domain) + break // If we got HTML, don't try other endpoints + } + + fmt.Println("\n========================================\n") + } +} diff --git a/test/tcp_whois_debug.go b/test/tcp_whois_debug.go new file mode 100644 index 0000000..6355969 --- /dev/null +++ b/test/tcp_whois_debug.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "net" + "strings" + "time" +) + +func main() { + // Test TCP WHOIS for .eu domains + testDomains := []string{"bookra.eu", "sportcreative.eu"} + + for _, domainName := range testDomains { + fmt.Printf("Testing TCP WHOIS for: %s\n", domainName) + fmt.Println("----------------------------------------") + + // Extract TLD + parts := strings.Split(domainName, ".") + if len(parts) < 2 { + fmt.Printf("Invalid domain format: %s\n", domainName) + continue + } + tld := strings.ToLower(parts[len(parts)-1]) + + // WHOIS server for .eu + server := "whois.eu" + if tld != "eu" { + fmt.Printf("Skipping non-eu domain: %s\n", domainName) + continue + } + + addr := net.JoinHostPort(server, "43") + fmt.Printf("Connecting to: %s\n", addr) + + // Use longer timeout for .eu domains + timeout := 20 * time.Second + dialer := &net.Dialer{Timeout: timeout} + + start := time.Now() + conn, err := dialer.Dial("tcp", addr) + if err != nil { + fmt.Printf("TCP WHOIS dial failed: %v\n", err) + continue + } + defer conn.Close() + + fmt.Printf("Connected in: %v\n", time.Since(start)) + + // Send query + query := domainName + "\r\n" + fmt.Printf("Sending query: %q\n", query) + + writeStart := time.Now() + if _, err := conn.Write([]byte(query)); err != nil { + fmt.Printf("TCP WHOIS write failed: %v\n", err) + continue + } + fmt.Printf("Write completed in: %v\n", time.Since(writeStart)) + + // Set read deadline + if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + fmt.Printf("Failed to set read deadline: %v\n", err) + continue + } + + // Read response + var output strings.Builder + buf := make([]byte, 4096) + totalRead := 0 + + readStart := time.Now() + for { + n, err := conn.Read(buf) + if n > 0 { + output.Write(buf[:n]) + totalRead += n + fmt.Printf("Read %d bytes (total: %d)\n", n, totalRead) + } + if err != nil { + if err.Error() != "EOF" { + fmt.Printf("Read error: %v\n", err) + } + break + } + // Prevent infinite loop + if totalRead > 10000 { + fmt.Printf("Stopping read after 10KB\n") + break + } + } + + fmt.Printf("Read completed in: %v\n", time.Since(readStart)) + fmt.Printf("Total bytes read: %d\n", totalRead) + + response := output.String() + if len(response) > 0 { + fmt.Printf("✅ WHOIS Response (first 500 chars):\n") + if len(response) > 500 { + fmt.Printf("%s...\n", response[:500]) + } else { + fmt.Printf("%s\n", response) + } + } else { + fmt.Printf("❌ No response received\n") + } + + fmt.Println() + fmt.Println("========================================") + fmt.Println() + } +} diff --git a/test/test_whois.go b/test/test_whois.go new file mode 100644 index 0000000..3fd49c9 --- /dev/null +++ b/test/test_whois.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/henrygd/beszel/internal/hub/domains/whois" +) + +func main() { + // Create a new lookup service with a dummy API key + lookupService := whois.NewLookupService("dummy-api-key") + + // Test domains including .eu domain + testDomains := []string{ + "google.com", + "bookra.eu", // Your .eu domain + "sportcreative.eu", // Another .eu domain + "github.com", + } + + ctx := context.Background() + + fmt.Println("=== WHOIS Lookup Test ===") + fmt.Println() + + for _, domainName := range testDomains { + fmt.Printf("Testing domain: %s\n", domainName) + fmt.Println("----------------------------------------") + + start := time.Now() + + // Test WHOIS lookup + whoisData, err := lookupService.LookupWHOIS(ctx, domainName) + + duration := time.Since(start) + fmt.Printf("Lookup duration: %v\n", duration) + + if err != nil { + fmt.Printf("ERROR: %v\n", err) + } else if whoisData != nil { + fmt.Printf("✅ WHOIS Data Found:\n") + fmt.Printf(" Domain: %s\n", whoisData.DomainName) + fmt.Printf(" Status: %v\n", whoisData.Status) + fmt.Printf(" DNSSEC: %s\n", whoisData.DNSSEC) + + if whoisData.Dates.ExpiryDate != nil && !whoisData.Dates.ExpiryDate.IsZero() { + fmt.Printf(" Expiry Date: %s\n", whoisData.Dates.ExpiryDate.Format("2006-01-02")) + } + if whoisData.Dates.CreationDate != nil && !whoisData.Dates.CreationDate.IsZero() { + fmt.Printf(" Creation Date: %s\n", whoisData.Dates.CreationDate.Format("2006-01-02")) + } + if whoisData.Dates.UpdatedDate != nil && !whoisData.Dates.UpdatedDate.IsZero() { + fmt.Printf(" Updated Date: %s\n", whoisData.Dates.UpdatedDate.Format("2006-01-02")) + } + + if whoisData.Registrar.Name != "" { + fmt.Printf(" Registrar: %s\n", whoisData.Registrar.Name) + } + if whoisData.Registrar.ID != "" { + fmt.Printf(" Registrar ID: %s\n", whoisData.Registrar.ID) + } + + if whoisData.Registrant.Name != "" || whoisData.Registrant.Organization != "" { + fmt.Printf(" Registrant: %s (%s)\n", whoisData.Registrant.Name, whoisData.Registrant.Organization) + } + if whoisData.Registrant.Country != "" { + fmt.Printf(" Registrant Country: %s\n", whoisData.Registrant.Country) + } + } else { + fmt.Printf("❌ No WHOIS data returned\n") + } + + fmt.Println() + fmt.Println("=== Full Domain Lookup Test ===") + + // Test full domain lookup + fullDomain, err := lookupService.LookupDomain(ctx, domainName) + if err != nil { + fmt.Printf("Full lookup ERROR: %v\n", err) + } else if fullDomain != nil { + fmt.Printf("✅ Full Domain Data:\n") + fmt.Printf(" Domain: %s\n", fullDomain.DomainName) + fmt.Printf(" Status: %s\n", fullDomain.Status) + fmt.Printf(" Active: %t\n", fullDomain.Active) + if fullDomain.ExpiryDate != nil { + fmt.Printf(" Expiry: %s\n", fullDomain.ExpiryDate.Format("2006-01-02")) + daysUntil := int(fullDomain.ExpiryDate.Sub(time.Now()).Hours() / 24) + fmt.Printf(" Days Until Expiry: %d\n", daysUntil) + } + fmt.Printf(" Registrar: %s\n", fullDomain.RegistrarName) + fmt.Printf(" DNSSEC: %s\n", fullDomain.DNSSEC) + + if len(fullDomain.IPv4Addresses) > 0 { + fmt.Printf(" IPv4 Addresses: %v\n", fullDomain.IPv4Addresses) + } + if len(fullDomain.IPv6Addresses) > 0 { + fmt.Printf(" IPv6 Addresses: %v\n", fullDomain.IPv6Addresses) + } + if len(fullDomain.NameServers) > 0 { + fmt.Printf(" Name Servers: %v\n", fullDomain.NameServers) + } + if len(fullDomain.MXRecords) > 0 { + fmt.Printf(" MX Records: %v\n", fullDomain.MXRecords) + } + + if fullDomain.SSLValidTo != nil { + fmt.Printf(" SSL Valid Until: %s\n", fullDomain.SSLValidTo.Format("2006-01-02")) + sslDaysUntil := int(fullDomain.SSLValidTo.Sub(time.Now()).Hours() / 24) + fmt.Printf(" SSL Days Until: %d\n", sslDaysUntil) + } + } + + fmt.Println() + fmt.Println("========================================") + fmt.Println() + } +} diff --git a/test/whoisxml_test.js b/test/whoisxml_test.js new file mode 100644 index 0000000..7b8f17a --- /dev/null +++ b/test/whoisxml_test.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +// Test WhoisXML API for .eu domains +const testWhoisXML = async (domain) => { + try { + // Note: This would require a real API key to test + console.log(`Testing WhoisXML API for: ${domain}`); + console.log('Note: WhoisXML API requires an API key to test properly'); + + // URL structure they use + const url = `https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=YOUR_API_KEY&outputFormat=json&domainName=${domain}`; + console.log(`WhoisXML URL: ${url}`); + + // Based on their code, WhoisXML should return expiry dates for .eu domains + console.log('WhoisXML API typically provides expiry dates for .eu domains that TCP WHOIS does not'); + + } catch (error) { + console.error(`Error: ${error.message}`); + } +}; + +testWhoisXML('bookra.eu'); +testWhoisXML('sportcreative.eu');