link guard www + cleanup
Browse files- app/links.py +27 -12
- tests/test_links.py +13 -9
app/links.py
CHANGED
|
@@ -20,36 +20,47 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 20 |
from app.models import KnowledgeSource
|
| 21 |
|
| 22 |
_MD_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
|
| 23 |
-
_BARE_URL = re.compile(r"https?://[^\s<>\"')]+")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
async def allowed_links(db: AsyncSession, tenant_id: int | None) -> tuple[set[str], set[str]]:
|
| 27 |
-
"""Return (exact_urls, hosts) the bot is allowed to cite for this tenant.
|
|
|
|
| 28 |
rows = (
|
| 29 |
await db.execute(
|
| 30 |
select(KnowledgeSource).where(KnowledgeSource.tenant_id == tenant_id)
|
| 31 |
)
|
| 32 |
).scalars().all()
|
| 33 |
-
|
| 34 |
for s in rows:
|
| 35 |
for u in (s.urls or []):
|
| 36 |
-
|
| 37 |
if (s.location or "").startswith(("http://", "https://")):
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
return exact, hosts
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def _fix_url(url: str, exact: set[str], hosts: set[str]) -> str | None:
|
| 44 |
"""Return the URL to use, or None if it should be dropped entirely."""
|
| 45 |
clean = url.rstrip(".,);:*]’'\"")
|
| 46 |
-
if
|
| 47 |
-
return clean
|
| 48 |
-
|
| 49 |
-
host = parsed.netloc.lower()
|
| 50 |
if host in hosts:
|
| 51 |
# known store domain but an unverified path → send to the real homepage
|
| 52 |
-
return f"
|
| 53 |
return None # unknown/foreign domain → drop
|
| 54 |
|
| 55 |
|
|
@@ -69,5 +80,9 @@ def sanitize_links(text: str, exact: set[str], hosts: set[str]) -> str:
|
|
| 69 |
return fixed if fixed else ""
|
| 70 |
|
| 71 |
out = _BARE_URL.sub(_bare, out)
|
|
|
|
|
|
|
|
|
|
| 72 |
out = re.sub(r"[ \t]{2,}", " ", out)
|
| 73 |
-
|
|
|
|
|
|
| 20 |
from app.models import KnowledgeSource
|
| 21 |
|
| 22 |
_MD_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
|
| 23 |
+
_BARE_URL = re.compile(r"https?://[^\s<>\"')*\]]+")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _norm_host(host: str) -> str:
|
| 27 |
+
return host.lower().removeprefix("www.")
|
| 28 |
|
| 29 |
|
| 30 |
async def allowed_links(db: AsyncSession, tenant_id: int | None) -> tuple[set[str], set[str]]:
|
| 31 |
+
"""Return (exact_urls, hosts) the bot is allowed to cite for this tenant.
|
| 32 |
+
URLs and hosts are normalized (no trailing slash, www-insensitive)."""
|
| 33 |
rows = (
|
| 34 |
await db.execute(
|
| 35 |
select(KnowledgeSource).where(KnowledgeSource.tenant_id == tenant_id)
|
| 36 |
)
|
| 37 |
).scalars().all()
|
| 38 |
+
raw: set[str] = set()
|
| 39 |
for s in rows:
|
| 40 |
for u in (s.urls or []):
|
| 41 |
+
raw.add(u)
|
| 42 |
if (s.location or "").startswith(("http://", "https://")):
|
| 43 |
+
raw.add(s.location)
|
| 44 |
+
exact = {_canon(u) for u in raw}
|
| 45 |
+
hosts = {_norm_host(urlparse(u).netloc) for u in raw if urlparse(u).netloc}
|
| 46 |
return exact, hosts
|
| 47 |
|
| 48 |
|
| 49 |
+
def _canon(url: str) -> str:
|
| 50 |
+
"""Scheme-less, www-less, slash-less key for comparing URLs."""
|
| 51 |
+
p = urlparse(url.rstrip(".,);:*]’'\"/"))
|
| 52 |
+
return f"{_norm_host(p.netloc)}{p.path}".rstrip("/").lower()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def _fix_url(url: str, exact: set[str], hosts: set[str]) -> str | None:
|
| 56 |
"""Return the URL to use, or None if it should be dropped entirely."""
|
| 57 |
clean = url.rstrip(".,);:*]’'\"")
|
| 58 |
+
if _canon(clean) in exact:
|
| 59 |
+
return clean # a verified real URL — keep exactly as written
|
| 60 |
+
host = _norm_host(urlparse(clean).netloc)
|
|
|
|
| 61 |
if host in hosts:
|
| 62 |
# known store domain but an unverified path → send to the real homepage
|
| 63 |
+
return f"https://{host}"
|
| 64 |
return None # unknown/foreign domain → drop
|
| 65 |
|
| 66 |
|
|
|
|
| 80 |
return fixed if fixed else ""
|
| 81 |
|
| 82 |
out = _BARE_URL.sub(_bare, out)
|
| 83 |
+
# tidy up artefacts left when a link was removed (empty bold/brackets, gaps)
|
| 84 |
+
out = re.sub(r"\*\*\s*\*\*", "", out)
|
| 85 |
+
out = re.sub(r"\[\s*\]", "", out)
|
| 86 |
out = re.sub(r"[ \t]{2,}", " ", out)
|
| 87 |
+
out = re.sub(r" ([,.;:!?])", r"\1", out)
|
| 88 |
+
return out.strip()
|
tests/test_links.py
CHANGED
|
@@ -3,22 +3,26 @@ from __future__ import annotations
|
|
| 3 |
from app.links import allowed_links, sanitize_links
|
| 4 |
from app.models import KnowledgeSource
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
def test_sanitize_keeps_real_drops_fake_and_fixes_path():
|
| 8 |
-
|
| 9 |
-
hosts = {"flexigobe.com"}
|
| 10 |
-
# real allowlisted link kept
|
| 11 |
assert "https://flexigobe.com/catalogo.pdf" in sanitize_links(
|
| 12 |
-
"Catálogo: https://flexigobe.com/catalogo.pdf",
|
| 13 |
)
|
| 14 |
# invented path on the known store domain → downgraded to the real homepage
|
| 15 |
-
out = sanitize_links("Más info: https://flexigobe.com/about",
|
| 16 |
assert "/about" not in out and "https://flexigobe.com" in out
|
|
|
|
|
|
|
|
|
|
| 17 |
# foreign/unknown domain → removed
|
| 18 |
-
|
| 19 |
-
assert "evil.example.com" not in out2
|
| 20 |
# markdown link with invented path → label kept, link fixed
|
| 21 |
-
out3 = sanitize_links("Lee [Sobre nosotros](https://flexigobe.com/about) aquí",
|
| 22 |
assert "Sobre nosotros" in out3 and "/about" not in out3
|
| 23 |
|
| 24 |
|
|
@@ -35,7 +39,7 @@ async def test_allowed_links_built_from_source_urls(db_session):
|
|
| 35 |
)
|
| 36 |
await db_session.flush()
|
| 37 |
exact, hosts = await allowed_links(db_session, 7)
|
| 38 |
-
assert "
|
| 39 |
assert "shop.example" in hosts
|
| 40 |
# another tenant's links must not leak in
|
| 41 |
exact2, _ = await allowed_links(db_session, 999)
|
|
|
|
| 3 |
from app.links import allowed_links, sanitize_links
|
| 4 |
from app.models import KnowledgeSource
|
| 5 |
|
| 6 |
+
# exact is the CANONICAL form produced by allowed_links (www/scheme/slash-less)
|
| 7 |
+
EXACT = {"flexigobe.com", "flexigobe.com/catalogo.pdf"}
|
| 8 |
+
HOSTS = {"flexigobe.com"}
|
| 9 |
+
|
| 10 |
|
| 11 |
def test_sanitize_keeps_real_drops_fake_and_fixes_path():
|
| 12 |
+
# real allowlisted link kept exactly
|
|
|
|
|
|
|
| 13 |
assert "https://flexigobe.com/catalogo.pdf" in sanitize_links(
|
| 14 |
+
"Catálogo: https://flexigobe.com/catalogo.pdf", EXACT, HOSTS
|
| 15 |
)
|
| 16 |
# invented path on the known store domain → downgraded to the real homepage
|
| 17 |
+
out = sanitize_links("Más info: https://flexigobe.com/about", EXACT, HOSTS)
|
| 18 |
assert "/about" not in out and "https://flexigobe.com" in out
|
| 19 |
+
# www variant of a known host is recognized (not treated as foreign)
|
| 20 |
+
out_www = sanitize_links("Web: https://www.flexigobe.com/inventado", EXACT, HOSTS)
|
| 21 |
+
assert "flexigobe.com" in out_www and "/inventado" not in out_www
|
| 22 |
# foreign/unknown domain → removed
|
| 23 |
+
assert "evil.example.com" not in sanitize_links("Visita https://evil.example.com/x", EXACT, HOSTS)
|
|
|
|
| 24 |
# markdown link with invented path → label kept, link fixed
|
| 25 |
+
out3 = sanitize_links("Lee [Sobre nosotros](https://flexigobe.com/about) aquí", EXACT, HOSTS)
|
| 26 |
assert "Sobre nosotros" in out3 and "/about" not in out3
|
| 27 |
|
| 28 |
|
|
|
|
| 39 |
)
|
| 40 |
await db_session.flush()
|
| 41 |
exact, hosts = await allowed_links(db_session, 7)
|
| 42 |
+
assert "shop.example/faq" in exact # canonical form
|
| 43 |
assert "shop.example" in hosts
|
| 44 |
# another tenant's links must not leak in
|
| 45 |
exact2, _ = await allowed_links(db_session, 999)
|