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>
This commit is contained in:
2026-08-04 17:58:49 +02:00
co-authored by Claude Opus 5
parent ed41f7f520
commit b397d3dc3d
38 changed files with 3739 additions and 1014 deletions
+59 -3
View File
@@ -6,11 +6,18 @@ Guide for Claude Code (claude.ai/code) work in repo.
**Run tests:**
```bash
pytest tests/
pytest tests/unit/test_inf_parser.py # single test file
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
@@ -51,6 +58,45 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
**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:**
@@ -69,4 +115,14 @@ ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaR
|-----|---------|---------|
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
| `PORT` | `8000` | Server port |
| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for local plain-HTTP dev (`uvicorn --reload`) or the browser drops the cookie and a new Owner is created on every request. |
| `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_*`.
+43 -4
View File
@@ -48,7 +48,46 @@ parameters (`-Registry`, `-Owner`, `-Image`, `-NoBuild`, `-SkipLogin`, …).
Set these under `environment:` in `docker-compose.yml`.
| Variable | Default | Purpose |
|------------|---------|--------------------------------------------------|
| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
| `PORT` | `8000` | Port the server listens on inside the container. |
| Variable | Default | Purpose |
|------------------|---------|--------------------------------------------------|
| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
| `PORT` | `8000` | Port the server listens on inside the container. |
| `COOKIE_SECURE` | `true` | `Secure` flag on the session cookie. Set to `false` when the app is reached over plain HTTP — see below. |
### `COOKIE_SECURE` and HTTPS
Printers, print defaults and clients belong to a session identified by an opaque
key in a cookie (there are no accounts). That cookie is `Secure` by default, so
it only travels over HTTPS.
**Behind a TLS-terminating proxy** (nginx, Traefik, Caddy — the normal setup):
leave the default. The session cookie lasts ten years, so a browser keeps its
printers indefinitely.
**Reached directly over plain HTTP** (`http://host:8000`): set
`COOKIE_SECURE=false`, otherwise the browser refuses the cookie and every
request starts a brand-new empty session — no printer you save is ever visible
again.
In that mode the app is fully usable and keeps remembering everything, but the
cookie becomes **memory-only**: the session ends when the browser closes, and
every page shows a warning saying so. This is deliberate — over plain HTTP the
key is readable on the wire, so it is not written to disk for ten years. Use
**Download my backup key** to save the key to a file; `/session/restore` takes
it back on the next browser start, or on another machine.
```yaml
services:
imptune:
environment:
- DATA_DIR=/data
- COOKIE_SECURE=false # only when serving plain HTTP
```
For local development the same applies:
```bash
export DATA_DIR=/tmp/imptune_data
export COOKIE_SECURE=false
uvicorn imptune.main:app --reload --port 8000
```
+5
View File
@@ -12,6 +12,11 @@ services:
restart: unless-stopped
environment:
- DATA_DIR=/data
# Uncomment when this port is reached over plain HTTP (no TLS proxy in
# front): the owner-session cookie is Secure by default, so the browser
# drops it and every request mints a new empty Owner. With `false` the
# session works but lives only until the browser closes, and the UI warns.
# - COOKIE_SECURE=false
volumes:
imptune_data:
+6 -2
View File
@@ -27,11 +27,15 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
def _render_client_list(request: Request) -> HTMLResponse:
"""Render the client list partial for HTMX swap."""
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
from imptune.api.pages import printer_counts_by_client
owner = request.state.owner
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="partials/client_list.html",
context={"clients": clients},
# Counts must come along, or the swapped-in rows all read "0 printers".
context={"clients": clients, "counts": printer_counts_by_client(owner)},
)
+37 -4
View File
@@ -6,7 +6,7 @@ import io
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, Response
from PIL import Image
import imptune.config as cfg
@@ -68,9 +68,11 @@ def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResp
status_code=422,
)
# Store SHA256-addressed on disk
# Store SHA256-addressed on disk. cfg.ICONS_DIR is the same path the
# .intunewin export reads from — deriving it a second time from DATA_DIR
# would silently diverge if the layout ever changes.
sha256 = hashlib.sha256(data).hexdigest()
icons_dir = Path(cfg.DATA_DIR) / "icons"
icons_dir = Path(cfg.ICONS_DIR)
icons_dir.mkdir(parents=True, exist_ok=True)
icon_path = icons_dir / sha256
icon_path.write_bytes(data)
@@ -85,6 +87,37 @@ def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResp
)
return HTMLResponse(
content="<p>Icon uploaded successfully</p>",
content=(
'<p class="ok-note">Icon uploaded successfully</p>'
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
f' width="56" height="56" alt=""><span class="meta">{img.size[0]}&times;{img.size[1]} PNG</span></div>'
),
status_code=200,
)
@router.get("/{printer_id}/icon")
def get_icon(request: Request, printer_id: int) -> Response:
"""Serve the stored 256x256 PNG so the UI can show what was uploaded.
Owner-scoped: a printer belonging to another owner reads as missing.
"""
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
return Response(status_code=404)
icon = Icon.get_or_none(Icon.printer == printer_id)
if icon is None:
return Response(status_code=404)
icon_path = Path(cfg.ICONS_DIR) / icon.sha256
if not icon_path.exists():
return Response(status_code=404)
return FileResponse(
icon_path,
media_type="image/png",
headers={"Cache-Control": "private, max-age=300"},
)
+66 -37
View File
@@ -1,5 +1,4 @@
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
import io
import json
import os
import shutil
@@ -7,7 +6,8 @@ import tempfile
import zipfile
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse, Response
from fastapi.responses import FileResponse, PlainTextResponse
from starlette.background import BackgroundTask
import imptune.config as cfg
from imptune.db.models import Icon, Owner, Printer
@@ -17,6 +17,11 @@ from imptune.storage.driver_store import DriverStore
router = APIRouter(prefix="/printers")
# Driver payloads run to ~100 MB. Packages are assembled in a temp dir and
# streamed from disk instead of being held in memory, so N concurrent
# downloads cost N file handles rather than N × package size of RAM.
_CHUNK = 1024 * 1024
def _get_printer_and_driver(printer_id: int, owner: Owner):
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
@@ -47,6 +52,16 @@ def _get_driver_zip_path(driver) -> str:
return str(DriverStore(cfg.DRIVERS_DIR).get_path(driver.sha256))
def _streamed_download(path: str, tmpdir: str, media_type: str, filename: str) -> FileResponse:
"""Serve a built package off disk, deleting its temp dir once sent."""
return FileResponse(
path,
media_type=media_type,
filename=filename,
background=BackgroundTask(shutil.rmtree, tmpdir, True),
)
@router.get("/{printer_id}/packages/ninja")
def get_ninja_package(request: Request, printer_id: int):
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
@@ -76,22 +91,30 @@ def get_ninja_package(request: Request, printer_id: int):
collate=printer.collate,
)
# Build ZIP in-memory
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Add install script
zf.writestr(f"{safe_name}/install.ps1", install_script)
# Build the ZIP on disk, copying driver members through in chunks so a
# 100 MB driver never lands in memory whole.
tmpdir = tempfile.mkdtemp(prefix="imptune_ninja_")
try:
# Fixed on-disk name — the printer name only shapes the download name,
# so a "/" or ":" in it can't break the temp path.
out_path = os.path.join(tmpdir, "package.zip")
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{safe_name}/install.ps1", install_script)
# Extract and re-add driver files from driver ZIP
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
for member in driver_zf.namelist():
member_data = driver_zf.read(member)
zf.writestr(f"{safe_name}/drivers/{member}", member_data)
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
for member in driver_zf.infolist():
target = f"{safe_name}/drivers/{member.filename}"
if member.is_dir():
zf.writestr(target, b"")
continue
with driver_zf.open(member) as src, zf.open(target, "w") as dst:
shutil.copyfileobj(src, dst, _CHUNK)
except BaseException:
shutil.rmtree(tmpdir, ignore_errors=True)
raise
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
return _streamed_download(
out_path, tmpdir, "application/zip", f"{safe_name}_ninja.zip"
)
@@ -130,17 +153,24 @@ def get_intunewin_package(request: Request, printer_id: int):
)
detect_script = render_detect(printer_name=printer.name)
with tempfile.TemporaryDirectory(prefix="imptune_") as tmpdir:
# Write scripts
with open(os.path.join(tmpdir, "install.ps1"), "w", encoding="utf-8") as f:
f.write(install_script)
with open(os.path.join(tmpdir, "uninstall.ps1"), "w", encoding="utf-8") as f:
f.write(uninstall_script)
with open(os.path.join(tmpdir, "detect.ps1"), "w", encoding="utf-8") as f:
f.write(detect_script)
# The build output is streamed straight off disk, so the temp tree has to
# outlive this handler — the response's background task removes it.
tmpdir = tempfile.mkdtemp(prefix="imptune_")
try:
staging = os.path.join(tmpdir, "staging")
os.makedirs(staging, exist_ok=True)
# Extract driver ZIP contents into tmpdir/drivers/
drivers_subdir = os.path.join(tmpdir, "drivers")
# Write scripts
for filename, script in (
("install.ps1", install_script),
("uninstall.ps1", uninstall_script),
("detect.ps1", detect_script),
):
with open(os.path.join(staging, filename), "w", encoding="utf-8") as f:
f.write(script)
# Extract driver ZIP contents into staging/drivers/
drivers_subdir = os.path.join(staging, "drivers")
os.makedirs(drivers_subdir, exist_ok=True)
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
driver_zf.extractall(drivers_subdir)
@@ -150,17 +180,16 @@ def get_intunewin_package(request: Request, printer_id: int):
if icon_record is not None:
icon_src = os.path.join(cfg.ICONS_DIR, icon_record.sha256)
if os.path.isfile(icon_src):
shutil.copy2(icon_src, os.path.join(tmpdir, "icon.png"))
shutil.copy2(icon_src, os.path.join(staging, "icon.png"))
# Build .intunewin
output_path = os.path.join(tmpdir, "out.intunewin")
build_intunewin(tmpdir, "install.ps1", output_path)
# Build .intunewin outside the staging dir — an output file written into
# the tree being packaged would end up inside its own package.
output_path = os.path.join(tmpdir, "package.intunewin")
build_intunewin(staging, "install.ps1", output_path)
except BaseException:
shutil.rmtree(tmpdir, ignore_errors=True)
raise
with open(output_path, "rb") as f:
content = f.read()
return Response(
content=content,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'},
return _streamed_download(
output_path, tmpdir, "application/octet-stream", f"{safe_name}.intunewin"
)
+108 -35
View File
@@ -5,37 +5,102 @@ from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from peewee import JOIN
from peewee import JOIN, fn
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
# Group label for printers with no client — the template translates it.
UNASSIGNED_GROUP = "Unassigned"
def printer_counts_by_driver(owner) -> dict[int, int]:
"""How many of this owner's printers use each (globally shared) driver."""
from imptune.db.models import Printer
rows = (
Printer.select(Printer.driver, fn.COUNT(Printer.id).alias("n"))
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.group_by(Printer.driver)
)
return {row.driver_id: row.n for row in rows}
def printer_counts_by_client(owner) -> dict[int, int]:
"""How many printers each client groups."""
from imptune.db.models import Printer
rows = (
Printer.select(Printer.client, fn.COUNT(Printer.id).alias("n"))
.where((Printer.owner == owner) & Printer.client.is_null(False))
.group_by(Printer.client)
)
return {row.client_id: row.n for row in rows}
def group_printers_by_client(printers) -> dict[str, list]:
"""Group printers under their client name, unassigned ones last.
The template renders groups in insertion order, and "a printer nobody has
filed yet" belongs at the bottom of the page, not the top.
"""
grouped: dict[str, list] = defaultdict(list)
for p in printers:
grouped[p.client.name if p.client_id else UNASSIGNED_GROUP].append(p)
unassigned = grouped.pop(UNASSIGNED_GROUP, None)
ordered = {name: grouped[name] for name in sorted(grouped)}
if unassigned:
ordered[UNASSIGNED_GROUP] = unassigned
return ordered
def _driver_data(drivers) -> list[dict]:
"""Attach the parsed driver names to each Driver row for template use."""
return [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in drivers
]
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
from imptune.db.models import Printer
from imptune.db.models import Client, Driver, Printer
owner = request.state.owner
recent_printers = list(
Printer.select()
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.owner == owner)
.order_by(Printer.created_at.desc())
.limit(5)
)
recent_packages = list(
Printer.select()
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.order_by(Printer.created_at.desc())
.limit(5)
)
printer_count = Printer.select().where(Printer.owner == owner).count()
ready_count = (
Printer.select()
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.count()
)
return templates.TemplateResponse(
request=request,
name="dashboard.html",
context={
"recent_printers": recent_printers,
"recent_packages": recent_packages,
"driver_count": Driver.select().count(),
"printer_count": printer_count,
"client_count": Client.select().where(Client.owner == owner).count(),
"ready_count": ready_count,
},
)
@@ -45,14 +110,13 @@ def drivers_page(request: Request):
from imptune.db.models import Driver
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = []
for d in drivers:
names = json.loads(d.driver_desc) if d.driver_desc else []
driver_data.append({"driver": d, "names": names})
return templates.TemplateResponse(
request=request,
name="drivers.html",
context={"driver_data": driver_data},
context={
"driver_data": _driver_data(drivers),
"usage": printer_counts_by_driver(request.state.owner),
},
)
@@ -67,18 +131,13 @@ def printers_page(request: Request):
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list] = defaultdict(list)
for p in query:
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
grouped = group_printers_by_client(query)
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = []
for d in all_drivers:
names = json.loads(d.driver_desc) if d.driver_desc else []
driver_data.append({"driver": d, "names": names})
printer_count = sum(len(v) for v in grouped.values())
ready_count = sum(1 for v in grouped.values() for p in v if p.driver_id)
return templates.TemplateResponse(
request=request,
@@ -86,7 +145,9 @@ def printers_page(request: Request):
context={
"grouped": grouped,
"clients": clients,
"driver_data": driver_data,
"driver_data": _driver_data(all_drivers),
"printer_count": printer_count,
"ready_count": ready_count,
},
)
@@ -97,14 +158,10 @@ def printers_new_page(request: Request):
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in all_drivers
]
return templates.TemplateResponse(
request=request,
name="printers_new.html",
context={"clients": clients, "driver_data": driver_data},
context={"clients": clients, "driver_data": _driver_data(all_drivers)},
)
@@ -154,11 +211,12 @@ def printer_detail(request: Request, printer_id: int):
def clients_page(request: Request):
from imptune.db.models import Client
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
owner = request.state.owner
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="clients.html",
context={"clients": clients},
context={"clients": clients, "counts": printer_counts_by_client(owner)},
)
@@ -180,14 +238,11 @@ def client_detail(request: Request, client_id: int):
.where((Printer.client == client_id) & (Printer.owner == owner))
.order_by(Printer.name)
)
grouped = {client.name: list(query)}
printers = list(query)
grouped = {client.name: printers}
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in all_drivers
]
return templates.TemplateResponse(
request=request,
@@ -196,25 +251,43 @@ def client_detail(request: Request, client_id: int):
"client": client,
"grouped": grouped,
"clients": clients,
"driver_data": driver_data,
"driver_data": _driver_data(all_drivers),
"printer_count": len(printers),
"ready_count": sum(1 for p in printers if p.driver_id),
},
)
@router.get("/packages", response_class=HTMLResponse)
def packages_page(request: Request):
from imptune.db.models import Client, Driver, Printer
from imptune.db.models import Client, Driver, Icon, Printer
owner = request.state.owner
printers = list(
Printer.select(Printer, Client, Driver)
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where((Printer.owner == request.state.owner) & Printer.driver.is_null(False))
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.order_by(Printer.name)
)
pending_count = (
Printer.select()
.where((Printer.owner == owner) & Printer.driver.is_null(True))
.count()
)
icon_printer_ids = {
row.printer_id
for row in Icon.select(Icon.printer).where(
Icon.printer.in_([p.id for p in printers] or [0])
)
}
return templates.TemplateResponse(
request=request,
name="packages.html",
context={"printers": printers},
context={
"printers": printers,
"pending_count": pending_count,
"icon_printer_ids": icon_printer_ids,
},
)
+81 -45
View File
@@ -1,14 +1,14 @@
"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from peewee import JOIN
from imptune.api.pages import group_printers_by_client
from imptune.db.models import Client, Driver, Printer
router = APIRouter(prefix="/printers")
@@ -29,6 +29,59 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
)
def _validate_fields(
name: str, ip_address: str, port_name: str, duplex_mode: str, paper_size: str
) -> HTMLResponse | None:
"""Shared field validation for create and update — None when everything is valid."""
if not name:
return _error_response("Printer name is required.")
if not ip_address:
return _error_response("IP address is required.")
if not port_name:
return _error_response("Port name is required.")
if duplex_mode not in _VALID_DUPLEX:
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
if paper_size not in _VALID_PAPER:
return _error_response(f"Invalid paper size: {paper_size}.")
return None
def _resolve_client(raw: str, owner) -> tuple[int | None, HTMLResponse | None]:
"""Resolve the optional client_id form field to an owned Client id.
The form value is attacker-controlled text: a non-numeric value used to
raise ValueError (HTTP 500) instead of the 400 the HTMX form can render.
"""
raw = raw.strip()
if not raw:
return None, None
try:
client_fk = int(raw)
except ValueError:
return None, _error_response(f"Invalid client id: {raw}.")
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return None, _error_response(f"Client {client_fk} not found.", status_code=404)
return client_fk, None
def _resolve_driver(raw: str) -> tuple[int | None, HTMLResponse | None]:
"""Resolve the optional driver_id form field. Drivers are global/shared.
Existence is checked here because an unknown id otherwise reaches SQLite as
a FOREIGN KEY violation — an IntegrityError (HTTP 500) rather than a 404.
"""
raw = raw.strip()
if not raw:
return None, None
try:
driver_fk = int(raw)
except ValueError:
return None, _error_response(f"Invalid driver id: {raw}.")
if Driver.get_or_none(Driver.id == driver_fk) is None:
return None, _error_response(f"Driver {driver_fk} not found.", status_code=404)
return driver_fk, None
def _render_printer_list(request: Request) -> HTMLResponse:
"""Query printers with LEFT JOIN on client and render grouped partial."""
import json
@@ -40,10 +93,7 @@ def _render_printer_list(request: Request) -> HTMLResponse:
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list[Printer]] = defaultdict(list)
for p in query:
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
grouped = group_printers_by_client(query)
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
@@ -71,48 +121,39 @@ def create_printer(
collate: str = Form(""),
client_id: str = Form(""),
driver_id: str = Form(""),
) -> HTMLResponse:
) -> Response:
"""Create a new printer configuration.
Boolean fields (color_mode, collate) use HTML checkbox convention:
"on" = True, absent/empty = False.
Redirects to /printers on success; returns an inline HTMX error fragment
(400/404) on validation failure.
"""
name = name.strip()
ip_address = ip_address.strip()
port_name = port_name.strip()
if not name:
return _error_response("Printer name is required.")
if not ip_address:
return _error_response("IP address is required.")
if not port_name:
return _error_response("Port name is required.")
if duplex_mode not in _VALID_DUPLEX:
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
if paper_size not in _VALID_PAPER:
return _error_response(f"Invalid paper size: {paper_size}.")
# Convert checkbox values
color_mode_bool = color_mode == "on"
collate_bool = collate == "on"
invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size)
if invalid is not None:
return invalid
owner = request.state.owner
# Resolve optional FK IDs — client must belong to this owner
client_fk = int(client_id) if client_id.strip() else None
if client_fk is not None:
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return _error_response(f"Client {client_fk} not found.", status_code=404)
driver_fk = int(driver_id) if driver_id.strip() else None
client_fk, error = _resolve_client(client_id, owner)
if error is not None:
return error
driver_fk, error = _resolve_driver(driver_id)
if error is not None:
return error
Printer.create(
name=name,
ip_address=ip_address,
port_name=port_name,
duplex_mode=duplex_mode,
color_mode=color_mode_bool,
# HTML checkbox convention: "on" = True, absent/empty = False
color_mode=color_mode == "on",
paper_size=paper_size,
collate=collate_bool,
collate=collate == "on",
owner=owner,
client=client_fk,
driver=driver_fk,
@@ -161,21 +202,16 @@ def update_printer(
ip_address = ip_address.strip() or printer.ip_address
port_name = port_name.strip() or printer.port_name
if not name:
return _error_response("Printer name is required.")
if not ip_address:
return _error_response("IP address is required.")
if not port_name:
return _error_response("Port name is required.")
if duplex_mode not in _VALID_DUPLEX:
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
if paper_size not in _VALID_PAPER:
return _error_response(f"Invalid paper size: {paper_size}.")
invalid = _validate_fields(name, ip_address, port_name, duplex_mode, paper_size)
if invalid is not None:
return invalid
client_fk = int(client_id) if client_id.strip() else None
if client_fk is not None:
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return _error_response(f"Client {client_fk} not found.", status_code=404)
client_fk, error = _resolve_client(client_id, owner)
if error is not None:
return error
driver_fk, error = _resolve_driver(driver_id)
if error is not None:
return error
printer.name = name
printer.ip_address = ip_address
@@ -185,7 +221,7 @@ def update_printer(
printer.paper_size = paper_size
printer.collate = collate == "on"
printer.client = client_fk
printer.driver = int(driver_id) if driver_id.strip() else None
printer.driver = driver_fk
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
printer.save()
+2 -10
View File
@@ -7,9 +7,8 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import imptune.config as cfg
from imptune.db.models import Owner
from imptune.services.session import COOKIE_MAX_AGE, COOKIE_NAME, is_same_origin
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
router = APIRouter(prefix="/session")
@@ -62,12 +61,5 @@ def restore_session(request: Request, key: str = Form(...)):
)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
COOKIE_NAME,
owner.key,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=cfg.COOKIE_SECURE,
)
response.set_cookie(COOKIE_NAME, owner.key, **cookie_kwargs())
return response
+94 -36
View File
@@ -27,8 +27,9 @@ Outer ZIP structure:
import base64
import hashlib
import hmac
import io
import os
import shutil
import tempfile
import zipfile
from xml.etree.ElementTree import Element, SubElement, indent, tostring
@@ -41,6 +42,10 @@ from Crypto.Util.Padding import pad
# was produced by a compatible tool version.
_TOOL_VERSION = "1.8.6.0"
# Streaming chunk size. Must be a multiple of AES.block_size (16) so every
# chunk but the last is a whole number of CBC blocks.
_CHUNK = 1024 * 1024
def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
"""Build a .intunewin file from source_dir, with setup_file as entry point.
@@ -54,43 +59,93 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
Raises:
FileNotFoundError: If source_dir does not exist.
ValueError: If setup_file is empty.
Driver payloads run to ~100 MB, so every step streams through 1 MB chunks
against temp files: buffering the inner ZIP, its ciphertext and the outer
ZIP in memory held 3-4 full copies of the package per concurrent download.
"""
# --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
inner_zip_buf = io.BytesIO()
with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(source_dir):
dirs.sort() # deterministic ordering
for filename in sorted(files):
abs_path = os.path.join(root, filename)
arc_name = os.path.relpath(abs_path, source_dir)
# Normalise to forward slashes for cross-platform consistency
arc_name = arc_name.replace("\\", "/")
zf.write(abs_path, arc_name)
plaintext = inner_zip_buf.getvalue()
if not setup_file:
raise ValueError("setup_file must not be empty")
if not os.path.isdir(source_dir):
raise FileNotFoundError(f"source_dir does not exist: {source_dir}")
# --- Step 2: Generate random keys and IV ---
aes_key = os.urandom(32) # 256-bit AES key
mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
with tempfile.TemporaryDirectory(prefix="intunewin_") as staging:
inner_zip_path = os.path.join(staging, "inner.zip")
ciphertext_path = os.path.join(staging, "inner.enc")
# --- Step 3: Encrypt with AES-256-CBC (PKCS7 padding) ---
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
# --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
with zipfile.ZipFile(inner_zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(source_dir):
dirs.sort() # deterministic ordering
for filename in sorted(files):
abs_path = os.path.join(root, filename)
arc_name = os.path.relpath(abs_path, source_dir)
# Normalise to forward slashes for cross-platform consistency
arc_name = arc_name.replace("\\", "/")
zf.write(abs_path, arc_name)
plaintext_size = os.path.getsize(inner_zip_path)
# --- Step 4: Compute HMAC-SHA256 over (IV + ciphertext) using mac_key ---
# The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
# 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
# (= IV || ciphertext) to verify integrity. Authenticated-encryption best
# practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
# that a forged IV cannot redirect decryption.
mac_digest = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
# --- Step 2: Generate random keys and IV ---
aes_key = os.urandom(32) # 256-bit AES key
mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
# --- Step 5: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] ---
encrypted_blob = mac_digest + iv + ciphertext
# --- Steps 3-4: Encrypt (AES-256-CBC, PKCS7) while MAC-ing and digesting ---
# The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
# 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
# (= IV || ciphertext) to verify integrity. Authenticated-encryption best
# practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
# that a forged IV cannot redirect decryption.
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
mac = hmac.new(mac_key, iv, hashlib.sha256)
plaintext_hash = hashlib.sha256()
# --- Step 6: Compute plaintext (inner ZIP) SHA256 digest for Detection.xml ---
file_digest = hashlib.sha256(plaintext).digest()
with open(inner_zip_path, "rb") as src, open(ciphertext_path, "wb") as dst:
while True:
# BufferedReader.read(n) returns n bytes unless EOF, so a short
# read means "last chunk" — the only one that carries padding.
chunk = src.read(_CHUNK)
plaintext_hash.update(chunk)
if len(chunk) < _CHUNK:
block = cipher.encrypt(pad(chunk, AES.block_size))
mac.update(block)
dst.write(block)
break
block = cipher.encrypt(chunk)
mac.update(block)
dst.write(block)
mac_digest = mac.digest()
# --- Step 6: Plaintext (inner ZIP) SHA256 digest for Detection.xml ---
file_digest = plaintext_hash.digest()
return _finalize(
output_path=output_path,
ciphertext_path=ciphertext_path,
setup_file=setup_file,
plaintext_size=plaintext_size,
aes_key=aes_key,
mac_key=mac_key,
iv=iv,
mac_digest=mac_digest,
file_digest=file_digest,
)
def _finalize(
*,
output_path: str,
ciphertext_path: str,
setup_file: str,
plaintext_size: int,
aes_key: bytes,
mac_key: bytes,
iv: bytes,
mac_digest: bytes,
file_digest: bytes,
) -> None:
"""Write Detection.xml plus the [HMAC(32)][IV(16)][ciphertext] blob into the outer ZIP."""
# --- Step 7: Build Detection.xml ---
# Format MUST match IntuneWinAppUtil.exe reference output exactly:
# - ToolVersion is an XML attribute on ApplicationInfo (not a child element)
@@ -102,7 +157,7 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
attrib={"ToolVersion": _TOOL_VERSION},
)
SubElement(app_info, "Name").text = setup_file
SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
SubElement(app_info, "UnencryptedContentSize").text = str(plaintext_size)
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
SubElement(app_info, "SetupFile").text = setup_file
@@ -121,11 +176,14 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
detection_xml = tostring(app_info, encoding="unicode", xml_declaration=False)
# --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
# The blob is streamed in as [HMAC(32)] + [IV(16)] + [ciphertext] so the
# encrypted payload is never materialised as a second in-memory copy.
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
outer.writestr(
"IntuneWinPackage/Contents/IntunePackage.intunewin",
encrypted_blob,
)
with outer.open("IntuneWinPackage/Contents/IntunePackage.intunewin", "w") as dest:
dest.write(mac_digest)
dest.write(iv)
with open(ciphertext_path, "rb") as ct:
shutil.copyfileobj(ct, dest, _CHUNK)
outer.writestr(
"IntuneWinPackage/Metadata/Detection.xml",
detection_xml.encode("utf-8"),
+5 -1
View File
@@ -173,7 +173,11 @@ def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedI
for key, _val in parser.items(section):
if key.startswith("__bare_"):
continue
resolved = _resolve_tokens(key, strings)
# DriverDesc keys are sometimes quoted directly in the INF
# (e.g. `"Canon Generic PCL6" = SectionName, HardwareID`)
# instead of via %TOKEN%; configparser keeps those quotes as
# part of the key, so strip them same as [Strings] values.
resolved = _resolve_tokens(key, strings).strip('"')
# Skip empty, purely numeric, or clearly non-driver-name entries
if resolved and not resolved.isdigit():
driver_names.add(resolved)
+56 -15
View File
@@ -12,11 +12,50 @@ from imptune.db.models import Owner
COOKIE_NAME = "imptune_owner_key"
COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60 # 10 years
# Paths that never need an Owner. Minting one for them means a DB write per
# request for any client that doesn't carry the cookie — asset fetches racing
# the first page load, health probes, crawlers, favicon hunts — and every row
# is dead weight, since only a browser holding the cookie can ever use it.
_UNSCOPED_PATHS = frozenset({"/health", "/favicon.ico", "/robots.txt"})
_UNSCOPED_PREFIXES = ("/static/",)
def generate_key() -> str:
return secrets.token_urlsafe(32)
def cookie_kwargs() -> dict:
"""`set_cookie` attributes for the owner key — persistent only when Secure.
With `COOKIE_SECURE=false` the key travels over plain HTTP, so persisting it
for ten years would leave a long-lived bearer credential on disk and in
cleartext traffic. Instead `max_age` is dropped: the browser holds the cookie
in memory only. The app stays fully usable and keeps remembering printers,
configs and clients for as long as the window is open — the session just
ends when the browser does, and the UI says so (see `ephemeral_session`).
The backup key remains the way to carry a session across browsers.
"""
kwargs: dict = {
"httponly": True,
"samesite": "lax",
"secure": cfg.COOKIE_SECURE,
}
if cfg.COOKIE_SECURE:
kwargs["max_age"] = COOKIE_MAX_AGE
return kwargs
def _request_hosts(request: Request) -> set[str]:
"""Every host spelling that legitimately identifies this deployment."""
hosts = {request.url.netloc}
for header in ("host", "x-forwarded-host"):
value = request.headers.get(header)
if value:
# X-Forwarded-Host may be a proxy chain: the client-facing host is first.
hosts.add(value.split(",")[0].strip())
return hosts
def is_same_origin(request: Request) -> bool:
"""Origin/Referer check — the CSRF guard for /session/restore.
@@ -28,19 +67,25 @@ def is_same_origin(request: Request) -> bool:
Origin (and usually Referer) on form POSTs, same-site or not, so
requiring a match — and rejecting when both are absent — blocks a plain
auto-submitting HTML form without needing a token.
Only the *host* is compared, not the scheme: behind a TLS-terminating
proxy the browser sends `Origin: https://host` while uvicorn sees
`http` (it only trusts X-Forwarded-Proto from `forwarded_allow_ips`,
which excludes a proxy in a sibling container), so a scheme comparison
rejected every legitimate restore in production. A same-host attacker
origin is not a capability the scheme check was buying.
"""
expected = f"{request.url.scheme}://{request.url.netloc}"
from urllib.parse import urlparse
hosts = _request_hosts(request)
origin = request.headers.get("origin")
if origin is not None:
return origin == expected
return urlparse(origin).netloc in hosts
referer = request.headers.get("referer")
if referer:
from urllib.parse import urlparse
parsed = urlparse(referer)
return f"{parsed.scheme}://{parsed.netloc}" == expected
return urlparse(referer).netloc in hosts
return False
@@ -49,7 +94,8 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
"""Resolves request.state.owner from a cookie, creating one on first visit."""
async def dispatch(self, request: Request, call_next):
if request.url.path == "/health":
path = request.url.path
if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
return await call_next(request)
key = request.cookies.get(COOKIE_NAME)
@@ -60,6 +106,8 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
request.state.owner = owner
request.state.is_new_owner = is_new
# Templates warn about it; see cookie_kwargs().
request.state.ephemeral_session = not cfg.COOKIE_SECURE
response = await call_next(request)
@@ -72,13 +120,6 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
)
if is_new and not route_already_set_cookie:
response.set_cookie(
COOKIE_NAME,
owner.key,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=cfg.COOKIE_SECURE,
)
response.set_cookie(COOKIE_NAME, owner.key, **cookie_kwargs())
return response
+1349 -94
View File
File diff suppressed because it is too large Load Diff
+338 -160
View File
@@ -1,9 +1,11 @@
{% import "partials/icons.html" as ico %}
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ImpTune</title>
<title>{% block head_title %}ImpTune{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230d6f79'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' font-weight='700' text-anchor='middle' fill='white'%3EiT%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="stylesheet" href="/static/app.css">
<script>
@@ -11,7 +13,11 @@
// Theme store: cycles Light -> Dark -> System, persists in localStorage
Alpine.store('theme', {
current: localStorage.getItem('imptune_theme') || 'auto',
icons: { light: '&#9728;', dark: '&#9790;', auto: '&#9681;' },
icons: {
light: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/></svg>',
dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>',
auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>'
},
init() {
document.documentElement.setAttribute('data-theme', this.current);
},
@@ -23,6 +29,10 @@
}
});
// Client-side list filter. Lives in a store so rows swapped in by HTMX
// (partials/printer_list.html) find it no matter which page included them.
Alpine.store('filter', { q: '' });
// i18n store: FR/EN toggle, persists in localStorage
Alpine.store('i18n', {
lang: (() => {
@@ -46,49 +56,78 @@
packages: 'Paquets',
add_printer: 'Ajouter une imprimante',
add_client: 'Ajouter un client',
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
printer_library: 'Bibliothèque dimprimantes',
edit: 'Modifier',
delete: 'Supprimer',
save: 'Enregistrer',
cancel: 'Annuler',
upload_driver: 'T\u00e9l\u00e9charger un pilote',
printer_name: 'Nom de l\u2019imprimante',
upload_driver: 'Importer un pilote',
printer_name: 'Nom de limprimante',
ip_address: 'Adresse IP',
port_name: 'Nom du port',
driver: 'Pilote',
duplex_mode: 'Mode recto-verso',
one_sided: 'Recto simple',
long_edge: 'Grand c\u00f4t\u00e9',
short_edge: 'Petit c\u00f4t\u00e9',
color_mode: 'Mode couleur',
long_edge: 'Grand côté',
short_edge: 'Petit côté',
color_mode: 'Couleur',
paper_size: 'Format papier',
collate: 'Assembler',
client: 'Client',
edit_printer: 'Modifier l\u2019imprimante',
no_printers: 'Aucune imprimante configur\u00e9e.',
no_clients: 'Aucun client configur\u00e9.',
edit_printer: 'Modifier limprimante',
no_printers: 'Aucune imprimante configurée.',
no_clients: 'Aucun client configuré.',
client_list: 'Liste des clients',
name: 'Nom',
created: 'Cr\u00e9\u00e9 le',
created: 'Créé le',
back_to_printers: 'Retour aux imprimantes',
theme_label: 'Th\u00e8me',
theme_label: 'Thème',
lang_label: 'FR',
// Dashboard page
new_printer: 'Nouvelle imprimante',
upload_driver_btn: 'T\u00e9l\u00e9charger un pilote',
export_package: 'Exporter un paquet',
recent_activity: 'Activit\u00e9 r\u00e9cente',
recent_printers: 'Imprimantes r\u00e9centes',
recent_packages: 'Paquets r\u00e9cents',
no_packages: 'Aucun paquet export\u00e9 pour l\u2019instant.',
// Printers new page
// Shell
brand_sub: 'Paquets dimpression',
nav_library: 'Bibliothèque',
nav_deploy: 'Déploiement',
session_menu: 'Cette session',
session_download_key: 'Télécharger ma clé de secours',
session_restore_key: 'Restaurer depuis une clé',
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
open_menu: 'Ouvrir le menu',
// Dashboard
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
step_driver_title: 'Importer le pilote',
step_driver_desc: 'Un ZIP contenant le .inf et ses fichiers. ImpTune lit le .inf et détecte les modèles.',
step_printer_title: 'Configurer limprimante',
step_printer_desc: 'Nom, adresse IP, pilote et réglages par défaut du poste.',
step_package_title: 'Exporter le paquet',
step_package_desc: '.intunewin pour Intune, ZIP pour NinjaRMM. Scripts inclus.',
step_done: 'Fait',
go_upload_driver: 'Importer un pilote',
go_add_printer: 'Ajouter une imprimante',
go_export: 'Voir les paquets',
view_all: 'Tout voir',
recent_activity: 'Activité récente',
recent_printers: 'Imprimantes récentes',
recent_packages: 'Prêtes à exporter',
no_packages: 'Aucune imprimante prête à exporter.',
ready_to_export: 'Prête',
needs_driver: 'Pilote manquant',
// Printers new
add_printer_title: 'Ajouter une imprimante',
back_to_printer_library: '\u2190 Retour \u00e0 la biblioth\u00e8que',
save_printer: 'Enregistrer l\u2019imprimante',
upload_new_driver: 'T\u00e9l\u00e9charger un nouveau pilote',
no_driver_option: '-- Aucun pilote --',
unassigned_option: '-- Non assign\u00e9 --',
// Printer list table headers
back_to_printer_library: 'Imprimantes',
save_printer: 'Enregistrer limprimante',
upload_new_driver: 'Importer un nouveau pilote',
no_driver_option: 'Aucun pilote',
unassigned_option: 'Non assigné',
section_identity: 'Identification',
section_connection: 'Connexion réseau',
section_driver: 'Pilote',
section_defaults: 'Réglages par défaut',
hint_port: 'Dérivé automatiquement de ladresse IP. Modifiable.',
hint_name: 'Le nom vu par lutilisateur sur son poste.',
hint_driver: 'Sans pilote, limprimante est enregistrée mais lexport reste bloqué.',
hint_ip: 'Adresse IPv4 de limprimante sur le réseau du client.',
// Printer list
th_name: 'Nom',
th_ip: 'Adresse IP',
th_port: 'Port',
@@ -98,37 +137,58 @@
th_paper: 'Format',
th_collate: 'Assemblage',
th_actions: 'Actions',
th_status: 'État',
th_config: 'Réglages',
th_export: 'Export',
th_printers: 'Imprimantes',
th_used_by: 'Utilisé par',
th_size: 'Taille',
yes: 'Oui',
no: 'Non',
no_driver_assigned: '\u2014',
// Clients page
no_driver_assigned: '',
filter_printers: 'Filtrer par nom, IP ou client',
no_match: 'Aucune imprimante ne correspond au filtre.',
printers_intro: 'Une imprimante prête a un pilote assigné : elle peut être exportée.',
// Clients
client_name_label: 'Nom du client',
add_client_section: 'Ajouter un client',
client_list_section: 'Liste des clients',
// Client detail page
back_to_clients: '\u2190 Tous les clients',
clients_intro: 'Les clients regroupent les imprimantes par site ou par organisation.',
open_client: 'Ouvrir',
// Client detail
back_to_clients: 'Clients',
printers_section: 'Imprimantes',
// Drivers page
// Drivers
drivers_title: 'Pilotes',
upload_driver_section: 'T\u00e9l\u00e9charger un package de pilote',
driver_library: 'Biblioth\u00e8que de pilotes',
uploading: 'T\u00e9l\u00e9chargement...',
upload_btn: 'T\u00e9l\u00e9charger',
driver_filename: 'Nom du fichier',
upload_driver_section: 'Importer un package de pilote',
driver_library: 'Bibliothèque de pilotes',
uploading: 'Import en cours…',
upload_btn: 'Importer',
driver_filename: 'Fichier',
driver_names_col: 'Nom(s) du pilote',
architecture: 'Architecture',
uploaded_at: 'T\u00e9l\u00e9charg\u00e9 le',
uploaded_at: 'Importé le',
unknown: 'Inconnu',
no_drivers: 'Aucun pilote t\u00e9l\u00e9charg\u00e9.',
// Packages page
no_drivers: 'Aucun pilote importé.',
drivers_intro: 'Les pilotes sont partagés : tout le monde voit la même bibliothèque.',
driver_package_label: 'Package de pilote (ZIP)',
hint_driver_zip: 'Le ZIP doit contenir le fichier .inf et tous les fichiers auxquels il renvoie.',
unused_files: 'fichier(s) du ZIP ne sont pas référencés par le .inf.',
show_unused: 'Voir ces fichiers',
// Packages
packages_title: 'Paquets',
packages_description: 'Imprimantes avec pilotes assign\u00e9s \u2014 pr\u00eates pour l\u2019export.',
packages_description: 'Imprimantes avec un pilote assigné — prêtes pour lexport.',
printer_col: 'Imprimante',
client_col: 'Client',
driver_col: 'Pilote',
downloads_col: 'T\u00e9l\u00e9chargements',
no_packages_ready: 'Aucune imprimante pr\u00eate. Assignez un pilote pour activer l\u2019export.',
// Printer detail page
downloads_col: 'Téléchargements',
no_packages_ready: 'Aucune imprimante prête. Assignez un pilote pour activer lexport.',
format_intune: 'Intune',
format_ninja: 'NinjaRMM',
hint_intunewin: '.intunewin — à téléverser dans Intune comme application Win32.',
hint_ninja: 'ZIP — scripts en clair pour NinjaRMM ou exécution manuelle.',
pending_printers: 'imprimante(s) attendent encore un pilote',
// Printer detail
configuration: 'Configuration',
duplex_mode_label: 'Mode recto-verso',
color_mode_label: 'Mode couleur',
@@ -137,32 +197,41 @@
paper_size_label: 'Format papier',
collate_label: 'Assemblage',
client_label: 'Client',
unassigned: 'Non assign\u00e9',
unassigned: 'Non assigné',
driver_section: 'Pilote',
package_label: 'Package',
driver_names_label: 'Nom(s) du pilote',
architecture_label: 'Architecture',
no_driver_detail: 'Aucun pilote assign\u00e9',
no_driver_detail: 'Aucun pilote assigné.',
assign_driver_cta: 'Assigner un pilote',
intune_commands: 'Commandes Intune',
install_cmd_label: 'Commande d\u2019installation',
uninstall_cmd_label: 'Commande de d\u00e9sinstallation',
hint_intune_cmds: 'À coller dans les champs dinstallation et de désinstallation de lapplication Win32.',
install_cmd_label: 'Commande dinstallation',
uninstall_cmd_label: 'Commande de désinstallation',
copy: 'Copier',
copied: 'Copi\u00e9\u00a0!',
scripts_section: 'Scripts',
download_install: 'T\u00e9l\u00e9charger le script d\u2019installation',
download_uninstall: 'T\u00e9l\u00e9charger le script de d\u00e9sinstallation',
download_detect: 'T\u00e9l\u00e9charger le script de d\u00e9tection',
export_section: 'Export',
download_ninja: 'T\u00e9l\u00e9charger NinjaRMM ZIP',
download_intunewin: 'T\u00e9l\u00e9charger .intunewin',
icon_section: 'Ic\u00f4ne',
icon_uploaded: 'Ic\u00f4ne t\u00e9l\u00e9charg\u00e9e',
upload_icon: 'T\u00e9l\u00e9charger l\u2019ic\u00f4ne',
copied: 'Copié',
scripts_section: 'Scripts PowerShell',
download_install: 'install.ps1',
download_uninstall: 'uninstall.ps1',
download_detect: 'detect.ps1',
export_section: 'Déployer',
download_ninja: 'Télécharger le ZIP NinjaRMM',
download_intunewin: 'Télécharger le .intunewin',
icon_section: 'Icône',
icon_uploaded: 'Icône enregistrée',
upload_icon: 'Téléverser licône',
replace_icon: 'Remplacer licône',
no_icon: 'Aucune icône. Intune affichera licône par défaut.',
hint_icon: 'PNG, exactement 256 × 256, 750 Ko maximum.',
back_to_printers_btn: 'Retour aux imprimantes',
export_locked: 'Assignez un pilote à cette imprimante pour débloquer les scripts et lexport.',
// Edit modal
edit_printer_title: 'Modifier l\u2019imprimante',
// Driver upload section
driver_package_label: 'Package de pilote (ZIP contenant .inf + fichiers pilote)'
edit_printer_title: 'Modifier limprimante',
// Session restore
restore_title: 'Restaurer une clé de secours',
restore_desc: 'Collez la clé du fichier imptune-backup-key.txt pour retrouver vos imprimantes, réglages et clients dans ce navigateur.',
backup_key_label: 'Clé de secours',
restore_btn: 'Restaurer'
},
en: {
dashboard: 'Dashboard',
@@ -170,27 +239,27 @@
printers: 'Printers',
clients: 'Clients',
packages: 'Packages',
add_printer: 'Add Printer',
add_client: 'Add Client',
add_printer: 'Add printer',
add_client: 'Add client',
printer_library: 'Printer Library',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
upload_driver: 'Upload Driver',
printer_name: 'Printer Name',
ip_address: 'IP Address',
port_name: 'Port Name',
upload_driver: 'Add driver',
printer_name: 'Printer name',
ip_address: 'IP address',
port_name: 'Port name',
driver: 'Driver',
duplex_mode: 'Duplex Mode',
one_sided: 'One-Sided',
long_edge: 'Long Edge',
short_edge: 'Short Edge',
color_mode: 'Color Mode',
paper_size: 'Paper Size',
duplex_mode: 'Duplex mode',
one_sided: 'One-sided',
long_edge: 'Long edge',
short_edge: 'Short edge',
color_mode: 'Color',
paper_size: 'Paper size',
collate: 'Collate',
client: 'Client',
edit_printer: 'Edit Printer',
edit_printer: 'Edit printer',
no_printers: 'No printers configured yet.',
no_clients: 'No clients configured yet.',
client_list: 'Client List',
@@ -199,24 +268,53 @@
back_to_printers: 'Back to Printers',
theme_label: 'Theme',
lang_label: 'EN',
// Dashboard page
new_printer: 'New Printer',
upload_driver_btn: 'Upload Driver',
export_package: 'Export Package',
recent_activity: 'Recent Activity',
recent_printers: 'Recent Printers',
recent_packages: 'Recent Packages',
no_packages: 'No packages exported yet.',
// Printers new page
add_printer_title: 'Add Printer',
back_to_printer_library: '\u2190 Back to Printer Library',
save_printer: 'Save Printer',
upload_new_driver: 'Upload New Driver',
no_driver_option: '-- No driver --',
unassigned_option: '-- Unassigned --',
// Printer list table headers
// Shell
brand_sub: 'Print packaging',
nav_library: 'Library',
nav_deploy: 'Deploy',
session_menu: 'This session',
session_download_key: 'Download my backup key',
session_restore_key: 'Restore from a key',
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
open_menu: 'Open menu',
// Dashboard
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
step_driver_title: 'Add the driver',
step_driver_desc: 'A ZIP holding the .inf and its files. ImpTune reads the .inf and finds the models.',
step_printer_title: 'Configure the printer',
step_printer_desc: 'Name, IP address, driver, and the defaults the workstation gets.',
step_package_title: 'Export the package',
step_package_desc: '.intunewin for Intune, ZIP for NinjaRMM. Scripts included.',
step_done: 'Done',
go_upload_driver: 'Add a driver',
go_add_printer: 'Add a printer',
go_export: 'View packages',
view_all: 'View all',
recent_activity: 'Recent activity',
recent_printers: 'Recent printers',
recent_packages: 'Ready to export',
no_packages: 'No printers are ready to export yet.',
ready_to_export: 'Ready',
needs_driver: 'Driver needed',
// Printers new
add_printer_title: 'Add printer',
back_to_printer_library: 'Printers',
save_printer: 'Save printer',
upload_new_driver: 'Add a new driver',
no_driver_option: 'No driver',
unassigned_option: 'Unassigned',
section_identity: 'Identity',
section_connection: 'Network connection',
section_driver: 'Driver',
section_defaults: 'Print defaults',
hint_port: 'Derived from the IP address. Edit it if your naming differs.',
hint_name: 'The name the user sees on their workstation.',
hint_driver: 'Without a driver the printer is saved, but export stays locked.',
hint_ip: 'The printer IPv4 address on the client network.',
// Printer list
th_name: 'Name',
th_ip: 'IP Address',
th_ip: 'IP address',
th_port: 'Port',
th_driver: 'Driver',
th_duplex: 'Duplex',
@@ -224,71 +322,101 @@
th_paper: 'Paper',
th_collate: 'Collate',
th_actions: 'Actions',
th_status: 'Status',
th_config: 'Defaults',
th_export: 'Export',
th_printers: 'Printers',
th_used_by: 'Used by',
th_size: 'Size',
yes: 'Yes',
no: 'No',
no_driver_assigned: '\u2014',
// Clients page
client_name_label: 'Client Name',
add_client_section: 'Add Client',
client_list_section: 'Client List',
// Client detail page
back_to_clients: '\u2190 All Clients',
no_driver_assigned: '',
filter_printers: 'Filter by name, IP, or client',
no_match: 'No printer matches this filter.',
printers_intro: 'A printer with a driver assigned is ready to export.',
// Clients
client_name_label: 'Client name',
add_client_section: 'Add client',
client_list_section: 'Client list',
clients_intro: 'Clients group printers by site or organization.',
open_client: 'Open',
// Client detail
back_to_clients: 'Clients',
printers_section: 'Printers',
// Drivers page
// Drivers
drivers_title: 'Drivers',
upload_driver_section: 'Upload Driver Package',
driver_library: 'Driver Library',
uploading: 'Uploading...',
upload_driver_section: 'Add a driver package',
driver_library: 'Driver library',
uploading: 'Uploading',
upload_btn: 'Upload',
driver_filename: 'Filename',
driver_names_col: 'Driver Name(s)',
driver_filename: 'File',
driver_names_col: 'Driver name(s)',
architecture: 'Architecture',
uploaded_at: 'Uploaded',
uploaded_at: 'Added',
unknown: 'Unknown',
no_drivers: 'No drivers uploaded yet.',
// Packages page
drivers_intro: 'Drivers are shared — everyone sees the same library.',
driver_package_label: 'Driver package (ZIP)',
hint_driver_zip: 'The ZIP must contain the .inf file and every file it references.',
unused_files: 'file(s) in the ZIP are unused — the .inf never references them.',
show_unused: 'Show these files',
// Packages
packages_title: 'Packages',
packages_description: 'Printers with drivers assigned \u2014 ready for deployment package export.',
packages_description: 'Printers with a driver assigned ready for export.',
printer_col: 'Printer',
client_col: 'Client',
driver_col: 'Driver',
downloads_col: 'Downloads',
no_packages_ready: 'No package-ready printers yet. Assign a driver to a printer to enable package export.',
// Printer detail page
no_packages_ready: 'No printer is ready yet. Assign a driver to enable export.',
format_intune: 'Intune',
format_ninja: 'NinjaRMM',
hint_intunewin: '.intunewin — upload to Intune as a Win32 app.',
hint_ninja: 'ZIP — plain scripts for NinjaRMM or a manual run.',
pending_printers: 'printer(s) still need a driver',
// Printer detail
configuration: 'Configuration',
duplex_mode_label: 'Duplex Mode',
color_mode_label: 'Color Mode',
duplex_mode_label: 'Duplex mode',
color_mode_label: 'Color mode',
color_value: 'Color',
grayscale_value: 'Grayscale',
paper_size_label: 'Paper Size',
paper_size_label: 'Paper size',
collate_label: 'Collate',
client_label: 'Client',
unassigned: 'Unassigned',
driver_section: 'Driver',
package_label: 'Package',
driver_names_label: 'Driver Name(s)',
driver_names_label: 'Driver name(s)',
architecture_label: 'Architecture',
no_driver_detail: 'No driver assigned',
intune_commands: 'Intune Commands',
no_driver_detail: 'No driver assigned.',
assign_driver_cta: 'Assign a driver',
intune_commands: 'Intune commands',
hint_intune_cmds: 'Paste these into the Win32 app install and uninstall fields.',
install_cmd_label: 'Install command',
uninstall_cmd_label: 'Uninstall command',
copy: 'Copy',
copied: 'Copied!',
scripts_section: 'Scripts',
download_install: 'Download Install Script',
download_uninstall: 'Download Uninstall Script',
download_detect: 'Download Detect Script',
export_section: 'Export',
copied: 'Copied',
scripts_section: 'PowerShell scripts',
download_install: 'install.ps1',
download_uninstall: 'uninstall.ps1',
download_detect: 'detect.ps1',
export_section: 'Deploy',
download_ninja: 'Download NinjaRMM ZIP',
download_intunewin: 'Download .intunewin',
icon_section: 'Icon',
icon_uploaded: 'Icon uploaded',
upload_icon: 'Upload Icon',
back_to_printers_btn: 'Back to Printers',
icon_uploaded: 'Icon saved',
upload_icon: 'Upload icon',
replace_icon: 'Replace icon',
no_icon: 'No icon. Intune will show its default.',
hint_icon: 'PNG, exactly 256 × 256, 750 KB max.',
back_to_printers_btn: 'Back to printers',
export_locked: 'Assign a driver to this printer to unlock scripts and export.',
// Edit modal
edit_printer_title: 'Edit Printer',
// Driver upload section
driver_package_label: 'Driver Package (ZIP containing .inf + driver files)'
edit_printer_title: 'Edit printer',
// Session restore
restore_title: 'Restore a backup key',
restore_desc: 'Paste the key from imptune-backup-key.txt to get your printers, settings, and clients back in this browser.',
backup_key_label: 'Backup key',
restore_btn: 'Restore'
}
}
});
@@ -297,57 +425,107 @@
<script defer src="/static/alpine.min.js"></script>
<script src="/static/htmx.min.js"></script>
</head>
<body>
<body x-data="{ nav: false }" :class="nav && 'nav-open'">
<div class="layout">
<nav class="sidebar">
<div class="sidebar-brand">
<strong>ImpTune</strong>
<div class="nav-scrim" x-show="nav" @click="nav = false" style="display:none"></div>
<nav class="sidebar" @click="nav = false">
<a class="brand" href="/">
<span class="brand-mark">iT</span>
<span>
<span class="brand-name">ImpTune</span>
<span class="brand-sub" x-data x-text="$store.i18n.t('brand_sub')">Print packaging</span>
</span>
</a>
<div class="nav-group">
<ul class="sidebar-nav">
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>{{ ico.i('gauge') }}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span></a></li>
</ul>
</div>
<div class="nav-group">
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_library')">Library</div>
<ul class="sidebar-nav">
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>{{ ico.i('driver') }}<span x-data x-text="$store.i18n.t('drivers')">Drivers</span></a></li>
<li><a href="/printers" {% if request.url.path.startswith("/printers") %}class="active"{% endif %}>{{ ico.i('printer') }}<span x-data x-text="$store.i18n.t('printers')">Printers</span></a></li>
<li><a href="/clients" {% if request.url.path.startswith("/clients") %}class="active"{% endif %}>{{ ico.i('group') }}<span x-data x-text="$store.i18n.t('clients')">Clients</span></a></li>
</ul>
</div>
<div class="nav-group">
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_deploy')">Deploy</div>
<ul class="sidebar-nav">
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>{{ ico.i('package') }}<span x-data x-text="$store.i18n.t('packages')">Packages</span></a></li>
</ul>
</div>
<div class="sidebar-foot">
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
<div class="foot-links">
<a href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
<a href="/session/restore" x-data x-text="$store.i18n.t('session_restore_key')">Restore from a key</a>
<p class="foot-note" x-data x-text="$store.i18n.t('session_note')">Your printers live in this browser.</p>
</div>
</details>
</div>
<ul class="sidebar-nav">
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('drivers')">Drivers</a></li>
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('printers')">Printers</a></li>
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('clients')">Clients</a></li>
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
</ul>
<ul class="sidebar-nav sidebar-nav-footer">
<li><a href="/session/key/download">Download backup key</a></li>
<li><a href="/session/restore" {% if request.url.path == "/session/restore" %}class="active"{% endif %}>Restore from backup key</a></li>
</ul>
</nav>
<div class="main-wrapper">
<header class="topbar">
<div class="topbar-controls" x-data>
<button class="icon-btn sidebar-toggle" @click="nav = !nav"
x-data :aria-label="$store.i18n.t('open_menu')">{{ ico.i('menu', 18) }}</button>
<div class="topbar-title">
{% block crumb %}{% endblock %}
<h1>{% block page_title %}ImpTune{% endblock %}</h1>
</div>
<div class="topbar-actions" x-data>
{% block page_actions %}{% endblock %}
<span class="topbar-sep"></span>
<!-- Theme toggle button: cycles Light -> Dark -> System -->
<button class="secondary outline"
<button class="icon-btn"
x-html="$store.theme.icons[$store.theme.current]"
:aria-label="$store.theme.current"
@click="$store.theme.cycle()"
title="Toggle theme">&#9681;</button>
<!-- Language toggle button -->
<button class="secondary outline"
<button class="icon-btn"
x-text="$store.i18n.t('lang_label')"
@click="$store.i18n.toggle()"
title="Toggle language">FR</button>
</div>
</header>
<main class="main-content">
{% if request.state.ephemeral_session %}
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
dies with the browser window. Say it before work is lost. #}
<div class="notice session-ephemeral" id="ephemeral-session-warning">
{{ ico.i('key', 16) }}
<p x-data x-text="$store.i18n.t('ephemeral_warning')">This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.</p>
<a class="btn ghost" href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
</div>
{% endif %}
{% if request.state.is_new_owner %}
<dialog open id="session-choice-modal">
<article>
<h3>Keep your printers?</h3>
<p>Your printers, configs, and groups are private to this browser
(drivers stay shared with everyone). If you clear your cookies
without a backup key, they're gone for good.</p>
<footer>
<a role="button" href="/session/key/download">Store permanently (download backup key)</a>
<button class="secondary" onclick="this.closest('dialog').close()">Keep temporary, this browser only</button>
</footer>
<div class="dialog-head">
<h3>Keep your printers?</h3>
</div>
<div class="dialog-body">
<p>Your printers, configs, and clients are private to this browser —
drivers stay shared with everyone. Clear your cookies without a backup
key and they are gone for good.</p>
{% if request.state.ephemeral_session %}
<p><strong>This server runs without HTTPS</strong>, so this session is
kept in memory only: closing the browser ends it. The backup key is the
only way back in.</p>
{% endif %}
</div>
<div class="dialog-foot">
<button class="btn ghost" onclick="this.closest('dialog').close()">Keep in this browser</button>
<a role="button" class="btn" href="/session/key/download">Download backup key</a>
</div>
</article>
</dialog>
{% endif %}
+24 -6
View File
@@ -1,11 +1,29 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}{{ client.name }} &middot; ImpTune{% endblock %}
{% block crumb %}
<span class="crumb" x-data>
<a href="/clients">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_clients')">Clients</span></a>
</span>
{% endblock %}
{% block page_title %}{{ client.name }}{% endblock %}
{% block page_actions %}
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
{% endblock %}
{% block content %}
<h1>{{ client.name }}</h1>
<p><a href="/clients" x-data x-text="$store.i18n.t('back_to_clients')">&larr; All Clients</a></p>
<div class="toolbar">
<h2 class="eyebrow" x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
<span class="spacer"></span>
{% if printer_count %}
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }}&nbsp;<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% if printer_count - ready_count %}
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }}&nbsp;<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
{% endif %}
</div>
<section>
<h2 x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
{% include "partials/printer_list.html" %}
</section>
{% include "partials/printer_list.html" %}
{% endblock %}
+30 -15
View File
@@ -1,21 +1,36 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Clients &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('clients')">Clients</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('clients')">Clients</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
<section>
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add Client</h2>
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML">
<label>
<span x-data x-text="$store.i18n.t('client_name_label')">Client Name</span>
<input type="text" name="name" placeholder="e.g. Contoso" required>
</label>
<button type="submit" x-data x-text="$store.i18n.t('add_client')">Add Client</button>
</form>
</section>
<div class="split">
<div>
{% include "partials/client_list.html" %}
</div>
<section>
<h2 x-data x-text="$store.i18n.t('client_list_section')">Client List</h2>
{% include "partials/client_list.html" %}
</section>
<section class="card">
<div class="card-head">
{{ ico.i('plus') }}
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add client</h2>
</div>
<div class="card-body">
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()">
<label>
<span class="label-text" x-data x-text="$store.i18n.t('client_name_label')">Client name</span>
<input type="text" name="name" placeholder="Contoso" required>
</label>
<div class="btn-row" style="margin-top:.8rem">
<button type="submit" class="btn" x-data>
{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_client')">Add client</span>
</button>
</div>
</form>
</div>
</section>
</div>
{% endblock %}
+139 -33
View File
@@ -1,41 +1,147 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span>{% endblock %}
{% block page_actions %}
<a href="/drivers" class="btn ghost sm">{{ ico.i('upload') }}<span x-text="$store.i18n.t('upload_driver')">Add driver</span></a>
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('dashboard')">Dashboard</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('dashboard_intro')">Three steps from driver ZIP to deployable package.</p>
<div class="quick-actions">
<a href="/printers" class="btn-action" x-data x-text="$store.i18n.t('new_printer')">New Printer</a>
<a href="/drivers" class="btn-action" x-data x-text="$store.i18n.t('upload_driver_btn')">Upload Driver</a>
<a href="/packages" class="btn-action" x-data x-text="$store.i18n.t('export_package')">Export Package</a>
{#
The rail is the spine of the tool: a printer cannot be exported before it has
a driver, so the order is real and the state of each stage is worth showing.
#}
{% set s1 = 'done' if driver_count else 'next' %}
{% set s2 = 'done' if printer_count else ('next' if driver_count else '') %}
{% set s3 = 'done' if ready_count else ('next' if printer_count else '') %}
<div class="rail">
<a class="rail-step {{ s1 }}" href="/drivers">
<div class="rail-top">
<span class="rail-num">{% if s1 == 'done' %}{{ ico.i('check', 13) }}{% else %}1{% endif %}</span>
<span class="rail-title" x-data x-text="$store.i18n.t('step_driver_title')">Add the driver</span>
<span class="rail-count">{{ driver_count }}</span>
</div>
<p class="rail-desc" x-data x-text="$store.i18n.t('step_driver_desc')">A ZIP holding the .inf and its files.</p>
<span class="rail-cta" x-data>
{% if s1 == 'done' %}<span x-text="$store.i18n.t('drivers')">Drivers</span>{{ ico.i('chevron', 13) }}
{% else %}<span x-text="$store.i18n.t('go_upload_driver')">Add a driver</span>{{ ico.i('chevron', 13) }}{% endif %}
</span>
</a>
<a class="rail-step {{ s2 }}" href="{% if printer_count %}/printers{% else %}/printers/new{% endif %}">
<div class="rail-top">
<span class="rail-num">{% if s2 == 'done' %}{{ ico.i('check', 13) }}{% else %}2{% endif %}</span>
<span class="rail-title" x-data x-text="$store.i18n.t('step_printer_title')">Configure the printer</span>
<span class="rail-count">{{ printer_count }}</span>
</div>
<p class="rail-desc" x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
<span class="rail-cta" x-data>
{% if s2 == 'done' %}<span x-text="$store.i18n.t('printers')">Printers</span>{{ ico.i('chevron', 13) }}
{% else %}<span x-text="$store.i18n.t('go_add_printer')">Add a printer</span>{{ ico.i('chevron', 13) }}{% endif %}
</span>
</a>
<a class="rail-step {{ s3 }}" href="/packages">
<div class="rail-top">
<span class="rail-num">{% if s3 == 'done' %}{{ ico.i('check', 13) }}{% else %}3{% endif %}</span>
<span class="rail-title" x-data x-text="$store.i18n.t('step_package_title')">Export the package</span>
<span class="rail-count">{{ ready_count }}</span>
</div>
<p class="rail-desc" x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM.</p>
<span class="rail-cta" x-data>
<span x-text="$store.i18n.t('go_export')">View packages</span>{{ ico.i('chevron', 13) }}
</span>
</a>
</div>
<section class="recent-activity">
<h2 x-data x-text="$store.i18n.t('recent_activity')">Recent Activity</h2>
<div class="split">
<section class="card card-flush">
<div class="card-head">
<h2 x-data x-text="$store.i18n.t('recent_printers')">Recent printers</h2>
{% if recent_printers %}
<span class="head-actions">
<a href="/printers" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
</span>
{% endif %}
</div>
<div class="card-body">
{% if recent_printers %}
<div class="table-scroll">
<table class="data-table">
<tbody>
{% for printer in recent_printers %}
<tr>
<td>
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
<span class="cell-sub">{{ printer.ip_address }}{% if printer.client_id %} &middot; {{ printer.client.name }}{% endif %}</span>
</td>
<td class="actions">
{% if printer.driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
</div>
{% endif %}
</div>
</section>
<div class="activity-section">
<h3 x-data x-text="$store.i18n.t('recent_printers')">Recent Printers</h3>
{% if recent_printers %}
<ul>
{% for printer in recent_printers %}
<li><a href="/printers/{{ printer.id }}">{{ printer.name }}</a> — {{ printer.ip_address }}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
{% endif %}
</div>
<div class="activity-section">
<h3 x-data x-text="$store.i18n.t('recent_packages')">Recent Packages</h3>
{% if recent_packages %}
<ul>
{% for package in recent_packages %}
<li><a href="/printers/{{ package.id }}">{{ package.name }}</a>{% if package.client_id %}{{ package.client.name }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages')">No packages exported yet.</p>
{% endif %}
</div>
</section>
<section class="card card-flush">
<div class="card-head">
<h2 x-data x-text="$store.i18n.t('recent_packages')">Ready to export</h2>
{% if recent_packages %}
<span class="head-actions">
<a href="/packages" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
</span>
{% endif %}
</div>
<div class="card-body">
{% if recent_packages %}
<div class="table-scroll">
<table class="data-table">
<tbody>
{% for package in recent_packages %}
<tr>
<td>
<a class="cell-name" href="/printers/{{ package.id }}">{{ package.name }}</a>
<span class="cell-sub">{% if package.client_id %}{{ package.client.name }}{% else %}&mdash;{% endif %}</span>
</td>
<td class="actions">
<span class="btn-row">
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_packages')">No printers are ready to export yet.</strong>
<p x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</p>
</div>
{% endif %}
</div>
</section>
</div>
{% endblock %}
+37 -19
View File
@@ -1,25 +1,43 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Drivers &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('drivers_title')">Drivers</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('drivers_title')">Drivers</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('drivers_intro')">Drivers are shared — everyone sees the same library.</p>
<section>
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Upload Driver Package</h2>
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<label for="driver-file" x-data x-text="$store.i18n.t('driver_package_label')">Driver Package (ZIP containing .inf + driver files)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<button type="submit" x-data x-text="$store.i18n.t('upload_btn')">Upload</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true" x-data x-text="$store.i18n.t('uploading')">Uploading...</span>
</form>
<section class="card">
<div class="card-head">
{{ ico.i('upload') }}
<div>
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Add a driver package</h2>
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
</div>
</div>
<div class="card-body">
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<div class="uploader">
<label for="driver-file" class="label-text" x-data x-text="$store.i18n.t('driver_package_label')">Driver package (ZIP)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<div class="btn-row">
<button type="submit" class="btn" x-data>
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true"
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
</div>
</div>
</form>
</div>
</section>
<section>
<h2 x-data x-text="$store.i18n.t('driver_library')">Driver Library</h2>
{% include "partials/driver_list.html" %}
</section>
<h2 class="eyebrow" style="margin-bottom:.6rem" x-data x-text="$store.i18n.t('driver_library')">Driver library</h2>
{% include "partials/driver_list.html" %}
{% endblock %}
+71 -17
View File
@@ -1,37 +1,91 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Packages &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('packages_title')">Packages</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('packages_title')">Packages</h1>
<p x-data x-text="$store.i18n.t('packages_description')">Printers with drivers assigned — ready for deployment package export.</p>
<p class="page-intro" x-data x-text="$store.i18n.t('packages_description')">Printers with a driver assigned — ready for export.</p>
{% if printers %}
<figure>
<table role="grid">
<div class="card-grid">
<div class="card">
<div class="card-head">
{{ ico.i('package') }}
<h2 class="mono">.intunewin</h2>
</div>
<div class="card-body">
<p class="dim" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</p>
</div>
</div>
<div class="card">
<div class="card-head">
{{ ico.i('script') }}
<h2 class="mono">.zip</h2>
</div>
<div class="card-body">
<p class="dim" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</p>
</div>
</div>
</div>
<div class="card card-flush">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col" x-data x-text="$store.i18n.t('printer_col')">Printer</th>
<th scope="col" x-data x-text="$store.i18n.t('client_col')">Client</th>
<th scope="col" x-data x-text="$store.i18n.t('driver_col')">Driver</th>
<th scope="col" x-data x-text="$store.i18n.t('icon_section')">Icon</th>
<th scope="col" x-data x-text="$store.i18n.t('downloads_col')">Downloads</th>
</tr>
</thead>
<tbody>
{% for printer in printers %}
<tr>
<td><a href="/printers/{{ printer.id }}">{{ printer.name }}</a></td>
<td>{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</td>
<td>{{ printer.driver.original_filename }}</td>
<td>
<a href="/printers/{{ printer.id }}/packages/intunewin">.intunewin</a>
&nbsp;|&nbsp;
<a href="/printers/{{ printer.id }}/packages/ninja">NinjaRMM ZIP</a>
</td>
</tr>
<tr>
<td>
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
<span class="cell-sub">{% if printer.client_id %}{{ printer.client.name }}{% else %}&mdash;{% endif %}</span>
</td>
<td>
<span class="mono">{{ printer.driver.original_filename }}</span>
{% if printer.driver.architecture %}<span class="cell-sub">{{ printer.driver.architecture }}</span>{% endif %}
</td>
<td>
{% if printer.id in icon_printer_ids %}
<img src="/printers/{{ printer.id }}/icon" width="28" height="28" alt=""
style="border-radius:5px;vertical-align:middle">
{% else %}
<span class="faint">&mdash;</span>
{% endif %}
</td>
<td class="actions">
<span class="btn-row">
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
</div>
</div>
{% if pending_count %}
<p class="hint" style="margin-top:.9rem" x-data>
<a href="/printers">{{ pending_count }} <span x-text="$store.i18n.t('pending_printers')">printers still need a driver</span></a>
</p>
{% endif %}
{% else %}
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages_ready')">No package-ready printers yet. Assign a driver to a printer to enable package export.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</strong>
<p x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM. Scripts included.</p>
<a href="/printers" class="btn sm" x-data>{{ ico.i('printer', 14) }}<span x-text="$store.i18n.t('printers')">Printers</span></a>
</div>
</div>
{% endif %}
{% endblock %}
+40 -17
View File
@@ -1,22 +1,45 @@
{% import "partials/icons.html" as ico %}
{% set counts = counts | default({}, true) %}
<div id="client-list">
{% if not clients %}
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('group', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</strong>
<p x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
</div>
</div>
{% else %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('created')">Created</th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
<td>{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card card-flush">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_printers')">Printers</th>
<th x-data x-text="$store.i18n.t('created')">Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><a class="cell-name" href="/clients/{{ c.id }}">{{ c.name }}</a></td>
<td class="num">
{% set n = counts.get(c.id, 0) %}
{% if n %}{{ n }}{% else %}<span class="faint">0</span>{% endif %}
</td>
<td class="num">{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
<td class="actions">
<a class="btn quiet sm" href="/clients/{{ c.id }}" x-data>
<span x-text="$store.i18n.t('open_client')">Open</span>{{ ico.i('chevron', 13) }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
+80 -35
View File
@@ -1,50 +1,95 @@
{% import "partials/icons.html" as ico %}
{% set usage = usage | default({}, true) %}
{% set new_driver_id = new_driver_id | default(None, true) %}
<div id="driver-list">
{% if parsed is defined and parsed.unused_files %}
<p class="notice">
{{ parsed.unused_files | length }} file(s) may be unused (not referenced by the INF):
<div class="notice">
<p>
{{ parsed.unused_files | length }}
<span x-data x-text="$store.i18n.t('unused_files')">file(s) in the ZIP are unused — the .inf never references them.</span>
</p>
<details>
<summary>Show unused files</summary>
<summary x-data x-text="$store.i18n.t('show_unused')">Show unused files</summary>
<ul>
{% for f in parsed.unused_files %}
<li>{{ f }}</li>
{% endfor %}
</ul>
</details>
</p>
</div>
{% endif %}
{% if driver_data %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('driver_filename')">Filename</th>
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver Name(s)</th>
<th x-data x-text="$store.i18n.t('architecture')">Architecture</th>
<th x-data x-text="$store.i18n.t('uploaded_at')">Uploaded</th>
</tr>
</thead>
<tbody>
{% for item in driver_data %}
<tr>
<td>{{ item.driver.original_filename }}</td>
<td>
{% if item.names %}
<select aria-label="Driver names">
{% for name in item.names %}
<option>{{ name }}</option>
{% endfor %}
</select>
{% else %}
<em x-data x-text="$store.i18n.t('unknown')">Unknown</em>
{% endif %}
</td>
<td>{% if item.driver.architecture %}{{ item.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</td>
<td>{{ item.driver.uploaded_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card card-flush">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th x-data x-text="$store.i18n.t('driver_filename')">File</th>
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver name(s)</th>
<th class="col-arch" x-data x-text="$store.i18n.t('architecture')">Architecture</th>
<th class="col-used" x-data x-text="$store.i18n.t('th_used_by')">Used by</th>
<th class="col-added" x-data x-text="$store.i18n.t('uploaded_at')">Added</th>
</tr>
</thead>
<tbody>
{% for item in driver_data %}
<tr>
<td>
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
<span class="cell-sub">
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
{% if item.driver.inf_filename %} &middot; {{ item.driver.inf_filename }}{% endif %}
</span>
</td>
<td>
{% if item.names %}
{# Multi-model INFs can carry a dozen names — show three, fold the rest. #}
<span class="pill-row">
{% for name in item.names[:3] %}<span class="pill">{{ name }}</span>{% endfor %}
{% if item.names | length > 3 %}
<details class="more-pills">
<summary class="pill">+{{ item.names | length - 3 }}</summary>
<span class="pill-row">
{% for name in item.names[3:] %}<span class="pill">{{ name }}</span>{% endfor %}
</span>
</details>
{% endif %}
</span>
{% else %}
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
{% endif %}
</td>
<td class="col-arch">
{% if item.driver.architecture %}
<span class="badge">{{ item.driver.architecture }}</span>
{% else %}
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
{% endif %}
</td>
<td class="num col-used">
{% set n = usage.get(item.driver.id, 0) %}
{% if n %}<a href="/printers">{{ n }}</a>{% else %}<span class="faint">0</span>{% endif %}
</td>
<td class="num col-added">
{{ item.driver.uploaded_at.strftime('%Y-%m-%d %H:%M') if item.driver.uploaded_at is not string else item.driver.uploaded_at }}
{% if new_driver_id and item.driver.id == new_driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}new</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</strong>
<p x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
</div>
</div>
{% endif %}
</div>
@@ -1,11 +1,12 @@
{% include "partials/driver_list.html" %}
{# Out-of-band: refresh the printer form's driver picker with the new driver chosen. #}
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
<option value="">-- No driver --</option>
<option value="" x-data x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if item.driver.id == new_driver_id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option>
{% endfor %}
</select>
+62
View File
@@ -0,0 +1,62 @@
{#
Inline SVG icon set — no external files, no text nodes.
Text-node-free on purpose: E2E tests read `textContent` of nav links to assert
the translated label, so icons must contribute no characters.
Usage: {% import "partials/icons.html" as ico %} → {{ ico.i('printer') }}
#}
{% macro i(name, size=16) -%}
{%- set s = size -%}
<svg class="ico" width="{{ s }}" height="{{ s }}" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true" focusable="false">
{%- if name == 'gauge' -%}
<path d="M12 21a9 9 0 1 0-9-9"/><path d="M3 12h2"/><path d="M19 12h2"/><path d="M12 3v2"/><path d="m12 12 4.5-3.5"/><circle cx="12" cy="12" r="1.6"/>
{%- elif name == 'driver' -%}
<path d="M3 8.5 12 4l9 4.5-9 4.5-9-4.5Z"/><path d="M3 8.5v7L12 20l9-4.5v-7"/><path d="M12 13v7"/>
{%- elif name == 'printer' -%}
<path d="M6 9V4h12v5"/><rect x="3" y="9" width="18" height="7" rx="1.5"/><path d="M7 16h10v4H7z"/><circle cx="17.5" cy="12" r=".8" fill="currentColor" stroke="none"/>
{%- elif name == 'group' -%}
<path d="M4 20v-1.5A3.5 3.5 0 0 1 7.5 15h3A3.5 3.5 0 0 1 14 18.5V20"/><circle cx="9" cy="8.5" r="3"/><path d="M16 15.5h.5A3.5 3.5 0 0 1 20 19v1"/><circle cx="17" cy="9.5" r="2.2"/>
{%- elif name == 'package' -%}
<path d="M4 7.5 12 4l8 3.5v9L12 20l-8-3.5v-9Z"/><path d="M4 7.5 12 11l8-3.5"/><path d="M12 11v9"/><path d="M8 5.7 16 9.2"/>
{%- elif name == 'key' -%}
<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8"/><path d="m15.5 7.5 2 2"/><path d="m18 5 2 2"/>
{%- elif name == 'upload' -%}
<path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
{%- elif name == 'download' -%}
<path d="M12 4v12"/><path d="m7.5 11.5 4.5 4.5 4.5-4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
{%- elif name == 'plus' -%}
<path d="M12 5v14"/><path d="M5 12h14"/>
{%- elif name == 'search' -%}
<circle cx="11" cy="11" r="6"/><path d="m15.5 15.5 4 4"/>
{%- elif name == 'pencil' -%}
<path d="M4 20h4l10-10-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>
{%- elif name == 'trash' -%}
<path d="M4 7h16"/><path d="M9 7V4.5h6V7"/><path d="M6 7v11.5A1.5 1.5 0 0 0 7.5 20h9a1.5 1.5 0 0 0 1.5-1.5V7"/><path d="M10 11v5"/><path d="M14 11v5"/>
{%- elif name == 'check' -%}
<path d="m5 12.5 4.5 4.5L19 7.5"/>
{%- elif name == 'alert' -%}
<path d="M12 4.5 21 19.5H3L12 4.5Z"/><path d="M12 10v4"/><circle cx="12" cy="16.8" r=".9" fill="currentColor" stroke="none"/>
{%- elif name == 'copy' -%}
<rect x="9" y="9" width="11" height="11" rx="1.5"/><path d="M15 6.5V5.5A1.5 1.5 0 0 0 13.5 4h-8A1.5 1.5 0 0 0 4 5.5v8A1.5 1.5 0 0 0 5.5 15h1"/>
{%- elif name == 'terminal' -%}
<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7.5 10 2.5 2.5-2.5 2.5"/><path d="M12.5 15h4"/>
{%- elif name == 'script' -%}
<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/><path d="M10 12h5"/><path d="M10 16h5"/>
{%- elif name == 'image' -%}
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4.5 18 4.5-4.5 3.5 3.5 3-2.5 4 3.5"/>
{%- elif name == 'menu' -%}
<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>
{%- elif name == 'chevron' -%}
<path d="m8.5 5.5 6 6.5-6 6.5"/>
{%- elif name == 'back' -%}
<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>
{%- elif name == 'link' -%}
<path d="M10 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1"/><path d="M14 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1"/>
{%- elif name == 'network' -%}
<rect x="3.5" y="14.5" width="6" height="5" rx="1"/><rect x="14.5" y="14.5" width="6" height="5" rx="1"/><rect x="9" y="4.5" width="6" height="5" rx="1"/><path d="M12 9.5v3H6.5v2"/><path d="M12 12.5h5.5v2"/>
{%- endif -%}
</svg>
{%- endmacro %}
@@ -1,102 +1,122 @@
<!-- Edit trigger button — placed in Actions column -->
<button class="secondary outline"
{% import "partials/icons.html" as ico %}
<!-- Edit trigger — sits in the row's Actions cell -->
<button class="btn ghost sm"
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
x-data x-text="$store.i18n.t('edit')">
Edit
x-data>
{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('edit')">Edit</span>
</button>
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
<!-- Edit dialog — native <dialog>, no extra library -->
<dialog id="edit-modal-{{ p.id }}">
<article>
<header>
<button aria-label="Close" rel="prev"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
<h3 x-data x-text="$store.i18n.t('edit_printer_title')">Edit Printer</h3>
</header>
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
<form hx-patch="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
<label><span x-text="$store.i18n.t('printer_name')">Printer Name</span>
<input type="text" name="name" value="{{ p.name }}" required>
</label>
<div class="dialog-head">
<h3 x-text="$store.i18n.t('edit_printer_title')">Edit printer</h3>
{# No aria-label="Close": Pico hooks that attribute to draw its own
floated X icon, which fights this button's glyph and placement. #}
<button type="button" class="icon-btn close"
:aria-label="$store.i18n.t('cancel')"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()">&times;</button>
</div>
<label><span x-text="$store.i18n.t('ip_address')">IP Address</span>
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
required>
</label>
<div class="dialog-body">
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
<label>
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
<input type="text" name="name" value="{{ p.name }}" required>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if p.client_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<label><span x-text="$store.i18n.t('port_name')">Port Name</span>
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true">
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
<input type="text" name="ip_address" class="mono"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
required>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
<input type="text" name="port_name" class="mono"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true">
</label>
</div>
</fieldset>
<label><span x-text="$store.i18n.t('driver')">Driver</span>
<select name="driver_id">
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if p.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
<label>
<select name="driver_id">
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if p.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option>
{% endfor %}
</select>
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
</label>
</fieldset>
<label><span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
<select name="duplex_mode">
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-Sided</option>
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long Edge</option>
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short Edge</option>
</select>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
<select name="duplex_mode">
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-sided</option>
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long edge</option>
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short edge</option>
</select>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
<select name="paper_size">
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
</div>
<div class="check-row">
<label>
<input type="checkbox" name="color_mode" value="on" {% if p.color_mode %}checked{% endif %}>
<span x-text="$store.i18n.t('color_mode')">Color</span>
</label>
<label>
<input type="checkbox" name="collate" value="on" {% if p.collate %}checked{% endif %}>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
</div>
</fieldset>
</div>
<label>
<input type="checkbox" name="color_mode" value="on"
{% if p.color_mode %}checked{% endif %}>
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
</label>
<label><span x-text="$store.i18n.t('paper_size')">Paper Size</span>
<select name="paper_size">
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on"
{% if p.collate %}checked{% endif %}>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
<label><span x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if p.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
<footer>
<button type="submit" x-text="$store.i18n.t('save')">Save</button>
<button type="button" class="secondary"
<div class="dialog-foot">
<button type="button" class="btn ghost"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
x-text="$store.i18n.t('cancel')">
Cancel
</button>
</footer>
x-text="$store.i18n.t('cancel')">Cancel</button>
<button type="submit" class="btn" x-text="$store.i18n.t('save')">Save</button>
</div>
</form>
</div>
</article>
@@ -1,99 +0,0 @@
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}', port: '{{ printer.port_name if printer else '' }}', portEdited: {{ 'true' if printer else 'false' }} }">
<form hx-post="/printers" hx-target="#printer-list" hx-swap="outerHTML">
<label>
Printer Name
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required
value="{{ printer.name if printer else '' }}">
</label>
<label>
IP Address
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<label>
Port Name
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<label>
Driver
<select name="driver_id" id="printer-form-driver-select">
<option value="">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}"
{% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
Duplex Mode
<select name="duplex_mode">
<option value="OneSided" {% if not printer or printer.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
<option value="LongEdge" {% if printer and printer.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
<option value="ShortEdge" {% if printer and printer.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on"
{% if not printer or printer.color_mode %}checked{% endif %}>
Color Mode
</label>
<label>
Paper Size
<select name="paper_size">
<option value="A4" {% if not printer or printer.paper_size == 'A4' %}selected{% endif %}>A4</option>
<option value="Letter" {% if printer and printer.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
<option value="Legal" {% if printer and printer.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on"
{% if not printer or printer.collate %}checked{% endif %}>
Collate
</label>
<label>
Client
<select name="client_id">
<option value="">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}"
{% if printer and printer.client_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
<button type="submit">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
Upload New Driver
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary">Upload Driver</button>
</form>
<div id="driver-list" style="display:none"></div>
</div>
+91 -50
View File
@@ -1,57 +1,98 @@
{% import "partials/icons.html" as ico %}
<div id="printer-list">
{% if not grouped %}
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
<div class="card">
<div class="empty">
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and the defaults the workstation gets.</p>
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
</div>
</div>
{% else %}
{% for client_name, printers in grouped.items() %}
<section>
{% set group_client_id = printers[0].client_id if printers else None %}
{% if group_client_id %}
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
{% else %}
<h3>{{ client_name }}</h3>
{% endif %}
<table>
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_ip')">IP Address</th>
<th x-data x-text="$store.i18n.t('th_port')">Port</th>
<th x-data x-text="$store.i18n.t('th_driver')">Driver</th>
<th x-data x-text="$store.i18n.t('th_duplex')">Duplex</th>
<th x-data x-text="$store.i18n.t('th_color')">Color</th>
<th x-data x-text="$store.i18n.t('th_paper')">Paper</th>
<th x-data x-text="$store.i18n.t('th_collate')">Collate</th>
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
</tr>
</thead>
<tbody>
{% for p in printers %}
<tr>
<td><a href="/printers/{{ p.id }}">{{ p.name }}</a></td>
<td>{{ p.ip_address }}</td>
<td>{{ p.port_name }}</td>
<td>{{ p.driver.original_filename if p.driver_id else '—' }}</td>
<td>{{ p.duplex_mode }}</td>
<td x-data="{ val: {{ 'true' if p.color_mode else 'false' }} }"
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.color_mode else 'No' }}</td>
<td>{{ p.paper_size }}</td>
<td x-data="{ val: {{ 'true' if p.collate else 'false' }} }"
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.collate else 'No' }}</td>
<td>
{% include "partials/printer_edit_modal.html" %}
<button
hx-delete="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete '{{ p.name }}'?"
x-data x-text="$store.i18n.t('delete')">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% set group_client_id = printers[0].client_id if printers else None %}
{# Group search text must cover everything its rows match on, or a row can
match while its group stays hidden. #}
{% set gsearch = (printers | map(attribute='name') | join(' ')) ~ ' '
~ (printers | map(attribute='ip_address') | join(' ')) ~ ' '
~ (printers | map(attribute='port_name') | join(' ')) ~ ' '
~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' '
~ client_name %}
<section class="card card-flush"
data-search="{{ gsearch | lower }}"
x-data
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
<div class="group-head">
{{ ico.i('group') }}
{% if group_client_id %}
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
{% else %}
<h3 x-data x-text="$store.i18n.t('unassigned')">{{ client_name }}</h3>
{% endif %}
<span class="count">{{ printers | length }}</span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
<th x-data x-text="$store.i18n.t('th_status')">Status</th>
<th class="col-defaults" x-data x-text="$store.i18n.t('th_config')">Defaults</th>
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
</tr>
</thead>
<tbody>
{% for p in printers %}
<tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename if p.driver_id else '')) | lower }}"
x-data
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
<td>
<a class="cell-name" href="/printers/{{ p.id }}">{{ p.name }}</a>
<span class="cell-sub">{{ p.ip_address }} &middot; {{ p.port_name }}</span>
</td>
<td>
{% if p.driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
<span class="cell-sub">{{ p.driver.original_filename }}</span>
{% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
</td>
<td class="col-defaults">
<span class="pill-row">
<span class="pill" x-data
x-text="$store.i18n.t('{{ 'one_sided' if p.duplex_mode == 'OneSided' else ('long_edge' if p.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ p.duplex_mode }}</span>
<span class="pill" x-data
x-text="$store.i18n.t('{{ 'color_value' if p.color_mode else 'grayscale_value' }}')">{{ 'Color' if p.color_mode else 'Grayscale' }}</span>
<span class="pill">{{ p.paper_size }}</span>
{% if p.collate %}
<span class="pill" x-data x-text="$store.i18n.t('collate')">Collate</span>
{% endif %}
</span>
</td>
<td class="actions">
<span class="btn-row">
{% if p.driver_id %}
<a class="btn quiet sm" href="/printers/{{ p.id }}/packages/intunewin"
title=".intunewin">{{ ico.i('download', 14) }}</a>
{% endif %}
{% include "partials/printer_edit_modal.html" %}
<button class="btn danger sm"
hx-delete="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete '{{ p.name }}'?"
x-data :title="$store.i18n.t('delete')">
{{ ico.i('trash', 14) }}
</button>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endfor %}
{% endif %}
+204 -77
View File
@@ -1,82 +1,209 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ printer.name }}</h1>
<article>
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
<dl>
<dt x-data x-text="$store.i18n.t('ip_address')">IP Address</dt><dd>{{ printer.ip_address }}</dd>
<dt x-data x-text="$store.i18n.t('port_name')">Port Name</dt><dd>{{ printer.port_name }}</dd>
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex Mode</dt><dd>{{ printer.duplex_mode }}</dd>
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color Mode</dt>
{% if printer.color_mode %}<dd x-data x-text="$store.i18n.t('color_value')">Color</dd>{% else %}<dd x-data x-text="$store.i18n.t('grayscale_value')">Grayscale</dd>{% endif %}
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper Size</dt><dd>{{ printer.paper_size }}</dd>
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
{% if printer.collate %}<dd x-data x-text="$store.i18n.t('yes')">Yes</dd>{% else %}<dd x-data x-text="$store.i18n.t('no')">No</dd>{% endif %}
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
{% if printer.client_id %}<dd>{{ printer.client.name }}</dd>{% else %}<dd x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>{% endif %}
</dl>
{% import "partials/icons.html" as ico %}
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
{% if printer.driver_id %}
<dl>
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt><dd>{{ printer.driver.original_filename }}</dd>
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver Name(s)</dt><dd>{{ driver_names | join(", ") }}</dd>
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
<dd>{% if printer.driver.architecture %}{{ printer.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
</dl>
{% else %}
<p x-data x-text="$store.i18n.t('no_driver_detail')">No driver assigned</p>
{% endif %}
{% if has_driver %}
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune Commands</h2>
<div x-data="{ copiedInstall: false }">
<label x-text="$store.i18n.t('install_cmd_label')">Install command</label>
<code id="install-cmd">{{ install_cmd }}</code>
<button @click="
const text = document.getElementById('install-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
{% block head_title %}{{ printer.name }} &middot; ImpTune{% endblock %}
{% block crumb %}
<span class="crumb" x-data>
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
{% if printer.client_id %}<span>/</span><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a>{% endif %}
</span>
{% endblock %}
{% block page_title %}{{ printer.name }}{% endblock %}
{% block page_actions %}
{% if has_driver %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
{% endblock %}
{% block content %}
<div class="split">
<!-- Left: what this printer is -->
<div>
<section class="card">
<div class="card-head">
{{ ico.i('network') }}
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
</div>
<div class="card-body">
<dl class="kv">
<dt x-data x-text="$store.i18n.t('ip_address')">IP address</dt>
<dd class="mono-val">{{ printer.ip_address }}</dd>
<dt x-data x-text="$store.i18n.t('port_name')">Port name</dt>
<dd class="mono-val">{{ printer.port_name }}</dd>
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
{% if printer.client_id %}
<dd><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a></dd>
{% else %}
<dd class="faint" x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>
{% endif %}
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex mode</dt>
<dd x-data x-text="$store.i18n.t('{{ 'one_sided' if printer.duplex_mode == 'OneSided' else ('long_edge' if printer.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ printer.duplex_mode }}</dd>
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color mode</dt>
<dd x-data x-text="$store.i18n.t('{{ 'color_value' if printer.color_mode else 'grayscale_value' }}')">{{ 'Color' if printer.color_mode else 'Grayscale' }}</dd>
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper size</dt>
<dd>{{ printer.paper_size }}</dd>
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
<dd x-data x-text="$store.i18n.t('{{ 'yes' if printer.collate else 'no' }}')">{{ 'Yes' if printer.collate else 'No' }}</dd>
</dl>
</div>
</section>
<section class="card">
<div class="card-head">
{{ ico.i('driver') }}
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
</div>
<div class="card-body">
{% if printer.driver_id %}
<dl class="kv">
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt>
<dd class="mono-val">{{ printer.driver.original_filename }}</dd>
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver name(s)</dt>
<dd>
<span class="pill-row">
{% for name in driver_names %}<span class="pill">{{ name }}</span>{% endfor %}
</span>
</dd>
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
<dd>{% if printer.driver.architecture %}<span class="badge">{{ printer.driver.architecture }}</span>{% else %}<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
</dl>
{% else %}
<div class="empty">
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
<strong>No driver assigned</strong>
<p x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
<a href="/printers" class="btn sm" x-data>{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('assign_driver_cta')">Assign a driver</span></a>
</div>
{% endif %}
</div>
</section>
{% if has_driver %}
<section class="card">
<div class="card-head">
{{ ico.i('terminal') }}
<div>
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune commands</h2>
<p class="sub" x-data x-text="$store.i18n.t('hint_intune_cmds')">Paste these into the Win32 app's install and uninstall fields.</p>
</div>
</div>
<div class="card-body field-stack">
<div x-data="{ copiedInstall: false }">
<span class="label-text" x-text="$store.i18n.t('install_cmd_label')">Install command</span>
<div class="cmd">
<code id="install-cmd">{{ install_cmd }}</code>
<button type="button" @click="
navigator.clipboard.writeText(document.getElementById('install-cmd').innerText)
.then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
.catch(() => { /* command stays visible for manual copy */ })
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
</div>
</div>
<div x-data="{ copiedUninstall: false }">
<span class="label-text" x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</span>
<div class="cmd">
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
<button type="button" @click="
navigator.clipboard.writeText(document.getElementById('uninstall-cmd').innerText)
.then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
.catch(() => { /* command stays visible for manual copy */ })
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
</div>
</div>
</div>
</section>
{% endif %}
</div>
<div x-data="{ copiedUninstall: false }">
<label x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</label>
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
<button @click="
const text = document.getElementById('uninstall-cmd').innerText;
navigator.clipboard.writeText(text).then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
.catch(() => { /* fallback: text is visible for manual copy */ })
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
<!-- Right: what you can do with it -->
<div>
<section class="card">
<div class="card-head">
{{ ico.i('package') }}
<h2 x-data x-text="$store.i18n.t('export_section')">Deploy</h2>
</div>
<div class="card-body">
{% if has_driver %}
<div class="field-stack">
<div>
<a class="btn" href="/printers/{{ printer.id }}/packages/intunewin" x-data>
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_intunewin')">Download .intunewin</span>
</a>
<span class="hint" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</span>
</div>
<div>
<a class="btn ghost" href="/printers/{{ printer.id }}/packages/ninja" x-data>
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</span>
</a>
<span class="hint" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</span>
</div>
</div>
{% else %}
<p class="dim" x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
{% endif %}
</div>
</section>
{% if has_driver %}
<section class="card">
<div class="card-head">
{{ ico.i('script') }}
<h2 x-data x-text="$store.i18n.t('scripts_section')">PowerShell scripts</h2>
</div>
<div class="card-body">
<div class="btn-row">
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/install.ps1">{{ ico.i('download', 13) }}install.ps1</a>
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/uninstall.ps1">{{ ico.i('download', 13) }}uninstall.ps1</a>
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/detect.ps1">{{ ico.i('download', 13) }}detect.ps1</a>
</div>
</div>
</section>
{% endif %}
<section class="card">
<div class="card-head">
{{ ico.i('image') }}
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
</div>
<div class="card-body">
<div id="icon-status">
{% if has_icon %}
<p class="ok-note">{{ ico.i('check', 14) }}<span x-data x-text="$store.i18n.t('icon_uploaded')">Icon saved</span></p>
<div class="icon-preview">
<img src="/printers/{{ printer.id }}/icon" width="56" height="56" alt="">
<span class="meta">256&times;256 PNG</span>
</div>
{% else %}
<p class="dim" x-data x-text="$store.i18n.t('no_icon')">No icon. Intune will show its default.</p>
{% endif %}
</div>
<form hx-post="/printers/{{ printer.id }}/icon"
hx-target="#icon-status" hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<div class="uploader">
<input type="file" name="file" accept="image/png" required
x-data :aria-label="$store.i18n.t('upload_icon')">
<div class="btn-row">
<button type="submit" class="btn ghost sm" x-data>
{{ ico.i('upload', 13) }}<span x-text="$store.i18n.t('{{ 'replace_icon' if has_icon else 'upload_icon' }}')">Upload icon</span>
</button>
</div>
<p class="hint" x-data x-text="$store.i18n.t('hint_icon')">PNG, exactly 256 × 256, 750 KB max.</p>
</div>
</form>
</div>
</section>
</div>
<h2 x-data x-text="$store.i18n.t('scripts_section')">Scripts</h2>
<a href="/printers/{{ printer.id }}/scripts/install.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_install')">
Download Install Script
</a>
<a href="/printers/{{ printer.id }}/scripts/uninstall.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_uninstall')">
Download Uninstall Script
</a>
<a href="/printers/{{ printer.id }}/scripts/detect.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_detect')">
Download Detect Script
</a>
<h2 x-data x-text="$store.i18n.t('export_section')">Export</h2>
<a href="/printers/{{ printer.id }}/packages/ninja" role="button" x-data x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</a>
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button" x-data x-text="$store.i18n.t('download_intunewin')">Download .intunewin</a>
{% endif %}
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
{% if has_icon %}
<p x-data x-text="$store.i18n.t('icon_uploaded')">Icon uploaded</p>
{% endif %}
<form hx-post="/printers/{{ printer.id }}/icon"
hx-target="#icon-status" hx-swap="innerHTML"
enctype="multipart/form-data">
<input type="file" name="file" accept="image/png" required>
<button type="submit" x-data x-text="$store.i18n.t('upload_icon')">Upload Icon</button>
</form>
<div id="icon-status"></div>
<a href="/printers" role="button" class="secondary" x-data x-text="$store.i18n.t('back_to_printers_btn')">Back to Printers</a>
</article>
</div>
{% endblock %}
+42 -5
View File
@@ -1,12 +1,49 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Printers &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('printers')">Printers</span>{% endblock %}
{% block page_actions %}
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('printers')">Printers</h1>
<p class="page-intro" x-data x-text="$store.i18n.t('printers_intro')">A printer with a driver assigned is ready to export.</p>
<p><a href="/printers/new" role="button" x-data x-text="$store.i18n.t('add_printer')">Add Printer</a></p>
<div x-data="{
shown: 0,
total: 0,
recount() {
const rows = Array.from(document.querySelectorAll('#printer-list tr[data-search]'));
this.total = rows.length;
const q = this.$store.filter.q.trim().toLowerCase();
this.shown = q ? rows.filter(r => r.dataset.search.includes(q)).length : rows.length;
}
}"
x-init="recount()"
@htmx:after-swap.window="recount()">
<div class="toolbar">
<label class="search">
{{ ico.i('search') }}
<input type="search" x-model="$store.filter.q" @input="recount()"
:placeholder="$store.i18n.t('filter_printers')"
:aria-label="$store.i18n.t('filter_printers')">
</label>
<span class="filter-count" x-show="$store.filter.q.trim()" x-text="shown + ' / ' + total"></span>
<span class="spacer"></span>
{% if printer_count %}
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }}&nbsp;<span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
{% if printer_count - ready_count %}
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }}&nbsp;<span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %}
{% endif %}
</div>
<section>
<h2 x-data x-text="$store.i18n.t('printer_library')">Printer Library</h2>
{% include "partials/printer_list.html" %}
</section>
<p class="empty-state" x-show="$store.filter.q.trim() && shown === 0" style="display:none"
x-text="$store.i18n.t('no_match')">No printer matches this filter.</p>
</div>
{% endblock %}
+140 -89
View File
@@ -1,100 +1,151 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Add printer &middot; ImpTune{% endblock %}
{% block crumb %}
<span class="crumb" x-data>
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
</span>
{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('add_printer_title')">Add printer</span>{% endblock %}
{% block content %}
<h1 x-data x-text="$store.i18n.t('add_printer_title')">Add Printer</h1>
<div class="split" x-data="{ ip: '', port: '', portEdited: false }">
<section class="card">
<div class="card-body">
<form action="/printers" method="post">
<p><a href="/printers" x-data x-text="$store.i18n.t('back_to_printer_library')">&larr; Back to Printer Library</a></p>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
<label>
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
<input type="text" name="name" placeholder="HP LaserJet 4050 — Accounting" required>
<span class="hint" x-text="$store.i18n.t('hint_name')">The name the user sees on their workstation.</span>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<div x-data="{ ip: '', port: '', portEdited: false }">
<form action="/printers" method="post">
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
<input type="text" name="ip_address" class="mono"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="192.168.1.100"
inputmode="decimal"
required>
<span class="hint" x-text="$store.i18n.t('hint_ip')">The printer IPv4 address on the client network.</span>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
<input type="text" name="port_name" class="mono"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="IP_192_168_1_100">
<span class="hint" x-text="$store.i18n.t('hint_port')">Derived from the IP address. Edit it if your naming differs.</span>
</label>
</div>
</fieldset>
<label>
<span x-text="$store.i18n.t('printer_name')">Printer Name</span>
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
<label>
<select name="driver_id" id="printer-form-driver-select">
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}">
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option>
{% endfor %}
</select>
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
</label>
</fieldset>
<label>
<span x-text="$store.i18n.t('ip_address')">IP Address</span>
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<fieldset class="form-section">
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
<div class="form-row">
<label>
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
<select name="duplex_mode">
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-sided</option>
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long edge</option>
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short edge</option>
</select>
</label>
<label>
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
<select name="paper_size">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
</label>
</div>
<div class="check-row">
<label>
<input type="checkbox" name="color_mode" value="on" checked>
<span x-text="$store.i18n.t('color_mode')">Color</span>
</label>
<label>
<input type="checkbox" name="collate" value="on" checked>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
</div>
</fieldset>
<label>
<span x-text="$store.i18n.t('port_name')">Port Name</span>
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<div class="form-foot">
<button type="submit" class="btn" x-text="$store.i18n.t('save_printer')">Save printer</button>
<a href="/printers" class="btn ghost" x-text="$store.i18n.t('cancel')">Cancel</a>
</div>
</form>
</div>
</section>
<label>
<span x-text="$store.i18n.t('driver')">Driver</span>
<select name="driver_id" id="printer-form-driver-select">
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}">
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
<span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
<select name="duplex_mode">
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-Sided</option>
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long Edge</option>
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on" checked>
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
</label>
<label>
<span x-text="$store.i18n.t('paper_size')">Paper Size</span>
<select name="paper_size">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on" checked>
<span x-text="$store.i18n.t('collate')">Collate</span>
</label>
<label>
<span x-text="$store.i18n.t('client')">Client</span>
<select name="client_id">
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit" x-text="$store.i18n.t('save_printer')">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
<span x-text="$store.i18n.t('upload_new_driver')">Upload New Driver</span>
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary" x-text="$store.i18n.t('upload_driver')">Upload Driver</button>
</form>
<div id="driver-list"></div>
{#
Separate form on purpose: HTML forbids nesting, and the upload answers a
question the technician has *while* filling the form ("my driver isn't in
the list"). The response OOB-swaps the select above, new driver selected.
#}
<section class="card form-aside">
<div class="card-head">
<div>
<h2 x-data x-text="$store.i18n.t('upload_new_driver')">Add a new driver</h2>
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
</div>
</div>
<div class="card-body">
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML"
hx-indicator="#form-upload-spinner">
<input type="hidden" name="caller" value="printer_form">
<div class="uploader">
<input type="file" name="file" accept=".zip" required
x-data :aria-label="$store.i18n.t('driver_package_label')">
<div class="btn-row">
<button type="submit" class="btn ghost" x-data>
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
</button>
<span id="form-upload-spinner" class="htmx-indicator" aria-busy="true"
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
</div>
</div>
</form>
<div id="driver-list"></div>
</div>
</section>
</div>
{% endblock %}
+29 -14
View File
@@ -1,20 +1,35 @@
{% extends "base.html" %}
{% import "partials/icons.html" as ico %}
{% block head_title %}Restore backup key &middot; ImpTune{% endblock %}
{% block page_title %}<span x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</span>{% endblock %}
{% block content %}
<h1>Restore backup key</h1>
<section class="card" style="max-width:34rem">
<div class="card-head">
{{ ico.i('key') }}
<h2 x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</h2>
</div>
<div class="card-body">
<p class="dim" x-data x-text="$store.i18n.t('restore_desc')">
Paste the key from <code>imptune-backup-key.txt</code> to get your printers,
settings, and clients back in this browser.
</p>
<p>Paste the key from your <code>imptune-backup-key.txt</code> backup file to
recover your printers, configs, and groups on this browser.</p>
{% if error %}
<div class="error"><p>{{ error }}</p></div>
{% endif %}
{% if error %}
<div class="error"><p>{{ error }}</p></div>
{% endif %}
<form method="post" action="/session/restore">
<label>
Backup key
<input type="text" name="key" placeholder="paste your key here" required autofocus>
</label>
<button type="submit">Restore</button>
</form>
<form method="post" action="/session/restore">
<label>
<span class="label-text" x-data x-text="$store.i18n.t('backup_key_label')">Backup key</span>
<input type="text" name="key" class="mono" placeholder="paste your key here" required autofocus>
</label>
<div class="btn-row" style="margin-top:.9rem">
<button type="submit" class="btn" x-data x-text="$store.i18n.t('restore_btn')">Restore</button>
<a href="/" class="btn ghost" x-data x-text="$store.i18n.t('cancel')">Cancel</a>
</div>
</form>
</div>
</section>
{% endblock %}
+6 -2
View File
@@ -1,8 +1,12 @@
fastapi==0.115.*
uvicorn[standard]==0.30.*
jinja2==3.1.*
python-multipart==0.0.9
# >=0.0.18: CVE-2024-53981 — a malformed multipart boundary made the parser
# emit one log line per byte, burning CPU on any /drivers/upload POST.
python-multipart>=0.0.18,<0.1
pycryptodome==3.20.*
python-dotenv==1.0.*
peewee==3.17.*
Pillow>=10.0
# >=10.3: CVE-2024-28219 (buffer overflow in _imagingcms) — icon uploads are
# decoded by Pillow, so this is reachable from untrusted input.
Pillow>=10.3
+4
View File
@@ -40,6 +40,10 @@ def tmp_data_dir(tmp_path, monkeypatch):
cfg.DB_PATH = str(data_dir / "imptune.db")
cfg.DRIVERS_DIR = str(data_dir / "drivers")
cfg.ICONS_DIR = str(data_dir / "icons")
# TestClient talks plain HTTP to http://testserver, so a Secure cookie is
# dropped and every request lands on a fresh Owner (~41 spurious 404s).
# Tests that care about the Secure branch monkeypatch this back to True.
cfg.COOKIE_SECURE = False
yield data_dir
+11 -3
View File
@@ -3,6 +3,14 @@ from __future__ import annotations
import pytest
# The Edit button's label goes through the i18n store, which follows
# navigator.language — on a French-locale machine it reads "Modifier", so a
# has-text('Edit') locator only ever matched on English hosts. Target the
# showModal() hook instead: it is the same in every language.
# showModal(), not just edit-modal: the dialog's own close buttons reference
# the same element id via .close().
EDIT_BUTTON = "button[onclick*='showModal']"
def test_printer_edit_modal_open_and_prefill(page, live_server: str, _e2e_owner_key: str) -> None:
"""Edit button opens modal with printer's current name pre-filled."""
@@ -24,8 +32,8 @@ def test_printer_edit_modal_open_and_prefill(page, live_server: str, _e2e_owner_
)
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
page.wait_for_selector("button:has-text('Edit')")
page.click("button:has-text('Edit')")
page.wait_for_selector(EDIT_BUTTON)
page.click(EDIT_BUTTON)
# Dialog should be open
page.wait_for_selector("dialog[open]")
@@ -57,7 +65,7 @@ def test_printer_edit_submit_updates_list(page, live_server: str, _e2e_owner_key
# Find the row containing "OriginalName" and click its Edit button
row = page.locator("tr", has=page.locator("a", has_text="OriginalName"))
row.wait_for()
row.locator("button:has-text('Edit')").click()
row.locator(EDIT_BUTTON).click()
page.wait_for_selector("dialog[open]")
# Clear and update the name field
+9 -3
View File
@@ -93,15 +93,21 @@ def test_upload_no_inf(client: TestClient) -> None:
assert resp.status_code == 400
def test_upload_returns_select(client: TestClient) -> None:
"""POST /drivers/upload with valid ZIP returns HTML containing a <select> element."""
def test_upload_returns_driver_list_with_parsed_names(client: TestClient) -> None:
"""POST /drivers/upload with a valid ZIP returns the driver-list fragment
showing the names parsed out of the .inf.
The names used to render inside a display-only <select>; the UI overhaul
renders them as pills, so this asserts the swap target and the names
themselves rather than the widget that carries them.
"""
zip_bytes = _make_driver_zip()
resp = client.post(
"/drivers/upload",
files={"file": ("driver.zip", zip_bytes, "application/zip")},
)
assert resp.status_code == 200
assert "<select" in resp.text
assert 'id="driver-list"' in resp.text
assert "Test LaserJet Pro" in resp.text
+68
View File
@@ -281,3 +281,71 @@ class TestCryptographicVerification:
assert xml_size == len(plaintext), (
f"UnencryptedContentSize {xml_size} does not match actual plaintext size {len(plaintext)}"
)
class TestStreamingChunkBoundaries:
"""The builder encrypts the inner ZIP in _CHUNK-sized pieces rather than in
one buffer. Only the final (short) read carries PKCS7 padding, so a payload
landing exactly on a chunk boundary is the case that breaks first."""
@staticmethod
def _decrypt(built_package):
with zipfile.ZipFile(built_package, "r") as outer:
blob = outer.read("IntuneWinPackage/Contents/IntunePackage.intunewin")
tree = ET.fromstring(
outer.read("IntuneWinPackage/Metadata/Detection.xml").decode("utf-8")
)
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(blob[48:]), AES.block_size)
# MAC covers IV || ciphertext and must survive chunked hashing
mac_key = base64.b64decode(_get_enc_text(tree, "MacKey"))
assert hmac.new(mac_key, blob[32:], hashlib.sha256).digest() == blob[:32]
# Metadata is computed while streaming, not from a buffered plaintext
assert base64.b64decode(_get_enc_text(tree, "FileDigest")) == hashlib.sha256(plaintext).digest()
assert int(_get_xml_text(tree, "UnencryptedContentSize")) == len(plaintext)
return plaintext
@pytest.mark.parametrize("payload_size", range(96, 128))
def test_every_residue_of_chunk_size(self, tmp_path, monkeypatch, payload_size):
"""Sweep 32 consecutive sizes with a 16-byte chunk so every offset mod
_CHUNK is exercised, including the exact-multiple case where the last
read returns b"" and padding is a whole standalone block."""
monkeypatch.setattr("imptune.generators.intunewin_builder._CHUNK", 16)
src = tmp_path / "src"
src.mkdir()
# Incompressible, so inner-ZIP size tracks payload size 1:1
(src / "install.ps1").write_bytes(os.urandom(payload_size))
output = str(tmp_path / "package.intunewin")
build_intunewin(str(src), "install.ps1", output)
plaintext = self._decrypt(output)
assert zipfile.is_zipfile(io.BytesIO(plaintext))
def test_multi_megabyte_payload_roundtrips(self, tmp_path):
"""Several full 1 MB chunks through the default code path."""
src = tmp_path / "src"
src.mkdir()
blob = os.urandom(2_500_000)
(src / "install.ps1").write_text("Write-Host 'go'")
(src / "driver.bin").write_bytes(blob)
output = str(tmp_path / "package.intunewin")
build_intunewin(str(src), "install.ps1", output)
plaintext = self._decrypt(output)
with zipfile.ZipFile(io.BytesIO(plaintext)) as inner:
assert inner.read("driver.bin") == blob
class TestArgumentValidation:
def test_missing_source_dir_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
build_intunewin(str(tmp_path / "nope"), "install.ps1", str(tmp_path / "o.intunewin"))
def test_empty_setup_file_raises(self, source_dir, tmp_path):
with pytest.raises(ValueError):
build_intunewin(source_dir, "", str(tmp_path / "o.intunewin"))
+67
View File
@@ -256,3 +256,70 @@ class TestIntunewinIconInclusion:
printer, _ = setup_printer_with_driver
resp = client.get(f"/printers/{printer.id}/packages/intunewin")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Streamed exports — packages are assembled on disk, not buffered in RAM
# ---------------------------------------------------------------------------
class TestStreamedExportCleanup:
@pytest.fixture
def bulky_printer(self, tmp_data_dir, owner):
"""Printer whose driver ZIP is large enough to prove the body streamed."""
import hashlib
import os
import imptune.config as cfg
from imptune.db.models import Driver, Printer
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("printer.inf", "[Version]\nSignature=$WINDOWS NT$\n")
zf.writestr("sub/payload.bin", os.urandom(200_000))
data = buf.getvalue()
sha = hashlib.sha256(data).hexdigest()
os.makedirs(cfg.DRIVERS_DIR, exist_ok=True)
with open(os.path.join(cfg.DRIVERS_DIR, f"{sha}.zip"), "wb") as f:
f.write(data)
driver = Driver.create(
sha256=sha,
original_filename="bulky.zip",
size_bytes=len(data),
driver_desc=json.dumps(["Acme SuperPrint"]),
inf_filename="printer.inf",
)
return Printer.create(
name="Bulky Printer",
ip_address="10.1.1.1",
port_name="IP_10.1.1.1",
owner=owner,
driver=driver,
)
@pytest.mark.parametrize("kind", ["ninja", "intunewin"])
def test_export_streams_and_removes_its_temp_dir(self, client, bulky_printer, kind):
"""The response outlives the handler's temp dir, so a background task
deletes it — a leak here fills the container's disk one download at a time."""
import glob
import os
import tempfile
pattern = os.path.join(tempfile.gettempdir(), "imptune_*")
before = set(glob.glob(pattern))
resp = client.get(f"/printers/{bulky_printer.id}/packages/{kind}")
assert resp.status_code == 200
assert len(resp.content) > 150_000, "payload looks truncated"
assert set(glob.glob(pattern)) == before
def test_ninja_zip_preserves_nested_driver_paths(self, client, bulky_printer):
"""Members are copied chunk-wise via ZipFile.open — nested paths must survive."""
resp = client.get(f"/printers/{bulky_printer.id}/packages/ninja")
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
names = zf.namelist()
assert "Bulky_Printer/drivers/sub/payload.bin" in names
assert zf.getinfo("Bulky_Printer/drivers/sub/payload.bin").file_size == 200_000
+88
View File
@@ -392,3 +392,91 @@ def test_client_links_in_printer_list(client: TestClient, owner) -> None:
resp = client.get("/printers")
assert resp.status_code == 200
assert f'href="/clients/{cl.id}"' in resp.text
# ---------------------------------------------------------------------------
# Malformed FK form fields — these used to escape as HTTP 500
# ---------------------------------------------------------------------------
def test_create_rejects_non_numeric_client_id(client: TestClient, owner) -> None:
"""A non-numeric client_id is a 400 fragment, not a ValueError traceback."""
resp = client.post(
"/printers",
data={
"name": "Bad FK",
"ip_address": "10.0.5.1",
"port_name": "IP_10_0_5_1",
"client_id": "abc",
},
follow_redirects=False,
)
assert resp.status_code == 400
assert "Invalid client id" in resp.text
def test_create_rejects_non_numeric_driver_id(client: TestClient, owner) -> None:
resp = client.post(
"/printers",
data={
"name": "Bad FK",
"ip_address": "10.0.5.2",
"port_name": "IP_10_0_5_2",
"driver_id": "not-an-id",
},
follow_redirects=False,
)
assert resp.status_code == 400
assert "Invalid driver id" in resp.text
def test_create_rejects_unknown_driver_id(client: TestClient, owner) -> None:
"""An unknown driver id is a 404 fragment, not a FOREIGN KEY IntegrityError."""
resp = client.post(
"/printers",
data={
"name": "Ghost Driver",
"ip_address": "10.0.5.3",
"port_name": "IP_10_0_5_3",
"driver_id": "9999",
},
follow_redirects=False,
)
assert resp.status_code == 404
assert "Driver 9999 not found" in resp.text
def test_update_rejects_unknown_driver_id(client: TestClient, owner) -> None:
from imptune.db.models import Printer
printer = Printer.create(
name="Patch Me",
ip_address="10.0.5.4",
port_name="IP_10_0_5_4",
owner=owner,
)
resp = client.patch(
f"/printers/{printer.id}",
data={"name": "Patch Me", "driver_id": "9999"},
)
assert resp.status_code == 404
assert "Driver 9999 not found" in resp.text
# The printer must be untouched
assert Printer.get_by_id(printer.id).driver is None
def test_update_rejects_non_numeric_driver_id(client: TestClient, owner) -> None:
from imptune.db.models import Printer
printer = Printer.create(
name="Patch Me Too",
ip_address="10.0.5.5",
port_name="IP_10_0_5_5",
owner=owner,
)
resp = client.patch(
f"/printers/{printer.id}",
data={"name": "Patch Me Too", "driver_id": "??"},
)
assert resp.status_code == 400
assert "Invalid driver id" in resp.text
+143
View File
@@ -125,3 +125,146 @@ def test_health_endpoint_does_not_create_owner_rows(client):
client.get("/health")
after = Owner.select().count()
assert after == before
def test_static_assets_do_not_create_owner_rows(client):
"""Asset fetches are unscoped — they must not mint an Owner row each time."""
from imptune.db.models import Owner
client.get("/") # first visit provisions exactly one Owner
before = Owner.select().count()
for _ in range(5):
assert client.get("/static/app.css").status_code == 200
client.get("/favicon.ico")
assert Owner.select().count() == before
def test_restore_accepts_https_origin_behind_tls_proxy(tmp_data_dir):
"""A TLS-terminating proxy leaves uvicorn seeing http:// while the browser
sends Origin: https://host. Comparing schemes rejected every real restore."""
from imptune.main import app
with TestClient(app) as client_a:
client_a.get("/")
client_a.post(
"/printers",
data={"name": "Proxied Printer", "ip_address": "10.0.0.9", "port_name": "IP_P"},
follow_redirects=False,
)
key = client_a.get("/session/key/download").text
with TestClient(app) as client_new:
resp = client_new.post(
"/session/restore",
data={"key": key},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert resp.status_code == 303
assert "Proxied Printer" in client_new.get("/printers").text
def test_restore_still_rejects_foreign_host_origin(client):
"""Host is what's checked — a same-scheme attacker host must still fail."""
resp = client.post(
"/session/restore",
data={"key": "irrelevant"},
headers={"origin": "http://attacker.example"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# COOKIE_SECURE=false — usable, remembered, but memory-only + warned about
# ---------------------------------------------------------------------------
def _set_cookie_header(resp):
return next(
v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"
)
def test_insecure_mode_issues_a_browser_session_cookie(client, monkeypatch):
"""No Max-Age/Expires: the browser keeps the key in memory and drops it on
exit, instead of writing a 10-year plaintext bearer credential to disk."""
import imptune.config as cfg
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
resp = client.get("/")
header = _set_cookie_header(resp)
assert COOKIE_NAME in header
assert "Max-Age" not in header
assert "Expires" not in header
assert "Secure" not in header
def test_secure_mode_still_issues_a_persistent_cookie(tmp_data_dir, monkeypatch):
from imptune.main import app
import imptune.config as cfg
monkeypatch.setattr(cfg, "COOKIE_SECURE", True)
with TestClient(app) as fresh:
header = _set_cookie_header(fresh.get("/"))
assert "Max-Age=" in header
assert "Secure" in header
def test_insecure_mode_still_remembers_configs_within_the_session(client, monkeypatch):
"""The whole point: memory-only ≠ stateless. Same browser, same data."""
import imptune.config as cfg
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
client.post(
"/printers",
data={"name": "Ephemeral Printer", "ip_address": "10.0.6.1", "port_name": "IP_E"},
follow_redirects=False,
)
assert "Ephemeral Printer" in client.get("/printers").text
def test_insecure_mode_warns_the_user(client, monkeypatch):
import imptune.config as cfg
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
html = client.get("/printers").text
assert "ephemeral-session-warning" in html
assert "only while this browser stays open" in html
assert "/session/key/download" in html
def test_secure_mode_shows_no_warning(tmp_data_dir, monkeypatch):
from imptune.main import app
import imptune.config as cfg
monkeypatch.setattr(cfg, "COOKIE_SECURE", True)
with TestClient(app) as fresh:
assert "ephemeral-session-warning" not in fresh.get("/").text
def test_restore_also_issues_a_session_cookie_when_insecure(tmp_data_dir, monkeypatch):
"""The restore route sets its own cookie — it must honour the same policy."""
from imptune.main import app
import imptune.config as cfg
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
with TestClient(app) as client_a:
client_a.get("/")
key = client_a.get("/session/key/download").text
with TestClient(app) as client_new:
resp = client_new.post(
"/session/restore",
data={"key": key},
headers={"origin": "http://testserver"},
follow_redirects=False,
)
assert resp.status_code == 303
header = _set_cookie_header(resp)
assert "Max-Age" not in header and "Expires" not in header