Skip to content

Commit 3dcb90a

Browse files
committed
ExternalImageCacher
1 parent 7e9bab2 commit 3dcb90a

File tree

3 files changed

+187
-1
lines changed

3 files changed

+187
-1
lines changed

composer.json

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

config/parameters.yml.dist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ parameters:
107107
env(USE_AMAZONSES): 0
108108
messaging.embed_external_images: '%%env(EMBEDEXTERNALIMAGES)%%'
109109
env(EMBEDEXTERNALIMAGES): 0
110+
messaging.embed_uploaded_images: '%%env(EMBEDUPLOADIMAGES)%%'
111+
env(EMBEDUPLOADIMAGES): 0
112+
messaging.external_image_max_age: '%%env(EXTERNALIMAGE_MAXAGE)%%'
113+
env(EXTERNALIMAGE_MAXAGE): 0
114+
messaging.external_image_timeout: '%%env(EXTERNALIMAGE_TIMEOUT)%%'
115+
env(EXTERNALIMAGE_TIMEOUT): 30
116+
messaging.external_image_max_size: '%%env(EXTERNALIMAGE_MAXSIZE)%%'
117+
env(EXTERNALIMAGE_MAXSIZE): 2048
110118

111119
phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%'
112120
env(PHPLIST_UPLOADIMAGES_DIR): 'images'
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Common;
6+
7+
use PhpList\Core\Domain\Configuration\Model\ConfigOption;
8+
use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;
9+
10+
class ExternalImageService
11+
{
12+
private string $externalCacheDir;
13+
14+
public function __construct(
15+
private readonly ConfigProvider $configProvider,
16+
private readonly string $tempDir,
17+
private readonly int $externalImageMaxAge,
18+
private readonly int $externalImageMaxSize,
19+
private readonly ?int $externalImageTimeout = 30,
20+
) {
21+
$this->externalCacheDir = $this->tempDir . '/external_cache';
22+
}
23+
24+
public function getFromCache(string $filename, int $messageId): ?string
25+
{
26+
$cacheFile = $this->generateLocalFileName($filename, $messageId);
27+
28+
if (!is_file($cacheFile) || filesize($cacheFile) <= 64) {
29+
return null;
30+
}
31+
32+
$content = file_get_contents($cacheFile);
33+
if ($content === false) {
34+
return null;
35+
}
36+
37+
return base64_encode($content);
38+
}
39+
40+
public function cache($filename, $messageId): bool
41+
{
42+
if (
43+
!(str_starts_with($filename, 'http'))
44+
|| str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/')
45+
) {
46+
return false;
47+
}
48+
49+
if (!file_exists($this->externalCacheDir)) {
50+
@mkdir($this->externalCacheDir);
51+
}
52+
53+
if (!file_exists($this->externalCacheDir) || !is_writable($this->externalCacheDir)) {
54+
return false;
55+
}
56+
57+
$this->removeOldFilesInCache();
58+
59+
$cacheFileName = $this->generateLocalFileName($filename, $messageId);
60+
61+
if (!file_exists($cacheFileName)) {
62+
$cacheFileContent = null;
63+
64+
if (function_exists('curl_init')) {
65+
$cacheFileContent = $this->downloadUsingCurl($filename);
66+
}
67+
68+
if ($cacheFileContent === null) {
69+
$cacheFileContent = $this->downloadUsingFileGetContent($filename);
70+
}
71+
72+
if ($this->externalImageMaxSize && (strlen($cacheFileContent) > $this->externalImageMaxSize)) {
73+
$cacheFileContent = 'MAX_SIZE';
74+
}
75+
76+
$cacheFileHandle = @fopen($cacheFileName, 'wb');
77+
if ($cacheFileHandle !== false) {
78+
if (flock($cacheFileHandle, LOCK_EX)) {
79+
fwrite($cacheFileHandle, $cacheFileContent);
80+
fflush($cacheFileHandle);
81+
flock($cacheFileHandle, LOCK_UN);
82+
}
83+
fclose($cacheFileHandle);
84+
}
85+
}
86+
87+
if (file_exists($cacheFileName) && (@filesize($cacheFileName) > 64)) {
88+
return true;
89+
}
90+
91+
return false;
92+
}
93+
94+
private function removeOldFilesInCache(): void
95+
{
96+
$extCacheDirHandle = @opendir($this->externalCacheDir);
97+
if (!$this->externalImageMaxAge || !$extCacheDirHandle) {
98+
return;
99+
}
100+
101+
while (($cacheFile = @readdir($extCacheDirHandle)) !== false) {
102+
// todo: make sure that this is what we need
103+
if (!str_starts_with($cacheFile, '.')) {
104+
$cacheFileMTime = @filemtime($this->externalCacheDir.'/'.$cacheFile);
105+
106+
if (
107+
is_numeric($cacheFileMTime)
108+
&& ($cacheFileMTime > 0)
109+
&& ((time() - $cacheFileMTime) > $this->externalImageMaxAge)
110+
) {
111+
@unlink($this->externalCacheDir.'/'.$cacheFile);
112+
}
113+
}
114+
}
115+
116+
@closedir($extCacheDirHandle);
117+
}
118+
119+
private function generateLocalFileName(string $filename, int $messageId): string
120+
{
121+
return $this->externalCacheDir
122+
. '/'
123+
. $messageId
124+
. '_'
125+
. preg_replace([ '~[\.][\.]+~Ui', '~[^\w\.]~Ui',], ['', '_'], $filename);
126+
}
127+
128+
private function downloadUsingCurl(string $filename): ?string
129+
{
130+
$cURLHandle = curl_init($filename);
131+
132+
if ($cURLHandle !== false) {
133+
curl_setopt($cURLHandle, CURLOPT_HTTPGET, true);
134+
curl_setopt($cURLHandle, CURLOPT_HEADER, 0);
135+
curl_setopt($cURLHandle, CURLOPT_RETURNTRANSFER, true);
136+
curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout);
137+
curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true);
138+
curl_setopt($cURLHandle, CURLOPT_MAXREDIRS, 10);
139+
curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, false);
140+
curl_setopt($cURLHandle, CURLOPT_FAILONERROR, true);
141+
142+
$cacheFileContent = curl_exec($cURLHandle);
143+
144+
$cURLErrNo = curl_errno($cURLHandle);
145+
$cURLInfo = curl_getinfo($cURLHandle);
146+
147+
curl_close($cURLHandle);
148+
149+
if ($cURLErrNo != 0) {
150+
$cacheFileContent = 'CURL_ERROR_' . $cURLErrNo;
151+
}
152+
if ($cURLInfo['http_code'] >= 400) {
153+
$cacheFileContent = 'HTTP_CODE_' . $cURLInfo['http_code'];
154+
}
155+
}
156+
157+
return $cacheFileContent ?? null;
158+
}
159+
160+
private function downloadUsingFileGetContent(string $filename): string
161+
{
162+
$remoteURLContext = stream_context_create([
163+
'http' => [
164+
'method' => 'GET',
165+
'timeout' => $this->externalImageTimeout,
166+
'max_redirects' => '10',
167+
]
168+
]);
169+
170+
$cacheFileContent = file_get_contents($filename, false, $remoteURLContext);
171+
if ($cacheFileContent === false) {
172+
$cacheFileContent = 'FGC_ERROR';
173+
}
174+
175+
return $cacheFileContent;
176+
}
177+
}

0 commit comments

Comments
 (0)