This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);
namespace MailPoet\NewsletterTemplates;
if (!defined('ABSPATH')) exit;
use MailPoet\WP\Functions as WPFunctions;
class BrandStyles {
/** @var WPFunctions */
private $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
public function isAvailable(): bool {
return $this->wp->wpIsBlockTheme();
}
}
@@ -0,0 +1,124 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\NewsletterTemplates;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterTemplateEntity;
/**
* @extends Repository<NewsletterTemplateEntity>
*/
class NewsletterTemplatesRepository extends Repository {
const RECENTLY_SENT_CATEGORIES = '["recent"]';
const RECENTLY_SENT_COUNT = 12;
protected function getEntityClassName() {
return NewsletterTemplateEntity::class;
}
/**
* @return NewsletterTemplateEntity[]
*/
public function findAllForListing(): array {
return $this->doctrineRepository->createQueryBuilder('nt')
->select('PARTIAL nt.{id,categories,thumbnail,name,readonly}')
->addOrderBy('nt.readonly', 'ASC')
->addOrderBy('nt.createdAt', 'DESC')
->addOrderBy('nt.id', 'DESC')
->getQuery()
->getResult();
}
public function createOrUpdate(array $data): NewsletterTemplateEntity {
$template = !empty($data['newsletter_id'])
? $this->findOneBy(['newsletter' => (int)$data['newsletter_id']])
: null;
if (!$template) {
$template = new NewsletterTemplateEntity($data['name'] ?? '');
$this->entityManager->persist($template);
}
if (isset($data['newsletter_id'])) {
$template->setNewsletter($this->entityManager->getReference(NewsletterEntity::class, (int)$data['newsletter_id']));
}
if (isset($data['name'])) {
$template->setName($data['name']);
}
if (isset($data['thumbnail'])) {
// Backward compatibility for importing templates exported from older versions
if (strpos($data['thumbnail'], 'data:image') === 0) {
$data['thumbnail_data'] = $data['thumbnail'];
} else {
$template->setThumbnail($data['thumbnail']);
}
}
if (isset($data['thumbnail_data'])) {
$template->setThumbnailData($data['thumbnail_data']);
}
if (isset($data['body'])) {
$template->setBody(json_decode($data['body'], true));
}
if (isset($data['categories'])) {
$template->setCategories($data['categories']);
}
$this->entityManager->flush();
return $template;
}
public function cleanRecentlySent() {
// fetch 'RECENTLY_SENT_COUNT' of most recent template IDs in 'RECENTLY_SENT_CATEGORIES'
$recentIds = $this->doctrineRepository->createQueryBuilder('nt')
->select('nt.id')
->where('nt.categories = :categories')
->setParameter('categories', self::RECENTLY_SENT_CATEGORIES)
->orderBy('nt.id', 'DESC')
->setMaxResults(self::RECENTLY_SENT_COUNT)
->getQuery()
->getResult();
// delete all 'RECENTLY_SENT_CATEGORIES' templates except the latest ones selected above
$this->entityManager->createQueryBuilder()
->delete(NewsletterTemplateEntity::class, 'nt')
->where('nt.categories = :categories')
->andWhere('nt.id NOT IN (:recentIds)')
->setParameter('categories', self::RECENTLY_SENT_CATEGORIES)
->setParameter('recentIds', array_column($recentIds, 'id'))
->getQuery()
->execute();
// delete was done via DQL, make sure the entities are also detached from the entity manager
$this->detachAll(function (NewsletterTemplateEntity $entity) use ($recentIds) {
return $entity->getCategories() === self::RECENTLY_SENT_CATEGORIES && !in_array($entity->getId(), $recentIds, true);
});
}
public function getRecentlySentCount(): int {
return (int)$this->doctrineRepository->createQueryBuilder('nt')
->select('COUNT(nt.id)')
->where('nt.categories = :categories')
->setParameter('categories', self::RECENTLY_SENT_CATEGORIES)
->getQuery()
->getSingleScalarResult();
}
public function getIdsOfEditableTemplates(): array {
$result = $this->doctrineRepository->createQueryBuilder('nt')
->select('nt.id')
->where('nt.readonly = :readonly')
->setParameter('readonly', false)
->getQuery()
->getArrayResult();
return array_column($result, 'id');
}
}
@@ -0,0 +1,88 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\NewsletterTemplates;
if (!defined('ABSPATH')) exit;
use MailPoet\WP\Functions as WPFunctions;
class TemplateImageLoader {
const TIMEOUT = 30; // seconds
/** @var WPFunctions */
private $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
/**
* Acts as a proxy for html2canvas
*/
public function loadExternalImage(string $url) {
if (!$this->isUrlAllowed($url)) {
// URL not allowed
return false;
}
$image = $this->downloadUrl($url);
if ($this->wp->isWpError($image)) {
// Failed to load the image
return false;
}
$mime = $this->wp->wpGetImageMime($image);
if (!$this->isTypeAllowed($image, $mime)) {
// Wrong file type
@unlink($image);
return false;
}
header('Content-Type: ' . $mime);
readfile($image);
@unlink($image);
return true;
}
protected function downloadUrl($url) {
require_once ABSPATH . '/wp-admin/includes/file.php';
return download_url($url, self::TIMEOUT);
}
private function isUrlAllowed($url) {
$urlParts = $this->wp->wpParseUrl($url);
$allowedExtensions = ['gif', 'png', 'jpg', 'jpeg'];
if (
!isset($urlParts['path'])
|| !preg_match('/\.(' . join('|', $allowedExtensions) . ')$/i', $urlParts['path'])
) {
return false;
}
/** @var string[] */
$allowedUrls = (array)$this->wp->applyFilters('mailpoet_template_image_allowed_urls', [
'https://ps.w.org/mailpoet/assets/newsletter-templates/',
]);
foreach ($allowedUrls as $allowedUrl) {
$allowedUrlParts = $this->wp->wpParseUrl($allowedUrl);
if (
isset($urlParts['host'], $urlParts['scheme'])
&& isset($allowedUrlParts['host'], $allowedUrlParts['scheme'], $allowedUrlParts['path'])
&& $urlParts['host'] === $allowedUrlParts['host']
&& $urlParts['scheme'] === $allowedUrlParts['scheme']
&& strpos($urlParts['path'], $allowedUrlParts['path']) === 0
) {
return true;
}
}
return false;
}
private function isTypeAllowed($image, $mime) {
$allowedMimeTypes = [
'image/gif',
'image/jpeg',
'image/png',
];
return $mime && in_array($mime, $allowedMimeTypes);
}
}
@@ -0,0 +1,128 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\NewsletterTemplates;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Env;
use MailPoet\Entities\NewsletterTemplateEntity;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
class ThumbnailSaver {
const THUMBNAIL_DIRECTORY = 'newsletter_thumbnails';
const IMAGE_QUALITY = 80;
/** @var NewsletterTemplatesRepository */
private $repository;
/** @var WPFunctions */
private $wp;
/** @var string */
private $baseDirectory;
/** @var string */
private $baseUrl;
public function __construct(
NewsletterTemplatesRepository $repository,
WPFunctions $wp
) {
$this->repository = $repository;
$this->wp = $wp;
$this->baseDirectory = Env::$tempPath;
$this->baseUrl = Env::$tempUrl;
}
public function ensureTemplateThumbnailsForAll() {
$templateIds = $this->repository->getIdsOfEditableTemplates();
foreach ($templateIds as $templateId) {
$template = $this->repository->findOneById((int)$templateId);
if (!$template) continue;
$this->ensureTemplateThumbnailFile($template);
// Remove template entity from memory after it was processed
$this->repository->detach($template);
unset($template);
}
}
public function ensureTemplateThumbnailFile(NewsletterTemplateEntity $template): NewsletterTemplateEntity {
if ($template->getReadonly()) {
return $template;
}
$thumbnailUrl = $template->getThumbnail();
$savedFilename = null;
$savedBaseUrl = null;
if ($thumbnailUrl && strpos($thumbnailUrl, self::THUMBNAIL_DIRECTORY) !== false) {
[$savedBaseUrl, $savedFilename] = explode('/' . self::THUMBNAIL_DIRECTORY . '/', $thumbnailUrl ?? '');
}
$file = $this->baseDirectory . '/' . self::THUMBNAIL_DIRECTORY . '/' . $savedFilename;
if (!$savedFilename || !file_exists($file)) {
$this->saveTemplateImage($template);
}
// File might exist but domain was changed
$thumbnailUrl = $template->getThumbnail();
if ($savedBaseUrl && $savedBaseUrl !== $this->baseUrl && $thumbnailUrl) {
$template->setThumbnail(str_replace($savedBaseUrl, $this->baseUrl, $thumbnailUrl));
}
return $template;
}
private function saveTemplateImage(NewsletterTemplateEntity $template): void {
$data = $template->getThumbnailData();
if (!$data) {
return;
}
// Check that data contains Base 64 encoded jpeg
if (strpos($data, 'data:image/jpeg;base64') !== 0) {
return;
}
$thumbNailsDirectory = $this->baseDirectory . '/' . self::THUMBNAIL_DIRECTORY;
if (!file_exists($thumbNailsDirectory)) {
$this->wp->wpMkdirP($thumbNailsDirectory);
}
$file = $thumbNailsDirectory . '/' . Security::generateHash(16) . '_template_' . $template->getId() . '.jpg';
// Save the original quality image to a file and update DB record
if (!$this->saveBase64AsImageFile($file, $data)) {
return;
}
$url = str_replace($this->baseDirectory, $this->baseUrl, $file);
$template->setThumbnail($url);
$this->repository->flush();
// It is important that compression happens after the url was saved to DB.
// For some large files there is a risk that compression (if done using GD library) may fail due hitting memory limit.
// This way if the error occures the url is already saved and next time (e.g. next cron run) the image will be skipped
// and the previously saved original quality image used
$this->compressImage($file);
}
private function compressImage(string $file): bool {
$editor = $this->wp->wpGetImageEditor($file);
if ($editor instanceof \WP_Error) {
return false;
}
$result = $editor->set_quality(self::IMAGE_QUALITY);
if ($result instanceof \WP_Error) {
return false;
}
$result = $editor->save($file);
if ($result instanceof \WP_Error) {
return false;
}
unset($editor);
return true;
}
/**
* Simply saves base64 to a file without any compression
* @return bool
*/
private function saveBase64AsImageFile(string $file, string $data): bool {
return file_put_contents($file, file_get_contents($data)) !== false;
}
}
@@ -0,0 +1 @@
<?php