main
py 55 lines 1.79 KB
Raw
1 from __future__ import annotations
2
3 import re
4 from urllib.parse import urlsplit, urlunsplit
5
6 from helpers.errors import RepairableException
7
8
9 _SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
10 _URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
11 _LOCAL_HOST_RE = re.compile(
12 r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?$",
13 re.I,
14 )
15 _TYPED_HOST_RE = re.compile(
16 r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3}|"
17 r"(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d-]{2,63})(?::\d+)?$",
18 re.I,
19 )
20
21
22 def normalize_url(value: str) -> str:
23 raw = str(value or "").strip()
24 if not raw:
25 raise ValueError("Browser navigation requires a non-empty URL.")
26 if raw.startswith(("/", "?", "#", ".")):
27 raise RepairableException(
28 f"Browser navigation target {raw!r} is relative; provide a full URL with a scheme."
29 )
30
31 def with_trailing_path(url: str) -> str:
32 parts = urlsplit(url)
33 if parts.scheme in {"http", "https"} and not parts.path:
34 return urlunsplit((parts.scheme, parts.netloc, "/", parts.query, parts.fragment))
35 return urlunsplit(parts)
36
37 try:
38 host = re.split(r"[/?#]", raw, maxsplit=1)[0] or ""
39 if (
40 not _URL_SCHEME_RE.match(raw)
41 and not _SPECIAL_SCHEME_RE.match(raw)
42 and not raw.startswith(("/", "?", "#", "."))
43 and not re.search(r"\s", raw)
44 and _TYPED_HOST_RE.match(host)
45 ):
46 protocol = "http://" if _LOCAL_HOST_RE.match(host) else "https://"
47 return with_trailing_path(protocol + raw)
48
49 parts = urlsplit(raw)
50 if parts.scheme:
51 return with_trailing_path(raw)
52 except Exception:
53 pass
54
55 return with_trailing_path("https://" + raw)