You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

456 lines
15 KiB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Talk\Controller;
  8. use GuzzleHttp\Exception\ConnectException;
  9. use InvalidArgumentException;
  10. use OCA\Talk\Config;
  11. use OCA\Talk\Exceptions\ParticipantNotFoundException;
  12. use OCA\Talk\Exceptions\RoomNotFoundException;
  13. use OCA\Talk\Manager;
  14. use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
  15. use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
  16. use OCA\Talk\Middleware\Attribute\RequireRoom;
  17. use OCA\Talk\Room;
  18. use OCA\Talk\Service\CertificateService;
  19. use OCA\Talk\Service\ParticipantService;
  20. use OCA\Talk\Service\RecordingService;
  21. use OCA\Talk\Service\RoomService;
  22. use OCP\AppFramework\Http;
  23. use OCP\AppFramework\Http\Attribute\BruteForceProtection;
  24. use OCP\AppFramework\Http\Attribute\NoAdminRequired;
  25. use OCP\AppFramework\Http\Attribute\OpenAPI;
  26. use OCP\AppFramework\Http\Attribute\PublicPage;
  27. use OCP\AppFramework\Http\DataResponse;
  28. use OCP\Http\Client\IClientService;
  29. use OCP\IRequest;
  30. use Psr\Log\LoggerInterface;
  31. class RecordingController extends AEnvironmentAwareController {
  32. public function __construct(
  33. string $appName,
  34. IRequest $request,
  35. private ?string $userId,
  36. private Config $talkConfig,
  37. private IClientService $clientService,
  38. private Manager $manager,
  39. private CertificateService $certificateService,
  40. private ParticipantService $participantService,
  41. private RecordingService $recordingService,
  42. private RoomService $roomService,
  43. private LoggerInterface $logger,
  44. ) {
  45. parent::__construct($appName, $request);
  46. }
  47. /**
  48. * Get the welcome message of a recording server
  49. *
  50. * @param int $serverId ID of the server
  51. * @psalm-param non-negative-int $serverId
  52. * @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{}>
  53. *
  54. * 200: Welcome message returned
  55. * 404: Recording server not found or not configured
  56. */
  57. #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
  58. public function getWelcomeMessage(int $serverId): DataResponse {
  59. $recordingServers = $this->talkConfig->getRecordingServers();
  60. if (empty($recordingServers) || !isset($recordingServers[$serverId])) {
  61. return new DataResponse([], Http::STATUS_NOT_FOUND);
  62. }
  63. $url = rtrim($recordingServers[$serverId]['server'], '/');
  64. $url = strtolower($url);
  65. $verifyServer = (bool) $recordingServers[$serverId]['verify'];
  66. if ($verifyServer && str_contains($url, 'https://')) {
  67. $expiration = $this->certificateService->getCertificateExpirationInDays($url);
  68. if ($expiration < 0) {
  69. return new DataResponse(['error' => 'CERTIFICATE_EXPIRED'], Http::STATUS_INTERNAL_SERVER_ERROR);
  70. }
  71. }
  72. $client = $this->clientService->newClient();
  73. try {
  74. $response = $client->get($url . '/api/v1/welcome', [
  75. 'verify' => $verifyServer,
  76. 'nextcloud' => [
  77. 'allow_local_address' => true,
  78. ],
  79. ]);
  80. if ($response->getHeader(\OCA\Talk\Signaling\Manager::FEATURE_HEADER)) {
  81. return new DataResponse([
  82. 'error' => 'IS_SIGNALING_SERVER',
  83. ], Http::STATUS_INTERNAL_SERVER_ERROR);
  84. }
  85. $body = $response->getBody();
  86. $data = json_decode($body, true);
  87. if (!is_array($data)) {
  88. return new DataResponse([
  89. 'error' => 'JSON_INVALID',
  90. ], Http::STATUS_INTERNAL_SERVER_ERROR);
  91. }
  92. return new DataResponse($data);
  93. } catch (ConnectException $e) {
  94. return new DataResponse(['error' => 'CAN_NOT_CONNECT'], Http::STATUS_INTERNAL_SERVER_ERROR);
  95. } catch (\Exception $e) {
  96. return new DataResponse(['error' => (string)$e->getCode()], Http::STATUS_INTERNAL_SERVER_ERROR);
  97. }
  98. }
  99. /**
  100. * Check if the current request is coming from an allowed backend.
  101. *
  102. * The backends are sending the custom header "Talk-Recording-Random"
  103. * containing at least 32 bytes random data, and the header
  104. * "Talk-Recording-Checksum", which is the SHA256-HMAC of the random data
  105. * and the body of the request, calculated with the shared secret from the
  106. * configuration.
  107. *
  108. * @param string $data
  109. * @return bool
  110. */
  111. private function validateBackendRequest(string $data): bool {
  112. $random = $this->request->getHeader('Talk-Recording-Random');
  113. if (empty($random) || strlen($random) < 32) {
  114. $this->logger->debug('Missing random');
  115. return false;
  116. }
  117. $checksum = $this->request->getHeader('Talk-Recording-Checksum');
  118. if (empty($checksum)) {
  119. $this->logger->debug('Missing checksum');
  120. return false;
  121. }
  122. $hash = hash_hmac('sha256', $random . $data, $this->talkConfig->getRecordingSecret());
  123. return hash_equals($hash, strtolower($checksum));
  124. }
  125. /**
  126. * Return the body of the backend request. This can be overridden in
  127. * tests.
  128. *
  129. * @return string
  130. */
  131. protected function getInputStream(): string {
  132. return file_get_contents('php://input');
  133. }
  134. /**
  135. * Update the recording status as a backend
  136. *
  137. * @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{}>
  138. *
  139. * 200: Recording status updated successfully
  140. * 400: Updating recording status is not possible
  141. * 403: Missing permissions to update recording status
  142. * 404: Room not found
  143. */
  144. #[OpenAPI(scope: 'backend-recording')]
  145. #[PublicPage]
  146. #[BruteForceProtection(action: 'talkRecordingSecret')]
  147. #[BruteForceProtection(action: 'talkRecordingStatus')]
  148. public function backend(): DataResponse {
  149. $json = $this->getInputStream();
  150. if (!$this->validateBackendRequest($json)) {
  151. $response = new DataResponse([
  152. 'type' => 'error',
  153. 'error' => [
  154. 'code' => 'invalid_request',
  155. 'message' => 'The request could not be authenticated.',
  156. ],
  157. ], Http::STATUS_FORBIDDEN);
  158. $response->throttle(['action' => 'talkRecordingSecret']);
  159. return $response;
  160. }
  161. $message = json_decode($json, true);
  162. switch ($message['type'] ?? '') {
  163. case 'started':
  164. return $this->backendStarted($message['started']);
  165. case 'stopped':
  166. return $this->backendStopped($message['stopped']);
  167. case 'failed':
  168. return $this->backendFailed($message['failed']);
  169. default:
  170. return new DataResponse([
  171. 'type' => 'error',
  172. 'error' => [
  173. 'code' => 'unknown_type',
  174. 'message' => 'The given type ' . json_encode($message) . ' is not supported.',
  175. ],
  176. ], Http::STATUS_BAD_REQUEST);
  177. }
  178. }
  179. /**
  180. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
  181. */
  182. private function backendStarted(array $started): DataResponse {
  183. $token = $started['token'];
  184. $status = $started['status'];
  185. $actor = $started['actor'];
  186. try {
  187. $room = $this->manager->getRoomByToken($token);
  188. } catch (RoomNotFoundException $e) {
  189. $this->logger->debug('Failed to get room {token}', [
  190. 'token' => $token,
  191. 'app' => 'spreed-recording',
  192. ]);
  193. return new DataResponse([
  194. 'type' => 'error',
  195. 'error' => [
  196. 'code' => 'no_such_room',
  197. 'message' => 'Room not found.',
  198. ],
  199. ], Http::STATUS_NOT_FOUND);
  200. }
  201. if ($room->getCallRecording() !== Room::RECORDING_VIDEO_STARTING && $room->getCallRecording() !== Room::RECORDING_AUDIO_STARTING) {
  202. $this->logger->error('Recording backend tried to start recording in room {token}, but it was not requested by a moderator.', [
  203. 'token' => $token,
  204. 'app' => 'spreed-recording',
  205. ]);
  206. $response = new DataResponse([
  207. 'type' => 'error',
  208. 'error' => [
  209. 'code' => 'no_such_room',
  210. 'message' => 'Room not found.',
  211. ],
  212. ], Http::STATUS_NOT_FOUND);
  213. $response->throttle(['action' => 'talkRecordingStatus']);
  214. return $response;
  215. }
  216. try {
  217. $participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
  218. } catch (ParticipantNotFoundException $e) {
  219. $participant = null;
  220. }
  221. $this->roomService->setCallRecording($room, $status, $participant);
  222. return new DataResponse();
  223. }
  224. /**
  225. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
  226. */
  227. private function backendStopped(array $stopped): DataResponse {
  228. $token = $stopped['token'];
  229. $actor = null;
  230. if (array_key_exists('actor', $stopped)) {
  231. $actor = $stopped['actor'];
  232. }
  233. try {
  234. $room = $this->manager->getRoomByToken($token);
  235. } catch (RoomNotFoundException $e) {
  236. $this->logger->debug('Failed to get room {token}', [
  237. 'token' => $token,
  238. 'app' => 'spreed-recording',
  239. ]);
  240. return new DataResponse([
  241. 'type' => 'error',
  242. 'error' => [
  243. 'code' => 'no_such_room',
  244. 'message' => 'Room not found.',
  245. ],
  246. ], Http::STATUS_NOT_FOUND);
  247. }
  248. try {
  249. if ($actor === null) {
  250. throw new ParticipantNotFoundException();
  251. }
  252. $participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
  253. } catch (ParticipantNotFoundException $e) {
  254. $participant = null;
  255. }
  256. $this->roomService->setCallRecording($room, Room::RECORDING_NONE, $participant);
  257. return new DataResponse();
  258. }
  259. /**
  260. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
  261. */
  262. private function backendFailed(array $failed): DataResponse {
  263. $token = $failed['token'];
  264. try {
  265. $room = $this->manager->getRoomByToken($token);
  266. } catch (RoomNotFoundException $e) {
  267. $this->logger->debug('Failed to get room {token}', [
  268. 'token' => $token,
  269. 'app' => 'spreed-recording',
  270. ]);
  271. return new DataResponse([
  272. 'type' => 'error',
  273. 'error' => [
  274. 'code' => 'no_such_room',
  275. 'message' => 'Room not found.',
  276. ],
  277. ], Http::STATUS_NOT_FOUND);
  278. }
  279. $this->roomService->setCallRecording($room, Room::RECORDING_FAILED);
  280. return new DataResponse();
  281. }
  282. /**
  283. * Start the recording
  284. *
  285. * @param int $status Type of the recording
  286. * @psalm-param Room::RECORDING_* $status
  287. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
  288. *
  289. * 200: Recording started successfully
  290. * 400: Starting recording is not possible
  291. */
  292. #[NoAdminRequired]
  293. #[RequireLoggedInModeratorParticipant]
  294. public function start(int $status): DataResponse {
  295. try {
  296. $this->recordingService->start($this->room, $status, $this->userId, $this->participant);
  297. } catch (InvalidArgumentException $e) {
  298. return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
  299. }
  300. return new DataResponse();
  301. }
  302. /**
  303. * Stop the recording
  304. *
  305. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
  306. *
  307. * 200: Recording stopped successfully
  308. * 400: Stopping recording is not possible
  309. */
  310. #[NoAdminRequired]
  311. #[RequireLoggedInModeratorParticipant]
  312. public function stop(): DataResponse {
  313. try {
  314. $this->recordingService->stop($this->room, $this->participant);
  315. } catch (InvalidArgumentException $e) {
  316. return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
  317. }
  318. return new DataResponse();
  319. }
  320. /**
  321. * Store the recording
  322. *
  323. * @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".
  324. * @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{}>
  325. *
  326. * 200: Recording stored successfully
  327. * 400: Storing recording is not possible
  328. * 401: Missing permissions to store recording
  329. */
  330. #[PublicPage]
  331. #[BruteForceProtection(action: 'talkRecordingSecret')]
  332. #[OpenAPI(scope: 'backend-recording')]
  333. #[RequireRoom]
  334. public function store(?string $owner): DataResponse {
  335. $data = $this->room->getToken();
  336. if (!$this->validateBackendRequest($data)) {
  337. $response = new DataResponse([
  338. 'type' => 'error',
  339. 'error' => [
  340. 'code' => 'invalid_request',
  341. 'message' => 'The request could not be authenticated.',
  342. ],
  343. ], Http::STATUS_UNAUTHORIZED);
  344. $response->throttle(['action' => 'talkRecordingSecret']);
  345. return $response;
  346. }
  347. if ($owner === null) {
  348. $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.');
  349. try {
  350. $this->recordingService->notifyAboutFailedStore($this->room);
  351. } catch (InvalidArgumentException) {
  352. // Ignoring, we logged an error already
  353. }
  354. return new DataResponse(['error' => 'size'], Http::STATUS_BAD_REQUEST);
  355. }
  356. try {
  357. $file = $this->request->getUploadedFile('file');
  358. $this->recordingService->store($this->getRoom(), $owner, $file);
  359. } catch (InvalidArgumentException $e) {
  360. return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
  361. }
  362. return new DataResponse();
  363. }
  364. /**
  365. * Dismiss the store call recording notification
  366. *
  367. * @param int $timestamp Timestamp of the notification to be dismissed
  368. * @psalm-param non-negative-int $timestamp
  369. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
  370. *
  371. * 200: Notification dismissed successfully
  372. * 400: Dismissing notification is not possible
  373. */
  374. #[NoAdminRequired]
  375. #[RequireModeratorParticipant]
  376. public function notificationDismiss(int $timestamp): DataResponse {
  377. try {
  378. $this->recordingService->notificationDismiss(
  379. $this->getRoom(),
  380. $this->participant,
  381. $timestamp
  382. );
  383. } catch (InvalidArgumentException $e) {
  384. return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
  385. }
  386. return new DataResponse();
  387. }
  388. /**
  389. * Share the recorded file to the chat
  390. *
  391. * @param int $fileId ID of the file
  392. * @psalm-param non-negative-int $fileId
  393. * @param int $timestamp Timestamp of the notification to be dismissed
  394. * @psalm-param non-negative-int $timestamp
  395. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
  396. *
  397. * 200: Recording shared to chat successfully
  398. * 400: Sharing recording to chat is not possible
  399. */
  400. #[NoAdminRequired]
  401. #[RequireModeratorParticipant]
  402. public function shareToChat(int $fileId, int $timestamp): DataResponse {
  403. try {
  404. $this->recordingService->shareToChat(
  405. $this->getRoom(),
  406. $this->participant,
  407. $fileId,
  408. $timestamp
  409. );
  410. } catch (InvalidArgumentException $e) {
  411. return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
  412. }
  413. return new DataResponse();
  414. }
  415. }