Skip to content

Add Plex OAuth #413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Jul 1, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c9bcc8a
Add Plex OAuth
JVT038 Jun 27, 2023
08a2796
Some refactoring
leepeuker Jun 28, 2023
38dd165
Add missing data
leepeuker Jun 28, 2023
e37c0f2
Update local plex api
leepeuker Jun 28, 2023
9e44345
Fix tests
leepeuker Jun 28, 2023
2814e0a
Remove obsolete code
leepeuker Jun 28, 2023
c6cfafd
Cleanup
leepeuker Jun 28, 2023
75cfd44
Merge pull request #415 from leepeuker/add-plex-oauth-2
leepeuker Jun 28, 2023
9073427
Use url value objects in for plex
leepeuker Jun 28, 2023
a6bf51c
Simplify code
leepeuker Jun 28, 2023
eda5991
Naming and tests
leepeuker Jun 28, 2023
ca07a32
Merge pull request #416 from leepeuker/add-plex-oauth-url
leepeuker Jun 28, 2023
45b7cc4
Simplify UI
leepeuker Jun 28, 2023
b5dceb0
Fix invalid header issue
leepeuker Jun 28, 2023
b84991b
Rework plex auth frontend
leepeuker Jun 29, 2023
e7d4f27
Add help modal for plex server url
leepeuker Jun 29, 2023
65ebcf8
Improve error handling on not set application url
leepeuker Jun 29, 2023
9ed70ae
Improve help text
leepeuker Jun 29, 2023
1ccba27
Add PLEX_IDENTIFIER env variable to docs
leepeuker Jun 29, 2023
6a7ee10
Add link to configuration docs page to example env files
leepeuker Jun 29, 2023
328d5ee
Naming changes
leepeuker Jun 29, 2023
5d62cda
Simplify exception message
leepeuker Jun 29, 2023
9812f83
Improve and unfiy help modals
leepeuker Jun 30, 2023
7f8a2fd
Improve error handling on missing server config
leepeuker Jun 30, 2023
fcbb58d
Return 400 response if user is already authenticated
leepeuker Jul 1, 2023
8aad533
Remove obsolete code
leepeuker Jul 1, 2023
3c5092b
Fix and improve database migrations
leepeuker Jul 1, 2023
130e53a
Fix naming
leepeuker Jul 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions public/js/settings-integration-plex.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,129 @@ 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) {
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 === ''
});
14 changes: 12 additions & 2 deletions settings/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,24 @@
);
$routeCollector->addRoute(
'POST',
'/settings/plex/saveserverurl',
'/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',
Expand Down
11 changes: 11 additions & 0 deletions src/Api/Plex/Exception/MovaryApplicationUrlNotSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php declare(strict_types=1);

namespace Movary\Api\Plex\Exception;

class MovaryApplicationUrlNotSet extends \RuntimeException
{
public static function create() : self
{
return new self('Movary application url is not set');
}
}
16 changes: 5 additions & 11 deletions src/Api/Plex/PlexApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
use Movary\Api\Plex\Dto\PlexAccessToken;
use Movary\Api\Plex\Dto\PlexAccount;
use Movary\Api\Plex\Dto\PlexItem;
use Movary\Api\Plex\Exception\MovaryApplicationUrlNotSet;
use Movary\Api\Plex\Exception\PlexAuthenticationError;
use Movary\Api\Plex\Exception\PlexNoClientIdentifier;
use Movary\Api\Plex\Exception\PlexNotFoundError;
use Movary\Domain\User\Service\Authentication;
use Movary\Domain\User\UserApi;
Expand Down Expand Up @@ -86,15 +86,11 @@ public function findPlexAccount(PlexAccessToken $plexAccessToken) : ?PlexAccount
* 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=<clientIdentifier>&code=<clientCode>&context[device][product]=<AppName>&forwardUrl=<urlCallback>`
* 4. The URL is returned to the settingsController
*/
public function generatePlexAuthenticationUrl() : ?string
public function generatePlexAuthenticationUrl() : string
{
$relativeUrl = RelativeUrl::create('/pins');

try {
$plexAuthenticationData = $this->plexTvClient->sendPostRequest($relativeUrl);
} catch (PlexNoClientIdentifier) {
return null;
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you remove this?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because I do not want to return null here, I always expect a authentication url from this method.
if this is not possible an exception should be thrown and the caller should decide what to do about it

$plexAuthenticationData = $this->plexTvClient->sendPostRequest($relativeUrl);

$this->userApi->updatePlexClientId($this->authenticationService->getCurrentUserId(), $plexAuthenticationData['id']);
$this->userApi->updateTemporaryPlexClientCode($this->authenticationService->getCurrentUserId(), $plexAuthenticationData['code']);
Expand All @@ -105,16 +101,14 @@ public function generatePlexAuthenticationUrl() : ?string

$applicationUrl = $this->serverSettings->getApplicationUrl();
if ($applicationUrl === null) {
return null;
throw new MovaryApplicationUrlNotSet();
}

$urlCallback = $applicationUrl . '/settings/plex/callback';

$getParameters = [
'clientID' => $plexClientIdentifier,
'code' => (string)$plexTemporaryClientCode,
'context[device][product]' => $plexAppName,
'forwardUrl' => $urlCallback,
'forwardUrl' => (string)Url::createFromString($applicationUrl . '/settings/plex/callback'),
];

return self::BASE_URL . http_build_query($getParameters);
Expand Down
2 changes: 1 addition & 1 deletion src/Domain/User/UserApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ public function updatePlexScrobblerOptions(int $userId, bool $scrobbleWatches, b
$this->repository->updatePlexScrobblerOptions($userId, $scrobbleWatches, $scrobbleRatings);
}

public function updatePlexServerUrl(int $userId, Url $plexServerUrl) : void
public function updatePlexServerUrl(int $userId, ?Url $plexServerUrl) : void
{
$this->repository->updatePlexServerurl($userId, $plexServerUrl);
}
Expand Down
70 changes: 51 additions & 19 deletions src/HttpController/PlexController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@

namespace Movary\HttpController;

use Movary\Api\Plex\Dto\PlexItemList;
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\Util\SessionWrapper;
use Movary\ValueObject\Exception\InvalidUrl;
use Movary\ValueObject\Http\Header;
use Movary\ValueObject\Http\Request;
Expand All @@ -25,7 +23,6 @@ public function __construct(
private readonly UserApi $userApi,
private readonly PlexScrobbler $plexScrobbler,
private readonly PlexApi $plexApi,
private readonly SessionWrapper $sessionWrapper,
private readonly WebhookUrlBuilder $webhookUrlBuilder,
private readonly LoggerInterface $logger,
) {
Expand All @@ -42,6 +39,22 @@ 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) {
throw new \RuntimeException('User is already authenticated');
}

$plexAuthenticationUrl = $this->plexApi->generatePlexAuthenticationUrl();

return Response::createJson(Json::encode(['authenticationUrl' => $plexAuthenticationUrl]));
}

public function handlePlexWebhook(Request $request) : Response
{
$webhookId = $request->getRouteParameters()['id'];
Expand All @@ -66,18 +79,18 @@ public function handlePlexWebhook(Request $request) : Response
public function processPlexCallback() : Response
{
if ($this->authenticationService->isUserAuthenticated() === false) {
return Response::createSeeOther('/');
return Response::createForbidden();
}

$plexClientId = $this->userApi->findPlexClientId($this->authenticationService->getCurrentUserId());
$plexClientCode = $this->userApi->findTemporaryPlexCode($this->authenticationService->getCurrentUserId());
if ($plexClientId === null || $plexClientCode === null) {
return Response::createSeeOther('/');
throw new \RuntimeException('Missing plex client id or code');
}

$plexAccessToken = $this->plexApi->findPlexAccessToken($plexClientId, $plexClientCode);
if ($plexAccessToken === null) {
return Response::createSeeOther('/');
throw new \RuntimeException('Missing plex client id or code');
}

$this->userApi->updatePlexAccessToken($this->authenticationService->getCurrentUserId(), $plexAccessToken->getPlexAccessTokenAsString());
Expand Down Expand Up @@ -124,31 +137,50 @@ public function savePlexServerUrl(Request $request) : Response
return Response::createSeeOther('/');
}

$plexAccessToken = $this->userApi->findPlexAccessToken($this->authenticationService->getCurrentUserId());
if ($plexAccessToken === null) {
$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();
$plexServerUrl = $request->getPostParameters()['plexServerUrlInput'];

$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::createSeeOther('/settings/integrations/plex');
return Response::createBadRequest('Url not correctly formatted');
}

try {
$plexServerUrl = Url::createFromString($plexServerUrl);
} catch (InvalidUrl) {
return Response::createBadRequest('Not a valid url: ' . $plexServerUrl);
}

if ($this->plexApi->verifyPlexUrl($userId, $plexServerUrl) === false) {
$this->sessionWrapper->set('serverUrlStatus', false);
} else {
$this->sessionWrapper->set('serverUrlStatus', true);
$this->userApi->updatePlexServerUrl($this->authenticationService->getCurrentUserId(), $plexServerUrl);
return Response::createBadRequest('Verification failed, url not properly formatted');
}

return Response::create(StatusCode::createSeeOther(), null, [Header::createLocation($_SERVER['HTTP_REFERER'])]);
return Response::createJson(Json::encode($this->plexApi->verifyPlexUrl($userId, $plexServerUrl)));
}
}
12 changes: 3 additions & 9 deletions src/HttpController/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -351,17 +351,11 @@ public function renderPlexPage() : Response

$plexAccessToken = $this->userApi->findPlexAccessToken($this->authenticationService->getCurrentUserId());

if ($plexAccessToken === null) {
$plexAuthenticationUrl = $this->plexApi->generatePlexAuthenticationUrl();
} else {
if ($plexAccessToken !== null) {
$plexAccount = $this->plexApi->findPlexAccount(PlexAccessToken::createPlexAccessToken($plexAccessToken));
if ($plexAccount instanceof PlexAccount) {
$plexUsername = $plexAccount->getPlexUsername();
if (($plexServerUrl = $this->userApi->findPlexServerUrl($this->authenticationService->getCurrentUserId())) == null) {
$plexServerUrl = "";
}
} else {
$plexAuthenticationUrl = $this->plexApi->generatePlexAuthenticationUrl();
$plexServerUrl = $this->userApi->findPlexServerUrl($this->authenticationService->getCurrentUserId());
}
}

Expand All @@ -386,7 +380,7 @@ public function renderPlexPage() : Response
'plexWebhookUrl' => $plexWebhookUrl ?? '-',
'scrobbleWatches' => $user->hasPlexScrobbleWatchesEnabled(),
'scrobbleRatings' => $user->hasPlexScrobbleRatingsEnabled(),
'plexAuth' => $plexAuthenticationUrl ?? '',
'plexTokenExists' => $plexAccessToken !== null,
'plexServerUrl' => $plexServerUrl ?? '',
'plexUsername' => $plexUsername ?? '',
'serverUrlStatus' => $serverUrlStatus
Expand Down
Loading