diff --git a/docs/capabilities.md b/docs/capabilities.md index c0e0e7b4d63..3f68c95d6cf 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -175,3 +175,5 @@ * `config => chat => has-translation-task-providers` (local) - When true, translations can be done using the [OCS TaskProcessing API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html). * `config => conversations => list-style` (local) - Whether conversation list should appear in certain way * `config => conversations => description-length` (local) - The maximum length for conversation descriptions, currently 2000. Before this config was added the implicit limit was 500, since the existance of the feature capability `room-description`. +* `call-end-to-end-encryption` - Signaling support of the server for the end-to-end encryption of calls +* `config => call => end-to-end-encryption` - Whether calls should be end-to-end encrypted (currently off by default, until all Talk mobile clients support it) diff --git a/docs/settings.md b/docs/settings.md index b1924aee7f8..05d97a08e24 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -118,5 +118,6 @@ Legend: | `enable_matterbridge` | string
`1` or `0` | `0` | No | 🖌️ | Whether the Matterbridge integration is enabled and can be configured | | `force_passwords` | string
`1` or `0` | `0` | No | ️ | Whether public chats are forced to use a password | | `create_samples` | string
`1` or `0` | `1` | No | ️ | Create sample conversations (the content can be overwritten by providing files in a provided `samples_directory` app config) | +| `call_end_to_end_encryption` | string
`1` or `0` | `0` | No | 🖌️ | Whether clients should end-to-end encrypt streams in calls (Only supported with High-performance backend) | | `inactivity_lock_after_days` | int | `0` | No | | A duration (in days) after which rooms are locked. Calculated from the last activity in the room. | | `inactivity_enable_lobby` | string
`1` or `0` | `0` | No | | Additionally enable the lobby for inactive rooms so they can only be read by moderators. | diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 3f7a4c1cb8f..af716e51757 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -119,6 +119,7 @@ class Capabilities implements IPublicCapability { 'message-expiration', 'reactions', 'chat-summary-api', + 'call-end-to-end-encryption', ]; public const LOCAL_FEATURES = [ @@ -224,6 +225,7 @@ public function getCapabilities(): array { 'start-without-media' => $this->talkConfig->getCallsStartWithoutMedia($user?->getUID()), 'max-duration' => $this->appConfig->getAppValueInt('max_call_duration'), 'blur-virtual-background' => $this->talkConfig->getBlurVirtualBackground($user?->getUID()), + 'end-to-end-encryption' => $this->talkConfig->isCallEndToEndEncryptionEnabled(), ], 'chat' => [ 'max-length' => ChatManager::MAX_CHAT_LENGTH, @@ -337,6 +339,10 @@ public function getCapabilities(): array { $capabilities['config']['chat']['has-translation-task-providers'] = true; } + if ($this->talkConfig->getSignalingMode() === Config::SIGNALING_EXTERNAL) { + $capabilities['features'][] = 'call-end-to-end-encryption'; + } + return [ 'spreed' => $capabilities, ]; diff --git a/lib/Config.php b/lib/Config.php index d0f40a60f1d..461890e7096 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -732,4 +732,13 @@ public function enableLobbyOnLockedRooms(): bool { public function isPasswordEnforced(): bool { return $this->appConfig->getAppValueBool('force_passwords'); } + + public function isCallEndToEndEncryptionEnabled(): bool { + if ($this->getSignalingMode() !== self::SIGNALING_EXTERNAL) { + return false; + } + + // TODO Default value will be set to true, once all mobile clients support it. + return $this->appConfig->getAppValueBool('call_end_to_end_encryption'); + } } diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php index 64892a9a679..52cbb7fd5f1 100644 --- a/lib/Controller/SignalingController.php +++ b/lib/Controller/SignalingController.php @@ -8,7 +8,6 @@ namespace OCA\Talk\Controller; -use GuzzleHttp\Exception\ConnectException; use OCA\Talk\Config; use OCA\Talk\Events\BeforeSignalingResponseSentEvent; use OCA\Talk\Exceptions\ForbiddenException; @@ -22,7 +21,6 @@ use OCA\Talk\ResponseDefinitions; use OCA\Talk\Room; use OCA\Talk\Service\BanService; -use OCA\Talk\Service\CertificateService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\SessionService; use OCA\Talk\Signaling\Messages; @@ -59,7 +57,6 @@ public function __construct( private \OCA\Talk\Signaling\Manager $signalingManager, private TalkSession $session, private Manager $manager, - private CertificateService $certificateService, private ParticipantService $participantService, private SessionService $sessionService, private IDBConnection $dbConnection, @@ -272,89 +269,12 @@ private function getFederationSettings(?Room $room): ?array { */ #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])] public function getWelcomeMessage(int $serverId): DataResponse { - $signalingServers = $this->talkConfig->getSignalingServers(); - if (empty($signalingServers) || !isset($signalingServers[$serverId])) { - return new DataResponse(null, Http::STATUS_NOT_FOUND); - } - - $url = rtrim($signalingServers[$serverId]['server'], '/'); - $url = strtolower($url); - - if (str_starts_with($url, 'wss://')) { - $url = 'https://' . substr($url, 6); - } - - if (str_starts_with($url, 'ws://')) { - $url = 'http://' . substr($url, 5); - } - - $verifyServer = (bool)$signalingServers[$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 { - $timeBefore = $this->timeFactory->getTime(); - $response = $client->get($url . '/api/v1/welcome', [ - 'verify' => $verifyServer, - 'nextcloud' => [ - 'allow_local_address' => true, - ], - ]); - $timeAfter = $this->timeFactory->getTime(); - - $body = $response->getBody(); - $data = json_decode($body, true); - - if (!is_array($data)) { - return new DataResponse([ - 'error' => 'JSON_INVALID', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - if (!isset($data['version'])) { - return new DataResponse([ - 'error' => 'UPDATE_REQUIRED', - 'version' => '', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - if (!$this->signalingManager->isCompatibleSignalingServer($response)) { - return new DataResponse([ - 'error' => 'UPDATE_REQUIRED', - 'version' => $data['version'] ?? '', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $responseTime = $this->timeFactory->getDateTime($response->getHeader('date'))->getTimestamp(); - if (($timeBefore - Config::ALLOWED_BACKEND_TIMEOFFSET) > $responseTime - || ($timeAfter + Config::ALLOWED_BACKEND_TIMEOFFSET) < $responseTime) { - return new DataResponse([ - 'error' => 'TIME_OUT_OF_SYNC', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $missingFeatures = $this->signalingManager->getSignalingServerMissingFeatures($response); - if (!empty($missingFeatures)) { - return new DataResponse([ - 'warning' => 'UPDATE_OPTIONAL', - 'features' => $missingFeatures, - 'version' => $data['version'] ?? '', - ]); - } - - 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); + $testResult = $this->signalingManager->checkServerCompatibility($serverId); + } catch (\OutOfBoundsException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); } + return new DataResponse($testResult['data'], $testResult['status']); } /** diff --git a/lib/Middleware/CanUseTalkMiddleware.php b/lib/Middleware/CanUseTalkMiddleware.php index fd01d305bb0..f9dcaf9eedc 100644 --- a/lib/Middleware/CanUseTalkMiddleware.php +++ b/lib/Middleware/CanUseTalkMiddleware.php @@ -36,9 +36,11 @@ class CanUseTalkMiddleware extends Middleware { public const TALK_DESKTOP_MIN_VERSION_RECORDING_CONSENT = '0.16.0'; public const TALK_ANDROID_MIN_VERSION = '15.0.0'; public const TALK_ANDROID_MIN_VERSION_RECORDING_CONSENT = '18.0.0'; + public const TALK_ANDROID_MIN_VERSION_E2EE_CALLS = '22.0.0'; public const TALK_IOS_MIN_VERSION = '15.0.0'; public const TALK_IOS_MIN_VERSION_RECORDING_CONSENT = '18.0.0'; + public const TALK_IOS_MIN_VERSION_E2EE_CALLS = '22.0.0'; public function __construct( @@ -139,14 +141,18 @@ protected function throwIfUnsupportedClientVersion(string $client, string $userA $versionRegex = IRequest::USER_AGENT_TALK_ANDROID; $minVersion = self::TALK_ANDROID_MIN_VERSION; - if ($this->talkConfig->recordingConsentRequired()) { + if ($this->talkConfig->isCallEndToEndEncryptionEnabled()) { + $minVersion = self::TALK_ANDROID_MIN_VERSION_E2EE_CALLS; + } elseif ($this->talkConfig->recordingConsentRequired()) { $minVersion = self::TALK_ANDROID_MIN_VERSION_RECORDING_CONSENT; } } elseif ($client === 'ios') { $versionRegex = IRequest::USER_AGENT_TALK_IOS; $minVersion = self::TALK_IOS_MIN_VERSION; - if ($this->talkConfig->recordingConsentRequired()) { + if ($this->talkConfig->isCallEndToEndEncryptionEnabled()) { + $minVersion = self::TALK_IOS_MIN_VERSION_E2EE_CALLS; + } elseif ($this->talkConfig->recordingConsentRequired()) { $minVersion = self::TALK_IOS_MIN_VERSION_RECORDING_CONSENT; } } else { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e2016a5a515..90880bb0e00 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -347,6 +347,7 @@ * start-without-media: bool, * max-duration: int, * blur-virtual-background: bool, + * end-to-end-encryption: bool, * }, * chat: array{ * max-length: int, diff --git a/lib/SetupCheck/HighPerformanceBackend.php b/lib/SetupCheck/HighPerformanceBackend.php index 030b0d6f257..9470f0a5d25 100644 --- a/lib/SetupCheck/HighPerformanceBackend.php +++ b/lib/SetupCheck/HighPerformanceBackend.php @@ -9,6 +9,8 @@ namespace OCA\Talk\SetupCheck; use OCA\Talk\Config; +use OCA\Talk\Signaling\Manager; +use OCP\AppFramework\Http; use OCP\ICacheFactory; use OCP\IL10N; use OCP\IURLGenerator; @@ -21,6 +23,7 @@ public function __construct( readonly protected ICacheFactory $cacheFactory, readonly protected IURLGenerator $urlGenerator, readonly protected IL10N $l, + readonly protected Manager $signalManager, ) { } @@ -54,12 +57,58 @@ public function run(): SetupResult { ); } - if ($this->cacheFactory->isAvailable()) { - return SetupResult::success(); + try { + $testResult = $this->signalManager->checkServerCompatibility(0); + } catch (\OutOfBoundsException) { + return SetupResult::error($this->l->t('High-performance backend not configured correctly')); } - return SetupResult::warning( - $this->l->t('It is highly recommended to configure a memory cache when running Nextcloud Talk with a High-performance backend.'), - $this->urlGenerator->linkToDocs('admin-cache'), - ); + if ($testResult['status'] === Http::STATUS_INTERNAL_SERVER_ERROR) { + $error = $testResult['data']['error']; + if ($error === 'CAN_NOT_CONNECT') { + return SetupResult::error($this->l->t('Error: Cannot connect to server')); + } + if ($error === 'JSON_INVALID') { + return SetupResult::error($this->l->t('Error: Server did not respond with proper JSON')); + } + if ($error === 'CERTIFICATE_EXPIRED') { + return SetupResult::error($this->l->t('Error: Certificate expired')); + } + if ($error === 'TIME_OUT_OF_SYNC') { + return SetupResult::error($this->l->t('Error: System times of Nextcloud server and High-performance backend server are out of sync. Please make sure that both servers are connected to a time-server or manually synchronize their time.')); + } + if ($error === 'UPDATE_REQUIRED') { + $version = $testResult['data']['version'] ?? $this->l->t('Could not get version'); + return SetupResult::error(str_replace( + '{version}', + $version, + $this->l->t('Error: Running version: {version}; Server needs to be updated to be compatible with this version of Talk'), + )); + } + if ($error) { + return SetupResult::error(str_replace('{error}', $error, $this->l->t('Error: Server responded with: {error}'))); + } + return SetupResult::error($this->l->t('Error: Unknown error occurred')); + } + if ($testResult['status'] === Http::STATUS_OK + && isset($testResult['data']['warning']) + && $testResult['data']['warning'] === 'UPDATE_OPTIONAL' + ) { + $version = $testResult['data']['version'] ?? $this->l->t('Could not get version'); + $features = implode(', ', $testResult['data']['features'] ?? []); + return SetupResult::warning(str_replace( + ['{version}', '{features}'], + [$version, $features], + $this->l->t('Warning: Running version: {version}; Server does not support all features of this Talk version, missing features: {features}') + )); + } + + if (!$this->cacheFactory->isAvailable()) { + return SetupResult::warning( + $this->l->t('It is highly recommended to configure a memory cache when running Nextcloud Talk with a High-performance backend.'), + $this->urlGenerator->linkToDocs('admin-cache'), + ); + } + + return SetupResult::success(); } } diff --git a/lib/Signaling/Manager.php b/lib/Signaling/Manager.php index c795f7540c7..61e36cce656 100644 --- a/lib/Signaling/Manager.php +++ b/lib/Signaling/Manager.php @@ -8,10 +8,15 @@ namespace OCA\Talk\Signaling; +use GuzzleHttp\Exception\ConnectException; use OCA\Talk\CachePrefix; use OCA\Talk\Config; use OCA\Talk\Room; +use OCA\Talk\Service\CertificateService; use OCA\Talk\Service\RoomService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; use OCP\ICache; use OCP\ICacheFactory; @@ -26,11 +31,139 @@ public function __construct( protected IConfig $serverConfig, protected Config $talkConfig, protected RoomService $roomService, + protected ITimeFactory $timeFactory, + protected IClientService $clientService, + protected CertificateService $certificateService, ICacheFactory $cacheFactory, ) { $this->cache = $cacheFactory->createDistributed(CachePrefix::SIGNALING_ASSIGNED_SERVER); } + /** + * @param int $serverId + * @return array{status: Http::STATUS_OK, data: array}|array{status: Http::STATUS_INTERNAL_SERVER_ERROR, data: array{error: string, version?: string}} + * @throws \OutOfBoundsException When the serverId is not found + */ + public function checkServerCompatibility(int $serverId): array { + $signalingServers = $this->talkConfig->getSignalingServers(); + if (empty($signalingServers) || !isset($signalingServers[$serverId])) { + throw new \OutOfBoundsException(); + } + + $url = rtrim($signalingServers[$serverId]['server'], '/'); + $url = strtolower($url); + + if (str_starts_with($url, 'wss://')) { + $url = 'https://' . substr($url, 6); + } + + if (str_starts_with($url, 'ws://')) { + $url = 'http://' . substr($url, 5); + } + + $verifyServer = (bool)$signalingServers[$serverId]['verify']; + + if ($verifyServer && str_contains($url, 'https://')) { + $expiration = $this->certificateService->getCertificateExpirationInDays($url); + + if ($expiration < 0) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'CERTIFICATE_EXPIRED', + ], + ]; + } + } + + $client = $this->clientService->newClient(); + try { + $timeBefore = $this->timeFactory->getTime(); + $response = $client->get($url . '/api/v1/welcome', [ + 'verify' => $verifyServer, + 'nextcloud' => [ + 'allow_local_address' => true, + ], + ]); + $timeAfter = $this->timeFactory->getTime(); + + $body = $response->getBody(); + $data = json_decode($body, true); + + if (!is_array($data)) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'JSON_INVALID', + ], + ]; + } + + if (!isset($data['version'])) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'UPDATE_REQUIRED', + 'version' => '', + ], + ]; + } + + if (!$this->isCompatibleSignalingServer($response)) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'UPDATE_REQUIRED', + 'version' => $data['version'] ?? '', + ], + ]; + } + + $responseTime = $this->timeFactory->getDateTime($response->getHeader('date'))->getTimestamp(); + if (($timeBefore - Config::ALLOWED_BACKEND_TIMEOFFSET) > $responseTime + || ($timeAfter + Config::ALLOWED_BACKEND_TIMEOFFSET) < $responseTime) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'TIME_OUT_OF_SYNC', + ], + ]; + } + + $missingFeatures = $this->getSignalingServerMissingFeatures($response); + if (!empty($missingFeatures)) { + return [ + 'status' => Http::STATUS_OK, + 'data' => [ + 'warning' => 'UPDATE_OPTIONAL', + 'features' => $missingFeatures, + 'version' => $data['version'], + ], + ]; + } + + return [ + 'status' => Http::STATUS_OK, + 'data' => $data, + ]; + } catch (ConnectException) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'CAN_NOT_CONNECT', + ], + ]; + } catch (\Exception $e) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => (string)$e->getCode(), + ], + ]; + } + + } + public function isCompatibleSignalingServer(IResponse $response): bool { $featureHeader = $response->getHeader(self::FEATURE_HEADER); $features = explode(',', $featureHeader); @@ -49,6 +182,7 @@ public function getSignalingServerMissingFeatures(IResponse $response): array { return array_values(array_diff([ 'dialout', + 'join-features', ], $features)); } diff --git a/openapi-administration.json b/openapi-administration.json index c05940b78b1..3dc667a4122 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -150,7 +150,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -199,6 +200,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index 20030f63c7f..571ea282643 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -83,7 +83,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -132,6 +133,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index 2a3e15d6886..a719b7a020d 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -83,7 +83,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -132,6 +133,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 0faed9c123b..f078c3cc06c 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -126,7 +126,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -175,6 +176,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi-bots.json b/openapi-bots.json index 8f65778056e..fc98590d7b0 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -83,7 +83,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -132,6 +133,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 03b18eff9d7..1e9b5d4d18b 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -126,7 +126,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -175,6 +176,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi-full.json b/openapi-full.json index 94a0354eeb1..5af06fb7e87 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -284,7 +284,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -333,6 +334,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/openapi.json b/openapi.json index c76ab8c9242..5cde9573d47 100644 --- a/openapi.json +++ b/openapi.json @@ -243,7 +243,8 @@ "can-enable-sip", "start-without-media", "max-duration", - "blur-virtual-background" + "blur-virtual-background", + "end-to-end-encryption" ], "properties": { "enabled": { @@ -292,6 +293,9 @@ }, "blur-virtual-background": { "type": "boolean" + }, + "end-to-end-encryption": { + "type": "boolean" } } }, diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 6ab75901a68..eaca66b63db 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -91,6 +91,11 @@ export const mockedCapabilities: Capabilities = { 'conversation-creation-password', 'call-notification-state-api', 'schedule-meeting', + // Conditional features + 'message-expiration', + 'reactions', + 'chat-summary-api', + 'call-end-to-end-encryption', ], 'features-local': [ 'favorites', @@ -126,6 +131,7 @@ export const mockedCapabilities: Capabilities = { 'start-without-media': false, 'max-duration': 0, 'blur-virtual-background': false, + 'end-to-end-encryption': false, }, chat: { 'max-length': 32000, @@ -176,6 +182,7 @@ export const mockedCapabilities: Capabilities = { conversations: [ 'can-create', 'list-style', + 'description-length', ], federation: [ 'enabled', diff --git a/src/components/AdminSettings/GeneralSettings.vue b/src/components/AdminSettings/GeneralSettings.vue index 05244a4b5fc..1cbd37e28fa 100644 --- a/src/components/AdminSettings/GeneralSettings.vue +++ b/src/components/AdminSettings/GeneralSettings.vue @@ -27,15 +27,38 @@ {{ t('spreed', 'Allow conversations on files') }} {{ t('spreed', 'Allow conversations on public shares for files') }} + + @@ -44,8 +67,11 @@ import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import { EventBus } from '../../services/EventBus.ts' + const defaultGroupNotificationOptions = [ { value: 1, label: t('spreed', 'All messages') }, { value: 2, label: t('spreed', '@-mentions only') }, @@ -55,6 +81,7 @@ export default { name: 'GeneralSettings', components: { + NcNoteCard, NcCheckboxRadioSwitch, NcSelect, }, @@ -70,6 +97,11 @@ export default { conversationsFiles: parseInt(loadState('spreed', 'conversations_files')) === 1, conversationsFilesPublicShares: parseInt(loadState('spreed', 'conversations_files_public_shares')) === 1, + + hasFeatureJoinFeatures: false, + hasSignalingServers: false, + isE2EECallsEnabled: false, + hasSIPBridge: !!loadState('spreed', 'sip_bridge_shared_secret'), } }, @@ -80,16 +112,53 @@ export default { isConversationsFilesPublicSharesChecked() { return this.conversationsFilesPublicShares }, + canEnableE2EECalls() { + return this.hasFeatureJoinFeatures || !this.hasSIPBridge + }, }, mounted() { this.loading = true this.defaultGroupNotification = defaultGroupNotificationOptions[parseInt(loadState('spreed', 'default_group_notification')) - 1] this.loading = false + + const signaling = loadState('spreed', 'signaling_servers') + this.updateSignalingServers(signaling.servers) + EventBus.on('signaling-servers-updated', this.updateSignalingServers) + EventBus.on('signaling-server-connected', this.updateSignalingDetails) + EventBus.on('sip-settings-updated', this.updateSipDetails) + }, + + beforeDestroy() { + EventBus.off('signaling-servers-updated', this.updateSignalingServers) + EventBus.off('signaling-server-connected', this.updateSignalingDetails) + EventBus.off('sip-settings-updated', this.updateSipDetails) }, methods: { t, + + updateSignalingServers(servers) { + this.hasSignalingServers = servers.length > 0 + }, + + updateSignalingDetails(signaling) { + this.hasFeatureJoinFeatures = signaling.hasFeature('join-features') + }, + + updateSipDetails(settings) { + this.hasSIPBridge = !!settings.sharedSecret + }, + + updateE2EECallsEnabled(value) { + this.loading = true + OCP.AppConfig.setValue('spreed', 'call_end_to_end_encryption', value ? '1' : '0', { + success: () => { + this.loading = false + }, + }) + }, + saveDefaultGroupNotification() { this.loadingDefaultGroupNotification = true @@ -139,6 +208,13 @@ h3 { font-weight: 600; } +small { + color: var(--color-warning); + border: 1px solid var(--color-warning); + border-radius: 16px; + padding: 0 9px; +} + .default-group-notification { min-width: 300px !important; } diff --git a/src/components/AdminSettings/SIPBridge.vue b/src/components/AdminSettings/SIPBridge.vue index 70c7be5460f..39f4455cf82 100644 --- a/src/components/AdminSettings/SIPBridge.vue +++ b/src/components/AdminSettings/SIPBridge.vue @@ -188,6 +188,7 @@ export default { dialOutEnabled: this.dialOutEnabled, sipGroups: this.sipGroups.map(group => group.id).join('_') } + EventBus.emit('sip-settings-updated', this.currentSetup) }, async saveSIPSettings() { diff --git a/src/components/AdminSettings/SignalingServer.vue b/src/components/AdminSettings/SignalingServer.vue index e356b471e69..6a2f8c4bf2a 100644 --- a/src/components/AdminSettings/SignalingServer.vue +++ b/src/components/AdminSettings/SignalingServer.vue @@ -77,6 +77,7 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadi import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import { EventBus } from '../../services/EventBus.ts' import { fetchSignalingSettings, getWelcomeMessage } from '../../services/signalingService.js' import { createConnection } from '../../utils/SignalingStandaloneTest.js' @@ -237,6 +238,7 @@ export default { { caption: t('spreed', 'WebSocket URL'), description: signalingTest.url }, { caption: t('spreed', 'Available features'), description: signalingTest.features.join(', ') }, ] + EventBus.emit('signaling-server-connected', signalingTest) } catch (exception) { console.error(exception) this.errorMessage = t('spreed', 'Error: Websocket connection failed. Check browser console') diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 44d9a7f79c1..5e26b7f2907 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -232,6 +232,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index 5a9ac82cbd9..7408cf07951 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -66,6 +66,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index 6630167c454..466b1efebdc 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -52,6 +52,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 3d136a1bec7..ce2b5875e90 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -147,6 +147,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index 35db1d924d6..bc8af5259b7 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -70,6 +70,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index c187b88d222..abb288bd4cd 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -178,6 +178,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 4bd99ce9082..2185d2998b9 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2015,6 +2015,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 08a7108ac0b..0cd42ea62b7 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1515,6 +1515,7 @@ export type components = { /** Format: int64 */ "max-duration": number; "blur-virtual-background": boolean; + "end-to-end-encryption": boolean; }; chat: { /** Format: int64 */ diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 3658a81c300..931c15775d4 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -136,6 +136,7 @@ public function testGetCapabilitiesGuest(): void { 'start-without-media' => false, 'max-duration' => 0, 'blur-virtual-background' => false, + 'end-to-end-encryption' => false, 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', @@ -275,6 +276,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'start-without-media' => false, 'max-duration' => 0, 'blur-virtual-background' => false, + 'end-to-end-encryption' => false, 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', diff --git a/tests/php/Controller/SignalingControllerTest.php b/tests/php/Controller/SignalingControllerTest.php index 1523388e0ba..e63791c1116 100644 --- a/tests/php/Controller/SignalingControllerTest.php +++ b/tests/php/Controller/SignalingControllerTest.php @@ -20,7 +20,6 @@ use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\BanService; -use OCA\Talk\Service\CertificateService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\RoomService; use OCA\Talk\Service\SessionService; @@ -65,7 +64,6 @@ class SignalingControllerTest extends TestCase { protected TalkSession&MockObject $session; protected \OCA\Talk\Signaling\Manager&MockObject $signalingManager; protected Manager|MockObject $manager; - protected CertificateService&MockObject $certificateService; protected ParticipantService&MockObject $participantService; protected SessionService&MockObject $sessionService; protected Messages&MockObject $messages; @@ -108,7 +106,6 @@ public function setUp(): void { $this->dbConnection = \OCP\Server::get(IDBConnection::class); $this->signalingManager = $this->createMock(\OCA\Talk\Signaling\Manager::class); $this->manager = $this->createMock(Manager::class); - $this->certificateService = $this->createMock(CertificateService::class); $this->participantService = $this->createMock(ParticipantService::class); $this->sessionService = $this->createMock(SessionService::class); $this->messages = $this->createMock(Messages::class); @@ -128,7 +125,6 @@ private function recreateSignalingController() { $this->signalingManager, $this->session, $this->manager, - $this->certificateService, $this->participantService, $this->sessionService, $this->dbConnection,