victor34593993 commited on
Commit
e360c25
·
verified ·
1 Parent(s): 9c48747

link guard www + cleanup

Browse files
Files changed (2) hide show
  1. app/links.py +27 -12
  2. 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
- exact: set[str] = set()
34
  for s in rows:
35
  for u in (s.urls or []):
36
- exact.add(u.rstrip("/"))
37
  if (s.location or "").startswith(("http://", "https://")):
38
- exact.add(s.location.rstrip("/"))
39
- hosts = {urlparse(u).netloc.lower() for u in exact if urlparse(u).netloc}
 
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 clean.rstrip("/") in exact:
47
- return clean
48
- parsed = urlparse(clean)
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"{parsed.scheme}://{host}"
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
- return re.sub(r" ([,.;:!?])", r"\1", out).strip()
 
 
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
- exact = {"https://flexigobe.com", "https://flexigobe.com/catalogo.pdf"}
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", exact, hosts
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", exact, hosts)
16
  assert "/about" not in out and "https://flexigobe.com" in out
 
 
 
17
  # foreign/unknown domain → removed
18
- out2 = sanitize_links("Visita https://evil.example.com/x ahora", exact, hosts)
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í", exact, hosts)
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 "https://shop.example/faq" in exact
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)