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>
196 lines
7.4 KiB
Python
196 lines
7.4 KiB
Python
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
|
||
import json
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
import zipfile
|
||
|
||
from fastapi import APIRouter, Request
|
||
from fastapi.responses import FileResponse, PlainTextResponse
|
||
from starlette.background import BackgroundTask
|
||
|
||
import imptune.config as cfg
|
||
from imptune.db.models import Icon, Owner, Printer
|
||
from imptune.generators.intunewin_builder import build_intunewin
|
||
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
|
||
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."""
|
||
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
|
||
if printer is None:
|
||
return None, PlainTextResponse("Printer not found", status_code=404)
|
||
|
||
driver = printer.driver
|
||
if driver is None:
|
||
return None, PlainTextResponse("No driver assigned", status_code=422)
|
||
|
||
if not driver.inf_filename:
|
||
return None, PlainTextResponse("Driver has no INF file", status_code=422)
|
||
|
||
if not driver.driver_desc:
|
||
return None, PlainTextResponse("Driver has no description", status_code=422)
|
||
|
||
try:
|
||
desc_list = json.loads(driver.driver_desc)
|
||
driver_name = desc_list[0]
|
||
except (json.JSONDecodeError, IndexError, TypeError):
|
||
return None, PlainTextResponse("Driver description is invalid", status_code=422)
|
||
|
||
return (printer, driver, driver_name), None
|
||
|
||
|
||
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."""
|
||
result, error = _get_printer_and_driver(printer_id, request.state.owner)
|
||
if error is not None:
|
||
return error
|
||
|
||
printer, driver, driver_name = result
|
||
|
||
# Validate driver file exists on disk
|
||
driver_zip_path = _get_driver_zip_path(driver)
|
||
if not os.path.isfile(driver_zip_path):
|
||
return PlainTextResponse("Driver file not found on disk", status_code=422)
|
||
|
||
safe_name = printer.name.replace(" ", "_")
|
||
|
||
# Render install script
|
||
install_script = render_install(
|
||
printer_name=printer.name,
|
||
ip_address=printer.ip_address,
|
||
port_name=printer.port_name,
|
||
driver_name=driver_name,
|
||
inf_filename=driver.inf_filename,
|
||
duplex_mode=printer.duplex_mode,
|
||
color_mode=printer.color_mode,
|
||
paper_size=printer.paper_size,
|
||
collate=printer.collate,
|
||
)
|
||
|
||
# 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)
|
||
|
||
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 _streamed_download(
|
||
out_path, tmpdir, "application/zip", f"{safe_name}_ninja.zip"
|
||
)
|
||
|
||
|
||
@router.get("/{printer_id}/packages/intunewin")
|
||
def get_intunewin_package(request: Request, printer_id: int):
|
||
"""Download a Microsoft Intune .intunewin deployment package."""
|
||
result, error = _get_printer_and_driver(printer_id, request.state.owner)
|
||
if error is not None:
|
||
return error
|
||
|
||
printer, driver, driver_name = result
|
||
|
||
# Validate driver file exists on disk
|
||
driver_zip_path = _get_driver_zip_path(driver)
|
||
if not os.path.isfile(driver_zip_path):
|
||
return PlainTextResponse("Driver file not found on disk", status_code=422)
|
||
|
||
safe_name = printer.name.replace(" ", "_")
|
||
|
||
# Render all three scripts
|
||
install_script = render_install(
|
||
printer_name=printer.name,
|
||
ip_address=printer.ip_address,
|
||
port_name=printer.port_name,
|
||
driver_name=driver_name,
|
||
inf_filename=driver.inf_filename,
|
||
duplex_mode=printer.duplex_mode,
|
||
color_mode=printer.color_mode,
|
||
paper_size=printer.paper_size,
|
||
collate=printer.collate,
|
||
)
|
||
uninstall_script = render_uninstall(
|
||
printer_name=printer.name,
|
||
driver_name=driver_name,
|
||
port_name=printer.port_name,
|
||
)
|
||
detect_script = render_detect(printer_name=printer.name)
|
||
|
||
# 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)
|
||
|
||
# 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)
|
||
|
||
# Copy icon into staging if one exists for this printer
|
||
icon_record = Icon.get_or_none(Icon.printer == printer.id)
|
||
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(staging, "icon.png"))
|
||
|
||
# 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
|
||
|
||
return _streamed_download(
|
||
output_path, tmpdir, "application/octet-stream", f"{safe_name}.intunewin"
|
||
)
|