Files
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

186 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Guide for Claude Code (claude.ai/code) work in repo.
## Commands
**Run tests:**
```bash
pytest tests/ # whole suite (no env vars needed)
pytest tests/test_inf_parser.py # single test file (no tests/unit/ dir)
pytest tests/ -k "test_name" # single test by name
```
Both `tests/conftest.py` (`tmp_data_dir`) and `tests/e2e/conftest.py` force
`cfg.COOKIE_SECURE = False`, because `TestClient` talks plain HTTP to
`http://testserver` and a `Secure` cookie would be dropped — every request would
land on a *new* `Owner` and ~41 tests would 404. Tests asserting the `Secure`
branch (`test_secure_mode_*` in `tests/test_session.py`) monkeypatch it back to
`True`.
**Run dev server:**
```bash
export DATA_DIR=/tmp/imptune_data
export COOKIE_SECURE=false # plain HTTP — omit if serving behind TLS
uvicorn imptune.main:app --reload --port 8000
```
**Docker:**
```bash
docker-compose up
docker build -t imptune .
```
**Install deps:**
```bash
pip install -r requirements.txt -r requirements-dev.txt
```
## Architecture
ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaRMM) from Windows driver ZIPs + web UI. No external services — single FastAPI + SQLite + Docker volume.
**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``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`, `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
- `imptune/generators/intunewin_builder.py` — Python-native `.intunewin` (ZIP-in-ZIP); IV 16 bytes (not 32); match reference tool `1.8.6.0` output
- `imptune/templates/scripts/` — Jinja2 templates for `install.ps1`, `uninstall.ps1`, `detect.ps1`
**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
(`--im-*`) are declared three times — `:root:not([data-theme=dark])`, the
`prefers-color-scheme: dark` block, and `[data-theme=dark]` — mirroring Pico's
own selectors so equal specificity + later source order wins; a new color must be
added to all three. Pico vars are remapped from those tokens, so use `--im-*` in
components. Prose is set in the system UI face, machine values (IPs, ports, INF
names, PS commands) in `--im-mono`. Components: `.card`, `.rail` (the
driver → printer → package pipeline on the dashboard), `.data-table`, `.badge`,
`.pill`, `.kv`, `.cmd`, `.empty`, `.form-section`, `.toolbar`. Because the edit
dialog renders inside a table cell, `dialog` resets inherited `text-align` /
`white-space` — keep that.
**Shell:** `base.html` owns the sidebar + topbar; pages fill the `crumb`,
`page_title`, `page_actions`, and `content` blocks and must not render their own
`<h1>`. Icons come from `{% import "partials/icons.html" as ico %}`
`{{ ico.i('printer') }}` — inline SVG with no text nodes, because E2E tests read
`textContent` of nav links to assert the translated label. Nav links are
`{{ ico.i(...) }}<span x-text="...">`: never add count badges or other text
inside them.
**i18n:** every user-facing string goes through `$store.i18n.t('key')` with the
English text as the element's fallback body, and keys must be added to *both*
`fr` and `en` in `base.html`. Server-rendered HTMX fragments (icon-upload
confirmation, `_error_response`) are English-only.
The store's default language follows `navigator.language`, so E2E specs must
**never locate a control by its visible label**`button:has-text('Edit')`
matched only on English-locale machines and timed out everywhere else. Target a
structural hook instead (`button[onclick*='showModal']`), except in
`test_i18n_toggle.py`, which asserts the labels on purpose and pins
`locale=` per context.
**Client-side filter:** `Alpine.store('filter')` holds the printer search text.
Rows and group cards carry `data-search` (lowercased) and `x-show` off that
store, so HTMX-swapped rows keep filtering. A group's `data-search` must be a
superset of its rows' — otherwise a matching row hides inside a hidden group.
`.col-defaults` / `.col-arch` / `.col-used` / `.col-added` mark columns dropped
on narrow screens or in the add-printer sidebar (`.form-aside`).
**HTMX pattern:** Forms `hx-post`, swap `#driver-list` / `#printer-list` / `#client-list` targets. Errors return inline HTML fragments (HTTP 400/409) via `_error_response()`. Success return partials from `templates/partials/`.
**PowerShell install script notes:**
- WOW64 64-bit relaunch guard (Intune run 32-bit, `pnputil` need 64-bit)
- UAC self-elevation for user context (SYSTEM context skip)
- Two-step: `pnputil /add-driver` then `Add-PrinterDriver` + `Add-PrinterPort` + `Add-Printer`
- All idempotent (`-ErrorAction SilentlyContinue`)
## Test Setup
`conftest.py` monkeypatch `config.DATA_DIR` + `config.DB_PATH` to temp dir per test. `client` fixture yield `TestClient(app)` with isolated SQLite. E2E in `tests/e2e/` use Playwright.
## Environment Variables
| Var | Default | Purpose |
|-----|---------|---------|
| `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:
`services/session.cookie_kwargs()` drops `max_age`, so the browser holds the
owner key in memory and the session ends when the window closes. Everything
still persists server-side; only the browser's link to it is temporary. Both
cookie-setting call sites (the middleware and `POST /session/restore`) must go
through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag,
and `base.html` renders the `#ephemeral-session-warning` banner plus an extra
paragraph in the onboarding modal off it. Changing this touches
`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`.
`COOKIE_SECURE=single_user` (`cfg.SINGLE_USER`) removes sessions for test boxes
and single-person local prod: the middleware never reads or sets a cookie and
returns `services/session.single_user_owner()` — the **oldest** `Owner` row,
created on demand — so a deployment switched over from cookie mode keeps the
printers it already had and no second row is ever minted. `request.state.owner`
is still what every route filters on, so per-owner query code is unchanged.
`request.state.single_user` gates the sidebar "This session" menu and the
`#single-user-notice` banner in `base.html`; `is_new_owner`/`ephemeral_session`
are forced `False` (no onboarding modal, no memory-only warning). All three
`/session/*` routes 404 via `api/session._require_cookie_sessions()` — a key
can't re-point a cookie that isn't read, and downloading one would leak the
shared owner's bearer credential for a later switch back to cookie mode. Tests:
`test_session.py::test_single_user_*` + `test_parse_cookie_mode_*`.