| 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) |