Links.php
303 lines
| 1 | <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing |
| 2 | |
| 3 | namespace MailPoet\Newsletter\Links; |
| 4 | |
| 5 | if (!defined('ABSPATH')) exit; |
| 6 | |
| 7 | |
| 8 | use MailPoet\Cron\Workers\StatsNotifications\NewsletterLinkRepository; |
| 9 | use MailPoet\DI\ContainerWrapper; |
| 10 | use MailPoet\Entities\NewsletterEntity; |
| 11 | use MailPoet\Entities\NewsletterLinkEntity; |
| 12 | use MailPoet\Entities\SendingQueueEntity; |
| 13 | use MailPoet\InvalidStateException; |
| 14 | use MailPoet\Newsletter\NewslettersRepository; |
| 15 | use MailPoet\Newsletter\Sending\SendingQueuesRepository; |
| 16 | use MailPoet\Newsletter\Shortcodes\Categories\Link; |
| 17 | use MailPoet\Newsletter\Shortcodes\Shortcodes; |
| 18 | use MailPoet\Router\Endpoints\Track as TrackEndpoint; |
| 19 | use MailPoet\Router\Router; |
| 20 | use MailPoet\Subscribers\LinkTokens; |
| 21 | use MailPoet\Subscribers\SubscribersRepository; |
| 22 | use MailPoet\Util\Helpers; |
| 23 | use MailPoet\Util\pQuery\pQuery as DomParser; |
| 24 | use MailPoet\Util\Security; |
| 25 | |
| 26 | class Links { |
| 27 | const DATA_TAG_CLICK = '[mailpoet_click_data]'; |
| 28 | const DATA_TAG_OPEN = '[mailpoet_open_data]'; |
| 29 | const LINK_TYPE_SHORTCODE = 'shortcode'; |
| 30 | const LINK_TYPE_URL = 'link'; |
| 31 | |
| 32 | /** @var LinkTokens */ |
| 33 | private $linkTokens; |
| 34 | |
| 35 | /** @var SubscribersRepository */ |
| 36 | private $subscribersRepository; |
| 37 | |
| 38 | /** @var NewsletterLinkRepository */ |
| 39 | private $newsletterLinkRepository; |
| 40 | |
| 41 | /** @var NewslettersRepository */ |
| 42 | private $newslettersRepository; |
| 43 | |
| 44 | /** @var SendingQueuesRepository */ |
| 45 | private $sendingQueueRepository; |
| 46 | |
| 47 | public function __construct( |
| 48 | LinkTokens $linkTokens, |
| 49 | SubscribersRepository $subscribersRepository, |
| 50 | NewsletterLinkRepository $newsletterLinkRepository, |
| 51 | NewslettersRepository $newslettersRepository, |
| 52 | SendingQueuesRepository $sendingQueuesRepository |
| 53 | ) { |
| 54 | $this->linkTokens = $linkTokens; |
| 55 | $this->subscribersRepository = $subscribersRepository; |
| 56 | $this->newsletterLinkRepository = $newsletterLinkRepository; |
| 57 | $this->newslettersRepository = $newslettersRepository; |
| 58 | $this->sendingQueueRepository = $sendingQueuesRepository; |
| 59 | } |
| 60 | |
| 61 | public function process($content, $newsletterId, $queueId) { |
| 62 | $extractedLinks = $this->extract($content); |
| 63 | $savedLinks = $this->load($newsletterId, $queueId); |
| 64 | $processedLinks = $this->hash($extractedLinks, $savedLinks); |
| 65 | return $this->replace($content, $processedLinks); |
| 66 | } |
| 67 | |
| 68 | public function extract($content) { |
| 69 | $extractedLinks = []; |
| 70 | // extract link shortcodes |
| 71 | /** @var Shortcodes $shortcodes */ |
| 72 | $shortcodes = ContainerWrapper::getInstance()->get(Shortcodes::class); |
| 73 | $shortcodes = $shortcodes->extract( |
| 74 | $content, |
| 75 | $categories = [Link::CATEGORY_NAME] |
| 76 | ); |
| 77 | if ($shortcodes) { |
| 78 | $extractedLinks = array_map(function($shortcode) { |
| 79 | return [ |
| 80 | 'type' => Links::LINK_TYPE_SHORTCODE, |
| 81 | 'link' => $shortcode, |
| 82 | ]; |
| 83 | }, $shortcodes); |
| 84 | } |
| 85 | // extract HTML anchor tags |
| 86 | $DOM = DomParser::parseStr($content); |
| 87 | foreach ($DOM->query('a') as $link) { |
| 88 | if (!$link->href) continue; |
| 89 | $extractedLinks[] = [ |
| 90 | 'type' => self::LINK_TYPE_URL, |
| 91 | 'link' => $link->href, |
| 92 | ]; |
| 93 | } |
| 94 | return array_unique($extractedLinks, SORT_REGULAR); |
| 95 | } |
| 96 | |
| 97 | public function replace($content, $processedLinks) { |
| 98 | // replace HTML anchor tags |
| 99 | $DOM = DomParser::parseStr($content); |
| 100 | foreach ($DOM->query('a') as $link) { |
| 101 | $linkToReplace = $link->href; |
| 102 | $replacementLink = (!empty($processedLinks[$linkToReplace]['processed_link'])) ? |
| 103 | $processedLinks[$linkToReplace]['processed_link'] : |
| 104 | null; |
| 105 | if (!$replacementLink) continue; |
| 106 | $link->setAttribute('href', $replacementLink); |
| 107 | } |
| 108 | $content = $DOM->__toString(); |
| 109 | // replace link shortcodes and markdown links |
| 110 | foreach ($processedLinks as $processedLink) { |
| 111 | $linkToReplace = $processedLink['link']; |
| 112 | $replacementLink = $processedLink['processed_link']; |
| 113 | if ($processedLink['type'] == self::LINK_TYPE_SHORTCODE) { |
| 114 | $content = str_replace($linkToReplace, $replacementLink, (string)$content); |
| 115 | } |
| 116 | $content = preg_replace( |
| 117 | '/\[(.*?)\](\(' . preg_quote($linkToReplace, '/') . '\))/', |
| 118 | '[$1](' . $replacementLink . ')', |
| 119 | (string)$content |
| 120 | ); |
| 121 | } |
| 122 | return [ |
| 123 | $content, |
| 124 | array_values($processedLinks), |
| 125 | ]; |
| 126 | } |
| 127 | |
| 128 | public function replaceSubscriberData( |
| 129 | $subscriberId, |
| 130 | $queueId, |
| 131 | $content, |
| 132 | $preview = false |
| 133 | ) { |
| 134 | // match data tags |
| 135 | $subscriber = $this->subscribersRepository->findOneById($subscriberId); |
| 136 | if (!$subscriber) { |
| 137 | throw new InvalidStateException('Subscriber not found for link replacement'); |
| 138 | } |
| 139 | preg_match_all($this->getLinkRegex(), $content, $matches); |
| 140 | foreach ($matches[1] as $index => $match) { |
| 141 | $hash = null; |
| 142 | if (preg_match('/-/', $match)) { |
| 143 | [, $hash] = explode('-', $match); |
| 144 | } |
| 145 | $data = $this->createUrlDataObject( |
| 146 | $subscriber->getId(), |
| 147 | $this->linkTokens->getToken($subscriber), |
| 148 | $queueId, |
| 149 | $hash, |
| 150 | $preview |
| 151 | ); |
| 152 | $routerAction = ($matches[2][$index] === self::DATA_TAG_CLICK) ? |
| 153 | TrackEndpoint::ACTION_CLICK : |
| 154 | TrackEndpoint::ACTION_OPEN; |
| 155 | $link = Router::buildRequest( |
| 156 | TrackEndpoint::ENDPOINT, |
| 157 | $routerAction, |
| 158 | $data |
| 159 | ); |
| 160 | $content = str_replace($match, $link, $content); |
| 161 | } |
| 162 | return $content; |
| 163 | } |
| 164 | |
| 165 | public function save(array $links, $newsletterId, $queueId) { |
| 166 | foreach ($links as $link) { |
| 167 | if (isset($link['id'])) { |
| 168 | continue; |
| 169 | } |
| 170 | |
| 171 | if (empty($link['hash']) || empty($link['link'])) { |
| 172 | continue; |
| 173 | } |
| 174 | |
| 175 | $newsletter = $this->newslettersRepository->getReference($newsletterId); |
| 176 | $sendingQueue = $this->sendingQueueRepository->getReference($queueId); |
| 177 | |
| 178 | if (!$newsletter instanceof NewsletterEntity || !$sendingQueue instanceof SendingQueueEntity) { |
| 179 | continue; |
| 180 | } |
| 181 | |
| 182 | $newsletterLink = new NewsletterLinkEntity($newsletter, $sendingQueue, $link['link'], $link['hash']); |
| 183 | $this->newsletterLinkRepository->persist($newsletterLink); |
| 184 | } |
| 185 | |
| 186 | $this->newsletterLinkRepository->flush(); |
| 187 | } |
| 188 | |
| 189 | public function ensureInstantUnsubscribeLink(array $processedLinks) { |
| 190 | if ( |
| 191 | in_array( |
| 192 | NewsletterLinkEntity::INSTANT_UNSUBSCRIBE_LINK_SHORT_CODE, |
| 193 | array_column($processedLinks, 'link') |
| 194 | ) |
| 195 | ) { |
| 196 | return $processedLinks; |
| 197 | } |
| 198 | $processedLinks[] = $this->hashLink( |
| 199 | NewsletterLinkEntity::INSTANT_UNSUBSCRIBE_LINK_SHORT_CODE, |
| 200 | Links::LINK_TYPE_SHORTCODE |
| 201 | ); |
| 202 | return $processedLinks; |
| 203 | } |
| 204 | |
| 205 | public function convertHashedLinksToShortcodesAndUrls($content, $queueId, $convertAll = false) { |
| 206 | preg_match_all($this->getLinkRegex(), $content, $links); |
| 207 | $links = array_unique(Helpers::flattenArray($links)); |
| 208 | foreach ($links as $link) { |
| 209 | $linkHash = explode('-', $link); |
| 210 | |
| 211 | if (!isset($linkHash[1])) { |
| 212 | continue; |
| 213 | } |
| 214 | |
| 215 | $newsletterLink = $this->newsletterLinkRepository->findOneBy(['hash' => $linkHash[1], 'queue' => $queueId]); |
| 216 | |
| 217 | // convert either only link shortcodes or all hashes links if "convert all" |
| 218 | // option is specified |
| 219 | if ( |
| 220 | ($newsletterLink instanceof NewsletterLinkEntity) && |
| 221 | (preg_match('/\[link:/', $newsletterLink->getUrl()) || $convertAll) |
| 222 | ) { |
| 223 | $content = str_replace($link, $newsletterLink->getUrl(), $content); |
| 224 | } |
| 225 | } |
| 226 | return $content; |
| 227 | } |
| 228 | |
| 229 | public function getLinkRegex() { |
| 230 | return sprintf( |
| 231 | '/((%s|%s)(?:-\w+)?)/', |
| 232 | preg_quote(self::DATA_TAG_CLICK), |
| 233 | preg_quote(self::DATA_TAG_OPEN) |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | public function createUrlDataObject( |
| 238 | $subscriberId, $subscriberLinkToken, $queueId, $linkHash, $preview |
| 239 | ) { |
| 240 | return [ |
| 241 | (string)$subscriberId, |
| 242 | $subscriberLinkToken, |
| 243 | (string)$queueId, |
| 244 | $linkHash, |
| 245 | $preview, |
| 246 | ]; |
| 247 | } |
| 248 | |
| 249 | public function transformUrlDataObject($data) { |
| 250 | reset($data); |
| 251 | if (!is_int(key($data))) return $data; |
| 252 | $transformedData = []; |
| 253 | $transformedData['subscriber_id'] = (!empty($data[0])) ? $data[0] : false; |
| 254 | $transformedData['subscriber_token'] = (!empty($data[1])) ? $data[1] : false; |
| 255 | $transformedData['queue_id'] = (!empty($data[2])) ? $data[2] : false; |
| 256 | $transformedData['link_hash'] = (!empty($data[3])) ? $data[3] : false; |
| 257 | $transformedData['preview'] = (!empty($data[4])) ? $data[4] : false; |
| 258 | return $transformedData; |
| 259 | } |
| 260 | |
| 261 | private static function hashLink($link, $type) { |
| 262 | $hash = Security::generateHash(); |
| 263 | return [ |
| 264 | 'type' => $type, |
| 265 | 'hash' => $hash, |
| 266 | 'link' => $link, |
| 267 | // replace link with a temporary data tag + hash |
| 268 | // it will be further replaced with the proper track API URL during sending |
| 269 | 'processed_link' => self::DATA_TAG_CLICK . '-' . $hash, |
| 270 | ]; |
| 271 | } |
| 272 | |
| 273 | private function hash($extractedLinks, $savedLinks) { |
| 274 | $processedLinks = array_map(function($link) { |
| 275 | $link['type'] = Links::LINK_TYPE_URL; |
| 276 | $link['link'] = $link['url']; |
| 277 | $link['processed_link'] = self::DATA_TAG_CLICK . '-' . $link['hash']; |
| 278 | return $link; |
| 279 | }, $savedLinks); |
| 280 | foreach ($extractedLinks as $extractedLink) { |
| 281 | $link = $extractedLink['link']; |
| 282 | if (array_key_exists($link, $processedLinks)) |
| 283 | continue; |
| 284 | // Use URL as a key to map between extracted and processed links |
| 285 | // regardless of their sequential position (useful for link skips etc.) |
| 286 | $processedLinks[$link] = $this->hashLink($link, $extractedLink['type']); |
| 287 | } |
| 288 | return $processedLinks; |
| 289 | } |
| 290 | |
| 291 | private function load($newsletterId, $queueId) { |
| 292 | $links = $this->newsletterLinkRepository->findBy( |
| 293 | ['newsletter' => $newsletterId, 'queue' => $queueId] |
| 294 | ); |
| 295 | |
| 296 | $savedLinks = []; |
| 297 | foreach ($links as $link) { |
| 298 | $savedLinks[$link->getUrl()] = $link->toArray(); |
| 299 | } |
| 300 | return $savedLinks; |
| 301 | } |
| 302 | } |
| 303 |