From 93a18820c21ceb7874aa7818a79a4dfad7a17b48 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Fri, 19 Dec 2025 09:35:11 +0100 Subject: [PATCH] Deploy: 2025-12-19 09:35 --- backend/app/api/drops.py | 101 ++++--------- backend/app/services/drop_status_checker.py | 153 ++++++++++++++++++++ frontend/src/components/hunt/DropsTab.tsx | 116 +++++---------- frontend/src/lib/api.ts | 14 +- 4 files changed, 230 insertions(+), 154 deletions(-) create mode 100644 backend/app/services/drop_status_checker.py diff --git a/backend/app/api/drops.py b/backend/app/api/drops.py index 9a7d193..bb6fb2a 100644 --- a/backend/app/api/drops.py +++ b/backend/app/api/drops.py @@ -193,12 +193,11 @@ async def api_check_drop_status( Returns: - available: Domain can be registered NOW - - pending_delete: Domain is in deletion phase (monitor it!) - - redemption: Domain is in redemption period (owner can recover) + - dropping_soon: Domain is in deletion phase (track it!) - taken: Domain was re-registered - unknown: Could not determine status """ - from app.services.domain_checker import domain_checker + from app.services.drop_status_checker import check_drop_status # Get the drop from DB result = await db.execute( @@ -212,36 +211,16 @@ async def api_check_drop_status( full_domain = f"{drop.domain}.{drop.tld}" try: - # Check with RDAP (not quick mode) - check_result = await domain_checker.check_domain(full_domain, quick=False) - - # Determine availability status - if check_result.is_available: - availability_status = "available" - rdap_status = check_result.raw_data.get("rdap_status", []) if check_result.raw_data else [] - - # Check if it's pending delete (available but not immediately) - if rdap_status and any("pending" in str(s).lower() for s in rdap_status): - availability_status = "pending_delete" - else: - # Domain exists - check specific status - rdap_status = check_result.raw_data.get("rdap_status", []) if check_result.raw_data else [] - rdap_str = str(rdap_status).lower() - - if "pending delete" in rdap_str or "pendingdelete" in rdap_str: - availability_status = "pending_delete" - elif "redemption" in rdap_str: - availability_status = "redemption" - else: - availability_status = "taken" + # Check with dedicated drop status checker + status_result = await check_drop_status(full_domain) # Update the drop in DB await db.execute( update(DroppedDomain) .where(DroppedDomain.id == drop_id) .values( - availability_status=availability_status, - rdap_status=str(rdap_status) if rdap_status else None, + availability_status=status_result.status, + rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None, last_status_check=datetime.utcnow() ) ) @@ -250,11 +229,11 @@ async def api_check_drop_status( return { "id": drop_id, "domain": full_domain, - "availability_status": availability_status, - "rdap_status": rdap_status, - "is_available": check_result.is_available, - "can_register_now": availability_status == "available", - "can_monitor": availability_status in ("pending_delete", "redemption"), + "status": status_result.status, + "rdap_status": status_result.rdap_status, + "can_register_now": status_result.can_register_now, + "should_track": status_result.should_monitor, + "message": status_result.message, } except Exception as e: @@ -262,17 +241,19 @@ async def api_check_drop_status( raise HTTPException(status_code=500, detail=str(e)) -@router.post("/monitor/{drop_id}") -async def api_monitor_drop( +@router.post("/track/{drop_id}") +async def api_track_drop( drop_id: int, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user) ): """ - Add a dropped domain to the Sniper monitoring list. + Add a dropped domain to the user's Watchlist. Will send notification when domain becomes available. + + This is the same as adding to watchlist, but optimized for drops. """ - from app.models.sniper_alert import SniperAlert + from app.models.domain import Domain, DomainStatus # Get the drop result = await db.execute( @@ -285,49 +266,29 @@ async def api_monitor_drop( full_domain = f"{drop.domain}.{drop.tld}" - # Check if already monitoring + # Check if already in watchlist existing = await db.execute( - select(SniperAlert).where( - SniperAlert.user_id == current_user.id, - SniperAlert.domain == full_domain, - SniperAlert.is_active == True + select(Domain).where( + Domain.user_id == current_user.id, + Domain.name == full_domain ) ) if existing.scalar_one_or_none(): - return {"status": "already_monitoring", "domain": full_domain} + return {"status": "already_tracking", "domain": full_domain} - # Check user limits - from app.api.sniper_alerts import get_user_alert_limit - limit = get_user_alert_limit(current_user) - - count_result = await db.execute( - select(SniperAlert).where( - SniperAlert.user_id == current_user.id, - SniperAlert.is_active == True - ) - ) - current_count = len(count_result.scalars().all()) - - if current_count >= limit: - raise HTTPException( - status_code=400, - detail=f"Monitor limit reached ({limit}). Upgrade to add more." - ) - - # Create sniper alert for this drop - alert = SniperAlert( + # Add to watchlist with notification enabled + domain = Domain( user_id=current_user.id, - domain=full_domain, - alert_type="drop", - is_active=True, - notify_email=True, - notify_push=True, + name=full_domain, + status=DomainStatus.AVAILABLE if drop.availability_status == 'available' else DomainStatus.UNKNOWN, + is_available=drop.availability_status == 'available', + notify_on_available=True, # Enable notification! ) - db.add(alert) + db.add(domain) await db.commit() return { - "status": "monitoring", + "status": "tracking", "domain": full_domain, - "message": f"You'll be notified when {full_domain} becomes available!" + "message": f"Added {full_domain} to your Watchlist. You'll be notified when available!" } diff --git a/backend/app/services/drop_status_checker.py b/backend/app/services/drop_status_checker.py new file mode 100644 index 0000000..9494d5c --- /dev/null +++ b/backend/app/services/drop_status_checker.py @@ -0,0 +1,153 @@ +""" +Drop Status Checker +==================== +Dedicated RDAP checker for dropped domains. +Correctly identifies pending_delete, redemption, and available status. +""" + +import httpx +import logging +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + +# RDAP endpoints for different TLDs +RDAP_ENDPOINTS = { + # ccTLDs + 'ch': 'https://rdap.nic.ch/domain/', + 'li': 'https://rdap.nic.ch/domain/', + 'de': 'https://rdap.denic.de/domain/', + # gTLDs via CentralNic + 'online': 'https://rdap.centralnic.com/online/domain/', + 'xyz': 'https://rdap.centralnic.com/xyz/domain/', + 'club': 'https://rdap.nic.club/domain/', + # gTLDs via Afilias/Donuts + 'info': 'https://rdap.afilias.net/rdap/info/domain/', + 'biz': 'https://rdap.afilias.net/rdap/biz/domain/', + 'org': 'https://rdap.publicinterestregistry.org/rdap/org/domain/', + # Google TLDs + 'dev': 'https://rdap.nic.google/domain/', + 'app': 'https://rdap.nic.google/domain/', +} + + +@dataclass +class DropStatus: + """Status of a dropped domain.""" + domain: str + status: str # 'available', 'dropping_soon', 'taken', 'unknown' + rdap_status: list[str] + can_register_now: bool + should_monitor: bool + message: str + + +async def check_drop_status(domain: str) -> DropStatus: + """ + Check the real status of a dropped domain via RDAP. + + Returns: + DropStatus with one of: + - 'available': Domain can be registered NOW + - 'dropping_soon': Domain is in pending delete/redemption (monitor it!) + - 'taken': Domain was re-registered + - 'unknown': Could not determine status + """ + tld = domain.split('.')[-1].lower() + + endpoint = RDAP_ENDPOINTS.get(tld) + if not endpoint: + # Try generic lookup + logger.warning(f"No RDAP endpoint for .{tld}, returning unknown") + return DropStatus( + domain=domain, + status='unknown', + rdap_status=[], + can_register_now=False, + should_monitor=False, + message=f"No RDAP endpoint for .{tld}" + ) + + url = f"{endpoint}{domain}" + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url) + + # 404 = Domain not found = AVAILABLE! + if resp.status_code == 404: + return DropStatus( + domain=domain, + status='available', + rdap_status=[], + can_register_now=True, + should_monitor=False, + message="Domain is available for registration!" + ) + + # 200 = Domain exists in registry + if resp.status_code == 200: + data = resp.json() + rdap_status = data.get('status', []) + status_lower = ' '.join(str(s).lower() for s in rdap_status) + + # Check for pending delete / redemption status + is_pending = any(x in status_lower for x in [ + 'pending delete', 'pendingdelete', + 'pending purge', 'pendingpurge', + 'redemption period', 'redemptionperiod', + 'pending restore', 'pendingrestore', + ]) + + if is_pending: + return DropStatus( + domain=domain, + status='dropping_soon', + rdap_status=rdap_status, + can_register_now=False, + should_monitor=True, + message="Domain is being deleted. Track it to get notified when available!" + ) + + # Domain is actively registered + return DropStatus( + domain=domain, + status='taken', + rdap_status=rdap_status, + can_register_now=False, + should_monitor=False, + message="Domain was re-registered" + ) + + # Other status code + logger.warning(f"RDAP returned {resp.status_code} for {domain}") + return DropStatus( + domain=domain, + status='unknown', + rdap_status=[], + can_register_now=False, + should_monitor=False, + message=f"RDAP returned HTTP {resp.status_code}" + ) + + except httpx.TimeoutException: + logger.warning(f"RDAP timeout for {domain}") + return DropStatus( + domain=domain, + status='unknown', + rdap_status=[], + can_register_now=False, + should_monitor=False, + message="RDAP timeout" + ) + except Exception as e: + logger.warning(f"RDAP error for {domain}: {e}") + return DropStatus( + domain=domain, + status='unknown', + rdap_status=[], + can_register_now=False, + should_monitor=False, + message=str(e) + ) diff --git a/frontend/src/components/hunt/DropsTab.tsx b/frontend/src/components/hunt/DropsTab.tsx index 93e0670..d7e3861 100644 --- a/frontend/src/components/hunt/DropsTab.tsx +++ b/frontend/src/components/hunt/DropsTab.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' -import { useStore } from '@/lib/store' import { Globe, Loader2, @@ -31,7 +30,7 @@ import clsx from 'clsx' // TYPES // ============================================================================ -type AvailabilityStatus = 'available' | 'pending_delete' | 'redemption' | 'taken' | 'unknown' +type AvailabilityStatus = 'available' | 'dropping_soon' | 'taken' | 'unknown' interface DroppedDomain { id: number @@ -74,7 +73,6 @@ interface DropsTabProps { export function DropsTab({ showToast }: DropsTabProps) { const openAnalyze = useAnalyzePanelStore((s) => s.open) - const addDomain = useStore((s) => s.addDomain) // Data State const [items, setItems] = useState([]) @@ -101,12 +99,9 @@ export function DropsTab({ showToast }: DropsTabProps) { const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('length') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') - // Tracking - const [tracking, setTracking] = useState(null) - // Status Checking const [checkingStatus, setCheckingStatus] = useState(null) - const [monitoringDrop, setMonitoringDrop] = useState(null) + const [trackingDrop, setTrackingDrop] = useState(null) // Load Stats const loadStats = useCallback(async () => { @@ -169,19 +164,6 @@ export function DropsTab({ showToast }: DropsTabProps) { await loadStats() }, [loadDrops, loadStats, page]) - const track = useCallback(async (domain: string) => { - if (tracking) return - setTracking(domain) - try { - await addDomain(domain) - showToast(`Added: ${domain}`, 'success') - } catch (e) { - showToast(e instanceof Error ? e.message : 'Failed', 'error') - } finally { - setTracking(null) - } - }, [addDomain, showToast, tracking]) - // Check real-time status of a drop const checkStatus = useCallback(async (dropId: number, domain: string) => { if (checkingStatus) return @@ -191,17 +173,11 @@ export function DropsTab({ showToast }: DropsTabProps) { // Update the item in our list setItems(prev => prev.map(item => item.id === dropId - ? { ...item, availability_status: result.availability_status, last_status_check: new Date().toISOString() } + ? { ...item, availability_status: result.status, last_status_check: new Date().toISOString() } : item )) - if (result.can_register_now) { - showToast(`✅ ${domain} is available NOW!`, 'success') - } else if (result.can_monitor) { - showToast(`⏳ ${domain} is pending deletion. Add to monitor!`, 'info') - } else { - showToast(`❌ ${domain} is taken`, 'error') - } + showToast(result.message, result.can_register_now ? 'success' : 'info') } catch (e) { showToast(e instanceof Error ? e.message : 'Status check failed', 'error') } finally { @@ -209,23 +185,23 @@ export function DropsTab({ showToast }: DropsTabProps) { } }, [checkingStatus, showToast]) - // Monitor a pending drop - const monitorDrop = useCallback(async (dropId: number, domain: string) => { - if (monitoringDrop) return - setMonitoringDrop(dropId) + // Track a drop (add to watchlist) + const trackDrop = useCallback(async (dropId: number, domain: string) => { + if (trackingDrop) return + setTrackingDrop(dropId) try { - const result = await api.monitorDrop(dropId) - if (result.status === 'already_monitoring') { - showToast(`Already monitoring ${domain}`, 'info') + const result = await api.trackDrop(dropId) + if (result.status === 'already_tracking') { + showToast(`${domain} is already in your Watchlist`, 'info') } else { - showToast(`🎯 Monitoring ${domain} - you'll be notified!`, 'success') + showToast(result.message, 'success') } } catch (e) { - showToast(e instanceof Error ? e.message : 'Failed to monitor', 'error') + showToast(e instanceof Error ? e.message : 'Failed to track', 'error') } finally { - setMonitoringDrop(null) + setTrackingDrop(null) } - }, [monitoringDrop, showToast]) + }, [trackingDrop, showToast]) // Sorted Items const sortedItems = useMemo(() => { @@ -480,7 +456,7 @@ export function DropsTab({ showToast }: DropsTabProps) { <>
{/* Table Header */} -
+
) : status === 'taken' ? ( - + + + Re-registered + ) : (
{/* Desktop Row */} -
+
{/* Domain */}
- {/* Status */} + {/* Status - clickable to refresh */}
- {/* Actions */} + {/* Actions - simplified */}
- ) : status === 'taken' ? ( - + Taken diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c6e4f17..1502581 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2050,7 +2050,7 @@ class AdminApiClient extends ApiClient { length: number is_numeric: boolean has_hyphen: boolean - availability_status: 'available' | 'pending_delete' | 'redemption' | 'taken' | 'unknown' + availability_status: 'available' | 'dropping_soon' | 'taken' | 'unknown' last_status_check: string | null }> }>(`/drops?${query}`) @@ -2071,20 +2071,20 @@ class AdminApiClient extends ApiClient { return this.request<{ id: number domain: string - availability_status: 'available' | 'pending_delete' | 'redemption' | 'taken' | 'unknown' + status: 'available' | 'dropping_soon' | 'taken' | 'unknown' rdap_status: string[] - is_available: boolean can_register_now: boolean - can_monitor: boolean + should_track: boolean + message: string }>(`/drops/check-status/${dropId}`, { method: 'POST' }) } - async monitorDrop(dropId: number) { + async trackDrop(dropId: number) { return this.request<{ status: string domain: string - message?: string - }>(`/drops/monitor/${dropId}`, { method: 'POST' }) + message: string + }>(`/drops/track/${dropId}`, { method: 'POST' }) } }