Spaces:
Running
Running
| """Author RAG Chatbot SaaS β Link Health Check Task. | |
| Runs daily. Validates all stored purchase/preview URLs. | |
| Marks broken URLs in the database and optionally alerts authors. | |
| """ | |
| import structlog | |
| import httpx | |
| from app.tasks.celery_app import celery_app | |
| logger = structlog.get_logger(__name__) | |
| def check_all_links(): | |
| """Check all stored book links for HTTP 200 response.""" | |
| import asyncio | |
| asyncio.run(_run()) | |
| async def _run(): | |
| from datetime import datetime, timezone | |
| from sqlalchemy import select, update | |
| from app.dependencies import _get_session_factory | |
| from app.models.link import Link | |
| now = datetime.now(timezone.utc) | |
| async with _get_session_factory()() as db: | |
| result = await db.execute( | |
| select(Link).where(Link.purchase_url != None) | |
| ) | |
| links = result.scalars().all() | |
| async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: | |
| for link in links: | |
| purchase_ok = None | |
| preview_ok = None | |
| if link.purchase_url: | |
| purchase_ok = await _check_url(client, link.purchase_url) | |
| if link.preview_url: | |
| preview_ok = await _check_url(client, link.preview_url) | |
| await db.execute( | |
| update(Link).where(Link.id == link.id).values( | |
| purchase_url_ok=purchase_ok, | |
| preview_url_ok=preview_ok, | |
| last_health_check=now, | |
| ) | |
| ) | |
| await db.commit() | |
| logger.info("Link health check complete", checked=len(links)) | |
| async def _check_url(client: httpx.AsyncClient, url: str) -> bool: | |
| """Check if a URL returns an OK response. | |
| Includes SSRF protection β refuses to fetch internal/private IPs (R-130). | |
| Args: | |
| client: httpx async client. | |
| url: URL to check. | |
| Returns: | |
| True if URL is reachable (2xx/3xx), False otherwise. | |
| """ | |
| # R-130: SSRF protection β block internal/private IPs before fetching. | |
| # BUG-7 fix: _is_internal_url() calls socket.gethostbyname() which is a | |
| # blocking DNS call β offload to thread pool to avoid freezing the event loop. | |
| import asyncio | |
| is_internal = await asyncio.to_thread(_is_internal_url, url) | |
| if is_internal: | |
| logger.warning("Link health: blocked internal URL", url=url[:100]) | |
| return False | |
| try: | |
| response = await client.head(url) | |
| return response.status_code < 400 | |
| except Exception: | |
| return False | |
| def _is_internal_url(url: str) -> bool: | |
| """Return True if the URL resolves to an internal/private/loopback IP.""" | |
| import ipaddress | |
| import socket | |
| from urllib.parse import urlparse | |
| try: | |
| hostname = urlparse(url).hostname | |
| if not hostname: | |
| return True | |
| ip = ipaddress.ip_address(socket.gethostbyname(hostname)) | |
| return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved | |
| except Exception: | |
| return True # Fail safe β block if we can't resolve | |