diff options
author | Daniel Calviño Sánchez <danxuliu@gmail.com> | 2023-02-05 05:32:05 +0100 |
---|---|---|
committer | Daniel Calviño Sánchez <danxuliu@gmail.com> | 2023-02-14 10:37:18 +0100 |
commit | 7f0d3071dd529909c25f519975db3a2bb6d76447 (patch) | |
tree | dea106814b02a6eacd527319a13c0bd7b4f0664f /recording | |
parent | f64716572ec81dd85c3d42d8b59d6bd66a78abb5 (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.toml | 1 | ||||
-rw-r--r-- | recording/src/nextcloud/talk/recording/Server.py | 203 | ||||
-rw-r--r-- | recording/src/nextcloud/talk/recording/__main__.py | 7 |
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) |