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>
186 lines
12 KiB
Markdown
186 lines
12 KiB
Markdown
# 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_*`. |