"""Authenticated HTTP views for document upload + download. Document blobs live under ``/config`` so they ride along in every Home Assistant backup — which means they must NOT be reachable through an unauthenticated static path (that is exactly why ``/config/www`` → ``/local`` is unsuitable). These two views mirror HA's own ``image_upload`` precedent: * :class:`DocumentUploadView` — ``POST`` a multipart file → stored as a content-addressed, reference-counted blob (write permission required). * :class:`DocumentServeView` — ``GET`` a document's binary content, auth-gated. The path parameter is a document id, resolved server-side to a blob whose name is a validated hex digest, so ``../`` traversal is structurally impossible. The frontend fetches served documents through HA's signed-path mechanism (``auth/sign_path``), so an ````/download works without exposing a token. """ from __future__ import annotations from http import HTTPStatus from typing import cast from urllib.parse import quote from aiohttp import hdrs, web from homeassistant.core import HomeAssistant from homeassistant.helpers.http import HomeAssistantView from .const import DOMAIN, MAX_DOCS_PER_OBJECT, MAX_NAME_LENGTH from .helpers import documents as docmod from .helpers.aggregate import is_object_entry from .helpers.documents import KIND_FILE from .helpers.permissions import user_can_write UPLOAD_URL = "/api/maintenance_supporter/document/upload" SERVE_URL = "/api/maintenance_supporter/document/{doc_id}" EXCERPT_URL = "/api/maintenance_supporter/document/{doc_id}/excerpt" # Documents archive (ZIP with blobs) — the one export carrying file contents. DOCS_ARCHIVE_URL = "/api/maintenance_supporter/documents/archive" # The documents archive is streamed out in pieces of this size. _ARCHIVE_CHUNK_BYTES = 1024 * 1024 # Survives the hass.data[DOMAIN] pop on last-entry unload (see registrar). _VIEWS_REGISTERED_KEY = f"{DOMAIN}_views_registered" # MIME types safe to render inline on the HA origin. Anything else (notably # text/html and image/svg+xml, which can carry active content) is served as an # opaque attachment so a write-capable user's upload can't become stored XSS. _INLINE_SAFE_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg", "image/gif", "image/webp"}) def _content_disposition(filename: str, *, inline: bool = True) -> str: """Build a safe ``Content-Disposition`` header value for ``filename``. ``inline`` (PDFs/known-safe images) previews in the browser; otherwise ``attachment`` forces a download. A plain ASCII fallback plus the RFC 5987 ``filename*`` form together handle non-ASCII names without letting quotes or control chars break the header. """ ascii_name = "".join(c for c in filename if c.isascii() and c not in '"\\\r\n').strip() or "document" kind = "inline" if inline else "attachment" return f"{kind}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}" def _get_store(hass: HomeAssistant) -> docmod.DocumentStore: """Return the loaded document store (an async_setup invariant).""" from . import DOCUMENT_STORE_KEY store: docmod.DocumentStore = hass.data[DOMAIN][DOCUMENT_STORE_KEY] return store class DocumentUploadView(HomeAssistantView): """Upload a file for a maintenance object (multipart, write-gated).""" url = UPLOAD_URL name = "api:maintenance_supporter:document:upload" def __init__(self, hass: HomeAssistant) -> None: """Store the hass reference (the view outlives any single request).""" self.hass = hass async def post(self, request: web.Request) -> web.Response: """Store the uploaded file as a content-addressed blob + metadata. Writers may upload anything. Every other signed-in user may upload a completion PHOTO — completing is open to them, and a task demanding a photo could otherwise not be completed by the household member who did the work (bug audit 2026-09-26): an image, tagged exactly ``photo``, under the same per-object caps. """ user = request["hass_user"] if user is None: return self.json_message("Not authorized", HTTPStatus.FORBIDDEN) may_write = user_can_write(self.hass, user) # aiohttp caps request bodies at 1 MiB by default; allow the full # per-file limit plus a margin for multipart framing. request._client_max_size = docmod.MAX_DOC_BYTES + 1024 * 1024 data = await request.post() entry_id = data.get("entry_id") file_field = data.get("file") if not isinstance(entry_id, str) or not isinstance(file_field, web.FileField): return self.json_message("entry_id and file are required", HTTPStatus.BAD_REQUEST) if not may_write and not ( [t for t in data.getall("tags", []) if isinstance(t, str)] == ["photo"] and (file_field.content_type or "").startswith("image/") ): return self.json_message("Not authorized", HTTPStatus.FORBIDDEN) entry = self.hass.config_entries.async_get_entry(entry_id) if entry is None or not is_object_entry(entry): return self.json_message("Object not found", HTTPStatus.NOT_FOUND) from .websocket import object_id_for_entry from .websocket.documents import _MAX_TAG_LEN, _MAX_TAGS if not may_write: # A non-writer's photos are meant for a completion; a runaway # (or hostile) client could otherwise fill any object's document # slots with orphans nobody but a writer could remove (bug audit # 2026-09-27, R SEC-3). Unattached ones are capped per object — # documents/discard_upload and the retention sweep clear them. from .helpers.completion_requirements import MAX_UNATTACHED_PHOTOS_PER_OBJECT, unattached_photo_ids pending = unattached_photo_ids(self.hass, object_id_for_entry(entry), strict=False) or [] if len(pending) >= MAX_UNATTACHED_PHOTOS_PER_OBJECT: return self.json_message( f"At most {MAX_UNATTACHED_PHOTOS_PER_OBJECT} photos per object may wait for a completion", HTTPStatus.CONFLICT, ) content = await self.hass.async_add_executor_job(file_field.file.read) # Multipart fields bypass the voluptuous caps of the WS document # paths, so cap them here to the SAME limits (title = MAX_NAME_LENGTH, # tag = _MAX_TAG_LEN): the filename is echoed back in every # Content-Disposition header and the title/tags land in the global # store verbatim (bug audit 2026-09-12). filename = (file_field.filename or "document")[:MAX_NAME_LENGTH] mime = file_field.content_type or "application/octet-stream" title = data.get("title") if isinstance(title, str): title = title[:MAX_NAME_LENGTH] tags = [t.strip()[:_MAX_TAG_LEN] for t in data.getall("tags", []) if isinstance(t, str) and t.strip()][:_MAX_TAGS] description = data.get("description") description = description if isinstance(description, str) else None try: doc = await _get_store(self.hass).async_add_file( object_id_for_entry(entry), content=content, filename=filename, mime=mime, title=title.strip() if isinstance(title, str) and title.strip() else None, tags=tags, description=description, ) except ValueError as err: if str(err) == "too_many_documents": return self.json_message( f"At most {MAX_DOCS_PER_OBJECT} documents per object", HTTPStatus.CONFLICT, ) return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) return self.json(doc) class DocumentServeView(HomeAssistantView): """Serve a stored document's binary content (auth-gated, path-validated).""" url = SERVE_URL name = "api:maintenance_supporter:document" def __init__(self, hass: HomeAssistant) -> None: """Store the hass reference (the view outlives any single request).""" self.hass = hass async def get(self, request: web.Request, doc_id: str) -> web.StreamResponse: """Return the blob behind ``doc_id`` (404 for links / missing blobs).""" store = _get_store(self.hass) doc = store.get(doc_id) if doc is None or doc.get("kind") != KIND_FILE: return web.Response(status=HTTPStatus.NOT_FOUND) try: path = store.blob_path(cast(str, doc.get("hash"))) except (ValueError, TypeError): return web.Response(status=HTTPStatus.NOT_FOUND) if not await self.hass.async_add_executor_job(path.is_file): return web.Response(status=HTTPStatus.NOT_FOUND) content = await self.hass.async_add_executor_job(path.read_bytes) # Only preview known-safe types inline; serve anything else (text/html, # image/svg+xml, unknown) as an opaque attachment so a malicious upload # can't execute as script on the HA origin. nosniff blocks sniffing on # top of the declared type. raw_mime = doc.get("mime") or "application/octet-stream" inline_safe = raw_mime in _INLINE_SAFE_MIMES return web.Response( body=content, content_type=raw_mime if inline_safe else "application/octet-stream", headers={ hdrs.CONTENT_DISPOSITION: _content_disposition(doc.get("filename") or "document", inline=inline_safe), "X-Content-Type-Options": "nosniff", }, ) class DocumentExcerptView(HomeAssistantView): """Serve a page range of a stored PDF as its own small PDF (v2.21). Backs the task work sheet's "manual excerpt": the documents feature stores a per-task page hint (``task_pages``), and this view cuts pages ``start .. start+count-1`` out of the attached manual so the user can print exactly the relevant section alongside the work sheet. Requires ``pypdf`` (a manifest requirement); auth-gated like the serve view and reachable through HA's signed-path mechanism. """ url = EXCERPT_URL name = "api:maintenance_supporter:document:excerpt" def __init__(self, hass: HomeAssistant) -> None: """Store the hass reference (the view outlives any single request).""" self.hass = hass async def get(self, request: web.Request, doc_id: str) -> web.StreamResponse: """Return pages ``start``..``start+count-1`` of the blob as a PDF.""" store = _get_store(self.hass) doc = store.get(doc_id) if doc is None or doc.get("kind") != KIND_FILE: return web.Response(status=HTTPStatus.NOT_FOUND) if (doc.get("mime") or "") != "application/pdf": return web.Response(status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE) try: start = int(request.query.get("start", "1")) count = int(request.query.get("count", "4")) except ValueError: return web.Response(status=HTTPStatus.BAD_REQUEST) if start < 1 or not 1 <= count <= 20: return web.Response(status=HTTPStatus.BAD_REQUEST) try: path = store.blob_path(cast(str, doc.get("hash"))) except (ValueError, TypeError): return web.Response(status=HTTPStatus.NOT_FOUND) if not await self.hass.async_add_executor_job(path.is_file): return web.Response(status=HTTPStatus.NOT_FOUND) def _extract() -> bytes | None: import io as _io from pypdf import PdfReader, PdfWriter reader = PdfReader(str(path)) total = len(reader.pages) if start > total: return None writer = PdfWriter() for i in range(start - 1, min(start - 1 + count, total)): writer.add_page(reader.pages[i]) buf = _io.BytesIO() writer.write(buf) return buf.getvalue() try: content = await self.hass.async_add_executor_job(_extract) except Exception: # noqa: BLE001 - a damaged PDF must 4xx, not 500-trace return web.Response(status=HTTPStatus.UNPROCESSABLE_ENTITY) if content is None: return web.Response(status=HTTPStatus.BAD_REQUEST) filename = f"excerpt-p{start}-{doc.get('filename') or 'document'}" return web.Response( body=content, content_type="application/pdf", headers={ hdrs.CONTENT_DISPOSITION: _content_disposition(filename, inline=True), "X-Content-Type-Options": "nosniff", }, ) class DocumentsArchiveView(HomeAssistantView): """Export/import the documents archive — a ZIP that carries file BLOBS. ``GET`` streams a ZIP (manifest + blobs) for all objects, or a selection via ``?entry_ids=a,b``. ``POST`` a ZIP restores blobs + re-attaches metadata. Admin-only both ways (it moves file contents in and out). """ url = DOCS_ARCHIVE_URL name = "api:maintenance_supporter:documents:archive" def __init__(self, hass: HomeAssistant) -> None: """Store the hass reference (the view outlives any single request).""" self.hass = hass def _require_admin(self, request: web.Request) -> bool: user = request.get("hass_user") return bool(user and user.is_admin) async def get(self, request: web.Request) -> web.StreamResponse: """Stream the documents archive ZIP (all objects or ?entry_ids=…). Written to a temporary file and streamed from there in chunks — the whole archive used to be built in memory with no ceiling (bug audit 2026-09-26). A selection larger than an import accepts is refused with 413 and a message saying so. """ if not self._require_admin(request): return web.Response(status=HTTPStatus.FORBIDDEN) import os from .helpers.doc_archive import ArchiveTooLarge, async_build_documents_archive_file raw = request.query.get("entry_ids") entry_ids = {e for e in raw.split(",") if e} if raw else None try: path = await async_build_documents_archive_file(self.hass, entry_ids) except ArchiveTooLarge as err: return self.json_message(str(err), HTTPStatus.REQUEST_ENTITY_TOO_LARGE) run = self.hass.async_add_executor_job try: response = web.StreamResponse( headers={ hdrs.CONTENT_DISPOSITION: _content_disposition("maintenance-documents.zip", inline=False), "X-Content-Type-Options": "nosniff", } ) response.content_type = "application/zip" response.content_length = await run(os.path.getsize, path) await response.prepare(request) handle = await run(open, path, "rb") try: while chunk := await run(handle.read, _ARCHIVE_CHUNK_BYTES): await response.write(chunk) finally: await run(handle.close) await response.write_eof() return response finally: await run(os.unlink, path) async def post(self, request: web.Request) -> web.Response: """Restore a documents archive ZIP (blobs + metadata).""" if not self._require_admin(request): return self.json_message("Not authorized", HTTPStatus.FORBIDDEN) from .helpers.doc_archive import MAX_ARCHIVE_BYTES, import_documents_archive request._client_max_size = MAX_ARCHIVE_BYTES + 1024 * 1024 data = await request.post() file_field = data.get("file") if not isinstance(file_field, web.FileField): return self.json_message("file is required", HTTPStatus.BAD_REQUEST) content = await self.hass.async_add_executor_job(file_field.file.read) result = await import_documents_archive(self.hass, content) if "error" in result: return self.json_message(result["error"], HTTPStatus.BAD_REQUEST) return self.json(result) def async_register_document_views(hass: HomeAssistant) -> None: """Register the upload + serve views on the HTTP component. Guarded outside hass.data[DOMAIN] (which is popped on last-entry unload): the views stay mounted on the HTTP app, so a reinstall in the same HA run must not add them a second time (journey O1). """ if hass.data.get(_VIEWS_REGISTERED_KEY): return hass.http.register_view(DocumentUploadView(hass)) hass.http.register_view(DocumentServeView(hass)) hass.http.register_view(DocumentExcerptView(hass)) hass.http.register_view(DocumentsArchiveView(hass)) hass.data[_VIEWS_REGISTERED_KEY] = True