252 lines
10 KiB
Python
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
|