generated from Nodarx/template
0250b5a847
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
183 lines
5.9 KiB
PHP
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);
|
|
}
|
|
}
|