generated from Nodarx/template
feat: implement content cache
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Stores;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use FilesystemIterator;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* IMAP Message Content Store
|
||||
*
|
||||
* Holds the immutable content of cached messages (message.json) on the filesystem:
|
||||
*
|
||||
* {rootDir}/storage/{tid}/provider_imap/{sid}/{sha1(mailbox)}/{uidValidity}/{uid}/message.json
|
||||
*
|
||||
* Mailbox names are hashed because they contain delimiters and modified UTF-7.
|
||||
* Files are written atomically (temporary file + rename).
|
||||
*/
|
||||
class MessageFileStore
|
||||
{
|
||||
private const CONTENT_FILE = 'message.json';
|
||||
private const FOLDER_PERMISSIONS = 0750;
|
||||
private const FILE_PERMISSIONS = 0640;
|
||||
|
||||
public function __construct(
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Write the content of a message, replacing any existing content.
|
||||
*/
|
||||
public function write(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid, array $content): void
|
||||
{
|
||||
$folder = $this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid);
|
||||
if (!is_dir($folder) && !@mkdir($folder, self::FOLDER_PERMISSIONS, true) && !is_dir($folder)) {
|
||||
throw new RuntimeException("Failed to create message folder: {$folder}");
|
||||
}
|
||||
|
||||
try {
|
||||
$json = json_encode($content, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
} catch (JsonException $e) {
|
||||
throw new RuntimeException('Failed to encode message content: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$file = $folder . DIRECTORY_SEPARATOR . self::CONTENT_FILE;
|
||||
$temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||
|
||||
if (@file_put_contents($temporary, $json, LOCK_EX) !== strlen($json)) {
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException("Failed to write message content: {$file}");
|
||||
}
|
||||
@chmod($temporary, self::FILE_PERMISSIONS);
|
||||
|
||||
if (!@rename($temporary, $file)) {
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException("Failed to replace message content: {$file}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content of a message.
|
||||
*
|
||||
* @return array|null null when the message is not cached or its content is unreadable
|
||||
*/
|
||||
public function read(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): ?array
|
||||
{
|
||||
$file = $this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid) . DIRECTORY_SEPARATOR . self::CONTENT_FILE;
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = @file_get_contents($file);
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($content) ? $content : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cached files of messages.
|
||||
*/
|
||||
public function delete(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int ...$uids): void
|
||||
{
|
||||
foreach ($uids as $uid) {
|
||||
$this->removeTree($this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cached files of a mailbox (all UIDVALIDITY generations).
|
||||
*/
|
||||
public function deleteByMailbox(string $tenantId, string $serviceId, string $mailbox): void
|
||||
{
|
||||
$this->removeTree($this->mailboxPath($tenantId, $serviceId, $mailbox));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cached files of a service.
|
||||
*/
|
||||
public function deleteByService(string $tenantId, string $serviceId): void
|
||||
{
|
||||
$this->removeTree($this->servicePath($tenantId, $serviceId));
|
||||
}
|
||||
|
||||
private function servicePath(string $tenantId, string $serviceId): string
|
||||
{
|
||||
return implode(DIRECTORY_SEPARATOR, [
|
||||
rtrim($this->rootDir, DIRECTORY_SEPARATOR),
|
||||
'storage',
|
||||
self::pathSegment($tenantId, 'tenant'),
|
||||
'provider_imap',
|
||||
self::pathSegment($serviceId, 'service'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function mailboxPath(string $tenantId, string $serviceId, string $mailbox): string
|
||||
{
|
||||
return $this->servicePath($tenantId, $serviceId) . DIRECTORY_SEPARATOR . sha1($mailbox);
|
||||
}
|
||||
|
||||
private function messagePath(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): string
|
||||
{
|
||||
if ($uidValidity < 0 || $uid <= 0) {
|
||||
throw new InvalidArgumentException('Invalid UIDVALIDITY or UID');
|
||||
}
|
||||
|
||||
return $this->mailboxPath($tenantId, $serviceId, $mailbox)
|
||||
. DIRECTORY_SEPARATOR . $uidValidity
|
||||
. DIRECTORY_SEPARATOR . $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard identifiers used as path segments against traversal.
|
||||
*/
|
||||
private static function pathSegment(string $value, string $label): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) {
|
||||
throw new InvalidArgumentException("Invalid {$label} identifier for cache path");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function removeTree(string $path): void
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($path) || is_link($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user