Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 2 additions & 2 deletions config/PHPMD/rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<exclude-pattern>*/Migrations/*</exclude-pattern>

<!-- rules from the "clean code" rule set -->
<rule ref="rulesets/cleancode.xml/BooleanArgumentFlag"/>
<!-- <rule ref="rulesets/cleancode.xml/BooleanArgumentFlag"/>-->
<rule ref="rulesets/codesize.xml/CyclomaticComplexity"/>
<rule ref="rulesets/codesize.xml/NPathComplexity"/>
<rule ref="rulesets/codesize.xml/ExcessiveMethodLength"/>
Expand Down Expand Up @@ -41,7 +41,7 @@
<!-- rules from the "naming" rule set -->
<rule ref="rulesets/naming.xml/ShortVariable">
<properties>
<property name="exceptions" value="id,ip,cc,io"/>
<property name="exceptions" value="id,ip,cc,io,to"/>
</properties>
</rule>
<rule ref="rulesets/naming.xml/LongVariable">
Expand Down
5 changes: 5 additions & 0 deletions config/PhpCodeSniffer/ruleset.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
<!-- Functions -->
<rule ref="Squiz.Functions.FunctionDuplicateArgument"/>
<rule ref="Squiz.Functions.GlobalFunction"/>
<rule ref="Squiz.WhiteSpace.OperatorSpacing">
<properties>
<property name="ignoreNewlines" value="true"/>
</properties>
</rule>
Comment on lines +57 to +61
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

Duplicate rule: consolidate the operator spacing configuration.

The Squiz.WhiteSpace.OperatorSpacing rule is declared twice—once here with the ignoreNewlines property and again at line 111 without properties. PHP_CodeSniffer may only apply the last declaration, which would ignore your ignoreNewlines setting.

Consider removing the duplicate at line 111 or moving this property configuration there instead (since line 111 sits in the Whitespace section where it semantically belongs).

🔎 Suggested fix: Remove line 111 to keep this configuration

Remove the duplicate at line 111 in the Whitespace section:

 <!-- Whitespace -->
 <rule ref="PEAR.WhiteSpace.ObjectOperatorIndent"/>
 <rule ref="PEAR.WhiteSpace.ScopeClosingBrace"/>
 <rule ref="Squiz.WhiteSpace.CastSpacing"/>
 <rule ref="Squiz.WhiteSpace.LogicalOperatorSpacing"/>
-<rule ref="Squiz.WhiteSpace.OperatorSpacing"/>
 <rule ref="Squiz.WhiteSpace.SemicolonSpacing"/>

Alternatively, move the property configuration to line 111 and remove lines 57-61.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In config/PhpCodeSniffer/ruleset.xml around lines 57-61 there is a duplicate
declaration of Squiz.WhiteSpace.OperatorSpacing (this block sets
ignoreNewlines=true) and another declaration at line 111 without properties;
remove the duplicate at line 111 in the Whitespace section so the operator
spacing rule keeps the ignoreNewlines property defined here, or alternatively
move the property block from lines 57-61 to line 111 and delete the current
declaration to avoid duplicate rule definitions.


<!-- Metrics -->
<rule ref="Generic.Metrics.CyclomaticComplexity"/>
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): '600'
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): 204800

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): 'https'
8 changes: 8 additions & 0 deletions resources/translations/messages.en.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,14 @@ Thank you.</target>
<source>Value must be an AttributeTypeEnum or string.</source>
<target>__Value must be an AttributeTypeEnum or string.</target>
</trans-unit>
<trans-unit id="1cRuI31" resname="Campaign started">
<source>Campaign started</source>
<target>__Campaign started</target>
</trans-unit>
<trans-unit id="Y4qXXak" resname="phplist has started sending the campaign with subject %s">
<source>phplist has started sending the campaign with subject %s</source>
<target>__phplist has started sending the campaign with subject %s</target>
</trans-unit>
</body>
</file>
</xliff>
3 changes: 0 additions & 3 deletions src/Bounce/Service/LockService.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ public function __construct(
$this->maxWaitCycles = $maxWaitCycles;
}

/**
* @SuppressWarnings("BooleanArgumentFlag")
*/
public function acquirePageLock(
string $page,
bool $force = false,
Expand Down
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
229 changes: 229 additions & 0 deletions src/Domain/Common/ExternalImageService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?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;
use Psr\Log\LoggerInterface;
use Throwable;

class ExternalImageService
{
private string $externalCacheDir;

public function __construct(
private readonly ConfigProvider $configProvider,
private readonly LoggerInterface $logger,
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 (!$this->isCacheableUrl($filename)) {
return false;
}

if (!$this->ensureCacheDirectory()) {
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';
}
Comment on lines +68 to +70
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid writing error markers to cache files.

When content exceeds externalImageMaxSize, the code writes the string 'MAX_SIZE' to the cache file. This error marker will later pass validation (it's > 64 bytes) and be returned as base64-encoded "content" in getFromCache, causing downstream failures. Either skip caching entirely for oversized images or return early without writing a file.

🔎 Proposed fix
             if ($this->externalImageMaxSize && (strlen($cacheFileContent) > $this->externalImageMaxSize)) {
-                $cacheFileContent = 'MAX_SIZE';
+                $this->logger->warning('External image exceeds max size', [
+                    'url' => $filename,
+                    'size' => strlen($cacheFileContent),
+                    'max' => $this->externalImageMaxSize,
+                ]);
+                return false;
             }
 
             $this->writeCacheFile($cacheFileName, $cacheFileContent);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/Domain/Common/ExternalImageService.php around lines 68-70, the code
currently writes the literal 'MAX_SIZE' into the cache when an image exceeds
externalImageMaxSize; instead, do not write any error marker to cache and return
early (or skip caching) for oversized images. Change the flow so that when
strlen($cacheFileContent) > $this->externalImageMaxSize you exit the caching
path (e.g., return null/false or continue without creating/writing the cache
file) and ensure downstream getFromCache cannot return a fabricated 'MAX_SIZE'
payload.


$this->writeCacheFile($cacheFileName, $cacheFileContent);
}

return $this->isValidCacheFile($cacheFileName);
}

private function removeOldFilesInCache(): void
{
// phpcs:ignore Generic.PHP.NoSilencedErrors
$extCacheDirHandle = @opendir($this->externalCacheDir);
if (!$this->externalImageMaxAge || !$extCacheDirHandle) {
return;
}

while (true) {
// phpcs:ignore Generic.PHP.NoSilencedErrors
$cacheFile = @readdir($extCacheDirHandle);

if ($cacheFile === false) {
break;
}
// todo: make sure that this is what we need
if (!str_starts_with($cacheFile, '.')) {
// phpcs:ignore Generic.PHP.NoSilencedErrors
$cfmt = @filemtime($this->externalCacheDir . '/' . $cacheFile);

if (is_numeric($cfmt) && ($cfmt > 0) && ((time() - $cfmt) > $this->externalImageMaxAge)) {
// phpcs:ignore Generic.PHP.NoSilencedErrors
@unlink($this->externalCacheDir . '/' . $cacheFile);
}
}
}
// phpcs:ignore Generic.PHP.NoSilencedErrors
@closedir($extCacheDirHandle);
}

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Strengthen filename sanitization against path traversal.

The regex ~[\.][\.]+~Ui only removes consecutive dots (.. becomes empty), but ../ is split by the slash and not fully neutralized. An attacker could craft a URL that, after sanitization, still contains relative path components. Use basename() or a whitelist approach to ensure filenames don't escape the cache directory.

🔎 Proposed fix
     private function generateLocalFileName(string $filename, int $messageId): string
     {
-        return $this->externalCacheDir
-            . '/'
-            . $messageId
-            . '_'
-            . preg_replace([ '~[\.][\.]+~Ui', '~[^\w\.]~Ui',], ['', '_'], $filename);
+        $sanitized = preg_replace('~[^\w\.-]~', '_', basename(parse_url($filename, PHP_URL_PATH)));
+        return $this->externalCacheDir . '/' . $messageId . '_' . $sanitized;
     }
🤖 Prompt for AI Agents
In src/Domain/Common/ExternalImageService.php around lines 102 to 109, the
current preg_replace sanitization can still allow path traversal (e.g., "../"
split by slashes) so replace it with a stronger approach: strip URL-decoding and
null bytes, then call basename() on the incoming filename (or apply a strict
whitelist of allowed characters) to remove any directory components, remove any
remaining disallowed characters, and truncate to a safe length before
concatenating with $this->externalCacheDir and $messageId; ensure no directory
separators remain and consider validating the final path is inside the cache dir
(realpath check) before returning.


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 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;
}
Comment on lines +149 to +165
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Return null instead of 'FGC_ERROR' string.

Similar to the cURL method, writing 'FGC_ERROR' into the cache file will corrupt the cache. Return null or throw an exception on failure.

🔎 Proposed fix
     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';
+            $this->logger->error('file_get_contents error downloading image', ['url' => $filename]);
+            return null;
         }
 
         return $cacheFileContent;
     }
