summaryrefslogtreecommitdiffstats
path: root/recording
diff options
context:
space:
mode:
authorDaniel Calviño Sánchez <danxuliu@gmail.com>2023-02-05 05:32:05 +0100
committerDaniel Calviño Sánchez <danxuliu@gmail.com>2023-02-14 10:37:18 +0100
commit7f0d3071dd529909c25f519975db3a2bb6d76447 (patch)
treedea106814b02a6eacd527319a13c0bd7b4f0664f /recording
parentf64716572ec81dd85c3d42d8b59d6bd66a78abb5 (diff)
Add module to handle incoming requests from the Nextcloud server
Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
Diffstat (limited to 'recording')
-rw-r--r--recording/pyproject.toml1
-rw-r--r--recording/src/nextcloud/talk/recording/Server.py203
-rw-r--r--recording/src/nextcloud/talk/recording/__main__.py7
3 files changed, 211 insertions, 0 deletions
diff --git a/recording/pyproject.toml b/recording/pyproject.toml
index adab8f50d..35653c7e6 100644
--- a/recording/pyproject.toml
+++ b/recording/pyproject.toml
@@ -6,6 +6,7 @@ classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
]
dependencies = [
+ "flask",
"pulsectl",
"pyvirtualdisplay>=2.0",
"selenium>=4.6.0",
diff --git a/recording/src/nextcloud/talk/recording/Server.py b/recording/src/nextcloud/talk/recording/Server.py
new file mode 100644
index 000000000..057b112f5
--- /dev/null
+++ b/recording/src/nextcloud/talk/recording/Server.py
@@ -0,0 +1,203 @@
+#
+# @copyright Copyright (c) 2023, Daniel Calviño Sánchez (danxuliu@gmail.com)
+#
+# @license GNU AGPL version 3 or any later version
+#
+# 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/>.
+#
+
+"""
+Module to handle incoming requests.
+"""
+
+import atexit
+import json
+import hashlib
+import hmac
+from threading import Lock, Thread
+
+from flask import Flask, jsonify, request
+from werkzeug.exceptions import BadRequest, Forbidden
+
+from nextcloud.talk import recording
+from .Config import config
+from .Service import RECORDING_STATUS_AUDIO_AND_VIDEO, Service
+
+app = Flask(__name__)
+
+services = {}
+servicesLock = Lock()
+
+@app.route("/api/v1/welcome", methods=["GET"])
+def welcome():
+ return jsonify(version=recording.__version__)
+
+@app.route("/api/v1/room/<token>", methods=["POST"])
+def handleBackendRequest(token):
+ backend, data = _validateRequest()
+
+ if 'type' not in data:
+ raise BadRequest()
+
+ if data['type'] == 'start':
+ return startRecording(backend, token, data)
+
+ if data['type'] == 'stop':
+ return stopRecording(backend, token, data)
+
+def _validateRequest():
+ """
+ Validates the current request.
+
+ :return: the backend that sent the request and the object representation of
+ the body.
+ """
+
+ if 'Talk-Recording-Backend' not in request.headers:
+ app.logger.warning("Missing Talk-Recording-Backend header")
+ raise Forbidden()
+
+ backend = request.headers['Talk-Recording-Backend']
+
+ secret = config.getBackendSecret(backend)
+ if not secret:
+ app.logger.warning(f"No secret configured for backend {backend}")
+ raise Forbidden()
+
+ if 'Talk-Recording-Random' not in request.headers:
+ app.logger.warning("Missing Talk-Recording-Random header")
+ raise Forbidden()
+
+ random = request.headers['Talk-Recording-Random']
+
+ if 'Talk-Recording-Checksum' not in request.headers:
+ app.logger.warning("Missing Talk-Recording-Checksum header")
+ raise Forbidden()
+
+ checksum = request.headers['Talk-Recording-Checksum']
+
+ maximumMessageSize = config.getBackendMaximumMessageSize(backend)
+
+ if not request.content_length or request.content_length > maximumMessageSize:
+ app.logger.warning(f"Message size above limit: {request.content_length} {maximumMessageSize}")
+ raise BadRequest()
+
+ body = request.get_data()
+
+ expectedChecksum = _calculateChecksum(secret, random, body)
+ if not hmac.compare_digest(checksum, expectedChecksum):
+ app.logger.warning(f"Checksum verification failed: {checksum} {expectedChecksum}")
+ raise Forbidden()
+
+ return backend, json.loads(body)
+
+def _calculateChecksum(secret, random, body):
+ secret = secret.encode()
+ message = random.encode() + body
+
+ hmacValue = hmac.new(secret, message, hashlib.sha256)
+
+ return hmacValue.hexdigest()
+
+def startRecording(backend, token, data):
+ serviceId = f'{backend}-{token}'
+
+ if 'start' not in data:
+ raise BadRequest()
+
+ if 'owner' not in data['start']:
+ raise BadRequest()
+
+ status = RECORDING_STATUS_AUDIO_AND_VIDEO
+ if 'status' in data['start']:
+ status = data['start']['status']
+
+ owner = data['start']['owner']
+
+ service = None
+ with servicesLock:
+ if serviceId in services:
+ app.logger.warning(f"Trying to start recording again: {backend} {token}")
+ return {}
+
+ service = Service(backend, token, status, owner)
+
+ services[serviceId] = service
+
+ app.logger.info(f"Start recording: {backend} {token}")
+
+ serviceStartThread = Thread(target=_startRecordingService, args=[service], daemon=True)
+ serviceStartThread.start()
+
+ return {}
+
+def _startRecordingService(service):
+ """
+ Helper function to start a recording service.
+
+ The recording service will be removed from the list of services if it can
+ not be started.
+
+ :param service: the Service to start.
+ """
+ serviceId = f'{service.backend}-{service.token}'
+
+ try:
+ service.start()
+ except Exception as exception:
+ with servicesLock:
+ if serviceId not in services:
+ # Service was already stopped, exception should have been caused
+ # by stopping the helpers even before the recorder started.
+ app.logger.info(f"Recording stopped before starting: {service.backend} {service.token}", exc_info=exception)
+
+ return
+
+ app.logger.exception(f"Failed to start recording: {service.backend} {service.token}")
+
+ services.pop(serviceId)
+
+def stopRecording(backend, token, data):
+ serviceId = f'{backend}-{token}'
+
+ service = None
+ with servicesLock:
+ if serviceId not in services:
+ app.logger.warning(f"Trying to stop unknown recording: {backend} {token}")
+ return {}
+
+ service = services[serviceId]
+
+ services.pop(serviceId)
+
+ app.logger.info(f"Stop recording: {backend} {token}")
+
+ serviceStopThread = Thread(target=service.stop, daemon=True)
+ serviceStopThread.start()
+
+ return {}
+
+# Despite this handler it seems that in some cases the geckodriver could have
+# been killed already when it is executed, which unfortunately prevents a proper
+# cleanup of the temporary files opened by the browser.
+def _stopServicesOnExit():
+ with servicesLock:
+ serviceIds = list(services.keys())
+ for serviceId in serviceIds:
+ service = services.pop(serviceId)
+ del service
+
+# Services should be explicitly deleted before exiting, as if they are
+# implicitly deleted while exiting the Selenium driver may not cleanly quit.
+atexit.register(_stopServicesOnExit)
diff --git a/recording/src/nextcloud/talk/recording/__main__.py b/recording/src/nextcloud/talk/recording/__main__.py
index 358fc91bb..e86806f54 100644
--- a/recording/src/nextcloud/talk/recording/__main__.py
+++ b/recording/src/nextcloud/talk/recording/__main__.py
@@ -21,6 +21,7 @@ import argparse
import logging
from .Config import config
+from .Server import app
parser = argparse.ArgumentParser()
parser.add_argument("-c", "--config", help="path to configuration file", default="server.conf")
@@ -29,3 +30,9 @@ args = parser.parse_args()
config.load(args.config)
logging.basicConfig(level=config.getLogLevel())
+logging.getLogger('werkzeug').setLevel(config.getLogLevel())
+
+listen = config.getListen()
+host, port = listen.split(':')
+
+app.run(host, port, threaded=True)