diff --git a/composer.json b/composer.json index 642f6f8..9f640ad 100644 --- a/composer.json +++ b/composer.json @@ -17,5 +17,8 @@ "kreait/firebase-php": "^7.0", "benmorel/gsm-charset-converter": "^0.3.0", "google/cloud-pubsub": "^1.46" + }, + "require-dev": { + "fakerphp/faker": "^1.21" } } diff --git a/controllers/internals/Console.php b/controllers/internals/Console.php index 2315629..9507bd8 100644 --- a/controllers/internals/Console.php +++ b/controllers/internals/Console.php @@ -12,6 +12,7 @@ namespace controllers\internals; use DateInterval; +use Faker\Factory; /** * Class to call the console scripts. @@ -62,198 +63,415 @@ use DateInterval; $phone = $internal_phone->get($id_phone); if (!$phone) - { - exit(1); - } - - new \daemons\Phone($phone); + { + exit(1); } - /** - * Check if a user exists based on email. - * - * @param string $email : User email - */ - public function user_exists(string $email) + new \daemons\Phone($phone); + } + + /** + * Check if a user exists based on email. + * + * @param string $email : User email + */ + public function user_exists(string $email) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_user = new \controllers\internals\User($bdd); + + $user = $internal_user->get_by_email($email); + + exit($user ? 0 : 1); + } + + /** + * Check if a user exists based on id. + * + * @param string $id : User id + */ + public function user_id_exists(string $id) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_user = new \controllers\internals\User($bdd); + + $user = $internal_user->get($id); + + exit($user ? 0 : 1); + } + + /** + * Create a user or update an existing user. + * + * @param $email : User email + * @param $password : User password + * @param $admin : Is user admin + * @param $api_key : User API key, if null random api key is generated + * @param $status : User status, default \models\User::STATUS_ACTIVE + * @param bool $encrypt_password : Should the password be encrypted, by default true + * + * exit code 0 on success | 1 on error + */ + public function create_update_user(string $email, string $password, bool $admin, ?string $api_key = null, string $status = \models\User::STATUS_ACTIVE, bool $encrypt_password = true) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_user = new \controllers\internals\User($bdd); + + $user = $internal_user->get_by_email($email); + if ($user) { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_user = new \controllers\internals\User($bdd); + $api_key = $api_key ?? $internal_user->generate_random_api_key(); + $update_datas = [ + 'email' => $email, + 'password' => $encrypt_password ? password_hash($password, PASSWORD_DEFAULT) : $password, + 'admin' => $admin, + 'api_key' => $api_key, + 'status' => $status, + ]; - $user = $internal_user->get_by_email($email); - - exit($user ? 0 : 1); - } - - /** - * Check if a user exists based on id. - * - * @param string $id : User id - */ - public function user_id_exists(string $id) - { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_user = new \controllers\internals\User($bdd); - - $user = $internal_user->get($id); - - exit($user ? 0 : 1); - } - - /** - * Create a user or update an existing user. - * - * @param $email : User email - * @param $password : User password - * @param $admin : Is user admin - * @param $api_key : User API key, if null random api key is generated - * @param $status : User status, default \models\User::STATUS_ACTIVE - * @param bool $encrypt_password : Should the password be encrypted, by default true - * - * exit code 0 on success | 1 on error - */ - public function create_update_user(string $email, string $password, bool $admin, ?string $api_key = null, string $status = \models\User::STATUS_ACTIVE, bool $encrypt_password = true) - { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_user = new \controllers\internals\User($bdd); - - $user = $internal_user->get_by_email($email); - if ($user) - { - $api_key = $api_key ?? $internal_user->generate_random_api_key(); - $update_datas = [ - 'email' => $email, - 'password' => $encrypt_password ? password_hash($password, PASSWORD_DEFAULT) : $password, - 'admin' => $admin, - 'api_key' => $api_key, - 'status' => $status, - ]; - - $success = $internal_user->update($user['id'], $update_datas); - echo json_encode(['id' => $user['id']]); - - exit($success ? 0 : 1); - } - - $new_user_id = $internal_user->create($email, $password, $admin, $api_key, $status, $encrypt_password); - echo json_encode(['id' => $new_user_id]); - - exit($new_user_id ? 0 : 1); - } - - /** - * Update a user status. - * - * @param string $id : User id - * @param string $status : User status, default \models\User::STATUS_ACTIVE - */ - public function update_user_status(string $id, string $status) - { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_user = new \controllers\internals\User($bdd); - - $user = $internal_user->get($id); - if (!$user) - { - exit(1); - } - - $success = $internal_user->update_status($user['id'], $status); + $success = $internal_user->update($user['id'], $update_datas); + echo json_encode(['id' => $user['id']]); exit($success ? 0 : 1); } - /** - * Delete a user. - * - * @param string $id : User id - */ - public function delete_user(string $id) + $new_user_id = $internal_user->create($email, $password, $admin, $api_key, $status, $encrypt_password); + echo json_encode(['id' => $new_user_id]); + + exit($new_user_id ? 0 : 1); + } + + /** + * Update a user status. + * + * @param string $id : User id + * @param string $status : User status, default \models\User::STATUS_ACTIVE + */ + public function update_user_status(string $id, string $status) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_user = new \controllers\internals\User($bdd); + + $user = $internal_user->get($id); + if (!$user) { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_user = new \controllers\internals\User($bdd); - - $success = $internal_user->delete($id); - - exit($success ? 0 : 1); + exit(1); } - /** - * Delete medias that are no longer usefull. - */ - public function clean_unused_medias() + $success = $internal_user->update_status($user['id'], $status); + + exit($success ? 0 : 1); + } + + /** + * Delete a user. + * + * @param string $id : User id + */ + public function delete_user(string $id) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_user = new \controllers\internals\User($bdd); + + $success = $internal_user->delete($id); + + exit($success ? 0 : 1); + } + + /** + * Delete medias that are no longer usefull. + */ + public function clean_unused_medias() + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_media = new \controllers\internals\Media($bdd); + + $medias = $internal_media->gets_unused(); + + foreach ($medias as $media) { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_media = new \controllers\internals\Media($bdd); + $success = $internal_media->delete_for_user($media['id_user'], $media['id']); - $medias = $internal_media->gets_unused(); - - foreach ($medias as $media) - { - $success = $internal_media->delete_for_user($media['id_user'], $media['id']); - - echo (false === $success ? '[KO]' : '[OK]') . ' - ' . $media['path'] . "\n"; - } - } - - /** - * Do alerting for quota limits. - */ - public function quota_limit_alerting() - { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_quota = new \controllers\internals\Quota($bdd); - $internal_quota->alerting_for_limit_close_and_reached(); - } - - /** - * Do quota renewal. - */ - public function renew_quotas() - { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_quota = new \controllers\internals\Quota($bdd); - $internal_quota->renew_quotas(); - } - - /** - * Do some fake population renewal. - */ - public function f() - { - $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); - $internal_sended = new \controllers\internals\Sended($bdd); - - $destinations = ['+33612345678','+33612345679','+33612345680',]; - $statuses = [\models\Sended::STATUS_DELIVERED, \models\Sended::STATUS_FAILED, \models\Sended::STATUS_UNKNOWN]; - $day = new \DateTime(); - $day->sub(new DateInterval('P30D')); - for ($i = 0; $i < 30; $i++) - { - $day->add(new DateInterval('P1D')); - $n = rand(0, 100); - for ($j = 0; $j < $n; $j++) - { - $id_user = 1; - $id_phone = rand(1, 2); - $destination = $destinations[array_rand($destinations)]; - $status = $statuses[array_rand($statuses)]; - $internal_sended->create( - $id_user, - $id_phone, - $day->format('Y-m-d H:i:s'), - "TEST N°$i:$j", - $destination, - uniqid(), - 'adapters\TestAdapter', - false, - false, - null, - [], - null, - $status, - ); - } - } - + echo (false === $success ? '[KO]' : '[OK]') . ' - ' . $media['path'] . "\n"; } } + + /** + * Do alerting for quota limits. + */ + public function quota_limit_alerting() + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_quota = new \controllers\internals\Quota($bdd); + $internal_quota->alerting_for_limit_close_and_reached(); + } + + /** + * Do quota renewal. + */ + public function renew_quotas() + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_quota = new \controllers\internals\Quota($bdd); + $internal_quota->renew_quotas(); + } + + /** + * Do phone reliability verifications + */ + public function phone_reliability() + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_user = new \controllers\internals\User($bdd); + $internal_settings = new \controllers\internals\Setting($bdd); + $internal_sended = new \controllers\internals\Sended($bdd); + $internal_phone_reliability = new \controllers\internals\PhoneReliability($bdd); + $internal_phone = new \controllers\internals\Phone($bdd); + $internal_webhook = new \controllers\internals\Webhook($bdd); + $internal_mailer = new \controllers\internals\Mailer(); + + $users = $internal_user->get_all_active(); + foreach ($users as $user) + { + $settings = $internal_settings->gets_for_user($user['id']); + echo "\nCheck phone reliability for user " . $user['id'] . ":\n"; + if ($settings['phone_reliability_failed']) + { + $rate_limit = intval($settings['phone_reliability_failed_rate_limit']) / 100; + $min_volume = intval($settings['phone_reliability_failed_volume']); + $period = intval($settings['phone_reliability_failed_period']); + $grace_period = intval($settings['phone_reliability_failed_grace_period']); + + echo " Check for failed SMS with rate > " . $rate_limit . " and volume > " . $min_volume . " on period " . $period . "s with grace period of " . $grace_period . "s.\n"; + + $unreliable_phones = $internal_phone_reliability->find_unreliable_phones($user['id'], \models\Sended::STATUS_FAILED, $rate_limit, $min_volume, $period, $grace_period); + foreach ($unreliable_phones as $unreliable_phone) + { + $phone = $internal_phone->get($unreliable_phone['id_phone']); + if (!$phone) + { + echo ' Cannot find phone: ' . $unreliable_phone['id_phone'] . "\n"; + + continue; + } + + echo "\n Phone " . $phone['id'] . ' - ' . $phone['name'] . " failed rate = " . $unreliable_phone['rate'] . " > " . $rate_limit . " and volume " . $unreliable_phone['total'] . " > " . $min_volume . "\n"; + + $internal_phone_reliability->create($user['id'], $phone['id'], \models\Sended::STATUS_FAILED); + + if ($settings['phone_reliability_failed_email']) + { + $success = $internal_mailer->enqueue($user['email'], EMAIL_PHONE_RELIABILITY_FAILED, [ + 'phone' => $phone, + 'period' => $period, + 'total' => $unreliable_phone['total'], + 'unreliable' => $unreliable_phone['unreliable'], + 'rate' => $unreliable_phone['rate'], + ]); + + if (!$success) + { + echo ' Cannot enqueue alert for unreliable failed phone: ' . $unreliable_phone['id_phone'] . "\n"; + + continue; + } + + echo " Alert mail for unreliable failed phone " . $phone['id'] . ' - ' . $phone['name'] . " added\n"; + } + + if ($settings['phone_reliability_failed_webhook']) + { + $webhook = [ + 'reliability_type' => \models\Sended::STATUS_FAILED, + 'id_phone' => $unreliable_phone['id_phone'], + 'period' => $period, + 'total' => $unreliable_phone['total'], + 'unreliable' => $unreliable_phone['unreliable'], + 'rate' => $unreliable_phone['rate'], + ]; + + $internal_webhook->trigger($user['id'], \models\Webhook::TYPE_PHONE_RELIABILITY, $webhook); + + echo " Webhook for unreliable failed phone " . $phone['id'] . ' - ' . $phone['name'] . " triggered\n"; + } + + if ($settings['phone_reliability_failed_auto_disable']) + { + $internal_phone->update_status($unreliable_phone['id_phone'], \models\Phone::STATUS_DISABLED); + } + } + } + + if ($settings['phone_reliability_unknown']) + { + $rate_limit = intval($settings['phone_reliability_unknown_rate_limit']) / 100; + $min_volume = intval($settings['phone_reliability_unknown_volume']); + $period = intval($settings['phone_reliability_unknown_period']); + $grace_period = intval($settings['phone_reliability_unknown_grace_period']); + + echo "\n Check for unknown SMS with rate > " . $rate_limit . " and volume > " . $min_volume . " on period " . $period . "s with grace period of " . $grace_period . "s.\n"; + + $unreliable_phones = $internal_phone_reliability->find_unreliable_phones($user['id'], \models\Sended::STATUS_UNKNOWN, $rate_limit, $min_volume, $period, $grace_period); + foreach ($unreliable_phones as $unreliable_phone) + { + $phone = $internal_phone->get($unreliable_phone['id_phone']); + if (!$phone) + { + echo ' Cannot find phone: ' . $unreliable_phone['id_phone'] . "\n"; + + continue; + } + + echo "\n Phone " . $phone['id'] . ' - ' . $phone['name'] . " unknown rate = " . $unreliable_phone['rate'] . " > " . $rate_limit . "\n"; + + $internal_phone_reliability->create($user['id'], $phone['id'], \models\Sended::STATUS_UNKNOWN); + + if ($settings['phone_reliability_unknown_email']) + { + $success = $internal_mailer->enqueue($user['email'], EMAIL_PHONE_RELIABILITY_UNKNOWN, [ + 'phone' => $phone, + 'period' => $period, + 'total' => $unreliable_phone['total'], + 'unreliable' => $unreliable_phone['unreliable'], + 'rate' => $unreliable_phone['rate'], + ]); + + if (!$success) + { + echo ' Cannot enqueue alert for unreliable unknown phone: ' . $unreliable_phone['id_phone'] . "\n"; + + continue; + } + + echo " Alert mail for unreliable unknown phone " . $phone['id'] . ' - ' . $phone['name'] . " added\n"; + } + + if ($settings['phone_reliability_unknown_webhook']) + { + $webhook = [ + 'reliability_type' => \models\Sended::STATUS_UNKNOWN, + 'id_phone' => $unreliable_phone['id_phone'], + 'period' => $period, + 'total' => $unreliable_phone['total'], + 'unreliable' => $unreliable_phone['unreliable'], + 'rate' => $unreliable_phone['rate'], + ]; + + $internal_webhook->trigger($user['id'], \models\Webhook::TYPE_PHONE_RELIABILITY, $webhook); + + echo " Webhook for unreliable unknown phone " . $phone['id'] . ' - ' . $phone['name'] . " triggered\n"; + } + + if ($settings['phone_reliability_unknown_auto_disable']) + { + $internal_phone->update_status($unreliable_phone['id_phone'], \models\Phone::STATUS_DISABLED); + } + } + } + } + } + + /** + * Function to easily populate the database with fake data for testing. + * + * @param int $id_user : User ID for whom data is to be generated + * @param int $received_entries : Number of entries to add to the received table + * @param int $sended_entries : Number of entries to add to the sended table + * @param int $contact_entries : Number of entries to add to the contact table + */ + public function seed_database(int $id_user, int $received_entries, int $sended_entries, int $contact_entries) + { + $this->seed_received($id_user, $received_entries); + $this->seed_sended($id_user, $sended_entries); + $this->seed_contact($id_user, $contact_entries); + } + + /** + * Fill table received with fake data + * + * @param int $id_user : User to insert received for + * @param int $entries : How many received to insert + */ + public function seed_received(int $id_user, int $entries) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_received = new \controllers\internals\Received($bdd); + $internal_phone = new \controllers\internals\Phone($bdd); + $faker = Factory::create(); + + $phones = $internal_phone->gets_for_user($id_user); + + for ($i = 0; $i < $entries; $i++) + { + $id_phone = $faker->randomElement($phones)['id']; + $at = $faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d H:i:s'); + $text = $faker->sentence(rand(5,10), true); + $origin = $faker->e164PhoneNumber; + $status = $faker->randomElement(['read', 'unread']); + $command = false; + $mms = false; + $media_ids = []; + + + $internal_received->create($id_user, $id_phone, $at, $text, $origin, $status, $command, $mms, $media_ids); + } + } + + /** + * Fill table sended with fake data + * + * @param int $id_user : User to insert sended entries for + * @param int $entries : Number of entries to insert + */ + public function seed_sended(int $id_user, int $entries) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_sended = new \controllers\internals\Sended($bdd); + $internal_phone = new \controllers\internals\Phone($bdd); + $faker = Factory::create(); + + $phones = $internal_phone->gets_for_user($id_user); + + for ($i = 0; $i < $entries; $i++) + { + echo $i."\n"; + $phone = $faker->randomElement($phones); + $id_phone = $phone['id']; + $at = $faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d H:i:s'); + $text = $faker->sentence(rand(5, 10), true); + $destination = $faker->e164PhoneNumber; + $uid = $faker->uuid; + $adapter = $phone['adapter']; + $flash = $faker->boolean; + $mms = $faker->boolean; + $tag = $faker->optional()->word; + $medias = []; // Add logic for media IDs if needed + $originating_scheduled = $faker->numberBetween(1, 100); + $status = $faker->randomElement([\models\Sended::STATUS_UNKNOWN, \models\Sended::STATUS_DELIVERED, \models\Sended::STATUS_FAILED]); + + $internal_sended->create($id_user, $id_phone, $at, $text, $destination, $uid, $adapter, $flash, $mms, $tag, $medias, $originating_scheduled, $status); + } + } + + /** + * Fill table contact with fake data + * + * @param int $id_user : User to insert contacts for + * @param int $entries : Number of contacts to insert + */ + public function seed_contact(int $id_user, int $entries) + { + $bdd = \descartes\Model::_connect(DATABASE_HOST, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD); + $internal_contact = new \controllers\internals\Contact($bdd); + $faker = Factory::create(); + + for ($i = 0; $i < $entries; $i++) + { + $name = $faker->name; + $number = $faker->e164PhoneNumber; + $data = '[]'; + + $internal_contact->create($id_user, $number, $name, $data); + } + } +} diff --git a/controllers/internals/PhoneReliability.php b/controllers/internals/PhoneReliability.php new file mode 100644 index 0000000..0b08d87 --- /dev/null +++ b/controllers/internals/PhoneReliability.php @@ -0,0 +1,65 @@ + + * + * This source file is subject to the GPL-3.0 license that is bundled + * with this source code in the file LICENSE. + */ + +namespace controllers\internals; + +use Exception; + + class PhoneReliability extends StandardController + { + protected $model; + + /** + * Create a phone reliability history entry. + * + * @param int $id_user : Id of user to create sended message for + * @param int $id_phone : Id of the number the message was send with + * @param $type : Type of reliability alert + * @return mixed : false on error, new sended id else + */ + public function create(int $id_user, int $id_phone, string $type) + { + return $this->get_model()->insert([ + 'id_user' => $id_user, + 'id_phone' => $id_phone, + 'type' => $type, + ]); + + return $id_sended; + } + + /** + * Find all unreliable phones for a user, based on sended sms status, rate limit, etc. + * + * @param int $id_user : User id + * @param string $sms_status : Status of SMS to use to calculate rate + * @param float $rate_limit : Percentage of SMS matching status after which we consider the phone unreliable + * @param int $min_volume : Minimum number of sms we need to have to consider the statistic relevent + * @param int $period : The time span in minutes from which SMS counting should begin. + * @param int $grace_period : How long in minutes should we wait before including a SMS in counting + * + * @return array : A list of unreliable phone for the user, with phone id, total number of sms, and rate of failed sms + */ + public function find_unreliable_phones (int $id_user, string $sms_status, float $rate_limit, int $min_volume, int $period, int $grace_period) + { + return $this->get_model()->find_unreliable_phones($id_user, $sms_status, $rate_limit, $min_volume, $period, $grace_period); + } + + /** + * Get the model for the Controller. + */ + protected function get_model(): \models\PhoneReliabilityHistory + { + $this->model = $this->model ?? new \models\PhoneReliabilityHistory($this->bdd); + + return $this->model; + } + } diff --git a/controllers/internals/User.php b/controllers/internals/User.php index 6dd93ac..45b129d 100644 --- a/controllers/internals/User.php +++ b/controllers/internals/User.php @@ -31,6 +31,17 @@ namespace controllers\internals; $this->internal_phone = new Phone($bdd); } + /** + * Return all active users. + * + * @return array + */ + public function get_all_active() + { + return $this->model_user->get_all_active(); + } + + /** * Return a list of users by their ids. * diff --git a/controllers/publics/Api.php b/controllers/publics/Api.php index d7a4b88..be9cc23 100644 --- a/controllers/publics/Api.php +++ b/controllers/publics/Api.php @@ -1034,6 +1034,15 @@ namespace controllers\publics; return $this->json($return); } + if ($phone['status'] === \models\Phone::STATUS_DISABLED) + { + $return['error'] = self::ERROR_CODES['CANNOT_UPDATE']; + $return['message'] = self::ERROR_MESSAGES['CANNOT_UPDATE'] . 'Phone have been manually disabled, you need to re-enable it manually.'; + $this->auto_http_code(false); + + return $this->json($return); + } + // If user have activated phone limits, check if RaspiSMS phone limit have already been reached $limit_reached = false; if ((int) ($this->user['settings']['phone_limit'] ?? false)) @@ -1073,6 +1082,42 @@ namespace controllers\publics; return $this->json($return); } + /** + * Manually disable/enable phones + * @param int id : id of phone we want to update status + * @param string $_POST['new_status'] : New status of the phone, either 'disabled' or 'available' + * @param $csrf : CSRF token + */ + public function post_change_phone_status ($id) + { + $new_status = $_POST['status'] ?? ''; + + if (!in_array($new_status, [\models\Phone::STATUS_AVAILABLE, \models\Phone::STATUS_DISABLED])) + { + $return['error'] = self::ERROR_CODES['INVALID_PARAMETER']; + $return['message'] = self::ERROR_MESSAGES['INVALID_PARAMETER'] . ' "status" must be "disabled" or "available".'; + $this->auto_http_code(false); + + return $this->json($return); + } + + $phone = $this->internal_phone->get_for_user($this->user['id'], $id); + if (!$phone) + { + $return['error'] = self::ERROR_CODES['CANNOT_UPDATE']; + $return['message'] = self::ERROR_MESSAGES['CANNOT_UPDATE']; + $this->auto_http_code(false); + + return $this->json($return); + } + + $status_update = $this->internal_phone->update_status($id, $new_status); + $return['response'] = $new_status; + $this->auto_http_code(true); + + return $this->json($return); + } + /** * Return statistics about status of sended sms for a period by phone diff --git a/controllers/publics/Phone.php b/controllers/publics/Phone.php index ef06f7b..3b35906 100644 --- a/controllers/publics/Phone.php +++ b/controllers/publics/Phone.php @@ -535,6 +535,16 @@ class Phone extends \descartes\Controller foreach ($ids as $id) { $phone = $this->internal_phone->get_for_user($id_user, $id); + if (!$phone) + { + continue; + } + + if ($phone['status'] === \models\Phone::STATUS_DISABLED) + { + \FlashMessage\FlashMessage::push('error', 'Certains téléphones ont été désactivés manuellements, vous devez les réactiver manuellement.'); + continue; + } // If user have activated phone limits, check if RaspiSMS phone limit have already been reached $limit_reached = false; @@ -581,6 +591,48 @@ class Phone extends \descartes\Controller return $this->redirect(\descartes\Router::url('Phone', 'list')); } + + /** + * Manually disable/enable phones + * @param array int $_GET['ids'] : ids of phones we want to update status + * @param string $new_status : New status of the phone, either 'disabled' or 'available' + * @param $csrf : CSRF token + */ + public function change_status ($new_status, $csrf) + { + if (!$this->verify_csrf($csrf)) + { + \FlashMessage\FlashMessage::push('danger', 'Jeton CSRF invalid !'); + + return $this->redirect(\descartes\Router::url('Phone', 'add')); + } + + if (!in_array($new_status, [\models\Phone::STATUS_AVAILABLE, \models\Phone::STATUS_DISABLED])) + { + \FlashMessage\FlashMessage::push('danger', 'Seul les status disponibles et désactivés peuvent être définis manuellement.'); + + return $this->redirect(\descartes\Router::url('Phone', 'add')); + } + + $ids = $_GET['ids'] ?? []; + $id_user = $_SESSION['user']['id']; + + foreach ($ids as $id) + { + $phone = $this->internal_phone->get_for_user($id_user, $id); + if (!$phone) + { + continue; + } + + $status_update = $this->internal_phone->update_status($id, $new_status); + } + + \FlashMessage\FlashMessage::push('success', 'Les status des téléphones ont bien été mis à jour manuellement.'); + + return $this->redirect(\descartes\Router::url('Phone', 'list')); + } + /** * Return a list of phones as a JSON array */ diff --git a/db/migrations/20241023185342_add_status_disabled_phone.php b/db/migrations/20241023185342_add_status_disabled_phone.php new file mode 100644 index 0000000..38926c5 --- /dev/null +++ b/db/migrations/20241023185342_add_status_disabled_phone.php @@ -0,0 +1,38 @@ +table('phone'); + $table->changeColumn('status', 'enum', ['values' => ['available', 'unavailable', 'no_credit', 'limit_reached', 'disabled'], 'default' => 'available']); + $table->save(); + } +} diff --git a/db/migrations/20241026134059_add_webhook_phone_reliability.php b/db/migrations/20241026134059_add_webhook_phone_reliability.php new file mode 100644 index 0000000..ce603f1 --- /dev/null +++ b/db/migrations/20241026134059_add_webhook_phone_reliability.php @@ -0,0 +1,38 @@ +table('webhook'); + $table->changeColumn('type', 'enum', ['values' => ['send_sms','send_sms_status_change','receive_sms','inbound_call', 'phone_reliability']]); + $table->save(); + } +} diff --git a/db/migrations/20241026141333_add_table_phone_reliability_history.php b/db/migrations/20241026141333_add_table_phone_reliability_history.php new file mode 100644 index 0000000..a39a93c --- /dev/null +++ b/db/migrations/20241026141333_add_table_phone_reliability_history.php @@ -0,0 +1,22 @@ +table('phone_reliability_history') + ->addColumn('id_user', 'integer', ['null' => false]) + ->addColumn('id_phone', 'integer', ['null' => false]) + ->addColumn('type', 'string', ['null' => false, 'limit' => 100]) + ->addColumn('created_at', 'timestamp', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'update' => 'CURRENT_TIMESTAMP']) + ->addForeignKey('id_user', 'user', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('id_phone', 'phone', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + } +} diff --git a/env.php.dist b/env.php.dist index 1ce52ac..608200f 100644 --- a/env.php.dist +++ b/env.php.dist @@ -91,6 +91,22 @@ 'shorten_url' => 0, 'smsstop_respond' => 1, 'smsstop_response' => 'Demande prise en compte, vous ne recevrez plus de messages.', + 'phone_reliability_failed' => 1, + 'phone_reliability_failed_rate_limit' => 10, + 'phone_reliability_failed_volume' => 25, + 'phone_reliability_failed_period' => 120, + 'phone_reliability_failed_grace_period' => 1, + 'phone_reliability_failed_email' => 0, + 'phone_reliability_failed_webhook' => 1, + 'phone_reliability_failed_auto_disable' => 0, + 'phone_reliability_unknown' => 0, + 'phone_reliability_unknown_rate_limit' => 25, + 'phone_reliability_unknown_volume' => 25, + 'phone_reliability_unknown_period' => 120, + 'phone_reliability_unknown_grace_period' => 1, + 'phone_reliability_unknown_email' => 0, + 'phone_reliability_unknown_webhook' => 1, + 'phone_reliability_unknown_auto_disable' => 0, ], ]; diff --git a/models/Phone.php b/models/Phone.php index 6be3735..b03fd72 100644 --- a/models/Phone.php +++ b/models/Phone.php @@ -14,10 +14,11 @@ namespace models; class Phone extends StandardModel { - const STATUS_AVAILABLE = 'available'; - const STATUS_UNAVAILABLE = 'unavailable'; - const STATUS_NO_CREDIT = 'no_credit'; - const STATUS_LIMIT_REACHED = 'limit_reached'; + const STATUS_AVAILABLE = 'available'; # Everything OK + const STATUS_UNAVAILABLE = 'unavailable'; # RaspiSMS cannot communication with the phone + const STATUS_DISABLED = 'disabled'; # Phone have been manually or automatically disabled by user/system + const STATUS_NO_CREDIT = 'no_credit'; # Phone have no more credit available + const STATUS_LIMIT_REACHED = 'limit_reached'; # We reached the limit in of SMS in RaspiSMS for this phone /** * Return all phones that belongs to active users @@ -109,7 +110,6 @@ namespace models; return $this->_delete('phone_limit', ['id_phone' => $id_phone]); } - /** * Return table name. */ diff --git a/models/PhoneReliabilityHistory.php b/models/PhoneReliabilityHistory.php new file mode 100644 index 0000000..a9b3c5d --- /dev/null +++ b/models/PhoneReliabilityHistory.php @@ -0,0 +1,95 @@ + + * + * This source file is subject to the GPL-3.0 license that is bundled + * with this source code in the file LICENSE. + */ + +namespace models; + + class PhoneReliabilityHistory extends StandardModel + { + /** + * Find all unreliable phones for a user, based on sended sms status, rate limit, etc. + * + * @param int $id_user : User id + * @param string $sms_status : Status of SMS to use to calculate rate + * @param float $rate_limit : Percentage of SMS matching status after which we consider the phone unreliable + * @param int $min_volume : Minimum number of sms we need to have to consider the statistic relevent + * @param int $period : The time span in minutes from which SMS counting should begin. + * @param int $grace_period : How long in minutes should we wait before including a SMS in counting + * + * @return array : A list of unreliable phone for the user, with phone id, total number of sms, and rate of failed sms + */ + public function find_unreliable_phones (int $id_user, string $sms_status, float $rate_limit, int $min_volume, int $period, int $grace_period) + { + return $this->_run_query(" + WITH recent_messages AS ( + SELECT + sended.id_phone AS id_phone, + COUNT(sended.id) AS total, + SUM(sended.status = :sms_status) AS unreliable + FROM + sended + JOIN + phone + ON + sended.id_phone = phone.id + LEFT JOIN + ( + SELECT + id_phone, + MAX(created_at) AS last_alert_time + FROM + phone_reliability_history + WHERE + type = :sms_status + GROUP BY + id_phone + ) AS last_alerts + ON + sended.id_phone = last_alerts.id_phone + WHERE + sended.id_user = :id_user + AND + phone.status != 'disabled' + AND + sended.at > IFNULL(last_alerts.last_alert_time, '1970-01-01') + AND + sended.at BETWEEN NOW() - INTERVAL :period MINUTE AND NOW() - INTERVAL :grace_period MINUTE + GROUP BY + id_phone + ) + SELECT + id_phone, + total, + unreliable, + (unreliable / total) AS rate + FROM + recent_messages + WHERE + total >= :min_volume + AND + (unreliable / total) > :rate_limit; + ", [ + 'id_user' => $id_user, + 'sms_status' => $sms_status, + 'period' => $period, + 'grace_period' => $grace_period, + 'min_volume' => $min_volume, + 'rate_limit' => $rate_limit, + ]); + } + + /** + * Return table name. + */ + protected function get_table_name(): string + { + return 'phone_reliability_history'; + } + } diff --git a/models/Sended.php b/models/Sended.php index 8d0e962..cba178c 100644 --- a/models/Sended.php +++ b/models/Sended.php @@ -338,6 +338,7 @@ namespace models; return $this->_run_query($query, $params); } + /** * Return table name. */ diff --git a/models/User.php b/models/User.php index 43a1560..6e84ea0 100644 --- a/models/User.php +++ b/models/User.php @@ -31,6 +31,16 @@ namespace models; return $this->_select_one('user', ['id' => $id]); } + /** + * Return all active users. + * + * @return array + */ + public function get_all_active() + { + return $this->_select('user', ['status' => self::STATUS_ACTIVE]); + } + /** * Find user by ids. * diff --git a/models/Webhook.php b/models/Webhook.php index f963df9..c019e8e 100644 --- a/models/Webhook.php +++ b/models/Webhook.php @@ -19,6 +19,7 @@ namespace models; const TYPE_INBOUND_CALL = 'inbound_call'; const TYPE_QUOTA_LEVEL_ALERT = 'quota_level'; const TYPE_QUOTA_REACHED = 'quota_reached'; + const TYPE_PHONE_RELIABILITY = 'phone_reliability'; /** * Find all webhooks for a user and for a type of webhook. diff --git a/routes.php b/routes.php index 5d7655c..9265e38 100644 --- a/routes.php +++ b/routes.php @@ -165,6 +165,7 @@ 'edit' => '/phone/edit/', 'update' => '/phone/update/{csrf}/', 'update_status' => '/phone/update_status/{csrf}/', + 'change_status' => '/phone/change_status/{new_status}/{csrf}/', 'json_list' => '/phones.json/', ], @@ -229,6 +230,9 @@ 'post_update_phone_status' => [ '/api/phone/{id}/status/', ], + 'post_change_phone_status' => [ + '/api/phone/{id}/status/force/', + ], 'delete_phone' => [ '/api/phone/{id}/', ], diff --git a/templates/email/phone-reliability-failed.php b/templates/email/phone-reliability-failed.php new file mode 100644 index 0000000..0d7c2ab --- /dev/null +++ b/templates/email/phone-reliability-failed.php @@ -0,0 +1,10 @@ +Le téléphone s($phone['name']); ?> semble rencontrer un taux de SMS échoués anormalement élevé. + +Période prise en compte : s($period); ?> dernières minutes +Total de SMS : s($total); ?> +Nombre d'échecs : s($unreliable); ?> +Taux d'échecs : s($rate); ?>% + + +-------------------------------------------------------------------------------------------- +Pour plus d'informations sur le système RaspiSMS, rendez-vous sur le site https://raspisms.fr diff --git a/templates/email/phone-reliability-unknown.php b/templates/email/phone-reliability-unknown.php new file mode 100644 index 0000000..80daf6d --- /dev/null +++ b/templates/email/phone-reliability-unknown.php @@ -0,0 +1,10 @@ +Le téléphone s($phone['name']); ?> semble rencontrer un taux de SMS incconnus anormalement élevé. + +Période prise en compte : s($period); ?> dernières minutes +Total de SMS : s($total); ?> +Nombre d'inconnus : s($unreliable); ?> +Taux d'inconnus : s($rate); ?>% + + +-------------------------------------------------------------------------------------------- +Pour plus d'informations sur le système RaspiSMS, rendez-vous sur le site https://raspisms.fr diff --git a/templates/phone/list.php b/templates/phone/list.php index 20aa0e9..192ccb3 100644 --- a/templates/phone/list.php +++ b/templates/phone/list.php @@ -62,9 +62,11 @@