passerelle/passerelle/apps/bbb/models.py

386 lines
14 KiB
Python

# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2022 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import hashlib
import time
import uuid
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import models, transaction
from django.db.models import JSONField
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from passerelle.base.models import BaseResource, HTTPResource
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
from . import utils
def complete_url(request, data):
data = dict(data)
for key in data:
if key == 'url' or key.endswith('_url') and data[key]:
data[key] = request.build_absolute_uri(data[key])
return data
class Resource(BaseResource, HTTPResource):
bbb_url = models.URLField(
max_length=400,
verbose_name=_('BBB URL'),
help_text=_('Base URL of Big Blue Button (use "bbb-conf --secret" to get it)'),
)
shared_secret = models.CharField(
max_length=128, verbose_name=_('Shared secret'), help_text=_('Use "bbb-conf --secret" to get it.')
)
category = _('Business Process Connectors')
class Meta:
verbose_name = _('Big Blue Button')
@cached_property
def bbb(self):
return utils.BBB(url=self.bbb_url, shared_secret=self.shared_secret, session=self.requests)
def check_status(self):
self.bbb.get_meetings()
@endpoint(
methods=['post'],
name='meeting',
perm='can_access',
description_post=_('Create a meeting'),
post={
'request_body': {
'schema': {
'application/json': {
'type': 'object',
'properties': {
'name': {
'type': 'string',
},
'idempotent_id': {
'type': 'string',
},
'create_parameters': {
'type': 'object',
'properties': {
'logoutURL': {'type': 'string'},
},
'additionalProperties': True,
},
'user_full_name': {'type': 'string'},
'join_user_parameters': {
'type': 'object',
'properties': {
'guest': {'type': 'string'},
},
'additionalProperties': True,
},
'agent_full_name': {'type': 'string'},
'join_agent_parameters': {
'type': 'object',
'properties': {
'guest': {'type': 'string'},
},
'additionalProperties': True,
},
'metadata': {
'type': 'object',
},
},
'required': ['name', 'idempotent_id'],
'unflatten': True,
}
}
}
},
)
@transaction.atomic
def meetings_endpoint(self, request, post_data):
defaults = {
key: post_data.get(key)
for key in [
'name',
'create_parameters',
'user_full_name',
'join_user_parameters',
'agent_full_name',
'join_agent_parameters',
'metadata',
]
}
meeting, created = Meeting.objects.select_for_update().get_or_create(
resource=self, idempotent_id=post_data['idempotent_id'], defaults=defaults
)
if not created:
for key, value in defaults.items():
if getattr(meeting, key, None) != value:
raise APIError('meeting already exists with different parameters')
try:
meeting.create()
except utils.BBB.BBBError as e:
raise APIError(e)
return {'data': complete_url(request, meeting.to_json())}
@endpoint(
methods=['get', 'delete'],
name='meeting',
perm='can_access',
pattern=r'^(?P<guid>[0-9a-f]{32})/?$',
example_pattern='{guid}/',
description_post=_('Get a meeting'),
parameters={
'guid': {
'description': _('Meeting guid'),
'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
},
},
)
def meeting_endpoint(self, request, guid):
if request.method == 'DELETE':
with transaction.atomic():
meeting = get_object_or_404(Meeting.objects.select_for_update(), guid=guid)
try:
meeting.create().end()
except Exception:
pass
meeting.delete()
return {}
else:
meeting = get_object_or_404(Meeting.objects, guid=guid)
return {'data': complete_url(request, meeting.to_json())}
@endpoint(
methods=['get'],
name='meeting',
perm='OPEN',
pattern=r'^(?P<guid>[0-9a-f]{32})/is-running/?$',
example_pattern='{guid}/is-running/',
description_post=_('Report if meeting is running'),
parameters={
'guid': {
'description': _('Meeting guid'),
'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
},
},
)
@transaction.atomic
def is_running(self, request, guid):
meeting = get_object_or_404(Meeting.objects.select_for_update(), guid=guid)
if (now() - meeting.updated).total_seconds() > 5:
meeting.update_is_running()
response = JsonResponse({'err': 0, 'data': meeting.running})
response['Access-Control-Allow-Origin'] = '*'
return response
@endpoint(
methods=['get'],
name='meeting',
perm='OPEN',
pattern=r'^(?P<guid>[0-9a-f]{32})/join/agent/(?P<key>[^/]*)/?$',
example_pattern='{guid}/join/agent/',
description_post=_('Get a meeting'),
parameters={
'guid': {
'description': _('Meeting guid'),
'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
},
'full_name': {
'description': _('Agent full name'),
'example_value': 'John Doe',
},
'key': {
'description': _('Secret key'),
'example_value': '1234',
},
},
)
def join_agent(self, request, guid, key, full_name=None):
meeting = get_object_or_404(Meeting, guid=guid)
if key != meeting.agent_key:
raise PermissionDenied
return HttpResponseRedirect(meeting.make_join_agent_url(full_name))
@endpoint(
methods=['get'],
name='meeting',
perm='OPEN',
pattern=r'^(?P<guid>[0-9a-f]{32})/join/user/(?P<key>[^/]*)/?$',
example_pattern='{guid}/join/user/',
description_post=_('Get a meeting'),
parameters={
'guid': {
'description': _('Meeting guid'),
'example_value': '7edb43abf2004f55a8a526ac4b1403e4',
},
'full_name': {
'description': _('User full name'),
'example_value': 'John Doe',
},
'key': {
'description': _('Secret key'),
'example_value': '1234',
},
},
)
def join_user(self, request, guid, key, full_name=None):
meeting = get_object_or_404(Meeting, guid=guid)
if key != meeting.user_key:
raise PermissionDenied
return HttpResponseRedirect(meeting.make_join_user_url(full_name))
def _make_endpoint_url(self, endpoint, rest):
return reverse(
'generic-endpoint',
kwargs={'connector': 'bbb', 'endpoint': endpoint, 'slug': self.slug, 'rest': rest},
)
class Meeting(models.Model):
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
updated = models.DateTimeField(verbose_name=_('Updated'), auto_now=True)
resource = models.ForeignKey(
Resource, verbose_name=_('Resource'), on_delete=models.CASCADE, related_name='meetings'
)
guid = models.UUIDField(verbose_name=_('UUID'), unique=True, default=uuid.uuid4)
name = models.TextField(verbose_name=_('Name'))
idempotent_id = models.TextField(verbose_name=_('Idempotent ID'), unique=True)
running = models.BooleanField(verbose_name=_('Is running?'), default=False)
last_time_running = models.DateTimeField(verbose_name=_('Last time running'), null=True)
create_parameters = JSONField('Create parameters', null=True)
user_full_name = models.TextField(_('User full name'), null=True)
agent_full_name = models.TextField(_('Agent full name'), null=True)
join_user_parameters = JSONField('Join user parameters', null=True)
join_agent_parameters = JSONField('Join agent parameters', null=True)
metadata = JSONField('Metadata', null=True)
def _make_key(self, _for):
return hashlib.sha1((self.resource.shared_secret + _for + self.meeting_id).encode()).hexdigest()[:6]
@property
def bbb(self):
return self.resource.bbb
@property
def user_key(self):
return self._make_key('user')
@property
def join_user_url(self):
return self.resource._make_endpoint_url(
endpoint='meeting', rest=f'{self.guid.hex}/join/user/{self.user_key}/'
)
@property
def agent_key(self):
return self._make_key('agent')
@property
def join_agent_url(self):
return self.resource._make_endpoint_url(
endpoint='meeting', rest=f'{self.guid.hex}/join/agent/{self.agent_key}/'
)
@property
def url(self):
return self.resource._make_endpoint_url(endpoint='meeting', rest=f'{self.guid.hex}/')
@property
def is_running_url(self):
return self.resource._make_endpoint_url(endpoint='meeting', rest=f'{self.guid.hex}/is-running/')
def to_json(self):
return {
'guid': self.guid.hex,
'created': self.created,
'updated': self.updated,
'name': self.name,
'idempotent_id': self.idempotent_id,
'running': self.running,
'last_time_running': self.last_time_running,
'url': self.url,
'join_user_url': self.join_user_url,
'join_agent_url': self.join_agent_url,
'is_running_url': self.is_running_url,
'bbb_meeting_info': self.meeting_info(),
'create_parameters': self.create_parameters,
'join_user_parameters': self.join_user_parameters,
'join_agent_parameters': self.join_agent_parameters,
'metadata': self.metadata,
}
@property
def meeting_id(self):
return self.guid.hex
def update_is_running(self):
try:
running = self.resource.bbb.is_meeting_running(self.meeting_id)
except self.resource.bbb.BBBError:
return
if self.running != running:
self.running = running
if running:
self.last_time_running = now()
self.save(update_fields=['updated', 'last_time_running', 'running'])
else:
self.save(update_fields=['updated', 'running'])
def create(self):
return self.resource.bbb.meetings.create(
name=self.name, meeting_id=self.meeting_id, **(self.create_parameters or {})
)
@property
def cache_key(self):
return f'bbb_{self.resource.slug}_{self.guid.hex}'
def meeting_info(self):
start = time.time()
try:
data, timestamp = cache.get(self.cache_key)
except TypeError:
data = None
if data is None or (start - timestamp) > 30:
try:
data = self.resource.bbb.meetings.get(meeting_id=self.meeting_id).attributes
except utils.BBB.BBBError:
data = data or {}
cache.set(self.cache_key, (data, start))
return data
def make_join_agent_url(self, full_name):
return self.create().join_url(
str(full_name or self.agent_full_name or _('Agent')),
self.bbb.ROLE_MODERATOR,
**(self.join_agent_parameters or {}),
)
def make_join_user_url(self, full_name):
return self.create().join_url(
str(full_name or self.user_full_name or _('User')),
self.bbb.ROLE_VIEWER,
**(self.join_user_parameters or {}),
)