init
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
+124
@@ -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
|
||||
Reference in New Issue
Block a user