📝 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 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;
}
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) {
$this->logger->error('file_get_contents error downloading image', ['url' => $filename]);
return null;
}
return $cacheFileContent;
}
🤖 Prompt for AI Agents
In src/Domain/Common/ExternalImageService.php around lines 143-159, the
downloadUsingFileGetContent method currently returns the literal 'FGC_ERROR' on
failure which corrupts the cache; change the method to return null on failure
instead by updating its return type to ?string (or adjust docblock) and replace
the 'FGC_ERROR' assignment with return null; then update any callers to handle a
null result (skip writing to cache / treat as download failure) or
propagate/throw an appropriate exception.


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

return true;
}

private function ensureCacheDirectory(): bool
{

if (!file_exists($this->externalCacheDir)) {
// phpcs:ignore Generic.PHP.NoSilencedErrors
@mkdir($this->externalCacheDir);
}

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

return true;
}

private function isValidCacheFile(string $cacheFileName): bool
{
// phpcs:ignore Generic.PHP.NoSilencedErrors
if (file_exists($cacheFileName) && (@filesize($cacheFileName) > 64)) {
return true;
}

return false;
}

private function writeCacheFile(string $cacheFileName, $content): void
{
try {
$handle = fopen($cacheFileName, 'wb');

if ($handle === false) {
$this->logger->error('Cannot open cache file', [
'file' => $cacheFileName,
]);
return;
}

if (flock($handle, LOCK_EX)) {
fwrite($handle, $content);
fflush($handle);
flock($handle, LOCK_UN);
}

fclose($handle);
} catch (Throwable $e) {
$this->logger->error('Cache file write failed', [
'file' => $cacheFileName,
'error' => $e->getMessage(),
]);
}
}
Comment on lines +203 to +228
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Check fwrite return value for partial writes.

