Add Adminstration API to support accounts management #94

Merged
maxime.batista merged 6 commits from admin/api-accounts into master 1 year ago

@ -5,9 +5,9 @@ object Account {
name
age
email
phoneNumber
passwordHash
profilePicture
phone_number
password_hash
profile_picture
}
object TacticFolder {

@ -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);
}

@ -1,5 +1,9 @@
<?php
use IQBall\Core\Connection;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Model\AuthModel;
$hostname = getHostName();
$front_url = "http://$hostname:5173";
@ -14,3 +18,15 @@ function _asset(string $assetURI): string {
global $front_url;
return $front_url . "/" . $assetURI;
}
function _init_database(PDO $pdo): void {
$accounts = new AccountGateway(new Connection($pdo));
$defaultAccounts = ["maxime", "mael", "yanis", "vivien"];
foreach ($defaultAccounts as $name) {
$email = "$name@mail.com";
$id = $accounts->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);
}
}

@ -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 {}

@ -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()));

@ -22,6 +22,7 @@ function get_database(): PDO {
}
}
init_database($pdo);
return $pdo;
}

@ -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)

@ -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<string, mixed>|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);
}
}

@ -0,0 +1,45 @@
<?php
namespace IQBall\Api;
use IQBall\Core\Control;
use IQBall\Core\ControlSchemaErrorResponseFactory;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\Validator;
class APIControl {
private static function errorFactory(): ControlSchemaErrorResponseFactory {
return new class () implements ControlSchemaErrorResponseFactory {
public function apply(array $failures): HttpResponse {
return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST);
}
};
}
/**
* Runs given callback, if the request's payload json validates the given schema.
* @param array<string, Validator[]> $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<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $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());
}
}

@ -0,0 +1,108 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Api\APIControl;
use IQBall\App\Control;
use IQBall\Core\Data\Account;
use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\AuthModel;
use IQBall\Core\Validation\DefaultValidators;
use IQBall\Core\Validation\ValidationFail;
class APIAccountsController {
private AccountGateway $accounts;
private AuthModel $authModel;
/**
* @param AuthModel $model
* @param AccountGateway $accounts
*/
public function __construct(AuthModel $model, AccountGateway $accounts) {
$this->accounts = $accounts;
$this->authModel = $model;
}
/**
* @param array<string, mixed> $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);
});
}
}

@ -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()]);
});
}
}

@ -0,0 +1,45 @@
<?php
namespace IQBall\Api\Controller;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
class APIServerController {
private string $basePath;
private \PDO $pdo;
/**
* @param string $basePath
* @param \PDO $pdo
*/
public function __construct(string $basePath, \PDO $pdo) {
$this->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)",
],
]);
}
}

@ -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);
}
}

@ -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);

@ -0,0 +1,44 @@
<?php
namespace IQBall\App;
use IQBall\Core\Control;
use IQBall\Core\ControlSchemaErrorResponseFactory;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Validation\Validator;
class AppControl {
private static function errorFactory(): ControlSchemaErrorResponseFactory {
return new class () implements ControlSchemaErrorResponseFactory {
public function apply(array $failures): HttpResponse {
return ViewHttpResponse::twig("error.html.twig", ['failures' => $failures], HttpCodes::BAD_REQUEST);
}
};
}
/**
* Runs given callback, if the request's payload json validates the given schema.
* @param array<string, Validator[]> $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<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $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());
}
}

@ -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");

@ -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;

@ -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<S> 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<S> 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<S> 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);
}
}

@ -1,30 +1,32 @@
<?php
namespace IQBall\App;
namespace IQBall\Core;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Validation\ValidationFail;
use IQBall\Core\Validation\Validator;
class Control {
/**
* Runs given callback, if the request's json validates the given schema.
* Runs given callback, if the request's payload json validates the given schema.
* @param array<string, Validator[]> $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<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $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]);

@ -0,0 +1,14 @@
<?php
namespace IQBall\Core;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Validation\ValidationFail;
interface ControlSchemaErrorResponseFactory {
/**
* @param ValidationFail[] $failures
* @return HttpResponse
*/
public function apply(array $failures): HttpResponse;
}

@ -2,8 +2,6 @@
namespace IQBall\Core\Data;
use _PHPStan_4c4f22f13\Nette\Utils\Json;
class User implements \JsonSerializable {
/**
* @var string $email user's mail address
@ -25,17 +23,31 @@ class User implements \JsonSerializable {
*/
private string $profilePicture;
/**
* @var bool isAdmin
*/
private bool $isAdmin;
/**
* @param string $email
* @param string $name
* @param int $id
* @param string $profilePicture
* @param bool $isAdmin
*/
public function __construct(string $email, string $name, int $id, string $profilePicture) {
public function __construct(string $email, string $name, int $id, string $profilePicture, bool $isAdmin) {
$this->email = $email;
$this->name = $name;
$this->id = $id;
$this->profilePicture = $profilePicture;
$this->isAdmin = $isAdmin;
}
/**
* @return bool
*/
public function isAdmin(): bool {
return $this->isAdmin;
}
/**

@ -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<string, mixed>|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],
]);
}
}
}

@ -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);
}
/**

@ -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

@ -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");
}

@ -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);
}
}

@ -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);
maxime.batista marked this conversation as resolved
Review
-     * @return TacticInfo[] | null
-     */
-    public function listAllOf(int $user_id): ?array {
-        return$this->tactics->listAllOf($user_id);
+     * @return TacticInfo[]
+     */
+    public function listAllOf(int $user_id): array {
+        return $this->tactics->listAllOf($user_id);
```diff - * @return TacticInfo[] | null - */ - public function listAllOf(int $user_id): ?array { - return$this->tactics->listAllOf($user_id); + * @return TacticInfo[] + */ + public function listAllOf(int $user_id): array { + return $this->tactics->listAllOf($user_id); ```
}
/**
* Get all the tactics of the owner
*

@ -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));
}
maxime.batista marked this conversation as resolved
Review
-                $idx = 0;
-                foreach ($val as $item) {
-                    $failures = array_merge($failures, $this->validator->validate($name . "[$idx]", $item));
-                    $idx += 1;
+                foreach ($val as $idx => $item) {
+                    $failures = array_merge($failures, $this->validator->validate("$name[$idx]", $item));
```diff - $idx = 0; - foreach ($val as $item) { - $failures = array_merge($failures, $this->validator->validate($name . "[$idx]", $item)); - $idx += 1; + foreach ($val as $idx => $item) { + $failures = array_merge($failures, $this->validator->validate("$name[$idx]", $item)); ```
return $failures;
}
};
}
}

Loading…
Cancel
Save