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>
114 lines
4.1 KiB
Python
114 lines
4.1 KiB
Python
"""Web lookup routes — image picker and driver-download search.
|
|
|
|
Both return HTML fragments for HTMX, never JSON: the results are only ever
|
|
rendered into a page, and keeping the shaping server-side means the templates
|
|
own the "unverified source" warnings.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from imptune.db.models import Driver, Printer
|
|
from imptune.services import websearch
|
|
from imptune.templating import templates
|
|
|
|
router = APIRouter(prefix="/web")
|
|
|
|
MAX_RESULTS = 12
|
|
_TARGETS = ("printer", "driver")
|
|
|
|
|
|
def _notice(key: str, fallback: str, status_code: int = 200) -> HTMLResponse:
|
|
"""A one-line status fragment that still speaks French.
|
|
|
|
Server-rendered fragments are normally English-only, but these are the
|
|
everyday path (empty search box, disabled feature), so they go through the
|
|
i18n store like the rest of the picker — Alpine initializes the swapped-in
|
|
node, and the English body is the fallback if it does not.
|
|
"""
|
|
return HTMLResponse(
|
|
content=f"<p class='dim' x-data x-text=\"$store.i18n.t('{key}')\">{fallback}</p>",
|
|
status_code=status_code,
|
|
)
|
|
|
|
|
|
@router.get("/images", response_class=HTMLResponse)
|
|
def search_images(
|
|
request: Request,
|
|
q: str = "",
|
|
target: str = "printer",
|
|
id: int = 0,
|
|
) -> HTMLResponse:
|
|
"""Image results for `q`, each pickable as the icon of `target`/`id`.
|
|
|
|
`target` decides which POST endpoint the result buttons hit, so the picker
|
|
is the same partial for a printer icon and a driver icon.
|
|
"""
|
|
if not websearch.enabled():
|
|
return _notice("web_search_off", "Web lookups are disabled on this server.")
|
|
if target not in _TARGETS:
|
|
return _notice("unknown_target", "Unknown search target.", status_code=400)
|
|
|
|
# Confirm the caller may write to this entity before spending a search on
|
|
# it — an unowned printer id must not even reveal that it exists.
|
|
if target == "printer":
|
|
owned = Printer.get_or_none(
|
|
(Printer.id == id) & (Printer.owner == request.state.owner)
|
|
)
|
|
if owned is None:
|
|
return _notice("printer_not_found", "Printer not found.", status_code=404)
|
|
post_url = f"/printers/{id}/icon/from-web"
|
|
status_target = "#icon-status"
|
|
else:
|
|
if Driver.get_or_none(Driver.id == id) is None:
|
|
return _notice("driver_not_found", "Driver not found.", status_code=404)
|
|
post_url = f"/drivers/{id}/icon/from-web"
|
|
status_target = f"#driver-icon-status-{id}"
|
|
|
|
query = q.strip()
|
|
if not query:
|
|
return _notice("type_to_search_image", "Type something to search for.")
|
|
|
|
hits = websearch.search_images(query, limit=MAX_RESULTS)
|
|
return templates.TemplateResponse(
|
|
request=request,
|
|
name="partials/image_results.html",
|
|
context={
|
|
"hits": hits,
|
|
"query": query,
|
|
"post_url": post_url,
|
|
"status_target": status_target,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/drivers", response_class=HTMLResponse)
|
|
def search_driver_pages(request: Request, q: str = "", mode: str = "generic") -> HTMLResponse:
|
|
"""Search the web for a driver download page.
|
|
|
|
`mode=generic` rewrites the term into the vendor's universal-driver product
|
|
name (HP UPD, Xerox Global, …); `mode=exact` searches the text as typed.
|
|
Results are plain links — nothing is downloaded, and the partial says so.
|
|
"""
|
|
if not websearch.enabled():
|
|
return _notice("web_search_off", "Web lookups are disabled on this server.")
|
|
|
|
typed = q.strip()
|
|
if not typed:
|
|
return _notice("type_to_search_driver", "Type a printer model or brand to search for.")
|
|
|
|
query = websearch.generic_driver_query(typed) if mode == "generic" else typed
|
|
hits = websearch.search_pages(query, limit=MAX_RESULTS)
|
|
return templates.TemplateResponse(
|
|
request=request,
|
|
name="partials/driver_search_results.html",
|
|
context={
|
|
"hits": hits,
|
|
"query": query,
|
|
"typed": typed,
|
|
"mode": mode,
|
|
"brand": websearch.detect_brand(typed),
|
|
},
|
|
)
|