mirror of
https://github.com/nextcloud/spreed.git
synced 2025-08-20 13:15:42 +00:00
440 lines
14 KiB
PHP
440 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/**
|
|
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace OCA\Talk\Controller;
|
|
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use InvalidArgumentException;
|
|
use OCA\Talk\Config;
|
|
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
|
use OCA\Talk\Exceptions\RoomNotFoundException;
|
|
use OCA\Talk\Manager;
|
|
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
|
|
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
|
|
use OCA\Talk\Middleware\Attribute\RequireRoom;
|
|
use OCA\Talk\Room;
|
|
use OCA\Talk\Service\CertificateService;
|
|
use OCA\Talk\Service\ParticipantService;
|
|
use OCA\Talk\Service\RecordingService;
|
|
use OCA\Talk\Service\RoomService;
|
|
use OCP\AppFramework\Http;
|
|
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
|
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
|
use OCP\AppFramework\Http\Attribute\PublicPage;
|
|
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 IClientService $clientService,
|
|
private Manager $manager,
|
|
private CertificateService $certificateService,
|
|
private ParticipantService $participantService,
|
|
private RecordingService $recordingService,
|
|
private RoomService $roomService,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
parent::__construct($appName, $request);
|
|
}
|
|
|
|
/**
|
|
* Get the welcome message of a recording server
|
|
*
|
|
* @param int $serverId ID of the server
|
|
* @psalm-param non-negative-int $serverId
|
|
* @return DataResponse<Http::STATUS_OK, array{version: float}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
|
|
*
|
|
* 200: Welcome message returned
|
|
* 404: Recording server not found or not configured
|
|
*/
|
|
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
|
|
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'], '/');
|
|
$url = strtolower($url);
|
|
|
|
$verifyServer = (bool) $recordingServers[$serverId]['verify'];
|
|
|
|
if ($verifyServer && str_contains($url, 'https://')) {
|
|
$expiration = $this->certificateService->getCertificateExpirationInDays($url);
|
|
|
|
if ($expiration < 0) {
|
|
return new DataResponse(['error' => 'CERTIFICATE_EXPIRED'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
$client = $this->clientService->newClient();
|
|
try {
|
|
$response = $client->get($url . '/api/v1/welcome', [
|
|
'verify' => $verifyServer,
|
|
'nextcloud' => [
|
|
'allow_local_address' => true,
|
|
],
|
|
]);
|
|
|
|
if ($response->getHeader(\OCA\Talk\Signaling\Manager::FEATURE_HEADER)) {
|
|
return new DataResponse([
|
|
'error' => 'IS_SIGNALING_SERVER',
|
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
$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' => (string)$e->getCode()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Return the body of the backend request. This can be overridden in
|
|
* tests.
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function getInputStream(): string {
|
|
return file_get_contents('php://input');
|
|
}
|
|
|
|
/**
|
|
* Update the recording status as a backend
|
|
*
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
|
|
*
|
|
* 200: Recording status updated successfully
|
|
* 400: Updating recording status is not possible
|
|
* 403: Missing permissions to update recording status
|
|
* 404: Room not found
|
|
*/
|
|
#[OpenAPI(scope: 'backend-recording')]
|
|
#[PublicPage]
|
|
#[BruteForceProtection(action: 'talkRecordingSecret')]
|
|
public function backend(): DataResponse {
|
|
$json = $this->getInputStream();
|
|
if (!$this->validateBackendRequest($json)) {
|
|
$response = new DataResponse([
|
|
'type' => 'error',
|
|
'error' => [
|
|
'code' => 'invalid_request',
|
|
'message' => 'The request could not be authenticated.',
|
|
],
|
|
], Http::STATUS_FORBIDDEN);
|
|
$response->throttle(['action' => 'talkRecordingSecret']);
|
|
return $response;
|
|
}
|
|
|
|
$message = json_decode($json, true);
|
|
switch ($message['type'] ?? '') {
|
|
case 'started':
|
|
return $this->backendStarted($message['started']);
|
|
case 'stopped':
|
|
return $this->backendStopped($message['stopped']);
|
|
case 'failed':
|
|
return $this->backendFailed($message['failed']);
|
|
default:
|
|
return new DataResponse([
|
|
'type' => 'error',
|
|
'error' => [
|
|
'code' => 'unknown_type',
|
|
'message' => 'The given type ' . json_encode($message) . ' is not supported.',
|
|
],
|
|
], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
|
|
*/
|
|
private function backendStarted(array $started): DataResponse {
|
|
$token = $started['token'];
|
|
$status = $started['status'];
|
|
$actor = $started['actor'];
|
|
|
|
try {
|
|
$room = $this->manager->getRoomByToken($token);
|
|
} catch (RoomNotFoundException $e) {
|
|
$this->logger->debug('Failed to get room {token}', [
|
|
'token' => $token,
|
|
'app' => 'spreed-recording',
|
|
]);
|
|
return new DataResponse([
|
|
'type' => 'error',
|
|
'error' => [
|
|
'code' => 'no_such_room',
|
|
'message' => 'Room not found.',
|
|
],
|
|
], Http::STATUS_NOT_FOUND);
|
|
}
|
|
|
|
try {
|
|
$participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
|
|
} catch (ParticipantNotFoundException $e) {
|
|
$participant = null;
|
|
}
|
|
|
|
$this->roomService->setCallRecording($room, $status, $participant);
|
|
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
|
|
*/
|
|
private function backendStopped(array $stopped): DataResponse {
|
|
$token = $stopped['token'];
|
|
$actor = null;
|
|
if (array_key_exists('actor', $stopped)) {
|
|
$actor = $stopped['actor'];
|
|
}
|
|
|
|
try {
|
|
$room = $this->manager->getRoomByToken($token);
|
|
} catch (RoomNotFoundException $e) {
|
|
$this->logger->debug('Failed to get room {token}', [
|
|
'token' => $token,
|
|
'app' => 'spreed-recording',
|
|
]);
|
|
return new DataResponse([
|
|
'type' => 'error',
|
|
'error' => [
|
|
'code' => 'no_such_room',
|
|
'message' => 'Room not found.',
|
|
],
|
|
], Http::STATUS_NOT_FOUND);
|
|
}
|
|
|
|
try {
|
|
if ($actor === null) {
|
|
throw new ParticipantNotFoundException();
|
|
}
|
|
|
|
$participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
|
|
} catch (ParticipantNotFoundException $e) {
|
|
$participant = null;
|
|
}
|
|
|
|
$this->roomService->setCallRecording($room, Room::RECORDING_NONE, $participant);
|
|
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
|
|
*/
|
|
private function backendFailed(array $failed): DataResponse {
|
|
$token = $failed['token'];
|
|
|
|
try {
|
|
$room = $this->manager->getRoomByToken($token);
|
|
} catch (RoomNotFoundException $e) {
|
|
$this->logger->debug('Failed to get room {token}', [
|
|
'token' => $token,
|
|
'app' => 'spreed-recording',
|
|
]);
|
|
return new DataResponse([
|
|
'type' => 'error',
|
|
'error' => [
|
|
'code' => 'no_such_room',
|
|
'message' => 'Room not found.',
|
|
],
|
|
], Http::STATUS_NOT_FOUND);
|
|
}
|
|
|
|
$this->roomService->setCallRecording($room, Room::RECORDING_FAILED);
|
|
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* Start the recording
|
|
*
|
|
* @param int $status Type of the recording
|
|
* @psalm-param Room::RECORDING_* $status
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
|
*
|
|
* 200: Recording started successfully
|
|
* 400: Starting recording is not possible
|
|
*/
|
|
#[NoAdminRequired]
|
|
#[RequireLoggedInModeratorParticipant]
|
|
public function start(int $status): DataResponse {
|
|
try {
|
|
$this->recordingService->start($this->room, $status, $this->userId, $this->participant);
|
|
} catch (InvalidArgumentException $e) {
|
|
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* Stop the recording
|
|
*
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
|
*
|
|
* 200: Recording stopped successfully
|
|
* 400: Stopping recording is not possible
|
|
*/
|
|
#[NoAdminRequired]
|
|
#[RequireLoggedInModeratorParticipant]
|
|
public function stop(): DataResponse {
|
|
try {
|
|
$this->recordingService->stop($this->room, $this->participant);
|
|
} catch (InvalidArgumentException $e) {
|
|
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* Store the recording
|
|
*
|
|
* @param ?string $owner User that will own the recording file. `null` is actually not allowed and will always result in a "400 Bad Request". It's only allowed code-wise to handle requests where the post data exceeded the limits, so we can return a proper error instead of "500 Internal Server Error".
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{type: string, error: array{code: string, message: string}}, array{}>
|
|
*
|
|
* 200: Recording stored successfully
|
|
* 400: Storing recording is not possible
|
|
* 401: Missing permissions to store recording
|
|
*/
|
|
#[PublicPage]
|
|
#[BruteForceProtection(action: 'talkRecordingSecret')]
|
|
#[OpenAPI(scope: 'backend-recording')]
|
|
#[RequireRoom]
|
|
public function store(?string $owner): DataResponse {
|
|
$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(['action' => 'talkRecordingSecret']);
|
|
return $response;
|
|
}
|
|
|
|
if ($owner === null) {
|
|
$this->logger->error('Recording backend failed to provide the owner when uploading a recording [ conversation: "' . $this->room->getToken() . '" ]. Most likely the post_max_size or upload_max_filesize were exceeded.');
|
|
try {
|
|
$this->recordingService->notifyAboutFailedStore($this->room);
|
|
} catch (InvalidArgumentException) {
|
|
// Ignoring, we logged an error already
|
|
}
|
|
return new DataResponse(['error' => 'size'], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
|
|
try {
|
|
$file = $this->request->getUploadedFile('file');
|
|
$this->recordingService->store($this->getRoom(), $owner, $file);
|
|
} catch (InvalidArgumentException $e) {
|
|
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* Dismiss the store call recording notification
|
|
*
|
|
* @param int $timestamp Timestamp of the notification to be dismissed
|
|
* @psalm-param non-negative-int $timestamp
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
|
*
|
|
* 200: Notification dismissed successfully
|
|
* 400: Dismissing notification is not possible
|
|
*/
|
|
#[NoAdminRequired]
|
|
#[RequireModeratorParticipant]
|
|
public function notificationDismiss(int $timestamp): DataResponse {
|
|
try {
|
|
$this->recordingService->notificationDismiss(
|
|
$this->getRoom(),
|
|
$this->participant,
|
|
$timestamp
|
|
);
|
|
} catch (InvalidArgumentException $e) {
|
|
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
return new DataResponse();
|
|
}
|
|
|
|
/**
|
|
* Share the recorded file to the chat
|
|
*
|
|
* @param int $fileId ID of the file
|
|
* @psalm-param non-negative-int $fileId
|
|
* @param int $timestamp Timestamp of the notification to be dismissed
|
|
* @psalm-param non-negative-int $timestamp
|
|
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
|
*
|
|
* 200: Recording shared to chat successfully
|
|
* 400: Sharing recording to chat is not possible
|
|
*/
|
|
#[NoAdminRequired]
|
|
#[RequireModeratorParticipant]
|
|
public function shareToChat(int $fileId, int $timestamp): DataResponse {
|
|
try {
|
|
$this->recordingService->shareToChat(
|
|
$this->getRoom(),
|
|
$this->participant,
|
|
$fileId,
|
|
$timestamp
|
|
);
|
|
} catch (InvalidArgumentException $e) {
|
|
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
return new DataResponse();
|
|
}
|
|
}
|