"""HTTP endpoint for uploading House Plan manual files. Files (PDF and the like) are uploaded not over WebSocket (its message size limit breaks the connection on a large PDF) but via a plain multipart POST — like media in HA itself. """ from __future__ import annotations import logging from pathlib import Path from aiohttp import web from homeassistant.components.http import HomeAssistantView try: # KEY_HASS — the modern way to access hass from the aiohttp application from homeassistant.components.http import KEY_HASS except ImportError: # older HA versions KEY_HASS = "hass" # type: ignore[assignment] from homeassistant.core import HomeAssistant from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL from .store import get_entry from .validation import ( FILE_EXTENSIONS, MAX_FILE_BYTES, file_ext, sanitize_filename, sanitize_marker_id, ) _LOGGER = logging.getLogger(__name__) _CHUNK = 64 * 1024 class HouseplanUploadView(HomeAssistantView): """POST /api/houseplan/upload — save a marker file, return its URL.""" url = "/api/houseplan/upload" name = "api:houseplan:upload" requires_auth = True async def post(self, request: web.Request) -> web.Response: hass: HomeAssistant = request.app[KEY_HASS] entry = get_entry(hass) admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False)) if admin_only: user = request.get("hass_user") if user is None or not user.is_admin: return web.json_response({"error": "unauthorized"}, status=403) marker_id = "misc" filename: str | None = None blob: bytes | None = None too_large = False try: reader = await request.multipart() async for part in reader: if part.name == "marker_id": marker_id = sanitize_marker_id(await part.text()) elif part.name == "file": filename = part.filename or "file" # read in chunks, aborting at the limit, instead of loading the whole file into memory chunks: list[bytes] = [] size = 0 while chunk := await part.read_chunk(_CHUNK): size += len(chunk) if size > MAX_FILE_BYTES: too_large = True break chunks.append(chunk) if too_large: break blob = b"".join(chunks) except Exception as err: # noqa: BLE001 _LOGGER.warning("House Plan upload: multipart read error: %s", err) return web.json_response({"error": "bad_request"}, status=400) if too_large: return web.json_response( {"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413 ) if blob is None or not filename: return web.json_response({"error": "no_file"}, status=400) ext = file_ext(filename) if ext not in FILE_EXTENSIONS: return web.json_response( {"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400 ) safe_name = sanitize_filename(filename) target_dir = Path(hass.config.path(FILES_DIR)) / marker_id path = target_dir / safe_name def _write() -> int: target_dir.mkdir(parents=True, exist_ok=True) path.write_bytes(blob) return int(path.stat().st_mtime) mtime = await hass.async_add_executor_job(_write) return web.json_response( {"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename} )