Files
ImpTune/imptune/services/websearch.py
T
kawaandClaude Opus 5 2c06806814 feat: driver rename, driver icons, web image + driver search
Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both
global/shared like the `Driver` row they hang off, so a rename or an icon is
what every Owner sees. The rename/icon dialog keeps its forms as siblings
(nested forms are invalid HTML) and the icon routes return an `hx-swap-oob`
thumbnail refresh rather than re-rendering the table, which would tear the open
`<dialog>` out of the DOM.

Web image picker: `GET /web/images` renders a pickable grid for a printer or a
driver icon, with the search term prefilled from the entity name and editable.
Picking one downloads it server-side and normalizes it.

Driver download search: `GET /web/drivers` searches for a vendor-wide driver
(the term is rewritten into the vendor's real product name for 15 brands) or for
the exact model as typed. Links only — nothing is downloaded, and the fragment
says the results are unvetted.

Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()`
letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256
PNG is returned byte-identical, because icon storage is content-addressed and
re-encoding would move the file on every save.

`fetch_image()` makes the request from the server, so `assert_fetchable()`
refuses any URL resolving to a private, loopback, or link-local address, and
re-runs on every redirect. ImpTune sits on the same LAN as the printers it
configures; an unguarded fetcher would be a port scanner for anyone who can
reach the UI.

DuckDuckGo is scraped, not called through an API — no key needed, but fragile,
so both search functions swallow parse failures and return [] instead of 500ing
a page. `WEB_SEARCH=false` disables every outbound request and hides the
controls, for air-gapped installs.

Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router
instances, so a template global is declared once; `_add_missing_columns()` in
`database.py` adds new nullable columns to a pre-existing table, which
`create_tables(safe=True)` skips; `db_env` in test_db.py now closes its
connection on teardown, or the next test's ORM writes land in the previous
test's DB file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:44:47 +02:00

385 lines
12 KiB
Python

"""Web lookups: image search, driver-page search, and guarded image download.
The only place in ImpTune that talks to the internet. Everything here is
best-effort: a failed lookup returns an empty list or raises `WebSearchError`,
and the rest of the app keeps working offline.
DuckDuckGo is scraped rather than called through an API — no key, no signup.
That makes it fragile by nature: `search_images` / `search_pages` swallow parse
failures and return `[]` instead of a 500. Kill the whole feature with
`WEB_SEARCH=false`.
Fetching a URL the user picked means the *server* makes the request, so
`fetch_image` refuses anything that resolves to a private address. ImpTune runs
on the same LAN as the printers it configures — an unguarded fetcher would be a
port scanner for whoever can reach the UI.
"""
from __future__ import annotations
import html
import ipaddress
import json
import re
import socket
import urllib.error
import urllib.request
from dataclasses import dataclass
from urllib.parse import parse_qs, quote_plus, urlparse
import imptune.config as cfg
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0 Safari/537.36"
)
TIMEOUT_SECONDS = 8
MAX_IMAGE_BYTES = 8 * 1024 * 1024
MAX_HTML_BYTES = 2 * 1024 * 1024
MAX_REDIRECTS = 3
DDG_HOME = "https://duckduckgo.com/"
DDG_IMAGES = "https://duckduckgo.com/i.js"
DDG_HTML = "https://html.duckduckgo.com/html/"
# Brands with a real vendor-wide driver. The search term is what a technician
# would type; the live results are whatever the engine returns for it.
GENERIC_DRIVER_TERMS: dict[str, str] = {
"hp": "HP Universal Print Driver PCL6 download",
"konica": "Konica Minolta Universal PCL Print Driver download",
"konica minolta": "Konica Minolta Universal PCL Print Driver download",
"xerox": "Xerox Global Print Driver download",
"ricoh": "Ricoh PCL6 Universal Print Driver download",
"lexmark": "Lexmark Universal Print Driver download",
"brother": "Brother universal printer driver download",
"canon": "Canon Generic PCL6 Printer Driver download",
"epson": "Epson Universal Print Driver download",
"kyocera": "Kyocera Classic Universal Print Driver download",
"sharp": "Sharp Universal Print Driver download",
"toshiba": "Toshiba Universal Printer 2 driver download",
"oki": "OKI PCL universal print driver download",
"samsung": "Samsung Universal Print Driver download",
"dell": "Dell universal printer driver download",
}
# Display spellings for the search box's datalist, in the order a French shop
# is likeliest to need them.
BRAND_SUGGESTIONS = (
"HP",
"Konica Minolta",
"Xerox",
"Ricoh",
"Canon",
"Kyocera",
"Brother",
"Lexmark",
"Epson",
"Sharp",
"Toshiba",
"OKI",
"Samsung",
"Dell",
)
class WebSearchError(RuntimeError):
"""A lookup or download could not be completed."""
@dataclass(frozen=True)
class ImageHit:
"""One image search result."""
url: str
thumbnail: str
title: str
width: int
height: int
source: str
@dataclass(frozen=True)
class PageHit:
"""One web page search result."""
url: str
title: str
snippet: str
host: str
def enabled() -> bool:
"""False when the deployment opted out of outbound requests."""
return bool(getattr(cfg, "WEB_SEARCH", True))
def _require_enabled() -> None:
if not enabled():
raise WebSearchError("Web lookups are disabled (WEB_SEARCH=false).")
# --------------------------------------------------------------------------
# SSRF guard
# --------------------------------------------------------------------------
def _is_public_ip(raw: str) -> bool:
try:
ip = ipaddress.ip_address(raw)
except ValueError:
return False
return not (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def assert_fetchable(url: str) -> None:
"""Raise `WebSearchError` unless `url` is an https(s) URL on a public host.
Resolution happens here *and* the socket connects by hostname afterwards, so
a DNS-rebinding host could still slip through between the two. Accepted:
the payload is decoded by Pillow and discarded unless it is an image.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise WebSearchError("Only http and https URLs can be fetched.")
if not parsed.hostname:
raise WebSearchError("URL has no host.")
try:
infos = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror as exc:
raise WebSearchError(f"Cannot resolve {parsed.hostname}.") from exc
for info in infos:
if not _is_public_ip(info[4][0]):
raise WebSearchError(
f"{parsed.hostname} resolves to a private address — refused."
)
class _GuardedRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Re-run the SSRF guard on every redirect target."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
assert_fetchable(newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
_opener = urllib.request.build_opener(_GuardedRedirectHandler)
def _get(
url: str,
*,
referer: str | None = None,
max_bytes: int,
extra_headers: dict[str, str] | None = None,
) -> bytes:
"""GET `url` with the SSRF guard applied and the response body capped."""
assert_fetchable(url)
headers = {"User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.7"}
if referer:
headers["Referer"] = referer
if extra_headers:
headers.update(extra_headers)
request = urllib.request.Request(url, headers=headers)
try:
with _opener.open(request, timeout=TIMEOUT_SECONDS) as response:
return response.read(max_bytes + 1)[: max_bytes + 1]
except urllib.error.URLError as exc:
raise WebSearchError(f"Request failed: {exc}") from exc
except (TimeoutError, socket.timeout) as exc: # noqa: UP041 — socket.timeout on 3.9 paths
raise WebSearchError("Request timed out.") from exc
# --------------------------------------------------------------------------
# Image search
# --------------------------------------------------------------------------
_VQD_RE = re.compile(rb"vqd=[\"']?([\w-]{8,})[\"']?")
# The image endpoint is an XHR route: without the fetch metadata a real browser
# would send, it answers 403 even with a valid vqd token. Verified header set —
# dropping any of these brings the 403 back.
_XHR_HEADERS = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
def _image_vqd(query: str) -> str:
"""Scrape the per-query token DuckDuckGo's image endpoint demands."""
body = _get(
f"{DDG_HOME}?q={quote_plus(query)}&iax=images&ia=images",
max_bytes=MAX_HTML_BYTES,
)
match = _VQD_RE.search(body)
if not match:
raise WebSearchError("Could not read the search token.")
return match.group(1).decode("ascii", "ignore")
def search_images(query: str, limit: int = 12) -> list[ImageHit]:
"""Image results for `query`, newest-first as the engine ranked them.
Returns `[]` on any parse or transport failure — a dead scrape must not
take a page down with it.
"""
_require_enabled()
query = query.strip()
if not query:
return []
try:
vqd = _image_vqd(query)
raw = _get(
f"{DDG_IMAGES}?l=us-en&o=json&q={quote_plus(query)}&vqd={vqd}&f=,,,&p=1",
referer=DDG_HOME,
max_bytes=MAX_HTML_BYTES,
extra_headers=_XHR_HEADERS,
)
payload = json.loads(raw.decode("utf-8", "replace"))
except (WebSearchError, json.JSONDecodeError):
return []
hits: list[ImageHit] = []
for item in payload.get("results", []):
url = item.get("image") or ""
if not url.startswith(("http://", "https://")):
continue
hits.append(
ImageHit(
url=url,
thumbnail=item.get("thumbnail") or url,
title=(item.get("title") or "").strip(),
width=int(item.get("width") or 0),
height=int(item.get("height") or 0),
source=urlparse(url).hostname or "",
)
)
if len(hits) >= limit:
break
return hits
def fetch_image(url: str) -> bytes:
"""Download `url` and return its bytes (caller validates it is an image)."""
_require_enabled()
data = _get(url, max_bytes=MAX_IMAGE_BYTES)
if len(data) > MAX_IMAGE_BYTES:
raise WebSearchError("Image is larger than 8 MB.")
if not data:
raise WebSearchError("Server returned an empty response.")
return data
# --------------------------------------------------------------------------
# Page search (driver downloads)
# --------------------------------------------------------------------------
_RESULT_RE = re.compile(
r'<a[^>]+class="result__a"[^>]+href="(?P<href>[^"]+)"[^>]*>(?P<title>.*?)</a>',
re.DOTALL,
)
_SNIPPET_RE = re.compile(
r'<a[^>]+class="result__snippet"[^>]*>(?P<snippet>.*?)</a>', re.DOTALL
)
_TAG_RE = re.compile(r"<[^>]+>")
def _text(fragment: str) -> str:
return html.unescape(_TAG_RE.sub("", fragment)).strip()
def _snippet_text(fragment: str) -> str:
"""Snippet text, minus the literal "undefined " DuckDuckGo's own template
prefixes onto some results."""
text = _text(fragment)
return text[len("undefined ") :].lstrip() if text.startswith("undefined ") else text
def _unwrap(href: str) -> str:
"""Unwrap DuckDuckGo's `/l/?uddg=` click-tracking redirect."""
if href.startswith("//"):
href = "https:" + href
parsed = urlparse(href)
if "duckduckgo.com" in (parsed.hostname or "") and parsed.path.startswith("/l/"):
target = parse_qs(parsed.query).get("uddg", [""])[0]
if target:
return target
return href
def search_pages(query: str, limit: int = 8) -> list[PageHit]:
"""Web page results for `query`. `[]` on any failure, same as image search."""
_require_enabled()
query = query.strip()
if not query:
return []
try:
body = _get(
f"{DDG_HTML}?q={quote_plus(query)}", max_bytes=MAX_HTML_BYTES
).decode("utf-8", "replace")
except WebSearchError:
return []
snippets = [_snippet_text(m.group("snippet")) for m in _SNIPPET_RE.finditer(body)]
hits: list[PageHit] = []
for index, match in enumerate(_RESULT_RE.finditer(body)):
url = _unwrap(html.unescape(match.group("href")))
if not url.startswith(("http://", "https://")):
continue
hits.append(
PageHit(
url=url,
title=_text(match.group("title")) or url,
snippet=snippets[index] if index < len(snippets) else "",
host=urlparse(url).hostname or "",
)
)
if len(hits) >= limit:
break
return hits
# --------------------------------------------------------------------------
# Query builders
# --------------------------------------------------------------------------
def detect_brand(text: str) -> str | None:
"""Longest brand in `GENERIC_DRIVER_TERMS` that appears in `text`."""
lowered = (text or "").lower()
matches = [brand for brand in GENERIC_DRIVER_TERMS if brand in lowered]
return max(matches, key=len) if matches else None
def generic_driver_query(text: str) -> str:
"""Search term for a vendor-wide driver, derived from a model name.
A recognised brand gets that vendor's actual product name ("HP Universal
Print Driver PCL6"); anything else falls back to the raw model plus
"universal print driver download", which is what a technician would type.
"""
text = (text or "").strip()
brand = detect_brand(text)
if brand:
return GENERIC_DRIVER_TERMS[brand]
return f"{text} universal print driver download".strip()
def image_query(text: str) -> str:
"""Default search term for a printer or driver icon."""
text = (text or "").strip()
if not text:
return ""
return f"{text} printer"