393 files
This commit is contained in:
@@ -25,8 +25,9 @@ from aiohttp import hdrs, web
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.http import HomeAssistantView
|
||||
|
||||
from .const import DOMAIN, GLOBAL_UNIQUE_ID, MAX_DOCS_PER_OBJECT, MAX_NAME_LENGTH
|
||||
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
|
||||
|
||||
@@ -36,6 +37,9 @@ 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"
|
||||
|
||||
@@ -78,9 +82,18 @@ class DocumentUploadView(HomeAssistantView):
|
||||
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"]):
|
||||
"""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.
|
||||
@@ -91,14 +104,34 @@ class DocumentUploadView(HomeAssistantView):
|
||||
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 entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_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,
|
||||
@@ -274,22 +307,47 @@ class DocumentsArchiveView(HomeAssistantView):
|
||||
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=…)."""
|
||||
"""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)
|
||||
from .helpers.doc_archive import build_documents_archive
|
||||
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
|
||||
content = await self.hass.async_add_executor_job(build_documents_archive, self.hass, entry_ids)
|
||||
return web.Response(
|
||||
body=content,
|
||||
content_type="application/zip",
|
||||
headers={
|
||||
hdrs.CONTENT_DISPOSITION: _content_disposition("maintenance-documents.zip", inline=False),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
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)."""
|
||||
|
||||
Reference in New Issue
Block a user