Skip to content
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@
"ext-imap": "*",
"tatevikgr/rss-feed": "dev-main",
"ext-pdo": "*",
"ezyang/htmlpurifier": "^4.19"
"ezyang/htmlpurifier": "^4.19",
"ext-libxml": "*",
"ext-gd": "*",
"ext-curl": "*",
"ext-fileinfo": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
Expand Down
35 changes: 34 additions & 1 deletion config/parameters.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ parameters:
env(DATABASE_PREFIX): 'phplist_'
list_table_prefix: '%%env(LIST_TABLE_PREFIX)%%'
env(LIST_TABLE_PREFIX): 'listattr_'
app.dev_version: '%%env(APP_DEV_VERSION)%%'
env(APP_DEV_VERSION): 0
app.dev_email: '%%env(APP_DEV_EMAIL)%%'
env(APP_DEV_EMAIL): 'dev@dev.com'
app.powered_by_phplist: '%%env(APP_POWERED_BY_PHPLIST)%%'
env(APP_POWERED_BY_PHPLIST): 0

# Email configuration
app.mailer_from: '%%env(MAILER_FROM)%%'
env(MAILER_FROM): 'noreply@phplist.com'
app.mailer_dsn: '%%env(MAILER_DSN)%%'
env(MAILER_DSN): 'null://null'
env(MAILER_DSN): 'null://null' # set local_domain on transport
app.confirmation_url: '%%env(CONFIRMATION_URL)%%'
env(CONFIRMATION_URL): 'https://example.com/subscriber/confirm/'
app.subscription_confirmation_url: '%%env(SUBSCRIPTION_CONFIRMATION_URL)%%'
Expand Down Expand Up @@ -89,3 +95,30 @@ parameters:
env(MESSAGING_MAX_PROCESS_TIME): '600'
messaging.max_mail_size: '%%env(MAX_MAILSIZE)%%'
env(MAX_MAILSIZE): '209715200'
messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%'
env(DEFAULT_MESSAGEAGE): '691200'
messaging.use_manual_text_part: '%%env(USE_MANUAL_TEXT_PART)%%'
env(USE_MANUAL_TEXT_PART): 0
messaging.blacklist_grace_time: '%%env(MESSAGING_BLACKLIST_GRACE_TIME)%%'
env(MESSAGING_BLACKLIST_GRACE_TIME):
messaging.google_sender_id: '%%env(GOOGLE_SENDERID)%%'
env(GOOGLE_SENDERID): ''
messaging.use_amazon_ses: '%%env(USE_AMAZONSES)%%'
env(USE_AMAZONSES): 0
messaging.embed_external_images: '%%env(EMBEDEXTERNALIMAGES)%%'
env(EMBEDEXTERNALIMAGES): 0
messaging.embed_uploaded_images: '%%env(EMBEDUPLOADIMAGES)%%'
env(EMBEDUPLOADIMAGES): 0
messaging.external_image_max_age: '%%env(EXTERNALIMAGE_MAXAGE)%%'
env(EXTERNALIMAGE_MAXAGE): 0
messaging.external_image_timeout: '%%env(EXTERNALIMAGE_TIMEOUT)%%'
env(EXTERNALIMAGE_TIMEOUT): 30
messaging.external_image_max_size: '%%env(EXTERNALIMAGE_MAXSIZE)%%'
env(EXTERNALIMAGE_MAXSIZE): 2048

phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%'
env(PHPLIST_UPLOADIMAGES_DIR): 'images'
phplist.editor_images_dir: '%%env(FCKIMAGES_DIR)%%'
env(FCKIMAGES_DIR): 'uploadimages'
phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%'
env(PUBLIC_SCHEMA): 'http'
11 changes: 5 additions & 6 deletions src/Domain/Analytics/Service/LinkTrackService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException;
use PhpList\Core\Domain\Analytics\Model\LinkTrack;
use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto;

