Files
Sebastian 0250b5a847 feat: implement content cache
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-29 19:56:30 -04:00

183 lines
5.9 KiB
PHP

<?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);
}
}