Files
meshcore-bot/modules/url_shortener.py
T
agessaman 4c76ee3dbe fix: address review findings on shlink shortener and template parser
Security: a shlink deployment with short_url_website unset POSTed the operator's
API key to v.gd. _normalize_base falls back to the public default and the shlink
branch guarded only on a missing key, never on a missing base. shlink now requires
an explicit base and refuses a v.gd/is.gd host outright rather than sending an
X-Api-Key there.

Correctness: _shorten_url_with_gd lost its response.ok guard when the backends were
split out, so a 502 whose body starts with http was returned as the short URL and
transmitted over RF. Restored, with a warning. The regression test that should have
caught this passed only because it left mock_resp.text as a MagicMock; it now uses
a realistic proxy maintenance body.

_shorten_url_with_shlink never checked the status either. Shlink reports failures as
RFC 7807 problem details, which parse as JSON and simply lack shortUrl, so a bad API
key was indistinguishable from an unshortenable URL at DEBUG. It now warns on a
non-OK status, a non-JSON body, and a missing shortUrl. Dropped the shortUrlSlug
fallback: that is a request field, not a response field, and returning it puts a
bare slug where a link belongs.

Timeouts and connection errors are back on their own DEBUG handler. They had fallen
through to the broad handler, whose level rose to ERROR in the same diff, so a
routine intermittent uplink logged "Unexpected error shortening URL" on every reply.

Parser: _GREEDY_ARG_FILTERS was tested before the quoted-argument branch, so
prefix_if_nonempty, the one filter already in shipped configs, could not use the
quoted syntax this PR adds. {path_distance|prefix_if_nonempty:"Dist {sender}: "}
rendered raw template text. A quote immediately after the ':' now selects the quoted
grammar; anything else stays greedy, so config.ini.example's
`prefix_if_nonempty: | Path Dist: ` keeps its pipe and its whitespace.

Blocking HTTP on the event loop: the path command rendered its reply prefix inline
from async code, so a slow shortener stalled radio RX, MQTT and every other handler
for the full 5s timeout. Added format_piped_template_async and switched the path
command to it. The test command still renders synchronously through the sync
check_keywords dispatcher; making that async is a separate change, so the filter now
warns once when it runs on the loop.

Naming: one operation should not have two names in an operator-facing DSL. shorten
and shorten_url are aliases in both response templates and feed formats, and
if_nonempty is canonical with if_notempty as an alias, so a filter chain copied
between a feed format and a response_format works either way.

Also: reduced _build_create_shlink_url to the one parameter it uses and dropped its
dead query/startswith lines, renamed the shlink POST callable from `get`, documented
the config argument on format_piped_template, stopped gating the render trace on an
unrelated parameter and logging field values (sender IDs, user phrases) with it, and
moved the changelog entry from Fixed to Added and Changed.
2026-09-07 15:45:49 -07:00

