diff --git a/.env.development.example b/.env.development.example index 40c3756a..d498e931 100644 --- a/.env.development.example +++ b/.env.development.example @@ -1,3 +1,5 @@ +# More info here: https://docs.movary.org/configuration/ + # Environment ENV=development USER_ID=1000 @@ -21,6 +23,9 @@ DATABASE_MYSQL_ROOT_PASSWORD=movary TMDB_API_KEY= TMDB_ENABLE_IMAGE_CACHING=0 +# Plex +#PLEX_IDENTIFIER= + # Logging LOG_LEVEL=debug LOG_ENABLE_STACKTRACE=1 diff --git a/.env.production.example b/.env.production.example index 83634768..97fa099f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,3 +1,5 @@ +# More info here: https://docs.movary.org/configuration/ + # Environment ENV=production TIMEZONE="Europe/Berlin" @@ -17,6 +19,9 @@ DATABASE_MYSQL_CHARSET=utf8mb4 TMDB_API_KEY= TMDB_ENABLE_IMAGE_CACHING=1 +# Plex +#PLEX_IDENTIFIER= + # Logging LOG_LEVEL=warning diff --git a/db/migrations/mysql/20230627162519_AddPlexOAuthColumnsToUserTable.php b/db/migrations/mysql/20230627162519_AddPlexOAuthColumnsToUserTable.php new file mode 100644 index 00000000..68441312 --- /dev/null +++ b/db/migrations/mysql/20230627162519_AddPlexOAuthColumnsToUserTable.php @@ -0,0 +1,32 @@ +execute( + <<execute( + <<execute( + <<execute( + 'INSERT INTO `tmp_user` ( + `id`, + `email`, + `name`, + `password`, + `is_admin`, + `dashboard_visible_rows`, + `dashboard_extended_rows`, + `dashboard_order_rows`, + `privacy_level`, + `date_format_id`, + `trakt_user_name`, + `plex_webhook_uuid`, + `jellyfin_webhook_uuid`, + `emby_webhook_uuid`, + `trakt_client_id`, + `jellyfin_scrobble_views`, + `emby_scrobble_views`, + `plex_scrobble_views`, + `plex_scrobble_ratings`, + `watchlist_automatic_removal_enabled`, + `core_account_changes_disabled`, + `created_at` + ) SELECT + `id`, + `email`, + `name`, + `password`, + `is_admin`, + `dashboard_visible_rows`, + `dashboard_extended_rows`, + `dashboard_order_rows`, + `privacy_level`, + `date_format_id`, + `trakt_user_name`, + `plex_webhook_uuid`, + `jellyfin_webhook_uuid`, + `emby_webhook_uuid`, + `trakt_client_id`, + `jellyfin_scrobble_views`, + `emby_scrobble_views`, + `plex_scrobble_views`, + `plex_scrobble_ratings`, + `watchlist_automatic_removal_enabled`, + `core_account_changes_disabled`, + `created_at` FROM user', + ); + $this->execute('DROP TABLE `user`'); + $this->execute('ALTER TABLE `tmp_user` RENAME TO `user`'); + } + + public function up() : void + { + $this->execute( + <<execute( + 'INSERT INTO `tmp_user` ( + `id`, + `email`, + `name`, + `password`, + `is_admin`, + `dashboard_visible_rows`, + `dashboard_extended_rows`, + `dashboard_order_rows`, + `privacy_level`, + `date_format_id`, + `trakt_user_name`, + `plex_webhook_uuid`, + `jellyfin_webhook_uuid`, + `emby_webhook_uuid`, + `trakt_client_id`, + `jellyfin_scrobble_views`, + `emby_scrobble_views`, + `plex_scrobble_views`, + `plex_scrobble_ratings`, + `watchlist_automatic_removal_enabled`, + `core_account_changes_disabled`, + `created_at` + ) SELECT * FROM user', + ); + $this->execute('DROP TABLE `user`'); + $this->execute('ALTER TABLE `tmp_user` RENAME TO `user`'); + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 8163ee3e..1da1d60a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,16 +15,17 @@ The `Web UI` column is set to yes if an environment variable can alternatively b ### General -| NAME | DEFAULT VALUE | INFO | Web UI | -|:--------------------------------------------|:-----------------:|:------------------------------------------------------------------------|:------:| -| `TMDB_API_KEY` | - | **Required** (get key [here](https://www.themoviedb.org/settings/api)) | yes | -| `APPLICATION_URL` | - | Public base url of the application (e.g. `htttp://localhost`) | yes | -| `TMDB_ENABLE_IMAGE_CACHING` | `0` | More info [here](features/tmdb-data.md#image-cache) | | -| `ENABLE_REGISTRATION` | `0` | Enables public user registration | | -| `MIN_RUNTIME_IN_SECONDS_FOR_JOB_PROCESSING` | `15` | Minimum time between background jobs processing | | -| `TIMEZONE` | `"Europe/Berlin"` | Supported timezones [here](https://www.php.net/manual/en/timezones.php) | | -| `DEFAULT_LOGIN_EMAIL` | - | Email address to always autofill on login page | | -| `DEFAULT_LOGIN_PASSWORD` | - | Password to always autofill on login page | | +| NAME | DEFAULT VALUE | INFO | Web UI | +|:--------------------------------------------|:-----------------:|:-------------------------------------------------------------------------------|:------:| +| `TMDB_API_KEY` | - | **Required** (get key [here](https://www.themoviedb.org/settings/api)) | yes | +| `APPLICATION_URL` | - | Public base url of the application (e.g. `htttp://localhost`) | yes | +| `TMDB_ENABLE_IMAGE_CACHING` | `0` | More info [here](features/tmdb-data.md#image-cache) | | +| `PLEX_IDENTIFIER` | - | Required for Plex Authentication. Generate with e.g. `openssl rand -base64 32` | | +| `ENABLE_REGISTRATION` | `0` | Enables public user registration | | +| `MIN_RUNTIME_IN_SECONDS_FOR_JOB_PROCESSING` | `15` | Minimum time between background jobs processing | | +| `TIMEZONE` | `"Europe/Berlin"` | Supported timezones [here](https://www.php.net/manual/en/timezones.php) | | +| `DEFAULT_LOGIN_EMAIL` | - | Email address to always autofill on login page | | +| `DEFAULT_LOGIN_PASSWORD` | - | Password to always autofill on login page | | ### Database diff --git a/public/css/bootstrap-icons-1.10.2.css b/public/css/bootstrap-icons-1.10.2.css index d68b8292..4a62d6d0 100644 --- a/public/css/bootstrap-icons-1.10.2.css +++ b/public/css/bootstrap-icons-1.10.2.css @@ -42,3 +42,4 @@ url("../fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wo .bi-x-circle-fill::before { content: "\f622"; } .bi-chevron-expand::before { content: "\f283"; } .bi-chevron-contract::before { content: "\f27d"; } +.bi-question-lg::before { content: "\f64e"; } diff --git a/public/js/settings-integration-plex.js b/public/js/settings-integration-plex.js index 23dca9f4..1e4a91a5 100644 --- a/public/js/settings-integration-plex.js +++ b/public/js/settings-integration-plex.js @@ -91,3 +91,135 @@ async function updateScrobbleOptions() { addAlert('alertWebhookOptionsDiv', 'Could not update scrobble options', 'danger') }); } + +async function authenticateWithPlex() { + const response = await fetch( + '/settings/plex/authentication-url', + {signal: AbortSignal.timeout(4000)} + ).catch(function (error) { + document.getElementById('alertPlexServerUrlLoadingSpinner').classList.add('d-none') + + console.log(error) + addAlert('alertPlexServerUrlDiv', 'Authentication did not work', 'danger') + }); + + if (!response.ok) { + if (response.status === 400) { + addAlert('alertPlexAuthenticationDiv', await response.text(), 'danger') + + return + } + + addAlert('alertPlexAuthenticationDiv', 'Authentication did not work', 'danger') + + return + } + + const data = await response.json() + + location.href = data.authenticationUrl; +} + +async function removePlexAuthentication() { + const response = await fetch( + '/settings/plex/logout', + {signal: AbortSignal.timeout(4000)} + ).catch(function (error) { + console.log(error) + + addAlert('alertPlexAuthenticationDiv', 'Could not remove authentication', 'danger') + }); + + if (!response.ok) { + addAlert('alertPlexAuthenticationDiv', 'Could not remove authentication', 'danger') + + return + } + + document.getElementById('plexServerUrlInput').disabled = true + document.getElementById('plexServerUrlInput').value = '' + document.getElementById('saveServerUrlButton').disabled = true + document.getElementById('verifyServerUrlButton').disabled = true + + document.getElementById('authenticateWithPlexDiv').classList.remove('d-none') + document.getElementById('removeAuthenticationWithPlexDiv').classList.add('d-none') + + addAlert('alertPlexAuthenticationDiv', 'Plex authentication was removed', 'success') +} + +async function savePlexServerUrl() { + const response = await fetch('/settings/plex/server-url-save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + 'plexServerUrl': document.getElementById('plexServerUrlInput').value, + }) + }).then(async function (response) { + return {'status': response.status, 'message': await response.text()}; + }).then(function (data) { + if (data.status === 200) { + addAlert('alertPlexServerUrlDiv', 'Server URL was updated', 'success') + + return + } + + if (data.status === 400) { + addAlert('alertPlexServerUrlDiv', data.message, 'danger') + + return + } + + addAlert('alertPlexServerUrlDiv', 'Server URL could not be updated', 'danger') + }).catch(function (error) { + document.getElementById('alertPlexServerUrlLoadingSpinner').classList.add('d-none') + + console.log(error) + addAlert('alertPlexServerUrlDiv', 'Server URL could not be updated', 'danger') + }); +} + +async function verifyPlexServerUrl() { + document.getElementById('alertPlexServerUrlLoadingSpinner').classList.remove('d-none') + removeAlert('alertPlexServerUrlDiv') + + const response = await fetch('/settings/plex/server-url-verify', { + signal: AbortSignal.timeout(4000), + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + 'plexServerUrl': document.getElementById('plexServerUrlInput').value, + }) + }).then(async function (response) { + document.getElementById('alertPlexServerUrlLoadingSpinner').classList.add('d-none') + + return {'status': response.status, 'message': await response.json()}; + }).then(function (data) { + if (data.status === 200 && data.message === true) { + addAlert('alertPlexServerUrlDiv', 'Connection test successful', 'success') + + return + } + + if (data.status === 400) { + addAlert('alertPlexServerUrlDiv', data.message, 'danger') + + return + } + + addAlert('alertPlexServerUrlDiv', 'Connection test failed', 'danger') + }).catch(function (error) { + document.getElementById('alertPlexServerUrlLoadingSpinner').classList.add('d-none') + + console.log(error) + addAlert('alertPlexServerUrlDiv', 'Connection test failed', 'danger') + }); +} + +document.getElementById('verifyServerUrlButton').disabled = document.getElementById('plexServerUrlInput').value === '' +document.getElementById('plexServerUrlInput').addEventListener('input', function (e) { + document.getElementById('verifyServerUrlButton').disabled = e.target.value === '' +}); diff --git a/settings/routes.php b/settings/routes.php index 33a54df0..aa37187b 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -223,6 +223,31 @@ '/settings/integrations/plex', [\Movary\HttpController\SettingsController::class, 'renderPlexPage'], ); + $routeCollector->addRoute( + 'GET', + '/settings/plex/logout', + [\Movary\HttpController\PlexController::class, 'removePlexAccessTokens'], + ); + $routeCollector->addRoute( + 'POST', + '/settings/plex/server-url-save', + [\Movary\HttpController\PlexController::class, 'savePlexServerUrl'], + ); + $routeCollector->addRoute( + 'POST', + '/settings/plex/server-url-verify', + [\Movary\HttpController\PlexController::class, 'verifyPlexServerUrl'], + ); + $routeCollector->addRoute( + 'GET', + '/settings/plex/authentication-url', + [\Movary\HttpController\PlexController::class, 'generatePlexAuthenticationUrl'], + ); + $routeCollector->addRoute( + 'GET', + '/settings/plex/callback', + [\Movary\HttpController\PlexController::class, 'processPlexCallback'], + ); $routeCollector->addRoute( 'POST', '/settings/plex', diff --git a/src/Api/Plex/Dto/PlexAccessToken.php b/src/Api/Plex/Dto/PlexAccessToken.php new file mode 100644 index 00000000..ef231432 --- /dev/null +++ b/src/Api/Plex/Dto/PlexAccessToken.php @@ -0,0 +1,21 @@ +plexAccessToken; + } +} diff --git a/src/Api/Plex/Dto/PlexAccount.php b/src/Api/Plex/Dto/PlexAccount.php new file mode 100644 index 00000000..96a9b4e7 --- /dev/null +++ b/src/Api/Plex/Dto/PlexAccount.php @@ -0,0 +1,27 @@ +plexId; + } + + public function getPlexUsername() : string + { + return $this->username; + } +} diff --git a/src/Api/Plex/Exception/PlexAuthenticationError.php b/src/Api/Plex/Exception/PlexAuthenticationError.php new file mode 100644 index 00000000..30267f4a --- /dev/null +++ b/src/Api/Plex/Exception/PlexAuthenticationError.php @@ -0,0 +1,13 @@ + $temporaryPlexClientCode, + ]; + + $relativeUrl = RelativeUrl::create('/pins/' . $plexPinId); + + try { + $plexRequest = $this->plexTvClient->get($relativeUrl, $headers); + } catch (PlexNotFoundError) { + $this->logger->error('Plex pin does not exist'); + + return null; + } + + return PlexAccessToken::create($plexRequest['authToken']); + } + + public function findPlexAccount(PlexAccessToken $plexAccessToken) : ?PlexAccount + { + $headers = [ + 'X-Plex-Token' => $plexAccessToken->getPlexAccessTokenAsString() + ]; + + $relativeUrl = RelativeUrl::create('/user'); + + try { + $accountData = $this->plexTvClient->get($relativeUrl, $headers); + } catch (PlexAuthenticationError) { + $this->logger->error('Plex access token is invalid'); + + return null; + } + + return PlexAccount::create((int)$accountData['id'], $accountData['username']); + } + + /** + * 1. A HTTP POST request will be sent to the Plex API, requesting a client ID and a client Code. The code is usually valid for 1800 seconds or 15 minutes. After 15min, a new code has to be requested. + * 2. Both the pin ID and code will be stored in the database for later use in the plexCallback controller + * 3. Based on the info returned by the Plex API, a new url will be generated, which looks like this: `https://app.plex.tv/auth#?clientID=&code=&context[device][product]=&forwardUrl=` + * 4. The URL is returned to the settingsController + */ + public function generatePlexAuthenticationUrl() : string + { + $relativeUrl = RelativeUrl::create('/pins'); + + $plexAuthenticationData = $this->plexTvClient->sendPostRequest($relativeUrl); + + $this->userApi->updatePlexClientId($this->authenticationService->getCurrentUserId(), $plexAuthenticationData['id']); + $this->userApi->updateTemporaryPlexClientCode($this->authenticationService->getCurrentUserId(), $plexAuthenticationData['code']); + + $plexAppName = $plexAuthenticationData['product']; + $plexClientIdentifier = $plexAuthenticationData['clientIdentifier']; + $plexTemporaryClientCode = $plexAuthenticationData['code']; + + $applicationUrl = $this->serverSettings->requireApplicationUrl(); + + $getParameters = [ + 'clientID' => $plexClientIdentifier, + 'code' => (string)$plexTemporaryClientCode, + 'context[device][product]' => $plexAppName, + 'forwardUrl' => (string)Url::createFromString(trim($applicationUrl, '/') . '/settings/plex/callback'), + ]; + + return self::BASE_URL . http_build_query($getParameters); + } + + public function verifyPlexUrl(int $userId, Url $url) : bool + { + $query = [ + 'X-Plex-Token' => $this->userApi->fetchUser($userId)->getPlexAccessToken() + ]; + + try { + $this->localClient->sendGetRequest($url, $query); + + return true; + } catch (PlexAuthenticationError) { + $this->logger->error('Plex access token is invalid'); + + return false; + } + } +} diff --git a/src/Api/Plex/PlexLocalServerClient.php b/src/Api/Plex/PlexLocalServerClient.php new file mode 100644 index 00000000..435d8e13 --- /dev/null +++ b/src/Api/Plex/PlexLocalServerClient.php @@ -0,0 +1,67 @@ + 'application/json' + ]; + + public function __construct( + private readonly HttpClient $httpClient, + private readonly ServerSettings $serverSettings, + ) { + } + + /** + * @psalm-suppress PossiblyUndefinedVariable + */ + public function sendGetRequest( + Url $requestUrl, + ?array $customQuery = [], + ) : array { + $requestOptions = [ + 'form_params' => $this->generateDefaultFormData(), + 'query' => $customQuery, + 'headers' => self::DEFAULT_HEADERS, + 'connect_timeout' => 2, + ]; + + try { + $response = $this->httpClient->request('GET', (string)$requestUrl, $requestOptions); + } catch (ClientException $e) { + match (true) { + $e->getCode() === 401 => throw PlexAuthenticationError::create(), + $e->getCode() === 404 => throw PlexNotFoundError::create($requestUrl), + default => throw new RuntimeException('Plex API error. Response message: ' . $e->getMessage()), + }; + } + + return Json::decode((string)$response->getBody()); + } + + private function generateDefaultFormData() : array + { + return [ + 'X-Plex-Client-Identifier' => $this->serverSettings->requirePlexIdentifier(), + 'X-Plex-Product' => self::APP_NAME, + 'X-Plex-Product-Version' => $this->serverSettings->getApplicationVersion(), + 'X-Plex-Platform' => php_uname('s'), + 'X-Plex-Platform-Version' => php_uname('v'), + 'X-Plex-Provides' => 'Controller', + 'strong' => 'true' + ]; + } +} diff --git a/src/Api/Plex/PlexTvClient.php b/src/Api/Plex/PlexTvClient.php new file mode 100644 index 00000000..e4fdbb93 --- /dev/null +++ b/src/Api/Plex/PlexTvClient.php @@ -0,0 +1,97 @@ + 'application/json' + ]; + + public function __construct( + private readonly HttpClient $httpClient, + private readonly ServerSettings $serverSettings, + ) { + } + + /** + * @psalm-suppress PossiblyUndefinedVariable + */ + public function get( + RelativeUrl $relativeUrl, + array $headers = [], + ) : array { + $requestUrl = Url::createFromString(self::BASE_URL)->appendRelativeUrl($relativeUrl); + $requestOptions = [ + 'form_params' => $this->generateDefaultFormData(), + 'headers' => array_merge(self::DEFAULT_HEADERS, $headers) + ]; + + try { + $response = $this->httpClient->request('GET', (string)$requestUrl, $requestOptions); + } catch (ClientException $e) { + match (true) { + $e->getCode() === 401 => throw PlexAuthenticationError::create(), + $e->getCode() === 404 => throw PlexNotFoundError::create($requestUrl), + + default => throw new RuntimeException('Plex API error. Response message: ' . $e->getMessage()), + }; + } + + return Json::decode((string)$response->getBody()); + } + + /** + * @psalm-suppress PossiblyUndefinedVariable + */ + public function sendPostRequest(RelativeUrl $relativeUrl) : array + { + $requestUrl = Url::createFromString(self::BASE_URL)->appendRelativeUrl($relativeUrl); + $requestOptions = [ + 'form_params' => $this->generateDefaultFormData(), + 'headers' => self::DEFAULT_HEADERS + ]; + + try { + $response = $this->httpClient->request('POST', (string)$requestUrl, $requestOptions); + } catch (ClientException $e) { + match (true) { + $e->getCode() === 401 => throw PlexAuthenticationError::create(), + $e->getCode() === 404 => throw PlexNotFoundError::create($requestUrl), + + default => throw new RuntimeException('Plex API error. Response message: ' . $e->getMessage()), + }; + } + + return Json::decode((string)$response->getBody()); + } + + private function generateDefaultFormData() : array + { + $plexIdentifier = $this->serverSettings->requirePlexIdentifier(); + + return [ + 'X-Plex-Client-Identifier' => $plexIdentifier, + 'X-Plex-Product' => self::APP_NAME, + 'X-Plex-Product-Version' => $plexIdentifier, + 'X-Plex-Platform' => php_uname('s'), + 'X-Plex-Platform-Version' => php_uname('v'), + 'X-Plex-Provides' => 'Controller', + 'strong' => 'true' + ]; + } +} diff --git a/src/Domain/User/UserApi.php b/src/Domain/User/UserApi.php index c85cbfaf..7da0a714 100644 --- a/src/Domain/User/UserApi.php +++ b/src/Domain/User/UserApi.php @@ -2,7 +2,9 @@ namespace Movary\Domain\User; +use Movary\Api\Plex\Dto\PlexAccessToken; use Movary\Domain\User\Service\Validator; +use Movary\ValueObject\Url; use Ramsey\Uuid\Uuid; use RuntimeException; @@ -90,19 +92,32 @@ public function fetchUser(int $userId) : UserEntity return $user; } - public function findEmbyWebhookId(int $userId) : ?string + public function findPlexAccessToken(int $userId) : ?PlexAccessToken { - return $this->repository->findEmbyWebhookId($userId); + $plexAccessToken = $this->repository->findPlexAccessToken($userId); + + if ($plexAccessToken === null) { + return null; + } + + return PlexAccessToken::create($plexAccessToken); } - public function findJellyfinWebhookId(int $userId) : ?string + public function findPlexClientId(int $userId) : ?string { - return $this->repository->findJellyfinWebhookId($userId); + return $this->repository->findPlexClientId($userId); } - public function findPlexWebhookId(int $userId) : ?string + public function findPlexServerUrl(int $userId) : ?Url { - return $this->repository->findPlexWebhookId($userId); + $url = $this->repository->findPlexServerUrl($userId); + + return $url !== null ? Url::createFromString($url) : null; + } + + public function findTemporaryPlexCode(int $userId) : ?string + { + return $this->repository->findTemporaryPlexCode($userId); } public function findTraktClientId(int $userId) : ?string @@ -249,16 +264,41 @@ public function updatePassword(int $userId, string $newPassword) : void $this->repository->updatePassword($userId, $passwordHash); } + public function updatePlexAccessToken(int $userId, ?string $plexAccessToken) : void + { + $this->repository->updatePlexAccessToken($userId, $plexAccessToken); + } + + public function updatePlexAccountId(int $userId, ?string $plexAccountId) : void + { + $this->repository->updatePlexAccountId($userId, $plexAccountId); + } + + public function updatePlexClientId(int $userId, ?int $plexClientId) : void + { + $this->repository->updatePlexClientId($userId, $plexClientId); + } + public function updatePlexScrobblerOptions(int $userId, bool $scrobbleWatches, bool $scrobbleRatings) : void { $this->repository->updatePlexScrobblerOptions($userId, $scrobbleWatches, $scrobbleRatings); } + public function updatePlexServerUrl(int $userId, ?Url $plexServerUrl) : void + { + $this->repository->updatePlexServerurl($userId, $plexServerUrl); + } + public function updatePrivacyLevel(int $userId, int $privacyLevel) : void { $this->repository->updatePrivacyLevel($userId, $privacyLevel); } + public function updateTemporaryPlexClientCode(int $userId, ?string $plexClientCode) : void + { + $this->repository->updateTemporaryPlexClientCode($userId, $plexClientCode); + } + public function updateTraktClientId(int $userId, ?string $traktClientId) : void { $this->repository->updateTraktClientId($userId, $traktClientId); diff --git a/src/Domain/User/UserEntity.php b/src/Domain/User/UserEntity.php index b0f97e20..f17f33b7 100644 --- a/src/Domain/User/UserEntity.php +++ b/src/Domain/User/UserEntity.php @@ -20,6 +20,7 @@ private function __construct( private readonly ?string $embyWebhookUuid, private readonly ?string $traktUserName, private readonly ?string $traktClientId, + private readonly ?string $plexAccessToken, private readonly bool $jellyfinScrobbleWatches, private readonly bool $embyScrobbleWatches, private readonly bool $plexScrobbleWatches, @@ -46,6 +47,7 @@ public static function createFromArray(array $data) : self $data['emby_webhook_uuid'], $data['trakt_user_name'], $data['trakt_client_id'], + $data['plex_access_token'], (bool)$data['jellyfin_scrobble_views'], (bool)$data['emby_scrobble_views'], (bool)$data['plex_scrobble_views'], @@ -99,6 +101,11 @@ public function getPasswordHash() : string return $this->passwordHash; } + public function getPlexAccessToken() : ?string + { + return $this->plexAccessToken; + } + public function getPlexWebhookId() : ?string { return $this->plexWebhookUuid; diff --git a/src/Domain/User/UserRepository.php b/src/Domain/User/UserRepository.php index 54cfe632..96086f67 100644 --- a/src/Domain/User/UserRepository.php +++ b/src/Domain/User/UserRepository.php @@ -4,6 +4,7 @@ use Doctrine\DBAL\Connection; use Movary\ValueObject\DateTime; +use Movary\ValueObject\Url; use RuntimeException; class UserRepository @@ -153,48 +154,48 @@ public function findAuthTokenExpirationDate(string $token) : ?DateTime return DateTime::createFromString($expirationDate); } - public function findDateFormatId(int $userId) : ?int + public function findPlexAccessToken(int $userId) : ?string { - $dateFormat = $this->dbConnection->fetchOne('SELECT `date_format_id` FROM `user` WHERE `id` = ?', [$userId]); + $plexAccessToken = $this->dbConnection->fetchOne('SELECT `plex_access_token` FROM `user` WHERE `id` = ?', [$userId]); - if ($dateFormat === false) { + if ($plexAccessToken === false) { return null; } - return (int)$dateFormat; + return $plexAccessToken; } - public function findEmbyWebhookId(int $userId) : ?string + public function findPlexClientId(int $userId) : ?string { - $embyWebhookId = $this->dbConnection->fetchOne('SELECT `emby_webhook_uuid` FROM `user` WHERE `id` = ?', [$userId]); + $plexClientId = $this->dbConnection->fetchOne('SELECT `plex_client_id` FROM `user` WHERE `id` = ?', [$userId]); - if ($embyWebhookId === false) { + if ($plexClientId === false) { return null; } - return $embyWebhookId; + return $plexClientId; } - public function findJellyfinWebhookId(int $userId) : ?string + public function findPlexServerUrl(int $userId) : ?string { - $jellyfinWebhookId = $this->dbConnection->fetchOne('SELECT `jellyfin_webhook_uuid` FROM `user` WHERE `id` = ?', [$userId]); + $plexServerUrl = $this->dbConnection->fetchOne('SELECT `plex_server_url` FROM `user` WHERE `id` = ?', [$userId]); - if ($jellyfinWebhookId === false) { + if ($plexServerUrl === false) { return null; } - return $jellyfinWebhookId; + return $plexServerUrl; } - public function findPlexWebhookId(int $userId) : ?string + public function findTemporaryPlexCode(int $userId) : ?string { - $plexWebhookId = $this->dbConnection->fetchOne('SELECT `plex_webhook_uuid` FROM `user` WHERE `id` = ?', [$userId]); + $plexCode = $this->dbConnection->fetchOne('SELECT `plex_client_temporary_code` FROM `user` WHERE `id` = ?', [$userId]); - if ($plexWebhookId === false) { + if ($plexCode === false) { return null; } - return $plexWebhookId; + return $plexCode; } public function findTraktClientId(int $userId) : ?string @@ -476,6 +477,45 @@ public function updatePassword(int $userId, string $passwordHash) : void ); } + public function updatePlexAccessToken(int $userId, ?string $accessToken) : void + { + $this->dbConnection->update( + 'user', + [ + 'plex_access_token' => $accessToken + ], + [ + 'id' => $userId + ], + ); + } + + public function updatePlexAccountId(int $userId, ?string $accountId) : void + { + $this->dbConnection->update( + 'user', + [ + 'plex_account_id' => $accountId + ], + [ + 'id' => $userId + ], + ); + } + + public function updatePlexClientId(int $userId, ?int $plexClientId) : void + { + $this->dbConnection->update( + 'user', + [ + 'plex_client_id' => $plexClientId + ], + [ + 'id' => $userId + ], + ); + } + public function updatePlexScrobblerOptions(int $userId, bool $scrobbleWatches, bool $scrobbleRatings) : void { $this->dbConnection->update( @@ -490,6 +530,19 @@ public function updatePlexScrobblerOptions(int $userId, bool $scrobbleWatches, b ); } + public function updatePlexServerUrl(int $userId, ?Url $plexServerUrl) : void + { + $this->dbConnection->update( + 'user', + [ + 'plex_server_url' => (string)$plexServerUrl + ], + [ + 'id' => $userId + ], + ); + } + public function updatePrivacyLevel(int $userId, int $privacyLevel) : void { $this->dbConnection->update( @@ -503,6 +556,19 @@ public function updatePrivacyLevel(int $userId, int $privacyLevel) : void ); } + public function updateTemporaryPlexClientCode(int $userId, ?string $plexClientCode) : void + { + $this->dbConnection->update( + 'user', + [ + 'plex_client_temporary_code' => $plexClientCode + ], + [ + 'id' => $userId + ], + ); + } + public function updateTraktClientId(int $userId, ?string $traktClientId) : void { $this->dbConnection->update( diff --git a/src/Factory.php b/src/Factory.php index 64bfbf65..c2a590c8 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -9,6 +9,8 @@ use Monolog\Handler\StreamHandler; use Monolog\Logger; use Movary\Api\Github\GithubApi; +use Movary\Api\Plex; +use Movary\Api\Plex\PlexApi; use Movary\Api\Tmdb; use Movary\Api\Tmdb\TmdbUrlGenerator; use Movary\Api\Trakt\Cache\User\Movie\Watched; @@ -254,6 +256,7 @@ public static function createSettingsController(ContainerInterface $container, C $container->get(UserApi::class), $container->get(MovieApi::class), $container->get(GithubApi::class), + $container->get(PlexApi::class), $container->get(SessionWrapper::class), $container->get(LetterboxdExporter::class), $container->get(TraktApi::class), diff --git a/src/HttpController/PlexController.php b/src/HttpController/PlexController.php index cfb6d00a..89947579 100644 --- a/src/HttpController/PlexController.php +++ b/src/HttpController/PlexController.php @@ -2,13 +2,19 @@ namespace Movary\HttpController; +use Movary\Api\Plex\PlexApi; use Movary\Domain\User\Service\Authentication; use Movary\Domain\User\UserApi; use Movary\Service\Plex\PlexScrobbler; use Movary\Service\WebhookUrlBuilder; use Movary\Util\Json; +use Movary\ValueObject\Exception\ConfigNotSetException; +use Movary\ValueObject\Exception\InvalidUrl; +use Movary\ValueObject\Http\Header; use Movary\ValueObject\Http\Request; use Movary\ValueObject\Http\Response; +use Movary\ValueObject\Http\StatusCode; +use Movary\ValueObject\Url; use Psr\Log\LoggerInterface; class PlexController @@ -17,6 +23,7 @@ public function __construct( private readonly Authentication $authenticationService, private readonly UserApi $userApi, private readonly PlexScrobbler $plexScrobbler, + private readonly PlexApi $plexApi, private readonly WebhookUrlBuilder $webhookUrlBuilder, private readonly LoggerInterface $logger, ) { @@ -33,6 +40,26 @@ public function deletePlexWebhookUrl() : Response return Response::createOk(); } + public function generatePlexAuthenticationUrl() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createForbidden(); + } + + $plexAccessToken = $this->userApi->findPlexAccessToken($this->authenticationService->getCurrentUserId()); + if ($plexAccessToken !== null) { + return Response::createBadRequest('User is already authenticated'); + } + + try { + $plexAuthenticationUrl = $this->plexApi->generatePlexAuthenticationUrl(); + } catch (ConfigNotSetException $e) { + return Response::createBadRequest($e->getMessage()); + } + + return Response::createJson(Json::encode(['authenticationUrl' => $plexAuthenticationUrl])); + } + public function handlePlexWebhook(Request $request) : Response { $webhookId = $request->getRouteParameters()['id']; @@ -54,6 +81,34 @@ public function handlePlexWebhook(Request $request) : Response return Response::createOk(); } + public function processPlexCallback() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createForbidden(); + } + + $plexClientId = $this->userApi->findPlexClientId($this->authenticationService->getCurrentUserId()); + $plexClientCode = $this->userApi->findTemporaryPlexCode($this->authenticationService->getCurrentUserId()); + if ($plexClientId === null || $plexClientCode === null) { + throw new \RuntimeException('Missing plex client id or code'); + } + + $plexAccessToken = $this->plexApi->findPlexAccessToken($plexClientId, $plexClientCode); + if ($plexAccessToken === null) { + throw new \RuntimeException('Missing plex client id or code'); + } + + $this->userApi->updatePlexAccessToken($this->authenticationService->getCurrentUserId(), $plexAccessToken->getPlexAccessTokenAsString()); + + $plexAccount = $this->plexApi->findPlexAccount($plexAccessToken); + if ($plexAccount !== null) { + $plexAccountId = $plexAccount->getPlexId(); + $this->userApi->updatePlexAccountId($this->authenticationService->getCurrentUserId(), (string)$plexAccountId); + } + + return Response::createSeeOther('/settings/integrations/plex'); + } + public function regeneratePlexWebhookUrl() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { @@ -64,4 +119,73 @@ public function regeneratePlexWebhookUrl() : Response return Response::createJson(Json::encode(['url' => $this->webhookUrlBuilder->buildPlexWebhookUrl($webhookId)])); } + + public function removePlexAccessTokens() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $userId = $this->authenticationService->getCurrentUserId(); + + $this->userApi->updatePlexAccessToken($userId, null); + $this->userApi->updatePlexClientId($userId, null); + $this->userApi->updatePlexAccountId($userId, null); + $this->userApi->updateTemporaryPlexClientCode($userId, null); + + return Response::create(StatusCode::createSeeOther(), null, [Header::createLocation($_SERVER['HTTP_REFERER'])]); + } + + public function savePlexServerUrl(Request $request) : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $userId = $this->authenticationService->getCurrentUserId(); + + $plexServerUrl = Json::decode($request->getBody())['plexServerUrl']; + if (empty($plexServerUrl)) { + $this->userApi->updatePlexServerUrl($userId, null); + + return Response::createOk(); + } + + try { + $plexServerUrl = Url::createFromString($plexServerUrl); + } catch (InvalidUrl) { + return Response::createBadRequest('Url not properly formatted'); + } + + $this->userApi->updatePlexServerUrl($userId, $plexServerUrl); + + return Response::createOk(); + } + + public function verifyPlexServerUrl(Request $request) : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $userId = $this->authenticationService->getCurrentUserId(); + + $plexAccessToken = $this->userApi->findPlexAccessToken($userId); + if ($plexAccessToken === null) { + return Response::createBadRequest('Verification failed, plex authentication token missing.'); + } + + $plexServerUrl = Json::decode($request->getBody())['plexServerUrl']; + if (empty($plexServerUrl)) { + return Response::createBadRequest('Url not correctly formatted'); + } + + try { + $plexServerUrl = Url::createFromString($plexServerUrl); + } catch (InvalidUrl) { + return Response::createBadRequest('Verification failed, url not properly formatted'); + } + + return Response::createJson(Json::encode($this->plexApi->verifyPlexUrl($userId, $plexServerUrl))); + } } diff --git a/src/HttpController/SettingsController.php b/src/HttpController/SettingsController.php index 0307b2d7..b8d07378 100644 --- a/src/HttpController/SettingsController.php +++ b/src/HttpController/SettingsController.php @@ -3,6 +3,7 @@ namespace Movary\HttpController; use Movary\Api\Github\GithubApi; +use Movary\Api\Plex\PlexApi; use Movary\Api\Trakt\TraktApi; use Movary\Domain\Movie; use Movary\Domain\User; @@ -35,6 +36,7 @@ public function __construct( private readonly UserApi $userApi, private readonly Movie\MovieApi $movieApi, private readonly GithubApi $githubApi, + private readonly PlexApi $plexApi, private readonly SessionWrapper $sessionWrapper, private readonly LetterboxdExporter $letterboxdExporter, private readonly TraktApi $traktApi, @@ -343,6 +345,16 @@ public function renderPlexPage() : Response return Response::createSeeOther('/'); } + $plexAccessToken = $this->userApi->findPlexAccessToken($this->authenticationService->getCurrentUserId()); + + if ($plexAccessToken !== null) { + $plexAccount = $this->plexApi->findPlexAccount($plexAccessToken); + if ($plexAccount !== null) { + $plexUsername = $plexAccount->getPlexUsername(); + $plexServerUrl = $this->userApi->findPlexServerUrl($this->authenticationService->getCurrentUserId()); + } + } + $user = $this->userApi->fetchUser($this->authenticationService->getCurrentUserId()); $applicationUrl = $this->serverSettings->getApplicationUrl(); @@ -359,6 +371,9 @@ public function renderPlexPage() : Response 'plexWebhookUrl' => $plexWebhookUrl ?? '-', 'scrobbleWatches' => $user->hasPlexScrobbleWatchesEnabled(), 'scrobbleRatings' => $user->hasPlexScrobbleRatingsEnabled(), + 'plexTokenExists' => $plexAccessToken !== null, + 'plexServerUrl' => $plexServerUrl ?? '', + 'plexUsername' => $plexUsername ?? '', ]), ); } @@ -710,7 +725,7 @@ public function updateServerEmail(Request $request) : Response $this->serverSettings->setSmtpHost($smtpHost); } if ($smtpPort !== null) { - $this->serverSettings->setSmtpPort($smtpPort); + $this->serverSettings->setSmtpPort((int)$smtpPort); } if ($smtpFromAddress !== null) { $this->serverSettings->setSmtpFromAddress($smtpFromAddress); diff --git a/src/Service/ServerSettings.php b/src/Service/ServerSettings.php index 025ff472..38de3f0c 100644 --- a/src/Service/ServerSettings.php +++ b/src/Service/ServerSettings.php @@ -4,12 +4,17 @@ use Doctrine\DBAL\Connection; use Movary\ValueObject\Config; -use Movary\ValueObject\Exception\ConfigKeyNotSetException; +use Movary\ValueObject\Exception\ConfigNotSetException; +use function PHPUnit\Framework\assertNotNull; class ServerSettings { + private const PLEX_IDENTIFIER = 'PLEX_IDENTIFIER'; + private const APPLICATION_URL = 'APPLICATION_URL'; + private const APPLICATION_VERSION = 'APPLICATION_VERSION'; + private const SMTP_HOST = 'SMTP_HOST'; private const SMTP_SENDER_ADDRESS = 'SMTP_SENDER_ADDRESS'; @@ -36,112 +41,62 @@ public function __construct( public function getApplicationUrl() : ?string { - try { - $value = $this->config->getAsString(self::APPLICATION_URL); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::APPLICATION_URL); - } + return $this->getByKey(self::APPLICATION_URL); + } - return (string)$value === '' ? null : (string)$value; + public function getApplicationVersion() : ?string + { + return $this->getByKey(self::APPLICATION_VERSION); } public function getFromAddress() : ?string { - try { - $value = $this->config->getAsString(self::SMTP_FROM_ADDRESS); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_FROM_ADDRESS); - } + return $this->getByKey(self::SMTP_FROM_ADDRESS); + } - return (string)$value === '' ? null : (string)$value; + public function getPlexIdentifier() : ?string + { + return $this->getByKey(self::PLEX_IDENTIFIER); } public function getSmtpEncryption() : ?string { - try { - $value = $this->config->getAsString(self::SMTP_ENCRYPTION); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_ENCRYPTION); - } - - return (string)$value === '' ? null : (string)$value; + return $this->getByKey(self::SMTP_ENCRYPTION); } public function getSmtpHost() : ?string { - try { - $value = $this->config->getAsString(self::SMTP_HOST); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_HOST); - } - - return (string)$value === '' ? null : (string)$value; + return $this->getByKey(self::SMTP_HOST); } public function getSmtpPassword() : ?string { - try { - $value = $this->config->getAsString(self::SMTP_PASSWORD); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_PASSWORD); - } - - return (string)$value === '' ? null : (string)$value; + return $this->getByKey(self::SMTP_PASSWORD); } public function getSmtpPort() : ?int { - try { - $value = $this->config->getAsString(self::SMTP_PORT); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_PORT); - } - - return (string)$value === '' ? null : (int)$value; + return (int)$this->getByKey(self::SMTP_PORT); } public function getSmtpSenderAddress() : ?string { - try { - $value = $this->config->getAsString(self::SMTP_SENDER_ADDRESS); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_SENDER_ADDRESS); - } - - return (string)$value === '' ? null : (string)$value; + return $this->getByKey(self::SMTP_SENDER_ADDRESS); } public function getSmtpUser() : ?string { - try { - $value = $this->config->getAsString(self::SMTP_USER); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_USER); - } - - return (string)$value === '' ? null : (string)$value; + return $this->getByKey(self::SMTP_USER); } public function getSmtpWithAuthentication() : ?bool { - try { - $value = $this->config->getAsString(self::SMTP_WITH_AUTH); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::SMTP_WITH_AUTH); - } - - return (string)$value === '' ? null : (bool)$value; + return (bool)$this->getByKey(self::SMTP_WITH_AUTH); } public function getTmdbApiKey() : ?string { - try { - $value = $this->config->getAsString(self::TMDB_API_KEY); - } catch (ConfigKeyNotSetException) { - $value = $this->fetchValueFromDatabase(self::TMDB_API_KEY); - } - - return (string)$value === '' ? null : (string)$value; + return (string)$this->getByKey(self::TMDB_API_KEY); } public function isApplicationUrlSetInEnvironment() : bool @@ -149,17 +104,6 @@ public function isApplicationUrlSetInEnvironment() : bool return $this->isSetInEnvironment(self::APPLICATION_URL); } - public function isSetInEnvironment(string $key) : bool - { - try { - $this->config->getAsString($key); - } catch (ConfigKeyNotSetException) { - return false; - } - - return true; - } - public function isSmtpEncryptionSetInEnvironment() : bool { return $this->isSetInEnvironment(self::SMTP_ENCRYPTION); @@ -200,6 +144,22 @@ public function isTmdbApiKeySetInEnvironment() : bool return $this->isSetInEnvironment(self::TMDB_API_KEY); } + public function requireApplicationUrl() : string + { + $value = $this->getByKey(self::APPLICATION_URL, true); + assertNotNull($value); + + return $value; + } + + public function requirePlexIdentifier() : string + { + $value = $this->getByKey(self::PLEX_IDENTIFIER, true); + assertNotNull($value); + + return $value; + } + public function setApplicationUrl(string $applicationUrl) : void { $this->updateValue(self::APPLICATION_URL, $applicationUrl); @@ -234,7 +194,7 @@ public function setSmtpPassword(string $smtpPassword) : void $this->updateValue(self::SMTP_PASSWORD, $smtpPassword); } - public function setSmtpPort(string $smtpPort) : void + public function setSmtpPort(int $smtpPort) : void { $this->updateValue(self::SMTP_PORT, $smtpPort); } @@ -264,6 +224,32 @@ private function fetchValueFromDatabase(string $environmentKey) : ?string return isset($value[0]) === false ? null : (string)$value[0]; } + private function getByKey(string $key, bool $required = false) : ?string + { + try { + $value = $this->config->getAsString($key); + } catch (ConfigNotSetException $e) { + $value = $this->fetchValueFromDatabase($key); + + if (empty($value) === true && $required === true) { + throw $e; + } + } + + return (string)$value === '' ? null : (string)$value; + } + + private function isSetInEnvironment(string $key) : bool + { + try { + $this->config->getAsString($key); + } catch (ConfigNotSetException) { + return false; + } + + return true; + } + private function updateValue(string $environmentKey, mixed $value) : void { $key = $this->convertEnvironmentKeyToDatabaseKey($environmentKey); diff --git a/src/ValueObject/Config.php b/src/ValueObject/Config.php index 95b2dce8..ac4bf495 100644 --- a/src/ValueObject/Config.php +++ b/src/ValueObject/Config.php @@ -3,7 +3,7 @@ namespace Movary\ValueObject; use Movary\Util\File; -use Movary\ValueObject\Exception\ConfigKeyNotSetException; +use Movary\ValueObject\Exception\ConfigNotSetException; class Config { @@ -13,12 +13,12 @@ public function __construct( ) { } - /** @throws ConfigKeyNotSetException */ + /** @throws ConfigNotSetException */ public function getAsBool(string $key, ?bool $fallbackValue = null) : bool { try { return (bool)$this->get($key); - } catch (ConfigKeyNotSetException $e) { + } catch (ConfigNotSetException $e) { if ($fallbackValue === null) { throw $e; } @@ -27,12 +27,12 @@ public function getAsBool(string $key, ?bool $fallbackValue = null) : bool } } - /** @throws ConfigKeyNotSetException */ + /** @throws ConfigNotSetException */ public function getAsInt(string $key, ?int $fallbackValue = null) : int { try { return (int)$this->get($key); - } catch (ConfigKeyNotSetException $e) { + } catch (ConfigNotSetException $e) { if ($fallbackValue === null) { throw $e; } @@ -41,12 +41,12 @@ public function getAsInt(string $key, ?int $fallbackValue = null) : int } } - /** @throws ConfigKeyNotSetException */ + /** @throws ConfigNotSetException */ public function getAsString(string $key, ?string $fallbackValue = null) : string { try { return (string)$this->get($key); - } catch (ConfigKeyNotSetException $e) { + } catch (ConfigNotSetException $e) { if ($fallbackValue === null) { throw $e; } @@ -59,12 +59,12 @@ public function getAsStringNullable(string $key, ?string $fallbackValue = null) { try { return $this->getAsString($key, $fallbackValue); - } catch (ConfigKeyNotSetException) { + } catch (ConfigNotSetException) { return null; } } - /** @throws ConfigKeyNotSetException */ + /** @throws ConfigNotSetException */ private function get(string $key) : mixed { if (isset($this->config[$key]) === true) { @@ -79,6 +79,6 @@ private function get(string $key) : mixed } } - throw ConfigKeyNotSetException::create($key); + throw ConfigNotSetException::create($key); } } diff --git a/src/ValueObject/Exception/ConfigKeyNotSetException.php b/src/ValueObject/Exception/ConfigNotSetException.php similarity index 54% rename from src/ValueObject/Exception/ConfigKeyNotSetException.php rename to src/ValueObject/Exception/ConfigNotSetException.php index e4db80e6..8a7f0ab9 100644 --- a/src/ValueObject/Exception/ConfigKeyNotSetException.php +++ b/src/ValueObject/Exception/ConfigNotSetException.php @@ -2,10 +2,10 @@ namespace Movary\ValueObject\Exception; -class ConfigKeyNotSetException extends \RuntimeException +class ConfigNotSetException extends \RuntimeException { public static function create(string $key) : self { - return new self('Config key does not exist: ' . $key); + return new self('Required config not set: ' . $key); } } diff --git a/src/ValueObject/Exception/InvalidRelativeUrl.php b/src/ValueObject/Exception/InvalidRelativeUrl.php new file mode 100644 index 00000000..c287c752 --- /dev/null +++ b/src/ValueObject/Exception/InvalidRelativeUrl.php @@ -0,0 +1,11 @@ +ensureIsValidRelativeUrl($relativeUrl); + } + //endregion instancing + + //region methods + public static function create(string $url) : self + { + return new self($url); + } + + public function __toString() : string + { + return $this->relativeUrl; + } + + public function jsonSerialize() : string + { + return $this->relativeUrl; + } + + private function ensureIsValidRelativeUrl(string $url) : void + { + if (str_starts_with($url, '/') === false || parse_url($url) === false) { + throw InvalidRelativeUrl::create($url); + } + } + //endregion methods +} diff --git a/src/ValueObject/Url.php b/src/ValueObject/Url.php index 059d3bf7..8e1eb106 100644 --- a/src/ValueObject/Url.php +++ b/src/ValueObject/Url.php @@ -2,6 +2,7 @@ namespace Movary\ValueObject; +use Movary\ValueObject\Exception\InvalidUrl; use RuntimeException; class Url @@ -9,7 +10,7 @@ class Url private function __construct(private readonly string $url) { if (filter_var($url, FILTER_VALIDATE_URL) === false) { - throw new RuntimeException('Invalid url: ' . $url); + throw InvalidUrl::create($this->url); } } @@ -23,6 +24,17 @@ public function __toString() : string return $this->url; } + public function appendRelativeUrl(RelativeUrl $relativeUrl) : self + { + if (parse_url($this->url, PHP_URL_QUERY) !== null) { + throw new RuntimeException( + sprintf('Cannot append relative url "%s" to url "%s", because the url has query parameters', $relativeUrl, $this->url) + ); + } + + return new self(rtrim($this->url, '/') . $relativeUrl); + } + public function getPath() : ?string { $path = parse_url($this->url, PHP_URL_PATH); diff --git a/templates/page/settings-integration-netflix.html.twig b/templates/page/settings-integration-netflix.html.twig index d69119a2..05b9b859 100644 --- a/templates/page/settings-integration-netflix.html.twig +++ b/templates/page/settings-integration-netflix.html.twig @@ -42,7 +42,9 @@
CSV Date Format - +
@@ -159,10 +161,10 @@