8fb3086792
Signed-off-by: Sebastian <krupinski01@gmail.com>
225 lines
7.7 KiB
PHP
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);
|
|
}
|
|
}
|