"""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 hashlib import json import logging import os import shutil import tempfile from datetime import datetime, timezone from functools import partial from pathlib import Path from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant 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 .asset_integrity import get_asset_integrity_verifier from .auth import may_write from .const import ( ASSETS_DIR, CONTENT_URL, FILES_DIR, MAX_DECOR_ASSET_BYTES, MAX_DECOR_ASSETS_BYTES, MAX_DECOR_ASSETS_COUNT, MAX_EXPORT_BYTES, MAX_FILES_BYTES, MAX_FILES_COUNT, MIN_FREE_BYTES, PLANS_DIR, ) from .decor_assets import ( ASSET_EXTENSIONS, ASSET_ID_RE, DecorAssetError, asset_meta_path, physical_asset_usage, public_asset, read_asset, validate_asset, ) from .import_export import ImportFailure, create_preview from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename from .registry_snapshot import import_registry_snapshot from .store import get_data from .validation import ( FILE_EXTENSIONS, MAX_FILE_BYTES, file_ext, sanitize_filename, sanitize_marker_id, ) _LOGGER = logging.getLogger(__name__) _CHUNK = 64 * 1024 # batch disk writes: one executor job per megabyte instead of per chunk _FLUSH_AT = 1024 * 1024 _MIME = { ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".gif": "image/gif", ".txt": "text/plain", } class HouseplanImportPreviewView(HomeAssistantView): """Upload a bounded JSON backup and return a server-side preview token.""" url = "/api/houseplan/import/preview" name = "api:houseplan:import-preview" requires_auth = True async def post(self, request: web.Request) -> web.Response: hass: HomeAssistant = request.app[KEY_HASS] user = request.get("hass_user") if not may_write(hass, user): return web.json_response({"error": "unauthorized"}, status=403) runtime = get_data(hass) if runtime is None: return web.json_response({"error": "not_ready"}, status=503) policy = request.query.get("duplicate_policy", "skip") if policy not in ("skip", "virtual"): return web.json_response({"error": "invalid_format"}, status=400) declared = request.content_length if declared is not None and declared > MAX_EXPORT_BYTES: return web.json_response({"error": "too_large"}, status=413) blocks: list[bytes] = [] size = 0 async for block in request.content.iter_chunked(_CHUNK): size += len(block) if size > MAX_EXPORT_BYTES: return web.json_response({"error": "too_large"}, status=413) blocks.append(block) owner_id = str(getattr(user, "id", "")) try: # Hold the global writer only while taking one coherent store # snapshot. Parsing up to 8 MiB, schema validation and space remap # are CPU work and apply will revalidate both revisions anyway. async with runtime.write_lock: config_data = await runtime.config_store.async_load() or {} layout_data = await runtime.store.async_load() or {} try: registry_snapshot = import_registry_snapshot(hass) except Exception: # noqa: BLE001 - summary must not block a valid backup _LOGGER.debug("House Plan import registry summary unavailable", exc_info=True) registry_snapshot = None result = await hass.async_add_executor_job( partial( create_preview, runtime, b"".join(blocks), owner_id=owner_id, duplicate_policy=policy, current_config_data=config_data, current_layout_data=layout_data, config_root=Path(hass.config.path("")), registry_snapshot=registry_snapshot, ) ) except ImportFailure as err: status = 413 if err.code == "too_large" else 400 return web.json_response({"error": err.code, "message": err.message}, status=status) except Exception: # noqa: BLE001 - the HTTP boundary must answer 400, never leak a traceback _LOGGER.exception("House Plan import preview failed") return web.json_response({"error": "invalid_format"}, status=400) return web.json_response(result) class HouseplanContentView(HomeAssistantView): """Authenticated read access to plans and marker files (audit B1). The directories used to be exposed as unauthenticated static paths, so anyone who could reach the HA endpoint could pull floor plans and uploaded manuals without logging in. This view keeps the same URLs but requires a Home Assistant session (or a signed path, which the frontend uses for inside the SVG). """ url = "/api/houseplan/content/{kind}/{sub}/{name}" name = "api:houseplan:content" requires_auth = True async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse: hass: HomeAssistant = request.app[KEY_HASS] if kind not in ("plans", "files", "assets"): return web.Response(status=404) safe_sub = sanitize_marker_id(sub) safe_name = sanitize_filename(name) if not safe_sub or not safe_name: return web.Response(status=404) root_dir = PLANS_DIR if kind == "plans" else ASSETS_DIR if kind == "assets" else FILES_DIR base = Path(hass.config.path(root_dir)).resolve() # plans live flat in one directory: the sub segment is a placeholder ("_") if kind == "assets": stem, suffix = Path(safe_name).stem, Path(safe_name).suffix.lower() if safe_sub != "_" or not ASSET_ID_RE.fullmatch(stem) or suffix not in ASSET_EXTENSIONS: return web.Response(status=404) path = (base / safe_name).resolve() else: path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve() # defence in depth: the sanitizers already strip separators if not str(path).startswith(str(base)): return web.Response(status=404) suffix = path.suffix.lower() if kind == "assets": verifier = get_asset_integrity_verifier(hass) if not await hass.async_add_executor_job(verifier.verify, path, path.stem): return web.Response(status=404) elif not await hass.async_add_executor_job(path.is_file): return web.Response(status=404) headers = { "Cache-Control": "private, max-age=31536000, immutable" if kind == "assets" else "private, max-age=3600", "Content-Type": _MIME.get(suffix, "application/octet-stream"), "X-Content-Type-Options": "nosniff", } if suffix == ".svg": # An uploaded SVG is user content served from Home Assistant's own # origin. Inside the card it is referenced by , where scripts # never run — but the same url opened as a top-level document is a # live document of this origin, and a