From ec6c150585202914bc1179c4d5d952d673b126cb Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 10 Apr 2026 13:47:12 +0200 Subject: [PATCH] docs(phase-05): research package export domain Cover .intunewin assembly (wiring Phase 1 spike), NinjaRMM ZIP builder, icon upload/validation, and Alpine.js clipboard preview patterns. Co-Authored-By: Claude Sonnet 4.6 --- .../phases/05-package-export/05-RESEARCH.md | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 .planning/phases/05-package-export/05-RESEARCH.md diff --git a/.planning/phases/05-package-export/05-RESEARCH.md b/.planning/phases/05-package-export/05-RESEARCH.md new file mode 100644 index 0000000..873dd9e --- /dev/null +++ b/.planning/phases/05-package-export/05-RESEARCH.md @@ -0,0 +1,478 @@ +# Phase 5: Package Export - Research + +**Researched:** 2026-04-10 +**Domain:** .intunewin file assembly, NinjaRMM ZIP packaging, icon upload, FastAPI StreamingResponse / Response binary downloads, HTMX copy-to-clipboard +**Confidence:** HIGH (NinjaRMM ZIP, FastAPI binary responses, icon validation), MEDIUM (.intunewin Intune acceptance — format is implemented but tenant-level acceptance unverified) + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| PKG-01 | User can export a complete .intunewin package (script + drivers + detection + metadata) | `build_intunewin()` in `imptune/generators/intunewin_builder.py` is complete; Phase 5 wires it to a printer config + driver files + rendered scripts | +| PKG-02 | .intunewin is generated natively in Python (no IntuneWinAppUtil.exe dependency) | Already implemented in Phase 1 spike using pycryptodome AES-256-CBC; no new libraries needed | +| PKG-03 | User can export a NinjaRMM ZIP package (install script + driver folder) | Standard `zipfile` + `io.BytesIO` in-memory build, served via FastAPI `Response` with `application/zip` | +| PKG-04 | User can upload a custom PNG icon for Intune app display (256x256, max 750KB) | `UploadFile` pattern from drivers.py; `Icon` ORM model already exists; validation with `imghdr` (stdlib) or `Pillow`; icon stored on DATA_DIR volume | +| PKG-05 | User can preview and copy Intune install/uninstall command strings before export | Alpine.js `navigator.clipboard.writeText()` + `$el.innerText` pattern; rendered server-side in Jinja2 template; no new library needed | + + +--- + +## Summary + +Phase 5 closes out v1 by wiring the already-proven `build_intunewin()` function and the Phase 4 script renderers into two download endpoints (`.intunewin` and NinjaRMM ZIP) plus an icon upload endpoint and a command-preview UI. + +All cryptographic and ZIP assembly code is complete and tested from Phase 1. The new work is: (1) assembling the right files into a temp directory and calling `build_intunewin()`, (2) building a NinjaRMM ZIP in memory via `io.BytesIO`, (3) accepting a PNG upload and storing it to `DATA_DIR/icons/`, and (4) adding an Alpine.js clipboard copy widget to the printer detail page. No new Python packages are required; `zipfile`, `io`, `tempfile`, and `shutil` are all stdlib. + +The one genuine risk remains .intunewin Intune tenant acceptance — the byte-level format has been reverse-engineered from svrooij.io documentation and validated in unit tests, but a real upload has not been attempted. This is called out as a manual gate before Phase 5 is declared done. + +**Primary recommendation:** Build three new API router files (`packages.py` for exports, `icons.py` for upload), add `ICONS_DIR` to config, update the printer detail page with export buttons and command preview, keep all ZIP assembly in-memory (no temp files on disk). + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `zipfile` | stdlib | Build NinjaRMM ZIP and inner .intunewin ZIP in memory | Already used throughout codebase | +| `io.BytesIO` | stdlib | In-memory byte stream for zip assembly without disk I/O | Already used in `intunewin_builder.py` and `drivers.py` | +| `tempfile` | stdlib | Temporary directory for .intunewin source staging | `build_intunewin()` requires a `source_dir` path | +| `shutil` | stdlib | Copy driver ZIP contents into temp staging dir | Clean recursive copy | +| `pycryptodome` | 3.20.* | AES-256-CBC encryption for .intunewin (already installed) | Phase 1 dependency; no change | +| `FastAPI Response` | 0.115.* | Serve binary file downloads with `application/zip` | `Response(content=bytes, media_type=...)` is simplest for in-memory content | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `FastAPI StreamingResponse` | 0.115.* | Alternative for large file streaming | Prefer `Response` for in-memory builds under ~50 MB; use `StreamingResponse` only if driver packages are so large that holding in RAM is a concern | +| `Pillow` (PIL) | 10.x | PNG validation (dimensions + format) | Only if stdlib `imghdr` is insufficient for size/dimension check; adds a dependency | +| `imghdr` | stdlib (deprecated 3.13) | Basic PNG format detection | Acceptable for Python 3.12; but deprecated — prefer Pillow for dimension validation | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| In-memory `io.BytesIO` ZIP | Write to `tmp_path` on disk, then stream | Disk I/O slower and requires cleanup; in-memory is simpler for packages under ~100 MB | +| `FastAPI Response` | `StreamingResponse` with generator | StreamingResponse is more complex; Response is sufficient for in-memory byte content | +| Pillow for icon validation | `imghdr` + manual struct parse | Pillow gives dimensions easily; `imghdr` only identifies format, not size — Pillow preferred | + +**Installation (if Pillow added):** +```bash +pip install Pillow +``` +> Note: Pillow is not in current `requirements.txt`. Only add it if dimension validation is required by PKG-04. The requirement states "256x256, max 750KB" — dimension check requires Pillow or struct-parsing PNG IHDR chunk manually. + +--- + +## Architecture Patterns + +### Recommended Project Structure additions +``` +imptune/ +├── api/ +│ ├── packages.py # GET /{printer_id}/packages/intunewin, GET /{printer_id}/packages/ninja +│ └── icons.py # POST /{printer_id}/icon, GET /{printer_id}/icon +├── generators/ +│ └── intunewin_builder.py # Already exists — no changes needed +├── storage/ +│ └── icon_store.py # Analogous to driver_store.py (SHA256 content-addressed) +└── templates/ + └── printer_detail.html # Add export buttons, command preview, icon upload form +``` + +### Pattern 1: In-memory NinjaRMM ZIP +**What:** Build the ZIP entirely in `io.BytesIO`, return as `Response` with `Content-Disposition: attachment` +**When to use:** PKG-03 — NinjaRMM export +**Example:** +```python +# Source: stdlib zipfile + FastAPI Response (project pattern from drivers.py) +import io +import json +import zipfile +from fastapi import APIRouter +from fastapi.responses import Response +from imptune.db.models import Printer +from imptune.generators.script_generator import render_install +import imptune.config as cfg + +@router.get("/{printer_id}/packages/ninja") +def download_ninja_package(printer_id: int): + printer = Printer.get_or_none(Printer.id == printer_id) + if printer is None: + return Response("Printer not found", status_code=404, media_type="text/plain") + + driver = printer.driver + driver_names = json.loads(driver.driver_desc) + script = render_install( + printer_name=printer.name, + ip_address=printer.ip_address, + port_name=printer.port_name, + driver_name=driver_names[0], + inf_filename=driver.inf_filename, + duplex_mode=printer.duplex_mode, + color_mode=printer.color_mode, + paper_size=printer.paper_size, + collate=printer.collate, + ) + + buf = io.BytesIO() + driver_zip_path = cfg.DRIVERS_DIR + "/" + driver.sha256 # raw driver ZIP bytes + driver_bytes = open(driver_zip_path, "rb").read() + + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATE) as zf: + safe_name = printer.name.replace(" ", "_") + zf.writestr(f"{safe_name}/install.ps1", script) + # Expand driver ZIP into drivers/ subfolder + with zipfile.ZipFile(io.BytesIO(driver_bytes)) as driver_zf: + for name in driver_zf.namelist(): + zf.writestr(f"{safe_name}/drivers/{name}", driver_zf.read(name)) + + filename = f"{safe_name}_ninja.zip" + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) +``` + +### Pattern 2: .intunewin Package via Temp Directory +**What:** Stage files to `tempfile.mkdtemp()`, call `build_intunewin()`, read output file, clean up +**When to use:** PKG-01/PKG-02 — Intune export +**Example:** +```python +# Source: imptune/generators/intunewin_builder.py (Phase 1 spike) +import io +import json +import os +import shutil +import tempfile +import zipfile +from fastapi.responses import Response +from imptune.generators.intunewin_builder import build_intunewin +from imptune.generators.script_generator import render_install, render_uninstall, render_detect + +@router.get("/{printer_id}/packages/intunewin") +def download_intunewin(printer_id: int): + # ... fetch printer + driver, validate ... + + tmpdir = tempfile.mkdtemp() + try: + # 1. Write rendered scripts + open(os.path.join(tmpdir, "install.ps1"), "w").write(render_install(...)) + open(os.path.join(tmpdir, "uninstall.ps1"), "w").write(render_uninstall(...)) + open(os.path.join(tmpdir, "detect.ps1"), "w").write(render_detect(...)) + + # 2. Expand driver ZIP into drivers/ subfolder + drivers_subdir = os.path.join(tmpdir, "drivers") + os.makedirs(drivers_subdir) + driver_zip_path = os.path.join(cfg.DRIVERS_DIR, driver.sha256) + with zipfile.ZipFile(driver_zip_path) as zf: + zf.extractall(drivers_subdir) + + # 3. Optionally copy icon + icon = getattr(printer, "icons", None) + # ... copy icon if it exists ... + + # 4. Build .intunewin + output_path = os.path.join(tmpdir, "package.intunewin") + build_intunewin(tmpdir, "install.ps1", output_path) + + # 5. Read and return + content = open(output_path, "rb").read() + safe_name = printer.name.replace(" ", "_") + return Response( + content=content, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'}, + ) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) +``` + +### Pattern 3: PNG Icon Upload and Validation +**What:** Accept PNG via `UploadFile`, validate format + dimensions + size, store SHA256-addressed on disk +**When to use:** PKG-04 — icon upload +**Example:** +```python +# Source: imptune/api/drivers.py upload pattern +from fastapi import UploadFile +from PIL import Image # if Pillow added + +MAX_ICON_BYTES = 750 * 1024 # 750 KB + +@router.post("/{printer_id}/icon") +def upload_icon(printer_id: int, file: UploadFile): + data = file.file.read(MAX_ICON_BYTES + 1) + if len(data) > MAX_ICON_BYTES: + return _error_response("Icon exceeds 750 KB limit.") + + # Validate PNG format and dimensions + try: + img = Image.open(io.BytesIO(data)) + if img.format != "PNG": + return _error_response("Icon must be PNG format.") + if img.size != (256, 256): + return _error_response(f"Icon must be 256x256 pixels, got {img.size}.") + except Exception: + return _error_response("Invalid image file.") + + # Store SHA256-addressed (same as DriverStore pattern) + sha256 = hashlib.sha256(data).hexdigest() + icons_dir = Path(cfg.DATA_DIR) / "icons" + icons_dir.mkdir(parents=True, exist_ok=True) + dest = icons_dir / sha256 + if not dest.exists(): + dest.write_bytes(data) + + # Upsert Icon ORM record (model already exists in models.py) + Icon.get_or_none(Icon.printer == printer_id) # delete old if exists + Icon.create(printer=printer_id, sha256=sha256, + original_filename=file.filename, size_bytes=len(data)) + # ... return success partial ... +``` + +### Pattern 4: Alpine.js Copy-to-Clipboard +**What:** Display command string in a `` element; Alpine.js copies it on button click +**When to use:** PKG-05 — Intune command preview +**Example:** +```html + +
+ powershell.exe -ExecutionPolicy Bypass -File install.ps1 + +
+``` + +### Anti-Patterns to Avoid +- **Writing temp files and not cleaning up:** Always use `try/finally: shutil.rmtree(tmpdir)` — unhandled exceptions skip cleanup +- **Including all files in inner ZIP including uninstall/detect:** build_intunewin() packages everything in tmpdir; if uninstall.ps1 should be separate, do NOT put it in tmpdir — it becomes part of the .intunewin payload, which is fine for Intune (it uses SetupFile="install.ps1" as the entry point) +- **Using os.path.join with driver SHA256 directly:** SHA256 is 64 hex chars — safe as a filename but must use `cfg.DRIVERS_DIR` dynamically (monkeypatch pattern from Phase 2) +- **Reading ICONS_DIR as a module-level constant:** Same pattern as DRIVERS_DIR — read `cfg.DATA_DIR` at call time, not import time, so tests can monkeypatch + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| AES-256-CBC encryption | Custom crypto | `pycryptodome` (already installed) | Padding oracle attacks, IV reuse bugs | +| ZIP assembly | Custom byte writer | `zipfile.ZipFile` + `io.BytesIO` | ZIP format edge cases (compression flags, CRC, central directory) | +| PNG format detection + dimensions | Manual byte parsing | Pillow `Image.open()` | PNG IHDR chunk parsing is 20 lines of struct code that breaks on edge cases | +| Filename sanitization in ZIPs | Custom strip | Explicit allowlist + `replace()` | Zip-slip paths (`../` prefix) — already handled in drivers.py | + +**Key insight:** The hard crypto work (intunewin format) is already done. Phase 5 is assembly and routing only. + +--- + +## Common Pitfalls + +### Pitfall 1: Temp Directory Leaking on Exception +**What goes wrong:** `tempfile.mkdtemp()` creates a directory that persists if an exception is raised before `shutil.rmtree()` +**Why it happens:** Any error in script rendering, driver extraction, or `build_intunewin()` bypasses cleanup +**How to avoid:** Always wrap in `try/finally` block; alternatively use `tempfile.TemporaryDirectory()` as a context manager (auto-cleanup on `__exit__`) +**Warning signs:** `/tmp` fills up with `tmp*` directories after repeated export calls + +### Pitfall 2: build_intunewin() Includes Unexpected Files +**What goes wrong:** `build_intunewin()` walks the entire `source_dir` recursively — any extra file added to tmpdir ends up in the package +**Why it happens:** The function design is "pack everything in this directory" +**How to avoid:** Only write `install.ps1`, `detect.ps1`, `uninstall.ps1`, and `drivers/` into tmpdir; if icon is embedded in the .intunewin, add it as a known filename (e.g., `icon.png`) at tmpdir root + +### Pitfall 3: driver.sha256 File Not Found +**What goes wrong:** Driver file on disk was deleted but ORM record remains, causing `FileNotFoundError` during export +**Why it happens:** No referential integrity between ORM and filesystem +**How to avoid:** Check `Path(cfg.DRIVERS_DIR, driver.sha256).exists()` before proceeding; return HTTP 422 with descriptive message + +### Pitfall 4: Icon Model Unique Constraint Violation +**What goes wrong:** `Icon` model has `unique=True` on the `printer` ForeignKey — second upload raises `IntegrityError` +**Why it happens:** `Icon.create()` called without checking/deleting existing record +**How to avoid:** Use `Icon.get_or_none(Icon.printer == printer_id)` then `.delete_instance()` before `Icon.create()`, or use `INSERT OR REPLACE` via Peewee's `replace()` method + +### Pitfall 5: driver_desc JSON Parse Failure in Export Endpoint +**What goes wrong:** `driver.driver_desc` contains malformed JSON or None +**Why it happens:** Edge case — driver was saved without running INF parsing +**How to avoid:** Reuse `_get_printer_and_driver()` helper from `scripts.py` — it already handles this with HTTP 422 responses + +### Pitfall 6: .intunewin Not Accepted by Intune Tenant +**What goes wrong:** Real Intune upload rejects the package despite passing all unit tests +**Why it happens:** The byte-level format was reverse-engineered from community documentation (svrooij.io), not from Microsoft's official spec +**How to avoid:** Manual validation gate — upload a test `.intunewin` to a real Intune tenant before Phase 5 is marked complete (documented as Phase 5 blocker in STATE.md) + +### Pitfall 7: Alpine.js Clipboard on HTTP (non-HTTPS) +**What goes wrong:** `navigator.clipboard.writeText()` throws `NotAllowedError` in some browsers when page is served over plain HTTP +**Why it happens:** Clipboard API requires secure context (HTTPS or localhost) in modern browsers +**How to avoid:** This tool runs on internal network, typically accessed via IP address. Provide a fallback: show the command text in a `