No Description

Links.php 8.3KB

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