diff --git a/docs/openapi.json b/docs/openapi.json index 0ba8eb00c..416d77507 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -985,6 +985,222 @@ ] } }, + "/movies/{id}": { + "get": { + "tags": [ + "Movies" + ], + "summary": "Get movie data", + "description": "Get the data of a specific movie based on their Movary ID", + "parameters": [ + { + "$ref": "#/components/schemas/id" + } + ], + "responses": { + "200": { + "description": "Movie ID is valid and the movie was found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "movie": { + "description": "Movie object", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/id" + }, + "tmdbId": { + "$ref": "#/components/schemas/idNullable" + }, + "imdbId": { + "type": "string", + "nullable": true, + "example": "tt12345678" + }, + "title": { + "$ref": "#/components/schemas/title" + }, + "releaseDate": { + "$ref":"#/components/schemas/releaseDateNullable" + }, + "posterPath": { + "$ref": "#/components/schemas/posterPath" + }, + "tagline": { + "$ref": "#/components/schemas/movie/properties/tagline" + }, + "overview": { + "$ref": "#/components/schemas/overview" + }, + "runtime": { + "$ref": "#/components/schemas/movie/properties/runtime" + }, + "imdbUrl": { + "type": "string", + "description": "URL of the movie in IMDb", + "nullable": true + }, + "imdbRatingAverage": { + "type": "string", + "description": "Average rating of movie on IMDb", + "nullable": true + }, + "imdbRatingVoteCount": { + "type": "string", + "description": "Amount of ratings of movie in IMDb", + "nullable": true + }, + "tmdbUrl": { + "type": "string", + "description": "The URL of the movie on TMDB", + "nullable": true + }, + "tmdbRatingAverage": { + "type": "number", + "description": "Average rating of movie on TMDB", + "nullable": true + }, + "tmdbRatingVotecount": { + "type": "string", + "description": "Amount of ratings of movie in TMDB", + "nullable": true + }, + "originalLanguage": { + "type": "string", + "description": "Original language of movie", + "nullable": true, + "example": "English" + } + } + }, + "movieGenres": { + "type": "array", + "properties": { + "genre": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the genre" + } + } + } + }, + "example": [ + { + "name": "Genre 1" + }, + { + "name": "Genre 2" + } + ] + }, + "castMembers": { + "type": "array", + "properties": { + "castMember": { + "type": "object", + "id": { + "$ref": "#/components/schemas/id" + }, + "name": { + "type": "string", + "description": "Name of the cast member" + }, + "posterPath": { + "$ref": "#/components/schemas/posterPath" + }, + "characterName": { + "type": "string", + "description": "Name of the character this cast member played in the movie" + } + } + }, + "example": [ + { + "id": 1, + "name": "Name 1", + "posterPath": "/path/to/poster1.jpg", + "characterName": "Character name 1" + }, + { + "id": 2, + "name": "Name 2", + "posterPath": "/path/to/poster2.jpg", + "characterName": "Character name 2" + } + ] + }, + "directors": { + "type": "array", + "properties": { + "director": { + "type": "object", + "description": "The director", + "properties": { + "id": { + "$ref": "#/components/schemas/id" + }, + "name": { + "type": "string", + "description": "Name of the director" + }, + "posterPath": { + "$ref": "#/components/schemas/posterPath" + } + } + } + } + }, + "totalPlays": { + "$ref": "#/components/schemas/playsOptional" + }, + "watchDates": { + "type": "array", + "properties": { + "date": { + "$ref": "#/components/schemas/dateNullable" + } + } + }, + "isOnWatchList": { + "type": "boolean", + "description": "True if on watchlist, false if not, and null if no token has been sent in the request header..", + "nullable": true + }, + "countries": { + "type": "object", + "properties": { + "ISOCode": { + "type": "object", + "description": "2-Letter ISO code of country, with as value their full name", + "example": { + "US": "United States" + } + } + }, + "example": { + "AF": "Afghanistan", + "AL": "Albania" + } + }, + "displayCharacterNames": { + "type": "boolean" + } + } + } + } + } + }, + "404": { + "description": "Movie was not found" + } + } + } + }, "/webhook/plex/{uuid}": { "post": { "tags": [ @@ -1396,6 +1612,12 @@ "example": 1, "nullable": false }, + "posterPath": { + "type": "string", + "description": "The path to the poster", + "nullable": true, + "example": "/storage/images/itemtype/1.jpg" + }, "positionOptional": { "type": "number", "example": 1, diff --git a/settings/routes.php b/settings/routes.php index 2de220432..ffdf009a0 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -228,7 +228,8 @@ function addApiRoutes(RouterService $routerService, FastRoute\RouteCollector $ro $routes->add('DELETE', $routeUserPlayed, [Api\PlayedController::class, 'deleteFromPlayed'], [Api\Middleware\IsAuthorizedToWriteUserData::class]); $routes->add('PUT', $routeUserPlayed, [Api\PlayedController::class, 'updatePlayed'], [Api\Middleware\IsAuthorizedToWriteUserData::class]); - $routes->add('GET', '/movies/search', [Api\MovieSearchController::class, 'search'], [Api\Middleware\IsAuthenticated::class]); + $routes->add('GET', '/movies/search', [Api\MovieController::class, 'search'], [Api\Middleware\IsAuthenticated::class]); + $routes->add('GET', '/movies/{id:\d}', [Api\MovieController::class, 'getMovie']); $routes->add('POST', '/webhook/plex/{id:.+}', [Api\PlexController::class, 'handlePlexWebhook']); $routes->add('POST', '/webhook/jellyfin/{id:.+}', [Api\JellyfinController::class, 'handleJellyfinWebhook']); diff --git a/src/Domain/User/Service/Authentication.php b/src/Domain/User/Service/Authentication.php index 8d0713217..a0fbea50f 100644 --- a/src/Domain/User/Service/Authentication.php +++ b/src/Domain/User/Service/Authentication.php @@ -9,6 +9,7 @@ use Movary\Domain\User\UserApi; use Movary\Domain\User\UserEntity; use Movary\Domain\User\UserRepository; +use Movary\HttpController\Api\ValueObject\AuthenticationObject; use Movary\HttpController\Web\CreateUserController; use Movary\Util\SessionWrapper; use Movary\ValueObject\DateTime; @@ -29,6 +30,58 @@ public function __construct( ) { } + public function createAuthenticationObjectFromCookie() : ?AuthenticationObject + { + $token = filter_input(INPUT_COOKIE, self::AUTHENTICATION_COOKIE_NAME); + $authenticationMethod = AuthenticationObject::COOKIE_AUTHENTICATION; + if (empty($token) === true || $this->isValidToken((string)$token) === false) { + unset($_COOKIE[self::AUTHENTICATION_COOKIE_NAME]); + setcookie(self::AUTHENTICATION_COOKIE_NAME, '', -1); + return null; + } + $user = $this->userApi->findByToken($token); + if(empty($user) === true) { + return null; + } + return AuthenticationObject::createAuthenticationObject($token, $authenticationMethod, $user); + } + + /** + * This method will 'dynamically' create an authentication object. + * It will first check if cookie authentication is possible and if not, it'll check if header authentication is possible. + * If neither are possible and/or the authentication token is invalid, then the cookie is deleted and null is returned. + * @param Request $request + * @return AuthenticationObject|null + */ + public function createAuthenticationObjectDynamically(Request $request) : ?AuthenticationObject + { + $token = filter_input(INPUT_COOKIE, self::AUTHENTICATION_COOKIE_NAME) ?? $request->getHeaders()['X-Movary-Token'] ?? null; + if (empty($token) === true || $this->isValidToken($token) === false) { + unset($_COOKIE[self::AUTHENTICATION_COOKIE_NAME]); + setcookie(self::AUTHENTICATION_COOKIE_NAME, '', -1); + return null; + } + $authenticationMethod = empty($request->getHeaders()['X-Movary-Token']) === true ? AuthenticationObject::COOKIE_AUTHENTICATION : AuthenticationObject::HEADER_AUTHENTICATION; + $user = $this->userApi->findByToken($token); + if(empty($user) === true) { + return null; + } + return AuthenticationObject::createAuthenticationObject($token, $authenticationMethod, $user); + } + + public function createAuthenticationObjectFromHeader(Request $request) : ?AuthenticationObject + { + $token = $request->getHeaders()['X-Movary-Token'] ?? null; + $authenticationMethod = AuthenticationObject::HEADER_AUTHENTICATION; + if (empty($token) === true || $this->isValidToken((string)$token) === false) { + return null; + } + $user = $this->userApi->findByToken($token); + if(empty($user) === true) { + return null; + } + return AuthenticationObject::createAuthenticationObject($token, $authenticationMethod, $user); + } public function createExpirationDate(int $days = 1) : DateTime { $timestamp = strtotime('+' . $days . ' day'); @@ -84,28 +137,28 @@ public function getCurrentUser() : UserEntity public function getCurrentUserId() : int { $userId = $this->sessionWrapper->find('userId'); - $token = (string)filter_input(INPUT_COOKIE, self::AUTHENTICATION_COOKIE_NAME); - if ($userId === null && $token !== '') { - $userId = $this->repository->findUserIdByAuthToken($token); - $this->sessionWrapper->set('userId', $userId); + if ($userId === null) { + + $authenticationObject = $this->createAuthenticationObjectFromCookie(); + if($authenticationObject === null) { + throw new RuntimeException('Could not find a current user'); + } + $this->sessionWrapper->set('userId', $authenticationObject->getUser()->getId()); + $userId = $authenticationObject->getUser()->getId(); } - if ($userId === null) { + if ($userId == null) { throw new RuntimeException('Could not find a current user'); } - return $userId; } public function getToken(Request $request) : ?string { - $tokenInCookie = (string)filter_input(INPUT_COOKIE, self::AUTHENTICATION_COOKIE_NAME); - if ($tokenInCookie !== '') { - return $tokenInCookie; - } + $authenticationObject = $this->createAuthenticationObjectDynamically($request); - return $request->getHeaders()['X-Movary-Token'] ?? null; + return $authenticationObject?->getToken(); } public function getUserIdByApiToken(Request $request) : ?int @@ -115,7 +168,7 @@ public function getUserIdByApiToken(Request $request) : ?int return null; } - if ($this->isValidAuthToken($apiToken) === false) { + if ($this->isValidToken($apiToken) === false) { return null; } @@ -124,18 +177,11 @@ public function getUserIdByApiToken(Request $request) : ?int public function isUserAuthenticatedWithCookie() : bool { - $token = (string)filter_input(INPUT_COOKIE, self::AUTHENTICATION_COOKIE_NAME); - - if ($token !== '' && $this->isValidAuthToken($token) === true) { - return true; - } - - if (empty($token) === false) { - unset($_COOKIE[self::AUTHENTICATION_COOKIE_NAME]); - setcookie(self::AUTHENTICATION_COOKIE_NAME, '', -1); + $authenticationObject = $this->createAuthenticationObjectFromCookie(); + if($authenticationObject === null) { + return false; } - - return false; + return true; } public function isUserPageVisibleForApiRequest(Request $request, UserEntity $targetUser) : bool @@ -155,16 +201,19 @@ public function isUserPageVisibleForWebRequest(UserEntity $targetUser) : bool return $this->isUserPageVisibleForUser($targetUser, $requestUserId); } - public function isValidAuthToken(string $token) : bool + public function isValidToken(string $token) : bool { $tokenExpirationDate = $this->repository->findAuthTokenExpirationDate($token); - if ($tokenExpirationDate === null || $tokenExpirationDate->isAfter(DateTime::create()) === false) { - if ($tokenExpirationDate !== null) { + if ($tokenExpirationDate === null) { + if($this->repository->findUserByApiToken($token) === null) { + return false; + } + } else { + if($tokenExpirationDate->isAfter(DateTime::create()) === false) { $this->repository->deleteAuthToken($token); + return false; } - - return false; } return true; @@ -203,10 +252,10 @@ public function login( public function logout() : void { - $token = (string)filter_input(INPUT_COOKIE, 'id'); + $token = filter_input(INPUT_COOKIE, 'id'); - if ($token !== '') { - $this->deleteToken($token); + if ($token !== null) { + $this->deleteToken((string)$token); unset($_COOKIE[self::AUTHENTICATION_COOKIE_NAME]); setcookie(self::AUTHENTICATION_COOKIE_NAME, '', -1); } diff --git a/src/Domain/User/UserRepository.php b/src/Domain/User/UserRepository.php index 15f6d5df2..921ab10fd 100644 --- a/src/Domain/User/UserRepository.php +++ b/src/Domain/User/UserRepository.php @@ -350,6 +350,24 @@ public function findUserByName(string $name) : ?UserEntity return UserEntity::createFromArray($data); } + public function findUserByApiToken(string $apiToken) : ?UserEntity + { + $data = $this->dbConnection->fetchAssociative( + 'SELECT user.* + FROM user + LEFT JOIN user_api_token ON user.id = user_api_token.user_id + LEFT JOIN user_auth_token ON user.id = user_auth_token.user_id + WHERE user_api_token.token = ?', + [$apiToken], + ); + + if (empty($data) === true) { + return null; + } + + return UserEntity::createFromArray($data); + } + public function findUserByToken(string $apiToken) : ?UserEntity { $data = $this->dbConnection->fetchAssociative( diff --git a/src/HttpController/Api/AuthenticationController.php b/src/HttpController/Api/AuthenticationController.php index 11324f47b..60da653e0 100644 --- a/src/HttpController/Api/AuthenticationController.php +++ b/src/HttpController/Api/AuthenticationController.php @@ -138,7 +138,7 @@ public function getTokenData(Request $request) : Response return Response::createUnauthorized(); } - if ($this->authenticationService->isUserAuthenticatedWithCookie() && $this->authenticationService->isValidAuthToken($token) === false) { + if($this->authenticationService->isUserAuthenticatedWithCookie() && $this->authenticationService->isValidToken($token) === false) { return Response::createUnauthorized(); } diff --git a/src/HttpController/Api/MovieController.php b/src/HttpController/Api/MovieController.php new file mode 100644 index 000000000..2a2ce8cde --- /dev/null +++ b/src/HttpController/Api/MovieController.php @@ -0,0 +1,91 @@ +searchRequestMapper->mapRequest($request); + + $tmdbResponse = $this->tmdbApi->searchMovie( + $requestData->getSearchTerm(), + $requestData->getYear(), + $requestData->getPage(), + ); + + $paginationElements = $this->paginationElementsCalculator->createPaginationElements( + $tmdbResponse['total_results'], + (int)floor($tmdbResponse['total_results'] / $tmdbResponse['total_pages']), + $requestData->getPage(), + ); + + return Response::createJson( + Json::encode([ + 'results' => $this->historyResponseMapper->mapMovieSearchResults($tmdbResponse), + 'currentPage' => $paginationElements->getCurrentPage(), + 'maxPage' => $paginationElements->getMaxPage(), + ]), + ); + } + + public function getMovie(Request $request) : Response + { + $requestedMovieId = (int)$request->getRouteParameters()['id']; + $movie = $this->movieApi->findByIdFormatted($requestedMovieId); + if($movie === null) { + return Response::createNotFound(); + } + $userId = $this->authenticationService->getUserIdByApiToken($request); + if($userId === null) { + $movieTotalPlays = null; + $movieWatchDates = null; + $isOnWatchlist = null; + $displayCharacterNames = true; + } else { + $movieTotalPlays = $this->movieApi->fetchHistoryMovieTotalPlays($requestedMovieId, $userId); + $movieWatchDates = $this->movieApi->fetchHistoryByMovieId($requestedMovieId, $userId); + $isOnWatchlist = $this->movieWatchlistApi->hasMovieInWatchlist($userId, $requestedMovieId); + $displayCharacterNames = $this->userApi->findUserById($userId)?->getDisplayCharacterNames() ?? true; + } + return Response::createJson( + Json::encode([ + 'movie' => $movie, + 'movieGenres' => $this->movieApi->findGenresByMovieId($requestedMovieId), + 'castMembers' => $this->movieApi->findCastByMovieId($requestedMovieId), + 'directors' => $this->movieApi->findDirectorsByMovieId($requestedMovieId), + 'totalPlays' => $movieTotalPlays, + 'watchDates' => $movieWatchDates, + 'isOnWatchlist' => $isOnWatchlist, + 'countries' => $this->tmdbIsoCountryCache->fetchAll(), + 'displayCharacterNames' => $displayCharacterNames + ]) + ); + } +} diff --git a/src/HttpController/Api/MovieSearchController.php b/src/HttpController/Api/MovieSearchController.php deleted file mode 100644 index 43a341361..000000000 --- a/src/HttpController/Api/MovieSearchController.php +++ /dev/null @@ -1,47 +0,0 @@ -searchRequestMapper->mapRequest($request); - - $tmdbResponse = $this->tmdbApi->searchMovie( - $requestData->getSearchTerm(), - $requestData->getYear(), - $requestData->getPage(), - ); - - $paginationElements = $this->paginationElementsCalculator->createPaginationElements( - $tmdbResponse['total_results'], - (int)floor($tmdbResponse['total_results'] / $tmdbResponse['total_pages']), - $requestData->getPage(), - ); - - return Response::createJson( - Json::encode([ - 'results' => $this->historyResponseMapper->mapMovieSearchResults($tmdbResponse), - 'currentPage' => $paginationElements->getCurrentPage(), - 'maxPage' => $paginationElements->getMaxPage(), - ]), - ); - } -} diff --git a/src/HttpController/Api/ValueObject/AuthenticationObject.php b/src/HttpController/Api/ValueObject/AuthenticationObject.php new file mode 100644 index 000000000..7fc8ae348 --- /dev/null +++ b/src/HttpController/Api/ValueObject/AuthenticationObject.php @@ -0,0 +1,46 @@ +token; + } + + public function getAuthenticationMethod() : int + { + return $this->authenticationMethod; + } + + public function getUser() : UserEntity + { + return $this->user; + } + + public function hasCookieAuthentication() : bool + { + return $this->authenticationMethod === self::COOKIE_AUTHENTICATION; + } + + public function hasHeaderAuthentication() : bool + { + return $this->authenticationMethod === self::HEADER_AUTHENTICATION; + } +} diff --git a/tests/rest/api/movie-search.http b/tests/rest/api/movie-search.http deleted file mode 100644 index 5eac78441..000000000 --- a/tests/rest/api/movie-search.http +++ /dev/null @@ -1,7 +0,0 @@ -GET http://127.0.0.1/api/movies/search?search=Matrix&page=1&releaseYear=2012 -Accept: */* -Cache-Control: no-cache -Content-Type: application/json -X-Movary-Token: {{xMovaryToken}} - -#### diff --git a/tests/rest/api/movie.http b/tests/rest/api/movie.http new file mode 100644 index 000000000..a663a418f --- /dev/null +++ b/tests/rest/api/movie.http @@ -0,0 +1,45 @@ +#@name Search movie +GET http://127.0.0.1/api/movies/search?search=Matrix&page=1&releaseYear=2012 +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Token: {{xMovaryToken}} + +#### +#@name Get movie data +GET http://127.0.0.1/api/movies/1 +Accept: */* +Cache-Control: no-cache +Content-Type: application/json + +> {% +client.test('Has correct status code', () => { + let expectedStatusCode = 200; + client.assert(response.status === expectedStatusCode, 'Response did not have status code ' + expectedStatusCode); +}); +client.test('Has the right properties', () => { + client.assert(response.body.hasOwnProperty('movie') === true, 'Response did not have the \'movie\' property.'); + client.assert(response.body.hasOwnProperty('movieGenres') === true, 'Response did not have the \'movieGenres\' property.'); + client.assert(response.body.hasOwnProperty('castMembers') === true, 'Response did not have the \'castMembers\' property.'); + client.assert(response.body.hasOwnProperty('directors') === true, 'Response did not have the \'directors\' property.'); + client.assert(response.body.hasOwnProperty('totalPlays') === true, 'Response did not have the \'totalPlays\' property.'); + client.assert(response.body.hasOwnProperty('watchDates') === true, 'Response did not have the \'watchDates\' property.'); + client.assert(response.body.hasOwnProperty('isOnWatchlist') === true, 'Response did not have the \'isOnWatchlist\' property.'); + client.assert(response.body.hasOwnProperty('countries') === true, 'Response did not have the \'countries\' property.'); + client.assert(response.body.hasOwnProperty('displayCharacterNames') === true, 'Response did not have the \'displayCharacterNames\' property.'); +}); +%} + +#### +#@name Trigger Not Found +GET http://127.0.0.1/api/movies/-1 +Accept: */* +Cache-Control: no-cache +Content-Type: application/json + +> {% + client.test('Has correct status code', () => { + let expectedStatusCode = 404; + client.assert(response.status === expectedStatusCode, 'Response did not have status code ' + expectedStatusCode); + }); +%}