339 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Shared URL shortening for MeshCore Bot and web viewer.
Two backends are supported, selected by ``short_url_website_service``:
``gd`` (default) uses the v.gd / is.gd-compatible API
(GET .../create.php?format=simple&url=...), and ``shlink`` POSTs to a self-hosted
Shlink instance's /rest/v3/short-urls with an ``X-Api-Key`` header. Configure the
base URL, service, and optional API key under [External_Data] in config.ini.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from urllib.parse import quote
import requests
def _coerce_url_string(url: Any) -> str:
"""Normalize feed/API link values to a string (feedparser may use dicts with href)."""
if url is None:
return ""
if isinstance(url, str):
return url.strip()
if isinstance(url, (bytes, bytearray)):
try:
return url.decode("utf-8", errors="replace").strip()
except Exception:
return ""
if isinstance(url, dict):
href = url.get("href") or url.get("url")
if href is not None:
return str(href).strip()
return ""
return str(url).strip()
def _safe_config_get(config: Any, section: str, option: str, fallback: str = "") -> str:
"""Read config without raising (missing section, interpolation, etc.)."""
if config is None:
return fallback
try:
get = getattr(config, "get", None)
if not callable(get):
return fallback
return get(section, option, fallback=fallback)
except Exception:
return fallback
DEFAULT_SHORT_URL_BASE = "https://v.gd"
# Hostnames that use the public create.php API without an API key query param.
_VGD_COMPAT_HOSTS = frozenset(
{
"v.gd",
"www.v.gd",
"is.gd",
"www.is.gd",
}
)
def _normalize_base(base: str) -> str:
b = (base or "").strip().rstrip("/")
return b if b else DEFAULT_SHORT_URL_BASE
def _is_vgd_compat_host(host: str) -> bool:
"""True for the public v.gd / is.gd hosts, which take no API key."""
return (host or "").lower().split(":")[0] in _VGD_COMPAT_HOSTS
def _host_allows_key_in_query(host: str) -> bool:
"""True if we may append api_key for this host. v.gd/is.gd public API: False."""
return not _is_vgd_compat_host(host)
def _base_host(base: str) -> str:
"""Hostname of *base*, tolerating a scheme-less value like ``example.com/x``."""
from urllib.parse import urlparse
root = (base or "").strip()
if "://" not in root:
root = f"https://{root}"
parsed = urlparse(root)
return (parsed.hostname or "").lower()
def _parse_simple_response(body: str) -> str | None:
text = (body or "").strip()
if not text:
return None
if text.startswith("Error:"):
return None
if text.startswith("http"):
return text
return None
def _build_create_gd_url(long_url: str, base: str, api_key: str) -> str:
from urllib.parse import urlparse, urlunparse
encoded = quote(long_url, safe="")
root = _normalize_base(base)
if "://" not in root:
root = f"https://{root}"
parsed = urlparse(root)
netloc = parsed.netloc
if not netloc and parsed.path:
netloc = parsed.path.split("/")[0]
path = (parsed.path or "").rstrip("/") + "/create.php"
if not path.startswith("/"):
path = "/" + path
query = f"format=simple&url={encoded}"
if api_key and _host_allows_key_in_query(parsed.hostname or ""):
query = f"{query}&key={quote(api_key, safe='')}"
rebuilt = urlunparse((parsed.scheme or "https", netloc, path, "", query, ""))
return rebuilt
def _build_create_shlink_url(base: str) -> str:
"""Build the Shlink create endpoint from *base*.
Only the base is needed: the long URL travels in the POST body and the API key
in an ``X-Api-Key`` header, never in the URL.
"""
from urllib.parse import urlparse, urlunparse
root = _normalize_base(base)
if "://" not in root:
root = f"https://{root}"
parsed = urlparse(root)
netloc = parsed.netloc
if not netloc and parsed.path:
netloc = parsed.path.split("/")[0]
path = (parsed.path or "").rstrip("/") + "/rest/v3/short-urls"
return urlunparse((parsed.scheme or "https", netloc, path, "", "", ""))
def _shorten_url_with_shlink(
long_url: str,
base: str,
api_key: str,
session: requests.Session | None = None,
timeout: float = 5.0,
logger: logging.Logger | None = None,
) -> str:
"""Shorten a URL using Shlink API."""
import json
shortener_url = _build_create_shlink_url(base)
headers = {
"Content-Type": "application/json",
"X-Api-Key": api_key,
}
payload = json.dumps(
{"longUrl": long_url, "findIfExists": True, "tags": ["meshcore-bot"]}
)
post = session.post if session is not None else requests.post
response = post(shortener_url, headers=headers, data=payload, timeout=timeout)
if logger:
logger.debug("Shlink response: %s", response.text)
# Shlink reports failures as RFC 7807 problem details, which parse as JSON just
# fine and simply lack shortUrl. Without this check a bad API key looks
# identical to a URL that could not be shortened, at DEBUG only.
if not response.ok:
if logger:
logger.warning(
"Shlink shortener returned HTTP %s: %s",
getattr(response, "status_code", "?"),
(response.text or "")[:200],
)
return ""
try:
data = response.json()
except ValueError:
if logger:
logger.warning("Shlink shortener returned a non-JSON body; not shortening.")
return ""
short_url = (data or {}).get("shortUrl")
if short_url:
return str(short_url)
if logger:
logger.warning("Shlink response carried no shortUrl; not shortening.")
return ""
def _shorten_url_with_gd(
long_url: str,
base: str,
api_key: str,
session: requests.Session | None = None,
timeout: float = 5.0,
logger: logging.Logger | None = None,
) -> str:
"""Shorten a URL using v.gd / is.gd API."""
shortener_url = _build_create_gd_url(long_url, base, api_key)
get = session.get if session is not None else requests.get
response = get(shortener_url, timeout=timeout)
# A failing proxy or maintenance page can return a body that looks like a URL.
# Without this check that body is returned as the short link and transmitted.
if not response.ok:
if logger:
logger.warning(
"URL shortener returned HTTP %s", getattr(response, "status_code", "?")
)
return ""
short = _parse_simple_response(response.text)
if short:
return short
return ""
def shorten_url_sync(
url: Any,
*,
config: Any,
session: requests.Session | None = None,
logger: logging.Logger | None = None,
timeout: float = 5.0,
) -> str:
"""Shorten a URL using [External_Data] short_url_website (default v.gd).
Returns the shortened URL or empty string on failure.
"""
try:
url_str = _coerce_url_string(url)
if not url_str:
return ""
raw_base = _safe_config_get(config, "External_Data", "short_url_website", "")
service = (
_safe_config_get(config, "External_Data", "short_url_website_service", "gd")
.strip()
.lower()
)
api_key = (
_safe_config_get(config, "External_Data", "short_url_website_api_key", "")
or ""
).strip()
if service == "shlink":
# Deliberately not _normalize_base: its v.gd fallback would POST the
# operator's API key to an unrelated third party when the base is unset.
base = (raw_base or "").strip().rstrip("/")
if not base:
if logger:
logger.warning(
"short_url_website_service=shlink requires short_url_website "
"(there is no default Shlink instance); skipping."
)
return ""
if not api_key:
if logger:
logger.warning(
"short_url_website_service=shlink requires short_url_website_api_key; skipping."
)
return ""
if _is_vgd_compat_host(_base_host(base)):
if logger:
logger.warning(
"short_url_website_service=shlink points at the public %s API, "
"which is not Shlink; skipping rather than sending the API key there.",
_base_host(base),
)
return ""
return _shorten_url_with_shlink(
url_str,
base,
api_key,
session=session,
timeout=timeout,
logger=logger,
)
# v.gd / is.gd-compatible: api_key is optional (unused for the public hosts,
# only appended for self-hosted alternates via _host_allows_key_in_query).
return _shorten_url_with_gd(
url_str,
_normalize_base(raw_base),
api_key,
session=session,
timeout=timeout,
logger=logger,
)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
# Routine on a mesh node with an intermittent uplink. Logging these at ERROR
# as "unexpected" floods the log and buries the errors that do need triage.
if logger:
logger.debug("URL shortener unreachable: %s", e)
return ""
except Exception as e:
if logger:
logger.error("Unexpected error shortening URL: %s", e)
return ""
async def shorten_url(
url: str,
*,
config: Any,
session: requests.Session | None = None,
logger: logging.Logger | None = None,
timeout: float = 5.0,
) -> str:
"""Async wrapper: runs shorten_url_sync in the default executor."""
if not url:
return ""
loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor(
None,
lambda: shorten_url_sync(
url,
config=config,
session=session,
logger=logger,
timeout=timeout,
),
)
except Exception as e:
if logger:
logger.debug("Unexpected error shortening URL: %s", e)
return ""