1
0
mirror of https://github.com/home-assistant/core.git synced 2026-06-03 22:24:02 +01:00
Files
core/homeassistant/components/backup/http.py
T
2026-04-30 11:38:49 +02:00

215 lines
7.4 KiB
Python

"""Http view for the Backup integration."""
import asyncio
from http import HTTPStatus
import threading
from typing import IO, cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
from aiohttp.web import FileResponse, Request, Response, StreamResponse
from multidict import istr
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import frame
from homeassistant.util import slugify
from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter
from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
@callback
def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views."""
hass.http.register_view(DownloadBackupView)
hass.http.register_view(UploadBackupView)
class DownloadBackupView(HomeAssistantView):
"""Generate backup view."""
url = "/api/backup/download/{backup_id}"
name = "api:backup:download"
async def get(
self,
request: Request,
backup_id: str,
) -> StreamResponse | FileResponse | Response:
"""Download a backup file."""
if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED)
try:
agent_id = request.query.getone("agent_id")
except KeyError:
return Response(status=HTTPStatus.BAD_REQUEST)
try:
password = request.query.getone("password")
except KeyError:
password = None
hass = request.app[KEY_HASS]
manager = hass.data[DATA_MANAGER]
if agent_id not in manager.backup_agents:
return Response(status=HTTPStatus.BAD_REQUEST)
agent = manager.backup_agents[agent_id]
try:
backup = await agent.async_get_backup(backup_id)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
# Check for None to be backwards compatible with the old BackupAgent API,
# this can be removed in HA Core 2025.10
if not backup:
frame.report_usage(
"returns None from BackupAgent.async_get_backup",
breaks_in_ha_version="2025.10",
integration_domain=agent_id.partition(".")[0],
)
return Response(status=HTTPStatus.NOT_FOUND)
headers = {
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar",
CONTENT_TYPE: "application/x-tar",
}
try:
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
return await self._send_backup_with_password(
hass,
backup,
request,
headers,
backup_id,
agent_id,
password,
agent,
manager,
)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
async def _send_backup_no_password(
self,
request: Request,
headers: dict[istr, str],
backup_id: str,
agent_id: str,
agent: BackupAgent,
manager: BackupManager,
) -> StreamResponse | FileResponse | Response:
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
# We don't need to check if the path exists, aiohttp.FileResponse will
# handle that
path = local_agent.get_backup_path(backup_id)
return FileResponse(path=path.as_posix(), headers=headers)
stream = await agent.async_download_backup(backup_id)
response = StreamResponse(status=HTTPStatus.OK, headers=headers)
await response.prepare(request)
async for chunk in stream:
await response.write(chunk)
return response
async def _send_backup_with_password(
self,
hass: HomeAssistant,
backup: AgentBackup,
request: Request,
headers: dict[istr, str],
backup_id: str,
agent_id: str,
password: str,
agent: BackupAgent,
manager: BackupManager,
) -> StreamResponse | FileResponse | Response:
reader: IO[bytes]
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
path = local_agent.get_backup_path(backup_id)
try:
reader = await hass.async_add_executor_job(open, path.as_posix(), "rb")
except FileNotFoundError:
return Response(status=HTTPStatus.NOT_FOUND)
else:
stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], AsyncIteratorReader(hass.loop, stream))
worker_done_event = asyncio.Event()
def on_done(error: Exception | None) -> None:
"""Call by the worker thread when it's done."""
hass.loop.call_soon_threadsafe(worker_done_event.set)
stream = AsyncIteratorWriter(hass.loop)
worker = threading.Thread(
target=util.decrypt_backup,
args=[backup, reader, stream, password, on_done, 0, []],
)
try:
worker.start()
response = StreamResponse(status=HTTPStatus.OK, headers=headers)
await response.prepare(request)
async for chunk in stream:
await response.write(chunk)
return response
finally:
reader.close()
await worker_done_event.wait()
class UploadBackupView(HomeAssistantView):
"""Upload backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
async def post(self, request: Request) -> Response:
"""Upload a backup file."""
return await self._post(request)
async def _post(self, request: Request) -> Response:
"""Upload a backup file."""
try:
agent_ids = request.query.getall("agent_id")
except KeyError:
return Response(status=HTTPStatus.BAD_REQUEST)
manager = request.app[KEY_HASS].data[DATA_MANAGER]
reader = await request.multipart()
contents = cast(BodyPartReader, await reader.next())
try:
backup_id = await manager.async_receive_backup(
contents=contents, agent_ids=agent_ids
)
except InvalidBackupFilename as err:
return Response(
body=str(err),
status=HTTPStatus.BAD_REQUEST,
)
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
except HomeAssistantError as err:
return Response(
body=f"Can't upload backup file: {err}",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED)