Files
Home-Assistant/custom_components/maintenance_supporter/views.py
T
2026-07-08 10:43:39 -04:00

252 lines
10 KiB
Python

"""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 ``<a>``/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, GLOBAL_UNIQUE_ID
from .helpers import documents as docmod
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"
# 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."""
if not user_can_write(self.hass, request["hass_user"]):
return self.json_message("Not authorized", HTTPStatus.FORBIDDEN)
# 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)
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
return self.json_message("Object not found", HTTPStatus.NOT_FOUND)
from .websocket import object_id_for_entry
content = await self.hass.async_add_executor_job(file_field.file.read)
filename = file_field.filename or "document"
mime = file_field.content_type or "application/octet-stream"
title = data.get("title")
tags = [t.strip() for t in data.getall("tags", []) if isinstance(t, str) and t.strip()][:20]
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,
)
except ValueError:
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",
},
)
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.data[_VIEWS_REGISTERED_KEY] = True