Replace token in camera.push with webhook (#18380)

* replace token with webhook

* missing PR 18206 aditions

* remove unused property

* increase robustness

* lint

* address review comments

* id -> name
This commit is contained in:
Diogo Gomes 2018-11-28 10:36:29 +01:00 committed by Paulus Schoutsen
parent fc8b1f4968
commit a039c3209b
2 changed files with 69 additions and 120 deletions

View File

@ -5,35 +5,33 @@ For more details about this platform, please refer to the documentation
https://home-assistant.io/components/camera.push/ https://home-assistant.io/components/camera.push/
""" """
import logging import logging
import asyncio
from collections import deque from collections import deque
from datetime import timedelta from datetime import timedelta
import voluptuous as vol import voluptuous as vol
import aiohttp
import async_timeout
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\
STATE_IDLE, STATE_RECORDING STATE_IDLE, STATE_RECORDING, DOMAIN
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.components.http.view import KEY_AUTHENTICATED,\ from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID
HomeAssistantView
from homeassistant.const import CONF_NAME, CONF_TIMEOUT,\
HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['webhook']
DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__)
CONF_BUFFER_SIZE = 'buffer' CONF_BUFFER_SIZE = 'buffer'
CONF_IMAGE_FIELD = 'field' CONF_IMAGE_FIELD = 'field'
CONF_TOKEN = 'token'
DEFAULT_NAME = "Push Camera" DEFAULT_NAME = "Push Camera"
ATTR_FILENAME = 'filename' ATTR_FILENAME = 'filename'
ATTR_LAST_TRIP = 'last_trip' ATTR_LAST_TRIP = 'last_trip'
ATTR_TOKEN = 'token'
PUSH_CAMERA_DATA = 'push_camera' PUSH_CAMERA_DATA = 'push_camera'
@ -43,7 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All(
cv.time_period, cv.positive_timedelta), cv.time_period, cv.positive_timedelta),
vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string,
vol.Optional(CONF_TOKEN): vol.All(cv.string, vol.Length(min=8)), vol.Required(CONF_WEBHOOK_ID): cv.string,
}) })
@ -53,69 +51,43 @@ async def async_setup_platform(hass, config, async_add_entities,
if PUSH_CAMERA_DATA not in hass.data: if PUSH_CAMERA_DATA not in hass.data:
hass.data[PUSH_CAMERA_DATA] = {} hass.data[PUSH_CAMERA_DATA] = {}
cameras = [PushCamera(config[CONF_NAME], webhook_id = config.get(CONF_WEBHOOK_ID)
cameras = [PushCamera(hass,
config[CONF_NAME],
config[CONF_BUFFER_SIZE], config[CONF_BUFFER_SIZE],
config[CONF_TIMEOUT], config[CONF_TIMEOUT],
config.get(CONF_TOKEN))] config[CONF_IMAGE_FIELD],
webhook_id)]
hass.http.register_view(CameraPushReceiver(hass,
config[CONF_IMAGE_FIELD]))
async_add_entities(cameras) async_add_entities(cameras)
class CameraPushReceiver(HomeAssistantView): async def handle_webhook(hass, webhook_id, request):
"""Handle pushes from remote camera.""" """Handle incoming webhook POST with image files."""
url = "/api/camera_push/{entity_id}"
name = 'api:camera_push:camera_entity'
requires_auth = False
def __init__(self, hass, image_field):
"""Initialize CameraPushReceiver with camera entity."""
self._cameras = hass.data[PUSH_CAMERA_DATA]
self._image = image_field
async def post(self, request, entity_id):
"""Accept the POST from Camera."""
_camera = self._cameras.get(entity_id)
if _camera is None:
_LOGGER.error("Unknown %s", entity_id)
status = HTTP_NOT_FOUND if request[KEY_AUTHENTICATED]\
else HTTP_UNAUTHORIZED
return self.json_message('Unknown {}'.format(entity_id),
status)
# Supports HA authentication and token based
# when token has been configured
authenticated = (request[KEY_AUTHENTICATED] or
(_camera.token is not None and
request.query.get('token') == _camera.token))
if not authenticated:
return self.json_message(
'Invalid authorization credentials for {}'.format(entity_id),
HTTP_UNAUTHORIZED)
try: try:
data = await request.post() with async_timeout.timeout(5, loop=hass.loop):
_LOGGER.debug("Received Camera push: %s", data[self._image]) data = dict(await request.post())
await _camera.update_image(data[self._image].file.read(), except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error:
data[self._image].filename) _LOGGER.error("Could not get information from POST <%s>", error)
except ValueError as value_error: return
_LOGGER.error("Unknown value %s", value_error)
return self.json_message('Invalid POST', HTTP_BAD_REQUEST) camera = hass.data[PUSH_CAMERA_DATA][webhook_id]
except KeyError as key_error:
_LOGGER.error('In your POST message %s', key_error) if camera.image_field not in data:
return self.json_message('{} missing'.format(self._image), _LOGGER.warning("Webhook call without POST parameter <%s>",
HTTP_BAD_REQUEST) camera.image_field)
return
await camera.update_image(data[camera.image_field].file.read(),
data[camera.image_field].filename)
class PushCamera(Camera): class PushCamera(Camera):
"""The representation of a Push camera.""" """The representation of a Push camera."""
def __init__(self, name, buffer_size, timeout, token): def __init__(self, hass, name, buffer_size, timeout, image_field,
webhook_id):
"""Initialize push camera component.""" """Initialize push camera component."""
super().__init__() super().__init__()
self._name = name self._name = name
@ -126,11 +98,28 @@ class PushCamera(Camera):
self._timeout = timeout self._timeout = timeout
self.queue = deque([], buffer_size) self.queue = deque([], buffer_size)
self._current_image = None self._current_image = None
self.token = token self._image_field = image_field
self.webhook_id = webhook_id
self.webhook_url = \
hass.components.webhook.async_generate_url(webhook_id)
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Call when entity is added to hass.""" """Call when entity is added to hass."""
self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self self.hass.data[PUSH_CAMERA_DATA][self.webhook_id] = self
try:
self.hass.components.webhook.async_register(DOMAIN,
self.name,
self.webhook_id,
handle_webhook)
except ValueError:
_LOGGER.error("In <%s>, webhook_id <%s> already used",
self.name, self.webhook_id)
@property
def image_field(self):
"""HTTP field containing the image file."""
return self._image_field
@property @property
def state(self): def state(self):
@ -189,6 +178,5 @@ class PushCamera(Camera):
name: value for name, value in ( name: value for name, value in (
(ATTR_LAST_TRIP, self._last_trip), (ATTR_LAST_TRIP, self._last_trip),
(ATTR_FILENAME, self._filename), (ATTR_FILENAME, self._filename),
(ATTR_TOKEN, self.token),
) if value is not None ) if value is not None
} }

View File

@ -4,90 +4,51 @@ import io
from datetime import timedelta from datetime import timedelta
from homeassistant import core as ha from homeassistant import core as ha
from homeassistant.components import webhook
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.components.http.auth import setup_auth
async def test_bad_posting(aioclient_mock, hass, aiohttp_client): async def test_bad_posting(aioclient_mock, hass, aiohttp_client):
"""Test that posting to wrong api endpoint fails.""" """Test that posting to wrong api endpoint fails."""
await async_setup_component(hass, webhook.DOMAIN, {})
await async_setup_component(hass, 'camera', { await async_setup_component(hass, 'camera', {
'camera': { 'camera': {
'platform': 'push', 'platform': 'push',
'name': 'config_test', 'name': 'config_test',
'token': '12345678' 'webhook_id': 'camera.config_test'
}}) }})
await hass.async_block_till_done()
assert hass.states.get('camera.config_test') is not None
client = await aiohttp_client(hass.http.app) client = await aiohttp_client(hass.http.app)
# missing file # wrong webhook
resp = await client.post('/api/camera_push/camera.config_test')
assert resp.status == 400
# wrong entity
files = {'image': io.BytesIO(b'fake')} files = {'image': io.BytesIO(b'fake')}
resp = await client.post('/api/camera_push/camera.wrong', data=files) resp = await client.post('/api/webhood/camera.wrong', data=files)
assert resp.status == 404 assert resp.status == 404
# missing file
camera_state = hass.states.get('camera.config_test')
assert camera_state.state == 'idle'
async def test_cases_with_no_auth(aioclient_mock, hass, aiohttp_client): resp = await client.post('/api/webhook/camera.config_test')
"""Test cases where aiohttp_client is not auth.""" assert resp.status == 200 # webhooks always return 200
await async_setup_component(hass, 'camera', {
'camera': {
'platform': 'push',
'name': 'config_test',
'token': '12345678'
}})
setup_auth(hass.http.app, [], True, api_password=None) camera_state = hass.states.get('camera.config_test')
client = await aiohttp_client(hass.http.app) assert camera_state.state == 'idle' # no file supplied we are still idle
# wrong token
files = {'image': io.BytesIO(b'fake')}
resp = await client.post('/api/camera_push/camera.config_test?token=1234',
data=files)
assert resp.status == 401
# right token
files = {'image': io.BytesIO(b'fake')}
resp = await client.post(
'/api/camera_push/camera.config_test?token=12345678',
data=files)
assert resp.status == 200
async def test_no_auth_no_token(aioclient_mock, hass, aiohttp_client):
"""Test cases where aiohttp_client is not auth."""
await async_setup_component(hass, 'camera', {
'camera': {
'platform': 'push',
'name': 'config_test',
}})
setup_auth(hass.http.app, [], True, api_password=None)
client = await aiohttp_client(hass.http.app)
# no token
files = {'image': io.BytesIO(b'fake')}
resp = await client.post('/api/camera_push/camera.config_test',
data=files)
assert resp.status == 401
# fake token
files = {'image': io.BytesIO(b'fake')}
resp = await client.post(
'/api/camera_push/camera.config_test?token=12345678',
data=files)
assert resp.status == 401
async def test_posting_url(hass, aiohttp_client): async def test_posting_url(hass, aiohttp_client):
"""Test that posting to api endpoint works.""" """Test that posting to api endpoint works."""
await async_setup_component(hass, webhook.DOMAIN, {})
await async_setup_component(hass, 'camera', { await async_setup_component(hass, 'camera', {
'camera': { 'camera': {
'platform': 'push', 'platform': 'push',
'name': 'config_test', 'name': 'config_test',
'token': '12345678' 'webhook_id': 'camera.config_test'
}}) }})
await hass.async_block_till_done()
client = await aiohttp_client(hass.http.app) client = await aiohttp_client(hass.http.app)
files = {'image': io.BytesIO(b'fake')} files = {'image': io.BytesIO(b'fake')}
@ -98,7 +59,7 @@ async def test_posting_url(hass, aiohttp_client):
# post image # post image
resp = await client.post( resp = await client.post(
'/api/camera_push/camera.config_test?token=12345678', '/api/webhook/camera.config_test',
data=files) data=files)
assert resp.status == 200 assert resp.status == 200