diff --git a/imptune/api/icons.py b/imptune/api/icons.py new file mode 100644 index 0000000..af123da --- /dev/null +++ b/imptune/api/icons.py @@ -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="
Printer not found.
", + 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="Icon exceeds 750 KB limit.
", + status_code=422, + ) + + # Validate with Pillow + try: + img = Image.open(io.BytesIO(data)) + except Exception: + return HTMLResponse( + content="Icon must be PNG format.
", + status_code=422, + ) + + if img.format != "PNG": + return HTMLResponse( + content="Icon must be PNG format.
", + status_code=422, + ) + + if img.size != (256, 256): + return HTMLResponse( + content=f"Icon must be 256x256 pixels, got {img.size}.
", + 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="Icon uploaded successfully
", + status_code=200, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 5d955ae..8d8cdf8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ def tmp_data_dir(tmp_path, monkeypatch): cfg.DATA_DIR = str(data_dir) cfg.DB_PATH = str(data_dir / "imptune.db") cfg.DRIVERS_DIR = str(data_dir / "drivers") + cfg.ICONS_DIR = str(data_dir / "icons") yield data_dir