Skip to content

Commit 0c5b4f4

Browse files
committed
TemplateImageEmbedder
1 parent 3dcb90a commit 0c5b4f4

File tree

6 files changed

+310
-1
lines changed

6 files changed

+310
-1
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@
8282
"ezyang/htmlpurifier": "^4.19",
8383
"ext-libxml": "*",
8484
"ext-gd": "*",
85-
"ext-curl": "*"
85+
"ext-curl": "*",
86+
"ext-fileinfo": "*"
8687
},
8788
"require-dev": {
8889
"phpunit/phpunit": "^9.5",

config/parameters.yml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,7 @@ parameters:
118118

119119
phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%'
120120
env(PHPLIST_UPLOADIMAGES_DIR): 'images'
121+
phplist.editor_images_dir: '%%env(FCKIMAGES_DIR)%%'
122+
env(FCKIMAGES_DIR): 'uploadimages'
121123
phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%'
122124
env(PUBLIC_SCHEMA): 'http'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Common\Model;
6+
7+
enum ContentTransferEncoding: string
8+
{
9+
case SevenBit = '7bit';
10+
case EightBit = '8bit';
11+
case Base64 = 'base64';
12+
case QuotedPrintable = 'quoted-printable';
13+
case Binary = 'binary';
14+
}

src/Domain/Configuration/Model/ConfigOption.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ enum ConfigOption: string
3131
case OrganisationLogo = 'organisation_logo';
3232
case PoweredByImage = 'PoweredByImage';
3333
case PoweredByText = 'PoweredByText';
34+
case UploadImageRoot = 'uploadimageroot';
35+
case PageRoot = 'pageroot';
3436
}

