diff --git a/Documentation/database_mld.puml b/Documentation/database_mld.puml index 2c33ce2..8b4d32f 100644 --- a/Documentation/database_mld.puml +++ b/Documentation/database_mld.puml @@ -5,9 +5,9 @@ object Account { name age email - phoneNumber - passwordHash - profilePicture + phone_number + password_hash + profile_picture } object TacticFolder { diff --git a/config.php b/config.php index fdf02a4..0dd030a 100644 --- a/config.php +++ b/config.php @@ -3,6 +3,7 @@ // `dev-config-profile.php` by default. // on production server the included profile is `prod-config-profile.php`. // Please do not touch. + require /*PROFILE_FILE*/ "profiles/dev-config-profile.php"; const SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH; @@ -21,3 +22,8 @@ global $_data_source_name; $data_source_name = $_data_source_name; const DATABASE_USER = _DATABASE_USER; const DATABASE_PASSWORD = _DATABASE_PASSWORD; + + +function init_database(PDO $pdo): void { + _init_database($pdo); +} diff --git a/profiles/dev-config-profile.php b/profiles/dev-config-profile.php index bd87f1d..e39f2f0 100644 --- a/profiles/dev-config-profile.php +++ b/profiles/dev-config-profile.php @@ -1,5 +1,9 @@ insertAccount($name, $email, AuthModel::generateToken(), password_hash("123456", PASSWORD_DEFAULT), "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"); + $accounts->setIsAdmin($id, true); + } +} diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php index e9bb12c..185541a 100644 --- a/profiles/prod-config-profile.php +++ b/profiles/prod-config-profile.php @@ -20,3 +20,6 @@ function _asset(string $assetURI): string { // fallback to the uri itself. return $basePath . "/" . (ASSETS[$assetURI] ?? $assetURI); } + + +function _init_database(PDO $pdo): void {} diff --git a/public/api/index.php b/public/api/index.php index da25013..226e8f1 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -6,7 +6,9 @@ require "../../sql/database.php"; require "../../src/index-utils.php"; use IQBall\Api\API; +use IQBall\Api\Controller\APIAccountsController; use IQBall\Api\Controller\APIAuthController; +use IQBall\Api\Controller\APIServerController; use IQBall\Api\Controller\APITacticController; use IQBall\App\Session\PhpSessionHandle; use IQBall\Core\Action; @@ -17,6 +19,8 @@ use IQBall\Core\Gateway\TacticInfoGateway; use IQBall\Core\Model\AuthModel; use IQBall\Core\Model\TacticModel; +$basePath = get_public_path(__DIR__); + function getTacticController(): APITacticController { return new APITacticController(new TacticModel(new TacticInfoGateway(new Connection(get_database())))); } @@ -25,14 +29,34 @@ function getAuthController(): APIAuthController { return new APIAuthController(new AuthModel(new AccountGateway(new Connection(get_database())))); } +function getAccountController(): APIAccountsController { + $con = new Connection(get_database()); + $gw = new AccountGateway($con); + return new APIAccountsController(new AuthModel($gw), $gw); +} + +function getServerController(): APIServerController { + global $basePath; + return new APIServerController($basePath, get_database()); +} + function getRoutes(): AltoRouter { $router = new AltoRouter(); - $router->setBasePath(get_public_path(__DIR__)); + global $basePath; + $router->setBasePath($basePath); $router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); $router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); + $router->map("GET", "/admin/list-users", Action::noAuth(fn() => getAccountController()->listUsers($_GET))); + $router->map("GET", "/admin/user/[i:id]", Action::noAuth(fn(int $id) => getAccountController()->getUser($id))); + $router->map("GET", "/admin/user/[i:id]/space", Action::noAuth(fn(int $id) => getTacticController()->getUserTactics($id))); + $router->map("POST", "/admin/user/add", Action::noAuth(fn() => getAccountController()->addUser())); + $router->map("POST", "/admin/user/remove-all", Action::noAuth(fn() => getAccountController()->removeUsers())); + $router->map("POST", "/admin/user/[i:id]/update", Action::noAuth(fn(int $id) => getAccountController()->updateUser($id))); + $router->map("GET", "/admin/server-info", Action::noAuth(fn() => getServerController()->getServerInfo())); + return $router; } @@ -56,4 +80,4 @@ function tryGetAuthorization(): ?Account { return $gateway->getAccountFromToken($token); } -Api::render(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization())); +Api::consume(API::handleMatch(getRoutes()->match(), fn() => tryGetAuthorization())); diff --git a/sql/database.php b/sql/database.php index 8f5aa9d..69b53e7 100644 --- a/sql/database.php +++ b/sql/database.php @@ -22,6 +22,7 @@ function get_database(): PDO { } } + init_database($pdo); return $pdo; } diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0d157d9..77f2b3d 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -4,6 +4,12 @@ DROP TABLE IF EXISTS Tactic; DROP TABLE IF EXISTS Team; DROP TABLE IF EXISTS User; DROP TABLE IF EXISTS Member; + +CREATE TABLE Admins +( + id integer PRIMARY KEY REFERENCES Account +); + CREATE TABLE Account ( id integer PRIMARY KEY AUTOINCREMENT, @@ -11,7 +17,7 @@ CREATE TABLE Account username varchar NOT NULL, token varchar UNIQUE NOT NULL, hash varchar NOT NULL, - profilePicture varchar NOT NULL + profile_picture varchar NOT NULL ); CREATE TABLE Tactic @@ -25,13 +31,6 @@ CREATE TABLE Tactic FOREIGN KEY (owner) REFERENCES Account ); -CREATE TABLE FormEntries -( - name varchar NOT NULL, - description varchar NOT NULL -); - - CREATE TABLE Team ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -41,11 +40,10 @@ CREATE TABLE Team second_color varchar NOT NULL ); - CREATE TABLE Member ( - id_team integer NOT NULL, - id_user integer NOT NULL, + id_team integer NOT NULL, + id_user integer NOT NULL, role text CHECK (role IN ('COACH', 'PLAYER')) NOT NULL, FOREIGN KEY (id_team) REFERENCES Team (id), FOREIGN KEY (id_user) REFERENCES Account (id) diff --git a/src/Api/API.php b/src/Api/API.php index 080db59..cc61c8d 100644 --- a/src/Api/API.php +++ b/src/Api/API.php @@ -4,14 +4,19 @@ namespace IQBall\Api; use Exception; use IQBall\Core\Action; +use IQBall\Core\Data\Account; +use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\ValidationFail; class API { - public static function render(HttpResponse $response): void { + public static function consume(HttpResponse $response): void { http_response_code($response->getCode()); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Headers: *'); + foreach ($response->getHeaders() as $header => $value) { header("$header: $value"); } @@ -27,7 +32,7 @@ class API { /** * @param array|false $match - * @param callable $tryGetAuthorization function to return an authorisation object for the given action (if required) + * @param callable(): Account $tryGetAuthorization function to return account authorisation for the given action (if required) * @return HttpResponse * @throws Exception */ @@ -41,15 +46,19 @@ class API { throw new Exception("routed action is not an AppAction object."); } - $auth = null; + $account = null; + + if ($action->getAuthType() != Action::NO_AUTH) { + $account = call_user_func($tryGetAuthorization); + if ($account == null) { + return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")], HttpCodes::UNAUTHORIZED); + } - if ($action->isAuthRequired()) { - $auth = call_user_func($tryGetAuthorization); - if ($auth == null) { - return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header.")]); + if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { + return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); } } - return $action->run($match['params'], $auth); + return $action->run($match['params'], $account); } } diff --git a/src/Api/APIControl.php b/src/Api/APIControl.php new file mode 100644 index 0000000..751fbfb --- /dev/null +++ b/src/Api/APIControl.php @@ -0,0 +1,45 @@ + $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runChecked(array $schema, callable $run): HttpResponse { + return Control::runChecked($schema, $run, self::errorFactory()); + } + + /** + * Runs given callback, if the given request data array validates the given schema. + * @param array $data the request's data array. + * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { + return Control::runCheckedFrom($data, $schema, $run, self::errorFactory()); + } + +} diff --git a/src/Api/Controller/APIAccountsController.php b/src/Api/Controller/APIAccountsController.php new file mode 100644 index 0000000..32fd956 --- /dev/null +++ b/src/Api/Controller/APIAccountsController.php @@ -0,0 +1,108 @@ +accounts = $accounts; + $this->authModel = $model; + } + + + /** + * @param array $request + * @return HttpResponse + */ + public function listUsers(array $request): HttpResponse { + return APIControl::runCheckedFrom($request, [ + 'start' => [DefaultValidators::isUnsignedInteger()], + 'n' => [DefaultValidators::isIntInRange(0, 250)], + 'search' => [DefaultValidators::lenBetween(0, 256)], + ], function (HttpRequest $req) { + $accounts = $this->accounts->searchAccounts(intval($req['start']), intval($req['n']), $req["search"]); + $users = array_map(fn(Account $acc) => $acc->getUser(), $accounts); + return new JsonHttpResponse([ + "users" => $users, + "totalCount" => $this->accounts->totalCount(), + ]); + }); + } + + /** + * @param int $userId + * @return HttpResponse given user information. + */ + public function getUser(int $userId): HttpResponse { + $acc = $this->accounts->getAccount($userId); + + if ($acc == null) { + return new JsonHttpResponse([ValidationFail::notFound("User not found")], HttpCodes::NOT_FOUND); + } + + return new JsonHttpResponse($acc->getUser()); + } + + public function addUser(): HttpResponse { + return APIControl::runChecked([ + "username" => [DefaultValidators::name()], + "email" => [DefaultValidators::email()], + "password" => [DefaultValidators::password()], + "isAdmin" => [DefaultValidators::bool()], + ], function (HttpRequest $req) { + $model = new AuthModel($this->accounts); + + $account = $model->register($req["username"], $req["password"], $req["email"]); + if ($account == null) { + return new JsonHttpResponse([new ValidationFail("already exists", "An account with provided email ")], HttpCodes::FORBIDDEN); + } + + return new JsonHttpResponse([ + "id" => $account->getUser()->getId(), + ]); + }); + } + + public function removeUsers(): HttpResponse { + return APIControl::runChecked([ + "identifiers" => [DefaultValidators::array(), DefaultValidators::forall(DefaultValidators::isUnsignedInteger())], + ], function (HttpRequest $req) { + $this->accounts->removeAccounts($req["identifiers"]); + return HttpResponse::fromCode(HttpCodes::OK); + }); + } + + public function updateUser(int $id): HttpResponse { + return APIControl::runChecked([ + "email" => [DefaultValidators::email()], + "username" => [DefaultValidators::name()], + "isAdmin" => [DefaultValidators::bool()], + ], function (HttpRequest $req) use ($id) { + $mailAccount = $this->accounts->getAccount($id); + if ($mailAccount->getUser()->getId() != $id) { + return new JsonHttpResponse([new ValidationFail("email exists", "The provided mail address already exists for another account.")], HttpCodes::FORBIDDEN); + } + + $this->authModel->update($id, $req["email"], $req["username"], $req["isAdmin"]); + return HttpResponse::fromCode(HttpCodes::OK); + }); + } +} diff --git a/src/Api/Controller/APIAuthController.php b/src/Api/Controller/APIAuthController.php index 8e6291c..c715803 100644 --- a/src/Api/Controller/APIAuthController.php +++ b/src/Api/Controller/APIAuthController.php @@ -2,6 +2,7 @@ namespace IQBall\Api\Controller; +use IQBall\Api\APIControl; use IQBall\App\Control; use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; @@ -26,9 +27,9 @@ class APIAuthController { * @return HttpResponse */ public function authorize(): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], - "password" => [DefaultValidators::lenBetween(6, 256)], + "password" => [DefaultValidators::password()], ], function (HttpRequest $req) { $failures = []; $account = $this->model->login($req["email"], $req["password"], $failures); @@ -40,5 +41,4 @@ class APIAuthController { return new JsonHttpResponse(["authorization" => $account->getToken()]); }); } - } diff --git a/src/Api/Controller/APIServerController.php b/src/Api/Controller/APIServerController.php new file mode 100644 index 0000000..1c82d3e --- /dev/null +++ b/src/Api/Controller/APIServerController.php @@ -0,0 +1,45 @@ +basePath = $basePath; + $this->pdo = $pdo; + } + + private function countLines(string $table): int { + $stmnt = $this->pdo->prepare("SELECT count(*) FROM $table"); + $stmnt->execute(); + $res = $stmnt->fetch(\PDO::FETCH_BOTH); + return $res[0]; + } + + + /** + * @return HttpResponse some (useless) information about the server + */ + public function getServerInfo(): HttpResponse { + + return new JsonHttpResponse([ + 'base_path' => $this->basePath, + 'date' => (int) gettimeofday(true) * 1000, + 'database' => [ + 'accounts' => $this->countLines("Account") . " line(s)", + 'tactics' => $this->countLines("Tactic") . " line(s)", + 'teams' => $this->countLines("Team") . " line(s)", + ], + ]); + } + +} diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index 2156538..9f71212 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -2,14 +2,14 @@ namespace IQBall\Api\Controller; -use IQBall\App\Control; +use IQBall\Api\APIControl; use IQBall\Core\Data\Account; +use IQBall\Core\Data\TacticInfo; use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Model\TacticModel; -use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\DefaultValidators; /** @@ -32,7 +32,7 @@ class APITacticController { * @return HttpResponse */ public function updateName(int $tactic_id, Account $account): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "name" => [DefaultValidators::lenBetween(1, 50), DefaultValidators::nameWithSpaces()], ], function (HttpRequest $request) use ($tactic_id, $account) { @@ -49,17 +49,37 @@ class APITacticController { /** * @param int $id - * @param Account $account * @return HttpResponse */ public function saveContent(int $id, Account $account): HttpResponse { - return Control::runChecked([ + return APIControl::runChecked([ "content" => [], ], function (HttpRequest $req) use ($id) { + //TODO verify that the account has the rights to update the tactic content if ($fail = $this->model->updateContent($id, json_encode($req["content"]))) { return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST); } return HttpResponse::fromCode(HttpCodes::OK); }); } + + + /** + * @param int $userId + * @return HttpResponse given user information. + */ + public function getUserTactics(int $userId): HttpResponse { + $tactics = $this->model->listAllOf($userId); + + $response = array_map(fn(TacticInfo $t) => [ + 'id' => $t->getId(), + 'name' => $t->getName(), + 'court' => $t->getCourtType(), + 'creation_date' => $t->getCreationDate(), + ], $tactics); + + return new JsonHttpResponse($response); + } + + } diff --git a/src/App/App.php b/src/App/App.php index cd3c293..5f208bc 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -4,8 +4,10 @@ namespace IQBall\App; use IQBall\App\Session\MutableSessionHandle; use IQBall\Core\Action; +use IQBall\Core\Http\HttpCodes; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Http\JsonHttpResponse; +use IQBall\Core\Validation\ValidationFail; use Twig\Environment; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -77,13 +79,17 @@ class App { * @return HttpResponse */ public static function runAction(string $authRoute, Action $action, array $params, MutableSessionHandle $session): HttpResponse { - if ($action->isAuthRequired()) { + if ($action->getAuthType() != Action::NO_AUTH) { $account = $session->getAccount(); if ($account == null) { // put in the session the initial url the user wanted to get $session->setInitialTarget($_SERVER['REQUEST_URI']); return HttpResponse::redirect($authRoute); } + + if ($action->getAuthType() == Action::AUTH_ADMIN && !$account->getUser()->isAdmin()) { + return new JsonHttpResponse([ValidationFail::unauthorized()], HttpCodes::UNAUTHORIZED); + } } return $action->run($params, $session); diff --git a/src/App/AppControl.php b/src/App/AppControl.php new file mode 100644 index 0000000..c313e69 --- /dev/null +++ b/src/App/AppControl.php @@ -0,0 +1,44 @@ + $failures], HttpCodes::BAD_REQUEST); + } + }; + } + + /** + * Runs given callback, if the request's payload json validates the given schema. + * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runChecked(array $schema, callable $run): HttpResponse { + return Control::runChecked($schema, $run, self::errorFactory()); + } + + /** + * Runs given callback, if the given request data array validates the given schema. + * @param array $data the request's data array. + * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema + * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ + public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { + return Control::runCheckedFrom($data, $schema, $run, self::errorFactory()); + } + +} diff --git a/src/App/Controller/AuthController.php b/src/App/Controller/AuthController.php index 3d16733..efb8862 100644 --- a/src/App/Controller/AuthController.php +++ b/src/App/Controller/AuthController.php @@ -7,7 +7,9 @@ use IQBall\App\ViewHttpResponse; use IQBall\Core\Http\HttpRequest; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Model\AuthModel; + use IQBall\Core\Validation\DefaultValidators; +use IQBall\Core\Validation\FieldValidationFail; class AuthController { private AuthModel $model; @@ -31,10 +33,10 @@ class AuthController { */ public function register(array $request, MutableSessionHandle $session): HttpResponse { $fails = []; - HttpRequest::from($request, $fails, [ + $request = HttpRequest::from($request, $fails, [ "username" => [DefaultValidators::name(), DefaultValidators::lenBetween(2, 32)], - "password" => [DefaultValidators::lenBetween(6, 256)], - "confirmpassword" => [DefaultValidators::lenBetween(6, 256)], + "password" => [DefaultValidators::password()], + "confirmpassword" => [DefaultValidators::password()], "email" => [DefaultValidators::email(), DefaultValidators::lenBetween(5, 256)], ]); @@ -44,7 +46,16 @@ class AuthController { } } - $account = $this->model->register($request['username'], $request["password"], $request['confirmpassword'], $request['email'], $fails); + if ($request["password"] != $request['confirmpassword']) { + $fails[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); + } + + $account = $this->model->register($request['username'], $request["password"], $request['email']); + + if (!$account) { + $fails[] = new FieldValidationFail("email", "L'email existe déjà"); + } + if (!empty($fails)) { return ViewHttpResponse::twig("display_register.html.twig", ['fails' => $fails]); } @@ -52,7 +63,7 @@ class AuthController { $target_url = $session->getInitialTarget(); if ($target_url != null) { - return HttpResponse::redirect_absolute($target_url); + return HttpResponse::redirectAbsolute($target_url); } return HttpResponse::redirect("/home"); @@ -81,7 +92,7 @@ class AuthController { $target_url = $session->getInitialTarget(); $session->setInitialTarget(null); if ($target_url != null) { - return HttpResponse::redirect_absolute($target_url); + return HttpResponse::redirectAbsolute($target_url); } return HttpResponse::redirect("/home"); diff --git a/src/App/Controller/UserController.php b/src/App/Controller/UserController.php index 6f56128..616bf54 100644 --- a/src/App/Controller/UserController.php +++ b/src/App/Controller/UserController.php @@ -5,6 +5,7 @@ namespace IQBall\App\Controller; use IQBall\App\Session\MutableSessionHandle; use IQBall\App\Session\SessionHandle; use IQBall\App\ViewHttpResponse; +use IQBall\Core\Gateway\AccountGateway; use IQBall\Core\Http\HttpResponse; use IQBall\Core\Model\TacticModel; use IQBall\Core\Model\TeamModel; diff --git a/src/Core/Action.php b/src/Core/Action.php index 35721c1..df40ea9 100644 --- a/src/Core/Action.php +++ b/src/Core/Action.php @@ -9,23 +9,27 @@ use IQBall\Core\Http\HttpResponse; * @template S session */ class Action { + public const NO_AUTH = 1; + public const AUTH_USER = 2; + public const AUTH_ADMIN = 3; + /** * @var callable(mixed[], S): HttpResponse $action action to call */ protected $action; - private bool $isAuthRequired; + private int $authType; /** * @param callable(mixed[], S): HttpResponse $action */ - protected function __construct(callable $action, bool $isAuthRequired) { + protected function __construct(callable $action, int $authType) { $this->action = $action; - $this->isAuthRequired = $isAuthRequired; + $this->authType = $authType; } - public function isAuthRequired(): bool { - return $this->isAuthRequired; + public function getAuthType(): int { + return $this->authType; } /** @@ -45,7 +49,7 @@ class Action { * @return Action an action that does not require to have an authorization. */ public static function noAuth(callable $action): Action { - return new Action($action, false); + return new Action($action, self::NO_AUTH); } /** @@ -53,6 +57,14 @@ class Action { * @return Action an action that does require to have an authorization. */ public static function auth(callable $action): Action { - return new Action($action, true); + return new Action($action, self::AUTH_USER); + } + + /** + * @param callable(mixed[], S): HttpResponse $action + * @return Action an action that does require to have an authorization, and to be an administrator. + */ + public static function admin(callable $action): Action { + return new Action($action, self::AUTH_ADMIN); } } diff --git a/src/App/Control.php b/src/Core/Control.php similarity index 65% rename from src/App/Control.php rename to src/Core/Control.php index b8148bb..106052d 100644 --- a/src/App/Control.php +++ b/src/Core/Control.php @@ -1,30 +1,32 @@ $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * THe callback must accept an HttpRequest, and return an HttpResponse object. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema * @return HttpResponse */ - public static function runChecked(array $schema, callable $run): HttpResponse { + public static function runChecked(array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse { $request_body = file_get_contents('php://input'); $payload_obj = json_decode($request_body); if (!$payload_obj instanceof \stdClass) { $fail = new ValidationFail("bad-payload", "request body is not a valid json object"); - return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST); + return $errorFactory->apply([$fail]); } $payload = get_object_vars($payload_obj); - return self::runCheckedFrom($payload, $schema, $run); + return self::runCheckedFrom($payload, $schema, $run, $errorFactory); } /** @@ -32,15 +34,16 @@ class Control { * @param array $data the request's data array. * @param array $schema an array of `fieldName => DefaultValidators` which represents the request object schema * @param callable(HttpRequest): HttpResponse $run the callback to run if the request is valid according to the given schema. - * THe callback must accept an HttpRequest, and return an HttpResponse object. + * The callback must accept an HttpRequest, and return an HttpResponse object. + * @param ControlSchemaErrorResponseFactory $errorFactory an error factory to use if the request does not validate the required schema * @return HttpResponse */ - public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { + public static function runCheckedFrom(array $data, array $schema, callable $run, ControlSchemaErrorResponseFactory $errorFactory): HttpResponse { $fails = []; $request = HttpRequest::from($data, $fails, $schema); if (!empty($fails)) { - return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST); + return $errorFactory->apply($fails); } return call_user_func_array($run, [$request]); diff --git a/src/Core/ControlSchemaErrorResponseFactory.php b/src/Core/ControlSchemaErrorResponseFactory.php new file mode 100644 index 0000000..9882a65 --- /dev/null +++ b/src/Core/ControlSchemaErrorResponseFactory.php @@ -0,0 +1,14 @@ +email = $email; $this->name = $name; $this->id = $id; $this->profilePicture = $profilePicture; + $this->isAdmin = $isAdmin; + } + + /** + * @return bool + */ + public function isAdmin(): bool { + return $this->isAdmin; } /** diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index a9c3e18..6752b01 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -18,16 +18,54 @@ class AccountGateway { } public function insertAccount(string $name, string $email, string $token, string $hash, string $profilePicture): int { - $this->con->exec("INSERT INTO Account(username, hash, email, token,profilePicture) VALUES (:username,:hash,:email,:token,:profilePic)", [ + $this->con->exec("INSERT INTO Account(username, hash, email, token,profile_picture) VALUES (:username,:hash,:email,:token,:profile_pic)", [ ':username' => [$name, PDO::PARAM_STR], ':hash' => [$hash, PDO::PARAM_STR], ':email' => [$email, PDO::PARAM_STR], ':token' => [$token, PDO::PARAM_STR], - ':profilePic' => [$profilePicture, PDO::PARAM_STR], + ':profile_pic' => [$profilePicture, PDO::PARAM_STR], ]); return intval($this->con->lastInsertId()); } + public function updateAccount(int $id, string $name, string $email, string $token, bool $isAdmin): void { + $this->con->exec("UPDATE Account SET username = :username, email = :email, token = :token WHERE id = :id", [ + ':username' => [$name, PDO::PARAM_STR], + ':email' => [$email, PDO::PARAM_STR], + ':token' => [$token, PDO::PARAM_STR], + ':id' => [$id, PDO::PARAM_INT], + ]); + $this->setIsAdmin($id, $isAdmin); + } + + public function isAdmin(int $id): bool { + $stmnt = $this->con->prepare("SELECT * FROM Admins WHERE id = :id"); + $stmnt->bindValue(':id', $id, PDO::PARAM_INT); + $stmnt->execute(); + $result = $stmnt->fetchAll(PDO::FETCH_ASSOC); + + return !empty($result); + } + + + /** + * promote or demote a user to server administrator + * @param int $id + * @param bool $isAdmin true to promote, false to demote + * @return bool true if the given user exists + */ + public function setIsAdmin(int $id, bool $isAdmin): bool { + if ($isAdmin) { + $stmnt = $this->con->prepare("INSERT INTO Admins VALUES(:id)"); + } else { + $stmnt = $this->con->prepare("DELETE FROM Admins WHERE id = :id"); + } + $stmnt->bindValue(':id', $id); + $stmnt->execute(); + + return $stmnt->rowCount() > 0; + } + /** * @param string $email * @return array|null @@ -66,7 +104,7 @@ class AccountGateway { return null; } - return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profilePicture"])); + return new Account($acc["token"], new User($email, $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))); } /** @@ -74,13 +112,68 @@ class AccountGateway { * @return Account|null */ public function getAccountFromToken(string $token): ?Account { - $acc = $this->con->fetch("SELECT * FROM Account WHERE token = :token", [':token' => [$token, PDO::PARAM_STR]])[0] ?? null; - if (empty($acc)) { + $stmnt = $this->con->prepare("SELECT * FROM Account WHERE token = :token"); + $stmnt->bindValue(':token', $token); + return $this->getAccountFrom($stmnt); + } + + /** + * @param int $id get an account from given identifier + * @return Account|null + */ + public function getAccount(int $id): ?Account { + $stmnt = $this->con->prepare("SELECT * FROM Account WHERE id = :id"); + $stmnt->bindValue(':id', $id); + return $this->getAccountFrom($stmnt); + } + + private function getAccountFrom(\PDOStatement $stmnt): ?Account { + $stmnt->execute(); + $acc = $stmnt->fetch(PDO::FETCH_ASSOC); + + if ($acc == null) { return null; } - return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profilePicture"])); + return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))); + } + + /** + * Return a list containing n accounts from a given starting index + * + * @param integer $n the number of accounts to retrieve + * @param int $start starting index of the list content + * @return Account[] + */ + public function searchAccounts(int $start, int $n, ?string $searchString): array { + $res = $this->con->fetch( + "SELECT * FROM Account WHERE username LIKE '%' || :search || '%' OR email LIKE '%' || :search || '%' ORDER BY username, email LIMIT :offset, :n", + [ + ":offset" => [$start, PDO::PARAM_INT], + ":n" => [$n, PDO::PARAM_INT], + ":search" => [$searchString ?? "", PDO::PARAM_STR], + ] + ); + return array_map(fn(array $acc) => new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profile_picture"], $this->isAdmin($acc["id"]))), $res); } + /** + * returns the total amount of accounts in the database + * @return int + */ + public function totalCount(): int { + return $this->con->fetch("SELECT count(*) FROM Account", [])[0]['count(*)']; + } + /** + * remove a bunch of account identifiers + * @param int[] $accountIds + */ + public function removeAccounts(array $accountIds): void { + foreach ($accountIds as $accountId) { + $this->con->fetch("DELETE FROM Account WHERE id = :accountId", [ + ":accountId" => [$accountId, PDO::PARAM_INT], + ]); + } + } } diff --git a/src/Core/Gateway/MemberGateway.php b/src/Core/Gateway/MemberGateway.php index a5116e8..98d2d41 100644 --- a/src/Core/Gateway/MemberGateway.php +++ b/src/Core/Gateway/MemberGateway.php @@ -41,12 +41,12 @@ class MemberGateway { */ public function getMembersOfTeam(int $teamId): array { $rows = $this->con->fetch( - "SELECT a.id,a.email,a.username,a.profilePicture,m.role FROM Account a,team t,Member m WHERE t.id = :id AND m.id_team = t.id AND m.id_user = a.id", + "SELECT a.id,a.email,a.username,a.profile_picture,m.role FROM Account a,team t,Member m WHERE t.id = :id AND m.id_team = t.id AND m.id_user = a.id", [ ":id" => [$teamId, PDO::PARAM_INT], ] ); - return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profilePicture']), $teamId, $row['role']), $rows); + return array_map(fn($row) => new Member(new User($row['email'], $row['username'], $row['id'], $row['profile_picture'], $row['is_admin']), $teamId, $row['role']), $rows); } /** diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php index 08302c9..d4b81e0 100644 --- a/src/Core/Gateway/TacticInfoGateway.php +++ b/src/Core/Gateway/TacticInfoGateway.php @@ -83,6 +83,24 @@ class TacticInfoGateway { return $res; } + + /** + * Return a list containing the nth last tactics of a given user id + * + * @param integer $user_id + * @return TacticInfo[] + */ + public function listAllOf(int $user_id): array { + $res = $this->con->fetch( + "SELECT * FROM Tactic WHERE owner = :owner_id ORDER BY creation_date DESC", + [ + ":owner_id" => [$user_id, PDO::PARAM_STR], + ] + ); + return array_map(fn(array $t) => new TacticInfo($t['id'], $t["name"], strtotime($t["creation_date"]), $t["owner"], CourtType::fromName($t['court_type']), $t['content']), $res); + } + + /** * @param string $name * @param int $owner diff --git a/src/Core/Http/HttpResponse.php b/src/Core/Http/HttpResponse.php index c98a261..6c6a743 100644 --- a/src/Core/Http/HttpResponse.php +++ b/src/Core/Http/HttpResponse.php @@ -45,7 +45,7 @@ class HttpResponse { public static function redirect(string $url, int $code = HttpCodes::FOUND): HttpResponse { global $basePath; - return self::redirect_absolute($basePath . $url, $code); + return self::redirectAbsolute($basePath . $url, $code); } /** @@ -54,7 +54,7 @@ class HttpResponse { * @return HttpResponse a response that will redirect client to given url */ - public static function redirect_absolute(string $url, int $code = HttpCodes::FOUND): HttpResponse { + public static function redirectAbsolute(string $url, int $code = HttpCodes::FOUND): HttpResponse { if ($code < 300 || $code >= 400) { throw new \InvalidArgumentException("given code is not a redirection http code"); } diff --git a/src/Core/Model/AuthModel.php b/src/Core/Model/AuthModel.php index bc29248..e1fc1bb 100644 --- a/src/Core/Model/AuthModel.php +++ b/src/Core/Model/AuthModel.php @@ -6,6 +6,8 @@ use Exception; use IQBall\Core\Data\Account; use IQBall\Core\Data\User; use IQBall\Core\Gateway\AccountGateway; +use IQBall\Core\Http\HttpCodes; +use IQBall\Core\Http\JsonHttpResponse; use IQBall\Core\Validation\FieldValidationFail; use IQBall\Core\Validation\ValidationFail; @@ -23,40 +25,34 @@ class AuthModel { /** * @param string $username * @param string $password - * @param string $confirmPassword * @param string $email - * @param ValidationFail[] $failures - * @return Account|null the registered account or null if failures occurred - * @throws Exception + * @return Account|null the registered account or null if the account already exists for the given email address */ - public function register(string $username, string $password, string $confirmPassword, string $email, array &$failures): ?Account { - - if ($password != $confirmPassword) { - $failures[] = new FieldValidationFail("confirmpassword", "Le mot de passe et la confirmation ne sont pas les mêmes."); - } - + public function register( + string $username, + string $password, + string $email + ): ?Account { if ($this->gateway->exists($email)) { - $failures[] = new FieldValidationFail("email", "L'email existe déjà"); - } - - if (!empty($failures)) { return null; } $hash = password_hash($password, PASSWORD_DEFAULT); - $token = $this->generateToken(); $accountId = $this->gateway->insertAccount($username, $email, $token, $hash, self::DEFAULT_PROFILE_PICTURE); - return new Account($token, new User($email, $username, $accountId, self::DEFAULT_PROFILE_PICTURE)); + return new Account($token, new User($email, $username, $accountId, self::DEFAULT_PROFILE_PICTURE, false)); } /** * Generate a random base 64 string * @return string - * @throws Exception */ - private function generateToken(): string { - return base64_encode(random_bytes(64)); + public static function generateToken(): string { + try { + return base64_encode(random_bytes(64)); + } catch (Exception $e) { + throw new \RuntimeException($e); + } } /** @@ -74,4 +70,9 @@ class AuthModel { return $this->gateway->getAccountFromMail($email); } + public function update(int $id, string $email, string $username, bool $isAdmin): void { + $token = $this->generateToken(); + $this->gateway->updateAccount($id, $username, $email, $token, $isAdmin); + } + } diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php index 4953fb2..920075b 100644 --- a/src/Core/Model/TacticModel.php +++ b/src/Core/Model/TacticModel.php @@ -62,6 +62,18 @@ class TacticModel { return $this->tactics->getLast($nb, $ownerId); } + + /** + * Return a list containing all the tactics of a given user + * NOTE: if given user id does not match any user, this function returns an empty array + * + * @param integer $user_id + * @return TacticInfo[] + */ + public function listAllOf(int $user_id): array { + return$this->tactics->listAllOf($user_id); + } + /** * Get all the tactics of the owner * diff --git a/src/Core/Validation/DefaultValidators.php b/src/Core/Validation/DefaultValidators.php index b6ffc38..c898170 100644 --- a/src/Core/Validation/DefaultValidators.php +++ b/src/Core/Validation/DefaultValidators.php @@ -38,6 +38,10 @@ class DefaultValidators { return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/"); } + public static function password(): Validator { + return self::lenBetween(6, 256); + } + /** * Validate string if its length is between given range * @param int $min minimum accepted length, inclusive @@ -68,7 +72,11 @@ class DefaultValidators { public static function isInteger(): Validator { - return self::regex("/^[0-9]+$/"); + return self::regex("/^[-+]?[0-9]+$/", "field is not an integer"); + } + + public static function isUnsignedInteger(): Validator { + return self::regex("/^[0-9]+$/", "field is not an unsigned integer"); } public static function isIntInRange(int $min, int $max): Validator { @@ -78,10 +86,61 @@ class DefaultValidators { ); } + /** + * @param mixed[] $values + * @return Validator + */ + public static function oneOf(array $values): Validator { + return new SimpleFunctionValidator( + fn(string $val) => in_array($val, $values), + fn(string $name) => [new FieldValidationFail($name, "The value must be one of '" . join(", ", $values) . "'")] + ); + } + + public static function bool(): Validator { + return self::oneOf([true, false]); + } + public static function isURL(): Validator { return new SimpleFunctionValidator( fn($val) => filter_var($val, FILTER_VALIDATE_URL), fn(string $name) => [new FieldValidationFail($name, "The value is not an URL")] ); } + + /** + * @return Validator + */ + public static function array(): Validator { + return new SimpleFunctionValidator( + fn($val) => is_array($val), + fn(string $name) => [new FieldValidationFail($name, "The value is not an array")] + ); + } + + /** + * @param Validator $validator + * @return Validator + */ + public static function forall(Validator $validator): Validator { + return new class ($validator) extends Validator { + private Validator $validator; + + /** + * @param Validator $validator + */ + public function __construct(Validator $validator) { + $this->validator = $validator; + } + + public function validate(string $name, $val): array { + $failures = []; + foreach ($val as $idx => $item) { + $failures = array_merge($failures, $this->validator->validate($name . "[$idx]", $item)); + } + + return $failures; + } + }; + } }