diff --git a/bootstrap.php b/bootstrap.php index 6fb46d622..5c524386e 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -7,6 +7,7 @@ $builder = new DI\ContainerBuilder(); $builder->addDefinitions( [ + \Movary\HttpController\Web\AuthenticationController::class => DI\Factory([Factory::class, 'createAuthenticationController']), \Movary\ValueObject\Config::class => DI\factory([Factory::class, 'createConfig']), \Movary\Api\Trakt\TraktApi::class => DI\factory([Factory::class, 'createTraktApi']), \Movary\Service\ImageCacheService::class => DI\factory([Factory::class, 'createImageCacheService']), @@ -18,7 +19,7 @@ \Movary\HttpController\Web\CreateUserController::class => DI\factory([Factory::class, 'createCreateUserController']), \Movary\HttpController\Web\JobController::class => DI\factory([Factory::class, 'createJobController']), \Movary\HttpController\Web\LandingPageController::class => DI\factory([Factory::class, 'createLandingPageController']), - \Movary\HttpController\Web\Middleware\ServerHasRegistrationEnabled::class => DI\factory([Factory::class, 'createMiddlewareServerHasRegistrationEnabled']), + \Movary\HttpController\Api\Middleware\CreateUserMiddleware::class => DI\factory([Factory::class, 'createCreateUserMiddleware']), \Movary\ValueObject\Http\Request::class => DI\factory([Factory::class, 'createCurrentHttpRequest']), \Movary\Command\CreatePublicStorageLink::class => DI\factory([Factory::class, 'createCreatePublicStorageLink']), \Movary\Command\DatabaseMigrationStatus::class => DI\factory([Factory::class, 'createDatabaseMigrationStatusCommand']), diff --git a/docs/openapi.json b/docs/openapi.json index 0ba8eb00c..5b705f0f7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -6,6 +6,562 @@ }, "servers": [], "paths": { + "\/users\/{username}\/statistics\/dashboard": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get all the data that's shown in the dashboard", + "description": "Get all the statistics and the order of the rows in the dashboard. When a row is collapsed / hidden by default, the data for the hidden row isn't sent.", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User is allowed to see the statistics of the requested user.", + "content": { + "application\/json": { + "schema": { + "type": "object", + "properties": { + "users": { + "type": "object", + "example": [ + { + "name": "user1" + }, + { + "name": "user2" + } + ] + }, + "totalPlayCount": { + "$ref": "#/components/schemas/plays" + }, + "uniqueMoviesCount" : { + "type": "integer", + "example": 1 + }, + "totalHoursWatched": { + "type": "number", + "example": 1 + }, + "averagePersonalRating": { + "type": "number", + "example": 1 + }, + "averagePlaysPerDay": { + "type": "number", + "example": 1 + }, + "averageRuntime": { + "type": "number", + "example": 1 + }, + "dashboardRows": { + "type": "array", + "description": "An array containing JSON objects of the dashboard rows. The order of the objects is the order of the rows.", + "example": [ + { + "row": "Last Plays", + "id": 0, + "isExtended": true, + "isVisible": true + }, + { + "row": "Latest in Watchlist", + "id": 9, + "isExtended": true, + "isVisible": true + }, + { + "row": "Most watched Actors", + "id": 1, + "isExtended": false, + "isVisible": true + }, + { + "row": "Most watched Actresses", + "id": 2, + "isExtended": false, + "isVisible": true + }, + { + "row": "Most watched Directors", + "id": 3, + "isExtended": false, + "isVisible": true + }, + { + "row": "Most watched Genres", + "id": 4, + "isExtended": false, + "isVisible": true + }, + { + "row": "Most watched Languages", + "id": 8, + "isExtended": false, + "isVisible": true + }, + { + "row": "Most watched Production Companies", + "id": 6, + "isExtended": false, + "isVisible": true + }, + { + "row": "Most watched Release Years", + "id": 7, + "isExtended": false, + "isVisible": true + } + ] + }, + "lastPlays": { + "type": "array", + "description": "An array containing JSON objects of the last played movies based on date.", + "items": { + "$ref": "#/components/schemas/movie" + } + }, + "mostWatchedActors": { + "type": "array", + "description": "An array containing JSON objects of the most watched male actors.", + "items": { + "$ref": "#/components/schemas/male" + } + }, + "mostWatchedActresses": { + "type": "array", + "description": "An array containing JSON objects of the most watched actresses.", + "items": { + "$ref": "#/components/schemas/female" + } + }, + "mostWatchedDirectors": { + "type": "array", + "description": "An array containing JSON objects of the most watched directors.", + "items": { + "$ref": "#/components/schemas/male" + } + }, + "mostWatchedLanguages": { + "type": "array", + "description": "An array containing JSON objects of the most watched languages.", + "items": { + "$ref": "#/components/schemas/language" + } + }, + "mostWatchedGenres": { + "type": "array", + "description": "An array containing JSON objects of the most watched genres.", + "items": { + "$ref": "#/components/schemas/genre" + } + }, + "mostWatchedProductionCompanies": { + "type": "array", + "description": "An array containing JSON objects of the most watched production companies, the watched movies they produced and their country of origin.", + "items": { + "$ref": "#/components/schemas/productioncompany" + } + }, + "mostWatchedReleaseYears": { + "type": "array", + "description": "An array containing JSON objects of the most watched release years.", + "items": { + "$ref": "#/components/schemas/releaseYears" + } + }, + "watchlistItems": { + "type": "array", + "description": "An array containing JSON objects of the items in the watchlist.", + "items": { + "$ref": "#/components/schemas/movie" + } + } + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + } + }, + "/users/{username}\/statistics/lastplays": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get last plays", + "description": "Gets the last plays for use in lazy-loading the data shown in the user's dashboard", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/movie" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}\/statistics/mostwatchedactors": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched actors", + "description": "Gets the most watched actors for use in lazy-loading the data shown in the user's dashboard", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/female" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}\/statistics/mostwatchedactresses": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched actresses", + "description": "Gets the most watched actresses for use in lazy-loading the data shown in the user's dashboard", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/male" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}\/statistics/mostwatcheddirectors": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched directors", + "description": "Gets the most watched directors for use in lazy-loading the data shown in the user's dashboard", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/male" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}/statistics/mostwatchedlanguages": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched languages", + "description": "Get list of the languages that have been watched the most, together with the amount of times they have been watched.", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/language" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}/statistics/mostwatchedgenres": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched genres", + "description": "Get list of the genres that have been watched the most, together with the amount of times they have been watched.", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/genre" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}/statistics/mostwatchedproductioncompanies": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched production companies", + "description": "Get list of the production companies that have been watched the most, together with the amount of times they have been watched, the watched movies they've produced and their country of origin.", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/productioncompany" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}/statistics/mostwatchedreleaseyears": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get most watched release years", + "description": "Get list of the release years that have been watched the most, together with the amount of times they have been watched.", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/releaseYears" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, + "/users/{username}/statistics/watchlist": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Get movies in the watchlist", + "description": "Get all the movies in the watchlist of the user.", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/movie" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "description": "The requested user does not exist." + } + } + } + }, "\/users\/{username}\/history\/movies": { "get": { "tags": [ @@ -1066,6 +1622,115 @@ } } }, + "/users/{username}/movies/{id}/rating": { + "post": { + "tags": [ + "Rating" + ], + "summary": "Update the rating of an user for a movie", + "description": "Update the rating of an user for a movie", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Name of user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "Movary ID of the movie", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "description": "Rating of the movie. It has to be an integer in the range of 0 - 10. If the rating is 0, then the rating will be deleted.", + "example": 10, + "minimum": 0, + "maximum": 10 + } + } + } + } + } + } + }, + "responses": { + "204": { + "$ref": "#/components/responses/204" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + }, + "security": [ + { + "authToken": [], + "cookieauth": [] + } + ] + } + }, + "\/fetchMovieRatingByTmdbdId": { + "get": { + "tags": [ + "Rating" + ], + "summary": "Get movie rating", + "description": "Get the movie rating from the current user. The movie is found by using the TMDB ID", + "parameters": [ + { + "name": "tmdbId", + "description": "The ID of the movie from TMDB", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The TMDB ID was valid and the ratings have been returned.", + "content": { + "application\/json": { + "schema": { + "type": "object", + "properties": { + "personalRating": { + "type": "integer", + "description": "The rating of the movie by the user. It will be null if there is no rating.", + "minimum": 1, + "maximum": 10 + } + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/403" + } + } + } + }, "/authentication/token": { "get": { "tags": [ @@ -1134,13 +1799,7 @@ "description": "Create an authentication token via email, password and optionally TOTP code. Add the token as X-Movary-Token header to further requests. Token lifetime 1d default, 30d with rememberMe.", "parameters": [ { - "in": "header", - "name": "X-Movary-Client", - "schema": { - "type": "string" - }, - "required": true, - "example": "Client Name" + "$ref": "#/components/parameters/deviceName" } ], "requestBody": { @@ -1251,10 +1910,244 @@ } } } + }, + "/create-user": { + "post": { + "tags": [ + "Account" + ], + "summary": "Create a new user", + "description": "Creates new user if one of the conditions are true: Public registration is enabled or the server has no users.", + "parameters": [ + { + "$ref": "#/components/parameters/deviceName" + } + ], + "requestBody": { + "description": "The request data in JSON format.", + "content": { + "application/json": { + "required": true, + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "myname@domain.com", + "description": "E-Mail address of the new user" + }, + "username": { + "type": "string", + "example": "A very cool username", + "description": "The username of the new user" + }, + "password": { + "type": "string", + "example": "My secure password", + "description": "The password of the new user" + }, + "repeatPassword": { + "type": "string", + "example": "My secure password", + "description": "The password of the new user but repeated for security." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Returned if the user was created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "description": "The id of the newly created user." + }, + "token": { + "type": "string", + "description": "The authentication token to be used in future requests." + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/400" + }, + "403": { + "$ref": "#/components/responses/403" + } + } + } } }, "components": { "schemas": { + "male": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The ID Movary has assigned this person", + "example": 32 + }, + "name": { + "type": "string", + "description": "The name of the person", + "example": "Peron name" + }, + "uniqueCount": { + "type": "integer", + "example": 1 + }, + "totalCount": { + "type": "integer", + "example": 1 + }, + "gender": { + "type": "string", + "example": "m", + "description": "Can be either f (for female) or m (for male)" + }, + "tmdb_poster_path": { + "type": "string", + "description": "The filepath of the poster from the TMDB API", + "example": "/tmdb_poster.jpg" + }, + "poster_path": { + "type": "string", + "description": "The filepath of the poster on the local Kovary installation from the root directory of Movary", + "example": "\/storage\/images\/person\/32.jpg" + } + } + }, + "female": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The ID Movary has assigned this person", + "example": 32 + }, + "name": { + "type": "string", + "description": "The name of the person", + "example": "Peron name" + }, + "uniqueCount": { + "type": "integer", + "example": 1 + }, + "totalCount": { + "type": "integer", + "example": 1 + }, + "gender": { + "type": "string", + "example": "f", + "description": "Can be either f (for female) or m (for male)" + }, + "tmdb_poster_path": { + "type": "string", + "description": "The filepath of the poster from the TMDB API", + "example": "/tmdb_poster.jpg" + }, + "poster_path": { + "type": "string", + "description": "The filepath of the poster on the local Kovary installation from the root directory of Movary", + "example": "\/storage\/images\/person\/32.jpg" + } + } + }, + "productioncompany": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the production company", + "example": "Disney" + }, + "count": { + "type": "integer", + "description": "The amount of watched movies this production company has released", + "example": 2 + }, + "origin_country": { + "type": "string", + "description": "The country where this production company was founded", + "example": "US" + }, + "movies": { + "type": "array", + "description": "The list of watched movies this production company has released", + "example": [ + "Movie 1", + "Movie 2" + ] + } + } + }, + "language": { + "type": "object", + "properties": { + "language": { + "type": "string", + "description": "Two-letter code of the language", + "example": "en" + }, + "count": { + "type": "integer", + "description": "The amount of times watched movies have occured in this language", + "example": 8 + }, + "name": { + "type": "string", + "description": "The name of the language in speaking format", + "example": "English" + }, + "code": { + "type": "string", + "description": "Two-letter code of the language", + "example": "en" + } + } + }, + "genre": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the genre", + "example": "Action" + }, + "count": { + "type": "integer", + "description": "The amount of times watched movies contain this genre", + "example": 8 + } + } + }, + "releaseYears": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The year in four digits", + "example": "2024" + }, + "count": { + "type": "integer", + "description": "The amount of times watched movies have been released in this year", + "example": 8 + } + } + }, "movie": { "type": "object", "properties": { @@ -1468,6 +2361,17 @@ "description": "The resource was not found" } }, + "parameters": { + "deviceName": { + "in": "header", + "name": "X-Movary-Client", + "schema": { + "type": "string" + }, + "required": true, + "example": "Client Name" + } + }, "securitySchemes": { "token": { "type": "apiKey", diff --git a/public/js/app.js b/public/js/app.js index 22d61a4ab..9b297f1c8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -435,7 +435,7 @@ function getCurrentDate() { * Rating star logic starting here */ async function fetchRating(tmdbId) { - const response = await fetch('/fetchMovieRatingByTmdbdId?tmdbId=' + tmdbId) + const response = await fetch('/api/fetchMovieRatingByTmdbdId?tmdbId=' + tmdbId) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) diff --git a/public/js/createnewuser.js b/public/js/createnewuser.js new file mode 100644 index 000000000..fd9890361 --- /dev/null +++ b/public/js/createnewuser.js @@ -0,0 +1,27 @@ +const MOVARY_CLIENT_IDENTIFIER = 'Movary Web'; +const button = document.getElementById('createNewUserBtn'); + +async function submitNewUser() { + await fetch('/api/create-user', { + 'method': 'POST', + 'headers': { + 'Content-Type': 'application/json', + 'X-Movary-Client': MOVARY_CLIENT_IDENTIFIER + }, + 'body': JSON.stringify({ + "email": document.getElementById('emailInput').value, + "username": document.getElementById('usernameInput').value, + "password": document.getElementById('passwordInput').value, + "repeatPassword": document.getElementById('repeatPasswordInput').value + }), + }).then(response => { + if(response.status === 200) { + window.location.href = '/'; + } else { + return response.json(); + } + }).then(error => { + document.getElementById('createUserResponse').innerText = error['message']; + document.getElementById('createUserResponse').classList.remove('d-none'); + }); +} \ No newline at end of file diff --git a/public/js/movie.js b/public/js/movie.js index 1bb9f1340..d2d4a7009 100644 --- a/public/js/movie.js +++ b/public/js/movie.js @@ -58,14 +58,16 @@ function getRouteUsername() { } function saveRating() { - let newRating = getRatingFromStars('editRatingModal') + let newRating = getRatingFromStars('editRatingModal'); - fetch('/users/' + getRouteUsername() + '/movies/' + getMovieId() + '/rating', { + fetch('/api/users/' + getRouteUsername() + '/movies/' + getMovieId() + '/rating', { method: 'post', headers: { - 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + 'Content-type': 'application/json' }, - body: 'rating=' + newRating + body: JSON.stringify({ + 'rating': newRating + }) }).then(function (response) { if (response.ok === false) { addAlert('editRatingModalDiv', 'Could not update rating.', 'danger') diff --git a/settings/routes.php b/settings/routes.php index 2de220432..a018f8a38 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -19,15 +19,9 @@ function addWebRoutes(RouterService $routerService, FastRoute\RouteCollector $ro $routes->add('GET', '/', [Web\LandingPageController::class, 'render'], [Web\Middleware\UserIsUnauthenticated::class, Web\Middleware\ServerHasNoUsers::class]); $routes->add('GET', '/login', [Web\AuthenticationController::class, 'renderLoginPage'], [Web\Middleware\UserIsUnauthenticated::class]); - $routes->add('POST', '/create-user', [Web\CreateUserController::class, 'createUser'], [ - Web\Middleware\UserIsUnauthenticated::class, - Web\Middleware\ServerHasUsers::class, - Web\Middleware\ServerHasRegistrationEnabled::class - ]); $routes->add('GET', '/create-user', [Web\CreateUserController::class, 'renderPage'], [ Web\Middleware\UserIsUnauthenticated::class, - Web\Middleware\ServerHasUsers::class, - Web\Middleware\ServerHasRegistrationEnabled::class + Api\Middleware\CreateUserMiddleware::class ]); $routes->add('GET', '/docs/api', [Web\OpenApiController::class, 'renderPage']); @@ -191,13 +185,8 @@ function addWebRoutes(RouterService $routerService, FastRoute\RouteCollector $ro Web\HistoryController::class, 'createHistoryEntry' ], [Web\Middleware\UserIsAuthenticated::class]); - $routes->add('POST', '/users/{username:[a-zA-Z0-9]+}/movies/{id:\d+}/rating', [ - Web\Movie\MovieRatingController::class, - 'updateRating' - ], [Web\Middleware\UserIsAuthenticated::class]); $routes->add('POST', '/log-movie', [Web\HistoryController::class, 'logMovie'], [Web\Middleware\UserIsAuthenticated::class]); $routes->add('POST', '/add-movie-to-watchlist', [Web\WatchlistController::class, 'addMovieToWatchlist'], [Web\Middleware\UserIsAuthenticated::class]); - $routes->add('GET', '/fetchMovieRatingByTmdbdId', [Web\Movie\MovieRatingController::class, 'fetchMovieRatingByTmdbdId'], [Web\Middleware\UserIsAuthenticated::class]); $routerService->addRoutesToRouteCollector($routeCollector, $routes, true); } @@ -210,6 +199,10 @@ function addApiRoutes(RouterService $routerService, FastRoute\RouteCollector $ro $routes->add('POST', '/authentication/token', [Api\AuthenticationController::class, 'createToken']); $routes->add('DELETE', '/authentication/token', [Api\AuthenticationController::class, 'destroyToken']); $routes->add('GET', '/authentication/token', [Api\AuthenticationController::class, 'getTokenData']); + $routes->add('POST', '/create-user', [Api\CreateUserController::class, 'createUser'], [Api\Middleware\IsUnauthenticated::class, Api\Middleware\CreateUserMiddleware::class]); + + $routes->add('GET', '/users/{username:[a-zA-Z0-9]+}/statistics/dashboard', [Api\StatisticsController::class, 'getDashboardData'], [Api\Middleware\IsAuthorizedToReadUserData::class]); + $routes->add('GET', '/users/{username:[a-zA-Z0-9]+}/statistics/{statistic:[a-zA-Z]+}', [Api\StatisticsController::class, 'getStatistic'], [Api\Middleware\IsAuthorizedToReadUserData::class]); $routeUserHistory = '/users/{username:[a-zA-Z0-9]+}/history/movies'; $routes->add('GET', $routeUserHistory, [Api\HistoryController::class, 'getHistory'], [Api\Middleware\IsAuthorizedToReadUserData::class]); @@ -230,6 +223,9 @@ function addApiRoutes(RouterService $routerService, FastRoute\RouteCollector $ro $routes->add('GET', '/movies/search', [Api\MovieSearchController::class, 'search'], [Api\Middleware\IsAuthenticated::class]); + $routes->add('POST', '/users/{username:[a-zA-Z0-9]+}/movies/{id:\d+}/rating', [Api\MovieRatingController::class, 'updateRating'], [Api\Middleware\IsAuthorizedToWriteUserData::class]); + $routes->add('GET', '/fetchMovieRatingByTmdbdId', [Api\MovieRatingController::class, 'fetchMovieRatingByTmdbdId'], [Api\Middleware\IsAuthenticated::class]); + $routes->add('POST', '/webhook/plex/{id:.+}', [Api\PlexController::class, 'handlePlexWebhook']); $routes->add('POST', '/webhook/jellyfin/{id:.+}', [Api\JellyfinController::class, 'handleJellyfinWebhook']); $routes->add('POST', '/webhook/emby/{id:.+}', [Api\EmbyController::class, 'handleEmbyWebhook']); diff --git a/src/Domain/User/Service/Authentication.php b/src/Domain/User/Service/Authentication.php index 8d0713217..f557e309f 100644 --- a/src/Domain/User/Service/Authentication.php +++ b/src/Domain/User/Service/Authentication.php @@ -9,7 +9,7 @@ use Movary\Domain\User\UserApi; use Movary\Domain\User\UserEntity; use Movary\Domain\User\UserRepository; -use Movary\HttpController\Web\CreateUserController; +use Movary\HttpController\Api\CreateUserController; use Movary\Util\SessionWrapper; use Movary\ValueObject\DateTime; use Movary\ValueObject\Http\Request; diff --git a/src/Domain/User/Service/Validator.php b/src/Domain/User/Service/Validator.php index 5b5cd024f..79786bed8 100644 --- a/src/Domain/User/Service/Validator.php +++ b/src/Domain/User/Service/Validator.php @@ -48,6 +48,9 @@ public function ensureNameIsUnique(string $name, ?int $expectUserId = null) : vo } } + /** + * @throws PasswordTooShort + */ public function ensurePasswordIsValid(string $password) : void { if (strlen($password) < self::PASSWORD_MIN_LENGTH) { diff --git a/src/Factory.php b/src/Factory.php index 997a5a8fb..09a798833 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -20,6 +20,7 @@ use Movary\Domain\User\Service\Authentication; use Movary\Domain\User\UserApi; use Movary\HttpController\Api\OpenApiController; +use Movary\HttpController\Web\AuthenticationController; use Movary\HttpController\Web\CreateUserController; use Movary\HttpController\Web\JobController; use Movary\HttpController\Web\LandingPageController; @@ -64,6 +65,15 @@ class Factory private const bool DEFAULT_ENABLE_FILE_LOGGING = true; + public static function createAuthenticationController(ContainerInterface $container) : AuthenticationController + { + return new AuthenticationController( + $container->get(Twig\Environment::class), + $container->get(Authentication::class), + $container->get(SessionWrapper::class) + ); + } + public static function createConfig(ContainerInterface $container) : Config { $dotenv = Dotenv::createMutable(self::createDirectoryAppRoot()); @@ -74,7 +84,7 @@ public static function createConfig(ContainerInterface $container) : Config return new Config( $container->get(File::class), - array_merge($fpmEnvironment, $systemEnvironment), + array_merge($fpmEnvironment, $systemEnvironment) ); } @@ -91,9 +101,7 @@ public static function createCreateUserController(ContainerInterface $container) { return new CreateUserController( $container->get(Twig\Environment::class), - $container->get(Authentication::class), $container->get(UserApi::class), - $container->get(SessionWrapper::class), ); } @@ -106,7 +114,7 @@ public static function createDatabaseMigrationMigrateCommand(ContainerInterface { return new Command\DatabaseMigrationMigrate( $container->get(PhinxApplication::class), - self::createDirectoryAppRoot() . 'settings/phinx.php', + self::createDirectoryAppRoot() . 'settings/phinx.php' ); } @@ -114,7 +122,7 @@ public static function createDatabaseMigrationRollbackCommand(ContainerInterface { return new Command\DatabaseMigrationRollback( $container->get(PhinxApplication::class), - self::createDirectoryAppRoot() . 'settings/phinx.php', + self::createDirectoryAppRoot() . 'settings/phinx.php' ); } @@ -122,7 +130,7 @@ public static function createDatabaseMigrationStatusCommand(ContainerInterface $ { return new Command\DatabaseMigrationStatus( $container->get(PhinxApplication::class), - self::createDirectoryAppRoot() . 'settings/phinx.php', + self::createDirectoryAppRoot() . 'settings/phinx.php' ); } @@ -191,7 +199,7 @@ public static function createJobController(ContainerInterface $container) : JobC $container->get(JobQueueApi::class), $container->get(LetterboxdCsvValidator::class), $container->get(SessionWrapper::class), - self::createDirectoryStorageApp(), + self::createDirectoryStorageApp() ); } @@ -199,7 +207,7 @@ public static function createJobQueueScheduler(ContainerInterface $container, Co { return new JobQueueScheduler( $container->get(JobQueueApi::class), - self::getTmdbEnabledImageCaching($config), + self::getTmdbEnabledImageCaching($config) ); } @@ -240,10 +248,11 @@ public static function createLogger(ContainerInterface $container, Config $confi return $logger; } - public static function createMiddlewareServerHasRegistrationEnabled(Config $config) : HttpController\Web\Middleware\ServerHasRegistrationEnabled + public static function createCreateUserMiddleware(Config $config, ContainerInterface $container) : HttpController\Api\Middleware\CreateUserMiddleware { - return new HttpController\Web\Middleware\ServerHasRegistrationEnabled( - $config->getAsBool('ENABLE_REGISTRATION', false), + return new HttpController\Api\Middleware\CreateUserMiddleware( + $container->get(UserApi::class), + $config->getAsBool('ENABLE_REGISTRATION', false) ); } @@ -260,7 +269,7 @@ public static function createTmdbApiClient(ContainerInterface $container) : Tmdb { return new Tmdb\TmdbClient( $container->get(ClientInterface::class), - $container->get(ServerSettings::class), + $container->get(ServerSettings::class) ); } @@ -324,7 +333,7 @@ public static function createUrlGenerator(ContainerInterface $container, Config return new UrlGenerator( $container->get(TmdbUrlGenerator::class), $container->get(ImageCacheService::class), - self::getTmdbEnabledImageCaching($config), + self::getTmdbEnabledImageCaching($config) ); } @@ -372,7 +381,7 @@ private static function createLoggerStreamHandlerFile(ContainerInterface $contai { $streamHandler = new StreamHandler( self::createDirectoryStorageLogs() . 'app.log', - self::getLogLevel($config), + self::getLogLevel($config) ); $streamHandler->setFormatter($container->get(LineFormatter::class)); diff --git a/src/HttpController/Api/CreateUserController.php b/src/HttpController/Api/CreateUserController.php new file mode 100644 index 000000000..63e20364b --- /dev/null +++ b/src/HttpController/Api/CreateUserController.php @@ -0,0 +1,115 @@ +userApi->hasUsers(); + $jsonData = Json::decode($request->getBody()); + + $deviceName = $request->getHeaders()['X-Movary-Client'] ?? null; + if(empty($deviceName)) { + return Response::createBadRequest('No client header'); + } + $userAgent = $request->getUserAgent(); + + $email = empty($jsonData['email']) === true ? null : (string)$jsonData['email']; + $username = empty($jsonData['username']) === true ? null : (string)$jsonData['username']; + $password = empty($jsonData['password']) === true ? null : (string)$jsonData['password']; + $repeatPassword = empty($jsonData['repeatPassword']) === true ? null : (string)$jsonData['repeatPassword']; + + if ($email === null || $username === null || $password === null || $repeatPassword === null) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'MissingInput', + 'message' => 'Email, username, password or the password repeat is missing' + ]), + [Header::createContentTypeJson()], + ); + } + + if ($password !== $repeatPassword) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'PasswordsNotEqual', + 'message' => 'The repeated password is not the same as the password' + ]), + [Header::createContentTypeJson()], + ); + } + + try { + $this->userApi->createUser($email, $password, $username, $hasUsers === false); + $userAndAuthToken = $this->authenticationService->login($email, $password, false, $deviceName, $userAgent); + + return Response::createJson( + Json::encode([ + 'userId' => $userAndAuthToken['user']->getId(), + 'token' => $userAndAuthToken['token'] + ]), + ); + } catch (UsernameInvalidFormat) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'UsernameInvalidFormat', + 'message' => 'Username can only contain letters or numbers' + ]), + [Header::createContentTypeJson()], + ); + } catch (UsernameNotUnique) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'UsernameNotUnique', + 'message' => 'Username is already taken' + ]), + [Header::createContentTypeJson()], + ); + } catch (EmailNotUnique) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'EmailNotUnique', + 'message' => 'Email is already taken' + ]), + [Header::createContentTypeJson()], + ); + } catch(PasswordTooShort) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'PasswordTooShort', + 'message' => 'Password must be at least 8 characters' + ]), + [Header::createContentTypeJson()], + ); + } catch (Exception) { + return Response::createBadRequest( + Json::encode([ + 'error' => 'GenericError', + 'message' => 'Something has gone wrong. Please check the logs and try again later.' + ]), + [Header::createContentTypeJson()], + ); + } + } +} diff --git a/src/HttpController/Api/Middleware/CreateUserMiddleware.php b/src/HttpController/Api/Middleware/CreateUserMiddleware.php new file mode 100644 index 000000000..301ba51b8 --- /dev/null +++ b/src/HttpController/Api/Middleware/CreateUserMiddleware.php @@ -0,0 +1,26 @@ +registrationEnabled === false && $this->userApi->hasUsers() === true) { + return Response::createForbidden(); + } + + return null; + } +} diff --git a/src/HttpController/Api/Middleware/IsUnauthenticated.php b/src/HttpController/Api/Middleware/IsUnauthenticated.php new file mode 100644 index 000000000..47f39ea6f --- /dev/null +++ b/src/HttpController/Api/Middleware/IsUnauthenticated.php @@ -0,0 +1,24 @@ +authenticationService->getUserIdByApiToken($request) !== null) { + return Response::createForbidden(); + } + + return null; + } +} diff --git a/src/HttpController/Web/Movie/MovieRatingController.php b/src/HttpController/Api/MovieRatingController.php similarity index 76% rename from src/HttpController/Web/Movie/MovieRatingController.php rename to src/HttpController/Api/MovieRatingController.php index 8bd1a1acc..267a59853 100644 --- a/src/HttpController/Web/Movie/MovieRatingController.php +++ b/src/HttpController/Api/MovieRatingController.php @@ -1,6 +1,6 @@ authenticationService->getCurrentUserId(); + $userId = $this->authenticationService->getUserIdByApiToken($request); $tmdbId = $request->getGetParameters()['tmdbId'] ?? null; $userRating = null; $movie = $this->movieApi->findByTmdbId((int)$tmdbId); + if($userId === null) { + return Response::createForbidden(); + } if ($movie !== null) { $userRating = $this->movieApi->findUserRating($movie->getId(), $userId); } @@ -39,7 +42,10 @@ public function fetchMovieRatingByTmdbdId(Request $request) : Response public function updateRating(Request $request) : Response { - $userId = $this->authenticationService->getCurrentUserId(); + $userId = $this->authenticationService->getUserIdByApiToken($request); + if($userId === null) { + return Response::createForbidden(); + } if ($this->userApi->fetchUser($userId)->getName() !== $request->getRouteParameters()['username']) { return Response::createForbidden(); @@ -47,14 +53,14 @@ public function updateRating(Request $request) : Response $movieId = (int)$request->getRouteParameters()['id']; - $postParameters = $request->getPostParameters(); + $postParameters = Json::decode($request->getBody()); $personalRating = null; if (empty($postParameters['rating']) === false && $postParameters['rating'] !== 0) { $personalRating = PersonalRating::create((int)$postParameters['rating']); } - $this->movieApi->updateUserRating($movieId, $this->authenticationService->getCurrentUserId(), $personalRating); + $this->movieApi->updateUserRating($movieId, $userId, $personalRating); return Response::create(StatusCode::createNoContent()); } diff --git a/src/HttpController/Api/StatisticsController.php b/src/HttpController/Api/StatisticsController.php new file mode 100644 index 000000000..32906152e --- /dev/null +++ b/src/HttpController/Api/StatisticsController.php @@ -0,0 +1,130 @@ +userApi->findUserByName((string)$request->getRouteParameters()['username']); + if ($requestedUser === null) { + return Response::createNotFound(); + } + $userId = $requestedUser->getId(); + + $dashboardRows = $this->dashboardFactory->createDashboardRowsForUser($requestedUser); + + $response = [ + 'users' => $this->userPageAuthorizationChecker->fetchAllVisibleUsernamesForCurrentVisitor(), + 'totalPlayCount' => $this->movieApi->fetchTotalPlayCount($userId), + 'uniqueMoviesCount' => $this->movieApi->fetchTotalPlayCountUnique($userId), + 'totalHoursWatched' => $this->movieHistoryApi->fetchTotalHoursWatched($userId), + 'averagePersonalRating' => $this->movieHistoryApi->fetchAveragePersonalRating($userId), + 'averagePlaysPerDay' => $this->movieHistoryApi->fetchAveragePlaysPerDay($userId), + 'averageRuntime' => $this->movieHistoryApi->fetchAverageRuntime($userId), + 'dashboardRows' => $dashboardRows->asArray(), + 'lastPlays' => [], + 'mostWatchedActors' => [], + 'mostWatchedActresses' => [], + 'mostWatchedDirectors' => [], + 'mostWatchedLanguages' => [], + 'mostWatchedGenres' => [], + 'mostWatchedProductionCompanies' => [], + 'mostWatchedReleaseYears' => [], + 'watchlistItems' => [], + ]; + + foreach($dashboardRows as $row) { + if($row->isExtended() && $row->isVisible()) { + if($row->isLastPlays()) { + $response['lastPlays'] = $this->movieHistoryApi->fetchLastPlays($userId); + } elseif($row->isMostWatchedActors()) { + $response['mostWatchedActors'] = $this->movieHistoryApi->fetchActors($userId, 6, 1, gender: Gender::createMale(), personFilterUserId: $userId); + } elseif($row->isMostWatchedActresses()) { + $response['mostWatchedActresses'] = $this->movieHistoryApi->fetchActors($userId, 6, 1, gender: Gender::createFemale(), personFilterUserId: $userId); + } elseif($row->isMostWatchedDirectors()) { + $response['mostWatchedDirectors'] = $this->movieHistoryApi->fetchDirectors($userId, 6, 1, personFilterUserId: $userId); + } elseif($row->isMostWatchedLanguages()) { + $response['mostWatchedLanguages'] = $this->movieHistoryApi->fetchMostWatchedLanguages($userId); + } elseif($row->isMostWatchedGenres()) { + $response['mostWatchedGenres'] = $this->movieHistoryApi->fetchMostWatchedGenres($userId); + } elseif($row->isMostWatchedProductionCompanies()) { + $response['mostWatchedProductionCompanies'] = $this->movieHistoryApi->fetchMostWatchedProductionCompanies($userId, 12); + } elseif($row->isMostWatchedReleaseYears()) { + $response['mostWatchedReleaseYears'] = $this->movieHistoryApi->fetchMostWatchedReleaseYears($userId); + } elseif($row->isWatchlist()) { + $response['watchlistItems'] = $this->movieWatchlistApi->fetchWatchlistPaginated($userId, 6, 1); + } + } + } + return Response::createJson(Json::encode($response)); + } + + // phpcs:ignore + public function getStatistic(Request $request) : Response + { + $requestedUser = $this->userApi->findUserByName((string)$request->getRouteParameters()['username']); + if ($requestedUser === null) { + return Response::createNotFound(); + } + $userId = $requestedUser->getId(); + $requestedStatistic = strtolower($request->getRouteParameters()['statistic'] ?? ''); + $response = null; + switch($requestedStatistic) { + case 'lastplays': + $response = $this->movieHistoryApi->fetchLastPlays($userId); + break; + case 'mostwatchedactors': + $response = $this->movieHistoryApi->fetchActors($userId, 6, 1, gender: Gender::createMale(), personFilterUserId: $userId); + break; + case 'mostwatchedactresses': + $response = $this->movieHistoryApi->fetchActors($userId, 6, 1, gender: Gender::createFemale(), personFilterUserId: $userId); + break; + case 'mostwatcheddirectors': + $response = $this->movieHistoryApi->fetchDirectors($userId, 6, 1, personFilterUserId: $userId); + break; + case 'mostwatchedlanguages': + $response = $this->movieHistoryApi->fetchMostWatchedLanguages($userId); + break; + case 'mostwatchedgenres': + $response = $this->movieHistoryApi->fetchMostWatchedGenres($userId); + break; + case 'mostwatchedproductioncompanies': + $response = $this->movieHistoryApi->fetchMostWatchedProductionCompanies($userId, 12); + break; + case 'mostwatchedreleaseyears': + $response = $this->movieHistoryApi->fetchMostWatchedReleaseYears($userId); + break; + case 'watchlist': + $response = $this->movieWatchlistApi->fetchWatchlistPaginated($userId, 6, 1); + break; + } + if($response === null) { + return Response::createNotFound(); + } + return Response::createJson(Json::encode($response)); + } +} diff --git a/src/HttpController/Web/AuthenticationController.php b/src/HttpController/Web/AuthenticationController.php index dbcbcc9bc..5dbc9754e 100644 --- a/src/HttpController/Web/AuthenticationController.php +++ b/src/HttpController/Web/AuthenticationController.php @@ -14,6 +14,7 @@ class AuthenticationController public function __construct( private readonly Environment $twig, private readonly SessionWrapper $sessionWrapper, + private readonly bool $registrationEnabled ) { } @@ -27,7 +28,8 @@ public function renderLoginPage(Request $request) : Response 'page/login.html.twig', [ 'failedLogin' => $failedLogin, - 'redirect' => $redirect + 'redirect' => $redirect, + 'registrationEnabled' => $this->registrationEnabled ], ); diff --git a/src/HttpController/Web/CreateUserController.php b/src/HttpController/Web/CreateUserController.php index 889a2547e..cedf8e17c 100644 --- a/src/HttpController/Web/CreateUserController.php +++ b/src/HttpController/Web/CreateUserController.php @@ -2,120 +2,27 @@ namespace Movary\HttpController\Web; -use Movary\Domain\User\Exception\EmailNotUnique; -use Movary\Domain\User\Exception\PasswordTooShort; -use Movary\Domain\User\Exception\UsernameInvalidFormat; -use Movary\Domain\User\Exception\UsernameNotUnique; -use Movary\Domain\User\Service\Authentication; use Movary\Domain\User\UserApi; -use Movary\Util\SessionWrapper; -use Movary\ValueObject\Http\Header; -use Movary\ValueObject\Http\Request; use Movary\ValueObject\Http\Response; use Movary\ValueObject\Http\StatusCode; -use Throwable; use Twig\Environment; class CreateUserController { - public const string MOVARY_WEB_CLIENT = 'Movary Web'; - public function __construct( private readonly Environment $twig, - private readonly Authentication $authenticationService, private readonly UserApi $userApi, - private readonly SessionWrapper $sessionWrapper, ) { } - // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh - public function createUser(Request $request) : Response - { - $hasUsers = $this->userApi->hasUsers(); - $postParameters = $request->getPostParameters(); - - $userAgent = $request->getUserAgent(); - $email = empty($postParameters['email']) === true ? null : (string)$postParameters['email']; - $name = empty($postParameters['name']) === true ? null : (string)$postParameters['name']; - $password = empty($postParameters['password']) === true ? null : (string)$postParameters['password']; - $repeatPassword = empty($postParameters['password']) === true ? null : (string)$postParameters['repeatPassword']; - - if ($email === null || $name === null || $password === null || $repeatPassword === null) { - $this->sessionWrapper->set('missingFormData', true); - - return Response::create( - StatusCode::createSeeOther(), - null, - [Header::createLocation($_SERVER['HTTP_REFERER'])], - ); - } - - if ($password !== $repeatPassword) { - $this->sessionWrapper->set('errorPasswordNotEqual', true); - - return Response::create( - StatusCode::createSeeOther(), - null, - [Header::createLocation($_SERVER['HTTP_REFERER'])], - ); - } - - try { - $this->userApi->createUser($email, $password, $name, $hasUsers === false); - - $this->authenticationService->login($email, $password, false, self::MOVARY_WEB_CLIENT, $userAgent); - } catch (PasswordTooShort) { - $this->sessionWrapper->set('errorPasswordTooShort', true); - } catch (UsernameInvalidFormat) { - $this->sessionWrapper->set('errorUsernameInvalidFormat', true); - } catch (UsernameNotUnique) { - $this->sessionWrapper->set('errorUsernameUnique', true); - } catch (EmailNotUnique) { - $this->sessionWrapper->set('errorEmailUnique', true); - } catch (Throwable) { - $this->sessionWrapper->set('errorGeneric', true); - } - - return Response::create( - StatusCode::createSeeOther(), - null, - [Header::createLocation($_SERVER['HTTP_REFERER'])], - ); - } - public function renderPage() : Response { $hasUsers = $this->userApi->hasUsers(); - $errorPasswordTooShort = $this->sessionWrapper->find('errorPasswordTooShort'); - $errorPasswordNotEqual = $this->sessionWrapper->find('errorPasswordNotEqual'); - $errorUsernameInvalidFormat = $this->sessionWrapper->find('errorUsernameInvalidFormat'); - $errorUsernameUnique = $this->sessionWrapper->find('errorUsernameUnique'); - $errorEmailUnique = $this->sessionWrapper->find('errorEmailUnique'); - $missingFormData = $this->sessionWrapper->find('missingFormData'); - $errorGeneric = $this->sessionWrapper->find('errorGeneric'); - - $this->sessionWrapper->unset( - 'errorPasswordTooShort', - 'errorPasswordNotEqual', - 'errorUsernameInvalidFormat', - 'errorUsernameUnique', - 'errorEmailUnique', - 'errorGeneric', - 'missingFormData', - ); - return Response::create( StatusCode::createOk(), $this->twig->render('page/create-user.html.twig', [ 'subtitle' => $hasUsers === false ? 'Create initial admin user' : 'Create new user', - 'errorPasswordTooShort' => $errorPasswordTooShort, - 'errorPasswordNotEqual' => $errorPasswordNotEqual, - 'errorUsernameInvalidFormat' => $errorUsernameInvalidFormat, - 'errorUsernameUnique' => $errorUsernameUnique, - 'errorEmailUnique' => $errorEmailUnique, - 'errorGeneric' => $errorGeneric, - 'missingFormData' => $missingFormData ]), ); } diff --git a/src/HttpController/Web/Middleware/ServerHasRegistrationEnabled.php b/src/HttpController/Web/Middleware/ServerHasRegistrationEnabled.php deleted file mode 100644 index 568e27704..000000000 --- a/src/HttpController/Web/Middleware/ServerHasRegistrationEnabled.php +++ /dev/null @@ -1,24 +0,0 @@ -registrationEnabled === false) { - return null; - } - - return Response::createForbidden(); - } -} diff --git a/src/HttpController/Web/Middleware/ServerHasUsers.php b/src/HttpController/Web/Middleware/ServerHasUsers.php deleted file mode 100644 index 66277f9db..000000000 --- a/src/HttpController/Web/Middleware/ServerHasUsers.php +++ /dev/null @@ -1,25 +0,0 @@ -userApi->hasUsers() === false) { - return null; - } - - return Response::createSeeOther('/'); - } -} diff --git a/src/Service/Dashboard/Dto/DashboardRowList.php b/src/Service/Dashboard/Dto/DashboardRowList.php index 3a82de9c7..b6b702f51 100644 --- a/src/Service/Dashboard/Dto/DashboardRowList.php +++ b/src/Service/Dashboard/Dto/DashboardRowList.php @@ -33,4 +33,18 @@ public function addAtOffset(int $position, DashboardRow $dashboardRow) : void ksort($this->data); } + + public function asArray() : array + { + $serialized = []; + foreach($this->data as $row) { + $serialized[] = [ + 'row' => $row->getName(), + 'id' => $row->getId(), + 'isExtended' => $row->isExtended(), + 'isVisible' => $row->isVisible() + ]; + } + return $serialized; + } } diff --git a/templates/page/create-user.html.twig b/templates/page/create-user.html.twig index 4ca506e92..8f9d522b5 100644 --- a/templates/page/create-user.html.twig +++ b/templates/page/create-user.html.twig @@ -8,61 +8,37 @@ {% endblock %} +{% block scripts %} + +{% endblock %} + {% block body %} - + Movary {{ subtitle }} - - Email address * + + Email address * - - Username * + + Username * - - Password * + + Password * - - Repeat password * + + Repeat password * - {% if errorPasswordNotEqual == true %} - - Passwords not equal - - {% endif %} - {% if errorPasswordTooShort == true %} - - Password must be at least 8 characters - - {% endif %} - {% if errorUsernameInvalidFormat == true %} - - Username must consist of only letters and numbers - - {% endif %} - {% if errorUsernameUnique == true %} - - Username is already used - - {% endif %} - {% if errorEmailUnique == true %} - - Email is already used - - {% endif %} - {% if missingFormData == true %} - - Please fill out all fields - - {% endif %} - Create + + Create + Return to login {% endblock %} diff --git a/tests/rest/api/create-user.http b/tests/rest/api/create-user.http new file mode 100644 index 000000000..a7fc03eec --- /dev/null +++ b/tests/rest/api/create-user.http @@ -0,0 +1,187 @@ +#@name Test creating a new user properly +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "myname@domain.com", + "username": "Averycoolusername", + "password": "Mysecurepassword", + "repeatPassword": "Mysecurepassword" +} + +> {% + client.test("User created", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); + client.global.set('responseAuthToken', response.body.token); +%} + +### +#@name Test missing input error + +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "", + "username": "", + "password": "", + "repeatPassword": "" +} + +> {% + client.test("Missing input", function() { + let expectedStatusCode = 400; + let expectedError = 'MissingInput' + client.assert(response.status === expectedStatusCode, "Response status is not: " + expectedStatusCode); + client.assert(response.body['error'] === expectedError, 'Response error was not: ' + expectedError); + client.assert(response.body['message'] === 'Email, username, password or the password repeat is missing', 'Response was not as expected'); + }); +%} + +### +#@name Test passwords not equal error +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "myname@domain.com", + "username": "Averycoolusername", + "password": "Mysecurepassword", + "repeatPassword": "Adifferentpassword" +} + +> {% + client.test("Passwords not equal", function() { + let expectedStatusCode = 400; + let expectedError = 'PasswordsNotEqual' + client.assert(response.status === expectedStatusCode, "Response status is not: " + expectedStatusCode); + client.assert(response.body['error'] === expectedError, 'Response error was not: ' + expectedError); + }); +%} + +### +#@name Test email already exists error + +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "myname@domain.com", + "username": "MyUniqueUsername", + "password": "Mysecurepassword", + "repeatPassword": "Mysecurepassword" +} + +> {% + client.test("Email already exists", function() { + let expectedStatusCode = 400; + let expectedError = 'EmailNotUnique' + client.assert(response.status === expectedStatusCode, "Response status is not: " + expectedStatusCode); + client.assert(response.body['error'] === expectedError, 'Response error was not: ' + expectedError); + }); +%} + +### +#@name Test password is shorter than 8 characters + +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "myuniqueaddress@domain.com", + "username": "MyUniqueUsername", + "password": "short", + "repeatPassword": "short" +} + +> {% + client.test("Password too short", function() { + let expectedStatusCode = 400; + let expectedError = 'PasswordTooShort' + client.assert(response.status === expectedStatusCode, "Response status is not: " + expectedStatusCode); + client.assert(response.body['error'] === expectedError, 'Response error was not: ' + expectedError); + }); +%} + +### +#@name Test invalid username error + +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "myuniqueaddress@domain.com", + "username": "An invalid username!!!!", + "password": "Mysecurepassword", + "repeatPassword": "Mysecurepassword" +} + +> {% + client.test("Invalid username", function() { + let expectedStatusCode = 400; + let expectedError = 'UsernameInvalidFormat' + client.assert(response.status === expectedStatusCode, "Response status is not: " + expectedStatusCode); + client.assert(response.body['error'] === expectedError, 'Response error was not: ' + expectedError); + }); +%} + +### +#@name Test username already exists error + +POST http://127.0.0.1/api/create-user +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test + +{ + "email": "myuniqueaddress@domain.com", + "username": "Averycoolusername", + "password": "Mysecurepassword", + "repeatPassword": "Mysecurepassword" +} + +> {% + client.test("Username already exists", function() { + let expectedStatusCode = 400; + let expectedError = 'UsernameNotUnique' + client.assert(response.status === expectedStatusCode, "Response status is not: " + expectedStatusCode); + client.assert(response.body['error'] === expectedError, 'Response error was not: ' + expectedError); + }); +%} + +### +#@name Delete the test user since all tests are finished + +DELETE http://127.0.0.1/api/authentication/token +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: RestAPI Test +X-Movary-Token: {{responseAuthToken}} + +> {% + client.test("Response has correct status code", function() { + let expected = 204 + client.assert(response.status === expected, "Expected status code: " + expected); + }); +%} diff --git a/tests/rest/api/movie-rating.http b/tests/rest/api/movie-rating.http new file mode 100644 index 000000000..605454357 --- /dev/null +++ b/tests/rest/api/movie-rating.http @@ -0,0 +1,18 @@ +@tmdbId = 329 +GET http://127.0.0.1/api/fetchMovieRatingByTmdbdId?tmdbId={{tmdbId}} +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Auth-Token: {{xAuthToken}} + +### + +POST http://127.0.0.1/api/users/{{username}}/movies/1/rating +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Auth-Token: {{xAuthToken}} + +{ + "rating": 10 +} \ No newline at end of file diff --git a/tests/rest/api/user-statistics.http b/tests/rest/api/user-statistics.http new file mode 100644 index 000000000..97e68fab5 --- /dev/null +++ b/tests/rest/api/user-statistics.http @@ -0,0 +1,78 @@ +#@name Dashboard statistics +GET http://127.0.0.1/api/users/{{username}}/statistics/dashboard +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Last plays +GET http://127.0.0.1/api/users/{{username}}/statistics/lastplays +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched actors +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatchedactors +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched actresses +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatchedactresses +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched directors +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatcheddirectors +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched languages +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatchedlanguages +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched genres +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatchedgenres +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched production companies +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatchedproductioncompanies +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Most watched release years +GET http://127.0.0.1/api/users/{{username}}/statistics/mostwatchedreleaseyears +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test + +### +#@name Watchlist +GET http://127.0.0.1/api/users/{{username}}/statistics/watchlist +Accept: */* +Cache-Control: no-cache +Content-Type: application/json +X-Movary-Client: REST Unit Test
{{ subtitle }}