class LinkTrackService
{
Expand Down Expand Up @@ -39,7 +38,7 @@ public function isExtractAndSaveLinksApplicable(): bool
* @return LinkTrack[] The saved LinkTrack entities
* @throws MissingMessageIdException
*/
public function extractAndSaveLinks(MessageContent $content, int $userId, ?int $messageId = null): array
public function extractAndSaveLinks(MessagePrecacheDto $content, int $userId, ?int $messageId = null): array
{
if (!$this->isExtractAndSaveLinksApplicable()) {
return [];
Expand All @@ -49,10 +48,10 @@ public function extractAndSaveLinks(MessageContent $content, int $userId, ?int $
throw new MissingMessageIdException();
}

$links = $this->extractLinksFromHtml($content->getText() ?? '');
$links = $this->extractLinksFromHtml($content->content ?? '');

if ($content->getFooter() !== null) {
$links = array_merge($links, $this->extractLinksFromHtml($content->getFooter()));
if ($content->htmlFooter) {
$links = array_merge($links, $this->extractLinksFromHtml($content->htmlFooter));
}

$links = array_unique($links);
Expand Down
177 changes: 177 additions & 0 deletions src/Domain/Common/ExternalImageService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Common;

use PhpList\Core\Domain\Configuration\Model\ConfigOption;
use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;

class ExternalImageService
{
private string $externalCacheDir;

public function __construct(
private readonly ConfigProvider $configProvider,
private readonly string $tempDir,
private readonly int $externalImageMaxAge,
private readonly int $externalImageMaxSize,
private readonly ?int $externalImageTimeout = 30,
) {
$this->externalCacheDir = $this->tempDir . '/external_cache';
}

public function getFromCache(string $filename, int $messageId): ?string
{
$cacheFile = $this->generateLocalFileName($filename, $messageId);

if (!is_file($cacheFile) || filesize($cacheFile) <= 64) {
return null;
}

$content = file_get_contents($cacheFile);
if ($content === false) {
return null;
}

return base64_encode($content);
}

public function cache($filename, $messageId): bool
{
if (
!(str_starts_with($filename, 'http'))
|| str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/')
) {
return false;
}

if (!file_exists($this->externalCacheDir)) {
@mkdir($this->externalCacheDir);
}

if (!file_exists($this->externalCacheDir) || !is_writable($this->externalCacheDir)) {
return false;
}

$this->removeOldFilesInCache();

$cacheFileName = $this->generateLocalFileName($filename, $messageId);

if (!file_exists($cacheFileName)) {
$cacheFileContent = null;

if (function_exists('curl_init')) {
$cacheFileContent = $this->downloadUsingCurl($filename);
}

if ($cacheFileContent === null) {
$cacheFileContent = $this->downloadUsingFileGetContent($filename);
}

if ($this->externalImageMaxSize && (strlen($cacheFileContent) > $this->externalImageMaxSize)) {
$cacheFileContent = 'MAX_SIZE';
}

$cacheFileHandle = @fopen($cacheFileName, 'wb');
if ($cacheFileHandle !== false) {
if (flock($cacheFileHandle, LOCK_EX)) {
fwrite($cacheFileHandle, $cacheFileContent);
fflush($cacheFileHandle);
flock($cacheFileHandle, LOCK_UN);
}
fclose($cacheFileHandle);
}
}

if (file_exists($cacheFileName) && (@filesize($cacheFileName) > 64)) {
return true;
}

return false;
}

private function removeOldFilesInCache(): void
{
$extCacheDirHandle = @opendir($this->externalCacheDir);
if (!$this->externalImageMaxAge || !$extCacheDirHandle) {
return;
}

while (($cacheFile = @readdir($extCacheDirHandle)) !== false) {
// todo: make sure that this is what we need
if (!str_starts_with($cacheFile, '.')) {
$cacheFileMTime = @filemtime($this->externalCacheDir.'/'.$cacheFile);

if (
is_numeric($cacheFileMTime)
&& ($cacheFileMTime > 0)
&& ((time() - $cacheFileMTime) > $this->externalImageMaxAge)
) {
@unlink($this->externalCacheDir.'/'.$cacheFile);
}
}
}

@closedir($extCacheDirHandle);
}

private function generateLocalFileName(string $filename, int $messageId): string
{
return $this->externalCacheDir
. '/'
. $messageId
. '_'
. preg_replace([ '~[\.][\.]+~Ui', '~[^\w\.]~Ui',], ['', '_'], $filename);
}

private function downloadUsingCurl(string $filename): ?string
{
$cURLHandle = curl_init($filename);

if ($cURLHandle !== false) {
curl_setopt($cURLHandle, CURLOPT_HTTPGET, true);
curl_setopt($cURLHandle, CURLOPT_HEADER, 0);
curl_setopt($cURLHandle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout);
curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($cURLHandle, CURLOPT_MAXREDIRS, 10);
curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($cURLHandle, CURLOPT_FAILONERROR, true);

$cacheFileContent = curl_exec($cURLHandle);

$cURLErrNo = curl_errno($cURLHandle);
$cURLInfo = curl_getinfo($cURLHandle);

curl_close($cURLHandle);

if ($cURLErrNo != 0) {
$cacheFileContent = 'CURL_ERROR_' . $cURLErrNo;
}
if ($cURLInfo['http_code'] >= 400) {
$cacheFileContent = 'HTTP_CODE_' . $cURLInfo['http_code'];
}
}

return $cacheFileContent ?? null;
}
Comment on lines +128 to +158
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CRITICAL: SSL certificate verification is disabled.

Line 139 sets CURLOPT_SSL_VERIFYPEER to false, which disables SSL certificate verification. This exposes the application to man-in-the-middle (MITM) attacks where an attacker could intercept or tamper with external image downloads.

Remove or set to true:

-            curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, false);
+            curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, true);

If there are legitimate cases where self-signed certificates need to be supported, make this configurable via a parameter rather than hardcoding false.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private function downloadUsingCurl(string $filename): ?string
{
$cURLHandle = curl_init($filename);
if ($cURLHandle !== false) {
curl_setopt($cURLHandle, CURLOPT_HTTPGET, true);
curl_setopt($cURLHandle, CURLOPT_HEADER, 0);
curl_setopt($cURLHandle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout);
curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($cURLHandle, CURLOPT_MAXREDIRS, 10);
curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($cURLHandle, CURLOPT_FAILONERROR, true);
$cacheFileContent = curl_exec($cURLHandle);
$cURLErrNo = curl_errno($cURLHandle);
$cURLInfo = curl_getinfo($cURLHandle);
curl_close($cURLHandle);
if ($cURLErrNo != 0) {
$cacheFileContent = 'CURL_ERROR_' . $cURLErrNo;
}
if ($cURLInfo['http_code'] >= 400) {
$cacheFileContent = 'HTTP_CODE_' . $cURLInfo['http_code'];
}
}
return $cacheFileContent ?? null;
}
private function downloadUsingCurl(string $filename): ?string
{
$cURLHandle = curl_init($filename);
if ($cURLHandle !== false) {
curl_setopt($cURLHandle, CURLOPT_HTTPGET, true);
curl_setopt($cURLHandle, CURLOPT_HEADER, 0);
curl_setopt($cURLHandle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout);
curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($cURLHandle, CURLOPT_MAXREDIRS, 10);
curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($cURLHandle, CURLOPT_FAILONERROR, true);
$cacheFileContent = curl_exec($cURLHandle);
$cURLErrNo = curl_errno($cURLHandle);
$cURLInfo = curl_getinfo($cURLHandle);
curl_close($cURLHandle);
if ($cURLErrNo != 0) {
$cacheFileContent = 'CURL_ERROR_' . $cURLErrNo;
}
if ($cURLInfo['http_code'] >= 400) {
$cacheFileContent = 'HTTP_CODE_' . $cURLInfo['http_code'];
}
}
return $cacheFileContent ?? null;
}
🤖 Prompt for AI Agents
In src/Domain/Common/ExternalImageService.php around lines 128 to 158,
CURLOPT_SSL_VERIFYPEER is hardcoded to false which disables SSL certificate
verification; change this so certificate verification is enabled by default (set
to true) and remove the insecure hardcoded false, and if you need to allow
self-signed certs make it a configurable option on the service
(constructor/config) that defaults to true and is used to set
CURLOPT_SSL_VERIFYPEER accordingly, do not leave false as the default or inline
literal.


private function downloadUsingFileGetContent(string $filename): string
{
$remoteURLContext = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $this->externalImageTimeout,
'max_redirects' => '10',
]
]);

$cacheFileContent = file_get_contents($filename, false, $remoteURLContext);
if ($cacheFileContent === false) {
$cacheFileContent = 'FGC_ERROR';
}

return $cacheFileContent;
}
}
85 changes: 85 additions & 0 deletions src/Domain/Common/Html2Text.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Common;

