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>
This commit is contained in:
2026-08-05 10:44:47 +02:00
co-authored by Claude Opus 5
parent 3f9cd1f266
commit 2c06806814
34 changed files with 2516 additions and 162 deletions
+46 -2
View File
@@ -43,11 +43,21 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
**Request flow:**
1. Driver upload → `api/drivers.py``services/inf_parser.py` parse INF → `storage/driver_store.py` store by SHA256 → Peewee `Driver` record (shared/global — visible to every Owner)
2. Printer config → `api/printers.py``db/models.py` `Printer` record (links Driver FK, scoped to `request.state.owner`)
3. Icon upload → `api/icons.py`Pillow validate PNG 256×256 → SHA256 storage → `Icon` record
3. Icon upload → `api/icons.py``services/image_utils.normalize_icon()` resize any raster to 256×256 PNG → SHA256 storage → `Icon` record
4. Package export → `api/packages.py``generators/script_generator.py` render Jinja2 PS1 templates → `generators/intunewin_builder.py` encrypt ZIP (AES-256-CBC + HMAC-SHA256)
**Key modules:**
- `imptune/config.py``DATA_DIR`, `DB_PATH`, `DRIVERS_DIR`, `ICONS_DIR`, `COOKIE_SECURE` from env
- `imptune/config.py``DATA_DIR`, `DB_PATH`, `DRIVERS_DIR`, `ICONS_DIR`, `COOKIE_SECURE`, `WEB_SEARCH` from env
- `imptune/templating.py` — the *only* `Jinja2Templates` instance; every router
imports `templates` from it. Template globals (`web_search_enabled`) are
declared once there, as callables so a monkeypatched `cfg` takes effect
- `imptune/services/image_utils.py``normalize_icon()`: any Pillow-decodable
raster → 256×256 PNG, letterboxed (aspect kept, transparent padding). An
already-exact 256×256 PNG is returned **byte-identical**, because icon storage
is content-addressed and re-encoding would move the file on every save
- `imptune/services/icons.py` — shared icon storage for `Icon` (printer) and
`DriverIcon`; 750 KB cap on the *source* bytes
- `imptune/services/websearch.py` — the only code that leaves the box
- `imptune/db/database.py` — SQLite WAL mode + `foreign_keys=1`; all models inherit `BaseModel`; `init_db()` also backfills `owner_id` on pre-per-owner-scoping DBs into a synthetic legacy `Owner` (key written to `{DATA_DIR}/legacy_owner_key.txt`)
- `imptune/services/session.py``OwnerSessionMiddleware` resolves `request.state.owner` from the `imptune_owner_key` cookie, creating one on first visit (skips `/health`)
- `imptune/services/inf_parser.py` — auto-detect encoding (UTF-16/UTF-8/cp1252), resolve `%TOKEN%` from `[Strings]`, handle multi-model INFs
@@ -56,6 +66,39 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
**Per-owner storage:** `Printer`/`Client` (groups) are scoped to an `Owner` identified by an opaque bearer key in a cookie — no accounts. `Driver` stays global/shared. Every route taking a `printer_id`/`client_id` must filter/check `.owner == request.state.owner` (404, not 403, on mismatch) — printer IDs are small sequential ints, so a list-only filter isn't enough. Onboarding modal (`templates/base.html`, gated on `request.state.is_new_owner`) offers "download backup key" (`GET /session/key/download`, marks `Owner.is_permanent`) vs. temporary; `/session/restore` re-attaches a browser to a previously downloaded key. In tests, use the `owner` fixture (`tests/conftest.py`) when creating `Printer`/`Client` rows directly via the ORM so the `client` fixture's cookie-scoped requests can see them.
**Web lookups (`services/websearch.py`, `api/web.py`):** DuckDuckGo is *scraped*,
not called through an API — no key, but fragile by nature, so `search_images()`
and `search_pages()` swallow parse/transport failures and return `[]` instead of
500ing a page. Image search needs a per-query `vqd` token scraped from the HTML
first, *and* the `_XHR_HEADERS` set (`Accept`, `X-Requested-With`, `Sec-Fetch-*`)
on the `i.js` call — with a valid token but no fetch metadata it answers **403**. `GET /web/images?q&target=printer|driver&id=` and `GET /web/drivers?q&mode=generic|exact`
return HTML fragments (never JSON), and ownership is checked *before* a search is
spent on the id. `fetch_image()` downloads server-side, so `assert_fetchable()`
refuses any URL resolving to a private/loopback/link-local address — ImpTune sits
on the same LAN as the printers, and an unguarded fetcher is a port scanner for
anyone who can reach the UI. Redirects re-run the guard via
`_GuardedRedirectHandler`. Driver search returns **links only** — nothing is
downloaded, and `partials/driver_search_results.html` must keep saying so.
`generic_driver_query()` maps a detected brand to that vendor's real universal-driver
product name (`GENERIC_DRIVER_TERMS`); an unknown brand falls back to
`"<typed> universal print driver download"`.
**Driver rename + driver icons:** `Driver.display_name` (nullable) and the
`DriverIcon` table. Both are **global/shared** like `Driver` itself — a rename is
visible to every Owner, and `GET /drivers/{id}/icon` is deliberately not
owner-scoped. `PATCH /drivers/{id}` swaps the whole `#driver-list`; the icon
routes return a small status fragment *plus* an `hx-swap-oob` refresh of
`#driver-thumb-{id}`, because the dialog stays open after picking an icon and
re-rendering the table would tear the open `<dialog>` out of the DOM.
`partials/driver_edit_modal.html` keeps the rename form and the icon forms as
*siblings* (nested forms are invalid HTML) — the footer's Save reaches the rename
form through `form="driver-rename-{id}"`.
**Schema changes on an existing DB:** `create_tables(safe=True)` skips a table
that already exists, so a new field on an old model needs an entry in
`database._add_missing_columns()` — that is what puts `display_name` on a
pre-rename `driver` table. Tests: `test_db.py::test_init_db_adds_display_name_*`.
**UI stack:** Pico CSS + HTMX 2 + Alpine.js 3 + Jinja2 server-side templates.
**Design layer (`static/app.css`):** a token + component layer over Pico. Tokens
@@ -115,6 +158,7 @@ on narrow screens or in the add-printer sidebar (`.form-aside`).
|-----|---------|---------|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
| `PORT` | `8000` | Server port |
| `WEB_SEARCH` | `true` | `false` disables every outbound request (image search, driver-page search, image download) and hides the search controls — `templating.py` exposes it to templates as `web_search_enabled()` |
| `COOKIE_SECURE` | `true` | Three-way session mode, parsed by `config.parse_cookie_mode()` into `(COOKIE_SECURE, SINGLE_USER)`: `true` = Secure + 10-year cookie; `false` = plain-HTTP serving (browser drops a Secure cookie → new Owner per request), cookie becomes **memory-only** (no `Max-Age`); `single_user` (or `single-user`/`single`) = no cookie at all, one shared Owner — see below. |
`COOKIE_SECURE=false` degrades the session instead of weakening the credential: