custom_components/bahmcloud_store/views.py aktualisiert
This commit is contained in:
@@ -14,13 +14,118 @@ if TYPE_CHECKING:
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_markdown_server_side(md: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Render Markdown -> sanitized HTML.
|
||||||
|
We do NOT rely on Home Assistant internal markdown utilities (they may change),
|
||||||
|
and we do NOT rely on frontend JS libs (marked/DOMPurify may be unavailable).
|
||||||
|
"""
|
||||||
|
text = (md or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
html: str | None = None
|
||||||
|
|
||||||
|
# 1) Try python-markdown (commonly available in HA)
|
||||||
|
try:
|
||||||
|
import markdown as mdlib # type: ignore
|
||||||
|
|
||||||
|
html = mdlib.markdown(
|
||||||
|
text,
|
||||||
|
extensions=[
|
||||||
|
"fenced_code",
|
||||||
|
"tables",
|
||||||
|
"sane_lists",
|
||||||
|
"toc",
|
||||||
|
],
|
||||||
|
output_format="html5",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.debug("python-markdown render failed: %s", e)
|
||||||
|
html = None
|
||||||
|
|
||||||
|
if not html:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2) Sanitize via bleach if available (preferred)
|
||||||
|
try:
|
||||||
|
import bleach # type: ignore
|
||||||
|
|
||||||
|
allowed_tags = [
|
||||||
|
# structure
|
||||||
|
"p",
|
||||||
|
"br",
|
||||||
|
"hr",
|
||||||
|
"div",
|
||||||
|
"span",
|
||||||
|
"blockquote",
|
||||||
|
"pre",
|
||||||
|
"code",
|
||||||
|
# headings
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
# lists
|
||||||
|
"ul",
|
||||||
|
"ol",
|
||||||
|
"li",
|
||||||
|
# emphasis
|
||||||
|
"strong",
|
||||||
|
"em",
|
||||||
|
"b",
|
||||||
|
"i",
|
||||||
|
"u",
|
||||||
|
"s",
|
||||||
|
# links/images
|
||||||
|
"a",
|
||||||
|
"img",
|
||||||
|
# tables
|
||||||
|
"table",
|
||||||
|
"thead",
|
||||||
|
"tbody",
|
||||||
|
"tr",
|
||||||
|
"th",
|
||||||
|
"td",
|
||||||
|
]
|
||||||
|
|
||||||
|
allowed_attrs = {
|
||||||
|
"a": ["href", "title", "target", "rel"],
|
||||||
|
"img": ["src", "alt", "title"],
|
||||||
|
"th": ["align"],
|
||||||
|
"td": ["align"],
|
||||||
|
"*": ["class"],
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized = bleach.clean(
|
||||||
|
html,
|
||||||
|
tags=allowed_tags,
|
||||||
|
attributes=allowed_attrs,
|
||||||
|
protocols=["http", "https", "mailto"],
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make links safe by default (no opener)
|
||||||
|
sanitized = sanitized.replace('<a href="', '<a rel="noreferrer noopener" target="_blank" href="')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.debug("bleach sanitize failed/unavailable: %s", e)
|
||||||
|
|
||||||
|
# 3) If bleach is not available, return raw rendered HTML as last resort
|
||||||
|
# (still okay-ish because Markdown renderer does not execute scripts by itself,
|
||||||
|
# but sanitization is strongly preferred).
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
class StaticAssetsView(HomeAssistantView):
|
class StaticAssetsView(HomeAssistantView):
|
||||||
url = "/api/bahmcloud_store_static/{path:.*}"
|
url = "/api/bahmcloud_store_static/{path:.*}"
|
||||||
name = "api:bahmcloud_store_static"
|
name = "api:bahmcloud_store_static"
|
||||||
|
|
||||||
# CRITICAL:
|
# Keep this as you currently have it working (no auth for static)
|
||||||
# Panel static files (JS/CSS/HTML) must be accessible without auth.
|
|
||||||
# Otherwise module loads can fail behind proxies/clients and trigger HA's ban middleware.
|
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
async def get(self, request: web.Request, path: str) -> web.Response:
|
async def get(self, request: web.Request, path: str) -> web.Response:
|
||||||
@@ -33,7 +138,6 @@ class StaticAssetsView(HomeAssistantView):
|
|||||||
|
|
||||||
target = (base / req_path).resolve()
|
target = (base / req_path).resolve()
|
||||||
|
|
||||||
# Prevent path traversal
|
|
||||||
if not str(target).startswith(str(base_resolved)):
|
if not str(target).startswith(str(base_resolved)):
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
@@ -44,7 +148,6 @@ class StaticAssetsView(HomeAssistantView):
|
|||||||
_LOGGER.error("BCS static asset not found: %s", target)
|
_LOGGER.error("BCS static asset not found: %s", target)
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
# aiohttp: do NOT include charset in content_type string
|
|
||||||
content_type = "text/plain"
|
content_type = "text/plain"
|
||||||
charset = None
|
charset = None
|
||||||
|
|
||||||
@@ -63,8 +166,6 @@ class StaticAssetsView(HomeAssistantView):
|
|||||||
content_type = "image/png"
|
content_type = "image/png"
|
||||||
|
|
||||||
resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset)
|
resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset)
|
||||||
|
|
||||||
# Development friendly (prevents stale cached panel.js)
|
|
||||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
resp.headers["Pragma"] = "no-cache"
|
resp.headers["Pragma"] = "no-cache"
|
||||||
return resp
|
return resp
|
||||||
@@ -80,7 +181,11 @@ class BCSApiView(HomeAssistantView):
|
|||||||
|
|
||||||
async def get(self, request: web.Request) -> web.Response:
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"ok": True, "version": self.core.version, "repos": self.core.list_repos_public()}
|
{
|
||||||
|
"ok": True,
|
||||||
|
"version": self.core.version,
|
||||||
|
"repos": self.core.list_repos_public(),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
@@ -132,19 +237,6 @@ class BCSReadmeView(HomeAssistantView):
|
|||||||
if not md:
|
if not md:
|
||||||
return web.json_response({"ok": False, "message": "README not found."}, status=404)
|
return web.json_response({"ok": False, "message": "README not found."}, status=404)
|
||||||
|
|
||||||
# For now keep html best-effort (we will perfect it after the store is stable again)
|
html = _render_markdown_server_side(md)
|
||||||
html = None
|
|
||||||
try:
|
|
||||||
from homeassistant.util.markdown import async_render_markdown # type: ignore
|
|
||||||
html = await async_render_markdown(self.core.hass, md)
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.debug("Markdown render failed: %s", e)
|
|
||||||
|
|
||||||
if html:
|
|
||||||
try:
|
|
||||||
from homeassistant.util.sanitize_html import async_sanitize_html # type: ignore
|
|
||||||
html = await async_sanitize_html(self.core.hass, html)
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.debug("HTML sanitize not available/failed: %s", e)
|
|
||||||
|
|
||||||
return web.json_response({"ok": True, "readme": md, "html": html})
|
return web.json_response({"ok": True, "readme": md, "html": html})
|
||||||
|
|||||||
Reference in New Issue
Block a user