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>
385 lines
12 KiB
Python
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"
|