Files
ImpTune/CLAUDE.md
T
kawaandClaude Opus 5 b397d3dc3d feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session

- COOKIE_SECURE=false no longer persists the owner key for ten years.
  services/session.cookie_kwargs() drops max_age in that mode, so the
  browser holds the key in memory and the session ends with the window.
  Everything still persists server-side; only the browser link is
  temporary. base.html shows a warning banner (FR/EN) and an extra
  paragraph in the onboarding modal, and the README explains the
  trade-off and the backup-key escape hatch.
- Both cookie writers (middleware, POST /session/restore) go through
  cookie_kwargs() so the policy cannot drift between them.
- The CSRF guard on /session/restore compared request.url.scheme against
  the Origin header. Behind a TLS-terminating proxy uvicorn sees http
  while the browser sends https, so every legitimate restore was
  rejected with 403. It now compares hosts only, including
  X-Forwarded-Host.
- /static/*, /favicon.ico and /robots.txt skip the middleware. Each
  cookieless hit was inserting an Owner row no browser could ever use.

Reliability

- Malformed printer-form FK fields no longer escape as HTTP 500:
  a non-numeric client_id/driver_id raised ValueError and an unknown
  driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX
  fragments, and the duplicated field checks moved into
  _validate_fields().
- Package exports stream. build_intunewin() encrypts the inner ZIP in
  1 MB chunks against temp files with a streaming HMAC and SHA256, and
  both endpoints serve the result with FileResponse plus a background
  cleanup task. A 100 MB driver used to be held in memory three or four
  times over per concurrent download. The byte layout is unchanged.
- FileResponse also escapes the download filename, which was previously
  interpolated raw into Content-Disposition.
- python-multipart >= 0.0.18 (CVE-2024-53981, reachable from
  /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from
  icon upload).
- icons.py reads cfg.ICONS_DIR instead of re-deriving the path from
  DATA_DIR, matching the .intunewin export.

UI

- Sidebar/topbar shell, inline SVG icon macros (partials/icons.html),
  card and data-table components, grouped printer list, and the
  dedicated /printers/new page replacing partials/printer_form.html.

Tests

- 194 pass with a bare `pytest tests/`: tests/conftest.py now forces
  cfg.COOKIE_SECURE = False like the e2e conftest already did, so the
  Secure cookie is no longer dropped over http://testserver.
- New coverage for the malformed-FK guards, the chunk-boundary cases in
  the encrypt loop (every residue mod _CHUNK plus a multi-megabyte
  payload), temp-dir cleanup after both exports, and the whole
  COOKIE_SECURE matrix.
- test_printer_edit.py located the Edit button by its translated label,
  so it only passed on English-locale machines. It now targets the
  showModal() hook, which also cuts the e2e run from 84s to 15s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:49 +02:00

128 lines
7.8 KiB
Markdown
Raw 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` → Pillow validate PNG 256×256 → 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/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.
**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 |
| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for plain-HTTP serving or the browser drops the cookie and a new Owner is created on every request. `false` also makes the cookie **memory-only** (no `Max-Age`) — 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_*`.