From 669edf832ce718f526751584980eea84fbb1bf6f Mon Sep 17 00:00:00 2001 From: Mike Koch Date: Sun, 8 Apr 2018 22:07:13 -0400 Subject: [PATCH 1/5] Starting to work on reply API endpoint --- api/BusinessLogic/Helpers.php | 43 +++++++++++++ .../Tickets/CreateReplyRequest.php | 12 ++++ api/BusinessLogic/Tickets/ReplyCreator.php | 60 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 api/BusinessLogic/Tickets/CreateReplyRequest.php create mode 100644 api/BusinessLogic/Tickets/ReplyCreator.php diff --git a/api/BusinessLogic/Helpers.php b/api/BusinessLogic/Helpers.php index b841fd27..f356bfbb 100644 --- a/api/BusinessLogic/Helpers.php +++ b/api/BusinessLogic/Helpers.php @@ -34,4 +34,47 @@ class Helpers extends \BaseClass { static function heskHtmlSpecialCharsDecode($in) { return str_replace(array('&', '<', '>', '"'), array('&', '<', '>', '"'), $in); } + + static function heskMakeUrl($text, $class = '', $shortenLinks = true) { + if (!defined('MAGIC_URL_EMAIL')) { + define('MAGIC_URL_EMAIL', 1); + define('MAGIC_URL_FULL', 2); + define('MAGIC_URL_LOCAL', 3); + define('MAGIC_URL_WWW', 4); + } + + $class = ($class) ? ' class="' . $class . '"' : ''; + + // matches a xxxx://aaaaa.bbb.cccc. ... + $text = preg_replace_callback( + '#(^|[\n\t (>.])(' . "[a-z][a-z\d+]*:/{2}(?:(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})+|[0-9.]+|\[[a-z0-9.]+:[a-z0-9.]+:[a-z0-9.:]+\])(?::\d*)?(?:/(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})*)*(?:\?(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?(?:\#(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?" . ')#iu', + create_function( + "\$matches", + "return make_clickable_callback(MAGIC_URL_FULL, \$matches[1], \$matches[2], '', '$class', '$shortenLinks');" + ), + $text + ); + + // matches a "www.xxxx.yyyy[/zzzz]" kinda lazy URL thing + $text = preg_replace_callback( + '#(^|[\n\t (>])(' . "www\.(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})+(?::\d*)?(?:/(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})*)*(?:\?(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?(?:\#(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?" . ')#iu', + create_function( + "\$matches", + "return make_clickable_callback(MAGIC_URL_WWW, \$matches[1], \$matches[2], '', '$class', '$shortenLinks');" + ), + $text + ); + + // matches an email address + $text = preg_replace_callback( + '/(^|[\n\t (>])(' . '((?:[\w\!\#$\%\&\'\*\+\-\/\=\?\^\`{\|\}\~]+\.)*(?:[\w\!\#$\%\'\*\+\-\/\=\?\^\`{\|\}\~]|&)+)@((((([a-z0-9]{1}[a-z0-9\-]{0,62}[a-z0-9]{1})|[a-z])\.)+[a-z]{2,63})|(\d{1,3}\.){3}\d{1,3}(\:\d{1,5})?)' . ')/iu', + create_function( + "\$matches", + "return make_clickable_callback(MAGIC_URL_EMAIL, \$matches[1], \$matches[2], '', '$class', '$shortenLinks');" + ), + $text + ); + + return $text; + } } \ No newline at end of file diff --git a/api/BusinessLogic/Tickets/CreateReplyRequest.php b/api/BusinessLogic/Tickets/CreateReplyRequest.php new file mode 100644 index 00000000..e0d5bcd6 --- /dev/null +++ b/api/BusinessLogic/Tickets/CreateReplyRequest.php @@ -0,0 +1,12 @@ +statusGateway = $statusGateway; + $this->ticketGateway = $ticketGateway; + $this->emailSenderHelper = $emailSenderHelper; + $this->userGateway = $userGateway; + $this->auditTrailGateway = $auditTrailGateway; + } + + /** + * @param $replyRequest CreateReplyRequest + * @param $heskSettings array + * @param $modsForHeskSettings array + * @param $userContext UserContext + * @throws ApiFriendlyException + */ + function createReplyByCustomer($replyRequest, $heskSettings, $modsForHeskSettings, $userContext) { + $ticket = $this->ticketGateway->getTicketByTrackingId($replyRequest->trackingId, $heskSettings); + + if ($ticket === null) { + throw new ApiFriendlyException("Ticket with tracking ID {$replyRequest->trackingId} not found.", + "Ticket not found", 404); + } + + $validationModel = new ValidationModel(); + if (!strlen($replyRequest->replyMessage)) { + $validationModel->errorKeys[] = 'MESSAGE_REQUIRED'; + } + + if ($modsForHeskSettings['rich_text_for_tickets_for_customers']) { + $replyRequest->replyMessage = Helpers::heskMakeUrl($replyRequest->replyMessage); + $replyRequest->replyMessage = nl2br($replyRequest->replyMessage); + } + } +} \ No newline at end of file From 9ae259dff68eecaa0cdd126c641c6a91d167ba91 Mon Sep 17 00:00:00 2001 From: Mike Koch Date: Mon, 9 Apr 2018 13:01:49 -0400 Subject: [PATCH 2/5] Working on reply endpoint. Just need to send out the email. --- .../Tickets/CreateReplyRequest.php | 1 + api/BusinessLogic/Tickets/ReplyCreator.php | 48 ++++++++++++++++++- .../Tickets/CustomerReplyController.php | 24 ++++++++++ api/DataAccess/Security/LoginGateway.php | 24 ++++++++++ api/DataAccess/Statuses/StatusGateway.php | 18 +++++++ api/DataAccess/Tickets/ReplyGateway.php | 17 +++++++ api/DataAccess/Tickets/TicketGateway.php | 30 ++++++++++++ api/index.php | 1 + 8 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 api/Controllers/Tickets/CustomerReplyController.php create mode 100644 api/DataAccess/Security/LoginGateway.php create mode 100644 api/DataAccess/Tickets/ReplyGateway.php diff --git a/api/BusinessLogic/Tickets/CreateReplyRequest.php b/api/BusinessLogic/Tickets/CreateReplyRequest.php index e0d5bcd6..b66febce 100644 --- a/api/BusinessLogic/Tickets/CreateReplyRequest.php +++ b/api/BusinessLogic/Tickets/CreateReplyRequest.php @@ -4,6 +4,7 @@ namespace BusinessLogic\Tickets; class CreateReplyRequest { + public $ticketId; public $trackingId; public $emailAddress; public $replyMessage; diff --git a/api/BusinessLogic/Tickets/ReplyCreator.php b/api/BusinessLogic/Tickets/ReplyCreator.php index cfbf74a0..d43eeb5f 100644 --- a/api/BusinessLogic/Tickets/ReplyCreator.php +++ b/api/BusinessLogic/Tickets/ReplyCreator.php @@ -4,13 +4,19 @@ namespace BusinessLogic\Tickets; use BusinessLogic\Emails\EmailSenderHelper; +use BusinessLogic\Emails\EmailTemplateRetriever; use BusinessLogic\Exceptions\ApiFriendlyException; +use BusinessLogic\Exceptions\ValidationException; use BusinessLogic\Helpers; use BusinessLogic\Security\UserContext; +use BusinessLogic\Statuses\Closable; +use BusinessLogic\Statuses\DefaultStatusForAction; use BusinessLogic\ValidationModel; use DataAccess\AuditTrail\AuditTrailGateway; +use DataAccess\Security\LoginGateway; use DataAccess\Security\UserGateway; use DataAccess\Statuses\StatusGateway; +use DataAccess\Tickets\ReplyGateway; use DataAccess\Tickets\TicketGateway; class ReplyCreator { @@ -19,17 +25,23 @@ class ReplyCreator { private $emailSenderHelper; private $userGateway; private $auditTrailGateway; + private $loginGateway; + private $replyGateway; public function __construct(StatusGateway $statusGateway, TicketGateway $ticketGateway, EmailSenderHelper $emailSenderHelper, UserGateway $userGateway, - AuditTrailGateway $auditTrailGateway) { + AuditTrailGateway $auditTrailGateway, + LoginGateway $loginGateway, + ReplyGateway $replyGateway) { $this->statusGateway = $statusGateway; $this->ticketGateway = $ticketGateway; $this->emailSenderHelper = $emailSenderHelper; $this->userGateway = $userGateway; $this->auditTrailGateway = $auditTrailGateway; + $this->loginGateway = $loginGateway; + $this->replyGateway = $replyGateway; } /** @@ -38,6 +50,7 @@ class ReplyCreator { * @param $modsForHeskSettings array * @param $userContext UserContext * @throws ApiFriendlyException + * @throws \Exception */ function createReplyByCustomer($replyRequest, $heskSettings, $modsForHeskSettings, $userContext) { $ticket = $this->ticketGateway->getTicketByTrackingId($replyRequest->trackingId, $heskSettings); @@ -50,11 +63,44 @@ class ReplyCreator { $validationModel = new ValidationModel(); if (!strlen($replyRequest->replyMessage)) { $validationModel->errorKeys[] = 'MESSAGE_REQUIRED'; + + throw new ValidationException($validationModel); } if ($modsForHeskSettings['rich_text_for_tickets_for_customers']) { $replyRequest->replyMessage = Helpers::heskMakeUrl($replyRequest->replyMessage); $replyRequest->replyMessage = nl2br($replyRequest->replyMessage); } + + if ($this->loginGateway->isIpLockedOut($replyRequest->ipAddress, $heskSettings)) { + throw new ApiFriendlyException("The IP address entered has been locked out of the system for {$heskSettings['attempt_banmin']} minutes because of too many login failures", + "Locked Out", + 403); + } + + if ($this->ticketGateway->areRepliesBeingFlooded($replyRequest->ticketId, $replyRequest->ipAddress, $heskSettings)) { + throw new ApiFriendlyException("You have been locked out of the system for {$heskSettings['attempt_banmin']} minutes because of too many replies to a ticket.", + "Locked Out", + 403); + } + + // If staff hasn't replied yet, don't change the status; otherwise set it to the status for customer replies + $currentStatus = $this->statusGateway->getStatusById($ticket->statusId, $heskSettings); + if ($currentStatus->closable === Closable::YES || $currentStatus->closable === Closable::CUSTOMERS_ONLY) { + $customerReplyStatus = $this->statusGateway->getStatusForDefaultAction(DefaultStatusForAction::CUSTOMER_REPLY, $heskSettings); + $defaultNewTicketStatus = $this->statusGateway->getStatusForDefaultAction(DefaultStatusForAction::NEW_TICKET, $heskSettings); + + $ticket->statusId = $ticket->statusId === $defaultNewTicketStatus->id ? + $defaultNewTicketStatus->id : + $customerReplyStatus->id; + } + + $this->ticketGateway->updateMetadataForReply($ticket->id, $ticket->statusId, $heskSettings); + $this->replyGateway->insertReply($ticket->id, $ticket->name, $replyRequest->replyMessage, $replyRequest->hasHtml, $heskSettings); + + //-- Changing the ticket message to the reply's + $ticket->message = $replyRequest->replyMessage; + + // TODO Send the email. } } \ No newline at end of file diff --git a/api/Controllers/Tickets/CustomerReplyController.php b/api/Controllers/Tickets/CustomerReplyController.php new file mode 100644 index 00000000..d5ffe113 --- /dev/null +++ b/api/Controllers/Tickets/CustomerReplyController.php @@ -0,0 +1,24 @@ +id = $ticketId; + $createReplyByCustomerModel->emailAddress = Helpers::safeArrayGet($jsonRequest, 'email'); + $createReplyByCustomerModel->trackingId = Helpers::safeArrayGet($jsonRequest, 'trackingId'); + $createReplyByCustomerModel->replyMessage = Helpers::safeArrayGet($jsonRequest, 'message'); + $createReplyByCustomerModel->hasHtml = Helpers::safeArrayGet($jsonRequest, 'html'); + $createReplyByCustomerModel->ipAddress = Helpers::safeArrayGet($jsonRequest, 'ip'); + } +} diff --git a/api/DataAccess/Security/LoginGateway.php b/api/DataAccess/Security/LoginGateway.php new file mode 100644 index 00000000..461eb62d --- /dev/null +++ b/api/DataAccess/Security/LoginGateway.php @@ -0,0 +1,24 @@ +init(); + + $rs = hesk_dbQuery("SELECT `number` FROM `" . hesk_dbEscape($heskSettings['db_pfix']) . "logins` + WHERE `ip` = '" . hesk_dbEscape($ipAddress) . "' + AND `last_attempt` IS NOT NULL + AND DATE_ADD(`last_attempt`, INTERVAL ".intval($heskSettings['attempt_banmin'])." MINUTE ) > NOW() LIMIT 1"); + + $result = hesk_dbNumRows($rs) == 1 && + hesk_dbResult($rs) >= $heskSettings['attempt_limit']; + + $this->close(); + + return $result; + } +} \ No newline at end of file diff --git a/api/DataAccess/Statuses/StatusGateway.php b/api/DataAccess/Statuses/StatusGateway.php index c18e9b19..a44d8d6b 100644 --- a/api/DataAccess/Statuses/StatusGateway.php +++ b/api/DataAccess/Statuses/StatusGateway.php @@ -53,4 +53,22 @@ class StatusGateway extends CommonDao { return $statuses; } + + function getStatusById($id, $heskSettings) { + $this->init(); + + $metaRs = hesk_dbQuery("SELECT * FROM `" . hesk_dbEscape($heskSettings['db_pfix']) . "statuses` WHERE `ID` = " . $id); + + $status = null; + if ($row = hesk_dbFetchAssoc($metaRs)) { + $languageRs = hesk_dbQuery("SELECT * FROM `" . hesk_dbEscape($heskSettings['db_pfix']) . "text_to_status_xref` + WHERE `status_id` = " . intval($row['ID'])); + + $status = Status::fromDatabase($row, $languageRs); + } + + $this->close(); + + return $status; + } } \ No newline at end of file diff --git a/api/DataAccess/Tickets/ReplyGateway.php b/api/DataAccess/Tickets/ReplyGateway.php new file mode 100644 index 00000000..399586f5 --- /dev/null +++ b/api/DataAccess/Tickets/ReplyGateway.php @@ -0,0 +1,17 @@ +init(); + + hesk_dbQuery("INSERT INTO `" . hesk_dbEscape($heskSettings['db_pfix']) . "replies` (`replyto`,`name`,`message`,`dt`,`attachments`, `html`) + VALUES ({$ticketId},'" . hesk_dbEscape($name) . "','" . hesk_dbEscape($message) . "',NOW(),'','" . $html . "')"); + + $this->close(); + } +} \ No newline at end of file diff --git a/api/DataAccess/Tickets/TicketGateway.php b/api/DataAccess/Tickets/TicketGateway.php index 2ea5a84d..62a40558 100644 --- a/api/DataAccess/Tickets/TicketGateway.php +++ b/api/DataAccess/Tickets/TicketGateway.php @@ -454,4 +454,34 @@ class TicketGateway extends CommonDao { $this->close(); } + + function areRepliesBeingFlooded($id, $ip, $heskSettings) { + $this->init(); + + $result = false; + $res = hesk_dbQuery("SELECT `staffid` FROM `" . hesk_dbEscape($heskSettings['db_pfix']) . "replies` WHERE `replyto`='{$id}' AND `dt` > DATE_SUB(NOW(), INTERVAL 10 MINUTE) ORDER BY `id` ASC"); + if (hesk_dbNumRows($res) > 0) { + $sequential_customer_replies = 0; + while ($tmp = hesk_dbFetchAssoc($res)) { + $sequential_customer_replies = $tmp['staffid'] ? 0 : $sequential_customer_replies + 1; + } + + if ($sequential_customer_replies > 10) { + hesk_dbQuery("INSERT INTO `".hesk_dbEscape($heskSettings['db_pfix'])."logins` (`ip`, `number`) VALUES ('".hesk_dbEscape($ip)."', ".intval($heskSettings['attempt_limit'] + 1).")"); + $result = true; + } + } + + $this->close(); + + return $result; + } + + function updateMetadataForReply($id, $status, $heskSettings) { + $this->init(); + + hesk_dbQuery("UPDATE `" . hesk_dbEscape($heskSettings['db_pfix']) . "tickets` SET `lastchange`=NOW(), `status`='{$status}', `replies`=`replies`+1, `lastreplier`='0' WHERE `id`='{$id}'"); + + $this->close(); + } } \ No newline at end of file diff --git a/api/index.php b/api/index.php index ec493347..b991e91c 100644 --- a/api/index.php +++ b/api/index.php @@ -199,6 +199,7 @@ Link::all(array( '/v1-internal/categories/{i}/sort/{s}' => action(\Controllers\Categories\CategoryController::clazz() . '::sort', array(RequestMethod::POST), SecurityHandler::INTERNAL), // Tickets '/v1/tickets' => action(\Controllers\Tickets\CustomerTicketController::clazz(), RequestMethod::all(), SecurityHandler::OPEN), + '/v1/tickets/{i}/replies' => action(\Controllers\Tickets\CustomerReplyController::clazz(), array(RequestMethod::POST), SecurityHandler::OPEN), // Tickets - Staff '/v1/staff/tickets/{i}' => action(\Controllers\Tickets\StaffTicketController::clazz(), RequestMethod::all()), '/v1/staff/tickets/{i}/due-date' => action(\Controllers\Tickets\StaffTicketController::clazz() . '::updateDueDate', array(RequestMethod::PATCH), SecurityHandler::INTERNAL_OR_AUTH_TOKEN), From 6fa6c7b6863e847e41f05bf6203bb58f9857e905 Mon Sep 17 00:00:00 2001 From: Mike Koch Date: Tue, 10 Apr 2018 13:11:48 -0400 Subject: [PATCH 3/5] Add ability to reply via API --- api/BusinessLogic/Helpers.php | 131 ++++++++++++++++-- .../Tickets/CustomerCreatedReplyModel.php | 13 ++ api/BusinessLogic/Tickets/ReplyCreator.php | 51 ++++++- .../Tickets/CustomerReplyController.php | 12 ++ api/DataAccess/Security/UserGateway.php | 15 ++ api/DataAccess/Tickets/ReplyGateway.php | 16 +++ api/DataAccess/Tickets/TicketGateway.php | 4 +- 7 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 api/BusinessLogic/Tickets/CustomerCreatedReplyModel.php diff --git a/api/BusinessLogic/Helpers.php b/api/BusinessLogic/Helpers.php index f356bfbb..ea92bc5c 100644 --- a/api/BusinessLogic/Helpers.php +++ b/api/BusinessLogic/Helpers.php @@ -48,33 +48,140 @@ class Helpers extends \BaseClass { // matches a xxxx://aaaaa.bbb.cccc. ... $text = preg_replace_callback( '#(^|[\n\t (>.])(' . "[a-z][a-z\d+]*:/{2}(?:(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})+|[0-9.]+|\[[a-z0-9.]+:[a-z0-9.]+:[a-z0-9.:]+\])(?::\d*)?(?:/(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})*)*(?:\?(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?(?:\#(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?" . ')#iu', - create_function( - "\$matches", - "return make_clickable_callback(MAGIC_URL_FULL, \$matches[1], \$matches[2], '', '$class', '$shortenLinks');" - ), + function($matches) use ($class, $shortenLinks) { + return self::makeClickableCallback(MAGIC_URL_FULL, $matches[1], $matches[2], '', $class, $shortenLinks); + }, $text ); // matches a "www.xxxx.yyyy[/zzzz]" kinda lazy URL thing $text = preg_replace_callback( '#(^|[\n\t (>])(' . "www\.(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})+(?::\d*)?(?:/(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@|]+|%[\dA-F]{2})*)*(?:\?(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?(?:\#(?:[^\p{C}\p{Z}\p{S}\p{P}\p{Nl}\p{No}\p{Me}\x{1100}-\x{115F}\x{A960}-\x{A97C}\x{1160}-\x{11A7}\x{D7B0}-\x{D7C6}\x{20D0}-\x{20FF}\x{1D100}-\x{1D1FF}\x{1D200}-\x{1D24F}\x{0640}\x{07FA}\x{302E}\x{302F}\x{3031}-\x{3035}\x{303B}]*[\x{00B7}\x{0375}\x{05F3}\x{05F4}\x{30FB}\x{002D}\x{06FD}\x{06FE}\x{0F0B}\x{3007}\x{00DF}\x{03C2}\x{200C}\x{200D}\pL0-9\-._~!$&'(*+,;=:@/?|]+|%[\dA-F]{2})*)?" . ')#iu', - create_function( - "\$matches", - "return make_clickable_callback(MAGIC_URL_WWW, \$matches[1], \$matches[2], '', '$class', '$shortenLinks');" - ), + function($matches) use ($class, $shortenLinks) { + return self::makeClickableCallback(MAGIC_URL_WWW, $matches[1], $matches[2], '', $class, $shortenLinks); + }, $text ); // matches an email address $text = preg_replace_callback( '/(^|[\n\t (>])(' . '((?:[\w\!\#$\%\&\'\*\+\-\/\=\?\^\`{\|\}\~]+\.)*(?:[\w\!\#$\%\'\*\+\-\/\=\?\^\`{\|\}\~]|&)+)@((((([a-z0-9]{1}[a-z0-9\-]{0,62}[a-z0-9]{1})|[a-z])\.)+[a-z]{2,63})|(\d{1,3}\.){3}\d{1,3}(\:\d{1,5})?)' . ')/iu', - create_function( - "\$matches", - "return make_clickable_callback(MAGIC_URL_EMAIL, \$matches[1], \$matches[2], '', '$class', '$shortenLinks');" - ), + function($matches) use ($class, $shortenLinks) { + return self::makeClickableCallback(MAGIC_URL_EMAIL, $matches[1], $matches[2], '', $class, $shortenLinks); + }, $text ); return $text; } + + static function makeClickableCallback($type, $whitespace, $url, $relative_url, $class, $shortenLinks) + { + global $hesk_settings; + + $orig_url = $url; + $orig_relative = $relative_url; + $append = ''; + $url = htmlspecialchars_decode($url); + $relative_url = htmlspecialchars_decode($relative_url); + + // make sure no HTML entities were matched + $chars = array('<', '>', '"'); + $split = false; + + foreach ($chars as $char) { + $next_split = strpos($url, $char); + if ($next_split !== false) { + $split = ($split !== false) ? min($split, $next_split) : $next_split; + } + } + + if ($split !== false) { + // an HTML entity was found, so the URL has to end before it + $append = substr($url, $split) . $relative_url; + $url = substr($url, 0, $split); + $relative_url = ''; + } else if ($relative_url) { + // same for $relative_url + $split = false; + foreach ($chars as $char) { + $next_split = strpos($relative_url, $char); + if ($next_split !== false) { + $split = ($split !== false) ? min($split, $next_split) : $next_split; + } + } + + if ($split !== false) { + $append = substr($relative_url, $split); + $relative_url = substr($relative_url, 0, $split); + } + } + + // if the last character of the url is a punctuation mark, exclude it from the url + $last_char = ($relative_url) ? $relative_url[strlen($relative_url) - 1] : $url[strlen($url) - 1]; + + switch ($last_char) { + case '.': + case '?': + case '!': + case ':': + case ',': + $append = $last_char; + if ($relative_url) { + $relative_url = substr($relative_url, 0, -1); + } else { + $url = substr($url, 0, -1); + } + break; + + // set last_char to empty here, so the variable can be used later to + // check whether a character was removed + default: + $last_char = ''; + break; + } + + $short_url = ($hesk_settings['short_link'] && strlen($url) > 70 && $shortenLinks) ? substr($url, 0, 54) . ' ... ' . substr($url, -10) : $url; + + switch ($type) { + case MAGIC_URL_LOCAL: + $tag = 'l'; + $relative_url = preg_replace('/[&?]sid=[0-9a-f]{32}$/', '', preg_replace('/([&?])sid=[0-9a-f]{32}&/', '$1', $relative_url)); + $url = $url . '/' . $relative_url; + $text = $relative_url; + + // this url goes to http://domain.tld/path/to/board/ which + // would result in an empty link if treated as local so + // don't touch it and let MAGIC_URL_FULL take care of it. + if (!$relative_url) { + return $whitespace . $orig_url . '/' . $orig_relative; // slash is taken away by relative url pattern + } + break; + + case MAGIC_URL_FULL: + $tag = 'm'; + $text = $short_url; + break; + + case MAGIC_URL_WWW: + $tag = 'w'; + $url = 'http://' . $url; + $text = $short_url; + break; + + case MAGIC_URL_EMAIL: + $tag = 'e'; + $text = $short_url; + $url = 'mailto:' . $url; + break; + } + + $url = htmlspecialchars($url); + $text = htmlspecialchars($text); + $append = htmlspecialchars($append); + + $html = "$whitespace$text$append"; + + return $html; + } // END make_clickable_callback() } \ No newline at end of file diff --git a/api/BusinessLogic/Tickets/CustomerCreatedReplyModel.php b/api/BusinessLogic/Tickets/CustomerCreatedReplyModel.php new file mode 100644 index 00000000..4b5bec2f --- /dev/null +++ b/api/BusinessLogic/Tickets/CustomerCreatedReplyModel.php @@ -0,0 +1,13 @@ +ticketGateway->getTicketByTrackingId($replyRequest->trackingId, $heskSettings); if ($ticket === null) { @@ -61,9 +61,19 @@ class ReplyCreator { } $validationModel = new ValidationModel(); - if (!strlen($replyRequest->replyMessage)) { + if ($replyRequest->replyMessage === null || trim($replyRequest->replyMessage) === '') { $validationModel->errorKeys[] = 'MESSAGE_REQUIRED'; + } + + if ($heskSettings['email_view_ticket']) { + if ($replyRequest->emailAddress === null || trim($replyRequest->emailAddress) === '') { + $validationModel->errorKeys[] = 'EMAIL_REQUIRED'; + } elseif (!in_array($replyRequest->emailAddress, $ticket->email)) { + $validationModel->errorKeys[] = 'EMAIL_NOT_FOUND_ON_TICKET'; + } + } + if (count($validationModel->errorKeys) > 0) { throw new ValidationException($validationModel); } @@ -96,11 +106,40 @@ class ReplyCreator { } $this->ticketGateway->updateMetadataForReply($ticket->id, $ticket->statusId, $heskSettings); - $this->replyGateway->insertReply($ticket->id, $ticket->name, $replyRequest->replyMessage, $replyRequest->hasHtml, $heskSettings); + $createdReply = $this->replyGateway->insertReply($ticket->id, $ticket->name, $replyRequest->replyMessage, $replyRequest->hasHtml, $heskSettings); //-- Changing the ticket message to the reply's $ticket->message = $replyRequest->replyMessage; - // TODO Send the email. + $addressees = new Addressees(); + if ($ticket->ownerId !== null && $ticket->ownerId !== 0) { + $owner = $this->userGateway->getUserById($ticket->ownerId, $heskSettings); + + if ($owner->notificationSettings->replyToMe) { + $addressees->to[] = $owner->email; + $language = $owner->language === null ? $heskSettings['language'] : $owner->language; + $this->emailSenderHelper->sendEmailForTicket(EmailTemplateRetriever::NEW_REPLY_BY_CUSTOMER, + $language, + $addressees, + $ticket, + $heskSettings, + $modsForHeskSettings); + } + } else { + $users = $this->userGateway->getUsersForUnassignedReplyNotification($heskSettings); + foreach ($users as $user) { + $addressees->to[] = $user->email; + $language = $user->language === null ? $heskSettings['language'] : $user->language; + + $this->emailSenderHelper->sendEmailForTicket(EmailTemplateRetriever::NEW_REPLY_BY_CUSTOMER, + $language, + $addressees, + $ticket, + $heskSettings, + $modsForHeskSettings); + } + } + + return $createdReply; } } \ No newline at end of file diff --git a/api/Controllers/Tickets/CustomerReplyController.php b/api/Controllers/Tickets/CustomerReplyController.php index d5ffe113..1ed31771 100644 --- a/api/Controllers/Tickets/CustomerReplyController.php +++ b/api/Controllers/Tickets/CustomerReplyController.php @@ -5,7 +5,9 @@ namespace Controllers\Tickets; use BusinessLogic\Helpers; use BusinessLogic\Tickets\CreateReplyRequest; +use BusinessLogic\Tickets\ReplyCreator; use Controllers\JsonRetriever; +use DataAccess\Settings\ModsForHeskSettingsGateway; class CustomerReplyController extends \BaseClass { function post($ticketId) { @@ -20,5 +22,15 @@ class CustomerReplyController extends \BaseClass { $createReplyByCustomerModel->replyMessage = Helpers::safeArrayGet($jsonRequest, 'message'); $createReplyByCustomerModel->hasHtml = Helpers::safeArrayGet($jsonRequest, 'html'); $createReplyByCustomerModel->ipAddress = Helpers::safeArrayGet($jsonRequest, 'ip'); + + /* @var $modsForHeskSettingsGateway ModsForHeskSettingsGateway */ + $modsForHeskSettingsGateway = $applicationContext->get(ModsForHeskSettingsGateway::clazz()); + $modsForHesk_settings = $modsForHeskSettingsGateway->getAllSettings($hesk_settings); + + /* @var $replyCreator ReplyCreator */ + $replyCreator = $applicationContext->get(ReplyCreator::clazz()); + $createdReply = $replyCreator->createReplyByCustomer($createReplyByCustomerModel, $hesk_settings, $modsForHesk_settings); + + return output($createdReply, 201); } } diff --git a/api/DataAccess/Security/UserGateway.php b/api/DataAccess/Security/UserGateway.php index e836f18a..7a08cc39 100644 --- a/api/DataAccess/Security/UserGateway.php +++ b/api/DataAccess/Security/UserGateway.php @@ -100,6 +100,21 @@ class UserGateway extends CommonDao { return $users; } + function getUsersForUnassignedReplyNotification($heskSettings) { + $this->init(); + + $rs = hesk_dbQuery("SELECT * FROM `" . hesk_dbEscape($heskSettings['db_pfix']) . "users` WHERE `notify_reply_unassigned` = '1' AND `active` = '1'"); + + $users = array(); + while ($row = hesk_dbFetchAssoc($rs)) { + $users[] = UserContext::fromDataRow($row); + } + + $this->close(); + + return $users; + } + function getManagerForCategory($categoryId, $heskSettings) { $this->init(); diff --git a/api/DataAccess/Tickets/ReplyGateway.php b/api/DataAccess/Tickets/ReplyGateway.php index 399586f5..ab74c459 100644 --- a/api/DataAccess/Tickets/ReplyGateway.php +++ b/api/DataAccess/Tickets/ReplyGateway.php @@ -3,6 +3,7 @@ namespace DataAccess\Tickets; +use BusinessLogic\Tickets\CustomerCreatedReplyModel; use DataAccess\CommonDao; class ReplyGateway extends CommonDao { @@ -12,6 +13,21 @@ class ReplyGateway extends CommonDao { hesk_dbQuery("INSERT INTO `" . hesk_dbEscape($heskSettings['db_pfix']) . "replies` (`replyto`,`name`,`message`,`dt`,`attachments`, `html`) VALUES ({$ticketId},'" . hesk_dbEscape($name) . "','" . hesk_dbEscape($message) . "',NOW(),'','" . $html . "')"); + $customerCreatedReplyModel = new CustomerCreatedReplyModel(); + $id = hesk_dbInsertID(); + + $rs = hesk_dbQuery("SELECT * FROM `" . hesk_dbEscape($heskSettings['db_pfix']) . "replies` WHERE `id` = " . intval($id)); + $row = hesk_dbFetchAssoc($rs); + + $customerCreatedReplyModel->id = $row['id']; + $customerCreatedReplyModel->message = $row['message']; + $customerCreatedReplyModel->ticketId = $row['replyto']; + $customerCreatedReplyModel->dateCreated = hesk_date($row['dt'], true); + $customerCreatedReplyModel->html = $row['html'] === '1'; + $customerCreatedReplyModel->replierName = $row['name']; + $this->close(); + + return $customerCreatedReplyModel; } } \ No newline at end of file diff --git a/api/DataAccess/Tickets/TicketGateway.php b/api/DataAccess/Tickets/TicketGateway.php index 62a40558..892afebc 100644 --- a/api/DataAccess/Tickets/TicketGateway.php +++ b/api/DataAccess/Tickets/TicketGateway.php @@ -312,8 +312,8 @@ class TicketGateway extends CommonDao { $generatedFields = new TicketGatewayGeneratedFields(); $generatedFields->id = $id; - $generatedFields->dateCreated = $row['dt']; - $generatedFields->dateModified = $row['lastchange']; + $generatedFields->dateCreated = hesk_date($row['dt'], true); + $generatedFields->dateModified = hesk_date($row['lastchange'], true); $this->close(); From 90df3de829ffd87213d0eb33546781fe442f109b Mon Sep 17 00:00:00 2001 From: Mike Koch Date: Wed, 11 Apr 2018 12:37:51 -0400 Subject: [PATCH 4/5] Allow sending over http if the magic constant is present --- api/BusinessLogic/Emails/MailgunEmailSender.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/BusinessLogic/Emails/MailgunEmailSender.php b/api/BusinessLogic/Emails/MailgunEmailSender.php index e46289e1..6231c26c 100644 --- a/api/BusinessLogic/Emails/MailgunEmailSender.php +++ b/api/BusinessLogic/Emails/MailgunEmailSender.php @@ -18,11 +18,11 @@ class MailgunEmailSender extends \BaseClass implements EmailSender { $mailgunArray['to'] = implode(',', $emailBuilder->to); - if ($emailBuilder->cc !== null) { + if ($emailBuilder->cc !== null && count($emailBuilder->cc) > 0) { $mailgunArray['cc'] = implode(',', $emailBuilder->cc); } - if ($emailBuilder->bcc !== null) { + if ($emailBuilder->bcc !== null && count($emailBuilder->bcc) > 0) { $mailgunArray['bcc'] = implode(',', $emailBuilder->bcc); } @@ -55,7 +55,9 @@ class MailgunEmailSender extends \BaseClass implements EmailSender { } private function sendMessage($mailgunArray, $attachments, $modsForHeskSettings) { - $messageClient = new Mailgun($modsForHeskSettings['mailgun_api_key']); + $ssl = !defined('NO_MAILGUN_SSL'); + + $messageClient = new Mailgun($modsForHeskSettings['mailgun_api_key'], 'api.mailgun.net', 'v2', $ssl); $mailgunAttachments = array(); if (count($attachments) > 0) { From e9db9796e3b22a4bd27d4eb58150679bbf798ce7 Mon Sep 17 00:00:00 2001 From: Mike Koch Date: Wed, 11 Apr 2018 12:57:23 -0400 Subject: [PATCH 5/5] Default the IP address to the requester's IP address --- api/Controllers/Tickets/CustomerReplyController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/Controllers/Tickets/CustomerReplyController.php b/api/Controllers/Tickets/CustomerReplyController.php index 1ed31771..a52d1866 100644 --- a/api/Controllers/Tickets/CustomerReplyController.php +++ b/api/Controllers/Tickets/CustomerReplyController.php @@ -23,6 +23,10 @@ class CustomerReplyController extends \BaseClass { $createReplyByCustomerModel->hasHtml = Helpers::safeArrayGet($jsonRequest, 'html'); $createReplyByCustomerModel->ipAddress = Helpers::safeArrayGet($jsonRequest, 'ip'); + if ($createReplyByCustomerModel->ipAddress === null) { + $createReplyByCustomerModel->ipAddress = hesk_getClientIP(); + } + /* @var $modsForHeskSettingsGateway ModsForHeskSettingsGateway */ $modsForHeskSettingsGateway = $applicationContext->get(ModsForHeskSettingsGateway::clazz()); $modsForHesk_settings = $modsForHeskSettingsGateway->getAllSettings($hesk_settings);