diff --git a/.github/workflows/test-cc.yml b/.github/workflows/test-cc.yml index 303fe97..e31c006 100644 --- a/.github/workflows/test-cc.yml +++ b/.github/workflows/test-cc.yml @@ -17,7 +17,7 @@ jobs: services: mysql: - image: mariadb + image: mysql:5.7 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: database diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 434cec2..1acd17a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: services: mysql: - image: mariadb + image: mysql:5.7 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: database @@ -61,7 +61,7 @@ jobs: services: mysql: - image: mariadb + image: mysql:5.7 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: database diff --git a/config/jaas.php b/config/jaas.php index e4dfb15..95332b1 100644 --- a/config/jaas.php +++ b/config/jaas.php @@ -6,5 +6,6 @@ 'iss' => env('JAAS_ISS', 'chat'), 'sub' => env('JAAS_SUB', ''), 'kid' => env('JAAS_KEY_ID', ''), - 'private_key' => env('JAAS_PRIVATE_KEY', '') + 'private_key' => env('JAAS_PRIVATE_KEY', ''), + 'recording' => env('JAAS_RECORDING', false), ]; diff --git a/config/jitsi.php b/config/jitsi.php index d0dae49..623103a 100644 --- a/config/jitsi.php +++ b/config/jitsi.php @@ -15,6 +15,6 @@ 'iss' => env('JAAS_ISS', 'chat'), 'sub' => env('JAAS_SUB', ''), 'kid' => env('JAAS_KEY_ID', ''), - 'private_key' => env('JAAS_PRIVATE_KEY', '') - + 'private_key' => env('JAAS_PRIVATE_KEY', ''), + 'recording' => env('JAAS_RECORDING', false), ]; diff --git a/src/Dto/RecordedVideoDataDto.php b/src/Dto/RecordedVideoDataDto.php new file mode 100644 index 0000000..ef446c7 --- /dev/null +++ b/src/Dto/RecordedVideoDataDto.php @@ -0,0 +1,37 @@ +participants = $participants; + $this->initiatorId = $initiatorId; + $this->durationSec = $durationSec; + $this->startTimestamp = $startTimestamp; + $this->endTimestamp = $endTimestamp; + $this->recordingSessionId = $recordingSessionId; + $this->preAuthenticatedLink = $preAuthenticatedLink; + $this->share = $share; + } + + public function getPreAuthenticatedLink(): string + { + return $this->preAuthenticatedLink; + } + + public function getStartTimestamp(): string + { + return $this->startTimestamp; + } +} diff --git a/src/Dto/RecordedVideoDto.php b/src/Dto/RecordedVideoDto.php new file mode 100644 index 0000000..5071e88 --- /dev/null +++ b/src/Dto/RecordedVideoDto.php @@ -0,0 +1,53 @@ +eventType = $eventType; + $this->timestamp = $timestamp; + $this->sessionId = $sessionId; + $this->fqn = $fqn; + $this->appId = $appId; + $this->data = new RecordedVideoDataDto(...$data); + } + + public function toArray(): array + { + return []; + } + + public static function instantiateFromRequest(Request $request): self + { + return new static( + $request->input('eventType'), + $request->input('timestamp'), + $request->input('sessionId'), + $request->input('fqn'), + $request->input('appId'), + $request->input('data'), + ); + } + public function getFqn(): string + { + return $this->fqn; + } + + public function getData(): RecordedVideoDataDto + { + return $this->data; + } +} diff --git a/src/Enum/JitsiEventsEnum.php b/src/Enum/JitsiEventsEnum.php new file mode 100644 index 0000000..a496a14 --- /dev/null +++ b/src/Enum/JitsiEventsEnum.php @@ -0,0 +1,10 @@ + JitsiService::class, JaasServiceContract::class => JaasService::class, + JitsiVideoServiceContract::class => JitsiVideoService::class, ]; /** @@ -27,6 +30,22 @@ class EscolaLmsJitsiServiceProvider extends ServiceProvider */ public function boot() { + $this->loadRoutesFrom(__DIR__ . '/routes.php'); + + if ($this->app->runningInConsole()) { + $this->bootForConsole(); + } + } + + protected function bootForConsole(): void + { + $this->publishes([ + __DIR__ . '/../config/jitsi.php' => config_path('jitsi.php'), + ], 'jitsi.config'); + + $this->publishes([ + __DIR__ . '/../config/jaas.php' => config_path('jaas.php'), + ], 'jaas.config'); } public function register() diff --git a/src/Exceptions/InvalidJitsiFqnException.php b/src/Exceptions/InvalidJitsiFqnException.php new file mode 100644 index 0000000..25a7c92 --- /dev/null +++ b/src/Exceptions/InvalidJitsiFqnException.php @@ -0,0 +1,26 @@ +json([ + 'data' => [ + 'code' => $this->getCode(), + 'message' => $this->getMessage() + ] + ], $this->getCode()); + } +} diff --git a/src/Exceptions/RecordedVideoSaveException.php b/src/Exceptions/RecordedVideoSaveException.php new file mode 100644 index 0000000..819cda0 --- /dev/null +++ b/src/Exceptions/RecordedVideoSaveException.php @@ -0,0 +1,26 @@ +json([ + 'data' => [ + 'code' => $this->getCode(), + 'message' => $this->getMessage() + ] + ], $this->getCode()); + } +} diff --git a/src/Helpers/StringHelper.php b/src/Helpers/StringHelper.php index 2c3c4f3..d9adb67 100644 --- a/src/Helpers/StringHelper.php +++ b/src/Helpers/StringHelper.php @@ -6,7 +6,7 @@ class StringHelper { - public static function convertToJitsiSlug(string $str, array $options = []): string + public static function convertToJitsiSlug(string $str, array $options = [], string|null $type = null, int|null $modelId = null, string|null $subModel = null): string { // Make sure string is in UTF-8 and strip invalid UTF-8 characters $str = mb_convert_encoding($str, 'UTF-8', mb_list_encodings()); @@ -43,7 +43,9 @@ public static function convertToJitsiSlug(string $str, array $options = []): str // Remove delimiter from ends $str = trim($str, $options['delimiter']); - return $options['lowercase'] ? mb_strtolower($str, 'UTF-8') : $str; + $str = $options['lowercase'] ? mb_strtolower($str, 'UTF-8') : $str; + $suffix = ($type ? "_{$type}" : '') . ($modelId ? "_{$modelId}" : '') . ($subModel ? "_{$subModel}" : ''); + return "{$str}{$suffix}"; } } diff --git a/src/Http/Controllers/JitsiApiController.php b/src/Http/Controllers/JitsiApiController.php new file mode 100644 index 0000000..6027b42 --- /dev/null +++ b/src/Http/Controllers/JitsiApiController.php @@ -0,0 +1,22 @@ +jitsiVideoService->recordedVideo(RecordedVideoDto::instantiateFromRequest($request)); + return $this->sendSuccess(__('Screen saved successfully')); + } +} diff --git a/src/Http/Requests/RecordedVideoRequest.php b/src/Http/Requests/RecordedVideoRequest.php new file mode 100644 index 0000000..79fc4a3 --- /dev/null +++ b/src/Http/Requests/RecordedVideoRequest.php @@ -0,0 +1,30 @@ + ['required', 'string', Rule::in(JitsiEventsEnum::RECORDING_UPLOADED)], + 'timestamp' => ['required', 'numeric'], + 'sessionId' => ['required', 'string'], + 'fqn' => ['required', 'string'], + 'appId' => ['required', 'string', Rule::in([config('jitsi.app_id')])], + 'data' => ['required', 'array'], + 'data.participants' => ['required', 'array'], + 'data.share' => ['nullable', 'boolean'], + 'data.initiatorId' => ['required', 'string'], + 'data.durationSec' => ['required', 'numeric'], + 'data.startTimestamp' => ['required', 'numeric'], + 'data.endTimestamp' => ['required', 'numeric'], + 'data.recordingSessionId' => ['required', 'string'], + 'data.preAuthenticatedLink' => ['required', 'string'], + ]; + } +} diff --git a/src/Providers/SettingsServiceProvider.php b/src/Providers/SettingsServiceProvider.php index 459ff2c..3d3e56c 100644 --- a/src/Providers/SettingsServiceProvider.php +++ b/src/Providers/SettingsServiceProvider.php @@ -28,6 +28,8 @@ public function register() AdministrableConfig::registerConfig(self::CONFIG_KEY . '.iss', ['nullable', 'string'], false); AdministrableConfig::registerConfig(self::CONFIG_KEY . '.kid', ['nullable', 'string'], false); AdministrableConfig::registerConfig(self::CONFIG_KEY . '.private_key', ['nullable', 'string'], false); + AdministrableConfig::registerConfig(self::CONFIG_KEY . '.sub', ['nullable', 'string', false]); + AdministrableConfig::registerConfig(self::CONFIG_KEY . '.recording', ['nullable', 'boolean', false]); } } } diff --git a/src/Services/Contracts/JitsiVideoServiceContract.php b/src/Services/Contracts/JitsiVideoServiceContract.php new file mode 100644 index 0000000..02343bf --- /dev/null +++ b/src/Services/Contracts/JitsiVideoServiceContract.php @@ -0,0 +1,10 @@ +getUserData($user, $isModerator); + $features = []; + if ($isModerator) { + $features = [ + 'livestreaming' => true, + 'outbound-call' => true, + 'sip-outbound-call' => false, + 'transcriptions' => true, + 'recording' => $this->config['recording'], + ]; + } $payload = [ 'aud' => $this->config['aud'], 'iss' => $this->config['iss'], @@ -26,6 +36,7 @@ public function generateJwt( 'sub' => $this->config['app_id'], 'room' => $room, 'context' => [ + 'features' => $features, 'user' => $userData, ], ]; diff --git a/src/Services/JitsiService.php b/src/Services/JitsiService.php index 935209f..24d6f3a 100644 --- a/src/Services/JitsiService.php +++ b/src/Services/JitsiService.php @@ -97,7 +97,7 @@ public function getChannelData( 'VideoConferenceModeStrategy', 'generateJwt', $user, - $channelName, + $channelDisplayName, $isModerator ); $url = StrategyHelper::useStrategyPattern( @@ -105,7 +105,7 @@ public function getChannelData( 'VideoConferenceModeStrategy', 'getUrl', $jwt, - $channelName + $channelDisplayName ); } if (!empty($jwt)) { diff --git a/src/Services/JitsiVideoService.php b/src/Services/JitsiVideoService.php new file mode 100644 index 0000000..b4ca4d0 --- /dev/null +++ b/src/Services/JitsiVideoService.php @@ -0,0 +1,44 @@ +getFqn(), $appId . '/')) { + throw new InvalidJitsiFqnException(); + } + + $folders = explode('_', Str::after($dto->getFqn(), $appId . '/')); + + if (count($folders) > 1) { + $path = ''; + for ($i = 1; $i < count($folders); $i++) { + $path .= "{$folders[$i]}/"; + } + } else { + $path = "jitsi/videos/{$folders[0]}/"; + } + + try { + $file = $this->fileService->getFileFromUrl($dto->getData()->getPreAuthenticatedLink()); + Storage::put($path . $dto->getData()->getStartTimestamp() . '.' . Str::afterLast($dto->getData()->getPreAuthenticatedLink(), '.'), $file); + } catch (Throwable $e) { + throw new RecordedVideoSaveException(); + } + } +} diff --git a/src/Strategies/VideoConferenceMode/JaasVideoConferenceModeStrategy.php b/src/Strategies/VideoConferenceMode/JaasVideoConferenceModeStrategy.php index 9e2fe1c..6bf9994 100644 --- a/src/Strategies/VideoConferenceMode/JaasVideoConferenceModeStrategy.php +++ b/src/Strategies/VideoConferenceMode/JaasVideoConferenceModeStrategy.php @@ -35,11 +35,11 @@ public function getUrl(array $data): string $jwt = $data[0] ?? ''; $channelName = $data[1] ?? ''; + $sub = strlen($this->config['sub']) > 0 ? $this->config['sub'] . '/' : ''; return 'https://' . $this->config['jaas_host'] . '/' . - $this->config['sub'] . - '/' . + $sub . $channelName . (!empty($jwt) ? "?jwt=" . $jwt : ""); } diff --git a/src/routes.php b/src/routes.php new file mode 100644 index 0000000..592d27c --- /dev/null +++ b/src/routes.php @@ -0,0 +1,6 @@ +toString(); + + $this->url = "https://localhost/test-app-id/{$recordingSessionId}/nagrywaniewideo_consultations_11_1728385200_2024-10-08-11-35-05.mp4"; + + $this->body = [ + "eventType" => "RECORDING_UPLOADED", + "sessionId" => Str::uuid()->toString(), + "timestamp" => 1728387319418, + "fqn" => "test-app-id/nagrywaniewideo_consultations_11_1728385200", + "idempotencyKey" => Str::uuid()->toString(), + "customerId" => "66d15721481e4cbbac651b1658dff55c", + "appId" => "test-app-id", + "data" => [ + "participants" => [ + [ + "name" => "admin.escolalms", + "id" => "auth0|66dffe0e58e320bf6575969c" + ] + ], + "share" => true, + "initiatorId" => "auth0|66dffe0e58e320bf6575969c", + "durationSec" => 2, + "startTimestamp" => 1728387309767, + "endTimestamp" => 1728387311997, + "recordingSessionId" => $recordingSessionId, + "preAuthenticatedLink" => $this->url, + ] + ]; + } + + public function testSaveRecordedVideoInvalidAppId(): void + { + Config::set('jitsi.app_id', 'test-app-id'); + + $this->body['appId'] = 'wrong-app-id'; + + $this->json('POST', '/api/jitsi/recorded-video', $this->body) + ->assertUnprocessable(); + } + + public function testSaveRecordedVideo(): void + { + Config::set('jitsi.app_id', 'test-app-id'); + + $this->mockGetFileContent('nagrywaniewideo_consultations_11_1728385200_2024-10-08-11-35-05.mp4'); + + $this->json('POST', '/api/jitsi/recorded-video', $this->body)->assertOk(); + + Storage::assertExists('consultations/11/1728385200/1728387309767.mp4'); + } + + public function testSaveRecordedVideoNoSuffix(): void + { + Config::set('jitsi.app_id', 'test-app-id'); + + $this->body['fqn'] = "test-app-id/nagrywaniewideo"; + + $this->mockGetFileContent('nagrywaniewideo_2024-10-08-11-35-05.mp4'); + + $this->json('POST', '/api/jitsi/recorded-video', $this->body)->assertOk(); + + Storage::assertExists('jitsi/videos/nagrywaniewideo/1728387309767.mp4'); + } + + public function testSaveRecordedVideoDifferentSuffix(): void + { + Config::set('jitsi.app_id', 'test-app-id'); + + $this->body['fqn'] = "test-app-id/nagrywaniewideo_webinar_20"; + + $this->mockGetFileContent('nagrywaniewideo_webinar_20_2024-10-08-11-35-05.mp4'); + + $this->json('POST', '/api/jitsi/recorded-video', $this->body)->assertOk(); + + Storage::assertExists('webinar/20/1728387309767.mp4'); + } + + private function mockGetFileContent(string $filename): void + { + $this->mock(FileService::class, function ($mock) use ($filename) { + $mock->shouldReceive('getFileFromUrl')->with($this->url)->andReturn(UploadedFile::fake()->create($filename, 50, 'video/mp4')); + }); + } +} diff --git a/tests/Api/SettingsApiTest.php b/tests/Api/SettingsApiTest.php index e8ebdfb..f08a08f 100644 --- a/tests/Api/SettingsApiTest.php +++ b/tests/Api/SettingsApiTest.php @@ -99,107 +99,105 @@ public function testAdministrableConfigApi(): void $this->response->assertOk(); $this->response->assertJsonFragment([ - $configKey => [ - 'package_status' => [ - 'full_key' => "$configKey.package_status", - 'key' => 'package_status', - 'rules' => [ - 'nullable', - 'string', - 'in:' . implode(',', PackageStatusEnum::getValues()), - ], - 'public' => false, - 'readonly' => false, - 'value' => $packageStatus, + 'package_status' => [ + 'full_key' => "$configKey.package_status", + 'key' => 'package_status', + 'rules' => [ + 'nullable', + 'string', + 'in:' . implode(',', PackageStatusEnum::getValues()), ], - 'jitsi_host' => [ - 'full_key' => "$configKey.jitsi_host", - 'key' => 'jitsi_host', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => true, - 'readonly' => false, - 'value' => $host, + 'public' => false, + 'readonly' => false, + 'value' => $packageStatus, + ], + 'jitsi_host' => [ + 'full_key' => "$configKey.jitsi_host", + 'key' => 'jitsi_host', + 'rules' => [ + 'nullable', + 'string' ], - 'jaas_host' => [ - 'full_key' => "$configKey.jaas_host", - 'key' => 'jaas_host', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => true, - 'readonly' => false, - 'value' => $host, + 'public' => true, + 'readonly' => false, + 'value' => $host, + ], + 'jaas_host' => [ + 'full_key' => "$configKey.jaas_host", + 'key' => 'jaas_host', + 'rules' => [ + 'nullable', + 'string' ], - 'aud' => [ - 'full_key' => "$configKey.aud", - 'key' => 'aud', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => false, - 'readonly' => false, - 'value' => $aud, + 'public' => true, + 'readonly' => false, + 'value' => $host, + ], + 'aud' => [ + 'full_key' => "$configKey.aud", + 'key' => 'aud', + 'rules' => [ + 'nullable', + 'string' ], - 'kid' => [ - 'full_key' => "$configKey.kid", - 'key' => 'kid', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => false, - 'readonly' => false, - 'value' => $kid, + 'public' => false, + 'readonly' => false, + 'value' => $aud, + ], + 'kid' => [ + 'full_key' => "$configKey.kid", + 'key' => 'kid', + 'rules' => [ + 'nullable', + 'string' ], - 'iss' => [ - 'full_key' => "$configKey.iss", - 'key' => 'iss', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => false, - 'readonly' => false, - 'value' => $iss, + 'public' => false, + 'readonly' => false, + 'value' => $kid, + ], + 'iss' => [ + 'full_key' => "$configKey.iss", + 'key' => 'iss', + 'rules' => [ + 'nullable', + 'string' ], - 'app_id' => [ - 'full_key' => "$configKey.app_id", - 'key' => 'app_id', - 'rules' => [ - 'nullable', - 'string', - ], - 'public' => false, - 'value' => $appId, - 'readonly' => false, + 'public' => false, + 'readonly' => false, + 'value' => $iss, + ], + 'app_id' => [ + 'full_key' => "$configKey.app_id", + 'key' => 'app_id', + 'rules' => [ + 'nullable', + 'string', ], - 'secret' => [ - 'full_key' => "$configKey.secret", - 'key' => 'secret', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => false, - 'readonly' => false, - 'value' => $secret, + 'public' => false, + 'value' => $appId, + 'readonly' => false, + ], + 'secret' => [ + 'full_key' => "$configKey.secret", + 'key' => 'secret', + 'rules' => [ + 'nullable', + 'string' ], - 'private_key' => [ - 'full_key' => "$configKey.private_key", - 'key' => 'private_key', - 'rules' => [ - 'nullable', - 'string' - ], - 'public' => false, - 'readonly' => false, - 'value' => $privateKey, + 'public' => false, + 'readonly' => false, + 'value' => $secret, + ], + 'private_key' => [ + 'full_key' => "$configKey.private_key", + 'key' => 'private_key', + 'rules' => [ + 'nullable', + 'string' ], + 'public' => false, + 'readonly' => false, + 'value' => $privateKey, ], ]); @@ -211,10 +209,8 @@ public function testAdministrableConfigApi(): void $this->response->assertOk(); $this->response->assertJsonFragment([ - $configKey => [ - 'jitsi_host' => $host, - 'jaas_host' => $host, - ], + 'jitsi_host' => $host, + 'jaas_host' => $host, ]); } }