Security configuration enhancements in the application (#1208)
This commit is contained in:
parent
aeee91f4ed
commit
bb71c97736
9 changed files with 209 additions and 19 deletions
|
@ -65,7 +65,7 @@ $autoload['libraries'] = ['database', 'session'];
|
||||||
| $autoload['helper'] = array('url', 'file');
|
| $autoload['helper'] = array('url', 'file');
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$autoload['helper'] = ['custom_exceptions', 'url', 'file', 'language', 'asset', 'config', 'render'];
|
$autoload['helper'] = ['custom_exceptions', 'url', 'file', 'language', 'asset', 'config', 'render', 'rate_limit', 'security'];
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -367,7 +367,7 @@ $config['sess_regenerate_destroy'] = FALSE;
|
||||||
$config['cookie_prefix'] = "";
|
$config['cookie_prefix'] = "";
|
||||||
$config['cookie_domain'] = "";
|
$config['cookie_domain'] = "";
|
||||||
$config['cookie_path'] = "/";
|
$config['cookie_path'] = "/";
|
||||||
$config['cookie_secure'] = FALSE;
|
$config['cookie_secure'] = strpos($config['base_url'], 'https') !== FALSE;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
@ -457,6 +457,17 @@ $config['rewrite_short_tags'] = FALSE;
|
||||||
*/
|
*/
|
||||||
$config['proxy_ips'] = '';
|
$config['proxy_ips'] = '';
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Toggle the rate limiting feature in your application. Using rate limiting
|
||||||
|
| will control the number of requests a client can sent to the app.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
$config['rate_limiting'] = TRUE;
|
||||||
|
|
||||||
|
|
||||||
/* End of file config.php */
|
/* End of file config.php */
|
||||||
/* Location: ./application/config/config.php */
|
/* Location: ./application/config/config.php */
|
||||||
|
|
|
@ -88,6 +88,23 @@ class Appointments extends EA_Controller {
|
||||||
$available_providers[$index] = $stripped_data;
|
$available_providers[$index] = $stripped_data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the data that are not needed inside the $available_services array.
|
||||||
|
foreach ($available_services as $index => $service)
|
||||||
|
{
|
||||||
|
$stripped_data = [
|
||||||
|
'id' => $service['id'],
|
||||||
|
'name' => $service['name'],
|
||||||
|
'duration' => $service['duration'],
|
||||||
|
'location' => $service['location'],
|
||||||
|
'price' => $service['price'],
|
||||||
|
'currency' => $service['currency'],
|
||||||
|
'description' => $service['description'],
|
||||||
|
'category_id' => $service['category_id'],
|
||||||
|
'category_name' => $service['category_name']
|
||||||
|
];
|
||||||
|
$available_services[$index] = $stripped_data;
|
||||||
|
}
|
||||||
|
|
||||||
// If an appointment hash is provided then it means that the customer is trying to edit a registered
|
// If an appointment hash is provided then it means that the customer is trying to edit a registered
|
||||||
// appointment record.
|
// appointment record.
|
||||||
if ($appointment_hash !== '')
|
if ($appointment_hash !== '')
|
||||||
|
@ -133,9 +150,40 @@ class Appointments extends EA_Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
$appointment = $results[0];
|
$appointment = $results[0];
|
||||||
|
|
||||||
|
$appointment = [
|
||||||
|
'id' => $appointment['id'],
|
||||||
|
'hash' => $appointment['hash'],
|
||||||
|
'start_datetime' => $appointment['start_datetime'],
|
||||||
|
'end_datetime' => $appointment['end_datetime'],
|
||||||
|
'id_services' => $appointment['id_services'],
|
||||||
|
'id_users_customer' => $appointment['id_users_customer'],
|
||||||
|
'id_users_provider' => $appointment['id_users_provider'],
|
||||||
|
'notes' => $appointment['notes']
|
||||||
|
];
|
||||||
|
|
||||||
$provider = $this->providers_model->get_row($appointment['id_users_provider']);
|
$provider = $this->providers_model->get_row($appointment['id_users_provider']);
|
||||||
|
|
||||||
|
$provider = [
|
||||||
|
'id' => $provider['id'],
|
||||||
|
'first_name' => $provider['first_name'],
|
||||||
|
'last_name' => $provider['last_name'],
|
||||||
|
'services' => $provider['services'],
|
||||||
|
'timezone' => $provider['timezone']
|
||||||
|
];
|
||||||
|
|
||||||
$customer = $this->customers_model->get_row($appointment['id_users_customer']);
|
$customer = $this->customers_model->get_row($appointment['id_users_customer']);
|
||||||
|
|
||||||
|
$customer = [
|
||||||
|
'id' => $customer['id'],
|
||||||
|
'first_name' => $customer['first_name'],
|
||||||
|
'last_name' => $customer['last_name'],
|
||||||
|
'timezone' => $customer['timezone'],
|
||||||
|
'address' => $customer['address'],
|
||||||
|
'city' => $customer['city'],
|
||||||
|
'zip_code' => $customer['zip_code']
|
||||||
|
];
|
||||||
|
|
||||||
$customer_token = md5(uniqid(mt_rand(), TRUE));
|
$customer_token = md5(uniqid(mt_rand(), TRUE));
|
||||||
|
|
||||||
// Save the token for 10 minutes.
|
// Save the token for 10 minutes.
|
||||||
|
@ -197,6 +245,13 @@ class Appointments extends EA_Controller {
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
$cancel_reason = $this->input->post('cancel_reason');
|
||||||
|
|
||||||
|
if ($this->input->method() !== 'post' || empty($cancel_reason))
|
||||||
|
{
|
||||||
|
show_error('Bad Request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Check whether the appointment hash exists in the database.
|
// Check whether the appointment hash exists in the database.
|
||||||
$appointments = $this->appointments_model->get_batch(['hash' => $appointment_hash]);
|
$appointments = $this->appointments_model->get_batch(['hash' => $appointment_hash]);
|
||||||
|
|
||||||
|
@ -279,22 +334,25 @@ class Appointments extends EA_Controller {
|
||||||
$exceptions = $this->session->flashdata('book_success');
|
$exceptions = $this->session->flashdata('book_success');
|
||||||
|
|
||||||
$view = [
|
$view = [
|
||||||
'appointment_data' => $appointment,
|
'appointment_data' => [
|
||||||
|
'start_datetime' => $appointment['start_datetime'],
|
||||||
|
'end_datetime' => $appointment['end_datetime'],
|
||||||
|
],
|
||||||
'provider_data' => [
|
'provider_data' => [
|
||||||
'id' => $provider['id'],
|
|
||||||
'first_name' => $provider['first_name'],
|
'first_name' => $provider['first_name'],
|
||||||
'last_name' => $provider['last_name'],
|
'last_name' => $provider['last_name'],
|
||||||
'email' => $provider['email'],
|
'email' => $provider['email'],
|
||||||
'timezone' => $provider['timezone'],
|
'timezone' => $provider['timezone'],
|
||||||
],
|
],
|
||||||
'customer_data' => [
|
'customer_data' => [
|
||||||
'id' => $customer['id'],
|
|
||||||
'first_name' => $customer['first_name'],
|
'first_name' => $customer['first_name'],
|
||||||
'last_name' => $customer['last_name'],
|
'last_name' => $customer['last_name'],
|
||||||
'email' => $customer['email'],
|
'email' => $customer['email'],
|
||||||
'timezone' => $customer['timezone'],
|
'timezone' => $customer['timezone'],
|
||||||
],
|
],
|
||||||
'service_data' => $service,
|
'service_data' => [
|
||||||
|
'name' => $service['name'],
|
||||||
|
],
|
||||||
'company_name' => $company_name,
|
'company_name' => $company_name,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -386,7 +444,7 @@ class Appointments extends EA_Controller {
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function search_any_provider($service_id, $date, $hour = null)
|
protected function search_any_provider($service_id, $date, $hour = NULL)
|
||||||
{
|
{
|
||||||
$available_providers = $this->providers_model->get_available_providers();
|
$available_providers = $this->providers_model->get_available_providers();
|
||||||
|
|
||||||
|
@ -405,7 +463,7 @@ class Appointments extends EA_Controller {
|
||||||
// Check if the provider is available for the requested date.
|
// Check if the provider is available for the requested date.
|
||||||
$available_hours = $this->availability->get_available_hours($date, $service, $provider);
|
$available_hours = $this->availability->get_available_hours($date, $service, $provider);
|
||||||
|
|
||||||
if (count($available_hours) > $max_hours_count && (empty($hour) || in_array($hour, $available_hours, false)))
|
if (count($available_hours) > $max_hours_count && (empty($hour) || in_array($hour, $available_hours, FALSE)))
|
||||||
{
|
{
|
||||||
$provider_id = $provider['id'];
|
$provider_id = $provider['id'];
|
||||||
$max_hours_count = count($available_hours);
|
$max_hours_count = count($available_hours);
|
||||||
|
@ -448,7 +506,7 @@ class Appointments extends EA_Controller {
|
||||||
$captcha_phrase = $this->session->userdata('captcha_phrase');
|
$captcha_phrase = $this->session->userdata('captcha_phrase');
|
||||||
|
|
||||||
// Validate the CAPTCHA string.
|
// Validate the CAPTCHA string.
|
||||||
if ($require_captcha === '1' && $captcha_phrase !== $captcha)
|
if ($require_captcha === '1' && strtolower($captcha_phrase) !== strtolower($captcha))
|
||||||
{
|
{
|
||||||
$this->output
|
$this->output
|
||||||
->set_content_type('application/json')
|
->set_content_type('application/json')
|
||||||
|
@ -473,6 +531,16 @@ class Appointments extends EA_Controller {
|
||||||
$customer['language'] = config('language');
|
$customer['language'] = config('language');
|
||||||
$customer_id = $this->customers_model->add($customer);
|
$customer_id = $this->customers_model->add($customer);
|
||||||
|
|
||||||
|
$appointment_start_instance = new DateTime($appointment['start_datetime']);
|
||||||
|
$appointment['end_datetime'] = $appointment_start_instance
|
||||||
|
->add(new DateInterval('PT' . $service['duration'] . 'M'))
|
||||||
|
->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
if ( ! in_array($service['id'], $provider['services']))
|
||||||
|
{
|
||||||
|
throw new Exception('Invalid provider record selected for appointment.');
|
||||||
|
}
|
||||||
|
|
||||||
$appointment['id_users_customer'] = $customer_id;
|
$appointment['id_users_customer'] = $customer_id;
|
||||||
$appointment['is_unavailable'] = (int)$appointment['is_unavailable']; // needs to be type casted
|
$appointment['is_unavailable'] = (int)$appointment['is_unavailable']; // needs to be type casted
|
||||||
$appointment['id'] = $this->appointments_model->add($appointment);
|
$appointment['id'] = $this->appointments_model->add($appointment);
|
||||||
|
|
|
@ -55,6 +55,10 @@ class Backend_api extends EA_Controller {
|
||||||
{
|
{
|
||||||
$this->privileges = $this->roles_model->get_privileges($this->session->userdata('role_slug'));
|
$this->privileges = $this->roles_model->get_privileges($this->session->userdata('role_slug'));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
show_error('Forbidden', 403);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -66,6 +70,11 @@ class Backend_api extends EA_Controller {
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if ($this->privileges[PRIV_APPOINTMENTS]['view'] == FALSE)
|
||||||
|
{
|
||||||
|
throw new Exception('You do not have the required privileges for this task.');
|
||||||
|
}
|
||||||
|
|
||||||
$start_date = $this->input->post('startDate') . ' 00:00:00';
|
$start_date = $this->input->post('startDate') . ' 00:00:00';
|
||||||
$end_date = $this->input->post('endDate') . ' 23:59:59';
|
$end_date = $this->input->post('endDate') . ' 23:59:59';
|
||||||
|
|
||||||
|
@ -1527,18 +1536,25 @@ class Backend_api extends EA_Controller {
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if ( ! $this->input->post('provider_id'))
|
$provider_id = $this->input->post('provider_id');
|
||||||
|
|
||||||
|
if ( ! $provider_id)
|
||||||
{
|
{
|
||||||
throw new Exception('Provider id is required in order to fetch the google calendars.');
|
throw new Exception('Provider id is required in order to fetch the google calendars.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->privileges[PRIV_USERS]['view'] == FALSE && $provider_id !== $this->session->userdata('user_id'))
|
||||||
|
{
|
||||||
|
throw new Exception('You do not have the required privileges for this task.');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if selected provider has sync enabled.
|
// Check if selected provider has sync enabled.
|
||||||
$google_sync = $this->providers_model->get_setting('google_sync', $this->input->post('provider_id'));
|
$google_sync = $this->providers_model->get_setting('google_sync', $provider_id);
|
||||||
|
|
||||||
if ($google_sync)
|
if ($google_sync)
|
||||||
{
|
{
|
||||||
$google_token = json_decode($this->providers_model->get_setting('google_token',
|
$google_token = json_decode($this->providers_model->get_setting('google_token',
|
||||||
$this->input->post('provider_id')));
|
$provider_id));
|
||||||
$this->google_sync->refresh_token($google_token->refresh_token);
|
$this->google_sync->refresh_token($google_token->refresh_token);
|
||||||
|
|
||||||
$calendars = $this->google_sync->get_google_calendars();
|
$calendars = $this->google_sync->get_google_calendars();
|
||||||
|
|
|
@ -45,9 +45,9 @@ class Google extends EA_Controller {
|
||||||
$CI = get_instance();
|
$CI = get_instance();
|
||||||
|
|
||||||
// The user must be logged in.
|
// The user must be logged in.
|
||||||
if ($CI->session->userdata('user_id') == FALSE && is_cli() === FALSE)
|
if ( ! $CI->session->userdata('user_id') && ! is_cli())
|
||||||
{
|
{
|
||||||
return;
|
show_error('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($provider_id === NULL)
|
if ($provider_id === NULL)
|
||||||
|
@ -204,7 +204,6 @@ class Google extends EA_Controller {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Record doesn't exist in the Easy!Appointments, so add the event now.
|
// Record doesn't exist in the Easy!Appointments, so add the event now.
|
||||||
$appointment = [
|
$appointment = [
|
||||||
'start_datetime' => $event_start->format('Y-m-d H:i:s'),
|
'start_datetime' => $event_start->format('Y-m-d H:i:s'),
|
||||||
|
@ -248,6 +247,11 @@ class Google extends EA_Controller {
|
||||||
*/
|
*/
|
||||||
public function oauth($provider_id)
|
public function oauth($provider_id)
|
||||||
{
|
{
|
||||||
|
if ( ! $this->session->userdata('user_id'))
|
||||||
|
{
|
||||||
|
show_error('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
// Store the provider id for use on the callback function.
|
// Store the provider id for use on the callback function.
|
||||||
$this->session->set_userdata('oauth_provider_id', $provider_id);
|
$this->session->set_userdata('oauth_provider_id', $provider_id);
|
||||||
|
|
||||||
|
@ -268,6 +272,11 @@ class Google extends EA_Controller {
|
||||||
*/
|
*/
|
||||||
public function oauth_callback()
|
public function oauth_callback()
|
||||||
{
|
{
|
||||||
|
if ( ! $this->session->userdata('user_id'))
|
||||||
|
{
|
||||||
|
show_error('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
$code = $this->input->get('code');
|
$code = $this->input->get('code');
|
||||||
|
|
||||||
if (empty($code))
|
if (empty($code))
|
||||||
|
@ -298,6 +307,4 @@ class Google extends EA_Controller {
|
||||||
$this->output->set_output('Sync provider id not specified.');
|
$this->output->set_output('Sync provider id not specified.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
* @property User_model $user_model
|
* @property User_model $user_model
|
||||||
*
|
*
|
||||||
* @property Availability $availability
|
* @property Availability $availability
|
||||||
|
* @property Captcha_builder $captcha_builder
|
||||||
* @property Google_Sync $google_sync
|
* @property Google_Sync $google_sync
|
||||||
* @property Ics_file $ics_file
|
* @property Ics_file $ics_file
|
||||||
* @property Notifications $notifications
|
* @property Notifications $notifications
|
||||||
|
@ -66,6 +67,8 @@ class EA_Controller extends CI_Controller {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
|
||||||
$this->configure_language();
|
$this->configure_language();
|
||||||
|
|
||||||
|
rate_limit($this->input->ip_address());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
* @property User_model $user_model
|
* @property User_model $user_model
|
||||||
*
|
*
|
||||||
* @property Availability $availability
|
* @property Availability $availability
|
||||||
|
* @property Captcha_builder $captcha_builder
|
||||||
* @property Google_Sync $google_sync
|
* @property Google_Sync $google_sync
|
||||||
* @property Ics_file $ics_file
|
* @property Ics_file $ics_file
|
||||||
* @property Notifications $notifications
|
* @property Notifications $notifications
|
||||||
|
|
84
application/helpers/rate_limit_helper.php
Normal file
84
application/helpers/rate_limit_helper.php
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<?php defined('BASEPATH') or exit('No direct script access allowed');
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
* Easy!Appointments - Open Source Web Scheduler
|
||||||
|
*
|
||||||
|
* @package EasyAppointments
|
||||||
|
* @author A.Tselegidis <alextselegidis@gmail.com>
|
||||||
|
* @copyright Copyright (c) 2013 - 2020, Alex Tselegidis
|
||||||
|
* @license http://opensource.org/licenses/GPL-3.0 - GPLv3
|
||||||
|
* @link http://easyappointments.org
|
||||||
|
* @since v1.1.0
|
||||||
|
* ---------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
if ( ! function_exists('rate_limit'))
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Rate-limit the application requests.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* rate_limit($CI->input->ip_address(), 100, 300);
|
||||||
|
*
|
||||||
|
* @link https://github.com/alexandrugaidei-atomate/ratelimit-codeigniter-filebased
|
||||||
|
*
|
||||||
|
* @param string $ip Client IP address.
|
||||||
|
* @param int $max_requests Number of allowed requests, defaults to 100.
|
||||||
|
* @param int $duration In seconds, defaults to 5 minutes.
|
||||||
|
*/
|
||||||
|
function rate_limit($ip, $max_requests = 100, $duration = 300)
|
||||||
|
{
|
||||||
|
$CI =& get_instance();
|
||||||
|
|
||||||
|
$rate_limiting = $CI->config->item('rate_limiting');
|
||||||
|
|
||||||
|
if ( ! $rate_limiting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$CI->load->driver('cache', ['adapter' => 'file']);
|
||||||
|
|
||||||
|
$cache_key = str_replace(':', '', 'rate_limit_key_' . $ip);
|
||||||
|
|
||||||
|
$cache_remain_time_key = str_replace(':', '', 'rate_limit_tmp_' . $ip);
|
||||||
|
|
||||||
|
$current_time = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
if ($CI->cache->get($cache_key) === FALSE) // First request
|
||||||
|
{
|
||||||
|
$current_time_plus = date('Y-m-d H:i:s', strtotime('+' . $duration . ' seconds'));
|
||||||
|
|
||||||
|
$CI->cache->save($cache_key, 1, $duration);
|
||||||
|
|
||||||
|
$CI->cache->save($cache_remain_time_key, $current_time_plus, $duration * 2);
|
||||||
|
}
|
||||||
|
else // Consequent request
|
||||||
|
{
|
||||||
|
$requests = $CI->cache->get($cache_key);
|
||||||
|
|
||||||
|
$time_lost = $CI->cache->get($cache_remain_time_key);
|
||||||
|
|
||||||
|
if ($current_time > $time_lost)
|
||||||
|
{
|
||||||
|
$current_time_plus = date('Y-m-d H:i:s', strtotime('+' . $duration . ' seconds'));
|
||||||
|
|
||||||
|
$CI->cache->save($cache_key, 1, $duration);
|
||||||
|
|
||||||
|
$CI->cache->save($cache_remain_time_key, $current_time_plus, $duration * 2);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$CI->cache->save($cache_key, $requests + 1, $duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requests = $CI->cache->get($cache_key);
|
||||||
|
|
||||||
|
if ($requests > $max_requests)
|
||||||
|
{
|
||||||
|
header('HTTP/1.0 429 Too Many Requests');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -73,7 +73,7 @@ class Settings_model extends EA_Model {
|
||||||
if ($query->num_rows() > 0)
|
if ($query->num_rows() > 0)
|
||||||
{
|
{
|
||||||
// Update setting
|
// Update setting
|
||||||
if ( ! $this->db->update('settings', ['value' => $value], ['name' => $name]))
|
if ( ! $this->db->update('settings', ['value' => xss_clean($value)], ['name' => $name]))
|
||||||
{
|
{
|
||||||
throw new Exception('Could not update database setting.');
|
throw new Exception('Could not update database setting.');
|
||||||
}
|
}
|
||||||
|
@ -144,7 +144,7 @@ class Settings_model extends EA_Model {
|
||||||
foreach ($settings as $setting)
|
foreach ($settings as $setting)
|
||||||
{
|
{
|
||||||
$this->db->where('name', $setting['name']);
|
$this->db->where('name', $setting['name']);
|
||||||
if ( ! $this->db->update('settings', ['value' => $setting['value']]))
|
if ( ! $this->db->update('settings', ['value' => xss_clean($setting['value'])]))
|
||||||
{
|
{
|
||||||
throw new Exception('Could not save setting (' . $setting['name']
|
throw new Exception('Could not save setting (' . $setting['name']
|
||||||
. ' - ' . $setting['value'] . ')');
|
. ' - ' . $setting['value'] . ')');
|
||||||
|
|
Loading…
Reference in a new issue