Files
mail/lib/Stores/CompositionStore.php
T

225 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\Mail\Stores;
use DI\Attribute\Inject;
use KTXF\Resource\BinaryResource;
use RuntimeException;
use UnexpectedValueException;
final class CompositionStore {
private const COMPOSITION_FILENAME = 'composition.json';
private string $storagePath;
public function __construct(
#[Inject('rootDir')] private readonly string $rootDir,
) {
$this->storagePath = $this->rootDir . '/var/cache/mail/composer';
}
public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_SH,
fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
);
}
/**
* Atomically read, mutate, and replace a composition snapshot.
*
* Returning null from the callback leaves the stored snapshot unchanged.
* The callback receives null when the composition does not exist.
*
* @param callable(?array): ?array $update
*/
public function compositionSave(string $tenantId, string $userId, string $draftId, callable $update): ?array {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_EX,
function () use ($tenantId, $userId, $draftId, $update): ?array {
$snapshot = $update($this->compositionRead($tenantId, $userId, $draftId));
if ($snapshot === null) {
return null;
}
if (!is_array($snapshot)) {
throw new UnexpectedValueException('Composition save callback must return an array or null');
}
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
$this->ensureDirectory($draftDir);
$temporaryPath = tempnam($draftDir, '.composition-');
if ($temporaryPath === false) {
throw new RuntimeException("Unable to create a temporary composition file for '$draftId'");
}
try {
$written = file_put_contents($temporaryPath, $json);
if ($written !== strlen($json)) {
throw new RuntimeException("Unable to write composition '$draftId'");
}
if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) {
throw new RuntimeException("Unable to replace composition '$draftId'");
}
} finally {
if (is_file($temporaryPath)) {
unlink($temporaryPath);
}
}
return $snapshot;
},
);
}
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_EX,
function () use ($tenantId, $userId, $draftId): bool {
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
if (!is_dir($draftDir)) {
return false;
}
$this->deleteDir($draftDir);
return true;
},
);
}
private function compositionRead(string $tenantId, string $userId, string $draftId): ?array {
$messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME;
if (!is_file($messagePath)) {
return null;
}
$contents = file_get_contents($messagePath);
if ($contents === false) {
throw new RuntimeException("Unable to read composition '$draftId'");
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : null;
}
private function withCompositionLock(string $tenantId, string $userId, string $draftId, int $operation, callable $callback): mixed {
$lockDir = $this->storagePath . '/.locks';
$this->ensureDirectory($lockDir);
$lockId = hash('sha256', $tenantId . "\0" . $userId . "\0" . $draftId);
$handle = fopen($lockDir . '/' . $lockId . '.lock', 'c+b');
if ($handle === false) {
throw new RuntimeException("Unable to open composition lock for '$draftId'");
}
try {
if (!flock($handle, $operation)) {
throw new RuntimeException("Unable to lock composition '$draftId'");
}
return $callback();
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array {
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
if (!is_dir($attachmentDir)) {
mkdir($attachmentDir, 0755, true);
}
$contentPath = $attachmentDir . '/' . $attachmentId . '.blob';
$handle = fopen($contentPath, 'wb');
$size = 0;
foreach ($data->stream() as $chunk) {
$chunkString = (string)$chunk;
$size += strlen($chunkString);
fwrite($handle, $chunkString);
}
fclose($handle);
$meta = [
'identifier' => $attachmentId,
'composition' => $compositionId,
'name' => $data->filename(),
'type' => $data->mimeType(),
'size' => $size,
];
file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $meta;
}
public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string {
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob';
if (!file_exists($path)) {
return null;
}
return file_get_contents($path) ?: null;
}
public function fetchAttachmentMeta(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?array {
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.meta';
if (!file_exists($path)) {
return null;
}
$decoded = json_decode((string)file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
private function attachmentDir(string $tenantId, string $userId, string $draftId): string {
return $this->draftDir($tenantId, $userId, $draftId) . '/attachments';
}
private function draftDir(string $tenantId, string $userId, string $draftId): string {
return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId;
}
private function ensureDirectory(string $path): void {
if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) {
throw new RuntimeException("Unable to create directory '$path'");
}
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
continue;
}
unlink($child);
}
rmdir($path);
}
}