Line 210 calls fwrite but doesn't verify all bytes were written. Partial writes (e.g., disk full) will silently corrupt the cache. Check the return value against strlen($content) and log failures.

🔎 Proposed fix
             if (flock($handle, LOCK_EX)) {
-                fwrite($handle, $content);
+                $written = fwrite($handle, $content);
+                if ($written === false || $written !== strlen($content)) {
+                    $this->logger->error('Incomplete cache write', [
+                        'file' => $cacheFileName,
+                        'expected' => strlen($content),
+                        'written' => $written,
+                    ]);
+                }
                 fflush($handle);
                 flock($handle, LOCK_UN);
             }
📝 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 writeCacheFile(string $cacheFileName, $content): void
{
try {
$handle = fopen($cacheFileName, 'wb');
if ($handle === false) {
$this->logger->error('Cannot open cache file', [
'file' => $cacheFileName,
]);
return;
}
if (flock($handle, LOCK_EX)) {
fwrite($handle, $content);
fflush($handle);
flock($handle, LOCK_UN);
}
fclose($handle);
} catch (Throwable $e) {
$this->logger->error('Cache file write failed', [
'file' => $cacheFileName,
'error' => $e->getMessage(),
]);
}
}
private function writeCacheFile(string $cacheFileName, $content): void
{
try {
$handle = fopen($cacheFileName, 'wb');
if ($handle === false) {
$this->logger->error('Cannot open cache file', [
'file' => $cacheFileName,
]);
return;
}
if (flock($handle, LOCK_EX)) {
$written = fwrite($handle, $content);
if ($written === false || $written !== strlen($content)) {
$this->logger->error('Incomplete cache write', [
'file' => $cacheFileName,
'expected' => strlen($content),
'written' => $written,
]);
}
fflush($handle);
flock($handle, LOCK_UN);
}
fclose($handle);
} catch (Throwable $e) {
$this->logger->error('Cache file write failed', [
'file' => $cacheFileName,
'error' => $e->getMessage(),
]);
}
}
🤖 Prompt for AI Agents
In src/Domain/Common/ExternalImageService.php around lines 197 to 222, the call
to fwrite does not verify that all bytes were written, so partial writes (e.g.
disk full) can corrupt the cache; change the write logic to check fwrite's
return value against strlen($content) and handle partial writes by looping until
all bytes are written (or an error/zero-bytes is returned), and if a full write
cannot be completed log an error with the file name, expected byte count and
actual bytes written, ensure fflush and fclose still run, and consider deleting
the incomplete cache file on failure.

}
Loading
Loading