зеркало из https://github.com/nextcloud/spreed.git
Merge pull request #8708 from nextcloud/add-recording-server
Add recording server
This commit is contained in:
Коммит
e581d23bfd
|
@ -30,6 +30,10 @@ $requirements = [
|
|||
|
||||
return [
|
||||
'ocs' => [
|
||||
/** @see \OCA\Talk\Controller\RecordingController::getWelcomeMessage() */
|
||||
['name' => 'Recording#getWelcomeMessage', 'url' => '/api/{apiVersion}/recording/welcome/{serverId}', 'verb' => 'GET', 'requirements' => array_merge($requirements, [
|
||||
'serverId' => '\d+',
|
||||
])],
|
||||
/** @see \OCA\Talk\Controller\RecordingController::start() */
|
||||
['name' => 'Recording#start', 'url' => '/api/{apiVersion}/recording/{token}', 'verb' => 'POST', 'requirements' => $requirements],
|
||||
/** @see \OCA\Talk\Controller\RecordingController::stop() */
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
+ `400 Bad Request` Message: `config`. Need to enable the config `recording`.
|
||||
+ `400 Bad Request` Message: `recording`. Already have a recording in progress.
|
||||
+ `400 Bad Request` Message: `call`. Call is not activated.
|
||||
+ `401 Unauthorized` When the participant is a guest.
|
||||
+ `403 Forbidden` When the user is not a moderator/owner.
|
||||
+ `412 Precondition Failed` When the lobby is active and the user is not a moderator.
|
||||
|
||||
|
@ -34,6 +35,7 @@
|
|||
+ `200 OK`
|
||||
+ `400 Bad Request` Message: `config`. Need to enable the config `recording`.
|
||||
+ `400 Bad Request` Message: `call`. Call is not activated.
|
||||
+ `401 Unauthorized` When the participant is a guest.
|
||||
+ `403 Forbidden` When the user is not a moderator/owner.
|
||||
+ `412 Precondition Failed` When the lobby is active and the user is not a moderator.
|
||||
|
||||
|
@ -45,10 +47,10 @@
|
|||
|
||||
* Header:
|
||||
|
||||
| field | type | Description |
|
||||
| ------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TALK_SIPBRIDGE_RANDOM` | string | Random string that needs to be concatenated with room token to generate the checksum using the `sip_bridge_shared_secret`. |
|
||||
| `TALK_SIPBRIDGE_CHECKSUM` | string | The checksum generated with `TALK_SIPBRIDGE_RANDOM`. |
|
||||
| field | type | Description |
|
||||
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TALK_RECORDING_RANDOM` | string | Random string that needs to be concatenated with room token to generate the checksum using the `recording_servers['secret']`. |
|
||||
| `TALK_RECORDING_CHECKSUM` | string | The checksum generated with `TALK_RECORDING_RANDOM`. |
|
||||
|
||||
* Data:
|
||||
|
||||
|
@ -68,7 +70,7 @@
|
|||
+ `400 Bad Request` Error: `owner_participant`: Owner is not to be a participant of room
|
||||
+ `400 Bad Request` Error: `owner_invalid`: Owner invalid
|
||||
+ `400 Bad Request` Error: `owner_permission`: Owner have not permission to store record file
|
||||
+ `401 Unauthorized` When the validation as SIP bridge failed
|
||||
+ `401 Unauthorized` When the validation as recording server failed
|
||||
+ `404 Not Found` Room not found
|
||||
+ `429 Too Many Request` Brute force protection
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ Option legend:
|
|||
| `hosted-signaling-server-account` | array | `{}` | 🖌️ | Account information of the hosted signaling server |
|
||||
| `stun_servers` | array[] | `[]` | 🖌💻️ | List of STUN servers, should be configured via the web interface or the OCC commands |
|
||||
| `turn_servers` | array[] | `[]` | 🖌️💻 | List of TURN servers, should be configured via the web interface or the OCC commands |
|
||||
| `recording_servers` | array[] | `[]` | 🖌️ | List of recording servers, should be configured via the web interface |
|
||||
| `signaling_servers` | array[] | `[]` | 🖌️💻 | List of signaling servers, should be configured via the web interface or the OCC commands |
|
||||
| `signaling_mode` | string<br>`internal` or `external` or `conversation_cluster` | `internal` | | `internal` when no HPB is configured, `external` when configured, `conversation_cluster` is an experimental flag that is deprecated |
|
||||
| `sip_bridge_dialin_info` | string | | 🖌️ | Additional information added in the SIP dial-in invitation mail and sidebar |
|
||||
|
|
|
@ -141,6 +141,28 @@ class Config {
|
|||
return $this->config->getAppValue('spreed', 'signaling_dev', 'no') === 'yes';
|
||||
}
|
||||
|
||||
public function getRecordingServers(): array {
|
||||
$config = $this->config->getAppValue('spreed', 'recording_servers');
|
||||
$recording = json_decode($config, true);
|
||||
|
||||
if (!is_array($recording) || !isset($recording['servers'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $recording['servers'];
|
||||
}
|
||||
|
||||
public function getRecordingSecret(): string {
|
||||
$config = $this->config->getAppValue('spreed', 'recording_servers');
|
||||
$recording = json_decode($config, true);
|
||||
|
||||
if (!is_array($recording)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $recording['secret'];
|
||||
}
|
||||
|
||||
public function isRecordingEnabled(): bool {
|
||||
$isSignalingInternal = $this->getSignalingMode() === self::SIGNALING_INTERNAL;
|
||||
$isSignalingDev = $this->isSignalingDev();
|
||||
|
|
|
@ -26,32 +26,96 @@ declare(strict_types=1);
|
|||
namespace OCA\Talk\Controller;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Exceptions\UnauthorizedException;
|
||||
use OCA\Talk\Service\RecordingService;
|
||||
use OCA\Talk\Service\SIPBridgeService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RecordingController extends AEnvironmentAwareController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private ?string $userId,
|
||||
private Config $talkConfig,
|
||||
private SIPBridgeService $SIPBridgeService,
|
||||
private RecordingService $recordingService
|
||||
private IClientService $clientService,
|
||||
private RecordingService $recordingService,
|
||||
private LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
public function getWelcomeMessage(int $serverId): DataResponse {
|
||||
$recordingServers = $this->talkConfig->getRecordingServers();
|
||||
if (empty($recordingServers) || !isset($recordingServers[$serverId])) {
|
||||
return new DataResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
$url = rtrim($recordingServers[$serverId]['server'], '/');
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->get($url . '/api/v1/welcome', [
|
||||
'verify' => (bool) $recordingServers[$serverId]['verify'],
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$body = $response->getBody();
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return new DataResponse([
|
||||
'error' => 'JSON_INVALID',
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new DataResponse($data);
|
||||
} catch (ConnectException $e) {
|
||||
return new DataResponse(['error' => 'CAN_NOT_CONNECT'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
} catch (\Exception $e) {
|
||||
return new DataResponse(['error' => $e->getCode()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @PublicPage
|
||||
* @RequireModeratorParticipant
|
||||
* Check if the current request is coming from an allowed backend.
|
||||
*
|
||||
* The backends are sending the custom header "Talk-Recording-Random"
|
||||
* containing at least 32 bytes random data, and the header
|
||||
* "Talk-Recording-Checksum", which is the SHA256-HMAC of the random data
|
||||
* and the body of the request, calculated with the shared secret from the
|
||||
* configuration.
|
||||
*
|
||||
* @param string $data
|
||||
* @return bool
|
||||
*/
|
||||
private function validateBackendRequest(string $data): bool {
|
||||
$random = $this->request->getHeader('Talk-Recording-Random');
|
||||
if (empty($random) || strlen($random) < 32) {
|
||||
$this->logger->debug("Missing random");
|
||||
return false;
|
||||
}
|
||||
$checksum = $this->request->getHeader('Talk-Recording-Checksum');
|
||||
if (empty($checksum)) {
|
||||
$this->logger->debug("Missing checksum");
|
||||
return false;
|
||||
}
|
||||
$hash = hash_hmac('sha256', $random . $data, $this->talkConfig->getRecordingSecret());
|
||||
return hash_equals($hash, strtolower($checksum));
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @RequireLoggedInModeratorParticipant
|
||||
*/
|
||||
public function start(int $status): DataResponse {
|
||||
try {
|
||||
$this->recordingService->start($this->room, $status);
|
||||
$this->recordingService->start($this->room, $status, $this->userId);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
@ -59,8 +123,8 @@ class RecordingController extends AEnvironmentAwareController {
|
|||
}
|
||||
|
||||
/**
|
||||
* @PublicPage
|
||||
* @RequireModeratorParticipant
|
||||
* @NoAdminRequired
|
||||
* @RequireLoggedInModeratorParticipant
|
||||
*/
|
||||
public function stop(): DataResponse {
|
||||
try {
|
||||
|
@ -74,20 +138,20 @@ class RecordingController extends AEnvironmentAwareController {
|
|||
/**
|
||||
* @PublicPage
|
||||
* @RequireRoom
|
||||
* @BruteForceProtection(action=talkSipBridgeSecret)
|
||||
* @BruteForceProtection(action=talkRecordingSecret)
|
||||
*
|
||||
* @return DataResponse
|
||||
*/
|
||||
public function store(string $owner): DataResponse {
|
||||
try {
|
||||
$random = $this->request->getHeader('TALK_SIPBRIDGE_RANDOM');
|
||||
$checksum = $this->request->getHeader('TALK_SIPBRIDGE_CHECKSUM');
|
||||
$secret = $this->talkConfig->getSIPSharedSecret();
|
||||
if (!$this->SIPBridgeService->validateSIPBridgeRequest($random, $checksum, $secret, $this->room->getToken())) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
} catch (UnauthorizedException $e) {
|
||||
$response = new DataResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
$data = $this->room->getToken();
|
||||
if (!$this->validateBackendRequest($data)) {
|
||||
$response = new DataResponse([
|
||||
'type' => 'error',
|
||||
'error' => [
|
||||
'code' => 'invalid_request',
|
||||
'message' => 'The request could not be authenticated.',
|
||||
],
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
$response->throttle();
|
||||
return $response;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @copyright Copyright (c) 2017 Joachim Bauch <bauch@struktur.de>
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Recording;
|
||||
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Exception\ServerException;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Security\ISecureRandom;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class BackendNotifier {
|
||||
private Config $config;
|
||||
private LoggerInterface $logger;
|
||||
private IClientService $clientService;
|
||||
private ISecureRandom $secureRandom;
|
||||
private IURLGenerator $urlGenerator;
|
||||
|
||||
public function __construct(Config $config,
|
||||
LoggerInterface $logger,
|
||||
IClientService $clientService,
|
||||
ISecureRandom $secureRandom,
|
||||
IURLGenerator $urlGenerator) {
|
||||
$this->config = $config;
|
||||
$this->logger = $logger;
|
||||
$this->clientService = $clientService;
|
||||
$this->secureRandom = $secureRandom;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform actual network request to the recording backend.
|
||||
* This can be overridden in tests.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $params
|
||||
* @param int $retries
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function doRequest(string $url, array $params, int $retries = 3): void {
|
||||
if (defined('PHPUNIT_RUN')) {
|
||||
// Don't perform network requests when running tests.
|
||||
return;
|
||||
}
|
||||
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->post($url, $params);
|
||||
} catch (ServerException | ConnectException $e) {
|
||||
if ($retries > 1) {
|
||||
$this->logger->error('Failed to send message to recording server, ' . $retries . ' retries left!', ['exception' => $e]);
|
||||
$this->doRequest($url, $params, $retries - 1);
|
||||
} else {
|
||||
$this->logger->error('Failed to send message to recording server, giving up!', ['exception' => $e]);
|
||||
throw $e;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to send message to recording server', ['exception' => $e]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request to the recording backend.
|
||||
*
|
||||
* @param Room $room
|
||||
* @param array $data
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function backendRequest(Room $room, array $data): void {
|
||||
$recordingServers = $this->config->getRecordingServers();
|
||||
if (empty($recordingServers)) {
|
||||
$this->logger->error('No configured recording server');
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME Currently clustering is not implemented in the recording
|
||||
// server, so for now only the first configured server is taken into
|
||||
// account to ensure that all the "stop" requests are sent to the same
|
||||
// server that received the "start" request.
|
||||
$recording = $recordingServers[0];
|
||||
$recording['server'] = rtrim($recording['server'], '/');
|
||||
|
||||
$url = '/api/v1/room/' . $room->getToken();
|
||||
$url = $recording['server'] . $url;
|
||||
if (strpos($url, 'wss://') === 0) {
|
||||
$url = 'https://' . substr($url, 6);
|
||||
} elseif (strpos($url, 'ws://') === 0) {
|
||||
$url = 'http://' . substr($url, 5);
|
||||
}
|
||||
$body = json_encode($data);
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
$random = $this->secureRandom->generate(64);
|
||||
$hash = hash_hmac('sha256', $random . $body, $this->config->getRecordingSecret());
|
||||
$headers['Talk-Recording-Random'] = $random;
|
||||
$headers['Talk-Recording-Checksum'] = $hash;
|
||||
$headers['Talk-Recording-Backend'] = $this->urlGenerator->getAbsoluteURL('');
|
||||
|
||||
$params = [
|
||||
'headers' => $headers,
|
||||
'body' => $body,
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => true,
|
||||
],
|
||||
];
|
||||
if (empty($recording['verify'])) {
|
||||
$params['verify'] = false;
|
||||
}
|
||||
$this->doRequest($url, $params);
|
||||
}
|
||||
|
||||
public function start(Room $room, int $status, string $owner): void {
|
||||
$start = microtime(true);
|
||||
$this->backendRequest($room, [
|
||||
'type' => 'start',
|
||||
'start' => [
|
||||
'status' => $status,
|
||||
'owner' => $owner,
|
||||
],
|
||||
]);
|
||||
$duration = microtime(true) - $start;
|
||||
$this->logger->debug('Send start message: {token} ({duration})', [
|
||||
'token' => $room->getToken(),
|
||||
'duration' => sprintf('%.2f', $duration),
|
||||
'app' => 'spreed-recording',
|
||||
]);
|
||||
}
|
||||
|
||||
public function stop(Room $room): void {
|
||||
$start = microtime(true);
|
||||
$this->backendRequest($room, [
|
||||
'type' => 'stop',
|
||||
]);
|
||||
$duration = microtime(true) - $start;
|
||||
$this->logger->debug('Send stop message: {token} ({duration})', [
|
||||
'token' => $room->getToken(),
|
||||
'duration' => sprintf('%.2f', $duration),
|
||||
'app' => 'spreed-recording',
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -32,6 +32,7 @@ use OCA\Talk\Config;
|
|||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Recording\BackendNotifier;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Files\File;
|
||||
|
@ -49,6 +50,7 @@ class RecordingService {
|
|||
public const DEFAULT_ALLOWED_RECORDING_FORMATS = [
|
||||
'audio/ogg' => ['ogg'],
|
||||
'video/ogg' => ['ogv'],
|
||||
'video/webm' => ['webm'],
|
||||
'video/x-matroska' => ['mkv'],
|
||||
];
|
||||
|
||||
|
@ -64,10 +66,11 @@ class RecordingService {
|
|||
protected ShareManager $shareManager,
|
||||
protected ChatManager $chatManager,
|
||||
protected LoggerInterface $logger,
|
||||
protected BackendNotifier $backendNotifier
|
||||
) {
|
||||
}
|
||||
|
||||
public function start(Room $room, int $status): void {
|
||||
public function start(Room $room, int $status, string $owner): void {
|
||||
$availableRecordingTypes = [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO];
|
||||
if (!in_array($status, $availableRecordingTypes)) {
|
||||
throw new InvalidArgumentException('status');
|
||||
|
@ -78,6 +81,9 @@ class RecordingService {
|
|||
if (!$room->getActiveSince() instanceof \DateTimeInterface) {
|
||||
throw new InvalidArgumentException('call');
|
||||
}
|
||||
|
||||
$this->backendNotifier->start($room, $status, $owner);
|
||||
|
||||
$this->roomService->setCallRecording($room, $status);
|
||||
}
|
||||
|
||||
|
@ -85,6 +91,9 @@ class RecordingService {
|
|||
if ($room->getCallRecording() === Room::RECORDING_NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backendNotifier->stop($room);
|
||||
|
||||
$this->roomService->setCallRecording($room);
|
||||
}
|
||||
|
||||
|
@ -116,6 +125,7 @@ class RecordingService {
|
|||
$file['error'] !== 0 ||
|
||||
!is_uploaded_file($file['tmp_name'])
|
||||
) {
|
||||
$this->logger->warning("Uploaded file might be larger than allowed by PHP settings 'upload_max_filesize' or 'post_max_size'");
|
||||
throw new InvalidArgumentException('invalid_file');
|
||||
}
|
||||
|
||||
|
@ -132,11 +142,13 @@ class RecordingService {
|
|||
$mimeType = $this->mimeTypeDetector->detectString($content);
|
||||
$allowed = self::DEFAULT_ALLOWED_RECORDING_FORMATS;
|
||||
if (!array_key_exists($mimeType, $allowed)) {
|
||||
$this->logger->warning("Uploaded file detected mime type ($mimeType) is not allowed");
|
||||
throw new InvalidArgumentException('file_mimetype');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (!$extension || !in_array($extension, $allowed[$mimeType])) {
|
||||
$this->logger->warning("Uploaded file extensions ($extension) is not allowed for the detected mime type ($mimeType)");
|
||||
throw new InvalidArgumentException('file_extension');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,6 +89,7 @@ class AdminSettings implements ISettings {
|
|||
$this->initTurnServers();
|
||||
$this->initSignalingServers();
|
||||
$this->initRequestSignalingServerTrial();
|
||||
$this->initRecordingServers();
|
||||
$this->initSIPBridge();
|
||||
|
||||
|
||||
|
@ -472,6 +473,13 @@ class AdminSettings implements ISettings {
|
|||
]);
|
||||
}
|
||||
|
||||
protected function initRecordingServers(): void {
|
||||
$this->initialState->provideInitialState('recording_servers', [
|
||||
'servers' => $this->talkConfig->getRecordingServers(),
|
||||
'secret' => $this->talkConfig->getRecordingSecret(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function initSIPBridge(): void {
|
||||
$groups = $this->getGroupDetailsArray($this->talkConfig->getSIPGroups(), 'sip_bridge_groups');
|
||||
|
||||
|
|
|
@ -77,6 +77,8 @@
|
|||
<file name="tests/stubs/oc_comments_comment.php" />
|
||||
<file name="tests/stubs/oc_comments_manager.php" />
|
||||
<file name="tests/stubs/oc_hooks_emitter.php" />
|
||||
<file name="tests/stubs/GuzzleHttp_Exception_ConnectException.php" />
|
||||
<file name="tests/stubs/GuzzleHttp_Exception_ServerException.php" />
|
||||
<file name="tests/stubs/Symfony_Component_EventDispatcher_GenericEvent.php" />
|
||||
</stubs>
|
||||
</psalm>
|
||||
|
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,5 @@
|
|||
# Nextcloud Talk Recording Server Documentation
|
||||
|
||||
## API
|
||||
|
||||
* [Recording API](recording-api.md)
|
|
@ -0,0 +1,52 @@
|
|||
# Nextcloud Talk Recording Server API
|
||||
|
||||
* API v1: Base endpoint `/api/v1`
|
||||
|
||||
## Get welcome message
|
||||
|
||||
* Method: `GET`
|
||||
* Endpoint: `/welcome`
|
||||
|
||||
* Response:
|
||||
- Status code:
|
||||
+ `200 OK`
|
||||
|
||||
## Requests from the Nextcloud server
|
||||
|
||||
* Method: `POST`
|
||||
* Endpoint: `/room/{token}`
|
||||
|
||||
* Header:
|
||||
|
||||
| field | type | Description |
|
||||
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TALK_RECORDING_BACKEND` | string | The base URL of the Nextcloud server sending the request. |
|
||||
| `TALK_RECORDING_RANDOM` | string | Random string that needs to be concatenated with request body to generate the checksum using the secret configured for the backend. |
|
||||
| `TALK_RECORDING_CHECKSUM` | string | The checksum generated with `TALK_RECORDING_RANDOM`. |
|
||||
|
||||
* Response:
|
||||
- Status code:
|
||||
+ `200 OK`
|
||||
+ `400 Bad Request`: When the body size exceeds the maximum allowed message size.
|
||||
+ `400 Bad Request`: When the body data does not match the expected format.
|
||||
+ `403 Forbidden`: When the request validation failed.
|
||||
|
||||
### Start call recording
|
||||
|
||||
* Data format (JSON):
|
||||
|
||||
{
|
||||
"type": "start",
|
||||
"start": {
|
||||
"status": "the-type-of-recording (1 for audio and video, 2 for audio only)",
|
||||
"owner": "the-user-to-upload-the-resulting-file-as",
|
||||
}
|
||||
}
|
||||
|
||||
### Stop call recording
|
||||
|
||||
* Data format:
|
||||
|
||||
{
|
||||
"type": "stop",
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
[project]
|
||||
name = "nextcloud-talk-recording"
|
||||
description = "Recording server for Nextcloud Talk"
|
||||
license = {text = "GNU AGPLv3+"}
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
]
|
||||
dependencies = [
|
||||
"flask",
|
||||
"pulsectl",
|
||||
"pyvirtualdisplay>=2.0",
|
||||
"selenium>=4.6.0",
|
||||
"urllib3",
|
||||
"websocket-client",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "nextcloud.talk.recording.__version__"}
|
|
@ -0,0 +1,67 @@
|
|||
[logs]
|
||||
# Log level based on numeric values of Python logging levels:
|
||||
# - Critical: 50
|
||||
# - Error: 40
|
||||
# - Warning: 30
|
||||
# - Info: 20
|
||||
# - Debug: 10
|
||||
# - Not set: 0
|
||||
#level = 20
|
||||
|
||||
[http]
|
||||
# IP and port to listen on for HTTP requests.
|
||||
#listen = 127.0.0.1:8000
|
||||
|
||||
[backend]
|
||||
# Allow any hostname as backend endpoint. This is extremely insecure and should
|
||||
# only be used during development.
|
||||
#allowall = false
|
||||
|
||||
# Common shared secret for requests from and to the backend servers if
|
||||
# "allowall" is enabled. This must be the same value as configured in the
|
||||
# Nextcloud admin ui.
|
||||
#secret = the-shared-secret
|
||||
|
||||
# Comma-separated list of backend ids allowed to connect.
|
||||
#backends = backend-id, another-backend
|
||||
|
||||
# If set to "true", certificate validation of backend endpoints will be skipped.
|
||||
# This should only be enabled during development, e.g. to work with self-signed
|
||||
# certificates.
|
||||
# Overridable by backend.
|
||||
#skipverify = false
|
||||
|
||||
# Maximum allowed size in bytes for messages sent by the backend.
|
||||
# Overridable by backend.
|
||||
#maxmessagesize = 1024
|
||||
|
||||
# Width for recorded videos.
|
||||
# Overridable by backend.
|
||||
#videowidth = 1920
|
||||
|
||||
# Height for recorded videos.
|
||||
# Overridable by backend.
|
||||
#videoheight = 1080
|
||||
|
||||
# Temporary directory used to store recordings until uploaded. It must be
|
||||
# writable by the user running the recording server.
|
||||
# Overridable by backend.
|
||||
#directory = /tmp
|
||||
|
||||
# Backend configurations as defined in the "[backend]" section above. The
|
||||
# section names must match the ids used in "backends" above.
|
||||
#[backend-id]
|
||||
# URL of the Nextcloud instance
|
||||
#url = https://cloud.domain.invalid
|
||||
|
||||
# Shared secret for requests from and to the backend servers. This must be the
|
||||
# same value as configured in the Nextcloud admin ui.
|
||||
#secret = the-shared-secret
|
||||
|
||||
#[another-backend]
|
||||
# URL of the Nextcloud instance
|
||||
#url = https://cloud.otherdomain.invalid
|
||||
|
||||
# Shared secret for requests from and to the backend servers. This must be the
|
||||
# same value as configured in the Nextcloud admin ui.
|
||||
#secret = the-shared-secret
|
|
@ -0,0 +1,116 @@
|
|||
#
|
||||
# @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 send requests to the Nextcloud server.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from secrets import token_urlsafe
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib3 import encode_multipart_formdata
|
||||
|
||||
from .Config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def getRandomAndChecksum(backend, data):
|
||||
"""
|
||||
Returns a random string and the checksum of the given data with that random.
|
||||
|
||||
:param backend: the backend to send the data to.
|
||||
:param data: the data, as bytes.
|
||||
"""
|
||||
secret = config.getBackendSecret(backend).encode()
|
||||
random = token_urlsafe(64)
|
||||
hmacValue = hmac.new(secret, random.encode() + data, hashlib.sha256)
|
||||
|
||||
return random, hmacValue.hexdigest()
|
||||
|
||||
def doRequest(backend, request, retries=3):
|
||||
"""
|
||||
Send the request to the backend.
|
||||
|
||||
SSL verification will be skipped if configured.
|
||||
|
||||
:param backend: the backend to send the request to.
|
||||
:param request: the request to send.
|
||||
:param retries: the number of times to retry in case of failure.
|
||||
"""
|
||||
context = None
|
||||
|
||||
if config.getBackendSkipVerify(backend):
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
urlopen(request, context=context)
|
||||
except Exception as exception:
|
||||
if retries > 1:
|
||||
logger.exception(f"Failed to send message to backend, {retries} retries left!")
|
||||
doRequest(backend, request, retries - 1)
|
||||
else:
|
||||
logger.exception(f"Failed to send message to backend, giving up!")
|
||||
raise
|
||||
|
||||
def uploadRecording(backend, token, fileName, owner):
|
||||
"""
|
||||
Upload the recording specified by fileName.
|
||||
|
||||
The name of the uploaded file is the basename of the original file.
|
||||
|
||||
:param backend: the backend to upload the file to.
|
||||
:param token: the token of the conversation that was recorded.
|
||||
:param fileName: the recording file name.
|
||||
:param owner: the owner of the uploaded file.
|
||||
"""
|
||||
|
||||
logger.info(f"Upload recording {fileName} to {backend} in {token} as {owner}")
|
||||
|
||||
url = backend + '/ocs/v2.php/apps/spreed/api/v1/recording/' + token + '/store'
|
||||
|
||||
fileContents = None
|
||||
with open(fileName, 'rb') as file:
|
||||
fileContents = file.read()
|
||||
|
||||
# Plain values become arguments, while tuples become files; the body used to
|
||||
# calculate the checksum is empty.
|
||||
data = {
|
||||
'owner': owner,
|
||||
'file': (os.path.basename(fileName), fileContents),
|
||||
}
|
||||
data, contentType = encode_multipart_formdata(data)
|
||||
|
||||
random, checksum = getRandomAndChecksum(backend, token.encode())
|
||||
|
||||
headers = {
|
||||
'Content-Type': contentType,
|
||||
'OCS-ApiRequest': 'true',
|
||||
'Talk-Recording-Random': random,
|
||||
'Talk-Recording-Checksum': checksum,
|
||||
}
|
||||
|
||||
uploadRequest = Request(url, data, headers)
|
||||
|
||||
doRequest(backend, uploadRequest)
|
|
@ -0,0 +1,160 @@
|
|||
#
|
||||
# @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 for getting the configuration.
|
||||
|
||||
Other modules are expected to import the shared "config" object, which will be
|
||||
loaded with the configuration file at startup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from configparser import ConfigParser
|
||||
|
||||
class Config:
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
self._configParser = ConfigParser()
|
||||
|
||||
self._backendIdsByBackendUrl = {}
|
||||
|
||||
def load(self, fileName):
|
||||
fileName = os.path.abspath(fileName)
|
||||
|
||||
if not os.path.exists(fileName):
|
||||
self._logger.warning(f"Configuration file not found: {fileName}")
|
||||
else:
|
||||
self._logger.info(f"Loading {fileName}")
|
||||
|
||||
self._configParser.read(fileName)
|
||||
|
||||
self._loadBackends()
|
||||
|
||||
def _loadBackends(self):
|
||||
self._backendIdsByBackendUrl = {}
|
||||
|
||||
if 'backend' not in self._configParser or 'backends' not in self._configParser['backend']:
|
||||
self._logger.warning(f"No configured backends")
|
||||
|
||||
return
|
||||
|
||||
backendIds = self._configParser.get('backend', 'backends')
|
||||
backendIds = [backendId.strip() for backendId in backendIds.split(',')]
|
||||
|
||||
for backendId in backendIds:
|
||||
if 'url' not in self._configParser[backendId]:
|
||||
self._logger.error(f"Missing 'url' property for backend {backendId}")
|
||||
continue
|
||||
|
||||
if 'secret' not in self._configParser[backendId]:
|
||||
self._logger.error(f"Missing 'secret' property for backend {backendId}")
|
||||
continue
|
||||
|
||||
backendUrl = self._configParser[backendId]['url'].rstrip('/')
|
||||
self._backendIdsByBackendUrl[backendUrl] = backendId
|
||||
|
||||
def getLogLevel(self):
|
||||
"""
|
||||
Returns the log level.
|
||||
|
||||
Defaults to INFO (20).
|
||||
"""
|
||||
return int(self._configParser.get('logs', 'level', fallback=logging.INFO))
|
||||
|
||||
def getListen(self):
|
||||
"""
|
||||
Returns the IP and port to listen on for HTTP requests.
|
||||
|
||||
Defaults to "127.0.0.1:8000".
|
||||
"""
|
||||
return self._configParser.get('http', 'listen', fallback='127.0.0.1:8000')
|
||||
|
||||
def getBackendSecret(self, backendUrl):
|
||||
"""
|
||||
Returns the shared secret for requests from and to the backend servers.
|
||||
|
||||
Defaults to None.
|
||||
"""
|
||||
if self._configParser.get('backend', 'allowall', fallback=None):
|
||||
return self._configParser.get('backend', 'secret')
|
||||
|
||||
backendUrl = backendUrl.rstrip('/')
|
||||
if backendUrl in self._backendIdsByBackendUrl:
|
||||
backendId = self._backendIdsByBackendUrl[backendUrl]
|
||||
|
||||
return self._configParser.get(backendId, 'secret', fallback=None)
|
||||
|
||||
return None
|
||||
|
||||
def getBackendSkipVerify(self, backendUrl):
|
||||
"""
|
||||
Returns whether the certificate validation of backend endpoints should
|
||||
be skipped or not.
|
||||
|
||||
Defaults to False.
|
||||
"""
|
||||
return self._getBackendValue(backendUrl, 'skipverify', False) == 'true'
|
||||
|
||||
def getBackendMaximumMessageSize(self, backendUrl):
|
||||
"""
|
||||
Returns the maximum allowed size in bytes for messages sent by the
|
||||
backend.
|
||||
|
||||
Defaults to 1024.
|
||||
"""
|
||||
return int(self._getBackendValue(backendUrl, 'maxmessagesize', 1024))
|
||||
|
||||
def getBackendVideoWidth(self, backendUrl):
|
||||
"""
|
||||
Returns the width for recorded videos.
|
||||
|
||||
Defaults to 1920.
|
||||
"""
|
||||
return int(self._getBackendValue(backendUrl, 'videowidth', 1920))
|
||||
|
||||
def getBackendVideoHeight(self, backendUrl):
|
||||
"""
|
||||
Returns the height for recorded videos.
|
||||
|
||||
Defaults to 1080.
|
||||
"""
|
||||
return int(self._getBackendValue(backendUrl, 'videoheight', 1080))
|
||||
|
||||
def getBackendDirectory(self, backendUrl):
|
||||
"""
|
||||
Returns the temporary directory used to store recordings until uploaded.
|
||||
|
||||
Defaults to False.
|
||||
"""
|
||||
return self._getBackendValue(backendUrl, 'directory', '/tmp')
|
||||
|
||||
def _getBackendValue(self, backendUrl, key, default):
|
||||
backendUrl = backendUrl.rstrip('/')
|
||||
if backendUrl in self._backendIdsByBackendUrl:
|
||||
backendId = self._backendIdsByBackendUrl[backendUrl]
|
||||
|
||||
if self._configParser.get(backendId, key, fallback=None):
|
||||
return self._configParser.get(backendId, key)
|
||||
|
||||
return self._configParser.get('backend', key, fallback=default)
|
||||
|
||||
config = Config()
|
|
@ -0,0 +1,424 @@
|
|||
#
|
||||
# @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 join a call with a browser.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import websocket
|
||||
|
||||
from datetime import datetime
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.service import Service as FirefoxService
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
from shutil import disk_usage
|
||||
from time import sleep
|
||||
|
||||
class BiDiLogsHelper:
|
||||
"""
|
||||
Helper class to get browser logs using the BiDi protocol.
|
||||
|
||||
A new thread is started by each object to receive the logs, so they can be
|
||||
printed in real time even if the main thread is waiting for some script to
|
||||
finish.
|
||||
"""
|
||||
|
||||
def __init__(self, driver, parentLogger):
|
||||
if not 'webSocketUrl' in driver.capabilities:
|
||||
raise Exception('webSocketUrl not found in capabilities')
|
||||
|
||||
self._logger = parentLogger.getChild('BiDiLogsHelper')
|
||||
|
||||
self.realtimeLogsEnabled = False
|
||||
self.pendingLogs = []
|
||||
self.logsLock = threading.Lock()
|
||||
|
||||
# Web socket connection is rejected by Firefox with "Bad request" if
|
||||
# "Origin" header is present; logs show:
|
||||
# "The handshake request has incorrect Origin header".
|
||||
self.websocket = websocket.create_connection(driver.capabilities['webSocketUrl'], suppress_origin=True)
|
||||
|
||||
self.websocket.send(json.dumps({
|
||||
'id': 1,
|
||||
'method': 'session.subscribe',
|
||||
'params': {
|
||||
'events': ['log.entryAdded'],
|
||||
},
|
||||
}))
|
||||
|
||||
self.initialLogsLock = threading.Lock()
|
||||
self.initialLogsLock.acquire()
|
||||
|
||||
self.loggingThread = threading.Thread(target=self.__processLogEvents, daemon=True)
|
||||
self.loggingThread.start()
|
||||
|
||||
# Do not return until the existing logs were fetched, except if it is
|
||||
# taking too long.
|
||||
self.initialLogsLock.acquire(timeout=10)
|
||||
|
||||
def __del__(self):
|
||||
if self.websocket:
|
||||
self.websocket.close()
|
||||
|
||||
if self.loggingThread:
|
||||
self.loggingThread.join()
|
||||
|
||||
def __messageFromEvent(self, event):
|
||||
if not 'params' in event:
|
||||
return '???'
|
||||
|
||||
method = ''
|
||||
if 'method' in event['params']:
|
||||
method = event['params']['method']
|
||||
elif 'level' in event['params']:
|
||||
method = event['params']['level'] if event['params']['level'] != 'warning' else 'warn'
|
||||
|
||||
text = ''
|
||||
if 'text' in event['params']:
|
||||
text = event['params']['text']
|
||||
|
||||
time = '??:??:??'
|
||||
if 'timestamp' in event['params']:
|
||||
timestamp = event['params']['timestamp']
|
||||
|
||||
# JavaScript timestamps are millisecond based, Python timestamps
|
||||
# are second based.
|
||||
time = datetime.fromtimestamp(timestamp / 1000).strftime('%H:%M:%S')
|
||||
|
||||
methodShort = '?'
|
||||
if method == 'error':
|
||||
methodShort = 'E'
|
||||
elif method == 'warn':
|
||||
methodShort = 'W'
|
||||
elif method == 'log':
|
||||
methodShort = 'L'
|
||||
elif method == 'info':
|
||||
methodShort = 'I'
|
||||
elif method == 'debug':
|
||||
methodShort = 'D'
|
||||
|
||||
return time + ' ' + methodShort + ' ' + text
|
||||
|
||||
def __processLogEvents(self):
|
||||
while True:
|
||||
try:
|
||||
event = json.loads(self.websocket.recv())
|
||||
except:
|
||||
self._logger.debug('BiDi WebSocket closed')
|
||||
return
|
||||
|
||||
if 'id' in event and event['id'] == 1:
|
||||
self.initialLogsLock.release()
|
||||
continue
|
||||
|
||||
if not 'method' in event or event['method'] != 'log.entryAdded':
|
||||
continue
|
||||
|
||||
message = self.__messageFromEvent(event)
|
||||
|
||||
with self.logsLock:
|
||||
if self.realtimeLogsEnabled:
|
||||
self._logger.debug(message)
|
||||
else:
|
||||
self.pendingLogs.append(message)
|
||||
|
||||
def clearLogs(self):
|
||||
"""
|
||||
Clears, without printing, the logs received while realtime logs were not
|
||||
enabled.
|
||||
"""
|
||||
|
||||
with self.logsLock:
|
||||
self.pendingLogs = []
|
||||
|
||||
def printLogs(self):
|
||||
"""
|
||||
Prints the logs received while realtime logs were not enabled.
|
||||
|
||||
The logs are cleared after printing them.
|
||||
"""
|
||||
|
||||
with self.logsLock:
|
||||
for log in self.pendingLogs:
|
||||
self._logger.debug(log)
|
||||
|
||||
self.pendingLogs = []
|
||||
|
||||
def setRealtimeLogsEnabled(self, realtimeLogsEnabled):
|
||||
"""
|
||||
Enable or disable realtime logs.
|
||||
|
||||
If logs are received while realtime logs are not enabled they can be
|
||||
printed using "printLogs()".
|
||||
"""
|
||||
|
||||
with self.logsLock:
|
||||
self.realtimeLogsEnabled = realtimeLogsEnabled
|
||||
|
||||
|
||||
class SeleniumHelper:
|
||||
"""
|
||||
Helper class to start a browser and execute scripts in it using WebDriver.
|
||||
|
||||
The browser is expected to be available in the local system.
|
||||
"""
|
||||
|
||||
def __init__(self, parentLogger):
|
||||
self._parentLogger = parentLogger
|
||||
self._logger = parentLogger.getChild('SeleniumHelper')
|
||||
|
||||
self.driver = None
|
||||
self.bidiLogsHelper = None
|
||||
|
||||
def __del__(self):
|
||||
if self.driver:
|
||||
# The session must be explicitly quit to remove the temporary files
|
||||
# created in "/tmp".
|
||||
self.driver.quit()
|
||||
|
||||
def startFirefox(self, width, height, env):
|
||||
"""
|
||||
Starts a Firefox instance.
|
||||
|
||||
:param width: the width of the browser window.
|
||||
:param height: the height of the browser window.
|
||||
:param env: the environment variables, including the display to start
|
||||
the browser in.
|
||||
"""
|
||||
|
||||
options = webdriver.FirefoxOptions()
|
||||
|
||||
# "webSocketUrl" is needed for BiDi; this should be set already by
|
||||
# default, but just in case.
|
||||
options.set_capability('webSocketUrl', True)
|
||||
# In Firefox < 101 BiDi protocol was not enabled by default, although it
|
||||
# works fine for getting the logs with Firefox 99, so it is explicitly
|
||||
# enabled.
|
||||
# https://bugzilla.mozilla.org/show_bug.cgi?id=1753997
|
||||
options.set_preference('remote.active-protocols', 3)
|
||||
|
||||
options.set_preference('media.navigator.permission.disabled', True)
|
||||
|
||||
options.add_argument('--kiosk')
|
||||
options.add_argument(f'--width={width}')
|
||||
options.add_argument(f'--height={height}')
|
||||
|
||||
if disk_usage('/tmp').free < 134217728:
|
||||
self._logger.warning('Less than 128 MiB available in "/tmp", strange failures may occur')
|
||||
|
||||
service = FirefoxService(
|
||||
env=env,
|
||||
)
|
||||
|
||||
self.driver = webdriver.Firefox(
|
||||
options=options,
|
||||
service=service,
|
||||
)
|
||||
|
||||
self.bidiLogsHelper = BiDiLogsHelper(self.driver, self._parentLogger)
|
||||
|
||||
def clearLogs(self):
|
||||
"""
|
||||
Clears browser logs not printed yet.
|
||||
|
||||
This does not affect the logs in the browser itself, only the ones
|
||||
received by the SeleniumHelper.
|
||||
"""
|
||||
|
||||
if self.bidiLogsHelper:
|
||||
self.bidiLogsHelper.clearLogs()
|
||||
return
|
||||
|
||||
self.driver.get_log('browser')
|
||||
|
||||
def printLogs(self):
|
||||
"""
|
||||
Prints browser logs received since last print.
|
||||
|
||||
These logs do not include realtime logs, as they are printed as soon as
|
||||
they are received.
|
||||
"""
|
||||
|
||||
if self.bidiLogsHelper:
|
||||
self.bidiLogsHelper.printLogs()
|
||||
return
|
||||
|
||||
for log in self.driver.get_log('browser'):
|
||||
self._logger.debug(log['message'])
|
||||
|
||||
def execute(self, script):
|
||||
"""
|
||||
Executes the given script.
|
||||
|
||||
If the script contains asynchronous code "executeAsync()" should be used
|
||||
instead to properly wait until the asynchronous code finished before
|
||||
returning.
|
||||
|
||||
Technically Chrome (unlike Firefox) works as expected with something
|
||||
like "execute('await someFunctionCall(); await anotherFunctionCall()'",
|
||||
but "executeAsync" has to be used instead for something like
|
||||
"someFunctionReturningAPromise().then(() => { more code })").
|
||||
|
||||
If realtime logs are available logs are printed as soon as they are
|
||||
received. Otherwise they will be printed once the script has finished.
|
||||
"""
|
||||
|
||||
# Real time logs are enabled while the command is being executed.
|
||||
if self.bidiLogsHelper:
|
||||
self.printLogs()
|
||||
self.bidiLogsHelper.setRealtimeLogsEnabled(True)
|
||||
|
||||
self.driver.execute_script(script)
|
||||
|
||||
if self.bidiLogsHelper:
|
||||
# Give it some time to receive the last real time logs before
|
||||
# disabling them again.
|
||||
sleep(0.5)
|
||||
|
||||
self.bidiLogsHelper.setRealtimeLogsEnabled(False)
|
||||
|
||||
self.printLogs()
|
||||
|
||||
def executeAsync(self, script):
|
||||
"""
|
||||
Executes the given script asynchronously.
|
||||
|
||||
This function should be used to execute JavaScript code that needs to
|
||||
wait for a promise to be fulfilled, either explicitly or through "await"
|
||||
calls.
|
||||
|
||||
The script needs to explicitly signal that the execution has finished by
|
||||
including the special text "{RETURN}" (without quotes). If "{RETURN}" is
|
||||
not included the function will automatically return once all the root
|
||||
statements of the script were executed (which works as expected if using
|
||||
"await" calls, but not if the script includes something like
|
||||
"someFunctionReturningAPromise().then(() => { more code })"; in that
|
||||
case the script should be written as
|
||||
"someFunctionReturningAPromise().then(() => { more code {RETURN} })").
|
||||
|
||||
If realtime logs are available logs are printed as soon as they are
|
||||
received. Otherwise they will be printed once the script has finished.
|
||||
"""
|
||||
|
||||
# Real time logs are enabled while the command is being executed.
|
||||
if self.bidiLogsHelper:
|
||||
self.printLogs()
|
||||
self.bidiLogsHelper.setRealtimeLogsEnabled(True)
|
||||
|
||||
# Add an explicit return point at the end of the script if none is
|
||||
# given.
|
||||
if script.find('{RETURN}') == -1:
|
||||
script += '{RETURN}'
|
||||
|
||||
# await is not valid in the root context in Firefox, so the script to be
|
||||
# executed needs to be wrapped in an async function.
|
||||
script = '(async() => { ' + script + ' })().catch(error => { console.error(error) {RETURN} })'
|
||||
|
||||
# Asynchronous scripts need to explicitly signal that they are finished
|
||||
# by invoking the callback injected as the last argument.
|
||||
# https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidexecute_async
|
||||
script = script.replace('{RETURN}', '; arguments[arguments.length - 1]()')
|
||||
|
||||
self.driver.execute_async_script(script)
|
||||
|
||||
if self.bidiLogsHelper:
|
||||
# Give it some time to receive the last real time logs before
|
||||
# disabling them again.
|
||||
sleep(0.5)
|
||||
|
||||
self.bidiLogsHelper.setRealtimeLogsEnabled(False)
|
||||
|
||||
self.printLogs()
|
||||
|
||||
|
||||
class Participant():
|
||||
"""
|
||||
Wrapper for a real participant in Talk.
|
||||
|
||||
This wrapper exposes functions to use a real participant in a browser.
|
||||
"""
|
||||
|
||||
def __init__(self, browser, nextcloudUrl, width, height, env, parentLogger):
|
||||
"""
|
||||
Starts a real participant in the given Nextcloud URL using the given
|
||||
browser.
|
||||
|
||||
:param browser: currently only "firefox" is supported.
|
||||
:param nextcloudUrl: the URL of the Nextcloud instance to start the real
|
||||
participant in.
|
||||
:param width: the width of the browser window.
|
||||
:param height: the height of the browser window.
|
||||
:param env: the environment variables, including the display to start
|
||||
the browser in.
|
||||
:param parentLogger: the parent logger to get a child from.
|
||||
"""
|
||||
|
||||
# URL should not contain a trailing '/', as that could lead to a double
|
||||
# '/' which may prevent Talk UI from loading as expected.
|
||||
self.nextcloudUrl = nextcloudUrl.rstrip('/')
|
||||
|
||||
self.seleniumHelper = SeleniumHelper(parentLogger)
|
||||
|
||||
if browser == 'firefox':
|
||||
self.seleniumHelper.startFirefox(width, height, env)
|
||||
else:
|
||||
raise Exception('Invalid browser: ' + browser)
|
||||
|
||||
self.seleniumHelper.driver.get(nextcloudUrl)
|
||||
|
||||
def joinCall(self, token):
|
||||
"""
|
||||
Joins (or starts) the call in the room with the given token.
|
||||
|
||||
The participant will join as a guest.
|
||||
|
||||
:param token: the token of the room to join.
|
||||
"""
|
||||
|
||||
self.seleniumHelper.driver.get(self.nextcloudUrl + '/index.php/call/' + token)
|
||||
|
||||
# Hack to prevent the participant from using any device that might be
|
||||
# available, including PulseAudio's "Monitor of Dummy Output".
|
||||
self.seleniumHelper.execute('navigator.mediaDevices.getUserMedia = async function() { throw new Error() }')
|
||||
|
||||
WebDriverWait(self.seleniumHelper.driver, timeout=30).until(lambda driver: driver.find_element(By.CSS_SELECTOR, '.top-bar #call_button:not(:disabled)'))
|
||||
self.seleniumHelper.driver.find_element(By.CSS_SELECTOR, '.top-bar #call_button').click()
|
||||
|
||||
try:
|
||||
# If the device selector is shown click on the "Join call" button
|
||||
# in the dialog to actually join the call.
|
||||
WebDriverWait(self.seleniumHelper.driver, timeout=5).until(lambda driver: driver.find_element(By.CSS_SELECTOR, '.device-checker #call_button'))
|
||||
self.seleniumHelper.driver.find_element(By.CSS_SELECTOR, '.device-checker #call_button').click()
|
||||
except:
|
||||
pass
|
||||
|
||||
def leaveCall(self):
|
||||
"""
|
||||
Leaves the current call.
|
||||
|
||||
The call must have been joined first.
|
||||
"""
|
||||
|
||||
self.seleniumHelper.executeAsync('''
|
||||
await OCA.Talk.SimpleWebRTC.connection.leaveCurrentCall()
|
||||
''')
|
|
@ -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)
|
|
@ -0,0 +1,312 @@
|
|||
#
|
||||
# @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 start and stop the recording for a specific call.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pulsectl
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pyvirtualdisplay import Display
|
||||
from secrets import token_urlsafe
|
||||
from threading import Thread
|
||||
|
||||
from . import BackendNotifier
|
||||
from .Config import config
|
||||
from .Participant import Participant
|
||||
|
||||
RECORDING_STATUS_AUDIO_AND_VIDEO = 1
|
||||
RECORDING_STATUS_AUDIO_ONLY = 2
|
||||
|
||||
def getRecorderArgs(status, displayId, audioSinkIndex, width, height, extensionlessOutputFileName):
|
||||
"""
|
||||
Returns the list of arguments to start the recorder process.
|
||||
|
||||
:param status: whether to record audio and video or only audio.
|
||||
:param displayId: the ID of the display that the browser is running in.
|
||||
:param audioSinkIndex: the index of the sink for the browser audio output.
|
||||
:param width: the width of the display and the recording.
|
||||
:param height: the height of the display and the recording.
|
||||
:param extensionlessOutputFileName: the file name for the recording, without
|
||||
extension.
|
||||
:returns: the file name for the recording, with extension.
|
||||
"""
|
||||
|
||||
ffmpegCommon = ['ffmpeg', '-loglevel', 'level+warning', '-n']
|
||||
ffmpegInputAudio = ['-f', 'pulse', '-i', audioSinkIndex]
|
||||
ffmpegInputVideo = ['-f', 'x11grab', '-draw_mouse', '0', '-video_size', f'{width}x{height}', '-i', displayId]
|
||||
ffmpegOutputAudio = ['-c:a', 'libopus']
|
||||
ffmpegOutputVideo = ['-c:v', 'libvpx', '-quality:v', 'realtime']
|
||||
|
||||
extension = '.ogg'
|
||||
if status == RECORDING_STATUS_AUDIO_AND_VIDEO:
|
||||
extension = '.webm'
|
||||
|
||||
outputFileName = extensionlessOutputFileName + extension
|
||||
|
||||
ffmpegArgs = ffmpegCommon
|
||||
ffmpegArgs += ffmpegInputAudio
|
||||
|
||||
if status == RECORDING_STATUS_AUDIO_AND_VIDEO:
|
||||
ffmpegArgs += ffmpegInputVideo
|
||||
|
||||
ffmpegArgs += ffmpegOutputAudio
|
||||
|
||||
if status == RECORDING_STATUS_AUDIO_AND_VIDEO:
|
||||
ffmpegArgs += ffmpegOutputVideo
|
||||
|
||||
return ffmpegArgs + [outputFileName]
|
||||
|
||||
def newAudioSink(sanitizedBackend, token):
|
||||
"""
|
||||
Start new audio sink for the audio output of the browser.
|
||||
|
||||
Each browser instance uses its own sink that will then be captured by the
|
||||
recorder. Otherwise several browsers would use the same default sink, and
|
||||
their audio output would be mixed.
|
||||
|
||||
The sink is created by loading a null sink module. This module needs to be
|
||||
unloaded once the sink is no longer needed to remove it.
|
||||
|
||||
:param sanitizedBackend: the backend of the call; it is expected to have
|
||||
been sanitized and to contain only alpha-numeric characters.
|
||||
:param token: the token of the call.
|
||||
:return: a tuple with the module index and the sink index, both as ints.
|
||||
"""
|
||||
|
||||
# A random value is appended to the backend and token to "ensure" that there
|
||||
# will be no name clashes if a previous sink for that backend and module was
|
||||
# not unloaded yet.
|
||||
sinkName = f"{sanitizedBackend}-{token}-{token_urlsafe(32)}"
|
||||
|
||||
# Module names can be, at most, 127 characters, so the name is truncated if
|
||||
# needed.
|
||||
sinkName = sinkName[:127]
|
||||
|
||||
with pulsectl.Pulse(f"{sinkName}-loader") as pacmd:
|
||||
pacmd.module_load("module-null-sink", f"sink_name={sinkName}")
|
||||
|
||||
moduleIndex = None
|
||||
moduleList = pacmd.module_list()
|
||||
for module in moduleList:
|
||||
if module.argument == f"sink_name={sinkName}":
|
||||
moduleIndex = module.index
|
||||
|
||||
if not moduleIndex:
|
||||
raise Exception(f"New audio module for sink {sinkName} not found ({moduleList})")
|
||||
|
||||
sinkIndex = None
|
||||
sinkList = pacmd.sink_list()
|
||||
for sink in sinkList:
|
||||
if sink.name == sinkName:
|
||||
sinkIndex = sink.index
|
||||
|
||||
if not sinkIndex:
|
||||
raise Exception(f"New audio sink {sinkName} not found ({sinkList})")
|
||||
|
||||
return moduleIndex, sinkIndex
|
||||
|
||||
def recorderLog(backend, token, pipe):
|
||||
"""
|
||||
Logs the recorder output.
|
||||
|
||||
:param backend: the backend of the call.
|
||||
:param token: the token of the call.
|
||||
:param pipe: Pipe to the recorder process output.
|
||||
"""
|
||||
logger = logging.getLogger(f"{__name__}.recorder-{backend}-{token}")
|
||||
|
||||
with pipe:
|
||||
for line in pipe:
|
||||
# Lines captured from the recorder have a trailing new line, so it
|
||||
# needs to be removed.
|
||||
logger.info(line.rstrip('\n'))
|
||||
|
||||
class Service:
|
||||
"""
|
||||
Class to set up and tear down the needed elements to record a call.
|
||||
|
||||
To record a call a virtual display server and an audio sink are created.
|
||||
Then a browser is launched in kiosk mode inside the virtual display server,
|
||||
and its audio is routed to the audio sink. This ensures that several
|
||||
Services / browsers can be running at the same time without interfering with
|
||||
each other, and that the virtual display driver will only show the browser
|
||||
contents, without any browser UI. Then the call is joined in the browser,
|
||||
and an FFMPEG process to record the virtual display driver and the audio
|
||||
sink is started.
|
||||
|
||||
Once the recording is stopped the helper elements are also stopped and the
|
||||
recording is uploaded to the Nextcloud server.
|
||||
|
||||
"start()" blocks until the recording ends, so "start()" and "stop()" are
|
||||
expected to be called from different threads.
|
||||
"""
|
||||
|
||||
def __init__(self, backend, token, status, owner):
|
||||
self._logger = logging.getLogger(f"{__name__}-{backend}-{token}")
|
||||
|
||||
self.backend = backend
|
||||
self.token = token
|
||||
self.status = status
|
||||
self.owner = owner
|
||||
|
||||
self._display = None
|
||||
self._audioModuleIndex = None
|
||||
self._participant = None
|
||||
self._process = None
|
||||
self._fileName = None
|
||||
|
||||
def __del__(self):
|
||||
self._stopHelpers()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Starts the recording.
|
||||
|
||||
This method blocks until the recording ends.
|
||||
|
||||
:raise Exception: if the recording ends unexpectedly (including if it
|
||||
could not be started).
|
||||
"""
|
||||
|
||||
width = config.getBackendVideoWidth(self.backend)
|
||||
height = config.getBackendVideoHeight(self.backend)
|
||||
|
||||
directory = config.getBackendDirectory(self.backend).rstrip('/')
|
||||
|
||||
sanitizedBackend = ''.join([character for character in self.backend if character.isalnum()])
|
||||
|
||||
fullDirectory = f'{directory}/{sanitizedBackend}/{self.token}'
|
||||
|
||||
try:
|
||||
# Ensure that PulseAudio is running.
|
||||
# A "long" timeout is used to prevent it from exiting before the
|
||||
# call was joined.
|
||||
subprocess.run(['pulseaudio', '--start', '--exit-idle-time=120'], check=True)
|
||||
|
||||
# Ensure that the directory to start the recordings exists.
|
||||
os.makedirs(fullDirectory, exist_ok=True)
|
||||
|
||||
self._display = Display(size=(width, height), manage_global_env=False)
|
||||
self._display.start()
|
||||
|
||||
# Start new audio sink for the audio output of the browser.
|
||||
self._audioModuleIndex, audioSinkIndex = newAudioSink(sanitizedBackend, self.token)
|
||||
audioSinkIndex = str(audioSinkIndex)
|
||||
|
||||
env = self._display.env()
|
||||
env['PULSE_SINK'] = audioSinkIndex
|
||||
|
||||
self._logger.debug("Starting participant")
|
||||
self._participant = Participant('firefox', self.backend, width, height, env, self._logger)
|
||||
|
||||
self._logger.debug("Joining call")
|
||||
self._participant.joinCall(self.token)
|
||||
|
||||
extensionlessFileName = f'{fullDirectory}/recording-{datetime.now().strftime("%Y%m%d-%H%M%S")}'
|
||||
|
||||
recorderArgs = getRecorderArgs(self.status, self._display.new_display_var, audioSinkIndex, width, height, extensionlessFileName)
|
||||
|
||||
self._fileName = recorderArgs[-1]
|
||||
|
||||
self._logger.debug("Starting recorder")
|
||||
self._process = subprocess.Popen(recorderArgs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
# Log recorder output.
|
||||
Thread(target=recorderLog, args=[self.backend, self.token, self._process.stdout], daemon=True).start()
|
||||
|
||||
returnCode = self._process.wait()
|
||||
|
||||
# recorder process will be explicitly terminated when needed, which
|
||||
# returns with 255; any other return code means that it ended
|
||||
# without an expected reason.
|
||||
if returnCode != 255:
|
||||
raise Exception("recorder ended unexpectedly")
|
||||
except Exception as exception:
|
||||
self._stopHelpers()
|
||||
|
||||
raise
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stops the recording and uploads it.
|
||||
|
||||
The recording is removed from the temporary directory once uploaded,
|
||||
although it is kept if the upload fails.
|
||||
|
||||
:raise Exception: if the file could not be uploaded.
|
||||
"""
|
||||
|
||||
self._stopHelpers()
|
||||
|
||||
if not self._fileName:
|
||||
self._logger.error(f"Recording stopping before starting, nothing to upload")
|
||||
|
||||
return
|
||||
|
||||
if not os.path.exists(self._fileName):
|
||||
self._logger.error(f"Recording can not be uploaded, {self._fileName} does not exist")
|
||||
|
||||
return
|
||||
|
||||
BackendNotifier.uploadRecording(self.backend, self.token, self._fileName, self.owner)
|
||||
|
||||
os.remove(self._fileName)
|
||||
|
||||
def _stopHelpers(self):
|
||||
if self._process:
|
||||
self._logger.debug("Stopping recorder")
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait()
|
||||
except:
|
||||
self._logger.exception("Error when terminating recorder")
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
if self._participant:
|
||||
self._logger.debug("Leaving call")
|
||||
try:
|
||||
self._participant.leaveCall()
|
||||
except:
|
||||
self._logger.exception("Error when leaving call")
|
||||
finally:
|
||||
self._participant = None
|
||||
|
||||
if self._audioModuleIndex:
|
||||
self._logger.debug("Unloading audio module")
|
||||
try:
|
||||
with pulsectl.Pulse(f"audio-module-{self._audioModuleIndex}-unloader") as pacmd:
|
||||
pacmd.module_unload(self._audioModuleIndex)
|
||||
except:
|
||||
self._logger.exception("Error when unloading audio module")
|
||||
finally:
|
||||
self._audioModuleIndex = None
|
||||
|
||||
if self._display:
|
||||
self._logger.debug("Stopping display")
|
||||
try:
|
||||
self._display.stop()
|
||||
except:
|
||||
self._logger.exception("Error when stopping display")
|
||||
finally:
|
||||
self._display = None
|
|
@ -0,0 +1,20 @@
|
|||
#
|
||||
# @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/>.
|
||||
#
|
||||
|
||||
__version__ = 0.1
|
|
@ -0,0 +1,38 @@
|
|||
#
|
||||
# @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/>.
|
||||
#
|
||||
|
||||
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")
|
||||
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)
|
|
@ -0,0 +1,210 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# @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/>.
|
||||
|
||||
# Helper script to run the recording backend for Nextcloud Talk.
|
||||
#
|
||||
# The recording backend is implemented in several Python files. This Bash script
|
||||
# is provided to set up a Docker container with Selenium, a web browser and all
|
||||
# the needed Python dependencies for the recording backend.
|
||||
#
|
||||
# This script creates an Ubuntu container, installs all the needed dependencies
|
||||
# in it and executes the recording backend inside the container. If the
|
||||
# container exists already the previous container will be reused and this script
|
||||
# will simply execute the recording backend in it.
|
||||
#
|
||||
# Due to that the Docker container will not be stopped nor removed when the
|
||||
# script exits (except when the container was created but it could not be
|
||||
# started); that must be explicitly done once the container is no longer needed.
|
||||
#
|
||||
#
|
||||
#
|
||||
# DOCKER AND PERMISSIONS
|
||||
#
|
||||
# To perform its job, this script requires the "docker" command to be available.
|
||||
#
|
||||
# The Docker Command Line Interface (the "docker" command) requires special
|
||||
# permissions to talk to the Docker daemon, and those permissions are typically
|
||||
# available only to the root user. Please see the Docker documentation to find
|
||||
# out how to give access to a regular user to the Docker daemon:
|
||||
# https://docs.docker.com/engine/installation/linux/linux-postinstall/
|
||||
#
|
||||
# Note, however, that being able to communicate with the Docker daemon is the
|
||||
# same as being able to get root privileges for the system. Therefore, you must
|
||||
# give access to the Docker daemon (and thus run this script as) ONLY to trusted
|
||||
# and secure users:
|
||||
# https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface
|
||||
|
||||
# Sets the variables that abstract the differences in command names and options
|
||||
# between operating systems.
|
||||
#
|
||||
# Switches between timeout on GNU/Linux and gtimeout on macOS (same for mktemp
|
||||
# and gmktemp).
|
||||
function setOperatingSystemAbstractionVariables() {
|
||||
case "$OSTYPE" in
|
||||
darwin*)
|
||||
if [ "$(which gtimeout)" == "" ]; then
|
||||
echo "Please install coreutils (brew install coreutils)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MKTEMP=gmktemp
|
||||
TIMEOUT=gtimeout
|
||||
DOCKER_OPTIONS="-e no_proxy=localhost "
|
||||
;;
|
||||
linux*)
|
||||
MKTEMP=mktemp
|
||||
TIMEOUT=timeout
|
||||
DOCKER_OPTIONS=" "
|
||||
;;
|
||||
*)
|
||||
echo "Operating system ($OSTYPE) not supported"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Removes Docker container if it was created but failed to start.
|
||||
function cleanUp() {
|
||||
# Disable (yes, "+" disables) exiting immediately on errors to ensure that
|
||||
# all the cleanup commands are executed (well, no errors should occur during
|
||||
# the cleanup anyway, but just in case).
|
||||
set +o errexit
|
||||
|
||||
# The name filter must be specified as "^/XXX$" to get an exact match; using
|
||||
# just "XXX" would match every name that contained "XXX".
|
||||
if [ -n "$(docker ps --all --quiet --filter status=created --filter name="^/$CONTAINER$")" ]; then
|
||||
echo "Removing Docker container $CONTAINER"
|
||||
docker rm --volumes --force $CONTAINER
|
||||
fi
|
||||
}
|
||||
|
||||
# Exit immediately on errors.
|
||||
set -o errexit
|
||||
|
||||
# Execute cleanUp when the script exits, either normally or due to an error.
|
||||
trap cleanUp EXIT
|
||||
|
||||
# Ensure working directory is script directory, as some actions (like copying
|
||||
# the files to the container) expect that.
|
||||
cd "$(dirname $0)"
|
||||
|
||||
HELP="Usage: $(basename $0) [OPTION]...
|
||||
|
||||
Options (all options can be omitted, but when present they must appear in the
|
||||
following order):
|
||||
--help prints this help and exits.
|
||||
--container CONTAINER_NAME the name to assign to the container. Defaults to
|
||||
talk-recording.
|
||||
--time-zone TIME_ZONE the time zone to use inside the container. Defaults to
|
||||
UTC. The recording backend can be started again later with a different time
|
||||
zone (although other commands executed in the container with 'docker exec'
|
||||
will still use the time zone specified during creation).
|
||||
--dev-shm-size SIZE the size to assign to /dev/shm in the Docker container.
|
||||
Defaults to 2g"
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo "$HELP"
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CONTAINER="talk-recording"
|
||||
if [ "$1" = "--container" ]; then
|
||||
CONTAINER="$2"
|
||||
|
||||
shift 2
|
||||
fi
|
||||
|
||||
if [ "$1" = "--time-zone" ]; then
|
||||
TIME_ZONE="$2"
|
||||
|
||||
shift 2
|
||||
fi
|
||||
|
||||
CUSTOM_CONTAINER_OPTIONS=false
|
||||
|
||||
# 2g is the default value recommended in the documentation of the Docker images
|
||||
# for Selenium:
|
||||
# https://github.com/SeleniumHQ/docker-selenium#--shm-size2g
|
||||
DEV_SHM_SIZE="2g"
|
||||
if [ "$1" = "--dev-shm-size" ]; then
|
||||
DEV_SHM_SIZE="$2"
|
||||
CUSTOM_CONTAINER_OPTIONS=true
|
||||
|
||||
shift 2
|
||||
fi
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
echo "Invalid option (or at invalid position): $1
|
||||
|
||||
$HELP"
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENVIRONMENT_VARIABLES=""
|
||||
if [ -n "$TIME_ZONE" ]; then
|
||||
ENVIRONMENT_VARIABLES="--env TZ=$TIME_ZONE"
|
||||
fi
|
||||
|
||||
setOperatingSystemAbstractionVariables
|
||||
|
||||
# If the container is not found a new one is prepared. Otherwise the existing
|
||||
# container is used.
|
||||
#
|
||||
# The name filter must be specified as "^/XXX$" to get an exact match; using
|
||||
# just "XXX" would match every name that contained "XXX".
|
||||
if [ -z "$(docker ps --all --quiet --filter name="^/$CONTAINER$")" ]; then
|
||||
echo "Creating Talk recording container"
|
||||
# In Ubuntu 22.04 and later Firefox is installed as a snap package, which
|
||||
# does not work out of the box in a container. Therefore, for now Ubuntu
|
||||
# 20.04 is used instead.
|
||||
docker run --detach --tty --name=$CONTAINER --shm-size=$DEV_SHM_SIZE $ENVIRONMENT_VARIABLES $DOCKER_OPTIONS ubuntu:20.04 bash
|
||||
|
||||
echo "Installing required Python modules"
|
||||
# "noninteractive" is used to provide default settings instead of asking for
|
||||
# them (for example, for tzdata).
|
||||
# Additional Python dependencies may be installed by pip if needed.
|
||||
docker exec $CONTAINER bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes ffmpeg firefox pulseaudio python3-pip xvfb"
|
||||
|
||||
echo "Adding user to run the recording backend"
|
||||
docker exec $CONTAINER useradd --create-home recording
|
||||
|
||||
echo "Copying recording backend to the container"
|
||||
docker exec $CONTAINER mkdir --parent /tmp/recording/
|
||||
docker cp . $CONTAINER:/tmp/recording/
|
||||
|
||||
echo "Installing recording backend inside container"
|
||||
docker exec $CONTAINER python3 -m pip install /tmp/recording/
|
||||
|
||||
echo "Copying configuration from server.conf.in to /etc/nextcloud-talk-recording/server.conf"
|
||||
docker exec $CONTAINER mkdir --parent /etc/nextcloud-talk-recording/
|
||||
docker cp server.conf.in $CONTAINER:/etc/nextcloud-talk-recording/server.conf
|
||||
elif $CUSTOM_CONTAINER_OPTIONS; then
|
||||
# Environment variables are excluded from this warning.
|
||||
echo "WARNING: Using existing container, custom container options ignored"
|
||||
fi
|
||||
|
||||
# Start existing container if it is stopped.
|
||||
if [ -n "$(docker ps --all --quiet --filter status=exited --filter name="^/$CONTAINER$")" ]; then
|
||||
echo "Starting Talk recording container"
|
||||
docker start $CONTAINER
|
||||
fi
|
||||
|
||||
echo "Starting recording backend"
|
||||
docker exec --tty --interactive --user recording $ENVIRONMENT_VARIABLES --workdir /home/recording $CONTAINER python3 -m nextcloud.talk.recording --config /etc/nextcloud-talk-recording/server.conf
|
|
@ -0,0 +1,204 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com>
|
||||
- @copyright Copyright (c) 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
-
|
||||
- @author Joas Schilling <coding@schilljs.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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="recording-server">
|
||||
<input ref="recording_server"
|
||||
type="text"
|
||||
name="recording_server"
|
||||
placeholder="https://recording.example.org"
|
||||
:value="server"
|
||||
:disabled="loading"
|
||||
:aria-label="t('spreed', 'Recording backend URL')"
|
||||
@input="updateServer">
|
||||
|
||||
<NcCheckboxRadioSwitch :checked="verify"
|
||||
@update:checked="updateVerify">
|
||||
{{ t('spreed', 'Validate SSL certificate') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcButton v-show="!loading"
|
||||
type="tertiary-no-background"
|
||||
:aria-label="t('spreed', 'Delete this server')"
|
||||
@click="removeServer">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<div v-if="server">
|
||||
<div class="testing-icon">
|
||||
<NcLoadingIcon v-if="!checked" :size="20" />
|
||||
<AlertCircle v-else-if="errorMessage" :size="20" :fill-color="'#E9322D'" />
|
||||
<Check v-else :size="20" :fill-color="'#46BA61'" />
|
||||
</div>
|
||||
<div class="testing-label">
|
||||
{{ connectionState }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||
import Check from 'vue-material-design-icons/Check.vue'
|
||||
import Delete from 'vue-material-design-icons/Delete.vue'
|
||||
import { getWelcomeMessage } from '../../services/recordingService.js'
|
||||
|
||||
export default {
|
||||
name: 'RecordingServer',
|
||||
|
||||
components: {
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcLoadingIcon,
|
||||
AlertCircle,
|
||||
Check,
|
||||
Delete,
|
||||
},
|
||||
|
||||
props: {
|
||||
server: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
verify: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
errorMessage: '',
|
||||
versionFound: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
connectionState() {
|
||||
if (!this.checked) {
|
||||
return t('spreed', 'Status: Checking connection')
|
||||
}
|
||||
if (this.errorMessage) {
|
||||
return this.errorMessage
|
||||
}
|
||||
return t('spreed', 'OK: Running version: {version}', {
|
||||
version: this.versionFound,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
loading(isLoading) {
|
||||
if (!isLoading) {
|
||||
this.checkServerVersion()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.server) {
|
||||
this.checkServerVersion()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeServer() {
|
||||
this.$emit('remove-server', this.index)
|
||||
},
|
||||
updateServer(event) {
|
||||
this.$emit('update:server', event.target.value)
|
||||
},
|
||||
updateVerify(checked) {
|
||||
this.$emit('update:verify', checked)
|
||||
},
|
||||
|
||||
async checkServerVersion() {
|
||||
this.checked = false
|
||||
|
||||
this.errorMessage = ''
|
||||
this.versionFound = ''
|
||||
|
||||
try {
|
||||
const response = await getWelcomeMessage(this.index)
|
||||
this.checked = true
|
||||
this.versionFound = response.data.ocs.data.version
|
||||
} catch (exception) {
|
||||
this.checked = true
|
||||
if (exception.response.data.ocs.data.error === 'CAN_NOT_CONNECT') {
|
||||
this.errorMessage = t('spreed', 'Error: Cannot connect to server')
|
||||
} else if (exception.response.data.ocs.data.error === 'JSON_INVALID') {
|
||||
this.errorMessage = t('spreed', 'Error: Server did not respond with proper JSON')
|
||||
} else if (exception.response.data.ocs.data.error) {
|
||||
this.errorMessage = t('spreed', 'Error: Server responded with: {error}', exception.response.data.ocs.data)
|
||||
} else {
|
||||
this.errorMessage = t('spreed', 'Error: Unknown error occurred')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recording-server {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
margin: 0 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.testing-icon{
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
line-height: 44px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.testing-label {
|
||||
display: inline-block;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,164 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com>
|
||||
- @copyright Copyright (c) 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
-
|
||||
- @author Joas Schilling <coding@schilljs.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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="recording_server" class="videocalls section recording-server">
|
||||
<h2>
|
||||
{{ t('spreed', 'Recording backend') }}
|
||||
|
||||
<NcButton v-if="!loading && showAddServerButton"
|
||||
class="recording-server__add-icon"
|
||||
type="tertiary-no-background"
|
||||
:aria-label="t('spreed', 'Add a new recording backend server')"
|
||||
@click="newServer">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</h2>
|
||||
|
||||
<ul class="recording-servers">
|
||||
<transition-group name="fade" tag="li">
|
||||
<RecordingServer v-for="(server, index) in servers"
|
||||
:key="`server${index}`"
|
||||
:server.sync="servers[index].server"
|
||||
:verify.sync="servers[index].verify"
|
||||
:index="index"
|
||||
:loading="loading"
|
||||
@remove-server="removeServer"
|
||||
@update:server="debounceUpdateServers"
|
||||
@update:verify="debounceUpdateServers" />
|
||||
</transition-group>
|
||||
</ul>
|
||||
|
||||
<div class="recording-secret">
|
||||
<h4>{{ t('spreed', 'Shared secret') }}</h4>
|
||||
<input v-model="secret"
|
||||
type="text"
|
||||
name="recording_secret"
|
||||
:disabled="loading"
|
||||
:placeholder="t('spreed', 'Shared secret')"
|
||||
:aria-label="t('spreed', 'Shared secret')"
|
||||
@input="debounceUpdateServers">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecordingServer from '../../components/AdminSettings/RecordingServer.vue'
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import { showSuccess } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import debounce from 'debounce'
|
||||
|
||||
export default {
|
||||
name: 'RecordingServers',
|
||||
|
||||
components: {
|
||||
NcButton,
|
||||
Plus,
|
||||
RecordingServer,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
servers: [],
|
||||
secret: '',
|
||||
loading: false,
|
||||
saved: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
showAddServerButton() {
|
||||
return this.servers.length === 0
|
||||
},
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
const state = loadState('spreed', 'recording_servers')
|
||||
this.servers = state.servers
|
||||
this.secret = state.secret
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeServer(index) {
|
||||
this.servers.splice(index, 1)
|
||||
this.debounceUpdateServers()
|
||||
},
|
||||
|
||||
newServer() {
|
||||
this.servers.push({
|
||||
server: '',
|
||||
verify: false,
|
||||
})
|
||||
},
|
||||
|
||||
debounceUpdateServers: debounce(function() {
|
||||
this.updateServers()
|
||||
}, 1000),
|
||||
|
||||
async updateServers() {
|
||||
this.loading = true
|
||||
|
||||
this.servers = this.servers.filter(server => server.server.trim() !== '')
|
||||
|
||||
const self = this
|
||||
OCP.AppConfig.setValue('spreed', 'recording_servers', JSON.stringify({
|
||||
servers: this.servers,
|
||||
secret: this.secret,
|
||||
}), {
|
||||
success() {
|
||||
showSuccess(t('spreed', 'Recording backend settings saved'))
|
||||
self.loading = false
|
||||
self.toggleSave()
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
toggleSave() {
|
||||
this.saved = true
|
||||
setTimeout(() => {
|
||||
this.saved = false
|
||||
}, 3000)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../assets/variables';
|
||||
|
||||
.recording-server {
|
||||
h2 {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-secret {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
|
@ -56,7 +56,7 @@
|
|||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<ul class="turn-servers">
|
||||
<ul class="signaling-servers">
|
||||
<transition-group name="fade" tag="li">
|
||||
<SignalingServer v-for="(server, index) in servers"
|
||||
:key="`server${index}`"
|
||||
|
|
|
@ -364,7 +364,7 @@ export default {
|
|||
|
||||
canModerateRecording() {
|
||||
const recordingEnabled = getCapabilities()?.spreed?.config?.call?.recording || false
|
||||
return this.canModerate && recordingEnabled
|
||||
return this.canFullModerate && recordingEnabled
|
||||
},
|
||||
|
||||
isRecording() {
|
||||
|
|
|
@ -63,3 +63,7 @@ EventBus.$on('signaling-join-room', (payload) => {
|
|||
const token = payload[0]
|
||||
store.dispatch('updateLastJoinedConversationToken', token)
|
||||
})
|
||||
|
||||
EventBus.$on('signaling-recording-status-changed', (token, status) => {
|
||||
store.dispatch('setConversationProperties', { token, properties: { callRecording: status } })
|
||||
})
|
||||
|
|
|
@ -23,6 +23,16 @@
|
|||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
|
||||
/**
|
||||
* Get welcome message from the recording server
|
||||
*
|
||||
* @param {number} serverId the index in the list of configured recording
|
||||
* servers
|
||||
*/
|
||||
const getWelcomeMessage = async (serverId) => {
|
||||
return axios.get(generateOcsUrl('apps/spreed/api/v1/recording/welcome/{serverId}', { serverId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start call recording
|
||||
*
|
||||
|
@ -46,6 +56,7 @@ const stopCallRecording = async (token) => {
|
|||
}
|
||||
|
||||
export {
|
||||
getWelcomeMessage,
|
||||
startCallRecording,
|
||||
stopCallRecording,
|
||||
}
|
||||
|
|
|
@ -1281,7 +1281,7 @@ Signaling.Standalone.prototype.processRoomEvent = function(data) {
|
|||
})
|
||||
break
|
||||
case 'message':
|
||||
this.processRoomMessageEvent(data.event.message.data)
|
||||
this.processRoomMessageEvent(data.event.message.roomid, data.event.message.data)
|
||||
break
|
||||
default:
|
||||
console.error('Unknown room event', data)
|
||||
|
@ -1289,12 +1289,15 @@ Signaling.Standalone.prototype.processRoomEvent = function(data) {
|
|||
}
|
||||
}
|
||||
|
||||
Signaling.Standalone.prototype.processRoomMessageEvent = function(data) {
|
||||
Signaling.Standalone.prototype.processRoomMessageEvent = function(token, data) {
|
||||
switch (data.type) {
|
||||
case 'chat':
|
||||
// FIXME this is not listened to
|
||||
EventBus.$emit('should-refresh-chat-messages')
|
||||
break
|
||||
case 'recording':
|
||||
EventBus.$emit('signaling-recording-status-changed', token, data.recording.status)
|
||||
break
|
||||
default:
|
||||
console.error('Unknown room message event', data)
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
<TurnServers />
|
||||
<SignalingServers />
|
||||
<HostedSignalingServer />
|
||||
<RecordingServers />
|
||||
<SIPBridge />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -41,6 +42,7 @@ import Commands from '../components/AdminSettings/Commands.vue'
|
|||
import GeneralSettings from '../components/AdminSettings/GeneralSettings.vue'
|
||||
import HostedSignalingServer from '../components/AdminSettings/HostedSignalingServer.vue'
|
||||
import MatterbridgeIntegration from '../components/AdminSettings/MatterbridgeIntegration.vue'
|
||||
import RecordingServers from '../components/AdminSettings/RecordingServers.vue'
|
||||
import SignalingServers from '../components/AdminSettings/SignalingServers.vue'
|
||||
import SIPBridge from '../components/AdminSettings/SIPBridge.vue'
|
||||
import StunServers from '../components/AdminSettings/StunServers.vue'
|
||||
|
@ -56,6 +58,7 @@ export default {
|
|||
GeneralSettings,
|
||||
HostedSignalingServer,
|
||||
MatterbridgeIntegration,
|
||||
RecordingServers,
|
||||
SignalingServers,
|
||||
SIPBridge,
|
||||
StunServers,
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
$receivedRequestsFile = sys_get_temp_dir() . '/fake-nextcloud-talk-recording-requests';
|
||||
|
||||
if (preg_match('/\/api\/v1\/welcome/', $_SERVER['REQUEST_URI'])) {
|
||||
echo json_encode(['version' => '0.1-fake']);
|
||||
} elseif (preg_match('/\/api\/v1\/room\/([^\/]+)/', $_SERVER['REQUEST_URI'], $matches)) {
|
||||
if (empty($_SERVER['HTTP_TALK_RECORDING_RANDOM'])) {
|
||||
error_log('fake-recording-server: Missing Talk-Recording-Random header');
|
||||
|
||||
header('HTTP/1.0 403 Forbidden');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($_SERVER['HTTP_TALK_RECORDING_CHECKSUM'])) {
|
||||
error_log('fake-recording-server: Missing Talk-Recording-Checksum header');
|
||||
|
||||
header('HTTP/1.0 403 Forbidden');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$random = $_SERVER['HTTP_TALK_RECORDING_RANDOM'];
|
||||
$checksum = $_SERVER['HTTP_TALK_RECORDING_CHECKSUM'];
|
||||
|
||||
$data = file_get_contents('php://input');
|
||||
|
||||
$hash = hash_hmac('sha256', $random . $data, 'the secret');
|
||||
if (!hash_equals($hash, strtolower($checksum))) {
|
||||
error_log('fake-recording-server: Checksum does not match');
|
||||
|
||||
header('HTTP/1.0 403 Forbidden');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$receivedRequests = [];
|
||||
if (file_exists($receivedRequestsFile)) {
|
||||
$receivedRequests = json_decode(file_get_contents($receivedRequestsFile));
|
||||
}
|
||||
$receivedRequests[] = [
|
||||
'token' => $matches[1],
|
||||
'data' => $data,
|
||||
];
|
||||
file_put_contents($receivedRequestsFile, json_encode($receivedRequests));
|
||||
} elseif (preg_match('/requests/', $_SERVER['REQUEST_URI'])) {
|
||||
if (!file_exists($receivedRequestsFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requests = file_get_contents($receivedRequestsFile);
|
||||
|
||||
// Previous received requests are cleared.
|
||||
unlink($receivedRequestsFile);
|
||||
|
||||
echo $requests;
|
||||
} else {
|
||||
header('HTTP/1.0 404 Not Found');
|
||||
}
|
|
@ -116,6 +116,7 @@ class FeatureContext implements Context, SnippetAcceptingContext {
|
|||
private $guestsOldWhitelist;
|
||||
|
||||
use CommandLineTrait;
|
||||
use RecordingTrait;
|
||||
|
||||
public static function getTokenForIdentifier(string $identifier) {
|
||||
return self::$identifierToToken[$identifier];
|
||||
|
@ -3223,13 +3224,13 @@ class FeatureContext implements Context, SnippetAcceptingContext {
|
|||
public function userStoreRecordingFileInRoom(string $user, string $file, string $identifier, int $statusCode, string $apiVersion = 'v1'): void {
|
||||
$this->setCurrentUser($user);
|
||||
|
||||
$sipBridgeSharedSecret = 'the secret';
|
||||
$this->setAppConfig('spreed', new TableNode([['sip_bridge_shared_secret', $sipBridgeSharedSecret]]));
|
||||
$recordingServerSharedSecret = 'the secret';
|
||||
$this->setAppConfig('spreed', new TableNode([['recording_servers', json_encode(['secret' => $recordingServerSharedSecret])]]));
|
||||
$validRandom = md5((string) rand());
|
||||
$validChecksum = hash_hmac('sha256', $validRandom . self::$identifierToToken[$identifier], $sipBridgeSharedSecret);
|
||||
$validChecksum = hash_hmac('sha256', $validRandom . self::$identifierToToken[$identifier], $recordingServerSharedSecret);
|
||||
$headers = [
|
||||
'TALK_SIPBRIDGE_RANDOM' => $validRandom,
|
||||
'TALK_SIPBRIDGE_CHECKSUM' => $validChecksum,
|
||||
'TALK_RECORDING_RANDOM' => $validRandom,
|
||||
'TALK_RECORDING_CHECKSUM' => $validChecksum,
|
||||
];
|
||||
$options = [
|
||||
'multipart' => [
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
use Behat\Gherkin\Node\TableNode;
|
||||
use GuzzleHttp\Client;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
// setAppConfig() method is expected to be available in the class that uses this
|
||||
// trait.
|
||||
trait RecordingTrait {
|
||||
/** @var string */
|
||||
private $recordingServerPid = '';
|
||||
|
||||
/** @var string */
|
||||
private $recordingServerAddress = 'localhost:9000';
|
||||
|
||||
/**
|
||||
* @Given /^recording server is started$/
|
||||
*/
|
||||
public function recordingServerIsStarted() {
|
||||
if ($this->recordingServerPid !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// "the secret" is hardcoded in the fake recording server.
|
||||
$this->setAppConfig('spreed', new TableNode([['recording_servers', json_encode(['servers' => [['server' => 'http://127.0.0.1:9000']], 'secret' => 'the secret'])]]));
|
||||
|
||||
$this->recordingServerPid = exec('php -S ' . $this->recordingServerAddress . ' features/bootstrap/FakeRecordingServer.php >/dev/null & echo $!');
|
||||
}
|
||||
|
||||
/**
|
||||
* @AfterScenario
|
||||
*
|
||||
* @When /^recording server is stopped$/
|
||||
*/
|
||||
public function recordingServerIsStopped() {
|
||||
if ($this->recordingServerPid === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get received requests to clear them.
|
||||
$this->getRecordingServerReceivedRequests();
|
||||
|
||||
exec('kill ' . $this->recordingServerPid);
|
||||
|
||||
$this->recordingServerPid = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then /^recording server received the following requests$/
|
||||
*/
|
||||
public function recordingServerReceivedTheFollowingRequests(TableNode $formData = null) {
|
||||
$requests = $this->getRecordingServerReceivedRequests();
|
||||
|
||||
if ($formData === null) {
|
||||
Assert::assertEmpty($requests);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($requests === null) {
|
||||
Assert::fail('No received requests');
|
||||
return;
|
||||
}
|
||||
|
||||
$expected = array_map(static function (array $request) {
|
||||
$request['token'] = FeatureContext::getTokenForIdentifier($request['token']);
|
||||
return $request;
|
||||
}, $formData->getHash());
|
||||
|
||||
$count = count($expected);
|
||||
Assert::assertCount($count, $requests, 'Request count does not match');
|
||||
|
||||
Assert::assertEquals($expected, $requests);
|
||||
}
|
||||
|
||||
private function getRecordingServerReceivedRequests() {
|
||||
$url = 'http://' . $this->recordingServerAddress . '/requests';
|
||||
$client = new Client();
|
||||
$response = $client->get($url);
|
||||
|
||||
return json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
}
|
|
@ -4,15 +4,19 @@ Feature: callapi/recording
|
|||
Given user "participant2" exists
|
||||
|
||||
Scenario: Start and stop video recording
|
||||
When the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
And user "participant1" starts "video" recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
When user "participant1" starts "video" recording in room "room1" with 200 (v1)
|
||||
Then recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"start","start":{"status":1,"owner":"participant1"}} |
|
||||
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
| room | actorType | actorId | actorDisplayName | systemMessage |
|
||||
| room1 | users | participant1 | participant1-displayname | recording_started |
|
||||
| room1 | users | participant1 | participant1-displayname | call_started |
|
||||
|
@ -21,7 +25,10 @@ Feature: callapi/recording
|
|||
| type | name | callRecording |
|
||||
| 2 | room1 | 1 |
|
||||
When user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
Then recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"stop"} |
|
||||
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
| room | actorType | actorId | actorDisplayName | systemMessage |
|
||||
| room1 | users | participant1 | participant1-displayname | recording_stopped |
|
||||
| room1 | users | participant1 | participant1-displayname | recording_started |
|
||||
|
@ -32,15 +39,19 @@ Feature: callapi/recording
|
|||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Start and stop audio recording
|
||||
When the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
And user "participant1" starts "audio" recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
When user "participant1" starts "audio" recording in room "room1" with 200 (v1)
|
||||
Then recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
|
||||
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
| room | actorType | actorId | actorDisplayName | systemMessage |
|
||||
| room1 | users | participant1 | participant1-displayname | audio_recording_started |
|
||||
| room1 | users | participant1 | participant1-displayname | call_started |
|
||||
|
@ -49,7 +60,10 @@ Feature: callapi/recording
|
|||
| type | name | callRecording |
|
||||
| 2 | room1 | 2 |
|
||||
When user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
Then recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"stop"} |
|
||||
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
|
||||
| room | actorType | actorId | actorDisplayName | systemMessage |
|
||||
| room1 | users | participant1 | participant1-displayname | audio_recording_stopped |
|
||||
| room1 | users | participant1 | participant1-displayname | audio_recording_started |
|
||||
|
@ -60,7 +74,8 @@ Feature: callapi/recording
|
|||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Get error when start|stop recording and already did this
|
||||
Given the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
|
@ -68,91 +83,114 @@ Feature: callapi/recording
|
|||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
When user "participant1" starts "audio" recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" starts "audio" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "recording"
|
||||
And recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
|
||||
And user "participant1" starts "audio" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "recording"
|
||||
And recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 2 |
|
||||
When user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
And recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"stop"} |
|
||||
And user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
Then recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
When user "participant1" starts "video" recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" starts "video" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "recording"
|
||||
And recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"start","start":{"status":1,"owner":"participant1"}} |
|
||||
And user "participant1" starts "video" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "recording"
|
||||
And recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 1 |
|
||||
When user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
Then user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
And recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"stop"} |
|
||||
And user "participant1" stops recording in room "room1" with 200 (v1)
|
||||
Then recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Get error when try to start recording with invalid status
|
||||
When the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
Then user "participant1" starts "invalid" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "status"
|
||||
When user "participant1" starts "invalid" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "status"
|
||||
And recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Manager try without success to start recording when signaling is internal
|
||||
When the following "spreed" app config is set
|
||||
Given the following "spreed" app config is set
|
||||
| signaling_dev | no |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
Then user "participant1" starts "video" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "config"
|
||||
When user "participant1" starts "video" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "config"
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
And user "participant1" starts "audio" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "config"
|
||||
When user "participant1" starts "audio" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "config"
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Get error when non moderator/owner try to start recording
|
||||
Given the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
When user "participant1" adds user "participant2" to room "room1" with 200 (v4)
|
||||
And user "participant1" adds user "participant2" to room "room1" with 200 (v4)
|
||||
And user "participant2" joins room "room1" with 200 (v4)
|
||||
And user "participant2" joins call "room1" with 200 (v4)
|
||||
Then user "participant2" starts "video" recording in room "room1" with 403 (v1)
|
||||
When user "participant2" starts "video" recording in room "room1" with 403 (v1)
|
||||
And user "participant2" starts "audio" recording in room "room1" with 403 (v1)
|
||||
Then recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Get error when try to start recording and no call started
|
||||
Given the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
Then user "participant1" starts "video" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "call"
|
||||
When user "participant1" starts "video" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "call"
|
||||
And recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
Then user "participant1" starts "audio" recording in room "room1" with 400 (v1)
|
||||
And the response error matches with "call"
|
||||
When user "participant1" starts "audio" recording in room "room1" with 400 (v1)
|
||||
Then the response error matches with "call"
|
||||
And recording server received the following requests
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
@ -162,8 +200,8 @@ Feature: callapi/recording
|
|||
| roomType | 2 |
|
||||
| roomName | room1 |
|
||||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
Then user "participant1" store recording file "/img/join_call.ogg" in room "room1" with 200 (v1)
|
||||
And user "participant1" has the following notifications
|
||||
When user "participant1" store recording file "/img/join_call.ogg" in room "room1" with 200 (v1)
|
||||
Then user "participant1" has the following notifications
|
||||
| app | object_type | object_id | subject |
|
||||
| spreed | chat | room1 | Recording for the call in room1 was uploaded. |
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
|
@ -171,7 +209,8 @@ Feature: callapi/recording
|
|||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Stop recording automatically when end the call
|
||||
When the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
|
@ -179,16 +218,23 @@ Feature: callapi/recording
|
|||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
And user "participant1" starts "audio" recording in room "room1" with 200 (v1)
|
||||
And recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 2 |
|
||||
Then user "participant1" ends call "room1" with 200 (v4)
|
||||
When user "participant1" ends call "room1" with 200 (v4)
|
||||
Then recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"stop"} |
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
||||
Scenario: Stop recording automatically when the last participant go out
|
||||
When the following "spreed" app config is set
|
||||
Given recording server is started
|
||||
And the following "spreed" app config is set
|
||||
| signaling_dev | yes |
|
||||
And user "participant1" creates room "room1" (v4)
|
||||
| roomType | 2 |
|
||||
|
@ -196,10 +242,16 @@ Feature: callapi/recording
|
|||
And user "participant1" joins room "room1" with 200 (v4)
|
||||
And user "participant1" joins call "room1" with 200 (v4)
|
||||
And user "participant1" starts "audio" recording in room "room1" with 200 (v1)
|
||||
And recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 2 |
|
||||
Then user "participant1" leaves room "room1" with 200 (v4)
|
||||
When user "participant1" leaves room "room1" with 200 (v4)
|
||||
Then recording server received the following requests
|
||||
| token | data |
|
||||
| room1 | {"type":"stop"} |
|
||||
And user "participant1" is participant of the following unordered rooms (v4)
|
||||
| type | name | callRecording |
|
||||
| 2 | room1 | 0 |
|
||||
|
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
/**
|
||||
*
|
||||
* @copyright Copyright (c) 2018 Joachim Bauch <bauch@struktur.de>
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Tests\php\Recording;
|
||||
|
||||
use OCA\Talk\Chat\CommentsManager;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\AttendeeMapper;
|
||||
use OCA\Talk\Model\SessionMapper;
|
||||
use OCA\Talk\Recording\BackendNotifier;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\TalkSession;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Security\IHasher;
|
||||
use OCP\Security\ISecureRandom;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Test\TestCase;
|
||||
|
||||
class CustomBackendNotifier extends BackendNotifier {
|
||||
private array $requests = [];
|
||||
|
||||
public function getRequests(): array {
|
||||
return $this->requests;
|
||||
}
|
||||
|
||||
public function clearRequests() {
|
||||
$this->requests = [];
|
||||
}
|
||||
|
||||
protected function doRequest(string $url, array $params, int $retries = 3): void {
|
||||
$this->requests[] = [
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @group DB
|
||||
*/
|
||||
class BackendNotifierTest extends TestCase {
|
||||
private ?Config $config = null;
|
||||
private ?ISecureRandom $secureRandom = null;
|
||||
/** @var IURLGenerator|MockObject */
|
||||
private $urlGenerator;
|
||||
private ?\OCA\Talk\Tests\php\Recording\CustomBackendNotifier $backendNotifier = null;
|
||||
|
||||
private ?Manager $manager = null;
|
||||
|
||||
private ?string $recordingSecret = null;
|
||||
private ?string $baseUrl = null;
|
||||
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$config = \OC::$server->getConfig();
|
||||
$this->recordingSecret = 'the-recording-secret';
|
||||
$this->baseUrl = 'https://localhost/recording';
|
||||
$config->setAppValue('spreed', 'recording_servers', json_encode([
|
||||
'secret' => $this->recordingSecret,
|
||||
'servers' => [
|
||||
[
|
||||
'server' => $this->baseUrl,
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->secureRandom = \OC::$server->getSecureRandom();
|
||||
$this->urlGenerator = $this->createMock(IURLGenerator::class);
|
||||
|
||||
$groupManager = $this->createMock(IGroupManager::class);
|
||||
$userManager = $this->createMock(IUserManager::class);
|
||||
$timeFactory = $this->createMock(ITimeFactory::class);
|
||||
$dispatcher = \OC::$server->get(IEventDispatcher::class);
|
||||
|
||||
$this->config = new Config($config, $this->secureRandom, $groupManager, $userManager, $this->urlGenerator, $timeFactory, $dispatcher);
|
||||
|
||||
$this->recreateBackendNotifier();
|
||||
|
||||
$dbConnection = \OC::$server->getDatabaseConnection();
|
||||
$this->manager = new Manager(
|
||||
$dbConnection,
|
||||
$config,
|
||||
$this->config,
|
||||
\OC::$server->get(IAppManager::class),
|
||||
\OC::$server->get(AttendeeMapper::class),
|
||||
\OC::$server->get(SessionMapper::class),
|
||||
$this->createMock(ParticipantService::class),
|
||||
$this->secureRandom,
|
||||
$this->createMock(IUserManager::class),
|
||||
$groupManager,
|
||||
$this->createMock(CommentsManager::class),
|
||||
$this->createMock(TalkSession::class),
|
||||
$dispatcher,
|
||||
$timeFactory,
|
||||
$this->createMock(IHasher::class),
|
||||
$this->createMock(IL10N::class)
|
||||
);
|
||||
}
|
||||
|
||||
public function tearDown(): void {
|
||||
$config = \OC::$server->getConfig();
|
||||
$config->deleteAppValue('spreed', 'recording_servers');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function recreateBackendNotifier() {
|
||||
$this->backendNotifier = new CustomBackendNotifier(
|
||||
$this->config,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$this->createMock(IClientService::class),
|
||||
$this->secureRandom,
|
||||
$this->urlGenerator,
|
||||
);
|
||||
}
|
||||
|
||||
private function calculateBackendChecksum($data, $random) {
|
||||
if (empty($random) || strlen($random) < 32) {
|
||||
return false;
|
||||
}
|
||||
return hash_hmac('sha256', $random . $data, $this->recordingSecret);
|
||||
}
|
||||
|
||||
private function validateBackendRequest($expectedUrl, $request) {
|
||||
$this->assertTrue(isset($request));
|
||||
$this->assertEquals($expectedUrl, $request['url']);
|
||||
$headers = $request['params']['headers'];
|
||||
$this->assertEquals('application/json', $headers['Content-Type']);
|
||||
$random = $headers['Talk-Recording-Random'];
|
||||
$checksum = $headers['Talk-Recording-Checksum'];
|
||||
$body = $request['params']['body'];
|
||||
$this->assertEquals($this->calculateBackendChecksum($body, $random), $checksum);
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function assertMessageWasSent(Room $room, array $message): void {
|
||||
$expectedUrl = $this->baseUrl . '/api/v1/room/' . $room->getToken();
|
||||
|
||||
$requests = $this->backendNotifier->getRequests();
|
||||
$requests = array_filter($requests, function ($request) use ($expectedUrl) {
|
||||
return $request['url'] === $expectedUrl;
|
||||
});
|
||||
$bodies = array_map(function ($request) use ($expectedUrl) {
|
||||
return json_decode($this->validateBackendRequest($expectedUrl, $request), true);
|
||||
}, $requests);
|
||||
|
||||
$bodies = array_filter($bodies, function (array $body) use ($message) {
|
||||
return $body['type'] === $message['type'];
|
||||
});
|
||||
|
||||
$this->assertContainsEquals($message, $bodies, json_encode($bodies, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
public function testStart() {
|
||||
$room = $this->manager->createRoom(Room::TYPE_PUBLIC);
|
||||
|
||||
$this->backendNotifier->start($room, Room::RECORDING_VIDEO, 'participant1');
|
||||
|
||||
$this->assertMessageWasSent($room, [
|
||||
'type' => 'start',
|
||||
'start' => [
|
||||
'status' => Room::RECORDING_VIDEO,
|
||||
'owner' => 'participant1',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testStop() {
|
||||
$room = $this->manager->createRoom(Room::TYPE_PUBLIC);
|
||||
|
||||
$this->backendNotifier->stop($room);
|
||||
|
||||
$this->assertMessageWasSent($room, [
|
||||
'type' => 'stop',
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -38,6 +38,7 @@ namespace OCA\Talk\Tests\php\Service;
|
|||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Recording\BackendNotifier;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RecordingService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
|
@ -73,6 +74,8 @@ class RecordingServiceTest extends TestCase {
|
|||
private $chatManager;
|
||||
/** @var LoggerInterface|MockObject */
|
||||
private $logger;
|
||||
/** @var BackendNotifier|MockObject */
|
||||
private $backendNotifier;
|
||||
/** @var RecordingService */
|
||||
protected $recordingService;
|
||||
|
||||
|
@ -90,6 +93,7 @@ class RecordingServiceTest extends TestCase {
|
|||
$this->shareManager = $this->createMock(ShareManager::class);
|
||||
$this->chatManager = $this->createMock(ChatManager::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->backendNotifier = $this->createMock(BackendNotifier::class);
|
||||
|
||||
$this->recordingService = new RecordingService(
|
||||
$this->mimeTypeDetector,
|
||||
|
@ -103,6 +107,7 @@ class RecordingServiceTest extends TestCase {
|
|||
$this->shareManager,
|
||||
$this->chatManager,
|
||||
$this->logger,
|
||||
$this->backendNotifier,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -190,7 +190,7 @@ class BackendNotifierTest extends TestCase {
|
|||
$dbConnection,
|
||||
$this->timeFactory,
|
||||
$this->createMock(IManager::class),
|
||||
$this->createMock(Config::class),
|
||||
$this->config,
|
||||
$this->createMock(IHasher::class),
|
||||
$this->dispatcher,
|
||||
$this->jobList
|
||||
|
@ -1469,4 +1469,21 @@ class BackendNotifierTest extends TestCase {
|
|||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRecordingStatusChanged() {
|
||||
$room = $this->manager->createRoom(Room::TYPE_PUBLIC);
|
||||
$this->roomService->setCallRecording($room, Room::RECORDING_VIDEO);
|
||||
|
||||
$this->assertMessageWasSent($room, [
|
||||
'type' => 'message',
|
||||
'message' => [
|
||||
'data' => [
|
||||
'type' => 'recording',
|
||||
'recording' => [
|
||||
'status' => Room::RECORDING_VIDEO,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace GuzzleHttp\Exception;
|
||||
|
||||
class ConnectException extends \RuntimeException {
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace GuzzleHttp\Exception;
|
||||
|
||||
class ServerException extends \RuntimeException {
|
||||
}
|
Загрузка…
Ссылка в новой задаче