From 927259ab9822ceca0132155cac760072633b5471 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 5 Jun 2024 12:44:36 +0200 Subject: [PATCH 01/16] ApiV1(*)Controller: Provide permission and add Routes --- configuration.php | 5 +++++ run.php | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/configuration.php b/configuration.php index 5d098aeb..c62c8ad9 100644 --- a/configuration.php +++ b/configuration.php @@ -42,6 +42,11 @@ $this->translate('Allow to configure contact groups') ); +$this->providePermission( + 'notifications/api/v1', + $this->translate('Allow to modify configuration via API') +); + $this->provideRestriction( 'notifications/filter/objects', $this->translate('Restrict access to the objects that match the filter') diff --git a/run.php b/run.php index 9e94cef8..6e610b85 100644 --- a/run.php +++ b/run.php @@ -21,3 +21,29 @@ ] ) ); + +$this->addRoute('notifications/api-v1-contacts', new Zend_Controller_Router_Route_Regex( + 'notifications/api/v1/contacts(?:\/(.+)|\?(.+))?', + [ + 'controller' => 'api-v1-contacts', + 'action' => 'index', + 'module' => 'notifications', + 'identifier' => null + ], + [ + 1 => 'identifier' + ] +)); + +$this->addRoute('notifications/api-v1-contactgroups', new Zend_Controller_Router_Route_Regex( + 'notifications/api/v1/contactgroups(?:\/(.+)|\?(.+))?', + [ + 'controller' => 'api-v1-contactgroups', + 'action' => 'index', + 'module' => 'notifications', + 'identifier' => null + ], + [ + 1 => 'identifier' + ] +)); From 2e209a0eeb218ae8fb375540eeb9fcf109dbfeb5 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Tue, 11 Jun 2024 10:57:47 +0200 Subject: [PATCH 02/16] Contact/Contactgroup(From): Add value for `external_uuid` column on insert Contactgroup: Don't trim groupname, as we donot do this in any other form. --- application/forms/ContactGroupForm.php | 9 ++++++++- library/Notifications/Web/Form/ContactForm.php | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/application/forms/ContactGroupForm.php b/application/forms/ContactGroupForm.php index d7d63ed8..7a462c2c 100644 --- a/application/forms/ContactGroupForm.php +++ b/application/forms/ContactGroupForm.php @@ -22,6 +22,7 @@ use ipl\Web\Compat\CompatForm; use ipl\Web\FormElement\TermInput; use ipl\Web\FormElement\TermInput\Term; +use Ramsey\Uuid\Uuid; class ContactGroupForm extends CompatForm { @@ -179,7 +180,13 @@ public function addGroup(): int $this->db->beginTransaction(); $changedAt = time() * 1000; - $this->db->insert('contactgroup', ['name' => trim($data['group_name']), 'changed_at' => $changedAt]); + $this->db->insert( + 'contactgroup', + [ + 'name' => $data['group_name'], + 'external_uuid' => Uuid::uuid4()->toString() + ] + ); $groupIdentifier = $this->db->lastInsertId(); diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 246f015a..70ebfae2 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -21,6 +21,7 @@ use ipl\Validator\StringLengthValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use Ramsey\Uuid\Uuid; class ContactForm extends CompatForm { @@ -185,7 +186,11 @@ public function addContact(): void $contactInfo = $this->getValues(); $changedAt = time() * 1000; $this->db->beginTransaction(); - $this->db->insert('contact', $contactInfo['contact'] + ['changed_at' => $changedAt]); + $this->db->insert( + 'contact', + $contactInfo['contact'] + ['external_uuid' => Uuid::uuid4()->toString(), 'changed_at' => $changedAt] + ); + $this->contactId = $this->db->lastInsertId(); foreach (array_filter($contactInfo['contact_address']) as $type => $address) { From 287e3cd02df933f487594c3aa5dda74a80c7ce2e Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 12 Jun 2024 13:49:54 +0200 Subject: [PATCH 03/16] Add `external_uuid` column to Contact/Contactgroup model Only show this column in the search column suggestions if explicitly specified Contact: Remove `changed_at` from column definations, as this should not be searchable. --- library/Notifications/Model/Contact.php | 5 +++-- library/Notifications/Model/Contactgroup.php | 8 ++++++-- .../Web/Control/SearchBar/ObjectSuggestions.php | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index 10f17b01..629aad1c 100644 --- a/library/Notifications/Model/Contact.php +++ b/library/Notifications/Model/Contact.php @@ -50,7 +50,8 @@ public function getColumns(): array 'username', 'default_channel_id', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } @@ -59,7 +60,7 @@ public function getColumnDefinitions(): array return [ 'full_name' => t('Full Name'), 'username' => t('Username'), - 'changed_at' => t('Changed At') + 'external_uuid' => t('UUID') ]; } diff --git a/library/Notifications/Model/Contactgroup.php b/library/Notifications/Model/Contactgroup.php index 3dc79481..95595cd0 100644 --- a/library/Notifications/Model/Contactgroup.php +++ b/library/Notifications/Model/Contactgroup.php @@ -42,13 +42,17 @@ public function getColumns(): array return [ 'name', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } public function getColumnDefinitions(): array { - return ['name' => t('Name')]; + return [ + 'name' => t('Name'), + 'external_uuid' => t('UUID') + ]; } public function getSearchColumns(): array diff --git a/library/Notifications/Web/Control/SearchBar/ObjectSuggestions.php b/library/Notifications/Web/Control/SearchBar/ObjectSuggestions.php index 6ca9b208..956bd378 100644 --- a/library/Notifications/Web/Control/SearchBar/ObjectSuggestions.php +++ b/library/Notifications/Web/Control/SearchBar/ObjectSuggestions.php @@ -231,7 +231,7 @@ protected function queryTags(Model $model, string $searchTerm): Query protected function matchSuggestion($path, $label, $searchTerm) { - if (preg_match('/[_.](id)$/', $path)) { + if (preg_match('/[_.](id|uuid)$/', $path)) { // Only suggest exotic columns if the user knows about them $trimmedSearch = trim($searchTerm, ' *'); return substr($path, -strlen($trimmedSearch)) === $trimmedSearch; From 8d83ad9677eb02917aa7e043c104e002af2d4ffb Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 5 Jun 2024 12:46:58 +0200 Subject: [PATCH 04/16] Introduce ApiV1(Contacts/Contactgroups)Controller --- .../ApiV1ContactgroupsController.php | 394 ++++++++++++ .../controllers/ApiV1ContactsController.php | 563 ++++++++++++++++++ 2 files changed, 957 insertions(+) create mode 100644 application/controllers/ApiV1ContactgroupsController.php create mode 100644 application/controllers/ApiV1ContactsController.php diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php new file mode 100644 index 00000000..2fc047a0 --- /dev/null +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -0,0 +1,394 @@ +assertPermission('notifications/api/v1'); + + $request = $this->getRequest(); + if (! $request->isApiRequest()) { + $this->httpBadRequest('No API request'); + } + + $method = $request->getMethod(); + if ( + in_array($method, ['POST', 'PUT']) + && ( + ! preg_match('/([^;]*);?/', $request->getHeader('Content-Type'), $matches) + || $matches[1] !== 'application/json' + ) + ) { + $this->httpBadRequest('No JSON content'); + } + + $results = []; + $responseCode = 200; + $db = Database::get(); + $identifier = $request->getParam('identifier'); + + if ($identifier && ! Uuid::isValid($identifier)) { + $this->httpBadRequest('The given identifier is not a valid UUID'); + } + + $filter = FilterProcessor::assembleFilter( + QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + ->on( + QueryString::ON_CONDITION, + function (Filter\Condition $condition) { + $column = $condition->getColumn(); + if (! in_array($column, ['id', 'name'])) { + $this->httpBadRequest(sprintf( + 'Invalid filter column %s given, only id and name are allowed', + $column + )); + } + + if ($column === 'id') { + if (! Uuid::isValid($condition->getValue())) { + $this->httpBadRequest('The given filter id is not a valid UUID'); + } + + $condition->setColumn('external_uuid'); + } + } + )->parse() + ); + + switch ($method) { + case 'GET': + $stmt = (new Select()) + ->distinct() + ->from('contactgroup cg') + ->columns([ + 'contactgroup_id' => 'cg.id', + 'id' => 'cg.external_uuid', + 'name' + ]); + + if ($identifier !== null) { + $stmt->where(['external_uuid = ?' => $identifier]); + $result = $db->fetchOne($stmt); + + if ($result === false) { + $this->httpNotFound('Contactgroup not found'); + } + + $users = $this->fetchUserIdentifiers($result->contactgroup_id); + if ($users) { + $result->users = $users; + } + + unset($result->contactgroup_id); + $results[] = $result; + + break; + } + + if ($filter !== null) { + $stmt->where($filter); + } + + $stmt->limit(500); + $offset = 0; + + ob_end_clean(); + Environment::raiseExecutionTime(); + + $this->getResponse() + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->sendResponse(); + + echo '['; + + $res = $db->select($stmt->offset($offset)); + do { + foreach ($res as $i => $row) { + $users = $this->fetchUserIdentifiers($row->contactgroup_id); + if ($users) { + $row->users = $users; + } + + if ($i > 0 || $offset !== 0) { + echo ",\n"; + } + + unset($row->contactgroup_id); + + echo Json::sanitize($row); + } + + $offset += 500; + $res = $db->select($stmt->offset($offset)); + } while ($res->rowCount()); + + echo ']'; + + exit; + case 'POST': + if ($filter !== null) { + $this->httpBadRequest('Cannot filter on POST'); + } + + $data = $request->getPost(); + + $this->assertValidData($data); + + $db->beginTransaction(); + + if ($identifier === null) { + if ($this->getContactgroupId($data['id']) !== null) { + throw new HttpException(422, 'Contactgroup already exists'); + } + + $this->addContactgroup($data); + } else { + $contactgroupId = $this->getContactgroupId($identifier); + if ($contactgroupId === null) { + $this->httpNotFound('Contactgroup not found'); + } + + if ($identifier === $data['id'] || $this->getContactgroupId($data['id']) !== null) { + throw new HttpException(422, 'Contactgroup already exists'); + } + + $this->removeContactgroup($contactgroupId); + $this->addContactgroup($data); + } + + $db->commitTransaction(); + + $this->getResponse()->setHeader('Location', self::ENDPOINT . '/' . $data['id']); + $responseCode = 201; + + break; + case 'PUT': + if ($identifier === null) { + $this->httpBadRequest('Identifier is required'); + } + + $data = $request->getPost(); + + $this->assertValidData($data); + + if ($identifier !== $data['id']) { + $this->httpBadRequest('Identifier mismatch'); + } + + $db->beginTransaction(); + + $contactgroupId = $this->getContactgroupId($identifier); + if ($contactgroupId !== null) { + $db->update('contactgroup', ['name' => $data['name']], ['id = ?' => $contactgroupId]); + + $db->delete('contactgroup_member', ['contactgroup_id = ?' => $contactgroupId]); + + if (! empty($data['users'])) { + $this->addUsers($contactgroupId, $data['users']); + } + + $responseCode = 204; + } else { + $this->addContactgroup($data); + $responseCode = 201; + } + + $db->commitTransaction(); + + break; + case 'DELETE': + if ($identifier === null) { + $this->httpBadRequest('Identifier is required'); + } + + $db->beginTransaction(); + + $contactgroupId = $this->getContactgroupId($identifier); + if ($contactgroupId === null) { + $this->httpNotFound('Contactgroup not found'); + } + + $this->removeContactgroup($contactgroupId); + + $db->commitTransaction(); + + $responseCode = 204; + + break; + default: + $this->httpBadRequest('Invalid method'); + } + + $this->getResponse() + ->setHttpResponseCode($responseCode) + ->json() + ->setSuccessData($results) + ->sendResponse(); + } + + /** + * Fetch the user(contact) identifiers of the contactgroup with the given id + * + * @param int $contactgroupId + * + * @return ?string[] + */ + private function fetchUserIdentifiers(int $contactgroupId): ?array + { + $users = Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member cgm') + ->columns('co.external_uuid') + ->joinLeft('contact co', 'co.id = cgm.contact_id') + ->where(['cgm.contactgroup_id = ?' => $contactgroupId]) + ->groupBy('co.external_uuid') + ); + + return $users ?: null; + } + + /** + * Assert that the given user IDs exist + * + * @param string $identifier + * + * @return int + * + * @throws HttpNotFoundException if the user with the given identifier does not exist + */ + private function getUserId(string $identifier): int + { + $user = Database::get()->fetchOne( + (new Select()) + ->from('contact') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); + + if ($user === false) { + $this->httpNotFound(sprintf('User with identifier %s not found', $identifier)); + } + + return $user->id; + } + + /** + * Get the contactgroup id with the given identifier + * + * @param string $identifier + * + * @return ?int Returns null, if contact does not exist + */ + private function getContactgroupId(string $identifier): ?int + { + $contactgroup = Database::get()->fetchOne( + (new Select()) + ->from('contactgroup') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); + + return $contactgroup->id ?? null; + } + + /** + * Add a new contactgroup with the given data + * + * @param array $data + * + * @return void + */ + private function addContactgroup(array $data): void + { + Database::get()->insert('contactgroup', [ + 'name' => $data['name'], + 'external_uuid' => $data['id'] + ]); + + $id = Database::get()->lastInsertId(); + + if (! empty($data['users'])) { + $this->addUsers($id, $data['users']); + } + } + + /** + * Add the given users as contactgroup_member with the given id + * + * @param int $contactgroupId + * @param string[] $users + * + * @return void + */ + private function addUsers(int $contactgroupId, array $users): void + { + foreach ($users as $identifier) { + $contactId = $this->getUserId($identifier); + + Database::get()->insert('contactgroup_member', [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId + ]); + } + } + + /** + * Remove the contactgroup with the given id + * + * @param int $id + * + * @return void + */ + private function removeContactgroup(int $id): void + { + Database::get()->delete('contactgroup_member', ['contactgroup_id = ?' => $id]); + Database::get()->delete('contactgroup', ['id = ?' => $id]); + } + + /** + * Assert that the given data contains the required fields + * + * @param array $data + * + * @throws HttpBadRequestException + */ + private function assertValidData(array $data): void + { + if (! isset($data['id'], $data['name'])) { + $this->httpBadRequest('The request body must contain the fields id and name'); + } + + if (! Uuid::isValid($data['id'])) { + $this->httpBadRequest('Given id in request body is not a valid UUID'); + } + + if (! empty($data['users'])) { + foreach ($data['users'] as $user) { + if (! Uuid::isValid($user)) { + $this->httpBadRequest('User identifiers in request body must be valid UUIDs'); + } + } + } + } +} diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php new file mode 100644 index 00000000..db1b5fd7 --- /dev/null +++ b/application/controllers/ApiV1ContactsController.php @@ -0,0 +1,563 @@ +assertPermission('notifications/api/v1'); + + $request = $this->getRequest(); + if (! $request->isApiRequest()) { + $this->httpBadRequest('No API request'); + } + + $method = $request->getMethod(); + if ( + in_array($method, ['POST', 'PUT']) + && ( + ! preg_match('/([^;]*);?/', $request->getHeader('Content-Type'), $matches) + || $matches[1] !== 'application/json' + ) + ) { + $this->httpBadRequest('No JSON content'); + } + + $results = []; + $responseCode = 200; + $db = Database::get(); + $identifier = $request->getParam('identifier'); + + if ($identifier && ! Uuid::isValid($identifier)) { + $this->httpBadRequest('The given identifier is not a valid UUID'); + } + + $filter = FilterProcessor::assembleFilter( + QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + ->on( + QueryString::ON_CONDITION, + function (Filter\Condition $condition) { + $column = $condition->getColumn(); + if (! in_array($column, ['id', 'full_name', 'username'])) { + $this->httpBadRequest(sprintf( + 'Invalid filter column %s given, only id, full_name and username are allowed', + $column + )); + } + + if ($column === 'id') { + if (! Uuid::isValid($condition->getValue())) { + $this->httpBadRequest('The given filter id is not a valid UUID'); + } + + $condition->setColumn('external_uuid'); + } + } + )->parse() + ); + + switch ($method) { + case 'GET': + $stmt = (new Select()) + ->distinct() + ->from('contact co') + ->columns([ + 'contact_id' => 'co.id', + 'id' => 'co.external_uuid', + 'full_name', + 'username', + 'default_channel' => 'ch.name', + ]) + ->joinLeft('contact_address ca', 'ca.contact_id = co.id') + ->joinLeft('channel ch', 'ch.id = co.default_channel_id'); + + if ($identifier !== null) { + $stmt->where(['external_uuid = ?' => $identifier]); + $result = $db->fetchOne($stmt); + + if ($result === false) { + $this->httpNotFound('Contact not found'); + } + + if ($result->username === null) { + unset($result->username); + } + + $groups = $this->fetchGroupIdentifiers($result->contact_id); + if ($groups) { + $result->groups = $groups; + } + + $addresses = $this->fetchContactAddresses($result->contact_id); + if ($addresses) { + $result->addresses = $addresses; + } + + unset($result->contact_id); + $results[] = $result; + + break; + } + + if ($filter !== null) { + $stmt->where($filter); + } + + $stmt->limit(500); + $offset = 0; + + ob_end_clean(); + Environment::raiseExecutionTime(); + + $this->getResponse() + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->sendResponse(); + + echo '['; + + $res = $db->select($stmt->offset($offset)); + do { + foreach ($res as $i => $row) { + if ($row->username === null) { + unset($row->username); + } + + $groups = $this->fetchGroupIdentifiers($row->contact_id); + if ($groups) { + $row->groups = $groups; + } + + $addresses = $this->fetchContactAddresses($row->contact_id); + if ($addresses) { + $row->addresses = $addresses; + } + + if ($i > 0 || $offset !== 0) { + echo ",\n"; + } + + unset($row->contact_id); + + echo Json::sanitize($row); + } + + $offset += 500; + $res = $db->select($stmt->offset($offset)); + } while ($res->rowCount()); + + echo ']'; + + exit; + case 'POST': + if ($filter !== null) { + $this->httpBadRequest('Cannot filter on POST'); + } + + $data = $request->getPost(); + + $this->assertValidData($data); + + $db->beginTransaction(); + + if ($identifier === null) { + if ($this->getContactId($data['id']) !== null) { + throw new HttpException(422, 'Contact already exists'); + } + + $this->addContact($data); + } else { + $contactId = $this->getContactId($identifier); + if ($contactId === null) { + $this->httpNotFound('Contact not found'); + } + + if ($identifier === $data['id'] || $this->getContactId($data['id']) !== null) { + throw new HttpException(422, 'Contact already exists'); + } + + $this->removeContact($contactId); + $this->addContact($data); + } + + $db->commitTransaction(); + + $this->getResponse()->setHeader('Location', self::ENDPOINT . '/' . $data['id']); + $responseCode = 201; + + break; + case 'PUT': + if ($identifier === null) { + $this->httpBadRequest('Identifier is required'); + } + + $data = $request->getPost(); + + $this->assertValidData($data); + + if ($identifier !== $data['id']) { + $this->httpBadRequest('Identifier mismatch'); + } + + $db->beginTransaction(); + + $contactId = $this->getContactId($identifier); + if ($contactId !== null) { + if (! empty($data['username'])) { + $this->assertUniqueUsername($data['username']); + } + + $db->update('contact', [ + 'full_name' => $data['full_name'], + 'username' => $data['username'] ?? null, + 'default_channel_id' => $this->getChannelId($data['default_channel']) + ], ['id = ?' => $contactId]); + + $db->delete('contact_address', ['contact_id = ?' => $contactId]); + $db->delete('contactgroup_member', ['contact_id = ?' => $contactId]); + + if (! empty($data['addresses'])) { + $this->addAddresses($contactId, $data['addresses']); + } + + if (! empty($data['groups'])) { + $this->addGroups($contactId, $data['groups']); + } + + $responseCode = 204; + } else { + $this->addContact($data); + $responseCode = 201; + } + + $db->commitTransaction(); + + break; + case 'DELETE': + if ($identifier === null) { + $this->httpBadRequest('Identifier is required'); + } + + $db->beginTransaction(); + + $contactId = $this->getContactId($identifier); + if ($contactId === null) { + $this->httpNotFound('Contact not found'); + } + + $this->removeContact($contactId); + + $db->commitTransaction(); + + $responseCode = 204; + + break; + default: + $this->httpBadRequest('Invalid method'); + } + + $this->getResponse() + ->setHttpResponseCode($responseCode) + ->json() + ->setSuccessData($results) + ->sendResponse(); + } + + /** + * Get the channel id with the given name + * + * @param string $channelName + * + * @return int + * + * @throws HttpNotFoundException if the channel does not exist + */ + private function getChannelId(string $channelName): int + { + $channel = Database::get()->fetchOne( + (new Select()) + ->from('channel') + ->columns('id') + ->where(['name = ?' => $channelName]) + ); + + if ($channel === false) { + $this->httpNotFound('Channel not found'); + } + + return $channel->id; + } + + /** + * Fetch the addresses of the contact with the given id + * + * @param int $contactId + * + * @return ?string + */ + private function fetchContactAddresses(int $contactId): ?string + { + $addresses = Database::get()->fetchPairs( + (new Select()) + ->from('contact_address') + ->columns(['type', 'address']) + ->where(['contact_id = ?' => $contactId]) + ); + + return ! empty($addresses) ? json_encode($addresses) : null; + } + + /** + * Fetch the group identifiers of the contact with the given id + * + * @param int $contactId + * + * @return ?string[] + */ + private function fetchGroupIdentifiers(int $contactId): ?array + { + $groups = Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member cgm') + ->columns('cg.external_uuid') + ->joinLeft('contactgroup cg', 'cg.id = cgm.contactgroup_id') + ->where(['cgm.contact_id = ?' => $contactId]) + ->groupBy('cg.external_uuid') + ); + + return $groups ?: null; + } + + /** + * Get the group id with the given identifier + * + * @param string $identifier + * + * @return int + * + * @throws HttpNotFoundException if the contactgroup with the given identifier does not exist + */ + private function getGroupId(string $identifier): int + { + $group = Database::get()->fetchOne( + (new Select()) + ->from('contactgroup') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); + + if ($group === false) { + $this->httpNotFound(sprintf('Group with identifier %s not found', $identifier)); + } + + return $group->id; + } + + /** + * Get the contact id with the given identifier + * + * @param string $identifier + * + * @return ?int Returns null, if contact does not exist + */ + protected function getContactId(string $identifier): ?int + { + $contact = Database::get()->fetchOne( + (new Select()) + ->from('contact') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); + + return $contact->id ?? null; + } + + /** + * Add a new contact with the given data + * + * @param array $data + * + * @return void + */ + private function addContact(array $data): void + { + if (! empty($data['username'])) { + $this->assertUniqueUsername($data['username']); + } + + Database::get()->insert('contact', [ + 'full_name' => $data['full_name'], + 'username' => $data['username'] ?? null, + 'default_channel_id' => $this->getChannelId($data['default_channel']), + 'external_uuid' => $data['id'] + ]); + + $contactId = Database::get()->lastInsertId(); + + if (! empty($data['addresses'])) { + $this->addAddresses($contactId, $data['addresses']); + } + + if (! empty($data['groups'])) { + $this->addGroups($contactId, $data['groups']); + } + } + + /** + * Assert that the username is unique + * + * @param string $username + * + * @return void + * + * @throws HttpException if the username already exists + */ + private function assertUniqueUsername(string $username): void + { + $user = Database::get()->fetchOne( + (new Select()) + ->from('contact') + ->columns(1) + ->where(['username = ?' => $username]) + ); + + if ($user !== false) { + throw new HttpException(422, 'Username already exists'); + } + } + + /** + * Assert that the address type exists + * + * @param string[] $addressTypes + * + * @return void + * + * @throws HttpBadRequestException if the username already exists + */ + private function assertAddressTypesExist(array $addressTypes): void + { + $types = Database::get()->fetchCol( + (new Select()) + ->from('available_channel_type') + ->columns('type') + ->where(['type IN (?)' => $addressTypes]) + ); + + if (count($types) !== count($addressTypes)) { + $this->httpBadRequest(sprintf( + 'Undefined address type %s given', + implode(', ', array_diff($addressTypes, $types)) + )); + } + } + + /** + * Add the groups to the given contact + * + * @param int $contactId + * @param string[] $groups + * + * @return void + */ + private function addGroups(int $contactId, array $groups): void + { + foreach ($groups as $groupIdentifier) { + $groupId = $this->getGroupId($groupIdentifier); + + Database::get()->insert('contactgroup_member', [ + 'contact_id' => $contactId, + 'contactgroup_id' => $groupId + ]); + } + } + + /** + * Add the addresses to the given contact + * + * @param int $contactId + * @param array $addresses + * + * @return void + */ + private function addAddresses(int $contactId, array $addresses): void + { + $this->assertAddressTypesExist(array_keys($addresses)); + + foreach ($addresses as $type => $address) { + Database::get()->insert('contact_address', [ + 'contact_id' => $contactId, + 'type' => $type, + 'address' => $address + ]); + } + } + + /** + * Remove the contact with the given id + * + * @param int $id + * + * @return void + */ + private function removeContact(int $id): void + { + Database::get()->delete('contactgroup_member', ['contact_id = ?' => $id]); + Database::get()->delete('contact_address', ['contact_id = ?' => $id]); + Database::get()->delete('contact', ['id = ?' => $id]); + } + + /** + * Assert that the given data contains the required fields + * + * @param array $data + * + * @return void + * + * @throws HttpBadRequestException + */ + private function assertValidData(array $data): void + { + if (! isset($data['id'], $data['full_name'], $data['default_channel'])) { + $this->httpBadRequest('The request body must contain the fields id, full_name and default_channel'); + } + + if (! Uuid::isValid($data['id'])) { + $this->httpBadRequest('Given id in the request body is not a valid UUID'); + } + + if (! empty($data['groups'])) { + foreach ($data['groups'] as $group) { + if (! Uuid::isValid($group)) { + $this->httpBadRequest('Group identifiers in the request body must be valid UUIDs'); + } + } + } + + if (! empty($data['addresses']['email']) + && ! (new EmailAddressValidator())->isValid($data['addresses']['email']) + ) { + $this->httpBadRequest('Request body contains an invalid email address'); + } + } +} From 518f5dae4f14ec3d796ab2a6b530d1776012f269 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 14 Jun 2024 09:43:16 +0200 Subject: [PATCH 05/16] ApiV1(*)Controller: Validate request body of correct data type --- .../ApiV1ContactgroupsController.php | 24 +++-- .../controllers/ApiV1ContactsController.php | 88 +++++++++++-------- 2 files changed, 70 insertions(+), 42 deletions(-) diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index 2fc047a0..a13b838d 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -375,19 +375,33 @@ private function removeContactgroup(int $id): void */ private function assertValidData(array $data): void { - if (! isset($data['id'], $data['name'])) { - $this->httpBadRequest('The request body must contain the fields id and name'); + $msgPrefix = 'Invalid request body: '; + + if ( + ! isset($data['id'], $data['name']) + || ! is_string($data['id']) + || ! is_string($data['name']) + ) { + $this->httpBadRequest( + $msgPrefix . 'the fields id and name must be present and of type string' + ); } if (! Uuid::isValid($data['id'])) { - $this->httpBadRequest('Given id in request body is not a valid UUID'); + $this->httpBadRequest($msgPrefix . 'given id is not a valid UUID'); } if (! empty($data['users'])) { + if (! is_array($data['users'])) { + $this->httpBadRequest($msgPrefix . 'expects users to be an array'); + } + foreach ($data['users'] as $user) { - if (! Uuid::isValid($user)) { - $this->httpBadRequest('User identifiers in request body must be valid UUIDs'); + if (! is_string($user) || ! Uuid::isValid($user)) { + $this->httpBadRequest($msgPrefix . 'user identifiers must be valid UUIDs'); } + + //TODO: check if users exist, here? } } } diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index db1b5fd7..af8c258f 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -446,32 +446,6 @@ private function assertUniqueUsername(string $username): void } } - /** - * Assert that the address type exists - * - * @param string[] $addressTypes - * - * @return void - * - * @throws HttpBadRequestException if the username already exists - */ - private function assertAddressTypesExist(array $addressTypes): void - { - $types = Database::get()->fetchCol( - (new Select()) - ->from('available_channel_type') - ->columns('type') - ->where(['type IN (?)' => $addressTypes]) - ); - - if (count($types) !== count($addressTypes)) { - $this->httpBadRequest(sprintf( - 'Undefined address type %s given', - implode(', ', array_diff($addressTypes, $types)) - )); - } - } - /** * Add the groups to the given contact * @@ -502,8 +476,6 @@ private function addGroups(int $contactId, array $groups): void */ private function addAddresses(int $contactId, array $addresses): void { - $this->assertAddressTypesExist(array_keys($addresses)); - foreach ($addresses as $type => $address) { Database::get()->insert('contact_address', [ 'contact_id' => $contactId, @@ -538,26 +510,68 @@ private function removeContact(int $id): void */ private function assertValidData(array $data): void { - if (! isset($data['id'], $data['full_name'], $data['default_channel'])) { - $this->httpBadRequest('The request body must contain the fields id, full_name and default_channel'); + $msgPrefix = 'Invalid request body: '; + + if ( + ! isset($data['id'], $data['full_name'], $data['default_channel']) + || ! is_string($data['id']) + || ! is_string($data['full_name']) + || ! is_string($data['default_channel']) + ) { + $this->httpBadRequest( + $msgPrefix . 'the fields id, full_name and default_channel must be present and of type string' + ); } if (! Uuid::isValid($data['id'])) { - $this->httpBadRequest('Given id in the request body is not a valid UUID'); + $this->httpBadRequest($msgPrefix . 'given id is not a valid UUID'); + } + + if (! empty($data['username']) && ! is_string($data['username'])) { + $this->httpBadRequest($msgPrefix . 'expects username to be a string'); } if (! empty($data['groups'])) { + if (! is_array($data['groups'])) { + $this->httpBadRequest($msgPrefix . 'expects groups to be an array'); + } + foreach ($data['groups'] as $group) { - if (! Uuid::isValid($group)) { - $this->httpBadRequest('Group identifiers in the request body must be valid UUIDs'); + if (! is_string($group) || ! Uuid::isValid($group)) { + $this->httpBadRequest($msgPrefix . 'group identifiers must be valid UUIDs'); } } } - if (! empty($data['addresses']['email']) - && ! (new EmailAddressValidator())->isValid($data['addresses']['email']) - ) { - $this->httpBadRequest('Request body contains an invalid email address'); + if (! empty($data['addresses'])) { + if (! is_array($data['addresses'])) { + $this->httpBadRequest($msgPrefix . 'expects addresses to be an array'); + } + + $addressTypes = array_keys($data['addresses']); + + $types = Database::get()->fetchCol( + (new Select()) + ->from('available_channel_type') + ->columns('type') + ->where(['type IN (?)' => $addressTypes]) + ); + + if (count($types) !== count($addressTypes)) { + $this->httpBadRequest(sprintf( + $msgPrefix . 'undefined address type %s given', + implode(', ', array_diff($addressTypes, $types)) + )); + } + //TODO: is it a good idea to check valid channel types here?, if yes, + //default_channel and group identifiers must be checked here as well..404 OR 400? + + if ( + ! empty($data['addresses']['email']) + && ! (new EmailAddressValidator())->isValid($data['addresses']['email']) + ) { + $this->httpBadRequest($msgPrefix . 'an invalid email address given'); + } } } } From 639e1b198c4f6c5af02308a13a94e67e579ae9cc Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 14 Jun 2024 12:57:53 +0200 Subject: [PATCH 06/16] ApiV1(*)Controller: Describe return types and variable types --- .../ApiV1ContactgroupsController.php | 38 ++++++++----- .../controllers/ApiV1ContactsController.php | 54 +++++++++++++------ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index a13b838d..b68fedbf 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -17,7 +17,14 @@ use ipl\Web\Filter\QueryString; use ipl\Web\Url; use Ramsey\Uuid\Uuid; - +use stdClass; + +/** @phpstan-type requestBody array{ + * id: string, + * name: string, + * users?: string[], + * } + */ class ApiV1ContactgroupsController extends CompatController { private const ENDPOINT = 'notifications/api/v1/contactgroups'; @@ -45,6 +52,8 @@ public function indexAction(): void $results = []; $responseCode = 200; $db = Database::get(); + + /** @var ?string $identifier */ $identifier = $request->getParam('identifier'); if ($identifier && ! Uuid::isValid($identifier)) { @@ -88,6 +97,8 @@ function (Filter\Condition $condition) { if ($identifier !== null) { $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ $result = $db->fetchOne($stmt); if ($result === false) { @@ -124,6 +135,7 @@ function (Filter\Condition $condition) { $res = $db->select($stmt->offset($offset)); do { + /** @var stdClass $row */ foreach ($res as $i => $row) { $users = $this->fetchUserIdentifiers($row->contactgroup_id); if ($users) { @@ -151,9 +163,7 @@ function (Filter\Condition $condition) { $this->httpBadRequest('Cannot filter on POST'); } - $data = $request->getPost(); - - $this->assertValidData($data); + $data = $this->getValidatedData(); $db->beginTransaction(); @@ -188,9 +198,7 @@ function (Filter\Condition $condition) { $this->httpBadRequest('Identifier is required'); } - $data = $request->getPost(); - - $this->assertValidData($data); + $data = $this->getValidatedData(); if ($identifier !== $data['id']) { $this->httpBadRequest('Identifier mismatch'); @@ -279,6 +287,7 @@ private function fetchUserIdentifiers(int $contactgroupId): ?array */ private function getUserId(string $identifier): int { + /** @var stdClass|false $user */ $user = Database::get()->fetchOne( (new Select()) ->from('contact') @@ -302,6 +311,7 @@ private function getUserId(string $identifier): int */ private function getContactgroupId(string $identifier): ?int { + /** @var stdClass|false $contactgroup */ $contactgroup = Database::get()->fetchOne( (new Select()) ->from('contactgroup') @@ -315,7 +325,7 @@ private function getContactgroupId(string $identifier): ?int /** * Add a new contactgroup with the given data * - * @param array $data + * @param requestBody $data * * @return void */ @@ -367,14 +377,15 @@ private function removeContactgroup(int $id): void } /** - * Assert that the given data contains the required fields + * Get the validated POST|PUT request data * - * @param array $data + * @return requestBody * - * @throws HttpBadRequestException + * @throws HttpBadRequestException if the request body is invalid */ - private function assertValidData(array $data): void + private function getValidatedData(): array { + $data = $this->getRequest()->getPost(); $msgPrefix = 'Invalid request body: '; if ( @@ -404,5 +415,8 @@ private function assertValidData(array $data): void //TODO: check if users exist, here? } } + + /** @var requestBody $data */ + return $data; } } diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index af8c258f..d83ace66 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -18,7 +18,18 @@ use ipl\Web\Filter\QueryString; use ipl\Web\Url; use Ramsey\Uuid\Uuid; - +use stdClass; + +/** + * @phpstan-type requestBody array{ + * id: string, + * full_name: string, + * default_channel: string, + * username?: string, + * groups?: string[], + * addresses?: array + * } + */ class ApiV1ContactsController extends CompatController { private const ENDPOINT = 'notifications/api/v1/contacts'; @@ -46,6 +57,8 @@ public function indexAction(): void $results = []; $responseCode = 200; $db = Database::get(); + + /** @var ?string $identifier */ $identifier = $request->getParam('identifier'); if ($identifier && ! Uuid::isValid($identifier)) { @@ -93,6 +106,8 @@ function (Filter\Condition $condition) { if ($identifier !== null) { $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ $result = $db->fetchOne($stmt); if ($result === false) { @@ -138,6 +153,7 @@ function (Filter\Condition $condition) { $res = $db->select($stmt->offset($offset)); do { + /** @var stdClass $row */ foreach ($res as $i => $row) { if ($row->username === null) { unset($row->username); @@ -174,9 +190,7 @@ function (Filter\Condition $condition) { $this->httpBadRequest('Cannot filter on POST'); } - $data = $request->getPost(); - - $this->assertValidData($data); + $data = $this->getValidatedData(); $db->beginTransaction(); @@ -211,9 +225,7 @@ function (Filter\Condition $condition) { $this->httpBadRequest('Identifier is required'); } - $data = $request->getPost(); - - $this->assertValidData($data); + $data = $this->getValidatedData(); if ($identifier !== $data['id']) { $this->httpBadRequest('Identifier mismatch'); @@ -294,6 +306,7 @@ function (Filter\Condition $condition) { */ private function getChannelId(string $channelName): int { + /** @var stdClass|false $channel */ $channel = Database::get()->fetchOne( (new Select()) ->from('channel') @@ -317,6 +330,7 @@ private function getChannelId(string $channelName): int */ private function fetchContactAddresses(int $contactId): ?string { + /** @var array $addresses */ $addresses = Database::get()->fetchPairs( (new Select()) ->from('contact_address') @@ -324,7 +338,11 @@ private function fetchContactAddresses(int $contactId): ?string ->where(['contact_id = ?' => $contactId]) ); - return ! empty($addresses) ? json_encode($addresses) : null; + if (! empty($addresses)) { + return json_encode($addresses) ?: null; + } + + return null; } /** @@ -359,6 +377,7 @@ private function fetchGroupIdentifiers(int $contactId): ?array */ private function getGroupId(string $identifier): int { + /** @var stdClass|false $group */ $group = Database::get()->fetchOne( (new Select()) ->from('contactgroup') @@ -382,6 +401,7 @@ private function getGroupId(string $identifier): int */ protected function getContactId(string $identifier): ?int { + /** @var stdClass|false $contact */ $contact = Database::get()->fetchOne( (new Select()) ->from('contact') @@ -395,7 +415,7 @@ protected function getContactId(string $identifier): ?int /** * Add a new contact with the given data * - * @param array $data + * @param requestBody $data * * @return void */ @@ -437,7 +457,7 @@ private function assertUniqueUsername(string $username): void $user = Database::get()->fetchOne( (new Select()) ->from('contact') - ->columns(1) + ->columns('1') ->where(['username = ?' => $username]) ); @@ -500,16 +520,15 @@ private function removeContact(int $id): void } /** - * Assert that the given data contains the required fields + * Get the validated POST|PUT request data * - * @param array $data + * @return requestBody * - * @return void - * - * @throws HttpBadRequestException + * @throws HttpBadRequestException if the request body is invalid */ - private function assertValidData(array $data): void + private function getValidatedData(): array { + $data = $this->getRequest()->getPost(); $msgPrefix = 'Invalid request body: '; if ( @@ -573,5 +592,8 @@ private function assertValidData(array $data): void $this->httpBadRequest($msgPrefix . 'an invalid email address given'); } } + + /** @var requestBody $data */ + return $data; } } From 81dce25d91de470d61bdbf5137b88dc3472203b1 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 14 Jun 2024 16:00:20 +0200 Subject: [PATCH 07/16] ApiV1ContactsController: PUT: Always update column contact.username --- .../controllers/ApiV1ContactsController.php | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index d83ace66..20386511 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -236,7 +236,7 @@ function (Filter\Condition $condition) { $contactId = $this->getContactId($identifier); if ($contactId !== null) { if (! empty($data['username'])) { - $this->assertUniqueUsername($data['username']); + $this->assertUniqueUsername($data['username'], $contactId); } $db->update('contact', [ @@ -447,19 +447,24 @@ private function addContact(array $data): void * Assert that the username is unique * * @param string $username + * @param int $contactId The id of the contact to exclude * * @return void * * @throws HttpException if the username already exists */ - private function assertUniqueUsername(string $username): void + private function assertUniqueUsername(string $username, int $contactId = null): void { - $user = Database::get()->fetchOne( - (new Select()) - ->from('contact') - ->columns('1') - ->where(['username = ?' => $username]) - ); + $stmt = (new Select()) + ->from('contact') + ->columns('1') + ->where(['username = ?' => $username]); + + if ($contactId) { + $stmt->where(['id != ?' => $contactId]); + } + + $user = Database::get()->fetchOne($stmt); if ($user !== false) { throw new HttpException(422, 'Username already exists'); From b7764292e168d7209b23db48f1bc026a78463f87 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 14 Jun 2024 13:05:23 +0200 Subject: [PATCH 08/16] Phpstan: Cleanup baseline files --- phpstan-baseline-7x.neon | 5 - phpstan-baseline-8x.neon | 5 - phpstan-baseline-standard.neon | 380 --------------------------------- 3 files changed, 390 deletions(-) diff --git a/phpstan-baseline-7x.neon b/phpstan-baseline-7x.neon index 5be0f381..5c9adab6 100644 --- a/phpstan-baseline-7x.neon +++ b/phpstan-baseline-7x.neon @@ -1,10 +1,5 @@ parameters: ignoreErrors: - - - message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#" - count: 2 - path: application/forms/EntryForm.php - - message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#" count: 2 diff --git a/phpstan-baseline-8x.neon b/phpstan-baseline-8x.neon index 99ed68cf..a26c7dd5 100644 --- a/phpstan-baseline-8x.neon +++ b/phpstan-baseline-8x.neon @@ -1,10 +1,5 @@ parameters: ignoreErrors: - - - message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#" - count: 2 - path: application/forms/EntryForm.php - - message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#" count: 2 diff --git a/phpstan-baseline-standard.neon b/phpstan-baseline-standard.neon index 7c04a796..2009dc0b 100644 --- a/phpstan-baseline-standard.neon +++ b/phpstan-baseline-standard.neon @@ -45,11 +45,6 @@ parameters: count: 1 path: application/controllers/ChannelsController.php - - - message: "#^Parameter \\#1 \\$name of method ipl\\\\Web\\\\Widget\\\\Tabs\\:\\:add\\(\\) expects string, mixed given\\.$#" - count: 1 - path: application/controllers/ChannelsController.php - - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" count: 1 @@ -60,11 +55,6 @@ parameters: count: 1 path: application/controllers/ConfigController.php - - - message: "#^Parameter \\#1 \\$name of method ipl\\\\Web\\\\Widget\\\\Tabs\\:\\:add\\(\\) expects string, mixed given\\.$#" - count: 1 - path: application/controllers/ConfigController.php - - message: "#^Cannot access property \\$full_name on ipl\\\\Orm\\\\Model\\|null\\.$#" count: 1 @@ -120,11 +110,6 @@ parameters: count: 2 path: application/controllers/EventRuleController.php - - - message: "#^Cannot access property \\$rule_escalation on ipl\\\\Orm\\\\Model\\|null\\.$#" - count: 1 - path: application/controllers/EventRuleController.php - - message: "#^Cannot access property \\$rule_escalation_recipient on mixed\\.$#" count: 1 @@ -275,161 +260,6 @@ parameters: count: 1 path: application/forms/DatabaseConfigForm.php - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:prepareMultipartUpdate\\(\\)\\.$#" - count: 2 - path: application/forms/EntryForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setDescription\\(\\)\\.$#" - count: 3 - path: application/forms/EntryForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setLabel\\(\\)\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Call to an undefined method ipl\\\\Scheduler\\\\RRule\\:\\:getUntil\\(\\)\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Call to an undefined method ipl\\\\Sql\\\\Connection\\:\\:lastInsertId\\(\\)\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access offset 'frequency' on mixed\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access offset 'rrule' on mixed\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access offset 'start' on mixed\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access property \\$contact_id on mixed\\.$#" - count: 6 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access property \\$contactgroup_id on mixed\\.$#" - count: 4 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access property \\$full_name on mixed\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 2 - path: application/forms/EntryForm.php - - - - message: "#^Cannot access property \\$name on mixed\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot call method add\\(\\) on DateTime\\|false\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot call method diff\\(\\) on mixed\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot call method format\\(\\) on DateTime\\|false\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot call method format\\(\\) on mixed\\.$#" - count: 2 - path: application/forms/EntryForm.php - - - - message: "#^Cannot call method getTimezone\\(\\) on DateTime\\|false\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Cannot cast mixed to int\\.$#" - count: 2 - path: application/forms/EntryForm.php - - - - message: "#^Cannot clone non\\-object variable \\$start of type DateTime\\|false\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EntryForm\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EntryForm\\:\\:formValuesToDb\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EntryForm\\:\\:getPartUpdates\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Method class@anonymous/application/forms/EntryForm\\.php\\:142\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:getValue\\(\\) invoked with 1 parameter, 0 required\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Parameter \\#1 \\$json of static method ipl\\\\Scheduler\\\\RRule\\:\\:fromJson\\(\\) expects string, string\\|false given\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Parameter \\#1 \\.\\.\\.\\$content of method ipl\\\\Html\\\\HtmlDocument\\:\\:setHtmlContent\\(\\) expects ipl\\\\Html\\\\ValidHtml, ipl\\\\Html\\\\Contract\\\\Wrappable\\|null given\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Parameter \\#2 \\$datetime of static method DateTime\\:\\:createFromFormat\\(\\) expects string, mixed given\\.$#" - count: 2 - path: application/forms/EntryForm.php - - - - message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#" - count: 1 - path: application/forms/EntryForm.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EntryForm\\:\\:\\$submitLabel \\(string\\) on left side of \\?\\? is not nullable\\.$#" - count: 1 - path: application/forms/EntryForm.php - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setName\\(\\)\\.$#" count: 4 @@ -580,11 +410,6 @@ parameters: count: 1 path: application/forms/ScheduleForm.php - - - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 3 - path: application/forms/ScheduleForm.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\ScheduleForm\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 @@ -675,11 +500,6 @@ parameters: count: 1 path: library/Notifications/Model/ContactAddress.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\Contactgroup\\:\\:createRelations\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Model/Contactgroup.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\Event\\:\\:createBehaviors\\(\\) has no return type specified\\.$#" count: 1 @@ -840,11 +660,6 @@ parameters: count: 1 path: library/Notifications/Model/RuleEscalationRecipient.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\ScheduleMember\\:\\:createRelations\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Model/ScheduleMember.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\Source\\:\\:createRelations\\(\\) has no return type specified\\.$#" count: 1 @@ -1000,26 +815,11 @@ parameters: count: 1 path: library/Notifications/Web/Form/EventRuleDecorator.php - - - message: "#^Call to an undefined method DateTimeInterface\\:\\:add\\(\\)\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php - - - - message: "#^Cannot call method diff\\(\\) on DateTime\\|null\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 path: library/Notifications/Widget/Calendar.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\:\\:getEntries\\(\\) return type has no value type specified in iterable type Traversable\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\:\\:getModeStart\\(\\) should return DateTime but returns DateTime\\|false\\.$#" count: 2 @@ -1030,26 +830,6 @@ parameters: count: 1 path: library/Notifications/Widget/Calendar.php - - - message: "#^Parameter \\#1 \\$from of static method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Util\\:\\:diffHours\\(\\) expects DateTime, DateTime\\|null given\\.$#" - count: 2 - path: library/Notifications/Widget/Calendar.php - - - - message: "#^Parameter \\#1 \\$start of method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:setStart\\(\\) expects DateTime, DateTimeInterface given\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php - - - - message: "#^Parameter \\#1 \\$start of method ipl\\\\Scheduler\\\\RRule\\:\\:startAt\\(\\) expects DateTimeInterface, DateTime\\|null given\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php - - - - message: "#^Parameter \\#2 \\$to of static method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Util\\:\\:diffHours\\(\\) expects DateTime, DateTime\\|null given\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\:\\:\\$addEntryUrl \\(ipl\\\\Web\\\\Url\\) does not accept ipl\\\\Web\\\\Url\\|null\\.$#" count: 1 @@ -1060,31 +840,6 @@ parameters: count: 1 path: library/Notifications/Widget/Calendar/Attendee.php - - - message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#" - count: 7 - path: library/Notifications/Widget/Calendar/BaseGrid.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\BaseGrid\\:\\:createGridSteps\\(\\) return type has no value type specified in iterable type Traversable\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/BaseGrid.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\BaseGrid\\:\\:getGridArea\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/BaseGrid.php - - - - message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/BaseGrid.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\BaseGrid\\:\\:\\$extraEntriesCount type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/BaseGrid.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Controls\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 @@ -1100,91 +855,16 @@ parameters: count: 1 path: library/Notifications/Widget/Calendar/DayGrid.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\DayGrid\\:\\:createGridSteps\\(\\) return type has no value type specified in iterable type Traversable\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/DayGrid.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\DayGrid\\:\\:getGridArea\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/DayGrid.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$description has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$end has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$id has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$isOccurrence has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$rrule has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$start has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Entry\\:\\:\\$url \\(ipl\\\\Web\\\\Url\\) does not accept ipl\\\\Web\\\\Url\\|null\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Entry.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\ExtraEntryCount\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/ExtraEntryCount.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\MonthGrid\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 path: library/Notifications/Widget/Calendar/MonthGrid.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\MonthGrid\\:\\:createGridSteps\\(\\) return type has no value type specified in iterable type Traversable\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/MonthGrid.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\MonthGrid\\:\\:getGridArea\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/MonthGrid.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\Util\\:\\:diffHours\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/Util.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\WeekGrid\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 path: library/Notifications/Widget/Calendar/WeekGrid.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\WeekGrid\\:\\:createGridSteps\\(\\) return type has no value type specified in iterable type Traversable\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/WeekGrid.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\\\WeekGrid\\:\\:getGridArea\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar/WeekGrid.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\CheckboxIcon\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 @@ -1410,11 +1090,6 @@ parameters: count: 2 path: library/Notifications/Widget/RecipientSuggestions.php - - - message: "#^Cannot access property \\$color on mixed\\.$#" - count: 2 - path: library/Notifications/Widget/RecipientSuggestions.php - - message: "#^Cannot access property \\$full_name on mixed\\.$#" count: 2 @@ -1480,66 +1155,11 @@ parameters: count: 1 path: library/Notifications/Widget/RuleEscalationRecipientBadge.php - - - message: "#^Cannot access property \\$contact on mixed\\.$#" - count: 2 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$contact_id on mixed\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$contactgroup on mixed\\.$#" - count: 2 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$description on mixed\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$end_time on mixed\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 2 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$rrule on mixed\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$start_time on mixed\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$timeperiod_id on mixed\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - - - message: "#^Cannot access property \\$timezone on mixed\\.$#" - count: 2 - path: library/Notifications/Widget/Schedule.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Schedule\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 path: library/Notifications/Widget/Schedule.php - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Schedule\\:\\:\\$schedule \\(Icinga\\\\Module\\\\Notifications\\\\Model\\\\Schedule\\) does not accept Icinga\\\\Module\\\\Notifications\\\\Model\\\\Schedule\\|null\\.$#" - count: 1 - path: library/Notifications/Widget/Schedule.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\ShowMore\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 From 9a97f2cff37f9737dd40a9445aec1abd4c42d9e5 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 27 Jun 2024 16:02:08 +0200 Subject: [PATCH 09/16] ApiV1(*)Controller: GET: Don't omit optional fields when empty Return the default empty data type instead --- .../ApiV1ContactgroupsController.php | 18 ++----- .../controllers/ApiV1ContactsController.php | 48 ++++--------------- 2 files changed, 15 insertions(+), 51 deletions(-) diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index b68fedbf..59f54f8f 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -105,10 +105,7 @@ function (Filter\Condition $condition) { $this->httpNotFound('Contactgroup not found'); } - $users = $this->fetchUserIdentifiers($result->contactgroup_id); - if ($users) { - $result->users = $users; - } + $result->users = $this->fetchUserIdentifiers($result->contactgroup_id); unset($result->contactgroup_id); $results[] = $result; @@ -137,10 +134,7 @@ function (Filter\Condition $condition) { do { /** @var stdClass $row */ foreach ($res as $i => $row) { - $users = $this->fetchUserIdentifiers($row->contactgroup_id); - if ($users) { - $row->users = $users; - } + $row->users = $this->fetchUserIdentifiers($row->contactgroup_id); if ($i > 0 || $offset !== 0) { echo ",\n"; @@ -260,11 +254,11 @@ function (Filter\Condition $condition) { * * @param int $contactgroupId * - * @return ?string[] + * @return string[] */ - private function fetchUserIdentifiers(int $contactgroupId): ?array + private function fetchUserIdentifiers(int $contactgroupId): array { - $users = Database::get()->fetchCol( + return Database::get()->fetchCol( (new Select()) ->from('contactgroup_member cgm') ->columns('co.external_uuid') @@ -272,8 +266,6 @@ private function fetchUserIdentifiers(int $contactgroupId): ?array ->where(['cgm.contactgroup_id = ?' => $contactgroupId]) ->groupBy('co.external_uuid') ); - - return $users ?: null; } /** diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index 20386511..96f41717 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -114,19 +114,8 @@ function (Filter\Condition $condition) { $this->httpNotFound('Contact not found'); } - if ($result->username === null) { - unset($result->username); - } - - $groups = $this->fetchGroupIdentifiers($result->contact_id); - if ($groups) { - $result->groups = $groups; - } - - $addresses = $this->fetchContactAddresses($result->contact_id); - if ($addresses) { - $result->addresses = $addresses; - } + $result->groups = $this->fetchGroupIdentifiers($result->contact_id); + $result->addresses = $this->fetchContactAddresses($result->contact_id); unset($result->contact_id); $results[] = $result; @@ -155,19 +144,8 @@ function (Filter\Condition $condition) { do { /** @var stdClass $row */ foreach ($res as $i => $row) { - if ($row->username === null) { - unset($row->username); - } - - $groups = $this->fetchGroupIdentifiers($row->contact_id); - if ($groups) { - $row->groups = $groups; - } - - $addresses = $this->fetchContactAddresses($row->contact_id); - if ($addresses) { - $row->addresses = $addresses; - } + $row->groups = $this->fetchGroupIdentifiers($row->contact_id); + $row->addresses = $this->fetchContactAddresses($row->contact_id); if ($i > 0 || $offset !== 0) { echo ",\n"; @@ -326,9 +304,9 @@ private function getChannelId(string $channelName): int * * @param int $contactId * - * @return ?string + * @return string */ - private function fetchContactAddresses(int $contactId): ?string + private function fetchContactAddresses(int $contactId): string { /** @var array $addresses */ $addresses = Database::get()->fetchPairs( @@ -338,11 +316,7 @@ private function fetchContactAddresses(int $contactId): ?string ->where(['contact_id = ?' => $contactId]) ); - if (! empty($addresses)) { - return json_encode($addresses) ?: null; - } - - return null; + return Json::sanitize($addresses, JSON_FORCE_OBJECT); } /** @@ -350,11 +324,11 @@ private function fetchContactAddresses(int $contactId): ?string * * @param int $contactId * - * @return ?string[] + * @return string[] */ - private function fetchGroupIdentifiers(int $contactId): ?array + private function fetchGroupIdentifiers(int $contactId): array { - $groups = Database::get()->fetchCol( + return Database::get()->fetchCol( (new Select()) ->from('contactgroup_member cgm') ->columns('cg.external_uuid') @@ -362,8 +336,6 @@ private function fetchGroupIdentifiers(int $contactId): ?array ->where(['cgm.contact_id = ?' => $contactId]) ->groupBy('cg.external_uuid') ); - - return $groups ?: null; } /** From 5ee9097438e663c97191830a2f20968674125dd1 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 27 Jun 2024 16:12:23 +0200 Subject: [PATCH 10/16] Channel: Add and use `external_uuid` as `default-channel` value As the channel names are not unique, uuid should be used in GET responses --- .../controllers/ApiV1ContactsController.php | 14 +++++++++----- application/forms/ChannelForm.php | 2 ++ library/Notifications/Model/Channel.php | 5 +++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index 96f41717..31d6cea3 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -99,7 +99,7 @@ function (Filter\Condition $condition) { 'id' => 'co.external_uuid', 'full_name', 'username', - 'default_channel' => 'ch.name', + 'default_channel' => 'ch.external_uuid', ]) ->joinLeft('contact_address ca', 'ca.contact_id = co.id') ->joinLeft('channel ch', 'ch.id = co.default_channel_id'); @@ -274,22 +274,22 @@ function (Filter\Condition $condition) { } /** - * Get the channel id with the given name + * Get the channel id with the given identifier * - * @param string $channelName + * @param string $channelIdentifier * * @return int * * @throws HttpNotFoundException if the channel does not exist */ - private function getChannelId(string $channelName): int + private function getChannelId(string $channelIdentifier): int { /** @var stdClass|false $channel */ $channel = Database::get()->fetchOne( (new Select()) ->from('channel') ->columns('id') - ->where(['name = ?' => $channelName]) + ->where(['external_uuid = ?' => $channelIdentifier]) ); if ($channel === false) { @@ -523,6 +523,10 @@ private function getValidatedData(): array $this->httpBadRequest($msgPrefix . 'given id is not a valid UUID'); } + if (! Uuid::isValid($data['default_channel'])) { + $this->httpBadRequest($msgPrefix . 'given default_channel is not a valid UUID'); + } + if (! empty($data['username']) && ! is_string($data['username'])) { $this->httpBadRequest($msgPrefix . 'expects username to be a string'); } diff --git a/application/forms/ChannelForm.php b/application/forms/ChannelForm.php index 7b03b7e1..e413aaec 100644 --- a/application/forms/ChannelForm.php +++ b/application/forms/ChannelForm.php @@ -20,6 +20,7 @@ use ipl\Validator\EmailAddressValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use Ramsey\Uuid\Uuid; /** * @phpstan-type ChannelOptionConfig array{ @@ -201,6 +202,7 @@ public function addChannel(): void $channel['config'] = json_encode($this->filterConfig($channel['config'])); $channel['changed_at'] = time() * 1000; + $channel['external_uuid'] = Uuid::uuid4()->toString(); $this->db->insert('channel', $channel); } diff --git a/library/Notifications/Model/Channel.php b/library/Notifications/Model/Channel.php index cc4f8103..c7ad1f16 100644 --- a/library/Notifications/Model/Channel.php +++ b/library/Notifications/Model/Channel.php @@ -46,7 +46,8 @@ public function getColumns(): array 'type', 'config', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } @@ -55,7 +56,7 @@ public function getColumnDefinitions(): array return [ 'name' => t('Name'), 'type' => t('Type'), - 'changed_at' => t('Changed At') + 'external_uuid' => t('UUID') ]; } From 44467ba58213d3c386ee0dd4290cc1b999b9ca09 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 3 Jul 2024 15:43:44 +0200 Subject: [PATCH 11/16] Introduce api endpoint `channels` --- .../controllers/ApiV1ChannelsController.php | 136 ++++++++++++++++++ run.php | 13 ++ 2 files changed, 149 insertions(+) create mode 100644 application/controllers/ApiV1ChannelsController.php diff --git a/application/controllers/ApiV1ChannelsController.php b/application/controllers/ApiV1ChannelsController.php new file mode 100644 index 00000000..7c5e974b --- /dev/null +++ b/application/controllers/ApiV1ChannelsController.php @@ -0,0 +1,136 @@ +assertPermission('notifications/api/v1'); + + $request = $this->getRequest(); + if (! $request->isApiRequest()) { + $this->httpBadRequest('No API request'); + } + + $method = $request->getMethod(); + if ($method !== 'GET') { + $this->httpBadRequest('Only GET method supported'); + } + + $db = Database::get(); + + /** @var ?string $identifier */ + $identifier = $request->getParam('identifier'); + + if ($identifier && ! Uuid::isValid($identifier)) { + $this->httpBadRequest('The given identifier is not a valid UUID'); + } + + $filter = FilterProcessor::assembleFilter( + QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + ->on( + QueryString::ON_CONDITION, + function (Filter\Condition $condition) { + $column = $condition->getColumn(); + if (! in_array($column, ['id', 'name', 'type'])) { + $this->httpBadRequest(sprintf( + 'Invalid filter column %s given, only id, name and type are allowed', + $column + )); + } + + if ($column === 'id') { + if (! Uuid::isValid($condition->getValue())) { + $this->httpBadRequest('The given filter id is not a valid UUID'); + } + + $condition->setColumn('external_uuid'); + } + } + )->parse() + ); + + $stmt = (new Select()) + ->distinct() + ->from('channel ch') + ->columns([ + 'channel_id' => 'ch.id', + 'id' => 'ch.external_uuid', + 'name', + 'type', + 'config' + ]); + + if ($identifier !== null) { + $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = $db->fetchOne($stmt); + + if ($result === false) { + $this->httpNotFound('Channel not found'); + } + + unset($result->channel_id); + + $this->getResponse() + ->setHttpResponseCode(200) + ->json() + ->setSuccessData((array) $result) + ->sendResponse(); + } else { + if ($filter !== null) { + $stmt->where($filter); + } + + $stmt->limit(500); + $offset = 0; + + ob_end_clean(); + Environment::raiseExecutionTime(); + + $this->getResponse() + ->setHeader('Content-Type', 'application/json') + ->setHeader('Cache-Control', 'no-store') + ->sendResponse(); + + echo '['; + + $res = $db->select($stmt->offset($offset)); + do { + /** @var stdClass $row */ + foreach ($res as $i => $row) { + if ($i > 0 || $offset !== 0) { + echo ",\n"; + } + + unset($row->channel_id); + + echo Json::sanitize($row); + } + + $offset += 500; + $res = $db->select($stmt->offset($offset)); + } while ($res->rowCount()); + + echo ']'; + } + + exit; + } +} diff --git a/run.php b/run.php index 6e610b85..fd185e72 100644 --- a/run.php +++ b/run.php @@ -47,3 +47,16 @@ 1 => 'identifier' ] )); + +$this->addRoute('notifications/api-v1-channels', new Zend_Controller_Router_Route_Regex( + 'notifications/api/v1/channels(?:\/(.+)|\?(.+))?', + [ + 'controller' => 'api-v1-channels', + 'action' => 'index', + 'module' => 'notifications', + 'identifier' => null + ], + [ + 1 => 'identifier' + ] +)); From 536ea428835a43244920c8e3f2ba297bc0c19aa8 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 3 Jul 2024 16:03:47 +0200 Subject: [PATCH 12/16] Contacts|Contactgroups endpoints: Only allow filter on GET method --- .../controllers/ApiV1ContactgroupsController.php | 7 ++++++- application/controllers/ApiV1ContactsController.php | 11 ++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index 59f54f8f..d235bf6f 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -60,8 +60,13 @@ public function indexAction(): void $this->httpBadRequest('The given identifier is not a valid UUID'); } + $filterStr = rawurldecode(Url::fromRequest()->getQueryString()); + if ($method !== 'GET' && $filterStr) { + $this->httpBadRequest('Filter is only allowed for GET requests'); + } + $filter = FilterProcessor::assembleFilter( - QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + QueryString::fromString($filterStr) ->on( QueryString::ON_CONDITION, function (Filter\Condition $condition) { diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index 31d6cea3..d9266ad8 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -65,8 +65,13 @@ public function indexAction(): void $this->httpBadRequest('The given identifier is not a valid UUID'); } + $filterStr = rawurldecode(Url::fromRequest()->getQueryString()); + if ($method !== 'GET' && $filterStr) { + $this->httpBadRequest('Filter is only allowed for GET requests'); + } + $filter = FilterProcessor::assembleFilter( - QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + QueryString::fromString($filterStr) ->on( QueryString::ON_CONDITION, function (Filter\Condition $condition) { @@ -164,10 +169,6 @@ function (Filter\Condition $condition) { exit; case 'POST': - if ($filter !== null) { - $this->httpBadRequest('Cannot filter on POST'); - } - $data = $this->getValidatedData(); $db->beginTransaction(); From 6453899f7cb4e6e43abd49c705996c03fd473e1e Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 5 Jul 2024 11:05:42 +0200 Subject: [PATCH 13/16] POST|PUT: Throw 400 if request body is not valid json --- application/controllers/ApiV1ContactgroupsController.php | 8 +++++++- application/controllers/ApiV1ContactsController.php | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index d235bf6f..78b314c6 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Notifications\Controllers; +use Exception; use Icinga\Exception\Http\HttpBadRequestException; use Icinga\Exception\Http\HttpException; use Icinga\Exception\Http\HttpNotFoundException; @@ -382,9 +383,14 @@ private function removeContactgroup(int $id): void */ private function getValidatedData(): array { - $data = $this->getRequest()->getPost(); $msgPrefix = 'Invalid request body: '; + try { + $data = $this->getRequest()->getPost(); + } catch (Exception $e) { + $this->httpBadRequest($msgPrefix . 'given content is not a valid JSON'); + } + if ( ! isset($data['id'], $data['name']) || ! is_string($data['id']) diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index d9266ad8..40293291 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Notifications\Controllers; +use Exception; use Icinga\Exception\Http\HttpBadRequestException; use Icinga\Exception\Http\HttpException; use Icinga\Exception\Http\HttpNotFoundException; @@ -506,9 +507,14 @@ private function removeContact(int $id): void */ private function getValidatedData(): array { - $data = $this->getRequest()->getPost(); $msgPrefix = 'Invalid request body: '; + try { + $data = $this->getRequest()->getPost(); + } catch (Exception $e) { + $this->httpBadRequest($msgPrefix . 'given content is not a valid JSON'); + } + if ( ! isset($data['id'], $data['full_name'], $data['default_channel']) || ! is_string($data['id']) From 4aa08e027c2d97793d09a214aeb367c311b1b06b Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 5 Jul 2024 11:22:54 +0200 Subject: [PATCH 14/16] Throw 400 if given filter is not properly escaped --- application/controllers/ApiV1ChannelsController.php | 13 +++++++++---- .../controllers/ApiV1ContactgroupsController.php | 12 ++++++++---- application/controllers/ApiV1ContactsController.php | 12 ++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/application/controllers/ApiV1ChannelsController.php b/application/controllers/ApiV1ChannelsController.php index 7c5e974b..fee4934c 100644 --- a/application/controllers/ApiV1ChannelsController.php +++ b/application/controllers/ApiV1ChannelsController.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Notifications\Controllers; +use Exception; use Icinga\Module\Notifications\Common\Database; use Icinga\Util\Environment; use Icinga\Util\Json; @@ -41,8 +42,8 @@ public function indexAction(): void $this->httpBadRequest('The given identifier is not a valid UUID'); } - $filter = FilterProcessor::assembleFilter( - QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + try { + $filterRule = QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) ->on( QueryString::ON_CONDITION, function (Filter\Condition $condition) { @@ -62,8 +63,12 @@ function (Filter\Condition $condition) { $condition->setColumn('external_uuid'); } } - )->parse() - ); + )->parse(); + + $filter = FilterProcessor::assembleFilter($filterRule); + } catch (Exception $e) { + $this->httpBadRequest($e->getMessage()); + } $stmt = (new Select()) ->distinct() diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index 78b314c6..0b7699c9 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -66,8 +66,8 @@ public function indexAction(): void $this->httpBadRequest('Filter is only allowed for GET requests'); } - $filter = FilterProcessor::assembleFilter( - QueryString::fromString($filterStr) + try { + $filterRule = QueryString::fromString($filterStr) ->on( QueryString::ON_CONDITION, function (Filter\Condition $condition) { @@ -87,8 +87,12 @@ function (Filter\Condition $condition) { $condition->setColumn('external_uuid'); } } - )->parse() - ); + )->parse(); + + $filter = FilterProcessor::assembleFilter($filterRule); + } catch (Exception $e) { + $this->httpBadRequest($e->getMessage()); + } switch ($method) { case 'GET': diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index 40293291..81a4d50c 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -71,8 +71,8 @@ public function indexAction(): void $this->httpBadRequest('Filter is only allowed for GET requests'); } - $filter = FilterProcessor::assembleFilter( - QueryString::fromString($filterStr) + try { + $filterRule = QueryString::fromString($filterStr) ->on( QueryString::ON_CONDITION, function (Filter\Condition $condition) { @@ -92,8 +92,12 @@ function (Filter\Condition $condition) { $condition->setColumn('external_uuid'); } } - )->parse() - ); + )->parse(); + + $filter = FilterProcessor::assembleFilter($filterRule); + } catch (Exception $e) { + $this->httpBadRequest($e->getMessage()); + } switch ($method) { case 'GET': From 8838575209ed6755987d1936502d48f898c3079f Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Tue, 9 Jul 2024 12:23:42 +0200 Subject: [PATCH 15/16] Don't decode the filter url Leave this to QueryString::fromString() method. --- application/controllers/ApiV1ChannelsController.php | 2 +- application/controllers/ApiV1ContactgroupsController.php | 2 +- application/controllers/ApiV1ContactsController.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/controllers/ApiV1ChannelsController.php b/application/controllers/ApiV1ChannelsController.php index fee4934c..ef3ee417 100644 --- a/application/controllers/ApiV1ChannelsController.php +++ b/application/controllers/ApiV1ChannelsController.php @@ -43,7 +43,7 @@ public function indexAction(): void } try { - $filterRule = QueryString::fromString(rawurldecode(Url::fromRequest()->getQueryString())) + $filterRule = QueryString::fromString(Url::fromRequest()->getQueryString()) ->on( QueryString::ON_CONDITION, function (Filter\Condition $condition) { diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index 0b7699c9..1dbb2745 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -61,7 +61,7 @@ public function indexAction(): void $this->httpBadRequest('The given identifier is not a valid UUID'); } - $filterStr = rawurldecode(Url::fromRequest()->getQueryString()); + $filterStr = Url::fromRequest()->getQueryString(); if ($method !== 'GET' && $filterStr) { $this->httpBadRequest('Filter is only allowed for GET requests'); } diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index 81a4d50c..73dc749d 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -66,7 +66,7 @@ public function indexAction(): void $this->httpBadRequest('The given identifier is not a valid UUID'); } - $filterStr = rawurldecode(Url::fromRequest()->getQueryString()); + $filterStr = Url::fromRequest()->getQueryString(); if ($method !== 'GET' && $filterStr) { $this->httpBadRequest('Filter is only allowed for GET requests'); } From d83ab13acce60a68e6f373760a97bc5644d435c6 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 10 Jul 2024 08:57:46 +0200 Subject: [PATCH 16/16] wip --- .../ApiV1ContactgroupsController.php | 24 ++++++++++++--- .../controllers/ApiV1ContactsController.php | 30 +++++++++++++++---- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/application/controllers/ApiV1ContactgroupsController.php b/application/controllers/ApiV1ContactgroupsController.php index 1dbb2745..6492be3c 100644 --- a/application/controllers/ApiV1ContactgroupsController.php +++ b/application/controllers/ApiV1ContactgroupsController.php @@ -213,8 +213,7 @@ function (Filter\Condition $condition) { $contactgroupId = $this->getContactgroupId($identifier); if ($contactgroupId !== null) { $db->update('contactgroup', ['name' => $data['name']], ['id = ?' => $contactgroupId]); - - $db->delete('contactgroup_member', ['contactgroup_id = ?' => $contactgroupId]); + $db->update('contactgroup_member', ['deleted' => 'y'], ['contactgroup_id = ?' => $contactgroupId, 'deleted = ?' => 'n']); if (! empty($data['users'])) { $this->addUsers($contactgroupId, $data['users']); @@ -374,8 +373,25 @@ private function addUsers(int $contactgroupId, array $users): void */ private function removeContactgroup(int $id): void { - Database::get()->delete('contactgroup_member', ['contactgroup_id = ?' => $id]); - Database::get()->delete('contactgroup', ['id = ?' => $id]); + $db = Database::get(); + $markAsDeleted = ['deleted' => 'y']; + + $db->update( + 'rotation_member', + $markAsDeleted + ['position' => null], + ['contactgroup_id = ?' => $id, 'deleted = ?' => 'n'] + ); + + $db->update( + 'rule_escalation_recipient', + $markAsDeleted, + ['contactgroup_id = ?' => $id, 'deleted = ?' => 'n'] + ); + + $db->update('contactgroup_member', $markAsDeleted, ['contactgroup_id = ?' => $id, 'deleted = ?' => 'n']); + $db->update('contactgroup', $markAsDeleted, ['id = ?' => $id]); + + //TODO: properly remove rotations|escalations with no members as in form } /** diff --git a/application/controllers/ApiV1ContactsController.php b/application/controllers/ApiV1ContactsController.php index 73dc749d..44a82eb7 100644 --- a/application/controllers/ApiV1ContactsController.php +++ b/application/controllers/ApiV1ContactsController.php @@ -226,11 +226,12 @@ function (Filter\Condition $condition) { $db->update('contact', [ 'full_name' => $data['full_name'], 'username' => $data['username'] ?? null, - 'default_channel_id' => $this->getChannelId($data['default_channel']) + 'default_channel_id' => $this->getChannelId($data['default_channel']), ], ['id = ?' => $contactId]); - $db->delete('contact_address', ['contact_id = ?' => $contactId]); - $db->delete('contactgroup_member', ['contact_id = ?' => $contactId]); + $markAsDeleted = ['deleted' => 'y']; + $db->update('contact_address', $markAsDeleted, ['contact_id = ?' => $contactId, 'deleted = ?' => 'n']); + $db->update('contactgroup_member', $markAsDeleted, ['contact_id = ?' => $contactId, 'deleted = ?' => 'n']); if (! empty($data['addresses'])) { $this->addAddresses($contactId, $data['addresses']); @@ -497,9 +498,26 @@ private function addAddresses(int $contactId, array $addresses): void */ private function removeContact(int $id): void { - Database::get()->delete('contactgroup_member', ['contact_id = ?' => $id]); - Database::get()->delete('contact_address', ['contact_id = ?' => $id]); - Database::get()->delete('contact', ['id = ?' => $id]); + $db = Database::get(); + $markAsDeleted = ['deleted' => 'y']; + + $db->update( + 'rotation_member', + $markAsDeleted + ['position' => null], + ['contact_id = ?' => $id, 'deleted = ?' => 'n'] + ); + + $db->update( + 'rule_escalation_recipient', + $markAsDeleted, + ['contact_id = ?' => $id, 'deleted = ?' => 'n'] + ); + + $db->update('contactgroup_member', $markAsDeleted, ['contact_id = ?' => $id, 'deleted = ?' => 'n']); + $db->update('contact_address', $markAsDeleted, ['contact_id = ?' => $id, 'deleted = ?' => 'n']); + $db->update('contact', $markAsDeleted, ['id = ?' => $id]); + + //TODO: properly remove rotations|escalations with no members as in form } /**