From e61da2fff341fb66ffe67c2fdb85840497308e0a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 10 Jul 2020 16:07:44 -0600 Subject: [PATCH] Re-add ability to use remote files (by URL) in Slack messages (#37161) * Re-add remote file support for Slack * More work * Ensure Slack can only upload files from whitelisted directories * Cleanup * Finish work * Code review * Messing around * Final cleanup * Add comment explaining why we use aiohttp for remote files * Typo --- homeassistant/components/slack/notify.py | 143 ++++++++++++++++++++--- 1 file changed, 124 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 6f05eebd87d..aec7de1bd88 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -4,6 +4,8 @@ import logging import os from urllib.parse import urlparse +from aiohttp import BasicAuth, FormData +from aiohttp.client_exceptions import ClientError from slack import WebClient from slack.errors import SlackApiError import voluptuous as vol @@ -26,11 +28,41 @@ ATTR_ATTACHMENTS = "attachments" ATTR_BLOCKS = "blocks" ATTR_BLOCKS_TEMPLATE = "blocks_template" ATTR_FILE = "file" +ATTR_PASSWORD = "password" +ATTR_PATH = "path" +ATTR_URL = "url" +ATTR_USERNAME = "username" CONF_DEFAULT_CHANNEL = "default_channel" DEFAULT_TIMEOUT_SECONDS = 15 +FILE_PATH_SCHEMA = vol.Schema({vol.Required(ATTR_PATH): cv.isfile}) + +FILE_URL_SCHEMA = vol.Schema( + { + vol.Required(ATTR_URL): cv.url, + vol.Inclusive(ATTR_USERNAME, "credentials"): cv.string, + vol.Inclusive(ATTR_PASSWORD, "credentials"): cv.string, + } +) + +DATA_FILE_SCHEMA = vol.Schema( + {vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA)} +) + +DATA_TEXT_ONLY_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ATTACHMENTS): list, + vol.Optional(ATTR_BLOCKS): list, + vol.Optional(ATTR_BLOCKS_TEMPLATE): list, + } +) + +DATA_SCHEMA = vol.All( + cv.ensure_list, [vol.Any(DATA_FILE_SCHEMA, DATA_TEXT_ONLY_SCHEMA)] +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -61,6 +93,13 @@ async def async_get_service(hass, config, discovery_info=None): ) +@callback +def _async_get_filename_from_url(url): + """Return the filename of a passed URL.""" + parsed_url = urlparse(url) + return os.path.basename(parsed_url.path) + + @callback def _async_sanitize_channel_names(channel_list): """Remove any # symbols from a channel list.""" @@ -112,6 +151,51 @@ class SlackNotificationService(BaseNotificationService): except SlackApiError as err: _LOGGER.error("Error while uploading file-based message: %s", err) + async def _async_send_remote_file_message( + self, url, targets, message, title, *, username=None, password=None + ): + """Upload a remote file (with message) to Slack. + + Note that we bypass the python-slackclient WebClient and use aiohttp directly, + as the former would require us to download the entire remote file into memory + first before uploading it to Slack. + """ + if not self._hass.config.is_allowed_external_url(url): + _LOGGER.error("URL is not allowed: %s", url) + return + + filename = _async_get_filename_from_url(url) + session = aiohttp_client.async_get_clientsession(self.hass) + + kwargs = {} + if username and password is not None: + kwargs = {"auth": BasicAuth(username, password=password)} + + resp = await session.request("get", url, **kwargs) + + try: + resp.raise_for_status() + except ClientError as err: + _LOGGER.error("Error while retrieving %s: %s", url, err) + return + + data = FormData( + { + "channels": ",".join(targets), + "filename": filename, + "initial_comment": message, + "title": title or filename, + "token": self._client.token, + }, + charset="utf-8", + ) + data.add_field("file", resp.content, filename=filename) + + try: + await session.post("https://slack.com/api/files.upload", data=data) + except ClientError as err: + _LOGGER.error("Error while uploading file message: %s", err) + async def _async_send_text_only_message( self, targets, message, title, attachments, blocks ): @@ -140,32 +224,53 @@ class SlackNotificationService(BaseNotificationService): async def async_send_message(self, message, **kwargs): """Send a message to Slack.""" - data = kwargs[ATTR_DATA] or {} + data = kwargs.get(ATTR_DATA, {}) + + try: + DATA_SCHEMA(data) + except vol.Invalid as err: + _LOGGER.error("Invalid message data: %s", err) + data = {} + title = kwargs.get(ATTR_TITLE) targets = _async_sanitize_channel_names( kwargs.get(ATTR_TARGET, [self._default_channel]) ) - if ATTR_FILE in data: - return await self._async_send_local_file_message( - data[ATTR_FILE], targets, message, title + # Message Type 1: A text-only message + if ATTR_FILE not in data: + attachments = data.get(ATTR_ATTACHMENTS, {}) + if attachments: + _LOGGER.warning( + "Attachments are deprecated and part of Slack's legacy API; " + "support for them will be dropped in 0.114.0. In most cases, " + "Blocks should be used instead: " + "https://www.home-assistant.io/integrations/slack/" + ) + + if ATTR_BLOCKS_TEMPLATE in data: + blocks = _async_templatize_blocks(self.hass, data[ATTR_BLOCKS_TEMPLATE]) + elif ATTR_BLOCKS in data: + blocks = data[ATTR_BLOCKS] + else: + blocks = {} + + return await self._async_send_text_only_message( + targets, message, title, attachments, blocks ) - attachments = data.get(ATTR_ATTACHMENTS, {}) - if attachments: - _LOGGER.warning( - "Attachments are deprecated and part of Slack's legacy API; support " - "for them will be dropped in 0.114.0. In most cases, Blocks should be " - "used instead: https://www.home-assistant.io/integrations/slack/" + # Message Type 2: A message that uploads a remote file + if ATTR_URL in data[ATTR_FILE]: + return await self._async_send_remote_file_message( + data[ATTR_FILE][ATTR_URL], + targets, + message, + title, + username=data[ATTR_FILE].get(ATTR_USERNAME), + password=data[ATTR_FILE].get(ATTR_PASSWORD), ) - if ATTR_BLOCKS_TEMPLATE in data: - blocks = _async_templatize_blocks(self.hass, data[ATTR_BLOCKS_TEMPLATE]) - elif ATTR_BLOCKS in data: - blocks = data[ATTR_BLOCKS] - else: - blocks = {} - - return await self._async_send_text_only_message( - targets, message, title, attachments, blocks + # Message Type 3: A message that uploads a local file + return await self._async_send_local_file_message( + data[ATTR_FILE][ATTR_PATH], targets, message, title )