src/Domain/Messaging/Repository/TemplateImageRepository.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,15 @@ public function findById(int $id): ?TemplateImage
3232
->getQuery()
3333
->getOneOrNullResult();
3434
}
35+
36+
public function findByTemplateIdAndFilename(int $templateId, string $filename): ?TemplateImage
37+
{
38+
return $this->createQueryBuilder('ti')
39+
->andWhere('IDENTITY(ti.template) = :templateId')
40+
->setParameter('templateId', $templateId)
41+
->andWhere('ti.filename IN (:filenames)')
42+
->setParameter('filenames', [$filename, basename($filename)])
43+
->getQuery()
44+
->getOneOrNullResult();
45+
}
3546
}
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Messaging\Service;
6+
7+
use Exception;
8+
use PhpList\Core\Domain\Common\ExternalImageService;
9+
use PhpList\Core\Domain\Common\Model\ContentTransferEncoding;
10+
use PhpList\Core\Domain\Configuration\Model\ConfigOption;
11+
use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager;
12+
use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;
13+
use PhpList\Core\Domain\Messaging\Model\TemplateImage;
14+
use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository;
15+
16+
class TemplateImageEmbedder
17+
{
18+
19+
/** @var array<string,string> extension => mime */
20+
private array $mimeMap = [
21+
'gif' => 'image/gif',
22+
'jpg' => 'image/jpeg',
23+
'jpeg' => 'image/jpeg',
24+
'jpe' => 'image/jpeg',
25+
'bmp' => 'image/bmp',
26+
'png' => 'image/png',
27+
'tif' => 'image/tiff',
28+
'tiff' => 'image/tiff',
29+
'swf' => 'application/x-shockwave-flash',
30+
];
31+
public array $attachment = [];
32+
33+
public function __construct(
34+
private readonly ConfigProvider $configProvider,
35+
private readonly ConfigManager $configManager,
36+
private readonly ExternalImageService $externalImageService,
37+
private readonly TemplateImageRepository $templateImageRepository,
38+
private readonly string $documentRoot,
39+
private readonly string $editorImagesDir,
40+
private readonly bool $embedExternalImages = false,
41+
private readonly bool $embedUploadedImages = false,
42+
private readonly ?string $uploadImagesDir = null,
43+
) {
44+
}
45+
46+
public function embedTemplateImages(
47+
string $html,
48+
int $templateId,
49+
int $messageId,
50+
): string {
51+
$extensions = implode('|', array_keys($this->mimeMap));
52+
$htmlImages = [];
53+
$filesystemImages = [];
54+
55+
//# addition for external images
56+
if ($this->embedExternalImages) {
57+
$externalImages = [];
58+
$matchedImages = [];
59+
$pattern = sprintf(
60+
'~="(https?://(?!%s)([^"]+\.(%s))([\\?/][^"]+)?)"~Ui',
61+
preg_quote($this->configProvider->getValue(ConfigOption::Website)),
62+
$extensions
63+
);
64+
preg_match_all($pattern, $html, $matchedImages);
65+
66+
for ($index = 0; $index < count($matchedImages[1]); ++$index) {
67+
if ($this->externalImageService->cache($matchedImages[1][$index], $messageId)) {
68+
$externalImages[] = $matchedImages[1][$index]
69+
. '~^~'
70+
. basename($matchedImages[2][$index])
71+
. '~^~'
72+
. strtolower($matchedImages[3][$index]);
73+
}
74+
}
75+
76+
if (!empty($externalImages)) {
77+
$externalImages = array_unique($externalImages);
78+
79+
for ($index = 0; $index < count($externalImages); ++$index) {
80+
$externalImage = explode('~^~', $externalImages[$index]);
81+
$image = $this->externalImageService->getFromCache($externalImage[0], $messageId);
82+
if ($image) {
83+
$contentType = $this->mimeMap[$externalImage[2]];
84+
$cid = $this->addHtmlImage($image, $externalImage[1], $contentType);
85+
86+
if (!empty($cid)) {
87+
$html = str_replace($externalImage[0], 'cid:' . $cid, $html);
88+
}
89+
}
90+
}
91+
}
92+
}
93+
//# end addition
94+
95+
preg_match_all('/"([^"]+\.('.$extensions.'))"/Ui', $html, $images);
96+
97+
for ($i = 0; $i < count($images[1]); ++$i) {
98+
if ($this->getTemplateImage($templateId, $images[1][$i]) !== null) {
99+
$htmlImages[] = $images[1][$i];
100+
$html = str_replace($images[1][$i], basename($images[1][$i]), $html);
101+
}
102+
//# addition for filesystem images
103+
if ($this->embedUploadedImages) {
104+
if ($this->filesystemImageExists($images[1][$i])) {
105+
$filesystemImages[] = $images[1][$i];
106+
$html = str_replace($images[1][$i], basename($images[1][$i]), $html);
107+
}
108+
}
109+
//# end addition
110+
}
111+
if (!empty($htmlImages)) {
112+
// If duplicate images are embedded, they may show up as attachments, so remove them.
113+
$htmlImages = array_unique($htmlImages);
114+
sort($htmlImages);
115+
for ($i = 0; $i < count($htmlImages); ++$i) {
116+
if ($image = $this->getTemplateImage($templateId, $htmlImages[$i])) {
117+
$content_type = $this->mimeMap[strtolower(substr($htmlImages[$i], strrpos($htmlImages[$i], '.') + 1))];
118+
$cid = $this->addHtmlImage($image->getData(), basename($htmlImages[$i]), $content_type);
119+
if (!empty($cid)) {
120+
$html = str_replace(basename($htmlImages[$i]), "cid:$cid", $html);
121+
}
122+
}
123+
}
124+
}
125+
//# addition for filesystem images
126+
if (!empty($filesystemImages)) {
127+
// If duplicate images are embedded, they may show up as attachments, so remove them.
128+
$filesystemImages = array_unique($filesystemImages);
129+
sort($filesystemImages);
130+
for ($i = 0; $i < count($filesystemImages); ++$i) {
131+
if ($image = $this->getFilesystemImage($filesystemImages[$i])) {
132+
$contentType = $this->mimeMap[strtolower(
133+
substr($filesystemImages[$i],
134+
strrpos($filesystemImages[$i], '.') + 1)
135+
)];
136+
$cid = $this->addHtmlImage($image, basename($filesystemImages[$i]), $contentType);
137+
if (!empty($cid)) {
138+
$html = str_replace(basename($filesystemImages[$i]), "cid:$cid", $html);
139+
}
140+
}
141+
}
142+
}
143+
144+
return $html;
145+
}
146+
147+
public function getFilesystemImage(string $filename): string
148+
{
149+
//# get the image contents
150+
$localFile = basename(urldecode($filename));
151+
if ($this->uploadImagesDir) {
152+
$imageRoot = $this->configProvider->getValue(ConfigOption::UploadImageRoot);
153+
if (is_file($imageRoot.$localFile)) {
154+
return base64_encode(file_get_contents($imageRoot.$localFile));
155+
} else {
156+
if (is_file($this->documentRoot.$localFile)) {
157+
//# save the document root to be able to retrieve the file later from commandline
158+
$this->configManager->create(
159+
ConfigOption::UploadImageRoot->value,
160+
$this->documentRoot,
161+
false,
162+
'string',
163+
);
164+
165+
return base64_encode(file_get_contents($this->documentRoot.$localFile));
166+
} elseif (is_file($this->documentRoot.'/'.$this->uploadImagesDir.'/image/'.$localFile)) {
167+
$this->configManager->create(
168+
ConfigOption::UploadImageRoot->value,
169+
$this->documentRoot.'/'.$this->uploadImagesDir.'/image/',
170+
false,
171+
'string',
172+
);
173+
174+
return base64_encode(file_get_contents($this->documentRoot.'/'.$this->uploadImagesDir.'/image/'.$localFile));
175+
} elseif (is_file($this->documentRoot.'/'.$this->uploadImagesDir.'/'.$localFile)) {
176+
$this->configManager->create(
177+
ConfigOption::UploadImageRoot->value,
178+
$this->documentRoot.'/'.$this->uploadImagesDir.'/',
179+
false,
180+
'string',
181+
);
182+
183+
return base64_encode(file_get_contents($this->documentRoot.'/'.$this->uploadImagesDir.'/'.$localFile));
184+
}
185+
}
186+
} elseif (is_file($this->documentRoot.$this->configProvider->getValue(ConfigOption::PageRoot).'/'.$this->editorImagesDir.'/'.$localFile)) {
187+
$elements = parse_url($filename);
188+
$localFile = basename($elements['path']);
189+
190+
return base64_encode(file_get_contents($this->documentRoot.$this->configProvider->getValue(ConfigOption::PageRoot).'/'.$this->editorImagesDir.'/'.$localFile));
191+
} elseif (is_file($this->documentRoot.$this->configProvider->getValue(ConfigOption::PageRoot).'/'.$this->editorImagesDir.'/image/'.$localFile)) {
192+
return base64_encode(file_get_contents($this->documentRoot.$this->configProvider->getValue(ConfigOption::PageRoot).'/'.$this->editorImagesDir.'/image/'.$localFile));
193+
} elseif (is_file('../'.$this->editorImagesDir.'/'.$localFile)) {
194+
return base64_encode(file_get_contents('../'.$this->editorImagesDir.'/'.$localFile));
195+
} elseif (is_file('../'.$this->editorImagesDir.'/image/'.$localFile)) {
196+
return base64_encode(file_get_contents('../'.$this->editorImagesDir.'/image/'.$localFile));
197+
}
198+
199+
return '';
200+
}
201+
202+
public function addHtmlImage(string $contents, $name = '', $content_type = 'application/octet-stream'): string
203+
{
204+
$cid = bin2hex(random_bytes(16));
205+
$this->addStringEmbeddedImage(base64_decode($contents), $cid, $name, 'base64', $content_type);
206+
207+
return $cid;
208+
}
209+
210+
public function addStringEmbeddedImage(
211+
$string,
212+
$cid,
213+
$name = '',
214+
$encoding = 'base64',
215+
$type = '',
216+
$disposition = 'inline'
217+
): bool {
218+
try {
219+
//If a MIME type is not specified, try to work it out from the name
220+
if ('' === $type && !empty($name)) {
221+
$type = mime_content_type($name);
222+
}
223+
224+
if (ContentTransferEncoding::tryFrom($encoding) === null) {
225+
throw new Exception('encoding ' . $encoding);
226+
}
227+
228+
//Append to $attachment array
229+
$this->attachment[] = [
230+
0 => $string,
231+
1 => $name,
232+
2 => $name,
233+
3 => $encoding,
234+
4 => $type,
235+
5 => true, //isStringAttachment
236+
6 => $disposition,
237+
7 => $cid,
238+
];
239+
} catch (Exception $exc) {
240+
return false;
241+
}
242+
243+
return true;
244+
}
245+
246+
public function filesystemImageExists($filename): bool
247+
{
248+
//# find the image referenced and see if it's on the server
249+
$imageRoot = $this->configProvider->getValue(ConfigOption::UploadImageRoot);
250+
251+
$elements = parse_url($filename);
252+
$localFile = basename($elements['path']);
253+
$localFile = urldecode($localFile);
254+
255+
if ($this->uploadImagesDir) {
256+
return
257+
is_file($this->documentRoot.'/'.$this->uploadImagesDir.'/image/'.$localFile)
258+
|| is_file($this->documentRoot.'/'.$this->uploadImagesDir.'/'.$localFile)
259+
//# commandline
260+
|| is_file($imageRoot.'/'.$localFile);
261+
} else {
262+
return
263+
is_file($this->documentRoot.$this->configProvider->getValue(ConfigOption::PageRoot).'/'.$this->editorImagesDir.'/image/'.$localFile)
264+
|| is_file($this->documentRoot.$this->configProvider->getValue(ConfigOption::PageRoot).'/'.$this->editorImagesDir.'/'.$localFile)
265+
//# commandline
266+
|| is_file('../'.$this->editorImagesDir.'/image/'.$localFile)
267+
|| is_file('../'.$this->editorImagesDir.'/'.$localFile);
268+
}
269+
}
270+
271+
public function getTemplateImage($templateId, $filename): ?TemplateImage
272+
{
273+
if (basename($filename) === 'powerphplist.png' || str_starts_with($filename, 'ORGANISATIONLOGO')) {
274+
$templateId = 0;
275+
}
276+
277+
return $this->templateImageRepository->findByTemplateIdAndFilename($templateId, $filename);
278+
}
279+
}

0 commit comments

Comments
 (0)