Arag / app /tasks /link_health_task.py
AuthorBot
Fix analytics accuracy, tenant isolation, and event-loop blocking.
ff0847b
Raw
History Blame Contribute Delete
3.18 kB
"""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__)
@celery_app.task(name="app.tasks.link_health_task.check_all_links")
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