diff --git a/.env.example b/.env.example index 5278a54f9..6665c4b1a 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,7 @@ MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null + +API_PREFIX=api +API_VERSION=v1 +API_DEBUG=false diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 1ef91c60c..d3a4599a7 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -55,7 +55,7 @@ class Handler extends ExceptionHandler $e = new NotFoundHttpException($e->getMessage(), $e); } - if ($request->isXmlHttpRequest() || $request->ajax() || $request->is('api/*') || $request->is('remote/*')) { + if ($request->isXmlHttpRequest() || $request->ajax() || $request->is('remote/*')) { $exception = 'An exception occured while attempting to perform this action, please try again.'; diff --git a/app/Http/Controllers/API/BaseController.php b/app/Http/Controllers/API/BaseController.php new file mode 100644 index 000000000..b0ade2a64 --- /dev/null +++ b/app/Http/Controllers/API/BaseController.php @@ -0,0 +1,11 @@ +join('nodes', 'locations.id', '=', 'nodes.location') + ->groupBy('locations.id') + ->get(); + + foreach($locations as &$location) { + $location->nodes = explode(',', $location->nodes); + } + + return $locations; + } + +} diff --git a/app/Http/Controllers/API/NodeController.php b/app/Http/Controllers/API/NodeController.php new file mode 100644 index 000000000..71580e4e4 --- /dev/null +++ b/app/Http/Controllers/API/NodeController.php @@ -0,0 +1,177 @@ +response->paginator($nodes, new NodeTransformer); + } + + /** + * Create a New Node + * + * @Post("/nodes") + * @Versions({"v1"}) + * @Transaction({ + * @Request({ + * 'name' => 'My API Node', + * 'location' => 1, + * 'public' => 1, + * 'fqdn' => 'daemon.wuzzle.woo', + * 'scheme' => 'https', + * 'memory' => 10240, + * 'memory_overallocate' => 100, + * 'disk' => 204800, + * 'disk_overallocate' => -1, + * 'daemonBase' => '/srv/daemon-data', + * 'daemonSFTP' => 2022, + * 'daemonListen' => 8080 + * }, headers={"Authorization": "Bearer "}), + * @Response(201), + * @Response(422, body={ + * "message": "A validation error occured.", + * "errors": {}, + * "status_code": 422 + * }), + * @Response(503, body={ + * "message": "There was an error while attempting to add this node to the system.", + * "status_code": 503 + * }) + * }) + */ + public function postNode(Request $request) + { + try { + $node = new NodeRepository; + $new = $node->create($request->all()); + return $this->response->created(route('api.nodes.view', [ + 'id' => $new + ])); + } catch (DisplayValidationException $ex) { + throw new ResourceException('A validation error occured.', json_decode($ex->getMessage(), true)); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $e) { + throw new BadRequestHttpException('There was an error while attempting to add this node to the system.'); + } + } + + /** + * List Specific Node + * + * Lists specific fields about a server or all fields pertaining to that node. + * + * @Get("/nodes/{id}/{?fields}") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the node to get information on."), + * @Parameter("fields", type="string", required=false, description="A comma delimidated list of fields to include.") + * }) + * @Response(200) + */ + public function getNode(Request $request, $id, $fields = null) + { + $query = Models\Node::where('id', $id); + + if (!is_null($request->input('fields'))) { + foreach(explode(',', $request->input('fields')) as $field) { + if (!empty($field)) { + $query->addSelect($field); + } + } + } + + try { + if (!$query->first()) { + throw new NotFoundHttpException('No node by that ID was found.'); + } + return $query->first(); + } catch (NotFoundHttpException $ex) { + throw $ex; + } catch (\Exception $ex) { + throw new BadRequestHttpException('There was an issue with the fields passed in the request.'); + } + } + + /** + * List Node Allocations + * + * Returns a listing of all node allocations. + * + * @Get("/nodes/{id}/allocations") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the node to get allocations for."), + * }) + * @Response(200) + */ + public function getNodeAllocations(Request $request, $id) + { + $allocations = Models\Allocation::select('ip', 'port', 'assigned_to')->where('node', $id)->orderBy('ip', 'asc')->orderBy('port', 'asc')->get(); + if ($allocations->count() < 1) { + throw new NotFoundHttpException('No allocations where found for the requested node.'); + } + return $allocations; + } + + /** + * Delete Node + * + * @Delete("/nodes/{id}") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the node."), + * }) + * @Response(204) + */ + public function deleteNode(Request $request, $id) + { + try { + $node = new NodeRepository; + $node->delete($id); + return $this->response->noContent(); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch(\Exception $e) { + throw new ServiceUnavailableHttpException('An error occured while attempting to delete this node.'); + } + } + +} diff --git a/app/Http/Controllers/API/ServerController.php b/app/Http/Controllers/API/ServerController.php new file mode 100644 index 000000000..ab2f1b3b5 --- /dev/null +++ b/app/Http/Controllers/API/ServerController.php @@ -0,0 +1,179 @@ +response->paginator($servers, new ServerTransformer); + } + + /** + * Create Server + * + * @Post("/servers") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("page", type="integer", description="The page of results to view.", default=1) + * }) + * @Response(201) + */ + public function postServer(Request $request) + { + try { + $server = new ServerRepository; + $new = $server->create($request->all()); + return $this->response->created(route('api.servers.view', [ + 'id' => $new + ])); + } catch (DisplayValidationException $ex) { + throw new ResourceException('A validation error occured.', json_decode($ex->getMessage(), true)); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $e) { + throw new BadRequestHttpException('There was an error while attempting to add this server to the system.'); + } + } + + /** + * List Specific Server + * + * Lists specific fields about a server or all fields pertaining to that server. + * + * @Get("/servers/{id}{?fields}") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the server to get information on."), + * @Parameter("fields", type="string", required=false, description="A comma delimidated list of fields to include.") + * }) + * @Response(200) + */ + public function getServer(Request $request, $id) + { + $query = Models\Server::where('id', $id); + + if (!is_null($request->input('fields'))) { + foreach(explode(',', $request->input('fields')) as $field) { + if (!empty($field)) { + $query->addSelect($field); + } + } + } + + try { + if (!$query->first()) { + throw new NotFoundHttpException('No server by that ID was found.'); + } + return $query->first(); + } catch (NotFoundHttpException $ex) { + throw $ex; + } catch (\Exception $ex) { + throw new BadRequestHttpException('There was an issue with the fields passed in the request.'); + } + } + + /** + * Suspend Server + * + * @Post("/servers/{id}/suspend") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the server."), + * }) + * @Response(204) + */ + public function postServerSuspend(Request $request, $id) + { + try { + $server = new ServerRepository; + $server->suspend($id); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $ex) { + throw new ServiceUnavailableHttpException('An error occured while attempting to suspend this server instance.'); + } + } + + /** + * Unsuspend Server + * + * @Post("/servers/{id}/unsuspend") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the server."), + * }) + * @Response(204) + */ + public function postServerUnsuspend(Request $request, $id) + { + try { + $server = new ServerRepository; + $server->unsuspend($id); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $ex) { + throw new ServiceUnavailableHttpException('An error occured while attempting to unsuspend this server instance.'); + } + } + + /** + * Delete Server + * + * @Delete("/servers/{id}/{force}") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the server."), + * @Parameter("force", type="string", required=false, description="Use 'force' if the server should be removed regardless of daemon response."), + * }) + * @Response(204) + */ + public function deleteServer(Request $request, $id, $force = null) + { + try { + $server = new ServerRepository; + $server->deleteServer($id, $force); + return $this->response->noContent(); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch(\Exception $e) { + throw new ServiceUnavailableHttpException('An error occured while attempting to delete this server.'); + } + } + +} diff --git a/app/Http/Controllers/API/ServiceController.php b/app/Http/Controllers/API/ServiceController.php new file mode 100644 index 000000000..cd0ac1202 --- /dev/null +++ b/app/Http/Controllers/API/ServiceController.php @@ -0,0 +1,47 @@ +where('parent_service', $service->id)->get(); + foreach($options as &$opt) { + $opt->variables = Models\ServiceVariables::where('option_id', $opt->id)->get(); + } + + return [ + 'service' => $service, + 'options' => $options + ]; + + } + +} diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php index 958dce88d..db63b44d3 100644 --- a/app/Http/Controllers/API/UserController.php +++ b/app/Http/Controllers/API/UserController.php @@ -2,82 +2,180 @@ namespace Pterodactyl\Http\Controllers\API; -use Gate; -use Log; -use Debugbar; -use Pterodactyl\Models\API; -use Pterodactyl\Models\User; - -use Pterodactyl\Http\Controllers\Controller; use Illuminate\Http\Request; -class UserController extends Controller +use Dingo\Api\Exception\ResourceException; + +use Pterodactyl\Models; +use Pterodactyl\Transformers\UserTransformer; +use Pterodactyl\Repositories\UserRepository; + +use Pterodactyl\Exceptions\DisplayValidationException; +use Pterodactyl\Exceptions\DisplayException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; + +/** + * @Resource("Users") + */ +class UserController extends BaseController { /** - * Constructor + * List All Users + * + * Lists all users currently on the system. + * + * @Get("/users/{?page}") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("page", type="integer", description="The page of results to view.", default=1) + * }) + * @Response(200) */ - public function __construct() + public function getUsers(Request $request) { - // - } - - public function getAllUsers(Request $request) - { - - // Policies don't work if the user isn't logged in for whatever reason in Laravel... - if(!API::checkPermission($request->header('X-Authorization'), 'get-users')) { - return API::noPermissionError(); - } - - return response()->json([ - 'users' => User::all() - ]); + $users = Models\User::paginate(50); + return $this->response->paginator($users, new UserTransformer); } /** - * Returns JSON response about a user given their ID. - * If fields are provided only those fields are returned. + * List Specific User * - * Does not return protected fields (i.e. password & totp_secret) + * Lists specific fields about a user or all fields pertaining to that user. * - * @param Request $request - * @param int $id - * @param string $fields - * @return Response + * @Get("/users/{id}/{fields}") + * @Versions({"v1"}) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the user to get information on."), + * @Parameter("fields", type="string", required=false, description="A comma delimidated list of fields to include.") + * }) + * @Response(200) */ - public function getUser(Request $request, $id, $fields = null) + public function getUser(Request $request, $id) { + $query = Models\User::where('id', $id); - // Policies don't work if the user isn't logged in for whatever reason in Laravel... - if(!API::checkPermission($request->header('X-Authorization'), 'get-users')) { - return API::noPermissionError(); - } - - if (is_null($fields)) { - return response()->json(User::find($id)); - } - - $query = User::where('id', $id); - $explode = explode(',', $fields); - - foreach($explode as &$exploded) { - if(!empty($exploded)) { - $query->addSelect($exploded); + if (!is_null($request->input('fields'))) { + foreach(explode(',', $request->input('fields')) as $field) { + if (!empty($field)) { + $query->addSelect($field); + } } } try { - return response()->json($query->get()); - } catch (\Exception $e) { - if ($e instanceof \Illuminate\Database\QueryException) { - return response()->json([ - 'error' => 'One of the fields provided in your argument list is invalid.' - ], 500); + if (!$query->first()) { + throw new NotFoundHttpException('No user by that ID was found.'); } - throw $e; + return $query->first(); + } catch (NotFoundHttpException $ex) { + throw $ex; + } catch (\Exception $ex) { + throw new BadRequestHttpException('There was an issue with the fields passed in the request.'); } } + /** + * Create a New User + * + * @Post("/users") + * @Versions({"v1"}) + * @Transaction({ + * @Request({ + * "email": "foo@example.com", + * "password": "foopassword", + * "admin": false + * }, headers={"Authorization": "Bearer "}), + * @Response(201), + * @Response(422, body={ + * "message": "A validation error occured.", + * "errors": { + * "email": {"The email field is required."}, + * "password": {"The password field is required."}, + * "admin": {"The admin field is required."} + * }, + * "status_code": 422 + * }) + * }) + */ + public function postUser(Request $request) + { + try { + $user = new UserRepository; + $create = $user->create($request->input('email'), $request->input('password'), $request->input('admin')); + return $this->response->created(route('api.users.view', [ + 'id' => $create + ])); + } catch (DisplayValidationException $ex) { + throw new ResourceException('A validation error occured.', json_decode($ex->getMessage(), true)); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $ex) { + throw new ServiceUnavailableHttpException('Unable to create a user on the system due to an error.'); + } + } + + /** + * Update an Existing User + * + * The data sent in the request will be used to update the existing user on the system. + * + * @Patch("/users/{id}") + * @Versions({"v1"}) + * @Transaction({ + * @Request({ + * "email": "new@email.com" + * }, headers={"Authorization": "Bearer "}), + * @Response(200, body={"email": "new@email.com"}), + * @Response(422) + * }) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the user to modify.") + * }) + */ + public function patchUser(Request $request, $id) + { + try { + $user = new UserRepository; + $user->update($id, $request->all()); + return Models\User::findOrFail($id); + } catch (DisplayValidationException $ex) { + throw new ResourceException('A validation error occured.', json_decode($ex->getMessage(), true)); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $ex) { + throw new ServiceUnavailableHttpException('Unable to update a user on the system due to an error.'); + } + } + + /** + * Delete a User + * + * @Delete("/users/{id}") + * @Versions({"v1"}) + * @Transaction({ + * @Request(headers={"Authorization": "Bearer "}), + * @Response(204), + * @Response(422) + * }) + * @Parameters({ + * @Parameter("id", type="integer", required=true, description="The ID of the user to delete.") + * }) + */ + public function deleteUser(Request $request, $id) + { + try { + $user = new UserRepository; + $user->delete($id); + return $this->response->noContent(); + } catch (DisplayException $ex) { + throw new ResourceException($ex->getMessage()); + } catch (\Exception $ex) { + throw new ServiceUnavailableHttpException('Unable to delete this user due to an error.'); + } + } + } diff --git a/app/Http/Controllers/Admin/AccountsController.php b/app/Http/Controllers/Admin/AccountsController.php index 813162a78..77a82d9f3 100644 --- a/app/Http/Controllers/Admin/AccountsController.php +++ b/app/Http/Controllers/Admin/AccountsController.php @@ -62,27 +62,18 @@ class AccountsController extends Controller public function postNew(Request $request) { - $this->validate($request, [ - 'email' => 'required|email|unique:users,email', - 'password' => 'required|confirmed|regex:((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})' - ]); - try { $user = new UserRepository; $userid = $user->create($request->input('username'), $request->input('email'), $request->input('password')); - - if (!$userid) { - throw new \Exception('Unable to create user, response was not an integer.'); - } - Alert::success('Account has been successfully created.')->flash(); return redirect()->route('admin.accounts.view', ['id' => $userid]); - } catch (\Exception $e) { - Log::error($e); + } catch (\Pterodactyl\Exceptions\DisplayValidationException $ex) { + return redirect()->route('admin.nodes.view', $id)->withErrors(json_decode($e->getMessage()))->withInput(); + } catch (\Exception $ex) { + Log::error($ex); Alert::danger('An error occured while attempting to add a new user. ' . $e->getMessage())->flash(); return redirect()->route('admin.accounts.new'); } - } public function postUpdate(Request $request) diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 5ae0c37ad..513fa4d9d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -17,7 +17,6 @@ class Kernel extends HttpKernel \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \Pterodactyl\Http\Middleware\VerifyCsrfToken::class, ]; /** @@ -30,7 +29,7 @@ class Kernel extends HttpKernel 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'guest' => \Pterodactyl\Http\Middleware\RedirectIfAuthenticated::class, 'server' => \Pterodactyl\Http\Middleware\CheckServer::class, - 'api' => \Pterodactyl\Http\Middleware\APIAuthenticate::class, 'admin' => \Pterodactyl\Http\Middleware\AdminAuthenticate::class, + 'csrf' => \Pterodactyl\Http\Middleware\VerifyCsrfToken::class, ]; } diff --git a/app/Http/Middleware/APIAuthenticate.php b/app/Http/Middleware/APIAuthenticate.php deleted file mode 100644 index 8b59f7e27..000000000 --- a/app/Http/Middleware/APIAuthenticate.php +++ /dev/null @@ -1,46 +0,0 @@ -header('X-Authorization')) { - return response()->json([ - 'error' => 'Authorization header was missing with this request. Please pass the \'X-Authorization\' header with your request.' - ], 403); - } - - $api = API::where('key', $request->header('X-Authorization'))->first(); - if (!$api) { - return response()->json([ - 'error' => 'Invalid API key was provided in the request.' - ], 403); - } - - if (!is_null($api->allowed_ips)) { - if (!in_array($request->ip(), json_decode($api->allowed_ips, true))) { - return response()->json([ - 'error' => 'This IP (' . $request->ip() . ') is not permitted to access the API with that token.' - ], 403); - } - } - - return $next($request); - - } -} diff --git a/app/Http/Middleware/APISecretToken.php b/app/Http/Middleware/APISecretToken.php new file mode 100644 index 000000000..7678a1e89 --- /dev/null +++ b/app/Http/Middleware/APISecretToken.php @@ -0,0 +1,80 @@ +bearerToken() || empty($request->bearerToken())) { + throw new UnauthorizedHttpException('The authentication header was missing or malformed'); + } + + list($public, $hashed) = explode('.', $request->bearerToken()); + + $key = APIKey::where('public', $public)->first(); + if (!$key) { + throw new AccessDeniedHttpException('Invalid API Key.'); + } + + // Check for Resource Permissions + if (!empty($request->route()->getName())) { + if(!is_null($key->allowed_ips)) { + if (!in_array($request->ip(), json_decode($key->allowed_ips))) { + throw new AccessDeniedHttpException('This IP address does not have permission to use this API key.'); + } + } + + foreach(APIPermission::where('key_id', $key->id)->get() as &$row) { + if ($row->permission === '*' || $row->permission === $request->route()->getName()) { + $this->permissionAllowed = true; + continue; + } + } + + if (!$this->permissionAllowed) { + throw new AccessDeniedHttpException('You do not have permission to access this resource.'); + } + } + + if($this->_generateHMAC($request->fullUrl(), $request->getContent(), $key->secret) !== base64_decode($hashed)) { + throw new BadRequestHttpException('The hashed body was not valid. Potential modification of contents in route.'); + } + + return true; + + } + + protected function _generateHMAC($url, $body, $key) + { + $data = urldecode($url) . '.' . $body; + return hash_hmac($this->algo, $data, $key, true); + } + +} diff --git a/app/Http/Routes/APIRoutes.php b/app/Http/Routes/APIRoutes.php new file mode 100644 index 000000000..7178fc4d8 --- /dev/null +++ b/app/Http/Routes/APIRoutes.php @@ -0,0 +1,129 @@ +version('v1', ['middleware' => 'api.auth'], function ($api) { + + /** + * User Routes + */ + $api->get('users', [ + 'as' => 'api.users', + 'uses' => 'Pterodactyl\Http\Controllers\API\UserController@getUsers' + ]); + + $api->post('users', [ + 'as' => 'api.users.post', + 'uses' => 'Pterodactyl\Http\Controllers\API\UserController@postUser' + ]); + + $api->get('users/{id}', [ + 'as' => 'api.users.view', + 'uses' => 'Pterodactyl\Http\Controllers\API\UserController@getUser' + ]); + + $api->patch('users/{id}', [ + 'as' => 'api.users.patch', + 'uses' => 'Pterodactyl\Http\Controllers\API\UserController@patchUser' + ]); + + $api->delete('users/{id}', [ + 'as' => 'api.users.delete', + 'uses' => 'Pterodactyl\Http\Controllers\API\UserController@deleteUser' + ]); + + /** + * Server Routes + */ + $api->get('servers', [ + 'as' => 'api.servers', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServerController@getServers' + ]); + + $api->post('servers', [ + 'as' => 'api.servers.post', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServerController@postServer' + ]); + + $api->get('servers/{id}', [ + 'as' => 'api.servers.view', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServerController@getServer' + ]); + + $api->post('servers/{id}/suspend', [ + 'as' => 'api.servers.suspend', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServerController@postServerSuspend' + ]); + + $api->post('servers/{id}/unsuspend', [ + 'as' => 'api.servers.unsuspend', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServerController@postServerUnsuspend' + ]); + + $api->delete('servers/{id}/{force?}', [ + 'as' => 'api.servers.delete', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServerController@deleteServer' + ]); + + /** + * Node Routes + */ + $api->get('nodes', [ + 'as' => 'api.nodes', + 'uses' => 'Pterodactyl\Http\Controllers\API\NodeController@getNodes' + ]); + + $api->post('nodes', [ + 'as' => 'api.nodes.post', + 'uses' => 'Pterodactyl\Http\Controllers\API\NodeController@postNode' + ]); + + $api->get('nodes/{id}', [ + 'as' => 'api.nodes.view', + 'uses' => 'Pterodactyl\Http\Controllers\API\NodeController@getNode' + ]); + + $api->get('nodes/{id}/allocations', [ + 'as' => 'api.nodes.view_allocations', + 'uses' => 'Pterodactyl\Http\Controllers\API\NodeController@getNodeAllocations' + ]); + + $api->delete('nodes/{id}', [ + 'as' => 'api.nodes.view', + 'uses' => 'Pterodactyl\Http\Controllers\API\NodeController@deleteNode' + ]); + + /** + * Location Routes + */ + $api->get('locations', [ + 'as' => 'api.locations', + 'uses' => 'Pterodactyl\Http\Controllers\API\LocationController@getLocations' + ]); + + /** + * Service Routes + */ + $api->get('services', [ + 'as' => 'api.services', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServiceController@getServices' + ]); + + $api->get('services/{id}', [ + 'as' => 'api.services.view', + 'uses' => 'Pterodactyl\Http\Controllers\API\ServiceController@getService' + ]); + + }); + } + +} diff --git a/app/Http/Routes/AdminRoutes.php b/app/Http/Routes/AdminRoutes.php index 568cae4b0..2e22b5285 100644 --- a/app/Http/Routes/AdminRoutes.php +++ b/app/Http/Routes/AdminRoutes.php @@ -13,7 +13,8 @@ class AdminRoutes { 'as' => 'admin.index', 'middleware' => [ 'auth', - 'admin' + 'admin', + 'csrf' ], 'uses' => 'Admin\BaseController@getIndex' ]); @@ -22,7 +23,8 @@ class AdminRoutes { 'prefix' => 'admin/accounts', 'middleware' => [ 'auth', - 'admin' + 'admin', + 'csrf' ] ], function () use ($router) { @@ -66,7 +68,8 @@ class AdminRoutes { 'prefix' => 'admin/servers', 'middleware' => [ 'auth', - 'admin' + 'admin', + 'csrf' ] ], function () use ($router) { @@ -148,7 +151,8 @@ class AdminRoutes { 'prefix' => 'admin/nodes', 'middleware' => [ 'auth', - 'admin' + 'admin', + 'csrf' ] ], function () use ($router) { @@ -204,7 +208,8 @@ class AdminRoutes { 'prefix' => 'admin/locations', 'middleware' => [ 'auth', - 'admin' + 'admin', + 'csrf' ] ], function () use ($router) { $router->get('/', [ diff --git a/app/Http/Routes/AuthRoutes.php b/app/Http/Routes/AuthRoutes.php index fa6e9771c..944011cfc 100644 --- a/app/Http/Routes/AuthRoutes.php +++ b/app/Http/Routes/AuthRoutes.php @@ -12,7 +12,8 @@ class AuthRoutes { $router->group([ 'prefix' => 'auth', 'middleware' => [ - 'guest' + 'guest', + 'csrf' ] ], function () use ($router) { diff --git a/app/Http/Routes/BaseRoutes.php b/app/Http/Routes/BaseRoutes.php index 7081db9d4..16a3795df 100644 --- a/app/Http/Routes/BaseRoutes.php +++ b/app/Http/Routes/BaseRoutes.php @@ -31,7 +31,8 @@ class BaseRoutes { $router->group([ 'profix' => 'account', 'middleware' => [ - 'auth' + 'auth', + 'csrf' ] ], function () use ($router) { $router->get('account', [ @@ -50,7 +51,8 @@ class BaseRoutes { $router->group([ 'prefix' => 'account/totp', 'middleware' => [ - 'auth' + 'auth', + 'csrf' ] ], function () use ($router) { $router->get('/', [ diff --git a/app/Http/Routes/RestRoutes.php b/app/Http/Routes/RestRoutes.php deleted file mode 100644 index 0e98cc981..000000000 --- a/app/Http/Routes/RestRoutes.php +++ /dev/null @@ -1,31 +0,0 @@ -group([ - 'prefix' => 'api/v1', - 'middleware' => [ - 'api' - ] - ], function () use ($router) { - // Users endpoint for API - $router->group(['prefix' => 'users'], function () use ($router) { - // Returns all users - $router->get('/', [ - 'uses' => 'API\UserController@getAllUsers' - ]); - - // Return listing of user [with only specified fields] - $router->get('/{id}/{fields?}', [ - 'uses' => 'API\UserController@getUser' - ])->where('id', '[0-9]+'); - }); - }); - } - -} diff --git a/app/Http/Routes/ServerRoutes.php b/app/Http/Routes/ServerRoutes.php index 333bdacd6..a00ec390b 100644 --- a/app/Http/Routes/ServerRoutes.php +++ b/app/Http/Routes/ServerRoutes.php @@ -11,7 +11,8 @@ class ServerRoutes { 'prefix' => 'server/{server}', 'middleware' => [ 'auth', - 'server' + 'server', + 'csrf' ] ], function ($server) use ($router) { // Index View for Server diff --git a/app/Models/API.php b/app/Models/API.php deleted file mode 100644 index b33985252..000000000 --- a/app/Models/API.php +++ /dev/null @@ -1,63 +0,0 @@ -hasMany(APIPermission::class); - } - - public static function findKey($key) - { - return self::where('key', $key)->first(); - } - - /** - * Determine if an API key has permission to perform an action. - * - * @param string $key - * @param string $permission - * @return boolean - */ - public static function checkPermission($key, $permission) - { - $api = self::findKey($key); - - if (!$api) { - throw new DisplayException('The requested API key (' . $key . ') was not found in the system.'); - } - - return APIPermission::check($api->id, $permission); - - } - - public static function noPermissionError($error = 'You do not have permission to perform this action with this API key.') - { - return response()->json([ - 'error' => 'You do not have permission to perform this action with this API key.' - ], 403); - } - -} diff --git a/app/Models/APIKey.php b/app/Models/APIKey.php new file mode 100644 index 000000000..dc7912f26 --- /dev/null +++ b/app/Models/APIKey.php @@ -0,0 +1,17 @@ +where('permission', $permission)->exists(); - } - } diff --git a/app/Models/User.php b/app/Models/User.php index df2aa8afe..2ce0c6746 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -34,7 +34,7 @@ class User extends Model implements AuthenticatableContract, * * @var array */ - protected $fillable = ['name', 'email', 'password']; + protected $fillable = ['name', 'email', 'password', 'use_totp', 'totp_secret', 'language']; /** * The attributes excluded from the model's JSON form. @@ -70,10 +70,10 @@ class User extends Model implements AuthenticatableContract, /** * Set a user password to a new value assuming it meets the following requirements: - * - 8 or more characters in length - * - at least one uppercase character - * - at least one lowercase character - * - at least one number + * - 8 or more characters in length + * - at least one uppercase character + * - at least one lowercase character + * - at least one number * * @param string $password The raw password to set the account password to. * @param string $regex The regex to use when validating the password. Defaults to '((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})'. diff --git a/app/Repositories/NodeRepository.php b/app/Repositories/NodeRepository.php index 5611cc260..368a3dae0 100644 --- a/app/Repositories/NodeRepository.php +++ b/app/Repositories/NodeRepository.php @@ -183,4 +183,10 @@ class NodeRepository { } } + public function delete($id) + { + // @TODO: add logic; + return true; + } + } diff --git a/app/Repositories/ServerRepository.php b/app/Repositories/ServerRepository.php index 4b8f292db..6587fe5cb 100644 --- a/app/Repositories/ServerRepository.php +++ b/app/Repositories/ServerRepository.php @@ -685,4 +685,28 @@ class ServerRepository return $server->save(); } + /** + * Suspends a server instance making it unable to be booted or used by a user. + * @param integer $id + * @return boolean + */ + public function suspend($id) + { + // @TODO: Implement logic; not doing it now since that is outside of the + // scope of this API brance. + return true; + } + + /** + * Unsuspends a server instance. + * @param integer $id + * @return boolean + */ + public function unsuspend($id) + { + // @TODO: Implement logic; not doing it now since that is outside of the + // scope of this API brance. + return true; + } + } diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index adffbf305..7f5b8ade1 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -2,11 +2,16 @@ namespace Pterodactyl\Repositories; +use DB; use Hash; +use Validator; -use Pterodactyl\Models\User; +use Pterodactyl\Models; use Pterodactyl\Services\UuidService; +use Pterodactyl\Exceptions\DisplayValidationException; +use Pterodactyl\Exceptions\DisplayException; + class UserRepository { @@ -22,32 +27,70 @@ class UserRepository * @param string $password An unhashed version of the user's password. * @return bool|integer */ - public function create($email, $password) + public function create($email, $password, $admin = false) { - $user = new User; + $validator = Validator::make([ + 'email' => $email, + 'password' => $password, + 'root_admin' => $admin + ], [ + 'email' => 'required|email|unique:users,email', + 'password' => 'required|regex:((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})', + 'root_admin' => 'required|boolean' + ]); + + // Run validator, throw catchable and displayable exception if it fails. + // Exception includes a JSON result of failed validation rules. + if ($validator->fails()) { + throw new DisplayValidationException($validator->errors()); + } + + $user = new Models\User; $uuid = new UuidService; $user->uuid = $uuid->generate('users', 'uuid'); $user->email = $email; $user->password = Hash::make($password); + $user->language = 'en'; + $user->root_admin = ($admin) ? 1 : 0; - return ($user->save()) ? $user->id : false; + try { + $user->save(); + return $user->id; + } catch (\Exception $ex) { + throw $e; + } } /** * Updates a user on the panel. * * @param integer $id - * @param array $user An array of columns and their associated values to update for the user. + * @param array $data An array of columns and their associated values to update for the user. * @return boolean */ - public function update($id, array $user) + public function update($id, array $data) { - if(array_key_exists('password', $user)) { - $user['password'] = Hash::make($user['password']); + $validator = Validator::make($data, [ + 'email' => 'email|unique:users,email,' . $id, + 'password' => 'regex:((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})', + 'root_admin' => 'boolean', + 'language' => 'string|min:1|max:5', + 'use_totp' => 'boolean', + 'totp_secret' => 'size:16' + ]); + + // Run validator, throw catchable and displayable exception if it fails. + // Exception includes a JSON result of failed validation rules. + if ($validator->fails()) { + throw new DisplayValidationException($validator->errors()); } - return User::find($id)->update($user); + if(array_key_exists('password', $data)) { + $user['password'] = Hash::make($data['password']); + } + + return Models\User::findOrFail($id)->update($data); } /** @@ -58,7 +101,22 @@ class UserRepository */ public function delete($id) { - return User::destroy($id); + if(Models\Server::where('owner', $id)->count() > 0) { + throw new DisplayException('Cannot delete a user with active servers attached to thier account.'); + } + + DB::beginTransaction(); + + Models\Permission::where('user_id', $id)->delete(); + Models\Subuser::where('user_id', $id)->delete(); + Models\User::destroy($id); + + try { + DB::commit(); + return true; + } catch (\Exception $ex) { + throw $ex; + } } } diff --git a/app/Transformers/NodeTransformer.php b/app/Transformers/NodeTransformer.php new file mode 100644 index 000000000..45a7efbc3 --- /dev/null +++ b/app/Transformers/NodeTransformer.php @@ -0,0 +1,21 @@ +=5.5.9", "laravel/framework": "5.2.*", "barryvdh/laravel-debugbar": "^2.0", + "dingo/api": "1.0.*@dev", "doctrine/dbal": "^2.5", "guzzlehttp/guzzle": "^6.1", "pragmarx/google2fa": "^0.7.1", diff --git a/config/api.php b/config/api.php new file mode 100644 index 000000000..234a8bca7 --- /dev/null +++ b/config/api.php @@ -0,0 +1,209 @@ + env('API_STANDARDS_TREE', 'x'), + + /* + |-------------------------------------------------------------------------- + | API Subtype + |-------------------------------------------------------------------------- + | + | Your subtype will follow the standards tree you use when used in the + | "Accept" header to negotiate the content type and version. + | + | For example: Accept: application/x.SUBTYPE.v1+json + | + */ + + 'subtype' => env('API_SUBTYPE', 'pterodactyl'), + + /* + |-------------------------------------------------------------------------- + | Default API Version + |-------------------------------------------------------------------------- + | + | This is the default version when strict mode is disabled and your API + | is accessed via a web browser. It's also used as the default version + | when generating your APIs documentation. + | + */ + + 'version' => env('API_VERSION', 'v1'), + + /* + |-------------------------------------------------------------------------- + | Default API Prefix + |-------------------------------------------------------------------------- + | + | A default prefix to use for your API routes so you don't have to + | specify it for each group. + | + */ + + 'prefix' => env('API_PREFIX', null), + + /* + |-------------------------------------------------------------------------- + | Default API Domain + |-------------------------------------------------------------------------- + | + | A default domain to use for your API routes so you don't have to + | specify it for each group. + | + */ + + 'domain' => env('API_DOMAIN', null), + + /* + |-------------------------------------------------------------------------- + | Name + |-------------------------------------------------------------------------- + | + | When documenting your API using the API Blueprint syntax you can + | configure a default name to avoid having to manually specify + | one when using the command. + | + */ + + 'name' => env('API_NAME', 'Pterodactyl Panel API'), + + /* + |-------------------------------------------------------------------------- + | Conditional Requests + |-------------------------------------------------------------------------- + | + | Globally enable conditional requests so that an ETag header is added to + | any successful response. Subsequent requests will perform a check and + | will return a 304 Not Modified. This can also be enabled or disabled + | on certain groups or routes. + | + */ + + 'conditionalRequest' => env('API_CONDITIONAL_REQUEST', true), + + /* + |-------------------------------------------------------------------------- + | Strict Mode + |-------------------------------------------------------------------------- + | + | Enabling strict mode will require clients to send a valid Accept header + | with every request. This also voids the default API version, meaning + | your API will not be browsable via a web browser. + | + */ + + 'strict' => env('API_STRICT', false), + + /* + |-------------------------------------------------------------------------- + | Debug Mode + |-------------------------------------------------------------------------- + | + | Enabling debug mode will result in error responses caused by thrown + | exceptions to have a "debug" key that will be populated with + | more detailed information on the exception. + | + */ + + 'debug' => env('API_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Generic Error Format + |-------------------------------------------------------------------------- + | + | When some HTTP exceptions are not caught and dealt with the API will + | generate a generic error response in the format provided. Any + | keys that aren't replaced with corresponding values will be + | removed from the final response. + | + */ + + 'errorFormat' => [ + 'message' => ':message', + 'errors' => ':errors', + 'code' => ':code', + 'status_code' => ':status_code', + 'debug' => ':debug', + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Providers + |-------------------------------------------------------------------------- + | + | The authentication providers that should be used when attempting to + | authenticate an incoming API request. + | + */ + + 'auth' => [ + 'custom' => 'Pterodactyl\Http\Middleware\APISecretToken' + ], + + /* + |-------------------------------------------------------------------------- + | Throttling / Rate Limiting + |-------------------------------------------------------------------------- + | + | Consumers of your API can be limited to the amount of requests they can + | make. You can create your own throttles or simply change the default + | throttles. + | + */ + + 'throttling' => [ + + ], + + /* + |-------------------------------------------------------------------------- + | Response Transformer + |-------------------------------------------------------------------------- + | + | Responses can be transformed so that they are easier to format. By + | default a Fractal transformer will be used to transform any + | responses prior to formatting. You can easily replace + | this with your own transformer. + | + */ + + 'transformer' => env('API_TRANSFORMER', Dingo\Api\Transformer\Adapter\Fractal::class), + + /* + |-------------------------------------------------------------------------- + | Response Formats + |-------------------------------------------------------------------------- + | + | Responses can be returned in multiple formats by registering different + | response formatters. You can also customize an existing response + | formatter. + | + */ + + 'defaultFormat' => env('API_DEFAULT_FORMAT', 'json'), + + 'formats' => [ + + 'json' => Dingo\Api\Http\Response\Format\Json::class, + + ], + +]; diff --git a/config/app.php b/config/app.php index 5fb65fa0b..c054c592d 100644 --- a/config/app.php +++ b/config/app.php @@ -112,6 +112,8 @@ return [ 'providers' => [ + Dingo\Api\Provider\LaravelServiceProvider::class, + /* * Laravel Framework Service Providers... */ @@ -179,6 +181,8 @@ return [ 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Debugbar' => Barryvdh\Debugbar\Facade::class, + 'DingoAPI' => Dingo\Api\Facade\API::class, + 'DingoRoute' => Dingo\Api\Facade\Route::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, diff --git a/database/migrations/2016_01_15_231502_create_table_api_keys.php b/database/migrations/2016_01_15_231502_create_table_api_keys.php new file mode 100644 index 000000000..9b1fb4bb7 --- /dev/null +++ b/database/migrations/2016_01_15_231502_create_table_api_keys.php @@ -0,0 +1,33 @@ +increments('id'); + $table->char('public', 16); + $table->char('secret', 32); + $table->json('allowed_ips')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('api_keys'); + } +} diff --git a/database/migrations/2016_01_15_233110_create_table_api_permissions.php b/database/migrations/2016_01_15_233110_create_table_api_permissions.php new file mode 100644 index 000000000..9056003b9 --- /dev/null +++ b/database/migrations/2016_01_15_233110_create_table_api_permissions.php @@ -0,0 +1,31 @@ +increments('id'); + $table->integer('key_id')->unsigned(); + $table->string('permission'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('api_permissions'); + } +} diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php index 3e7428f63..6c30d699d 100644 --- a/resources/lang/de/auth.php +++ b/resources/lang/de/auth.php @@ -18,7 +18,7 @@ return [ 'sendlink' => 'Senden Sie Passwort-Reset Link.', /* Send password reset link */ 'emailsent' => 'E-Mail gesendet.', /* Email Sent */ 'remeberme' => 'Login merken.', /* Remember my Login*/ - 'totp_failed' => 'Die TOTP Token vorgesehen war ist ungültig.' /* The TOTP token was provided is not valid. */ + 'totp_failed' => 'Die TOTP Token vorgesehen war ist ungültig.' /* The TOTP token was provided is not valid. */ ]; /* diff --git a/resources/lang/de/validation.php b/resources/lang/de/validation.php index 346d3448b..e029d182c 100644 --- a/resources/lang/de/validation.php +++ b/resources/lang/de/validation.php @@ -69,7 +69,7 @@ return [ 'array' => 'The :attribute must contain :size items.', ], 'string' => 'The :attribute must be a string.', - 'totp' => 'The totp token is invalid. Did it expire?', + 'totp' => 'The totp token is invalid. Did it expire?', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'url' => 'The :attribute format is invalid.', diff --git a/resources/views/admin/accounts/index.blade.php b/resources/views/admin/accounts/index.blade.php index 57ba64c19..98ae0dfb2 100644 --- a/resources/views/admin/accounts/index.blade.php +++ b/resources/views/admin/accounts/index.blade.php @@ -7,35 +7,35 @@ @section('content')
+
  • Admin Control
  • +
  • Accounts
  • +

    All Registered Users


    - - - - +
    Email
    + + + - - - - @foreach ($users as $user) - - + + + + @foreach ($users as $user) + + - - @endforeach - -
    Email Account Created Account Updated
    {{ $user->email }} @if($user->root_admin === 1)Administrator@endif
    {{ $user->email }} @if($user->root_admin === 1)Administrator@endif {{ $user->created_at }} {{ $user->updated_at }}
    + + @endforeach + +
    {!! $users->render() !!}
    @endsection diff --git a/resources/views/admin/accounts/new.blade.php b/resources/views/admin/accounts/new.blade.php index 398daa03a..0eefdbadf 100644 --- a/resources/views/admin/accounts/new.blade.php +++ b/resources/views/admin/accounts/new.blade.php @@ -10,65 +10,65 @@
  • Admin Controls
  • Accounts
  • Add New Account
  • - +

    Create New Account


    -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    -
    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    {!! csrf_field() !!} - - -
    -
    -
    - + + +
    +
    +
    + @endsection diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 4f6dac515..f950c6a1d 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -7,14 +7,14 @@ @section('content')
    +
  • Admin Control
  • +

    Pterodactyl Admin Control Panel


    Welcome to the most advanced, lightweight, and user-friendly open source game server control panel.

    @endsection diff --git a/resources/views/admin/locations/index.blade.php b/resources/views/admin/locations/index.blade.php index be73c9503..c84dd238e 100644 --- a/resources/views/admin/locations/index.blade.php +++ b/resources/views/admin/locations/index.blade.php @@ -7,37 +7,37 @@ @section('content')
    +
  • Admin Control
  • +
  • Locations
  • +

    All Locations


    - - - - +
    Location
    + + + - + - - - - @foreach ($locations as $location) - - + + + + @foreach ($locations as $location) + + - - @endforeach - -
    Location DescriptionNodesNodes Servers
    {{ $location->short }}
    {{ $location->short }} {{ $location->long }} {{ $location->a_nodeCount }} {{ $location->a_serverCount }}
    + + @endforeach + +
    {!! $locations->render() !!}
    @endsection diff --git a/resources/views/admin/nodes/index.blade.php b/resources/views/admin/nodes/index.blade.php index d8286f8b1..a6dee7ced 100644 --- a/resources/views/admin/nodes/index.blade.php +++ b/resources/views/admin/nodes/index.blade.php @@ -7,27 +7,27 @@ @section('content')
    +
  • Admin Control
  • +
  • Nodes
  • +

    All Nodes


    - - - - +
    Name
    + + + - + - - - - @foreach ($nodes as $node) - - + + + + @foreach ($nodes as $node) + + @@ -35,17 +35,17 @@ - - @endforeach - -
    Name LocationFQDNFQDN HTTPS Public
    {{ $node->name }}
    {{ $node->name }} {{ $node->a_locationName }} {{ $node->fqdn }}
    + + @endforeach + +
    {!! $nodes->render() !!}
    @endsection diff --git a/resources/views/admin/nodes/new.blade.php b/resources/views/admin/nodes/new.blade.php index d63ee4075..53269dbb8 100644 --- a/resources/views/admin/nodes/new.blade.php +++ b/resources/views/admin/nodes/new.blade.php @@ -7,10 +7,10 @@ @section('content')
    +
  • Create New Node
  • +

    Create New Node


    @@ -158,7 +158,7 @@
    @endsection diff --git a/resources/views/auth/reset.blade.php b/resources/views/auth/reset.blade.php index 4b0eb2f45..3cbd78db2 100644 --- a/resources/views/auth/reset.blade.php +++ b/resources/views/auth/reset.blade.php @@ -14,20 +14,20 @@ {{ trans('auth.resetpassword') }}
    - +
    -
    +
    -
    +
    diff --git a/resources/views/base/account.blade.php b/resources/views/base/account.blade.php index 516e93cff..7378398aa 100644 --- a/resources/views/base/account.blade.php +++ b/resources/views/base/account.blade.php @@ -7,61 +7,61 @@ @section('content')
    -
    -
    -

    {{ trans('base.account.update_pass') }}


    - -
    - -
    - -
    -
    -
    - -
    - +
    +
    +

    {{ trans('base.account.update_pass') }}


    + +
    + +
    + +
    +
    +
    + +
    +

    {{ trans('base.password_req') }}

    -
    -
    -
    - -
    - -
    -
    -
    -
    - {!! csrf_field() !!} - -
    -
    - -
    -
    -

    {{ trans('base.account.update_email') }}


    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - {!! csrf_field() !!} - -
    -
    -
    -
    -
    +
    +
    +
    + +
    + +
    +
    +
    +
    + {!! csrf_field() !!} + +
    +
    + +
    +
    +

    {{ trans('base.account.update_email') }}


    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + {!! csrf_field() !!} + +
    +
    +
    +
    +