mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Serve SVG media as attachments (#182095)
This commit is contained in:
@@ -17,6 +17,10 @@ DATA_MEDIA_SOURCE_PLATFORMS: HassKey[LazyIntegrationPlatforms[MediaSource]] = Ha
|
||||
"media_source_platforms"
|
||||
)
|
||||
MEDIA_MIME_TYPES = ("audio", "video", "image")
|
||||
# Media types that pass the check above but are documents a browser executes.
|
||||
# Serving these inline would run them on the Home Assistant origin, where the
|
||||
# frontend keeps its tokens, so the browser is told to download them instead.
|
||||
DOWNLOAD_ONLY_MIME_TYPES = {"image/svg+xml"}
|
||||
MEDIA_CLASS_MAP = {
|
||||
"audio": MediaClass.MUSIC,
|
||||
"video": MediaClass.VIDEO,
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol, cast, override
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp.web_request import FileField
|
||||
import probatio
|
||||
|
||||
@@ -24,7 +24,13 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
|
||||
from .const import DATA_LOCAL_SOURCE, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES
|
||||
from .const import (
|
||||
DATA_LOCAL_SOURCE,
|
||||
DOMAIN,
|
||||
DOWNLOAD_ONLY_MIME_TYPES,
|
||||
MEDIA_CLASS_MAP,
|
||||
MEDIA_MIME_TYPES,
|
||||
)
|
||||
from .error import Unresolvable
|
||||
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
|
||||
|
||||
@@ -349,6 +355,15 @@ class LocalSource(MediaSource):
|
||||
return media
|
||||
|
||||
|
||||
@callback
|
||||
def _async_media_headers(mime_type: str) -> dict[str, str]:
|
||||
"""Return the headers a media file of this type is served with."""
|
||||
if mime_type in DOWNLOAD_ONLY_MIME_TYPES:
|
||||
return {hdrs.CONTENT_DISPOSITION: "attachment"}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
class LocalMediaView(http.HomeAssistantView):
|
||||
"""Local Media Finder View.
|
||||
|
||||
@@ -364,8 +379,10 @@ class LocalMediaView(http.HomeAssistantView):
|
||||
self.name = source.url_prefix.strip("/").replace("/", ":")
|
||||
self.url = f"{source.url_prefix}/{{source_dir_id}}/{{location:.*}}"
|
||||
|
||||
async def _validate_media_path(self, source_dir_id: str, location: str) -> Path:
|
||||
"""Validate media path and return it if valid."""
|
||||
async def _validate_media_path(
|
||||
self, source_dir_id: str, location: str
|
||||
) -> tuple[Path, str]:
|
||||
"""Validate media path and return it with its media type if valid."""
|
||||
try:
|
||||
raise_if_invalid_path(location)
|
||||
except ValueError as err:
|
||||
@@ -385,7 +402,7 @@ class LocalMediaView(http.HomeAssistantView):
|
||||
if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
return media_path
|
||||
return media_path, mime_type
|
||||
|
||||
async def head(
|
||||
self, request: web.Request, source_dir_id: str, location: str
|
||||
@@ -397,16 +414,17 @@ class LocalMediaView(http.HomeAssistantView):
|
||||
|
||||
Check whether the location exists or not.
|
||||
"""
|
||||
media_path = await self._validate_media_path(source_dir_id, location)
|
||||
mime_type, _ = mimetypes.guess_type(str(media_path))
|
||||
return web.Response(content_type=mime_type)
|
||||
_, mime_type = await self._validate_media_path(source_dir_id, location)
|
||||
return web.Response(
|
||||
content_type=mime_type, headers=_async_media_headers(mime_type)
|
||||
)
|
||||
|
||||
async def get(
|
||||
self, request: web.Request, source_dir_id: str, location: str
|
||||
) -> web.FileResponse:
|
||||
"""Handle a GET request."""
|
||||
media_path = await self._validate_media_path(source_dir_id, location)
|
||||
return web.FileResponse(media_path)
|
||||
media_path, mime_type = await self._validate_media_path(source_dir_id, location)
|
||||
return web.FileResponse(media_path, headers=_async_media_headers(mime_type))
|
||||
|
||||
|
||||
class UploadMediaView(http.HomeAssistantView):
|
||||
|
||||
@@ -505,3 +505,73 @@ async def test_remove_file(
|
||||
|
||||
assert not msg["success"]
|
||||
assert to_delete_3.is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "content_type"),
|
||||
[
|
||||
("photo.jpg", "image/jpeg"),
|
||||
("song.mp3", "audio/mpeg"),
|
||||
("clip.mp4", "video/mp4"),
|
||||
],
|
||||
)
|
||||
async def test_media_view_serves_media_inline(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
tmp_path: Path,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
) -> None:
|
||||
"""Test ordinary media is served for the browser to render."""
|
||||
(tmp_path / filename).touch()
|
||||
|
||||
await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await async_setup_component(hass, const.DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.get(f"/media/local/{filename}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert resp.content_type == content_type
|
||||
assert "Content-Disposition" not in resp.headers
|
||||
|
||||
resp = await client.head(f"/media/local/{filename}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert resp.content_type == content_type
|
||||
assert "Content-Disposition" not in resp.headers
|
||||
|
||||
|
||||
async def test_media_view_serves_svg_as_attachment(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test an SVG is downloaded rather than rendered.
|
||||
|
||||
An SVG is a document a browser executes, and these are served from the
|
||||
Home Assistant origin.
|
||||
"""
|
||||
(tmp_path / "drawing.svg").write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
)
|
||||
|
||||
await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await async_setup_component(hass, const.DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.get("/media/local/drawing.svg")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert resp.headers["Content-Disposition"] == "attachment"
|
||||
# Applied to every response by the HTTP integration, asserted here because
|
||||
# it is what keeps the type from being sniffed into something executable
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
resp = await client.head("/media/local/drawing.svg")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert resp.headers["Content-Disposition"] == "attachment"
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
Reference in New Issue
Block a user