use PhpList\Core\Domain\Configuration\Model\ConfigOption;
use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;

class Html2Text
{
private const WORD_WRAP = 70;

public function __construct(private readonly ConfigProvider $configProvider)
{
}

public function __invoke(string $html): string
{
$text = preg_replace("/\r/", '', $html);

$text = preg_replace("/<script[^>]*>(.*?)<\/script\s*>/is", '', $text);
$text = preg_replace("/<style[^>]*>(.*?)<\/style\s*>/is", '', $text);

$text = preg_replace(
"/<a[^>]*href=([\"\'])(.*)\\1[^>]*>(.*)<\/a>/Umis",
"[URLTEXT]\\3[ENDURLTEXT][LINK]\\2[ENDLINK]\n",
$text
);
$text = preg_replace("/<b>(.*?)<\/b\s*>/is", '*\\1*', $text);
$text = preg_replace("/<h[\d]>(.*?)<\/h[\d]\s*>/is", "**\\1**\n", $text);
$text = preg_replace("/<i>(.*?)<\/i\s*>/is", '/\\1/', $text);
$text = preg_replace("/<\/tr\s*?>/i", "<\/tr>\n\n", $text);
$text = preg_replace("/<\/p\s*?>/i", "<\/p>\n\n", $text);
$text = preg_replace('/<br[^>]*?>/i', "<br>\n", $text);
$text = preg_replace("/<br[^>]*?\/>/i", "<br\/>\n", $text);
$text = preg_replace('/<table/i', "\n\n<table", $text);
$text = strip_tags($text);

// find all URLs and replace them back
preg_match_all('~\[URLTEXT\](.*)\[ENDURLTEXT\]\[LINK\](.*)\[ENDLINK\]~Umis', $text, $links);
foreach ($links[0] as $matchindex => $fullmatch) {
$linktext = $links[1][$matchindex];
$linkurl = $links[2][$matchindex];
// check if the text linked is a repetition of the URL
if (trim($linktext) == trim($linkurl) ||
'https://'.trim($linktext) == trim($linkurl) ||
'http://'.trim($linktext) == trim($linkurl)
) {
$linkreplace = $linkurl;
} else {
//# if link is an anchor only, take it out
if (strpos($linkurl, '#') === 0) {
$linkreplace = $linktext;
} else {
$linkreplace = $linktext.' <'.$linkurl.'>';
}
}
$text = str_replace($fullmatch, $linkreplace, $text);
}
$text = preg_replace(
"/<a href=[\"\'](.*?)[\"\'][^>]*>(.*?)<\/a>/is",
'[URLTEXT]\\2[ENDURLTEXT][LINK]\\1[ENDLINK]',
$text,
500
);
Comment on lines +61 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unreachable code: anchor tags are already stripped.

This regex replacement looks for <a href=...> tags, but strip_tags() at line 38 already removed all HTML tags. This block will never match anything.

If this is intentional for edge cases, consider adding a comment explaining when it would apply, otherwise remove it.

🤖 Prompt for AI Agents
In src/Domain/Common/Html2Text.php around lines 61 to 66, the preg_replace that
converts <a href="..."> tags into placeholder text is unreachable because
strip_tags() earlier (line ~38) already removed all HTML; either remove this
unreachable block, or move the regex so it runs before strip_tags() if the
intent is to preserve link text/URLs, or if it was left for a specific edge case
add a clarifying comment explaining that edge case and why strip_tags() does not
remove those anchors; pick one of these fixes and apply it consistently.


$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');

$text = preg_replace('/###NL###/', "\n", $text);
$text = preg_replace("/\n /", "\n", $text);
$text = preg_replace("/\t/", ' ', $text);

// reduce whitespace
while (preg_match('/ /', $text)) {
$text = preg_replace('/ /', ' ', $text);
}
while (preg_match("/\n\s*\n\s*\n/", $text)) {
$text = preg_replace("/\n\s*\n\s*\n/", "\n\n", $text);
}
$ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP;

return wordwrap($text, $ww);
Comment on lines +81 to +83
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type mismatch: wordwrap expects int, but config returns string.

getValue() returns ?string, so $ww could be "75" (string). Cast to int for type safety.

-        $ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP;
+        $ww = (int) ($this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP);

         return wordwrap($text, $ww);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP;
return wordwrap($text, $ww);
$ww = (int) ($this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP);
return wordwrap($text, $ww);
🤖 Prompt for AI Agents
In src/Domain/Common/HtmlToText.php around lines 81 to 83, the value retrieved
from configProvider is ?string but wordwrap requires an int; cast the config
value to int (e.g. $ww =
(int)($this->configProvider->getValue(ConfigOption::WordWrap) ??
self::WORD_WRAP);) or use intval with the same fallback so wordwrap always
receives an integer.

}
}
Loading
Loading