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.

833 lines
27 KiB

9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
Fix slower active users not included in user list returned by signaling When a user requests the signaling messages from the internal signaling server first the last ping of the user is updated. Then, after waiting for at most 30 seconds, the list of users active in the room is returned. That list was based on the users whose last ping happened around 30 seconds ago or less (it could be a bit longer than 30 seconds, but the described problem remains in that case too); if other user pulled the messages slightly before the current user and that other user did not pull the messages again (or the chat messages, as that updates the last ping too) before the user list was returned that other user was not included in the list, as her last ping happened more than 30 seconds ago. Now the elapsed time since the last ping for users returned in the list is longer than the timeout used for pulling messages (and chat messages) to ensure (up to a point) that active users will be included in the list even if it took a bit longer for them to pull messages again. The drawback of this approach is that the internal signaling server will now need a few more seconds to notice when a user left a call abruptly, but before it was not immediate anyway and it should not be a common scenario either. Finally, note that it is unlikely that more than 40 seconds pass between the ping is updated for the current user and the user list is returned, but the condition to handle that case gracefully was kept to be on the safe side. Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
7 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
Fix slower active users not included in user list returned by signaling When a user requests the signaling messages from the internal signaling server first the last ping of the user is updated. Then, after waiting for at most 30 seconds, the list of users active in the room is returned. That list was based on the users whose last ping happened around 30 seconds ago or less (it could be a bit longer than 30 seconds, but the described problem remains in that case too); if other user pulled the messages slightly before the current user and that other user did not pull the messages again (or the chat messages, as that updates the last ping too) before the user list was returned that other user was not included in the list, as her last ping happened more than 30 seconds ago. Now the elapsed time since the last ping for users returned in the list is longer than the timeout used for pulling messages (and chat messages) to ensure (up to a point) that active users will be included in the list even if it took a bit longer for them to pull messages again. The drawback of this approach is that the internal signaling server will now need a few more seconds to notice when a user left a call abruptly, but before it was not immediate anyway and it should not be a common scenario either. Finally, note that it is unlikely that more than 40 seconds pass between the ping is updated for the current user and the user list is returned, but the condition to handle that case gracefully was kept to be on the safe side. Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
7 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
Fix slower active users not included in user list returned by signaling When a user requests the signaling messages from the internal signaling server first the last ping of the user is updated. Then, after waiting for at most 30 seconds, the list of users active in the room is returned. That list was based on the users whose last ping happened around 30 seconds ago or less (it could be a bit longer than 30 seconds, but the described problem remains in that case too); if other user pulled the messages slightly before the current user and that other user did not pull the messages again (or the chat messages, as that updates the last ping too) before the user list was returned that other user was not included in the list, as her last ping happened more than 30 seconds ago. Now the elapsed time since the last ping for users returned in the list is longer than the timeout used for pulling messages (and chat messages) to ensure (up to a point) that active users will be included in the list even if it took a bit longer for them to pull messages again. The drawback of this approach is that the internal signaling server will now need a few more seconds to notice when a user left a call abruptly, but before it was not immediate anyway and it should not be a common scenario either. Finally, note that it is unlikely that more than 40 seconds pass between the ping is updated for the current user and the user list is returned, but the condition to handle that case gracefully was kept to be on the safe side. Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
7 years ago
9 years ago
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
  5. *
  6. * @author Lukas Reschke <lukas@statuscode.ch>
  7. * @author Kate Döen <kate.doeen@nextcloud.com>
  8. *
  9. * @license GNU AGPL version 3 or any later version
  10. *
  11. * This program is free software: you can redistribute it and/or modify
  12. * it under the terms of the GNU Affero General Public License as
  13. * published by the Free Software Foundation, either version 3 of the
  14. * License, or (at your option) any later version.
  15. *
  16. * This program is distributed in the hope that it will be useful,
  17. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. * GNU Affero General Public License for more details.
  20. *
  21. * You should have received a copy of the GNU Affero General Public License
  22. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  23. *
  24. */
  25. namespace OCA\Talk\Controller;
  26. use GuzzleHttp\Exception\ConnectException;
  27. use OCA\Talk\Config;
  28. use OCA\Talk\Events\SignalingEvent;
  29. use OCA\Talk\Exceptions\ParticipantNotFoundException;
  30. use OCA\Talk\Exceptions\RoomNotFoundException;
  31. use OCA\Talk\Manager;
  32. use OCA\Talk\Model\Attendee;
  33. use OCA\Talk\Model\Session;
  34. use OCA\Talk\Participant;
  35. use OCA\Talk\ResponseDefinitions;
  36. use OCA\Talk\Room;
  37. use OCA\Talk\Service\CertificateService;
  38. use OCA\Talk\Service\ParticipantService;
  39. use OCA\Talk\Service\SessionService;
  40. use OCA\Talk\Signaling\Messages;
  41. use OCA\Talk\TalkSession;
  42. use OCP\AppFramework\Http;
  43. use OCP\AppFramework\Http\Attribute\BruteForceProtection;
  44. use OCP\AppFramework\Http\Attribute\IgnoreOpenAPI;
  45. use OCP\AppFramework\Http\Attribute\PublicPage;
  46. use OCP\AppFramework\Http\DataResponse;
  47. use OCP\AppFramework\OCSController;
  48. use OCP\AppFramework\Utility\ITimeFactory;
  49. use OCP\DB\Exception;
  50. use OCP\EventDispatcher\IEventDispatcher;
  51. use OCP\Http\Client\IClientService;
  52. use OCP\IConfig;
  53. use OCP\IDBConnection;
  54. use OCP\IRequest;
  55. use OCP\IUser;
  56. use OCP\IUserManager;
  57. use OCP\Security\Bruteforce\IThrottler;
  58. use Psr\Log\LoggerInterface;
  59. /**
  60. * @psalm-import-type TalkSignalingSession from ResponseDefinitions
  61. * @psalm-import-type TalkSignalingSettings from ResponseDefinitions
  62. */
  63. class SignalingController extends OCSController {
  64. /** @var int */
  65. private const PULL_MESSAGES_TIMEOUT = 30;
  66. public const EVENT_BACKEND_SIGNALING_ROOMS = self::class . '::signalingBackendRoom';
  67. public function __construct(
  68. string $appName,
  69. IRequest $request,
  70. IConfig $serverConfig,
  71. private Config $talkConfig,
  72. private \OCA\Talk\Signaling\Manager $signalingManager,
  73. private TalkSession $session,
  74. private Manager $manager,
  75. private CertificateService $certificateService,
  76. private ParticipantService $participantService,
  77. private SessionService $sessionService,
  78. private IDBConnection $dbConnection,
  79. private Messages $messages,
  80. private IUserManager $userManager,
  81. private IEventDispatcher $dispatcher,
  82. private ITimeFactory $timeFactory,
  83. private IClientService $clientService,
  84. IThrottler $throttler,
  85. private LoggerInterface $logger,
  86. private ?string $userId,
  87. ) {
  88. parent::__construct($appName, $request);
  89. }
  90. /**
  91. * Check if the current request is coming from an allowed recording backend.
  92. *
  93. * The backends are sending the custom header "Talk-Recording-Random"
  94. * containing at least 32 bytes random data, and the header
  95. * "Talk-Recording-Checksum", which is the SHA256-HMAC of the random data
  96. * and the body of the request, calculated with the shared secret from the
  97. * configuration.
  98. *
  99. * @param string $data
  100. * @return bool
  101. */
  102. private function validateRecordingBackendRequest(string $data): bool {
  103. $random = $this->request->getHeader('Talk-Recording-Random');
  104. if (empty($random) || strlen($random) < 32) {
  105. $this->logger->debug("Missing random");
  106. return false;
  107. }
  108. $checksum = $this->request->getHeader('Talk-Recording-Checksum');
  109. if (empty($checksum)) {
  110. $this->logger->debug("Missing checksum");
  111. return false;
  112. }
  113. $hash = hash_hmac('sha256', $random . $data, $this->talkConfig->getRecordingSecret());
  114. return hash_equals($hash, strtolower($checksum));
  115. }
  116. /**
  117. * Get the signaling settings
  118. *
  119. * @param string $token Token of the room
  120. * @return DataResponse<Http::STATUS_OK, TalkSignalingSettings, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, array<empty>, array{}>
  121. *
  122. * 200: Signaling settings returned
  123. * 401: Recording request invalid
  124. * 404: Room not found
  125. */
  126. #[PublicPage]
  127. #[BruteForceProtection(action: 'talkRoomToken')]
  128. #[BruteForceProtection(action: 'talkRecordingSecret')]
  129. public function getSettings(string $token = ''): DataResponse {
  130. $isRecordingRequest = false;
  131. if (!empty($this->request->getHeader('Talk-Recording-Random')) || !empty($this->request->getHeader('Talk-Recording-Checksum'))) {
  132. if (!$this->validateRecordingBackendRequest('')) {
  133. $response = new DataResponse([], Http::STATUS_UNAUTHORIZED);
  134. $response->throttle(['action' => 'talkRecordingSecret']);
  135. return $response;
  136. }
  137. $isRecordingRequest = true;
  138. }
  139. try {
  140. if ($token !== '' && $isRecordingRequest) {
  141. $room = $this->manager->getRoomByToken($token);
  142. } elseif ($token !== '') {
  143. $room = $this->manager->getRoomForUserByToken($token, $this->userId);
  144. } else {
  145. // FIXME Soft-fail for legacy support in mobile apps
  146. $room = null;
  147. }
  148. } catch (RoomNotFoundException $e) {
  149. $response = new DataResponse([], Http::STATUS_NOT_FOUND);
  150. $response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
  151. return $response;
  152. }
  153. $stun = [];
  154. $stunUrls = [];
  155. $stunServers = $this->talkConfig->getStunServers();
  156. foreach ($stunServers as $stunServer) {
  157. $stunUrls[] = 'stun:' . $stunServer;
  158. }
  159. $stun[] = [
  160. 'urls' => $stunUrls
  161. ];
  162. $turn = [];
  163. $turnSettings = $this->talkConfig->getTurnSettings();
  164. foreach ($turnSettings as $turnServer) {
  165. $turnUrls = [];
  166. $schemes = explode(',', $turnServer['schemes']);
  167. $protocols = explode(',', $turnServer['protocols']);
  168. foreach ($schemes as $scheme) {
  169. foreach ($protocols as $proto) {
  170. $turnUrls[] = $scheme . ':' . $turnServer['server'] . '?transport=' . $proto;
  171. }
  172. }
  173. $turn[] = [
  174. 'urls' => $turnUrls,
  175. 'username' => (string)$turnServer['username'],
  176. 'credential' => (string)$turnServer['password'],
  177. ];
  178. }
  179. $signalingMode = $this->talkConfig->getSignalingMode();
  180. $signaling = $this->signalingManager->getSignalingServerLinkForConversation($room);
  181. $helloAuthParams = [
  182. '1.0' => [
  183. 'userid' => $this->userId,
  184. 'ticket' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId),
  185. ],
  186. '2.0' => [
  187. 'token' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V2, $this->userId),
  188. ],
  189. ];
  190. $data = [
  191. 'signalingMode' => $signalingMode,
  192. 'userId' => $this->userId,
  193. 'hideWarning' => $signaling !== '' || $this->talkConfig->getHideSignalingWarning(),
  194. 'server' => $signaling,
  195. 'ticket' => $helloAuthParams['1.0']['ticket'],
  196. 'helloAuthParams' => $helloAuthParams,
  197. 'stunservers' => $stun,
  198. 'turnservers' => $turn,
  199. 'sipDialinInfo' => $this->talkConfig->isSIPConfigured() ? $this->talkConfig->getDialInInfo() : '',
  200. ];
  201. return new DataResponse($data);
  202. }
  203. /**
  204. * Get the welcome message from a signaling server
  205. *
  206. * Only available for logged-in users because guests can not use the apps
  207. * right now.
  208. *
  209. * @param int $serverId ID of the signaling server
  210. * @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string, version?: string}, array{}>
  211. *
  212. * 200: Welcome message returned
  213. * 404: Signaling server not found
  214. */
  215. public function getWelcomeMessage(int $serverId): DataResponse {
  216. $signalingServers = $this->talkConfig->getSignalingServers();
  217. if (empty($signalingServers) || !isset($signalingServers[$serverId])) {
  218. return new DataResponse([], Http::STATUS_NOT_FOUND);
  219. }
  220. $url = rtrim($signalingServers[$serverId]['server'], '/');
  221. $url = strtolower($url);
  222. if (strpos($url, 'wss://') === 0) {
  223. $url = 'https://' . substr($url, 6);
  224. }
  225. if (strpos($url, 'ws://') === 0) {
  226. $url = 'http://' . substr($url, 5);
  227. }
  228. $verifyServer = (bool) $signalingServers[$serverId]['verify'];
  229. if ($verifyServer && str_contains($url, 'https://')) {
  230. $expiration = $this->certificateService->getCertificateExpirationInDays($url);
  231. if ($expiration < 0) {
  232. return new DataResponse(['error' => 'CERTIFICATE_EXPIRED'], Http::STATUS_INTERNAL_SERVER_ERROR);
  233. }
  234. }
  235. $client = $this->clientService->newClient();
  236. try {
  237. $response = $client->get($url . '/api/v1/welcome', [
  238. 'verify' => $verifyServer,
  239. 'nextcloud' => [
  240. 'allow_local_address' => true,
  241. ],
  242. ]);
  243. $body = $response->getBody();
  244. $data = json_decode($body, true);
  245. if (!is_array($data)) {
  246. return new DataResponse([
  247. 'error' => 'JSON_INVALID',
  248. ], Http::STATUS_INTERNAL_SERVER_ERROR);
  249. }
  250. if (!isset($data['version'])) {
  251. return new DataResponse([
  252. 'error' => 'UPDATE_REQUIRED',
  253. 'version' => '',
  254. ], Http::STATUS_INTERNAL_SERVER_ERROR);
  255. }
  256. if (!$this->signalingManager->isCompatibleSignalingServer($response)) {
  257. return new DataResponse([
  258. 'error' => 'UPDATE_REQUIRED',
  259. 'version' => $data['version'] ?? '',
  260. ], Http::STATUS_INTERNAL_SERVER_ERROR);
  261. }
  262. return new DataResponse($data);
  263. } catch (ConnectException $e) {
  264. return new DataResponse(['error' => 'CAN_NOT_CONNECT'], Http::STATUS_INTERNAL_SERVER_ERROR);
  265. } catch (\Exception $e) {
  266. return new DataResponse(['error' => (string)$e->getCode()], Http::STATUS_INTERNAL_SERVER_ERROR);
  267. }
  268. }
  269. /**
  270. * Send signaling messages
  271. *
  272. * @param string $token Token of the room
  273. * @param string $messages JSON encoded messages
  274. * @return DataResponse<Http::STATUS_OK, array<empty>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
  275. *
  276. * 200: Signaling message sent successfully
  277. * 400: Sending signaling message is not possible
  278. */
  279. #[PublicPage]
  280. public function signaling(string $token, string $messages): DataResponse {
  281. if ($this->talkConfig->getSignalingMode() !== Config::SIGNALING_INTERNAL) {
  282. return new DataResponse('Internal signaling disabled.', Http::STATUS_BAD_REQUEST);
  283. }
  284. $response = [];
  285. $messages = json_decode($messages, true);
  286. foreach ($messages as $message) {
  287. $ev = $message['ev'];
  288. switch ($ev) {
  289. case 'message':
  290. $fn = $message['fn'];
  291. if (!is_string($fn)) {
  292. break;
  293. }
  294. $decodedMessage = json_decode($fn, true);
  295. if ($message['sessionId'] !== $this->session->getSessionForRoom($token)) {
  296. break;
  297. }
  298. $decodedMessage['from'] = $message['sessionId'];
  299. if ($decodedMessage['type'] === 'control') {
  300. $room = $this->manager->getRoomForSession($this->userId, $message['sessionId']);
  301. $participant = $this->participantService->getParticipantBySession($room, $message['sessionId']);
  302. if (!$participant->hasModeratorPermissions(false)) {
  303. break;
  304. }
  305. }
  306. $this->messages->addMessage($message['sessionId'], $decodedMessage['to'], json_encode($decodedMessage));
  307. break;
  308. }
  309. }
  310. return new DataResponse($response);
  311. }
  312. /**
  313. * Get signaling messages
  314. *
  315. * @param string $token Token of the room
  316. * @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND|Http::STATUS_CONFLICT, array{type: string, data: TalkSignalingSession[]}[], array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
  317. *
  318. * 200: Signaling messages returned
  319. * 400: Getting signaling messages is not possible
  320. * 404: Session, room or participant not found
  321. * 409: Session killed
  322. */
  323. #[PublicPage]
  324. public function pullMessages(string $token): DataResponse {
  325. if ($this->talkConfig->getSignalingMode() !== Config::SIGNALING_INTERNAL) {
  326. return new DataResponse('Internal signaling disabled.', Http::STATUS_BAD_REQUEST);
  327. }
  328. $data = [];
  329. $seconds = self::PULL_MESSAGES_TIMEOUT;
  330. try {
  331. $sessionId = $this->session->getSessionForRoom($token);
  332. if ($sessionId === null) {
  333. // User is not active in this room
  334. return new DataResponse([['type' => 'usersInRoom', 'data' => []]], Http::STATUS_NOT_FOUND);
  335. }
  336. $room = $this->manager->getRoomForSession($this->userId, $sessionId);
  337. $participant = $this->participantService->getParticipantBySession($room, $sessionId); // FIXME this causes another query
  338. $pingTimestamp = $this->timeFactory->getTime();
  339. if ($participant->getSession() instanceof Session) {
  340. $this->sessionService->updateLastPing($participant->getSession(), $pingTimestamp);
  341. }
  342. } catch (RoomNotFoundException $e) {
  343. return new DataResponse([['type' => 'usersInRoom', 'data' => []]], Http::STATUS_NOT_FOUND);
  344. }
  345. while ($seconds > 0) {
  346. // Query all messages and send them to the user
  347. $data = $this->messages->getAndDeleteMessages($sessionId);
  348. $messageCount = count($data);
  349. $data = array_filter($data, function ($message) {
  350. return $message['data'] !== 'refresh-participant-list';
  351. });
  352. if ($messageCount !== count($data)) {
  353. // Make sure the array is a json array not a json object,
  354. // because the index list has a gap
  355. $data = array_values($data);
  356. // Participant list changed, bail out and deliver the info to the user
  357. break;
  358. }
  359. $this->dbConnection->close();
  360. if (empty($data)) {
  361. $seconds--;
  362. } else {
  363. break;
  364. }
  365. sleep(1);
  366. // Refresh the session and retry
  367. $sessionId = $this->session->getSessionForRoom($token);
  368. if ($sessionId === null) {
  369. // User is not active in this room
  370. return new DataResponse([['type' => 'usersInRoom', 'data' => []]], Http::STATUS_NOT_FOUND);
  371. }
  372. }
  373. try {
  374. // Add an update of the room participants at the end of the waiting
  375. $room = $this->manager->getRoomForSession($this->userId, $sessionId);
  376. $data[] = ['type' => 'usersInRoom', 'data' => $this->getUsersInRoom($room, $pingTimestamp)];
  377. } catch (RoomNotFoundException $e) {
  378. $data[] = ['type' => 'usersInRoom', 'data' => []];
  379. // Was the session killed or the complete conversation?
  380. try {
  381. $room = $this->manager->getRoomForUserByToken($token, $this->userId);
  382. if ($this->userId) {
  383. // For logged in users we check if they are still part of the public conversation,
  384. // if not they were removed instead of having a conflict.
  385. $this->participantService->getParticipant($room, $this->userId, false);
  386. }
  387. // Session was killed, make the UI redirect to an error
  388. return new DataResponse($data, Http::STATUS_CONFLICT);
  389. } catch (ParticipantNotFoundException $e) {
  390. // User removed from conversation, bye!
  391. return new DataResponse($data, Http::STATUS_NOT_FOUND);
  392. } catch (RoomNotFoundException $e) {
  393. // Complete conversation was killed, bye!
  394. return new DataResponse($data, Http::STATUS_NOT_FOUND);
  395. }
  396. }
  397. return new DataResponse($data);
  398. }
  399. /**
  400. * @param Room $room
  401. * @param int $pingTimestamp
  402. * @return TalkSignalingSession[]
  403. */
  404. protected function getUsersInRoom(Room $room, int $pingTimestamp): array {
  405. $usersInRoom = [];
  406. // Get participants active in the last 40 seconds (an extra time is used
  407. // to include other participants pinging almost at the same time as the
  408. // current user), or since the last signaling ping of the current user
  409. // if it was done more than 40 seconds ago.
  410. $timestamp = min($this->timeFactory->getTime() - (self::PULL_MESSAGES_TIMEOUT + 10), $pingTimestamp);
  411. // "- 1" is needed because only the participants whose last ping is
  412. // greater than the given timestamp are returned.
  413. $participants = $this->participantService->getParticipantsForAllSessions($room, $timestamp - 1);
  414. foreach ($participants as $participant) {
  415. $session = $participant->getSession();
  416. if (!$session instanceof Session) {
  417. // This is just to make Psalm happy, since we select by session it's always with one.
  418. continue;
  419. }
  420. $userId = '';
  421. if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
  422. $userId = $participant->getAttendee()->getActorId();
  423. }
  424. $usersInRoom[] = [
  425. 'userId' => $userId,
  426. 'roomId' => $room->getId(),
  427. 'lastPing' => $session->getLastPing(),
  428. 'sessionId' => $session->getSessionId(),
  429. 'inCall' => $session->getInCall(),
  430. 'participantPermissions' => $participant->getPermissions(),
  431. ];
  432. }
  433. return $usersInRoom;
  434. }
  435. /**
  436. * Check if the current request is coming from an allowed backend.
  437. *
  438. * The backends are sending the custom header "Talk-Signaling-Random"
  439. * containing at least 32 bytes random data, and the header
  440. * "Talk-Signaling-Checksum", which is the SHA256-HMAC of the random data
  441. * and the body of the request, calculated with the shared secret from the
  442. * configuration.
  443. *
  444. * @param string $data
  445. * @return bool
  446. */
  447. private function validateBackendRequest(string $data): bool {
  448. if (!isset($_SERVER['HTTP_SPREED_SIGNALING_RANDOM'],
  449. $_SERVER['HTTP_SPREED_SIGNALING_CHECKSUM'])) {
  450. return false;
  451. }
  452. $random = $_SERVER['HTTP_SPREED_SIGNALING_RANDOM'];
  453. if (empty($random) || strlen($random) < 32) {
  454. return false;
  455. }
  456. $checksum = $_SERVER['HTTP_SPREED_SIGNALING_CHECKSUM'];
  457. if (empty($checksum)) {
  458. return false;
  459. }
  460. $hash = hash_hmac('sha256', $random . $data, $this->talkConfig->getSignalingSecret());
  461. return hash_equals($hash, strtolower($checksum));
  462. }
  463. /**
  464. * Return the body of the backend request. This can be overridden in
  465. * tests.
  466. *
  467. * @return string
  468. */
  469. protected function getInputStream(): string {
  470. return file_get_contents('php://input');
  471. }
  472. /**
  473. * Backend API to query information required for standalone signaling
  474. * servers
  475. *
  476. * See sections "Backend validation" in
  477. * https://nextcloud-spreed-signaling.readthedocs.io/en/latest/standalone-signaling-api-v1/#backend-requests
  478. *
  479. * @return DataResponse<Http::STATUS_OK, array{type: string, error?: array{code: string, message: string}, auth?: array{version: string, userid?: string, user?: array<string, mixed>}, room?: array{version: string, roomid?: string, properties?: array<string, mixed>, permissions?: string[], session?: string}}, array{}>
  480. *
  481. * 200: Always, sorry about that
  482. */
  483. #[IgnoreOpenAPI]
  484. #[PublicPage]
  485. #[BruteForceProtection(action: 'talkSignalingSecret')]
  486. public function backend(): DataResponse {
  487. $json = $this->getInputStream();
  488. if (!$this->validateBackendRequest($json)) {
  489. $response = new DataResponse([
  490. 'type' => 'error',
  491. 'error' => [
  492. 'code' => 'invalid_request',
  493. 'message' => 'The request could not be authenticated.',
  494. ],
  495. ]);
  496. $response->throttle(['action' => 'talkSignalingSecret']);
  497. return $response;
  498. }
  499. $message = json_decode($json, true);
  500. switch ($message['type'] ?? '') {
  501. case 'auth':
  502. // Query authentication information about a user.
  503. return $this->backendAuth($message['auth']);
  504. case 'room':
  505. // Query information about a room.
  506. return $this->backendRoom($message['room']);
  507. case 'ping':
  508. // Ping sessions connected to a room.
  509. return $this->backendPing($message['ping']);
  510. default:
  511. return new DataResponse([
  512. 'type' => 'error',
  513. 'error' => [
  514. 'code' => 'unknown_type',
  515. 'message' => 'The given type ' . json_encode($message) . ' is not supported.',
  516. ],
  517. ]);
  518. }
  519. }
  520. /**
  521. * @return DataResponse<Http::STATUS_OK, array{type: string, error?: array{code: string, message: string}, auth?: array{version: string, userid?: string, user?: array<string, mixed>}}, array{}>
  522. */
  523. private function backendAuth(array $auth): DataResponse {
  524. $params = $auth['params'];
  525. $userId = $params['userid'];
  526. if (!$this->talkConfig->validateSignalingTicket($userId, $params['ticket'])) {
  527. $this->logger->debug('Signaling ticket for {user} was not valid', [
  528. 'user' => !empty($userId) ? $userId : '(guests)',
  529. 'app' => 'spreed-hpb',
  530. ]);
  531. return new DataResponse([
  532. 'type' => 'error',
  533. 'error' => [
  534. 'code' => 'invalid_ticket',
  535. 'message' => 'The given ticket is not valid for this user.',
  536. ],
  537. ]);
  538. }
  539. if (!empty($userId)) {
  540. $user = $this->userManager->get($userId);
  541. if (!$user instanceof IUser) {
  542. $this->logger->debug('Tried to validate signaling ticket for {user}, but user manager returned no user', [
  543. 'user' => $userId,
  544. 'app' => 'spreed-hpb',
  545. ]);
  546. return new DataResponse([
  547. 'type' => 'error',
  548. 'error' => [
  549. 'code' => 'no_such_user',
  550. 'message' => 'The given user does not exist.',
  551. ],
  552. ]);
  553. }
  554. }
  555. $response = [
  556. 'type' => 'auth',
  557. 'auth' => [
  558. 'version' => '1.0',
  559. ],
  560. ];
  561. if (!empty($userId)) {
  562. $response['auth']['userid'] = $user->getUID();
  563. $response['auth']['user'] = $this->talkConfig->getSignalingUserData($user);
  564. }
  565. $this->logger->debug('Validated signaling ticket for {user}', [
  566. 'user' => !empty($userId) ? $userId : '(guests)',
  567. 'app' => 'spreed-hpb',
  568. ]);
  569. return new DataResponse($response);
  570. }
  571. /**
  572. * @return DataResponse<Http::STATUS_OK, array{type: string, error?: array{code: string, message: string}, room?: array{version: string, roomid: string, properties: array<string, mixed>, permissions: string[], session?: string}}, array{}>
  573. */
  574. private function backendRoom(array $roomRequest): DataResponse {
  575. $token = $roomRequest['roomid']; // It's actually the room token
  576. $userId = $roomRequest['userid'];
  577. $sessionId = $roomRequest['sessionid'];
  578. $action = !empty($roomRequest['action']) ? $roomRequest['action'] : 'join';
  579. $actorId = $roomRequest['actorid'] ?? null;
  580. $actorType = $roomRequest['actortype'] ?? null;
  581. $inCall = $roomRequest['incall'] ?? null;
  582. $participant = null;
  583. if ($actorId !== null && $actorType !== null) {
  584. try {
  585. $room = $this->manager->getRoomByActor($token, $actorType, $actorId);
  586. } catch (RoomNotFoundException $e) {
  587. $this->logger->debug('Failed to get room {token} by actor {actorType}/{actorId}', [
  588. 'token' => $token,
  589. 'actorType' => $actorType ?? 'null',
  590. 'actorId' => $actorId ?? 'null',
  591. 'app' => 'spreed-hpb',
  592. ]);
  593. return new DataResponse([
  594. 'type' => 'error',
  595. 'error' => [
  596. 'code' => 'no_such_room',
  597. 'message' => 'The user is not invited to this room.',
  598. ],
  599. ]);
  600. }
  601. if ($sessionId) {
  602. try {
  603. $participant = $this->participantService->getParticipantBySession($room, $sessionId);
  604. } catch (ParticipantNotFoundException $e) {
  605. if ($action === 'join') {
  606. // If the user joins the session might not be known to the server yet.
  607. // In this case we load by actor information and use the session id as new session.
  608. try {
  609. $participant = $this->participantService->getParticipantByActor($room, $actorType, $actorId);
  610. } catch (ParticipantNotFoundException $e) {
  611. }
  612. }
  613. }
  614. } else {
  615. try {
  616. $participant = $this->participantService->getParticipantByActor($room, $actorType, $actorId);
  617. } catch (ParticipantNotFoundException $e) {
  618. }
  619. }
  620. } else {
  621. try {
  622. // FIXME Don't preload with the user as that misses the session, kinda meh.
  623. $room = $this->manager->getRoomByToken($token);
  624. } catch (RoomNotFoundException $e) {
  625. $this->logger->debug('Failed to get room by token {token}', [
  626. 'token' => $token,
  627. 'app' => 'spreed-hpb',
  628. ]);
  629. return new DataResponse([
  630. 'type' => 'error',
  631. 'error' => [
  632. 'code' => 'no_such_room',
  633. 'message' => 'The user is not invited to this room.',
  634. ],
  635. ]);
  636. }
  637. if ($sessionId) {
  638. try {
  639. $participant = $this->participantService->getParticipantBySession($room, $sessionId);
  640. } catch (ParticipantNotFoundException $e) {
  641. }
  642. } elseif (!empty($userId)) {
  643. // User trying to join room.
  644. try {
  645. $participant = $this->participantService->getParticipant($room, $userId, false);
  646. } catch (ParticipantNotFoundException $e) {
  647. }
  648. }
  649. }
  650. if (!$participant instanceof Participant) {
  651. $this->logger->debug('Failed to get room {token} with participant', [
  652. 'token' => $token,
  653. 'app' => 'spreed-hpb',
  654. ]);
  655. // Return generic error to avoid leaking which rooms exist.
  656. return new DataResponse([
  657. 'type' => 'error',
  658. 'error' => [
  659. 'code' => 'no_such_room',
  660. 'message' => 'The user is not invited to this room.',
  661. ],
  662. ]);
  663. }
  664. if ($action === 'join') {
  665. if ($sessionId && !$participant->getSession() instanceof Session) {
  666. try {
  667. $session = $this->sessionService->createSessionForAttendee($participant->getAttendee(), $sessionId);
  668. } catch (Exception $e) {
  669. return new DataResponse([
  670. 'type' => 'error',
  671. 'error' => [
  672. 'code' => 'duplicate_session',
  673. 'message' => 'The given session is already in use.',
  674. ],
  675. ]);
  676. }
  677. $participant->setSession($session);
  678. }
  679. if ($participant->getSession() instanceof Session) {
  680. if ($inCall !== null) {
  681. $this->participantService->changeInCall($room, $participant, $inCall);
  682. }
  683. $this->sessionService->updateLastPing($participant->getSession(), $this->timeFactory->getTime());
  684. }
  685. } elseif ($action === 'leave') {
  686. // Guests are removed completely as they don't reuse attendees,
  687. // but this is only true for guests that joined directly.
  688. // Emails are retained as their PIN needs to remain and stay
  689. // valid.
  690. if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_GUESTS) {
  691. $this->participantService->removeAttendee($room, $participant, Room::PARTICIPANT_LEFT);
  692. } else {
  693. $this->participantService->leaveRoomAsSession($room, $participant);
  694. }
  695. }
  696. $this->logger->debug('Room request to "{action}" room {token} by actor {actorType}/{actorId}', [
  697. 'token' => $token,
  698. 'action' => $action ?? 'null',
  699. 'actorType' => $participant->getAttendee()->getActorType(),
  700. 'actorId' => $participant->getAttendee()->getActorId(),
  701. 'app' => 'spreed-hpb',
  702. ]);
  703. $permissions = [];
  704. if ($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_AUDIO) {
  705. $permissions[] = 'publish-audio';
  706. }
  707. if ($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_VIDEO) {
  708. $permissions[] = 'publish-video';
  709. }
  710. if ($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_SCREEN) {
  711. $permissions[] = 'publish-screen';
  712. }
  713. if ($participant->hasModeratorPermissions(false)) {
  714. $permissions[] = 'control';
  715. }
  716. $event = new SignalingEvent($room, $participant, $action);
  717. $this->dispatcher->dispatch(self::EVENT_BACKEND_SIGNALING_ROOMS, $event);
  718. $response = [
  719. 'type' => 'room',
  720. 'room' => [
  721. 'version' => '1.0',
  722. 'roomid' => $room->getToken(),
  723. 'properties' => $room->getPropertiesForSignaling((string) $userId),
  724. 'permissions' => $permissions,
  725. ],
  726. ];
  727. if ($event->getSession()) {
  728. $response['room']['session'] = $event->getSession();
  729. }
  730. return new DataResponse($response);
  731. }
  732. /**
  733. * @return DataResponse<Http::STATUS_OK, array{type: string, room: array{version: string}}, array{}>
  734. */
  735. private function backendPing(array $request): DataResponse {
  736. $pingSessionIds = [];
  737. $now = $this->timeFactory->getTime();
  738. foreach ($request['entries'] as $entry) {
  739. if ($entry['sessionid'] !== '0') {
  740. $pingSessionIds[] = $entry['sessionid'];
  741. }
  742. }
  743. // Ping all active sessions with one query
  744. $this->sessionService->updateMultipleLastPings($pingSessionIds, $now);
  745. $response = [
  746. 'type' => 'room',
  747. 'room' => [
  748. 'version' => '1.0',
  749. ],
  750. ];
  751. $this->logger->debug('Pinged {numSessions} sessions {token}', [
  752. 'numSessions' => count($pingSessionIds),
  753. 'token' => !empty($request['roomid']) ? ('in room ' . $request['roomid']) : '',
  754. 'app' => 'spreed-hpb',
  755. ]);
  756. return new DataResponse($response);
  757. }
  758. }