Commit initial
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
"""Client CRUD API — POST /clients, GET /clients."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import IntegrityError
|
||||
|
||||
from imptune.db.models import Client
|
||||
|
||||
router = APIRouter(prefix="/clients")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
"""Return an HTMX-friendly error fragment swapped into #client-list."""
|
||||
return HTMLResponse(
|
||||
content=f"<div id='client-list' class='error'><p>{message}</p></div>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def _render_client_list(request: Request) -> HTMLResponse:
|
||||
"""Render the client list partial for HTMX swap."""
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/client_list.html",
|
||||
context={"clients": clients},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
def create_client(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
"""Create a new client.
|
||||
|
||||
Accepts form-encoded `name`. Validates non-empty. Returns HTMX partial
|
||||
with updated client list on success, or error fragment on failure.
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return _error_response("Client name is required.")
|
||||
|
||||
try:
|
||||
Client.create(name=name)
|
||||
except IntegrityError:
|
||||
return _error_response(f"Client '{name}' already exists.", status_code=409)
|
||||
|
||||
return _render_client_list(request)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Driver upload API — POST /drivers/upload."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import imptune.config as _cfg
|
||||
from imptune.db.models import Driver
|
||||
from imptune.services.inf_parser import _detect_encoding, parse_inf
|
||||
from imptune.storage.driver_store import DriverStore
|
||||
|
||||
router = APIRouter(prefix="/drivers")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
"""Return an HTMX-friendly error fragment swapped into #driver-list."""
|
||||
return HTMLResponse(
|
||||
content=f"<div id='driver-list' class='error'><p>{message}</p></div>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
def upload_driver(
|
||||
request: Request,
|
||||
file: UploadFile,
|
||||
caller: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Accept a driver ZIP, parse its INF, persist via DriverStore + Peewee ORM.
|
||||
|
||||
Returns an HTMX partial (partials/driver_list.html) on success, or an
|
||||
inline error fragment with HTTP 400 on validation failure.
|
||||
"""
|
||||
data = file.file.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
return _error_response("File exceeds 100 MB limit.")
|
||||
|
||||
# Must end with .zip
|
||||
filename = file.filename or ""
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return _error_response("Only .zip files are accepted.")
|
||||
|
||||
# Must be a valid ZIP archive
|
||||
if not zipfile.is_zipfile(io.BytesIO(data)):
|
||||
return _error_response("Uploaded file is not a valid ZIP archive.")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
zip_names = zf.namelist()
|
||||
|
||||
# Reject zip-slip paths
|
||||
for name in zip_names:
|
||||
if ".." in name or name.startswith("/"):
|
||||
return _error_response("ZIP contains unsafe paths.")
|
||||
|
||||
# Find .inf files
|
||||
inf_names = [n for n in zip_names if n.lower().endswith(".inf")]
|
||||
if not inf_names:
|
||||
return _error_response("No .inf file found in the uploaded ZIP.")
|
||||
|
||||
# Prefer amd64/x64 INF when multiple exist; fall back to alphabetical first
|
||||
preferred = [
|
||||
n for n in inf_names if "amd64" in n.lower() or "x64" in n.lower()
|
||||
]
|
||||
chosen_inf = preferred[0] if preferred else sorted(inf_names)[0]
|
||||
|
||||
raw_inf = zf.read(chosen_inf)
|
||||
|
||||
# Decode INF
|
||||
encoding = _detect_encoding(raw_inf)
|
||||
inf_text = raw_inf.decode(encoding)
|
||||
|
||||
# Parse INF
|
||||
parsed = parse_inf(inf_text, inf_filename=chosen_inf, zip_names=zip_names)
|
||||
|
||||
# Persist file (content-addressed, dedup automatic)
|
||||
# Read DRIVERS_DIR at call time so tests can monkeypatch imptune.config.DRIVERS_DIR
|
||||
store = DriverStore(_cfg.DRIVERS_DIR)
|
||||
sha256 = store.save(data)
|
||||
|
||||
# Upsert Driver record (no duplicate if same SHA256)
|
||||
new_driver, _created = Driver.get_or_create(
|
||||
sha256=sha256,
|
||||
defaults={
|
||||
"original_filename": filename,
|
||||
"size_bytes": len(data),
|
||||
"driver_desc": json.dumps(parsed.driver_names),
|
||||
"inf_filename": parsed.inf_filename,
|
||||
"architecture": parsed.architecture,
|
||||
"has_cat_file": parsed.has_cat_file,
|
||||
},
|
||||
)
|
||||
|
||||
# Build driver_data for template
|
||||
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})
|
||||
|
||||
# When called from the printer form, emit primary fragment + OOB select refresh
|
||||
if caller == "printer_form":
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/driver_upload_with_oob.html",
|
||||
context={
|
||||
"driver_data": driver_data,
|
||||
"new_driver_id": new_driver.id,
|
||||
"parsed": parsed,
|
||||
},
|
||||
)
|
||||
|
||||
# Default: existing behavior — driver list fragment only
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/driver_list.html",
|
||||
context={
|
||||
"driver_data": driver_data,
|
||||
"parsed": parsed,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Icon upload API — POST /printers/{printer_id}/icon."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from PIL import Image
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Printer
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
MAX_ICON_BYTES = 750 * 1024 # 750 KB
|
||||
|
||||
|
||||
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
|
||||
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
|
||||
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
|
||||
|
||||
Validation rules:
|
||||
- Format: PNG only
|
||||
- Dimensions: exactly 256x256 pixels
|
||||
- Size: at most 750 KB
|
||||
|
||||
Replaces any previously uploaded icon for this printer.
|
||||
Returns an HTMX-friendly HTML fragment.
|
||||
"""
|
||||
# Check printer exists
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
if printer is None:
|
||||
return HTMLResponse(
|
||||
content="<p>Printer not found.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Read file (read one byte extra to detect oversized files)
|
||||
data = file.file.read(MAX_ICON_BYTES + 1)
|
||||
if len(data) > MAX_ICON_BYTES:
|
||||
return HTMLResponse(
|
||||
content="<p>Icon exceeds 750 KB limit.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# Validate with Pillow
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
except Exception:
|
||||
return HTMLResponse(
|
||||
content="<p>Icon must be PNG format.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
if img.format != "PNG":
|
||||
return HTMLResponse(
|
||||
content="<p>Icon must be PNG format.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
if img.size != (256, 256):
|
||||
return HTMLResponse(
|
||||
content=f"<p>Icon must be 256x256 pixels, got {img.size}.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# Store SHA256-addressed on disk
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
icons_dir = Path(cfg.DATA_DIR) / "icons"
|
||||
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_path = icons_dir / sha256
|
||||
icon_path.write_bytes(data)
|
||||
|
||||
# Replace existing Icon record for this printer
|
||||
Icon.delete().where(Icon.printer == printer_id).execute()
|
||||
Icon.create(
|
||||
printer=printer_id,
|
||||
sha256=sha256,
|
||||
original_filename=file.filename or "icon.png",
|
||||
size_bytes=len(data),
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
content="<p>Icon uploaded successfully</p>",
|
||||
status_code=200,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, 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")
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int):
|
||||
"""Fetch printer and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
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))
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/ninja")
|
||||
def get_ninja_package(printer_id: int):
|
||||
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
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 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)
|
||||
|
||||
# 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)
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/intunewin")
|
||||
def get_intunewin_package(printer_id: int):
|
||||
"""Download a Microsoft Intune .intunewin deployment package."""
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# Extract driver ZIP contents into tmpdir/drivers/
|
||||
drivers_subdir = os.path.join(tmpdir, "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(tmpdir, "icon.png"))
|
||||
|
||||
# Build .intunewin
|
||||
output_path = os.path.join(tmpdir, "out.intunewin")
|
||||
build_intunewin(tmpdir, "install.ps1", output_path)
|
||||
|
||||
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"'},
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
from imptune.db.models import Printer
|
||||
|
||||
recent_printers = list(
|
||||
Printer.select().order_by(Printer.created_at.desc()).limit(5)
|
||||
)
|
||||
recent_packages = list(
|
||||
Printer.select()
|
||||
.where(Printer.driver.is_null(False))
|
||||
.order_by(Printer.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="dashboard.html",
|
||||
context={
|
||||
"recent_printers": recent_printers,
|
||||
"recent_packages": recent_packages,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drivers", response_class=HTMLResponse)
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/printers", response_class=HTMLResponse)
|
||||
def printers_page(request: Request):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.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)
|
||||
|
||||
clients = list(Client.select().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})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="printers.html",
|
||||
context={
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": driver_data,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/printers/new", response_class=HTMLResponse)
|
||||
def printers_new_page(request: Request):
|
||||
from imptune.db.models import Client, Driver
|
||||
|
||||
clients = list(Client.select().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},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/printers/{printer_id}", response_class=HTMLResponse)
|
||||
def printer_detail(request: Request, printer_id: int):
|
||||
from imptune.db.models import Client, Driver, Icon, Printer
|
||||
|
||||
printer = (
|
||||
Printer.select(Printer, Client, Driver)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where(Printer.id == printer_id)
|
||||
.first()
|
||||
)
|
||||
if printer is None:
|
||||
return HTMLResponse(
|
||||
content="<h1>404 Not Found</h1><p>Printer not found.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
driver_names: list[str] = []
|
||||
if printer.driver_id and printer.driver.driver_desc:
|
||||
driver_names = json.loads(printer.driver.driver_desc)
|
||||
|
||||
has_driver = printer.driver_id is not None and bool(driver_names)
|
||||
icon = Icon.get_or_none(Icon.printer == printer_id)
|
||||
|
||||
install_cmd = "powershell.exe -ExecutionPolicy Bypass -File install.ps1"
|
||||
uninstall_cmd = "powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="printer_detail.html",
|
||||
context={
|
||||
"printer": printer,
|
||||
"driver_names": driver_names,
|
||||
"has_driver": has_driver,
|
||||
"has_icon": icon is not None,
|
||||
"install_cmd": install_cmd,
|
||||
"uninstall_cmd": uninstall_cmd,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients", response_class=HTMLResponse)
|
||||
def clients_page(request: Request):
|
||||
from imptune.db.models import Client
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="clients.html",
|
||||
context={"clients": clients},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients/{client_id}", response_class=HTMLResponse)
|
||||
def client_detail(request: Request, client_id: int):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
client = Client.get_or_none(Client.id == client_id)
|
||||
if client is None:
|
||||
return HTMLResponse(
|
||||
content="<h1>404 Not Found</h1><p>Client not found.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.where(Printer.client == client_id)
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
grouped = {client.name: list(query)}
|
||||
|
||||
clients = list(Client.select().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="client_detail.html",
|
||||
context={
|
||||
"client": client,
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": driver_data,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/packages", response_class=HTMLResponse)
|
||||
def packages_page(request: Request):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
printers = list(
|
||||
Printer.select(Printer, Client, Driver)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where(Printer.driver.is_null(False))
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="packages.html",
|
||||
context={"printers": printers},
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
_VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"}
|
||||
_VALID_PAPER = {"A4", "Letter", "Legal"}
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
"""Return an HTMX-friendly error fragment swapped into #printer-list."""
|
||||
return HTMLResponse(
|
||||
content=f"<div id='printer-list' class='error'><p>{message}</p></div>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
"""Query printers with LEFT JOIN on client and render grouped partial."""
|
||||
import json
|
||||
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.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)
|
||||
|
||||
clients = list(Client.select().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="partials/printer_list.html",
|
||||
context={"grouped": grouped, "clients": clients, "driver_data": driver_data},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
def create_printer(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
ip_address: str = Form(...),
|
||||
port_name: str = Form(...),
|
||||
duplex_mode: str = Form("OneSided"),
|
||||
color_mode: str = Form(""),
|
||||
paper_size: str = Form("A4"),
|
||||
collate: str = Form(""),
|
||||
client_id: str = Form(""),
|
||||
driver_id: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Create a new printer configuration.
|
||||
|
||||
Boolean fields (color_mode, collate) use HTML checkbox convention:
|
||||
"on" = True, absent/empty = False.
|
||||
"""
|
||||
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"
|
||||
|
||||
# Resolve optional FK IDs
|
||||
client_fk = int(client_id) if client_id.strip() else None
|
||||
driver_fk = int(driver_id) if driver_id.strip() else None
|
||||
|
||||
Printer.create(
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
port_name=port_name,
|
||||
duplex_mode=duplex_mode,
|
||||
color_mode=color_mode_bool,
|
||||
paper_size=paper_size,
|
||||
collate=collate_bool,
|
||||
client=client_fk,
|
||||
driver=driver_fk,
|
||||
)
|
||||
|
||||
return RedirectResponse(url="/printers", status_code=303)
|
||||
|
||||
|
||||
@router.delete("/{printer_id}", response_class=HTMLResponse)
|
||||
def delete_printer(request: Request, printer_id: int) -> HTMLResponse:
|
||||
"""Delete a printer by ID. Returns updated printer list partial."""
|
||||
deleted = Printer.delete().where(Printer.id == printer_id).execute()
|
||||
if not deleted:
|
||||
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
||||
|
||||
return _render_printer_list(request)
|
||||
|
||||
|
||||
@router.patch("/{printer_id}", response_class=HTMLResponse)
|
||||
def update_printer(
|
||||
request: Request,
|
||||
printer_id: int,
|
||||
name: str = Form(...),
|
||||
ip_address: str = Form(""),
|
||||
port_name: str = Form(""),
|
||||
duplex_mode: str = Form("OneSided"),
|
||||
color_mode: str = Form(""),
|
||||
paper_size: str = Form("A4"),
|
||||
collate: str = Form(""),
|
||||
client_id: str = Form(""),
|
||||
driver_id: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Update an existing printer configuration in-place."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
if printer is None:
|
||||
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
||||
|
||||
name = name.strip()
|
||||
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}.")
|
||||
|
||||
printer.name = name
|
||||
printer.ip_address = ip_address
|
||||
printer.port_name = port_name
|
||||
printer.duplex_mode = duplex_mode
|
||||
printer.color_mode = color_mode == "on"
|
||||
printer.paper_size = paper_size
|
||||
printer.collate = collate == "on"
|
||||
printer.client = int(client_id) if client_id.strip() else None
|
||||
printer.driver = int(driver_id) if driver_id.strip() else None
|
||||
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
printer.save()
|
||||
|
||||
return _render_printer_list(request)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Script download endpoints — generates and serves PowerShell scripts for a printer."""
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from imptune.db.models import Printer
|
||||
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int):
|
||||
"""Fetch printer and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
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 _install_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
rendered = 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,
|
||||
)
|
||||
return PlainTextResponse(
|
||||
content=rendered,
|
||||
headers={"Content-Disposition": 'attachment; filename="install.ps1"'},
|
||||
)
|
||||
|
||||
|
||||
def _uninstall_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
rendered = render_uninstall(
|
||||
printer_name=printer.name,
|
||||
driver_name=driver_name,
|
||||
port_name=printer.port_name,
|
||||
)
|
||||
return PlainTextResponse(
|
||||
content=rendered,
|
||||
headers={"Content-Disposition": 'attachment; filename="uninstall.ps1"'},
|
||||
)
|
||||
|
||||
|
||||
def _detect_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
rendered = render_detect(printer_name=printer.name)
|
||||
return PlainTextResponse(
|
||||
content=rendered,
|
||||
headers={"Content-Disposition": 'attachment; filename="detect.ps1"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/install")
|
||||
def get_install_script(printer_id: int):
|
||||
"""Download the PowerShell install script for a printer."""
|
||||
return _install_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/install.ps1")
|
||||
def get_install_script_ps1(printer_id: int):
|
||||
"""Download the PowerShell install script for a printer (.ps1 alias)."""
|
||||
return _install_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/uninstall")
|
||||
def get_uninstall_script(printer_id: int):
|
||||
"""Download the PowerShell uninstall script for a printer."""
|
||||
return _uninstall_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/uninstall.ps1")
|
||||
def get_uninstall_script_ps1(printer_id: int):
|
||||
"""Download the PowerShell uninstall script for a printer (.ps1 alias)."""
|
||||
return _uninstall_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/detect")
|
||||
def get_detect_script(printer_id: int):
|
||||
"""Download the PowerShell detection script for a printer."""
|
||||
return _detect_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/detect.ps1")
|
||||
def get_detect_script_ps1(printer_id: int):
|
||||
"""Download the PowerShell detection script for a printer (.ps1 alias)."""
|
||||
return _detect_response(printer_id)
|
||||
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
|
||||
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
||||
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
||||
ICONS_DIR = str(Path(DATA_DIR) / "icons")
|
||||
@@ -0,0 +1 @@
|
||||
# Database package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
"""Peewee SQLite database instance and initialization."""
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from imptune.config import DB_PATH
|
||||
|
||||
# Deferred init — path is set at runtime via init_db() so tests can override DB_PATH
|
||||
db = SqliteDatabase(None)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize the SQLite database with WAL mode and foreign keys.
|
||||
|
||||
Idempotent — safe to call on every application startup.
|
||||
Creates all ORM tables if they do not already exist.
|
||||
|
||||
Closes any existing connection before re-initializing so that test
|
||||
fixtures can monkeypatch DB_PATH between test runs.
|
||||
"""
|
||||
from imptune.db.models import Client, Driver, Printer, Icon
|
||||
|
||||
# Re-read DB_PATH each time so tests can patch imptune.config.DB_PATH
|
||||
import imptune.config as cfg
|
||||
|
||||
# Close any lingering connection from a previous run (important for tests)
|
||||
if not db.is_closed():
|
||||
db.close()
|
||||
|
||||
db.init(
|
||||
cfg.DB_PATH,
|
||||
pragmas={
|
||||
"journal_mode": "wal",
|
||||
"foreign_keys": 1,
|
||||
},
|
||||
)
|
||||
db.connect(reuse_if_open=True)
|
||||
db.create_tables([Client, Driver, Printer, Icon], safe=True)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Peewee ORM models — full schema for phases 1-5."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
from peewee import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
DateTimeField,
|
||||
ForeignKeyField,
|
||||
IntegerField,
|
||||
Model,
|
||||
)
|
||||
|
||||
from imptune.db.database import db
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
"""Base model that binds all models to the shared db instance."""
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
||||
|
||||
class Client(BaseModel):
|
||||
"""Represents a deployment target (AD client / OU)."""
|
||||
|
||||
name = CharField(unique=True)
|
||||
created_at = DateTimeField(default=_utcnow)
|
||||
|
||||
class Meta:
|
||||
table_name = "client"
|
||||
|
||||
|
||||
class Driver(BaseModel):
|
||||
"""Uploaded printer driver package (content-addressed by SHA256)."""
|
||||
|
||||
sha256 = CharField(unique=True, index=True)
|
||||
original_filename = CharField()
|
||||
size_bytes = IntegerField()
|
||||
uploaded_at = DateTimeField(default=_utcnow)
|
||||
driver_desc = CharField(null=True)
|
||||
inf_filename = CharField(null=True)
|
||||
architecture = CharField(null=True)
|
||||
has_cat_file = BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
table_name = "driver"
|
||||
|
||||
|
||||
class Printer(BaseModel):
|
||||
"""Printer configuration record."""
|
||||
|
||||
name = CharField()
|
||||
ip_address = CharField()
|
||||
port_name = CharField()
|
||||
client = ForeignKeyField(Client, null=True, backref="printers")
|
||||
driver = ForeignKeyField(Driver, null=True, backref="printers")
|
||||
duplex_mode = CharField(default="OneSided")
|
||||
color_mode = BooleanField(default=True)
|
||||
paper_size = CharField(default="A4")
|
||||
collate = BooleanField(default=True)
|
||||
created_at = DateTimeField(default=_utcnow)
|
||||
updated_at = DateTimeField(default=_utcnow)
|
||||
|
||||
class Meta:
|
||||
table_name = "printer"
|
||||
|
||||
|
||||
class Icon(BaseModel):
|
||||
"""Printer icon image (one per printer)."""
|
||||
|
||||
printer = ForeignKeyField(Printer, unique=True, backref="icons")
|
||||
sha256 = CharField()
|
||||
original_filename = CharField()
|
||||
size_bytes = IntegerField()
|
||||
uploaded_at = DateTimeField(default=_utcnow)
|
||||
|
||||
class Meta:
|
||||
table_name = "icon"
|
||||
@@ -0,0 +1 @@
|
||||
"""Generators package for ImpTune."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Python-native .intunewin file assembler.
|
||||
|
||||
Generates valid .intunewin packages using AES-256-CBC encryption with HMAC-SHA256,
|
||||
producing the exact byte layout that Microsoft Intune expects.
|
||||
|
||||
Encrypted blob layout (from svrooij.io reverse-engineering):
|
||||
[0:32] HMAC-SHA256 of the ciphertext (32 bytes, mac_key)
|
||||
[32:48] AES-256-CBC Initialization Vector (16 bytes — standard AES block size)
|
||||
[48:] AES-256-CBC ciphertext (PKCS7-padded to 16-byte boundary)
|
||||
|
||||
IMPORTANT: IV is 16 bytes, NOT 32. STACK.md has a documentation error on this point.
|
||||
|
||||
Detection.xml format matches the reference IntuneWinAppUtil.exe output exactly:
|
||||
- ToolVersion is an XML *attribute* on <ApplicationInfo> (not a child element)
|
||||
- No xmlns namespace declaration (reference uses [XmlRoot("ApplicationInfo")] with no namespace)
|
||||
- No <?xml ...?> declaration header (reference uses OmitXmlDeclaration=true)
|
||||
- No <MacAlgorithm> element (not present in reference FileEncryptionInfo model)
|
||||
|
||||
Outer ZIP structure:
|
||||
IntuneWinPackage/
|
||||
├── Contents/
|
||||
│ └── IntunePackage.intunewin (the encrypted blob)
|
||||
└── Metadata/
|
||||
└── Detection.xml (encryption metadata)
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from xml.etree.ElementTree import Element, SubElement, indent, tostring
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad
|
||||
|
||||
|
||||
# Version string that matches the reference IntuneWinAppUtil.exe tool.
|
||||
# Intune's upload wizard validates or uses this field to confirm the package
|
||||
# was produced by a compatible tool version.
|
||||
_TOOL_VERSION = "1.8.6.0"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
source_dir: Path to the directory containing files to package.
|
||||
setup_file: Name of the setup/entry-point file (e.g., "install.ps1").
|
||||
Must be present in source_dir. Used in Detection.xml metadata.
|
||||
output_path: Destination path for the generated .intunewin file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If source_dir does not exist.
|
||||
ValueError: If setup_file is empty.
|
||||
"""
|
||||
# --- 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()
|
||||
|
||||
# --- 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 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 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 5: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] ---
|
||||
encrypted_blob = mac_digest + iv + ciphertext
|
||||
|
||||
# --- Step 6: Compute plaintext (inner ZIP) SHA256 digest for Detection.xml ---
|
||||
file_digest = hashlib.sha256(plaintext).digest()
|
||||
|
||||
# --- Step 7: Build Detection.xml ---
|
||||
# Format MUST match IntuneWinAppUtil.exe reference output exactly:
|
||||
# - ToolVersion is an XML attribute on ApplicationInfo (not a child element)
|
||||
# - No xmlns namespace (reference omits it)
|
||||
# - No <?xml?> declaration header
|
||||
# - No MacAlgorithm element (not in reference FileEncryptionInfo model)
|
||||
app_info = Element(
|
||||
"ApplicationInfo",
|
||||
attrib={"ToolVersion": _TOOL_VERSION},
|
||||
)
|
||||
SubElement(app_info, "Name").text = setup_file
|
||||
SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
|
||||
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
|
||||
SubElement(app_info, "SetupFile").text = setup_file
|
||||
|
||||
enc_info = SubElement(app_info, "EncryptionInfo")
|
||||
SubElement(enc_info, "EncryptionKey").text = base64.b64encode(aes_key).decode()
|
||||
SubElement(enc_info, "MacKey").text = base64.b64encode(mac_key).decode()
|
||||
SubElement(enc_info, "InitializationVector").text = base64.b64encode(iv).decode()
|
||||
SubElement(enc_info, "Mac").text = base64.b64encode(mac_digest).decode()
|
||||
SubElement(enc_info, "ProfileIdentifier").text = "ProfileVersion1"
|
||||
SubElement(enc_info, "FileDigest").text = base64.b64encode(file_digest).decode()
|
||||
SubElement(enc_info, "FileDigestAlgorithm").text = "SHA256"
|
||||
|
||||
# indent() adds pretty-print whitespace in-place (Python 3.9+).
|
||||
# tostring with xml_declaration=False omits the <?xml?> header.
|
||||
indent(app_info, space=" ")
|
||||
detection_xml = tostring(app_info, encoding="unicode", xml_declaration=False)
|
||||
|
||||
# --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
|
||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
|
||||
outer.writestr(
|
||||
"IntuneWinPackage/Contents/IntunePackage.intunewin",
|
||||
encrypted_blob,
|
||||
)
|
||||
outer.writestr(
|
||||
"IntuneWinPackage/Metadata/Detection.xml",
|
||||
detection_xml.encode("utf-8"),
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Script generator module — renders PowerShell scripts from Jinja2 templates.
|
||||
|
||||
Provides render_install(), render_uninstall(), and render_detect() which produce
|
||||
complete PowerShell scripts for Intune deployment:
|
||||
- render_install: WOW64 guard, UAC self-elevation, pnputil two-step, idempotent setup
|
||||
- render_uninstall: Ordered removal of printer, driver, and port
|
||||
- render_detect: Intune detection contract (Write-Output + exit 0/1)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).parent.parent / "templates" / "scripts"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(_SCRIPTS_DIR)),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
_duplex_map = {
|
||||
"OneSided": "OneSided",
|
||||
"LongEdge": "TwoSidedLongEdge",
|
||||
"ShortEdge": "TwoSidedShortEdge",
|
||||
}
|
||||
|
||||
|
||||
def render_install(
|
||||
printer_name: str,
|
||||
ip_address: str,
|
||||
port_name: str,
|
||||
driver_name: str,
|
||||
inf_filename: str,
|
||||
duplex_mode: str,
|
||||
color_mode: bool,
|
||||
paper_size: str,
|
||||
collate: bool,
|
||||
) -> str:
|
||||
"""Render install.ps1.j2 with the given printer configuration.
|
||||
|
||||
Args:
|
||||
printer_name: Display name of the printer.
|
||||
ip_address: IP address for the printer TCP/IP port.
|
||||
port_name: Port name (e.g. "IP_192.168.1.10").
|
||||
driver_name: Exact driver name as registered in Windows.
|
||||
inf_filename: INF filename inside the drivers/ subfolder.
|
||||
duplex_mode: One of "OneSided", "LongEdge", "ShortEdge" (model values).
|
||||
color_mode: True for color printing, False for mono.
|
||||
paper_size: Paper size string (e.g. "A4", "Letter").
|
||||
collate: True to enable collation.
|
||||
|
||||
Returns:
|
||||
Rendered PowerShell script as a string.
|
||||
"""
|
||||
tpl = _env.get_template("install.ps1.j2")
|
||||
return tpl.render(
|
||||
printer_name=printer_name,
|
||||
ip_address=ip_address,
|
||||
port_name=port_name,
|
||||
driver_name=driver_name,
|
||||
inf_filename=inf_filename,
|
||||
duplex_mode=_duplex_map.get(duplex_mode, duplex_mode),
|
||||
color=str(color_mode).lower(),
|
||||
paper_size=paper_size,
|
||||
collate=str(collate).lower(),
|
||||
)
|
||||
|
||||
|
||||
def render_uninstall(printer_name: str, driver_name: str, port_name: str) -> str:
|
||||
"""Render uninstall.ps1.j2 — removes printer, driver, and port in safe order.
|
||||
|
||||
Removal order: Printer first (so driver is no longer referenced), then Driver,
|
||||
then Port. All operations use -ErrorAction SilentlyContinue for idempotency.
|
||||
|
||||
Args:
|
||||
printer_name: Display name of the printer to remove.
|
||||
driver_name: Exact driver name as registered in Windows.
|
||||
port_name: Port name (e.g. "IP_192.168.1.10").
|
||||
|
||||
Returns:
|
||||
Rendered PowerShell script as a string.
|
||||
"""
|
||||
tpl = _env.get_template("uninstall.ps1.j2")
|
||||
return tpl.render(
|
||||
printer_name=printer_name,
|
||||
driver_name=driver_name,
|
||||
port_name=port_name,
|
||||
)
|
||||
|
||||
|
||||
def render_detect(printer_name: str) -> str:
|
||||
"""Render detect.ps1.j2 — Intune detection script.
|
||||
|
||||
Follows the Intune detection contract: Write-Output + exit 0 when printer
|
||||
is found, exit 1 when absent. Intune considers exit 0 as "app installed".
|
||||
|
||||
Args:
|
||||
printer_name: Display name of the printer to detect.
|
||||
|
||||
Returns:
|
||||
Rendered PowerShell script as a string.
|
||||
"""
|
||||
tpl = _env.get_template("detect.ps1.j2")
|
||||
return tpl.render(printer_name=printer_name)
|
||||
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts
|
||||
from imptune.config import DATA_DIR, DRIVERS_DIR, ICONS_DIR
|
||||
from imptune.db.database import db, init_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(DRIVERS_DIR, exist_ok=True)
|
||||
os.makedirs(ICONS_DIR, exist_ok=True)
|
||||
init_db()
|
||||
yield
|
||||
# Close DB connection on shutdown so test fixtures can re-initialize cleanly
|
||||
if not db.is_closed():
|
||||
db.close()
|
||||
|
||||
|
||||
app = FastAPI(title="ImpTune", lifespan=lifespan)
|
||||
|
||||
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
|
||||
_static_dir = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
||||
|
||||
# Register routers
|
||||
app.include_router(health.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(drivers.router)
|
||||
app.include_router(printers.router)
|
||||
app.include_router(clients.router)
|
||||
app.include_router(scripts.router)
|
||||
app.include_router(packages.router)
|
||||
app.include_router(icons.router)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
INF parser service for Windows driver INF files.
|
||||
|
||||
Extracts driver names (DriverDesc), resolves %TOKEN% references,
|
||||
auto-detects encoding (ANSI/UTF-8/UTF-16), handles multi-model INFs,
|
||||
detects unused files, architecture, and presence of .cat files.
|
||||
|
||||
Source: Microsoft WDK — General Syntax Rules for INF Files
|
||||
https://learn.microsoft.com/en-us/windows-hardware/drivers/install/general-syntax-rules-for-inf-files
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedInf:
|
||||
"""Result of parsing a Windows INF file."""
|
||||
|
||||
driver_names: list[str] # resolved DriverDesc values, deduplicated and sorted
|
||||
inf_filename: str # which .inf file was parsed (basename from ZIP)
|
||||
architecture: str | None # 'x64', 'x86', 'arm64', or None if ambiguous/unknown
|
||||
has_cat_file: bool # True if a .cat file exists in the ZIP member list
|
||||
unused_files: list[str] # ZIP members not referenced anywhere in the INF text
|
||||
|
||||
|
||||
def _detect_encoding(raw: bytes) -> str:
|
||||
"""Detect INF file encoding by sniffing BOM bytes.
|
||||
|
||||
INF files from real vendors arrive as:
|
||||
- ANSI / Windows-1252 (cp1252) — most legacy drivers
|
||||
- UTF-8 with BOM — modern drivers
|
||||
- UTF-16 LE with BOM — HP/Canon x64 signed drivers (most common UTF-16)
|
||||
- UTF-16 BE with BOM — rare
|
||||
|
||||
Source: Microsoft WDK — general-syntax-rules-for-inf-files
|
||||
"""
|
||||
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
||||
return "utf-16" # UTF-16 LE or BE with BOM
|
||||
if raw[:3] == b"\xef\xbb\xbf":
|
||||
return "utf-8-sig" # UTF-8 with BOM
|
||||
return "cp1252" # ANSI / Windows-1252 safe fallback
|
||||
|
||||
|
||||
def _neutralize_bare_lines(inf_text: str) -> str:
|
||||
"""Rewrite INF lines that lack ``=`` so configparser can ingest the file.
|
||||
|
||||
Real vendor INFs contain sections like ``[SourceDisksFiles]`` or copy-list
|
||||
sections whose entries are bare filenames (no key/value). configparser is
|
||||
strict and aborts on those. We only care about ``key = value`` lines for
|
||||
DriverDesc extraction, so it's safe to convert each bare payload line into
|
||||
a synthetic ``__bare_N = <original>`` entry.
|
||||
"""
|
||||
out: list[str] = []
|
||||
counter = 0
|
||||
for line in inf_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if (
|
||||
not stripped
|
||||
or stripped.startswith(";")
|
||||
or stripped.startswith("#")
|
||||
or stripped.startswith("[")
|
||||
or "=" in stripped
|
||||
):
|
||||
out.append(line)
|
||||
continue
|
||||
counter += 1
|
||||
out.append(f"__bare_{counter} = {stripped}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _resolve_tokens(value: str, strings: dict[str, str]) -> str:
|
||||
"""Expand %TOKEN% placeholders using the [Strings] section lookup dict.
|
||||
|
||||
Keys in ``strings`` must already be lowercased (configparser lowercases
|
||||
option names by default).
|
||||
|
||||
Source: Microsoft WDK — general-syntax-rules-for-inf-files — strkey% syntax
|
||||
"""
|
||||
|
||||
def replacer(match: re.Match) -> str:
|
||||
key = match.group(1).lower()
|
||||
return strings.get(key, match.group(0))
|
||||
|
||||
return re.sub(r"%([^%]+)%", replacer, value)
|
||||
|
||||
|
||||
def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf:
|
||||
"""Parse a Windows INF file and extract driver metadata.
|
||||
|
||||
Args:
|
||||
inf_text: Decoded text content of the .inf file.
|
||||
inf_filename: Name of the .inf file (used as-is in the result).
|
||||
zip_names: List of all member paths from the containing ZIP archive.
|
||||
Used for unused-file detection and .cat presence check.
|
||||
|
||||
Returns:
|
||||
ParsedInf dataclass with driver_names, architecture, has_cat_file,
|
||||
unused_files, and inf_filename.
|
||||
|
||||
Notes:
|
||||
- Uses RawConfigParser (NOT ConfigParser) to avoid %(interpolation)s
|
||||
interference with %TOKEN% INF syntax.
|
||||
- strict=False is required because real INF files frequently have
|
||||
duplicate option keys within a section (multiple hardware IDs).
|
||||
- Architecture detection: if exactly one arch hint found -> return it;
|
||||
multiple arch hints -> None (ambiguous / multi-arch INF).
|
||||
- driver_names are sorted for deterministic dropdown order.
|
||||
"""
|
||||
parser = configparser.RawConfigParser(
|
||||
comment_prefixes=(";", "#"),
|
||||
strict=False, # real INFs have duplicate keys
|
||||
delimiters=("=",),
|
||||
)
|
||||
# Preserve original option-key casing so DriverDesc literals keep their case.
|
||||
# configparser lowercases keys by default, which would mangle "Acme SuperPrint 9000"
|
||||
# into "acme superprint 9000". We disable that behaviour here and manually lowercase
|
||||
# only when building the [Strings] lookup dict.
|
||||
parser.optionxform = str # type: ignore[assignment]
|
||||
parser.read_string(_neutralize_bare_lines(inf_text))
|
||||
|
||||
# Build strings lookup with LOWERCASED keys (case-insensitive token resolution).
|
||||
# INF token references are case-insensitive per WDK spec.
|
||||
strings: dict[str, str] = {}
|
||||
if parser.has_section("Strings"):
|
||||
for key, val in parser.items("Strings"):
|
||||
# INF string values are typically surrounded by double-quotes; strip them.
|
||||
strings[key.lower()] = val.strip('"')
|
||||
|
||||
# Collect Models section base names from [Manufacturer]
|
||||
# Format per WDK: mfg-id = models-section-name[,target-OS-version[,target-OS-version...]]
|
||||
models_section_names: list[str] = []
|
||||
if parser.has_section("Manufacturer"):
|
||||
for _mfg_key, mfg_val in parser.items("Manufacturer"):
|
||||
# Resolve any %TOKEN% in the manufacturer value (rare, but safe)
|
||||
resolved_val = _resolve_tokens(mfg_val, strings)
|
||||
parts = [p.strip() for p in resolved_val.split(",")]
|
||||
if parts:
|
||||
models_section_names.append(parts[0])
|
||||
|
||||
# For each referenced Models section base name, find all matching sections
|
||||
# (undecorated, .NTamd64, .NTarm64, .NTx86, etc.) and extract DriverDesc entries.
|
||||
driver_names: set[str] = set()
|
||||
arch_hints: set[str] = set()
|
||||
|
||||
all_sections_lower = {s.lower(): s for s in parser.sections()}
|
||||
|
||||
for base_name in models_section_names:
|
||||
base_lower = base_name.lower()
|
||||
for section_lower, section in all_sections_lower.items():
|
||||
# Match: exact base name (undecorated) OR base name + .NT<arch> decoration
|
||||
if section_lower == base_lower:
|
||||
# Undecorated section — architecture hint: x86
|
||||
arch_hints.add("x86")
|
||||
suffix = ""
|
||||
elif section_lower.startswith(base_lower + ".nt"):
|
||||
suffix = section_lower[len(base_lower):] # e.g. ".ntamd64"
|
||||
if "amd64" in suffix:
|
||||
arch_hints.add("x64")
|
||||
elif "arm64" in suffix:
|
||||
arch_hints.add("arm64")
|
||||
elif "x86" in suffix:
|
||||
arch_hints.add("x86")
|
||||
else:
|
||||
# Generic .NT decoration (no specific arch) — treat as x86
|
||||
arch_hints.add("x86")
|
||||
else:
|
||||
continue
|
||||
|
||||
# Each option key in a Models section is a device-description (DriverDesc)
|
||||
for key, _val in parser.items(section):
|
||||
if key.startswith("__bare_"):
|
||||
continue
|
||||
resolved = _resolve_tokens(key, strings)
|
||||
# Skip empty, purely numeric, or clearly non-driver-name entries
|
||||
if resolved and not resolved.isdigit():
|
||||
driver_names.add(resolved)
|
||||
|
||||
# Architecture: unambiguous only when exactly one arch hint found
|
||||
architecture: str | None = arch_hints.pop() if len(arch_hints) == 1 else None
|
||||
|
||||
# .cat file detection
|
||||
has_cat_file = any(name.lower().endswith(".cat") for name in zip_names)
|
||||
|
||||
# Unused files: ZIP members whose basename does not appear anywhere in INF text
|
||||
inf_lower = inf_text.lower()
|
||||
unused_files: list[str] = []
|
||||
for member in zip_names:
|
||||
# Normalise path separators, then take basename
|
||||
basename = member.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
if basename.lower() not in inf_lower:
|
||||
unused_files.append(member)
|
||||
|
||||
return ParsedInf(
|
||||
driver_names=sorted(driver_names),
|
||||
inf_filename=inf_filename,
|
||||
architecture=architecture,
|
||||
has_cat_file=has_cat_file,
|
||||
unused_files=unused_files,
|
||||
)
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,166 @@
|
||||
/* ImpTune layout — supplements Pico CSS */
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.layout > * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Sidebar — override Pico's default nav styling (which is horizontal flex) */
|
||||
nav.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
flex: 0 0 220px;
|
||||
border-right: 1px solid var(--pico-muted-border-color, #e0e0e0);
|
||||
padding: 1rem 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
nav.sidebar ul,
|
||||
nav.sidebar li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
nav.sidebar ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav.sidebar li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Override Pico's horizontal nav ul default */
|
||||
.sidebar nav,
|
||||
.sidebar-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-nav li {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
background-color: var(--pico-secondary-background, rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
.sidebar-nav a.active {
|
||||
font-weight: 600;
|
||||
background-color: var(--pico-primary-background, rgba(0,0,0,0.08));
|
||||
border-left-color: var(--pico-primary, #1a73e8);
|
||||
}
|
||||
|
||||
/* Main wrapper: fills remaining space beside sidebar, stacks topbar + content */
|
||||
.main-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Topbar: holds top-right controls */
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.topbar-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topbar-controls button {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 2.2rem;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 1.5rem 2rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Quick action buttons */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--pico-primary, #1a73e8);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--pico-primary, #1a73e8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-action[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
color: var(--pico-muted-color, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recent activity */
|
||||
.activity-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+4
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
# Storage package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
"""Content-addressed file storage for driver packages."""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DriverStore:
|
||||
"""SHA256 content-addressed file storage for printer driver packages.
|
||||
|
||||
Files are stored as DRIVERS_DIR/{sha256}.zip so identical uploads are
|
||||
deduplicated automatically. The .zip suffix lets operators identify
|
||||
stored driver packages by type when browsing the volume directly.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str) -> None:
|
||||
self._base = Path(base_dir)
|
||||
self._base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save(self, data: bytes) -> str:
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
dest = self.get_path(digest)
|
||||
if not dest.exists():
|
||||
dest.write_bytes(data)
|
||||
return digest
|
||||
|
||||
def get_path(self, sha256: str) -> Path:
|
||||
return self._base / f"{sha256}.zip"
|
||||
|
||||
def exists(self, sha256: str) -> bool:
|
||||
return self.get_path(sha256).exists()
|
||||
@@ -0,0 +1,341 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Theme store: cycles Light -> Dark -> System, persists in localStorage
|
||||
Alpine.store('theme', {
|
||||
current: localStorage.getItem('imptune_theme') || 'auto',
|
||||
icons: { light: '☀', dark: '☾', auto: '◑' },
|
||||
init() {
|
||||
document.documentElement.setAttribute('data-theme', this.current);
|
||||
},
|
||||
cycle() {
|
||||
const order = ['light', 'dark', 'auto'];
|
||||
this.current = order[(order.indexOf(this.current) + 1) % order.length];
|
||||
localStorage.setItem('imptune_theme', this.current);
|
||||
document.documentElement.setAttribute('data-theme', this.current);
|
||||
}
|
||||
});
|
||||
|
||||
// i18n store: FR/EN toggle, persists in localStorage
|
||||
Alpine.store('i18n', {
|
||||
lang: (() => {
|
||||
const saved = localStorage.getItem('imptune_lang');
|
||||
if (saved) return saved;
|
||||
return navigator.language && navigator.language.startsWith('en') ? 'en' : 'fr';
|
||||
})(),
|
||||
t(key) {
|
||||
return (this.translations[this.lang] || {})[key] || key;
|
||||
},
|
||||
toggle() {
|
||||
this.lang = this.lang === 'fr' ? 'en' : 'fr';
|
||||
localStorage.setItem('imptune_lang', this.lang);
|
||||
},
|
||||
translations: {
|
||||
fr: {
|
||||
dashboard: 'Tableau de bord',
|
||||
drivers: 'Pilotes',
|
||||
printers: 'Imprimantes',
|
||||
clients: 'Clients',
|
||||
packages: 'Paquets',
|
||||
add_printer: 'Ajouter une imprimante',
|
||||
add_client: 'Ajouter un client',
|
||||
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
|
||||
edit: 'Modifier',
|
||||
delete: 'Supprimer',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
upload_driver: 'T\u00e9l\u00e9charger un pilote',
|
||||
printer_name: 'Nom de l\u2019imprimante',
|
||||
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',
|
||||
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.',
|
||||
client_list: 'Liste des clients',
|
||||
name: 'Nom',
|
||||
created: 'Cr\u00e9\u00e9 le',
|
||||
back_to_printers: 'Retour aux imprimantes',
|
||||
theme_label: 'Th\u00e8me',
|
||||
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
|
||||
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
|
||||
th_name: 'Nom',
|
||||
th_ip: 'Adresse IP',
|
||||
th_port: 'Port',
|
||||
th_driver: 'Pilote',
|
||||
th_duplex: 'Recto-verso',
|
||||
th_color: 'Couleur',
|
||||
th_paper: 'Format',
|
||||
th_collate: 'Assemblage',
|
||||
th_actions: 'Actions',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
no_driver_assigned: '\u2014',
|
||||
// Clients page
|
||||
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',
|
||||
printers_section: 'Imprimantes',
|
||||
// Drivers page
|
||||
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',
|
||||
driver_names_col: 'Nom(s) du pilote',
|
||||
architecture: 'Architecture',
|
||||
uploaded_at: 'T\u00e9l\u00e9charg\u00e9 le',
|
||||
unknown: 'Inconnu',
|
||||
no_drivers: 'Aucun pilote t\u00e9l\u00e9charg\u00e9.',
|
||||
// Packages page
|
||||
packages_title: 'Paquets',
|
||||
packages_description: 'Imprimantes avec pilotes assign\u00e9s \u2014 pr\u00eates pour l\u2019export.',
|
||||
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
|
||||
configuration: 'Configuration',
|
||||
duplex_mode_label: 'Mode recto-verso',
|
||||
color_mode_label: 'Mode couleur',
|
||||
color_value: 'Couleur',
|
||||
grayscale_value: 'Niveaux de gris',
|
||||
paper_size_label: 'Format papier',
|
||||
collate_label: 'Assemblage',
|
||||
client_label: 'Client',
|
||||
unassigned: 'Non assign\u00e9',
|
||||
driver_section: 'Pilote',
|
||||
package_label: 'Package',
|
||||
driver_names_label: 'Nom(s) du pilote',
|
||||
architecture_label: 'Architecture',
|
||||
no_driver_detail: 'Aucun pilote assign\u00e9',
|
||||
intune_commands: 'Commandes Intune',
|
||||
install_cmd_label: 'Commande d\u2019installation',
|
||||
uninstall_cmd_label: 'Commande de d\u00e9sinstallation',
|
||||
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',
|
||||
back_to_printers_btn: 'Retour aux imprimantes',
|
||||
// Edit modal
|
||||
edit_printer_title: 'Modifier l\u2019imprimante',
|
||||
// Driver upload section
|
||||
driver_package_label: 'Package de pilote (ZIP contenant .inf + fichiers pilote)'
|
||||
},
|
||||
en: {
|
||||
dashboard: 'Dashboard',
|
||||
drivers: 'Drivers',
|
||||
printers: 'Printers',
|
||||
clients: 'Clients',
|
||||
packages: 'Packages',
|
||||
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',
|
||||
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',
|
||||
collate: 'Collate',
|
||||
client: 'Client',
|
||||
edit_printer: 'Edit Printer',
|
||||
no_printers: 'No printers configured yet.',
|
||||
no_clients: 'No clients configured yet.',
|
||||
client_list: 'Client List',
|
||||
name: 'Name',
|
||||
created: 'Created',
|
||||
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
|
||||
th_name: 'Name',
|
||||
th_ip: 'IP Address',
|
||||
th_port: 'Port',
|
||||
th_driver: 'Driver',
|
||||
th_duplex: 'Duplex',
|
||||
th_color: 'Color',
|
||||
th_paper: 'Paper',
|
||||
th_collate: 'Collate',
|
||||
th_actions: 'Actions',
|
||||
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',
|
||||
printers_section: 'Printers',
|
||||
// Drivers page
|
||||
drivers_title: 'Drivers',
|
||||
upload_driver_section: 'Upload Driver Package',
|
||||
driver_library: 'Driver Library',
|
||||
uploading: 'Uploading...',
|
||||
upload_btn: 'Upload',
|
||||
driver_filename: 'Filename',
|
||||
driver_names_col: 'Driver Name(s)',
|
||||
architecture: 'Architecture',
|
||||
uploaded_at: 'Uploaded',
|
||||
unknown: 'Unknown',
|
||||
no_drivers: 'No drivers uploaded yet.',
|
||||
// Packages page
|
||||
packages_title: 'Packages',
|
||||
packages_description: 'Printers with drivers assigned \u2014 ready for deployment package 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
|
||||
configuration: 'Configuration',
|
||||
duplex_mode_label: 'Duplex Mode',
|
||||
color_mode_label: 'Color Mode',
|
||||
color_value: 'Color',
|
||||
grayscale_value: 'Grayscale',
|
||||
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)',
|
||||
architecture_label: 'Architecture',
|
||||
no_driver_detail: 'No driver assigned',
|
||||
intune_commands: 'Intune Commands',
|
||||
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',
|
||||
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',
|
||||
// Edit modal
|
||||
edit_printer_title: 'Edit Printer',
|
||||
// Driver upload section
|
||||
driver_package_label: 'Driver Package (ZIP containing .inf + driver files)'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script defer src="/static/alpine.min.js"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<strong>ImpTune</strong>
|
||||
</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>
|
||||
</nav>
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-controls" x-data>
|
||||
<!-- Theme toggle button: cycles Light -> Dark -> System -->
|
||||
<button class="secondary outline"
|
||||
x-html="$store.theme.icons[$store.theme.current]"
|
||||
:aria-label="$store.theme.current"
|
||||
@click="$store.theme.cycle()"
|
||||
title="Toggle theme">◑</button>
|
||||
<!-- Language toggle button -->
|
||||
<button class="secondary outline"
|
||||
x-text="$store.i18n.t('lang_label')"
|
||||
@click="$store.i18n.toggle()"
|
||||
title="Toggle language">FR</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ client.name }}</h1>
|
||||
<p><a href="/clients" x-data x-text="$store.i18n.t('back_to_clients')">← All Clients</a></p>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('clients')">Clients</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('client_list_section')">Client List</h2>
|
||||
{% include "partials/client_list.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('dashboard')">Dashboard</h1>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section class="recent-activity">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_activity')">Recent Activity</h2>
|
||||
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('drivers_title')">Drivers</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('driver_library')">Driver Library</h2>
|
||||
{% include "partials/driver_list.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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>
|
||||
|
||||
{% if printers %}
|
||||
<figure>
|
||||
<table role="grid">
|
||||
<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('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>
|
||||
|
|
||||
<a href="/printers/{{ printer.id }}/packages/ninja">NinjaRMM ZIP</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
<div id="client-list">
|
||||
{% if not clients %}
|
||||
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<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):
|
||||
<details>
|
||||
<summary>Show unused files</summary>
|
||||
<ul>
|
||||
{% for f in parsed.unused_files %}
|
||||
<li>{{ f }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</p>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% include "partials/driver_list.html" %}
|
||||
|
||||
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
|
||||
<option value="">-- 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(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -0,0 +1,103 @@
|
||||
<!-- Edit trigger button — placed in Actions column -->
|
||||
<button class="secondary outline"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
|
||||
x-data x-text="$store.i18n.t('edit')">
|
||||
Edit
|
||||
</button>
|
||||
|
||||
<!-- Edit dialog — Pico CSS 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
|
||||
x-text="$store.i18n.t('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
</dialog>
|
||||
@@ -0,0 +1,99 @@
|
||||
<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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<div id="printer-list">
|
||||
{% if not grouped %}
|
||||
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
|
||||
{% 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>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('printers')">Printers</h1>
|
||||
|
||||
<p><a href="/printers/new" role="button" x-data x-text="$store.i18n.t('add_printer')">Add Printer</a></p>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('printer_library')">Printer Library</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('add_printer_title')">Add Printer</h1>
|
||||
|
||||
<p><a href="/printers" x-data x-text="$store.i18n.t('back_to_printer_library')">← Back to Printer Library</a></p>
|
||||
|
||||
<div x-data="{ ip: '', port: '', portEdited: false }">
|
||||
<form action="/printers" method="post">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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" style="display:none"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Generated by ImpTune — Detection script for {{ printer_name }}
|
||||
$printer = Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
|
||||
if ($printer) {
|
||||
Write-Output "Installed: {{ printer_name }}"
|
||||
exit 0
|
||||
} else {
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Generated by ImpTune — Printer: {{ printer_name }}
|
||||
# Install command: powershell.exe -NoProfile -ExecutionPolicy Bypass -File "install.ps1"
|
||||
#
|
||||
# This script installs the printer "{{ printer_name }}" via Intune Win32 app deployment.
|
||||
# It must be launched with -File (not -Command) so $PSScriptRoot is populated.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WOW64 Guard — relaunch in 64-bit PowerShell if running under WOW64 (Intune
|
||||
# runs Win32 app scripts in a 32-bit process; pnputil is 64-bit only).
|
||||
# This block MUST be the first executable code in the script.
|
||||
# ---------------------------------------------------------------------------
|
||||
if ($env:PROCESSOR_ARCHITECTURE -eq "x86" -and $env:PROCESSOR_ARCHITEW6432) {
|
||||
$ps64 = "$env:WINDIR\SysNative\WindowsPowerShell\v1.0\powershell.exe"
|
||||
& $ps64 -NoProfile -ExecutionPolicy Bypass -File "$PSCommandPath" @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SYSTEM vs User context detection + UAC self-elevation
|
||||
# Skip elevation when already running as SYSTEM (Intune MDM context).
|
||||
# ---------------------------------------------------------------------------
|
||||
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$isSystem = $id.IsSystem
|
||||
$isAdmin = ([System.Security.Principal.WindowsPrincipal]$id).IsInRole(
|
||||
[System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
|
||||
if (-not $isSystem -and -not $isAdmin) {
|
||||
Start-Process powershell.exe `
|
||||
-Verb Runas `
|
||||
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" `
|
||||
-Wait
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pnputil two-step driver staging
|
||||
# Step 1: Stage INF + all associated files into Windows Driver Store
|
||||
# Step 2: Install the named driver from the Driver Store
|
||||
# ---------------------------------------------------------------------------
|
||||
pnputil.exe /add-driver "$PSScriptRoot\drivers\{{ inf_filename }}" /install
|
||||
|
||||
Add-PrinterDriver -Name "{{ driver_name }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotent port creation
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not (Get-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue)) {
|
||||
Add-PrinterPort -Name "{{ port_name }}" -PrinterHostAddress "{{ ip_address }}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotent printer creation
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not (Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue)) {
|
||||
Add-Printer -Name "{{ printer_name }}" `
|
||||
-PortName "{{ port_name }}" `
|
||||
-DriverName "{{ driver_name }}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configure print settings
|
||||
# ---------------------------------------------------------------------------
|
||||
Set-PrintConfiguration -PrinterName "{{ printer_name }}" `
|
||||
-DuplexingMode {{ duplex_mode }} `
|
||||
-Color ${{ color }} `
|
||||
-PaperSize {{ paper_size }} `
|
||||
-Collate ${{ collate }}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by ImpTune — Uninstall script for {{ printer_name }}
|
||||
Remove-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
|
||||
Remove-PrinterDriver -Name "{{ driver_name }}" -ErrorAction SilentlyContinue
|
||||
Remove-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue
|
||||
Reference in New Issue
Block a user