Initial Version
This commit is contained in:
219
shared/lib/Blob/MimeTypes.php
Normal file
219
shared/lib/Blob/MimeTypes.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Blob;
|
||||
|
||||
/**
|
||||
* MimeTypes - MIME type and format resolution utility
|
||||
*
|
||||
* Provides bidirectional mapping between MIME types and file format identifiers.
|
||||
*/
|
||||
class MimeTypes {
|
||||
|
||||
/** Default MIME type for unknown/binary content */
|
||||
public const MIME_BINARY = 'application/octet-stream';
|
||||
|
||||
/** Default format for unknown/binary content */
|
||||
public const FORMAT_BINARY = 'binary';
|
||||
|
||||
/**
|
||||
* MIME type to format mapping
|
||||
*/
|
||||
private const MIME_TO_FORMAT = [
|
||||
// Images
|
||||
'image/jpeg' => 'jpeg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/bmp' => 'bmp',
|
||||
'image/x-ms-bmp' => 'bmp',
|
||||
'image/tiff' => 'tiff',
|
||||
'image/x-icon' => 'ico',
|
||||
'image/vnd.microsoft.icon' => 'ico',
|
||||
'image/svg+xml' => 'svg',
|
||||
'image/heic' => 'heic',
|
||||
'image/heif' => 'heif',
|
||||
'image/avif' => 'avif',
|
||||
|
||||
// Documents
|
||||
'application/pdf' => 'pdf',
|
||||
'application/rtf' => 'rtf',
|
||||
'text/rtf' => 'rtf',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/vnd.ms-powerpoint' => 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
||||
'application/vnd.oasis.opendocument.text' => 'odt',
|
||||
'application/vnd.oasis.opendocument.spreadsheet' => 'ods',
|
||||
'application/vnd.oasis.opendocument.presentation' => 'odp',
|
||||
|
||||
// Archives
|
||||
'application/zip' => 'zip',
|
||||
'application/x-zip-compressed' => 'zip',
|
||||
'application/gzip' => 'gzip',
|
||||
'application/x-gzip' => 'gzip',
|
||||
'application/x-bzip2' => 'bzip2',
|
||||
'application/x-xz' => 'xz',
|
||||
'application/x-rar-compressed' => 'rar',
|
||||
'application/vnd.rar' => 'rar',
|
||||
'application/x-7z-compressed' => '7z',
|
||||
'application/x-tar' => 'tar',
|
||||
|
||||
// Audio
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/mp3' => 'mp3',
|
||||
'audio/ogg' => 'ogg',
|
||||
'audio/flac' => 'flac',
|
||||
'audio/x-flac' => 'flac',
|
||||
'audio/wav' => 'wav',
|
||||
'audio/x-wav' => 'wav',
|
||||
'audio/aac' => 'aac',
|
||||
'audio/mp4' => 'm4a',
|
||||
'audio/x-m4a' => 'm4a',
|
||||
'audio/webm' => 'webm',
|
||||
|
||||
// Video
|
||||
'video/mp4' => 'mp4',
|
||||
'video/webm' => 'webm',
|
||||
'video/x-msvideo' => 'avi',
|
||||
'video/mpeg' => 'mpeg',
|
||||
'video/quicktime' => 'mov',
|
||||
'video/x-matroska' => 'mkv',
|
||||
'video/x-flv' => 'flv',
|
||||
'video/3gpp' => '3gp',
|
||||
|
||||
// Fonts
|
||||
'font/woff' => 'woff',
|
||||
'font/woff2' => 'woff2',
|
||||
'font/ttf' => 'ttf',
|
||||
'font/otf' => 'otf',
|
||||
'application/font-woff' => 'woff',
|
||||
'application/font-woff2' => 'woff2',
|
||||
'application/x-font-ttf' => 'ttf',
|
||||
'application/x-font-otf' => 'otf',
|
||||
|
||||
// Text/Code
|
||||
'text/plain' => 'text',
|
||||
'text/html' => 'html',
|
||||
'text/css' => 'css',
|
||||
'text/csv' => 'csv',
|
||||
'text/xml' => 'xml',
|
||||
'application/xml' => 'xml',
|
||||
'application/json' => 'json',
|
||||
'application/javascript' => 'js',
|
||||
'text/javascript' => 'js',
|
||||
'application/x-httpd-php' => 'php',
|
||||
'text/x-php' => 'php',
|
||||
'text/markdown' => 'md',
|
||||
'text/x-python' => 'py',
|
||||
'application/x-python-code' => 'py',
|
||||
|
||||
// Other
|
||||
'application/epub+zip' => 'epub',
|
||||
'application/x-sqlite3' => 'sqlite',
|
||||
'application/wasm' => 'wasm',
|
||||
'application/octet-stream' => 'binary',
|
||||
];
|
||||
|
||||
/** Cached reverse mapping (format -> mime) */
|
||||
private static ?array $formatToMime = null;
|
||||
|
||||
/**
|
||||
* Get format from MIME type
|
||||
*
|
||||
* @param string $mime MIME type
|
||||
* @return string|null Format or null if not found
|
||||
*/
|
||||
public static function toFormat(string $mime): ?string {
|
||||
return self::MIME_TO_FORMAT[$mime] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from format
|
||||
*
|
||||
* @param string $format Format identifier
|
||||
* @return string|null MIME type or null if not found
|
||||
*/
|
||||
public static function toMime(string $format): ?string {
|
||||
if (self::$formatToMime === null) {
|
||||
self::$formatToMime = [];
|
||||
foreach (self::MIME_TO_FORMAT as $mime => $fmt) {
|
||||
// Keep first occurrence (most canonical MIME type)
|
||||
if (!isset(self::$formatToMime[$fmt])) {
|
||||
self::$formatToMime[$fmt] = $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
return self::$formatToMime[$format] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract format from MIME type string (with fallback parsing)
|
||||
*
|
||||
* @param string $mime MIME type
|
||||
* @return string|null Format or null
|
||||
*/
|
||||
public static function parseFormat(string $mime): ?string {
|
||||
// Check direct mapping first
|
||||
if (isset(self::MIME_TO_FORMAT[$mime])) {
|
||||
return self::MIME_TO_FORMAT[$mime];
|
||||
}
|
||||
|
||||
// Try to extract from MIME subtype (e.g., "image/jpeg" -> "jpeg")
|
||||
$parts = explode('/', $mime, 2);
|
||||
if (count($parts) === 2) {
|
||||
$subtype = $parts[1];
|
||||
// Remove x- prefix and any parameters
|
||||
$subtype = preg_replace('/^x-/', '', $subtype);
|
||||
$subtype = explode(';', $subtype)[0];
|
||||
$subtype = explode('+', $subtype)[0];
|
||||
|
||||
if (strlen($subtype) > 0 && strlen($subtype) <= 10) {
|
||||
return strtolower($subtype);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MIME type is known
|
||||
*
|
||||
* @param string $mime MIME type
|
||||
* @return bool
|
||||
*/
|
||||
public static function isKnownMime(string $mime): bool {
|
||||
return isset(self::MIME_TO_FORMAT[$mime]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if format is known
|
||||
*
|
||||
* @param string $format Format identifier
|
||||
* @return bool
|
||||
*/
|
||||
public static function isKnownFormat(string $format): bool {
|
||||
if (self::$formatToMime === null) {
|
||||
self::toMime($format); // Initialize cache
|
||||
}
|
||||
return isset(self::$formatToMime[$format]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known MIME types
|
||||
*
|
||||
* @return array<string, string> MIME type to format mapping
|
||||
*/
|
||||
public static function all(): array {
|
||||
return self::MIME_TO_FORMAT;
|
||||
}
|
||||
|
||||
}
|
||||
230
shared/lib/Blob/Signature.php
Normal file
230
shared/lib/Blob/Signature.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Blob;
|
||||
|
||||
use finfo;
|
||||
|
||||
/**
|
||||
* Signature - Analyzes binary content to determine MIME type and format
|
||||
*
|
||||
* This utility only requires the first bytes of a file to detect its format,
|
||||
* making it compatible with streams, chunked uploads, and remote storage backends like S3.
|
||||
*
|
||||
* Uses PHP's built-in finfo extension (libmagic) for reliable detection with
|
||||
* fallback to custom magic byte detection if finfo is unavailable.
|
||||
*/
|
||||
class Signature {
|
||||
|
||||
/** Minimum bytes needed for reliable detection */
|
||||
public const HEADER_SIZE = 256;
|
||||
|
||||
/**
|
||||
* Fallback magic byte signatures for when finfo is unavailable
|
||||
*/
|
||||
private const SIGNATURES = [
|
||||
['offset' => 0, 'bytes' => 'FFD8FF', 'format' => 'jpeg'],
|
||||
['offset' => 0, 'bytes' => '89504E470D0A1A0A', 'format' => 'png'],
|
||||
['offset' => 0, 'bytes' => '47494638', 'format' => 'gif'],
|
||||
['offset' => 0, 'bytes' => '25504446', 'format' => 'pdf'],
|
||||
['offset' => 0, 'bytes' => '504B0304', 'format' => 'zip'],
|
||||
['offset' => 0, 'bytes' => '1F8B08', 'format' => 'gzip'],
|
||||
['offset' => 4, 'bytes' => '66747970', 'format' => 'mp4'],
|
||||
['offset' => 0, 'bytes' => '494433', 'format' => 'mp3'],
|
||||
['offset' => 0, 'bytes' => 'FFFB', 'format' => 'mp3'],
|
||||
['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP
|
||||
];
|
||||
|
||||
/** Cached finfo instance */
|
||||
private static ?finfo $finfo = null;
|
||||
|
||||
/**
|
||||
* Detect both MIME type and format from content bytes in a single operation
|
||||
*
|
||||
* @param string $headerBytes First bytes of the file content (256 recommended)
|
||||
* @return array{mime: string, format: string} Array with 'mime' and 'format' keys
|
||||
*/
|
||||
public static function detect(string $headerBytes): array {
|
||||
if (strlen($headerBytes) === 0) {
|
||||
return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY];
|
||||
}
|
||||
|
||||
$mime = null;
|
||||
$format = null;
|
||||
|
||||
// Try finfo first (most reliable)
|
||||
if (extension_loaded('fileinfo')) {
|
||||
$mime = self::detectMimeType($headerBytes);
|
||||
if ($mime !== null) {
|
||||
// Get format from MIME
|
||||
$format = MimeTypes::toFormat($mime);
|
||||
if ($format === null && $mime !== MimeTypes::MIME_BINARY) {
|
||||
$format = MimeTypes::parseFormat($mime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to magic bytes if format not determined
|
||||
if ($format === null) {
|
||||
$format = self::detectFromMagicBytes($headerBytes);
|
||||
}
|
||||
|
||||
// Ensure MIME type is set
|
||||
if ($mime === null || $mime === MimeTypes::MIME_BINARY) {
|
||||
$mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY;
|
||||
}
|
||||
|
||||
return ['mime' => $mime, 'format' => $format];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect both MIME type and format from a stream in a single operation
|
||||
*
|
||||
* @param resource $stream File stream
|
||||
* @return array{mime: string, format: string} Array with 'mime' and 'format' keys
|
||||
*/
|
||||
public static function detectFromStream($stream): array {
|
||||
$position = ftell($stream);
|
||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
||||
fseek($stream, $position);
|
||||
|
||||
if ($headerBytes === false || $headerBytes === '') {
|
||||
return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY];
|
||||
}
|
||||
|
||||
return self::detect($headerBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect file format from content bytes
|
||||
*
|
||||
* @param string $headerBytes First bytes of the file content (256 recommended)
|
||||
* @return string Detected format (e.g., 'jpeg', 'png', 'pdf') or 'binary' if unknown
|
||||
*/
|
||||
public static function detectFormat(string $headerBytes): string {
|
||||
return self::detect($headerBytes)['format'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from content bytes using finfo
|
||||
*
|
||||
* @param string $headerBytes Content bytes
|
||||
* @return string|null MIME type or null on failure
|
||||
*/
|
||||
public static function detectMimeType(string $headerBytes): ?string {
|
||||
if (!extension_loaded('fileinfo')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::$finfo === null) {
|
||||
self::$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
}
|
||||
|
||||
$mime = self::$finfo->buffer($headerBytes);
|
||||
return $mime !== false ? $mime : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect file format from a stream
|
||||
*
|
||||
* Reads the header bytes, detects format, and rewinds the stream.
|
||||
*
|
||||
* @param resource $stream File stream
|
||||
* @return string Detected format
|
||||
*/
|
||||
public static function detectFormatFromStream($stream): string {
|
||||
$position = ftell($stream);
|
||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
||||
fseek($stream, $position);
|
||||
|
||||
if ($headerBytes === false || $headerBytes === '') {
|
||||
return MimeTypes::FORMAT_BINARY;
|
||||
}
|
||||
|
||||
return self::detectFormat($headerBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from a stream
|
||||
*
|
||||
* @param resource $stream File stream
|
||||
* @return string|null MIME type or null
|
||||
*/
|
||||
public static function detectMimeTypeFromStream($stream): ?string {
|
||||
$position = ftell($stream);
|
||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
||||
fseek($stream, $position);
|
||||
|
||||
if ($headerBytes === false || $headerBytes === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::detectMimeType($headerBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback detection using magic bytes
|
||||
*
|
||||
* @param string $headerBytes Content bytes
|
||||
* @return string Detected format or 'binary'
|
||||
*/
|
||||
private static function detectFromMagicBytes(string $headerBytes): string {
|
||||
$headerHex = strtoupper(bin2hex($headerBytes));
|
||||
|
||||
foreach (self::SIGNATURES as $sig) {
|
||||
$offset = $sig['offset'] * 2;
|
||||
$sigBytes = strtoupper($sig['bytes']);
|
||||
$sigLength = strlen($sigBytes);
|
||||
|
||||
if (strlen($headerHex) < $offset + $sigLength) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slice = substr($headerHex, $offset, $sigLength);
|
||||
if ($slice === $sigBytes) {
|
||||
return $sig['format'];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if likely text
|
||||
if (self::isLikelyText($headerBytes)) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return MimeTypes::FORMAT_BINARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content appears to be text
|
||||
*
|
||||
* @param string $bytes Content bytes
|
||||
* @return bool
|
||||
*/
|
||||
private static function isLikelyText(string $bytes): bool {
|
||||
// Check for UTF-8 BOM
|
||||
if (str_starts_with($bytes, "\xEF\xBB\xBF")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$length = min(strlen($bytes), 256);
|
||||
$printableCount = 0;
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$byte = ord($bytes[$i]);
|
||||
if (($byte >= 32 && $byte <= 126) || $byte === 9 || $byte === 10 || $byte === 13) {
|
||||
$printableCount++;
|
||||
} elseif ($byte >= 128 && $byte <= 247) {
|
||||
$printableCount++; // UTF-8 bytes
|
||||
}
|
||||
}
|
||||
|
||||
return ($printableCount / $length) > 0.9;
|
||||
}
|
||||
|
||||
}
|
||||
123
shared/lib/Cache/BlobCacheInterface.php
Normal file
123
shared/lib/Cache/BlobCacheInterface.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Blob Cache Interface
|
||||
*
|
||||
* For binary/media data like preview images, thumbnails, and file caches.
|
||||
* Stored in storage/ rather than var/cache/ due to larger sizes and user ownership.
|
||||
*
|
||||
* Use cases: contact previews, file thumbnails, generated images.
|
||||
*/
|
||||
interface BlobCacheInterface
|
||||
{
|
||||
/**
|
||||
* Default TTL for blob cache entries (7 days)
|
||||
*/
|
||||
public const DEFAULT_TTL = 604800;
|
||||
|
||||
/**
|
||||
* Get blob data as a string
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name (e.g., 'previews', 'thumbnails')
|
||||
* @return string|null Blob data or null if not found
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): ?string;
|
||||
|
||||
/**
|
||||
* Get blob data as a stream resource
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return resource|null Stream resource or null if not found
|
||||
*/
|
||||
public function getStream(string $key, CacheScope $scope, string $usage);
|
||||
|
||||
/**
|
||||
* Store blob data from a string
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param string $data Blob data
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param string|null $mimeType MIME type of the blob
|
||||
* @param int|null $ttl Time-to-live in seconds
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Store blob data from a stream
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param resource $stream Stream resource to read from
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param string|null $mimeType MIME type of the blob
|
||||
* @param int|null $ttl Time-to-live in seconds
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Check if a blob exists
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if exists and not expired
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Delete a blob
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if deleted
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Get the local filesystem path to a blob (if available)
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return string|null Filesystem path or null if not available/not local
|
||||
*/
|
||||
public function getPath(string $key, CacheScope $scope, string $usage): ?string;
|
||||
|
||||
/**
|
||||
* Get metadata for a blob
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return array{mimeType: string|null, size: int, createdAt: int, expiresAt: int|null}|null
|
||||
*/
|
||||
public function getMetadata(string $key, CacheScope $scope, string $usage): ?array;
|
||||
|
||||
/**
|
||||
* Clear all blobs in a usage bucket
|
||||
*
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int Number of blobs removed
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int;
|
||||
|
||||
/**
|
||||
* Clean up expired blobs
|
||||
*
|
||||
* @return int Number of blobs cleaned up
|
||||
*/
|
||||
public function cleanup(): int;
|
||||
}
|
||||
72
shared/lib/Cache/CacheInterface.php
Normal file
72
shared/lib/Cache/CacheInterface.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Base Cache Interface
|
||||
*
|
||||
* Common interface for all cache implementations.
|
||||
* Supports scoped caching with automatic key prefixing based on scope.
|
||||
*/
|
||||
interface CacheInterface
|
||||
{
|
||||
/**
|
||||
* Retrieve an item from the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name (e.g., 'sessions', 'config')
|
||||
* @return mixed|null Cached value or null if not found/expired
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): mixed;
|
||||
|
||||
/**
|
||||
* Store an item in the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param mixed $value Value to cache (must be serializable)
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int|null $ttl Time-to-live in seconds (null = default, 0 = indefinite)
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Check if an item exists in the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if exists and not expired
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Remove an item from the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if deleted (or didn't exist)
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Remove all items matching a usage pattern
|
||||
*
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int Number of items removed
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int;
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
*
|
||||
* @return int Number of entries cleaned up
|
||||
*/
|
||||
public function cleanup(): int;
|
||||
}
|
||||
50
shared/lib/Cache/CacheScope.php
Normal file
50
shared/lib/Cache/CacheScope.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Cache Scope
|
||||
*
|
||||
* Defines the scope/namespace level for cached entries:
|
||||
* - Global: Shared across all tenants and users (e.g., routes, modules)
|
||||
* - Tenant: Scoped to a specific tenant (e.g., config, sessions)
|
||||
* - User: Scoped to a specific user within a tenant (e.g., rate limits)
|
||||
*/
|
||||
enum CacheScope: string
|
||||
{
|
||||
case Global = 'global';
|
||||
case Tenant = 'tenant';
|
||||
case User = 'user';
|
||||
|
||||
/**
|
||||
* Build the cache path prefix for this scope
|
||||
*
|
||||
* @param string|null $tenantId Tenant identifier (required for Tenant/User scope)
|
||||
* @param string|null $userId User identifier (required for User scope)
|
||||
* @return string Path prefix (e.g., "global", "tenant/{tid}", "user/{tid}/{uid}")
|
||||
*/
|
||||
public function buildPrefix(?string $tenantId = null, ?string $userId = null): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Global => 'global',
|
||||
self::Tenant => $tenantId ? "tenant/{$tenantId}" : 'tenant/_unknown',
|
||||
self::User => $tenantId && $userId
|
||||
? "user/{$tenantId}/{$userId}"
|
||||
: "user/_unknown/_unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required identifiers are provided for this scope
|
||||
*/
|
||||
public function validate(?string $tenantId, ?string $userId): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Global => true,
|
||||
self::Tenant => $tenantId !== null,
|
||||
self::User => $tenantId !== null && $userId !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
57
shared/lib/Cache/EphemeralCacheInterface.php
Normal file
57
shared/lib/Cache/EphemeralCacheInterface.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Ephemeral Cache Interface
|
||||
*
|
||||
* For short-lived cached data with automatic expiration.
|
||||
* Use cases: sessions, rate limits, challenges, temporary config.
|
||||
*
|
||||
* Default TTL is typically seconds to minutes.
|
||||
*/
|
||||
interface EphemeralCacheInterface extends CacheInterface
|
||||
{
|
||||
/**
|
||||
* Default TTL for ephemeral cache entries (5 minutes)
|
||||
*/
|
||||
public const DEFAULT_TTL = 300;
|
||||
|
||||
/**
|
||||
* Get or set a value with a callback
|
||||
*
|
||||
* If the key doesn't exist, execute the callback and cache the result.
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param callable $callback Function to generate value if not cached
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int|null $ttl Time-to-live in seconds
|
||||
* @return mixed Cached or generated value
|
||||
*/
|
||||
public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed;
|
||||
|
||||
/**
|
||||
* Increment a numeric value
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int $amount Amount to increment by
|
||||
* @return int|false New value or false on failure
|
||||
*/
|
||||
public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false;
|
||||
|
||||
/**
|
||||
* Decrement a numeric value
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int $amount Amount to decrement by
|
||||
* @return int|false New value or false on failure
|
||||
*/
|
||||
public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false;
|
||||
}
|
||||
66
shared/lib/Cache/PersistentCacheInterface.php
Normal file
66
shared/lib/Cache/PersistentCacheInterface.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Persistent Cache Interface
|
||||
*
|
||||
* For long-lived cached data with optional tagging for bulk invalidation.
|
||||
* Use cases: routes, modules, compiled configs, firewall rules.
|
||||
*
|
||||
* Default TTL is typically hours to days, or indefinite until explicit invalidation.
|
||||
*/
|
||||
interface PersistentCacheInterface extends CacheInterface
|
||||
{
|
||||
/**
|
||||
* Default TTL for persistent cache entries (1 hour)
|
||||
* Use 0 for indefinite storage
|
||||
*/
|
||||
public const DEFAULT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Store an item with tags for bulk invalidation
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param mixed $value Value to cache
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param array<string> $tags Tags for grouping/invalidation
|
||||
* @param int|null $ttl Time-to-live in seconds (null = default, 0 = indefinite)
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Invalidate all entries with a specific tag
|
||||
*
|
||||
* @param string $tag Tag to invalidate
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int Number of entries invalidated
|
||||
*/
|
||||
public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int;
|
||||
|
||||
/**
|
||||
* Get the version/timestamp of a cached entry
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int|null Timestamp when entry was cached, or null if not found
|
||||
*/
|
||||
public function getVersion(string $key, CacheScope $scope, string $usage): ?int;
|
||||
|
||||
/**
|
||||
* Check if an entry is stale based on a reference timestamp
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int $reference Reference timestamp to compare against
|
||||
* @return bool True if entry is older than reference (or doesn't exist)
|
||||
*/
|
||||
public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool;
|
||||
}
|
||||
412
shared/lib/Cache/Store/FileBlobCache.php
Normal file
412
shared/lib/Cache/Store/FileBlobCache.php
Normal file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache\Store;
|
||||
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\CacheScope;
|
||||
|
||||
/**
|
||||
* File-based Blob Cache Implementation
|
||||
*
|
||||
* Stores binary/media data with metadata in the storage directory.
|
||||
* Directory structure: storage/{tid}/cache/{usage}/ or storage/{tid}/{uid}/cache/{usage}/
|
||||
*/
|
||||
class FileBlobCache implements BlobCacheInterface
|
||||
{
|
||||
private string $basePath;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $userId = null;
|
||||
|
||||
public function __construct(string $projectDir)
|
||||
{
|
||||
$this->basePath = rtrim($projectDir, '/') . '/storage';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tenant context for scoped operations
|
||||
*/
|
||||
public function setTenantContext(?string $tenantId): void
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the user context for scoped operations
|
||||
*/
|
||||
public function setUserContext(?string $userId): void
|
||||
{
|
||||
$this->userId = $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): ?string
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!$this->isValid($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
return $content !== false ? $content : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getStream(string $key, CacheScope $scope, string $usage)
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!$this->isValid($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$handle = @fopen($path, 'rb');
|
||||
|
||||
return $handle !== false ? $handle : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Write data file
|
||||
$tempPath = $path . '.tmp.' . getmypid();
|
||||
|
||||
if (file_put_contents($tempPath, $data, LOCK_EX) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chmod($tempPath, 0600);
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
$this->writeMetadata($path, [
|
||||
'mimeType' => $mimeType,
|
||||
'size' => strlen($data),
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Write data file from stream
|
||||
$tempPath = $path . '.tmp.' . getmypid();
|
||||
$dest = @fopen($tempPath, 'wb');
|
||||
|
||||
if ($dest === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$size = 0;
|
||||
while (!feof($stream)) {
|
||||
$chunk = fread($stream, 8192);
|
||||
if ($chunk === false) {
|
||||
fclose($dest);
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
$written = fwrite($dest, $chunk);
|
||||
if ($written === false) {
|
||||
fclose($dest);
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
$size += $written;
|
||||
}
|
||||
|
||||
fclose($dest);
|
||||
chmod($tempPath, 0600);
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
$this->writeMetadata($path, [
|
||||
'mimeType' => $mimeType,
|
||||
'size' => $size,
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
return $this->isValid($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
$metaPath = $path . '.meta';
|
||||
|
||||
$result = true;
|
||||
|
||||
if (file_exists($path)) {
|
||||
$result = @unlink($path);
|
||||
}
|
||||
|
||||
if (file_exists($metaPath)) {
|
||||
@unlink($metaPath);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPath(string $key, CacheScope $scope, string $usage): ?string
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!$this->isValid($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getMetadata(string $key, CacheScope $scope, string $usage): ?array
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->readMetadata($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$files = glob($dir . '/*');
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file) && !str_ends_with($file, '.meta')) {
|
||||
if (@unlink($file)) {
|
||||
$count++;
|
||||
// Also remove metadata file
|
||||
@unlink($file . '.meta');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cleanup(): int
|
||||
{
|
||||
$count = 0;
|
||||
$now = time();
|
||||
|
||||
// Scan all tenant directories
|
||||
$tenantDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
|
||||
|
||||
foreach ($tenantDirs as $tenantDir) {
|
||||
$files = $this->findBlobFiles($tenantDir);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$meta = $this->readMetadata($file);
|
||||
|
||||
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < $now) {
|
||||
if (@unlink($file)) {
|
||||
@unlink($file . '.meta');
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a blob is valid (exists and not expired)
|
||||
*/
|
||||
private function isValid(string $path): bool
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$meta = $this->readMetadata($path);
|
||||
|
||||
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < time()) {
|
||||
@unlink($path);
|
||||
@unlink($path . '.meta');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full path for a blob
|
||||
*/
|
||||
private function buildPath(string $key, CacheScope $scope, string $usage): string
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
$hash = $this->hashKey($key);
|
||||
|
||||
return $dir . '/' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the directory path for a scope/usage combination
|
||||
*/
|
||||
private function buildDir(CacheScope $scope, string $usage): string
|
||||
{
|
||||
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
|
||||
|
||||
return match ($scope) {
|
||||
CacheScope::Global => $this->basePath . '/_global/cache/' . $usage,
|
||||
CacheScope::Tenant => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/cache/' . $usage,
|
||||
CacheScope::User => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/' . ($this->userId ?? '_unknown') . '/cache/' . $usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a cache key for filesystem safety
|
||||
*/
|
||||
private function hashKey(string $key): string
|
||||
{
|
||||
// Extract extension if present in key
|
||||
$ext = '';
|
||||
if (preg_match('/\.([a-zA-Z0-9]{2,5})$/', $key, $matches)) {
|
||||
$ext = '.' . strtolower($matches[1]);
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
|
||||
$hash = substr(hash('sha256', $key), 0, 16);
|
||||
|
||||
return $safe . '_' . $hash . $ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read metadata for a blob
|
||||
*/
|
||||
private function readMetadata(string $path): ?array
|
||||
{
|
||||
$metaPath = $path . '.meta';
|
||||
|
||||
if (!file_exists($metaPath)) {
|
||||
// Return basic metadata from file stats
|
||||
if (file_exists($path)) {
|
||||
$stat = stat($path);
|
||||
return [
|
||||
'mimeType' => null,
|
||||
'size' => $stat['size'] ?? 0,
|
||||
'createdAt' => $stat['ctime'] ?? time(),
|
||||
'expiresAt' => null,
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($metaPath);
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = @unserialize($content);
|
||||
|
||||
return is_array($meta) ? $meta : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write metadata for a blob
|
||||
*/
|
||||
private function writeMetadata(string $path, array $metadata): bool
|
||||
{
|
||||
$metaPath = $path . '.meta';
|
||||
|
||||
return file_put_contents($metaPath, serialize($metadata), LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all blob files in a directory
|
||||
*/
|
||||
private function findBlobFiles(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
$cacheDirs = glob($dir . '/cache/*', GLOB_ONLYDIR) ?: [];
|
||||
$cacheDirs = array_merge($cacheDirs, glob($dir . '/*/cache/*', GLOB_ONLYDIR) ?: []);
|
||||
|
||||
foreach ($cacheDirs as $cacheDir) {
|
||||
$blobFiles = glob($cacheDir . '/*');
|
||||
foreach ($blobFiles as $file) {
|
||||
if (is_file($file) && !str_ends_with($file, '.meta')) {
|
||||
$files[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
346
shared/lib/Cache/Store/FileEphemeralCache.php
Normal file
346
shared/lib/Cache/Store/FileEphemeralCache.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache\Store;
|
||||
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
|
||||
/**
|
||||
* File-based Ephemeral Cache Implementation
|
||||
*
|
||||
* Stores cache entries as serialized files with expiration metadata.
|
||||
* Directory structure: var/cache/{scope}/{usage}/{key_hash}.cache
|
||||
*/
|
||||
class FileEphemeralCache implements EphemeralCacheInterface
|
||||
{
|
||||
private string $basePath;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $userId = null;
|
||||
|
||||
public function __construct(string $projectDir)
|
||||
{
|
||||
$this->basePath = rtrim($projectDir, '/') . '/var/cache';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tenant context for scoped operations
|
||||
*/
|
||||
public function setTenantContext(?string $tenantId): void
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the user context for scoped operations
|
||||
*/
|
||||
public function setUserContext(?string $userId): void
|
||||
{
|
||||
$this->userId = $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): mixed
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entry = $this->readEntry($path);
|
||||
|
||||
if ($entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) {
|
||||
@unlink($path);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $entry['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$ttl = $ttl ?? self::DEFAULT_TTL;
|
||||
$entry = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => $ttl > 0 ? time() + $ttl : 0,
|
||||
];
|
||||
|
||||
return $this->writeEntry($path, $entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
return $this->get($key, $scope, $usage) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (file_exists($path)) {
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$files = glob($dir . '/*.cache');
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (@unlink($file)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cleanup(): int
|
||||
{
|
||||
$count = 0;
|
||||
$now = time();
|
||||
|
||||
// Scan all scope directories
|
||||
$scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
|
||||
|
||||
foreach ($scopeDirs as $scopeDir) {
|
||||
$files = $this->findCacheFiles($scopeDir);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$entry = $this->readEntry($file);
|
||||
|
||||
if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) {
|
||||
if (@unlink($file)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed
|
||||
{
|
||||
$value = $this->get($key, $scope, $usage);
|
||||
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value = $callback();
|
||||
$this->set($key, $value, $scope, $usage, $ttl);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
// Use file locking for atomic increment
|
||||
$handle = @fopen($path, 'c+');
|
||||
if ($handle === false) {
|
||||
// File doesn't exist, create with initial value
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$handle = @fopen($path, 'c+');
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
fclose($handle);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = stream_get_contents($handle);
|
||||
$entry = $content ? @unserialize($content) : null;
|
||||
|
||||
if ($entry === null || ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time())) {
|
||||
// Initialize new entry
|
||||
$newValue = $amount;
|
||||
$entry = [
|
||||
'key' => $key,
|
||||
'value' => $newValue,
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => time() + self::DEFAULT_TTL,
|
||||
];
|
||||
} else {
|
||||
$newValue = (int)$entry['value'] + $amount;
|
||||
$entry['value'] = $newValue;
|
||||
}
|
||||
|
||||
ftruncate($handle, 0);
|
||||
rewind($handle);
|
||||
fwrite($handle, serialize($entry));
|
||||
|
||||
return $newValue;
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false
|
||||
{
|
||||
return $this->increment($key, $scope, $usage, -$amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full path for a cache entry
|
||||
*/
|
||||
private function buildPath(string $key, CacheScope $scope, string $usage): string
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
$hash = $this->hashKey($key);
|
||||
|
||||
return $dir . '/' . $hash . '.cache';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the directory path for a scope/usage combination
|
||||
*/
|
||||
private function buildDir(CacheScope $scope, string $usage): string
|
||||
{
|
||||
$prefix = $scope->buildPrefix($this->tenantId, $this->userId);
|
||||
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
|
||||
|
||||
return $this->basePath . '/' . $prefix . '/' . $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a cache key for filesystem safety
|
||||
*/
|
||||
private function hashKey(string $key): string
|
||||
{
|
||||
// Use a prefix of the original key for debugging + hash for uniqueness
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
|
||||
$hash = substr(hash('sha256', $key), 0, 16);
|
||||
|
||||
return $safe . '_' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and unserialize a cache entry
|
||||
*/
|
||||
private function readEntry(string $path): ?array
|
||||
{
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entry = @unserialize($content);
|
||||
|
||||
if (!is_array($entry) || !isset($entry['value'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize and write a cache entry atomically
|
||||
*/
|
||||
private function writeEntry(string $path, array $entry): bool
|
||||
{
|
||||
$content = serialize($entry);
|
||||
$tempPath = $path . '.tmp.' . getmypid();
|
||||
|
||||
if (file_put_contents($tempPath, $content, LOCK_EX) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chmod($tempPath, 0600);
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all cache files in a directory
|
||||
*/
|
||||
private function findCacheFiles(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'cache') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
433
shared/lib/Cache/Store/FilePersistentCache.php
Normal file
433
shared/lib/Cache/Store/FilePersistentCache.php
Normal file
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache\Store;
|
||||
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
|
||||
/**
|
||||
* File-based Persistent Cache Implementation
|
||||
*
|
||||
* Stores long-lived cache entries with support for tagging and versioning.
|
||||
* Directory structure: var/cache/{scope}/{usage}/{key_hash}.cache
|
||||
*/
|
||||
class FilePersistentCache implements PersistentCacheInterface
|
||||
{
|
||||
private string $basePath;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $userId = null;
|
||||
|
||||
public function __construct(string $projectDir)
|
||||
{
|
||||
$this->basePath = rtrim($projectDir, '/') . '/var/cache';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tenant context for scoped operations
|
||||
*/
|
||||
public function setTenantContext(?string $tenantId): void
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the user context for scoped operations
|
||||
*/
|
||||
public function setUserContext(?string $userId): void
|
||||
{
|
||||
$this->userId = $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): mixed
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entry = $this->readEntry($path);
|
||||
|
||||
if ($entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration (0 = never expires)
|
||||
if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) {
|
||||
@unlink($path);
|
||||
$this->removeFromTagIndex($key, $scope, $usage, $entry['tags'] ?? []);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $entry['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool
|
||||
{
|
||||
return $this->setWithTags($key, $value, $scope, $usage, [], $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from old tags if entry exists
|
||||
$existingEntry = $this->readEntry($path);
|
||||
if ($existingEntry !== null && !empty($existingEntry['tags'])) {
|
||||
$this->removeFromTagIndex($key, $scope, $usage, $existingEntry['tags']);
|
||||
}
|
||||
|
||||
$ttl = $ttl ?? self::DEFAULT_TTL;
|
||||
$entry = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'tags' => $tags,
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => $ttl > 0 ? time() + $ttl : 0,
|
||||
];
|
||||
|
||||
$result = $this->writeEntry($path, $entry);
|
||||
|
||||
if ($result && !empty($tags)) {
|
||||
$this->addToTagIndex($key, $scope, $usage, $tags);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
return $this->get($key, $scope, $usage) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (file_exists($path)) {
|
||||
$entry = $this->readEntry($path);
|
||||
if ($entry !== null && !empty($entry['tags'])) {
|
||||
$this->removeFromTagIndex($key, $scope, $usage, $entry['tags']);
|
||||
}
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$files = glob($dir . '/*.cache');
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (@unlink($file)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear tag index
|
||||
$tagIndexPath = $dir . '/.tags';
|
||||
if (file_exists($tagIndexPath)) {
|
||||
@unlink($tagIndexPath);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cleanup(): int
|
||||
{
|
||||
$count = 0;
|
||||
$now = time();
|
||||
|
||||
// Scan all scope directories
|
||||
$scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
|
||||
|
||||
foreach ($scopeDirs as $scopeDir) {
|
||||
$files = $this->findCacheFiles($scopeDir);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$entry = $this->readEntry($file);
|
||||
|
||||
if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) {
|
||||
if (@unlink($file)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int
|
||||
{
|
||||
$tagIndex = $this->readTagIndex($scope, $usage);
|
||||
|
||||
if (!isset($tagIndex[$tag])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$keys = $tagIndex[$tag];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if ($this->delete($key, $scope, $usage)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getVersion(string $key, CacheScope $scope, string $usage): ?int
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entry = $this->readEntry($path);
|
||||
|
||||
return $entry['createdAt'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool
|
||||
{
|
||||
$version = $this->getVersion($key, $scope, $usage);
|
||||
|
||||
if ($version === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $version < $reference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full path for a cache entry
|
||||
*/
|
||||
private function buildPath(string $key, CacheScope $scope, string $usage): string
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
$hash = $this->hashKey($key);
|
||||
|
||||
return $dir . '/' . $hash . '.cache';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the directory path for a scope/usage combination
|
||||
*/
|
||||
private function buildDir(CacheScope $scope, string $usage): string
|
||||
{
|
||||
$prefix = $scope->buildPrefix($this->tenantId, $this->userId);
|
||||
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
|
||||
|
||||
return $this->basePath . '/' . $prefix . '/' . $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a cache key for filesystem safety
|
||||
*/
|
||||
private function hashKey(string $key): string
|
||||
{
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
|
||||
$hash = substr(hash('sha256', $key), 0, 16);
|
||||
|
||||
return $safe . '_' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and unserialize a cache entry
|
||||
*/
|
||||
private function readEntry(string $path): ?array
|
||||
{
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entry = @unserialize($content);
|
||||
|
||||
if (!is_array($entry) || !isset($entry['value'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize and write a cache entry atomically
|
||||
*/
|
||||
private function writeEntry(string $path, array $entry): bool
|
||||
{
|
||||
$content = serialize($entry);
|
||||
$tempPath = $path . '.tmp.' . getmypid();
|
||||
|
||||
if (file_put_contents($tempPath, $content, LOCK_EX) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chmod($tempPath, 0600);
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the tag index for a usage bucket
|
||||
*/
|
||||
private function readTagIndex(CacheScope $scope, string $usage): array
|
||||
{
|
||||
$path = $this->buildDir($scope, $usage) . '/.tags';
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
if ($content === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$index = @unserialize($content);
|
||||
|
||||
return is_array($index) ? $index : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the tag index for a usage bucket
|
||||
*/
|
||||
private function writeTagIndex(CacheScope $scope, string $usage, array $index): bool
|
||||
{
|
||||
$path = $this->buildDir($scope, $usage) . '/.tags';
|
||||
$dir = dirname($path);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return file_put_contents($path, serialize($index), LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key to the tag index
|
||||
*/
|
||||
private function addToTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void
|
||||
{
|
||||
$index = $this->readTagIndex($scope, $usage);
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
if (!isset($index[$tag])) {
|
||||
$index[$tag] = [];
|
||||
}
|
||||
if (!in_array($key, $index[$tag], true)) {
|
||||
$index[$tag][] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
$this->writeTagIndex($scope, $usage, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a key from the tag index
|
||||
*/
|
||||
private function removeFromTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void
|
||||
{
|
||||
$index = $this->readTagIndex($scope, $usage);
|
||||
$changed = false;
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
if (isset($index[$tag])) {
|
||||
$pos = array_search($key, $index[$tag], true);
|
||||
if ($pos !== false) {
|
||||
unset($index[$tag][$pos]);
|
||||
$index[$tag] = array_values($index[$tag]);
|
||||
$changed = true;
|
||||
|
||||
if (empty($index[$tag])) {
|
||||
unset($index[$tag]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$this->writeTagIndex($scope, $usage, $index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all cache files in a directory
|
||||
*/
|
||||
private function findCacheFiles(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'cache') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
24
shared/lib/Chrono/Collection/CollectionContent.php
Normal file
24
shared/lib/Chrono/Collection/CollectionContent.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum CollectionContent: string implements JsonSerializable {
|
||||
|
||||
case Event = 'event';
|
||||
case Task = 'task';
|
||||
case Journal = 'journal';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
26
shared/lib/Chrono/Collection/CollectionPermissions.php
Normal file
26
shared/lib/Chrono/Collection/CollectionPermissions.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum CollectionPermissions: string implements JsonSerializable {
|
||||
|
||||
case View = 'view';
|
||||
case Create = 'create';
|
||||
case Modify = 'modify';
|
||||
case Destroy = 'destroy';
|
||||
case Share = 'share';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
23
shared/lib/Chrono/Collection/CollectionRoles.php
Normal file
23
shared/lib/Chrono/Collection/CollectionRoles.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum CollectionRoles: string implements JsonSerializable {
|
||||
|
||||
case System = 'system';
|
||||
case Individual = 'individual';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
160
shared/lib/Chrono/Collection/ICollectionBase.php
Normal file
160
shared/lib/Chrono/Collection/ICollectionBase.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
interface ICollectionBase extends JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'chrono.collection';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_PROVIDER = 'provider';
|
||||
public const JSON_PROPERTY_SERVICE = 'service';
|
||||
public const JSON_PROPERTY_IN = 'in';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_DESCRIPTION = 'description';
|
||||
public const JSON_PROPERTY_PRIORITY = 'priority';
|
||||
public const JSON_PROPERTY_VISIBILITY = 'visibility';
|
||||
public const JSON_PROPERTY_COLOR = 'color';
|
||||
public const JSON_PROPERTY_CREATED = 'created';
|
||||
public const JSON_PROPERTY_MODIFIED = 'modified';
|
||||
public const JSON_PROPERTY_ENABLED = 'enabled';
|
||||
public const JSON_PROPERTY_SIGNATURE = 'signature';
|
||||
public const JSON_PROPERTY_PERMISSIONS = 'permissions';
|
||||
public const JSON_PROPERTY_ROLES = 'roles';
|
||||
public const JSON_PROPERTY_CONTENTS = 'contents';
|
||||
|
||||
/**
|
||||
* Unique identifier of the service this collection belongs to
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function in(): string|int|null;
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying this collection (e.g. 1 or collection1 or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the creation date of this collection
|
||||
*/
|
||||
public function created(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the modification date of this collection
|
||||
*/
|
||||
public function modified(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Lists all supported attributes
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,array<string,bool>>
|
||||
*/
|
||||
public function attributes(): array;
|
||||
|
||||
/**
|
||||
* Gets the signature of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function signature(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the role(s) of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function roles(): array;
|
||||
|
||||
/**
|
||||
* Checks if this collection supports the given role
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function role(CollectionRoles $value): bool;
|
||||
|
||||
/**
|
||||
* Gets the content types of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function contents(): array;
|
||||
|
||||
/**
|
||||
* Checks if this collection contains the given content type
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function contains(CollectionContent $value): bool;
|
||||
|
||||
/**
|
||||
* Gets the active status of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Gets the permissions of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPermissions(): array;
|
||||
|
||||
/**
|
||||
* Checks if this collection has the given permission
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function hasPermission(CollectionPermissions $permission): bool;
|
||||
|
||||
/**
|
||||
* Gets the human friendly name of this collection (e.g. Personal Calendar)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getLabel(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the human friendly description of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getDescription(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the priority of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPriority(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the visibility of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getVisibility(): ?bool;
|
||||
|
||||
/**
|
||||
* Gets the color of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getColor(): ?string;
|
||||
|
||||
}
|
||||
58
shared/lib/Chrono/Collection/ICollectionMutable.php
Normal file
58
shared/lib/Chrono/Collection/ICollectionMutable.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
interface ICollectionMutable extends ICollectionBase, JsonDeserializable {
|
||||
|
||||
/**
|
||||
* Sets the active status of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setEnabled(bool $value): self;
|
||||
|
||||
/**
|
||||
* Sets the human friendly name of this collection (e.g. Personal Calendar)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setLabel(string $value): self;
|
||||
|
||||
/**
|
||||
* Sets the human friendly description of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setDescription(?string $value): self;
|
||||
|
||||
/**
|
||||
* Sets the priority of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setPriority(?int $value): self;
|
||||
|
||||
/**
|
||||
* Sets the visibility of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setVisibility(?bool $value): self;
|
||||
|
||||
/**
|
||||
* Sets the color of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setColor(?string $value): self;
|
||||
|
||||
}
|
||||
25
shared/lib/Chrono/Entity/EntityPermissions.php
Normal file
25
shared/lib/Chrono/Entity/EntityPermissions.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum EntityPermissions: string implements JsonSerializable {
|
||||
|
||||
case View = 'view';
|
||||
case Modify = 'modify';
|
||||
case Delete = 'delete';
|
||||
case Share = 'share';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
93
shared/lib/Chrono/Entity/IEntityBase.php
Normal file
93
shared/lib/Chrono/Entity/IEntityBase.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface IEntityBase extends \JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'chrono.entity';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_IN = 'in';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_DATA = 'data';
|
||||
public const JSON_PROPERTY_CREATED = 'created';
|
||||
public const JSON_PROPERTY_MODIFIED = 'modified';
|
||||
public const JSON_PROPERTY_SIGNATURE = 'signature';
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function in(): string|int;
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying this entity (e.g. 1 or Entity or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the creation date of this entity
|
||||
*/
|
||||
public function created(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the modification date of this entity
|
||||
*/
|
||||
public function modified(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the signature of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function signature(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the priority of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPriority(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the visibility of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getVisibility(): ?bool;
|
||||
|
||||
/**
|
||||
* Gets the color of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getColor(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the object data (event, task, or journal).
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getDataObject(): object|null;
|
||||
|
||||
/**
|
||||
* Gets the raw data as an associative array or JSON string.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array|string|null
|
||||
*/
|
||||
public function getDataJson(): array|string|null;
|
||||
|
||||
}
|
||||
51
shared/lib/Chrono/Entity/IEntityMutable.php
Normal file
51
shared/lib/Chrono/Entity/IEntityMutable.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
interface IEntityMutable extends IEntityBase, JsonDeserializable {
|
||||
|
||||
/**
|
||||
* Sets the priority of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setPriority(?int $value): static;
|
||||
|
||||
/**
|
||||
* Sets the visibility of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setVisibility(?bool $value): static;
|
||||
|
||||
/**
|
||||
* Sets the color of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setColor(?string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the object as a class instance.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setDataObject(object $value): static;
|
||||
|
||||
/**
|
||||
* Sets the object data from a json string
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setDataJson(array|string $value): static;
|
||||
|
||||
}
|
||||
15
shared/lib/Chrono/Event/EventAvailabilityTypes.php
Normal file
15
shared/lib/Chrono/Event/EventAvailabilityTypes.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventAvailabilityTypes: string {
|
||||
case Free = 'free';
|
||||
case Busy = 'busy';
|
||||
}
|
||||
50
shared/lib/Chrono/Event/EventCommonObject.php
Normal file
50
shared/lib/Chrono/Event/EventCommonObject.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use DateInterval;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventCommonObject extends JsonSerializableObject {
|
||||
|
||||
public int|null $sequence = null;
|
||||
public DateTimeZone|null $timeZone = null;
|
||||
public DateTime|DateTimeImmutable|null $startsOn = null;
|
||||
public DateTimeZone|null $startsTZ = null;
|
||||
public DateTime|DateTimeImmutable|null $endsOn = null;
|
||||
public DateTimeZone|null $endsTZ = null;
|
||||
public DateInterval|null $duration = null;
|
||||
public bool|null $timeless = false;
|
||||
public string|null $label = null;
|
||||
public string|null $description = null;
|
||||
public EventLocationPhysicalCollection $locationsPhysical;
|
||||
public EventLocationVirtualCollection $locationsVirtual;
|
||||
public EventAvailabilityTypes|null $availability = null;
|
||||
public EventSensitivityTypes|null $sensitivity = null;
|
||||
public int|null $priority = null;
|
||||
public string|null $color = null;
|
||||
public EventTagCollection $tags;
|
||||
public EventOrganizerObject $organizer;
|
||||
public EventParticipantCollection $participants;
|
||||
public EventNotificationCollection $notifications;
|
||||
|
||||
public function __construct() {
|
||||
$this->participants = new EventParticipantCollection();
|
||||
$this->locationsPhysical = new EventLocationPhysicalCollection();
|
||||
$this->locationsVirtual = new EventLocationVirtualCollection();
|
||||
$this->notifications = new EventNotificationCollection();
|
||||
$this->organizer = new EventOrganizerObject();
|
||||
$this->tags = new EventTagCollection();
|
||||
}
|
||||
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventLocationPhysicalCollection.php
Normal file
20
shared/lib/Chrono/Event/EventLocationPhysicalCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventLocationPhysicalCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, EventLocationPhysicalObject::class, 'string');
|
||||
}
|
||||
|
||||
}
|
||||
22
shared/lib/Chrono/Event/EventLocationPhysicalObject.php
Normal file
22
shared/lib/Chrono/Event/EventLocationPhysicalObject.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventLocationPhysicalObject extends JsonSerializableObject {
|
||||
|
||||
public string|null $identifier = null;
|
||||
public string|null $label = null;
|
||||
public string|null $description = null;
|
||||
public string|null $relation = null; // e.g. start, end of event
|
||||
public string|null $timeZone = null;
|
||||
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventLocationVirtualCollection.php
Normal file
20
shared/lib/Chrono/Event/EventLocationVirtualCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventLocationVirtualCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, EventLocationVirtualObject::class, 'string');
|
||||
}
|
||||
|
||||
}
|
||||
22
shared/lib/Chrono/Event/EventLocationVirtualObject.php
Normal file
22
shared/lib/Chrono/Event/EventLocationVirtualObject.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventLocationVirtualObject extends JsonSerializableObject {
|
||||
|
||||
public string|null $identifier = null;
|
||||
public string|null $label = null;
|
||||
public string|null $description = null;
|
||||
public string|null $relation = null;
|
||||
public string|null $location = null;
|
||||
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventMutationCollection.php
Normal file
20
shared/lib/Chrono/Event/EventMutationCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventMutationCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, EventMutationObject::class, 'string');
|
||||
}
|
||||
|
||||
}
|
||||
21
shared/lib/Chrono/Event/EventMutationObject.php
Normal file
21
shared/lib/Chrono/Event/EventMutationObject.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class EventMutationObject extends EventCommonObject {
|
||||
|
||||
public DateTime|DateTimeImmutable|null $mutationId = null;
|
||||
public string|null $mutationTz = null;
|
||||
public bool|null $mutationExclusion = null;
|
||||
|
||||
}
|
||||
15
shared/lib/Chrono/Event/EventNotificationAnchorTypes.php
Normal file
15
shared/lib/Chrono/Event/EventNotificationAnchorTypes.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventNotificationAnchorTypes: string {
|
||||
case Start = 'start';
|
||||
case End = 'end';
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventNotificationCollection.php
Normal file
20
shared/lib/Chrono/Event/EventNotificationCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventNotificationCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, EventNotificationObject::class, 'string');
|
||||
}
|
||||
|
||||
}
|
||||
24
shared/lib/Chrono/Event/EventNotificationObject.php
Normal file
24
shared/lib/Chrono/Event/EventNotificationObject.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use DateInterval;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventNotificationObject extends JsonSerializableObject {
|
||||
public string|null $identifier = null;
|
||||
public EventNotificationTypes|null $Type = null;
|
||||
public EventNotificationPatterns|null $Pattern = null;
|
||||
public DateTime|DateTimeImmutable|null $When = null;
|
||||
public EventNotificationAnchorTypes|null $Anchor = null;
|
||||
public DateInterval|null $Offset = null;
|
||||
}
|
||||
16
shared/lib/Chrono/Event/EventNotificationPatterns.php
Normal file
16
shared/lib/Chrono/Event/EventNotificationPatterns.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventNotificationPatterns: string {
|
||||
case Absolute = 'absolute';
|
||||
case Relative = 'relative';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
16
shared/lib/Chrono/Event/EventNotificationTypes.php
Normal file
16
shared/lib/Chrono/Event/EventNotificationTypes.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventNotificationTypes: string {
|
||||
case Visual = 'visual';
|
||||
case Audible = 'audible';
|
||||
case Email = 'email';
|
||||
}
|
||||
31
shared/lib/Chrono/Event/EventObject.php
Normal file
31
shared/lib/Chrono/Event/EventObject.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
class EventObject extends EventCommonObject {
|
||||
|
||||
// Meta Information
|
||||
public string $type = 'event';
|
||||
public int $version = 1;
|
||||
public string|null $urid = null;
|
||||
public ?DateTimeInterface $created = null;
|
||||
public ?DateTimeInterface $modified = null;
|
||||
|
||||
public EventOccurrenceObject|null $pattern = null;
|
||||
public EventMutationCollection $mutations;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->mutations = new EventMutationCollection();
|
||||
}
|
||||
|
||||
}
|
||||
36
shared/lib/Chrono/Event/EventOccurrenceObject.php
Normal file
36
shared/lib/Chrono/Event/EventOccurrenceObject.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventOccurrenceObject extends JsonSerializableObject {
|
||||
public EventOccurrencePatternTypes|null $pattern = null; // Pattern - Absolute / Relative
|
||||
public EventOccurrencePrecisionTypes|null $precision = null; // Time Interval
|
||||
public int|null $interval = null; // Time Interval - Every 2 Days / Every 4 Weeks / Every 1 Year
|
||||
public int|null $iterations = null; // Number of recurrence
|
||||
public DateTime|DateTimeImmutable|null $concludes = null; // Date to stop recurrence
|
||||
public String|null $scale = null; // calendar system in which this recurrence rule operates
|
||||
public array $onDayOfWeek = [];
|
||||
public array $onDayOfMonth = [];
|
||||
public array $onDayOfYear = [];
|
||||
public array $onWeekOfMonth = [];
|
||||
public array $onWeekOfYear = [];
|
||||
public array $onMonthOfYear = [];
|
||||
public array $onHour = [];
|
||||
public array $onMinute = [];
|
||||
public array $onSecond = [];
|
||||
public array $onPosition = [];
|
||||
|
||||
public function __construct() {
|
||||
}
|
||||
}
|
||||
15
shared/lib/Chrono/Event/EventOccurrencePatternTypes.php
Normal file
15
shared/lib/Chrono/Event/EventOccurrencePatternTypes.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventOccurrencePatternTypes: string {
|
||||
case Absolute = 'absolute';
|
||||
case Relative = 'relative';
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php
Normal file
20
shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventOccurrencePrecisionTypes: string {
|
||||
case Yearly = 'yearly';
|
||||
case Monthly = 'monthly';
|
||||
case Weekly = 'weekly';
|
||||
case Daily = 'daily';
|
||||
case Hourly = 'hourly';
|
||||
case Minutely = 'minutely';
|
||||
case Secondly = 'secondly';
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventOrganizerObject.php
Normal file
20
shared/lib/Chrono/Event/EventOrganizerObject.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventOrganizerObject extends JsonSerializableObject {
|
||||
|
||||
public EventParticipantRealm|null $realm = null; // E - external, I - internal
|
||||
public string|null $address = null;
|
||||
public string|null $name = null;
|
||||
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventParticipantCollection.php
Normal file
20
shared/lib/Chrono/Event/EventParticipantCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventParticipantCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, EventParticipantObject::class, 'string');
|
||||
}
|
||||
|
||||
}
|
||||
31
shared/lib/Chrono/Event/EventParticipantObject.php
Normal file
31
shared/lib/Chrono/Event/EventParticipantObject.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class EventParticipantObject extends JsonSerializableObject {
|
||||
|
||||
public string|null $identifier = null;
|
||||
public EventParticipantRealm|null $realm = null; // E - external, I - internal
|
||||
public string|null $name = null;
|
||||
public string|null $description = null;
|
||||
public string|null $language = null;
|
||||
public string|null $address = null;
|
||||
public EventParticipantTypes|null $type = null;
|
||||
public EventParticipantStatusTypes|null $status = null;
|
||||
public string|null $comment = null;
|
||||
public EventParticipantRoleCollection $roles;
|
||||
|
||||
public function __construct() {
|
||||
$this->roles = new EventParticipantRoleCollection();
|
||||
}
|
||||
|
||||
}
|
||||
15
shared/lib/Chrono/Event/EventParticipantRealm.php
Normal file
15
shared/lib/Chrono/Event/EventParticipantRealm.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventParticipantRealm: string {
|
||||
case Internal = 'I';
|
||||
case External = 'E';
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventParticipantRoleCollection.php
Normal file
20
shared/lib/Chrono/Event/EventParticipantRoleCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventParticipantRoleCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, EventParticipantRoleTypes::class);
|
||||
}
|
||||
|
||||
}
|
||||
19
shared/lib/Chrono/Event/EventParticipantRoleTypes.php
Normal file
19
shared/lib/Chrono/Event/EventParticipantRoleTypes.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventParticipantRoleTypes: string {
|
||||
case Owner = 'owner';
|
||||
case Chair = 'chair';
|
||||
case Attendee = 'attendee';
|
||||
case Optional = 'optional';
|
||||
case Informational = 'informational';
|
||||
case Contact = 'contact';
|
||||
}
|
||||
18
shared/lib/Chrono/Event/EventParticipantStatusTypes.php
Normal file
18
shared/lib/Chrono/Event/EventParticipantStatusTypes.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventParticipantStatusTypes: string {
|
||||
case None = 'none';
|
||||
case Accepted = 'accepted';
|
||||
case Declined = 'declined';
|
||||
case Tentative = 'tentative';
|
||||
case Delegated = 'delegated';
|
||||
}
|
||||
18
shared/lib/Chrono/Event/EventParticipantTypes.php
Normal file
18
shared/lib/Chrono/Event/EventParticipantTypes.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventParticipantTypes: string {
|
||||
case Unknown = 'unknown';
|
||||
case Individual = 'individual';
|
||||
case Group = 'group';
|
||||
case Resource = 'resource';
|
||||
case Location = 'location';
|
||||
}
|
||||
16
shared/lib/Chrono/Event/EventSensitivityTypes.php
Normal file
16
shared/lib/Chrono/Event/EventSensitivityTypes.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
enum EventSensitivityTypes: string {
|
||||
case Public = 'public';
|
||||
case Private = 'private';
|
||||
case Secret = 'secret';
|
||||
}
|
||||
20
shared/lib/Chrono/Event/EventTagCollection.php
Normal file
20
shared/lib/Chrono/Event/EventTagCollection.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Event;
|
||||
|
||||
use KTXF\Json\JsonSerializableCollection;
|
||||
|
||||
class EventTagCollection extends JsonSerializableCollection {
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
parent::__construct($data, 'string');
|
||||
}
|
||||
|
||||
}
|
||||
96
shared/lib/Chrono/Provider/IProviderBase.php
Normal file
96
shared/lib/Chrono/Provider/IProviderBase.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Provider;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Chrono\Service\IServiceBase;
|
||||
|
||||
interface IProviderBase extends JsonSerializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_LIST = 'ServiceList';
|
||||
public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch';
|
||||
public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant';
|
||||
|
||||
public const JSON_TYPE = 'chrono.provider';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
|
||||
/**
|
||||
* Confirms if specific capability is supported (e.g. 'ServiceList')
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string;
|
||||
|
||||
/**
|
||||
* The localized human friendly name of this provider (e.g. System Calendar Provider)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Retrieve collection of services for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param array $filter filter criteria
|
||||
*
|
||||
* @return array<string,IServiceBase> collection of service objects
|
||||
*/
|
||||
public function serviceList(string $tenantId, string $userId, array $filter): array;
|
||||
|
||||
/**
|
||||
* Determine if any services are configured for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param int|string ...$identifiers variadic collection of service identifiers
|
||||
*
|
||||
* @return array<string,bool> collection of service identifiers with boolean values indicating if the service is available
|
||||
*/
|
||||
public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Retrieve a service with a specific identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param string|int $identifier service identifier
|
||||
*
|
||||
* @return IServiceBase|null returns service object or null if non found
|
||||
*/
|
||||
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase;
|
||||
|
||||
}
|
||||
50
shared/lib/Chrono/Provider/IProviderServiceMutate.php
Normal file
50
shared/lib/Chrono/Provider/IProviderServiceMutate.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Provider;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
use KTXF\Chrono\Service\IServiceBase;
|
||||
|
||||
interface IProviderServiceMutate extends JsonDeserializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh';
|
||||
public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate';
|
||||
public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate';
|
||||
public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy';
|
||||
|
||||
/**
|
||||
* construct and new blank service instance
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceFresh(string $uid = ''): IServiceBase;
|
||||
|
||||
/**
|
||||
* create a service configuration for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceCreate(string $uid, IServiceBase $service): string;
|
||||
|
||||
/**
|
||||
* modify a service configuration for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceModify(string $uid, IServiceBase $service): string;
|
||||
|
||||
/**
|
||||
* delete a service configuration for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceDestroy(string $uid, IServiceBase $service): bool;
|
||||
|
||||
}
|
||||
197
shared/lib/Chrono/Service/IServiceBase.php
Normal file
197
shared/lib/Chrono/Service/IServiceBase.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Service;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Chrono\Collection\ICollectionBase;
|
||||
use KTXF\Resource\Filter\IFilter;
|
||||
use KTXF\Resource\Range\IRange;
|
||||
use KTXF\Resource\Range\RangeType;
|
||||
use KTXF\Resource\Sort\ISort;
|
||||
|
||||
interface IServiceBase extends JsonSerializable {
|
||||
|
||||
public const CAPABILITY_COLLECTION_LIST = 'CollectionList';
|
||||
public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter';
|
||||
public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort';
|
||||
public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant';
|
||||
public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch';
|
||||
|
||||
public const CAPABILITY_ENTITY_LIST = 'EntityList';
|
||||
public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter';
|
||||
public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort';
|
||||
public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange';
|
||||
public const CAPABILITY_ENTITY_DELTA = 'EntityDelta';
|
||||
public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant';
|
||||
public const CAPABILITY_ENTITY_FETCH = 'EntityFetch';
|
||||
|
||||
public const CAPABILITY_FILTER_ANY = '*';
|
||||
public const CAPABILITY_FILTER_ID = 'id';
|
||||
public const CAPABILITY_FILTER_URID = 'urid';
|
||||
public const CAPABILITY_FILTER_LABEL = 'label';
|
||||
public const CAPABILITY_FILTER_DESCRIPTION = 'description';
|
||||
|
||||
public const CAPABILITY_SORT_ID = 'id';
|
||||
public const CAPABILITY_SORT_URID = 'urid';
|
||||
public const CAPABILITY_SORT_LABEL = 'label';
|
||||
public const CAPABILITY_SORT_PRIORITY = 'priority';
|
||||
|
||||
public const CAPABILITY_RANGE_TALLY = 'tally';
|
||||
public const CAPABILITY_RANGE_TALLY_ABSOLUTE = 'absolute';
|
||||
public const CAPABILITY_RANGE_TALLY_RELATIVE = 'relative';
|
||||
public const CAPABILITY_RANGE_DATE = 'date';
|
||||
|
||||
public const JSON_TYPE = 'chrono.service';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_PROVIDER = 'provider';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
public const JSON_PROPERTY_ENABLED = 'enabled';
|
||||
|
||||
/**
|
||||
* Confirms if specific capability is supported
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $value required ability e.g. 'EntityList'
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* Unique identifier of the provider this service belongs to
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function in(): string;
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the localized human friendly name of this service (e.g. ACME Company Calendar Service)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getLabel(): string;
|
||||
|
||||
/**
|
||||
* Gets the active status of this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Retrieve collection of collections for this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array;
|
||||
|
||||
/**
|
||||
* Retrieve filter object for collection list
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function collectionListFilter(): IFilter;
|
||||
|
||||
/**
|
||||
* Retrieve sort object for collection list
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function collectionListSort(): ISort;
|
||||
|
||||
/**
|
||||
* Determine if a collection exists
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function collectionExtant(string|int $identifier): bool;
|
||||
|
||||
/**
|
||||
* Retrieve a specific collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function collectionFetch(string|int $identifier): ?ICollectionBase;
|
||||
|
||||
/**
|
||||
* Retrieve collection of entities from a specific collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $elements = null): array;
|
||||
|
||||
/**
|
||||
* Retrieve filter object for entity list
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function entityListFilter(): IFilter;
|
||||
|
||||
/**
|
||||
* Retrieve sort object for entity list
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function entityListSort(): ISort;
|
||||
|
||||
/**
|
||||
* Retrieve range object for entity list
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function entityListRange(RangeType $type): IRange;
|
||||
|
||||
/**
|
||||
* Retrieve collection of entities that have changed since a given signature
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|int $collection collection identifier
|
||||
* @param string $signature signature to compare against
|
||||
* @param string $detail level of detail to return (ids, full, etc)
|
||||
*
|
||||
* @return array collection of entities or entity identifiers
|
||||
*/
|
||||
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array;
|
||||
|
||||
/**
|
||||
* Determine if entities exist in a specific collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function entityExtant(string|int $collection, string|int ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Retrieve specific entities from a specific collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function entityFetch(string|int $collection, string|int ...$identifiers): array;
|
||||
|
||||
}
|
||||
79
shared/lib/Chrono/Service/IServiceCollectionMutable.php
Normal file
79
shared/lib/Chrono/Service/IServiceCollectionMutable.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Service;
|
||||
|
||||
use KTXF\Chrono\Collection\ICollectionBase;
|
||||
use KTXF\Chrono\Collection\ICollectionMutable;
|
||||
|
||||
interface IServiceCollectionMutable extends IServiceBase {
|
||||
|
||||
public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate';
|
||||
public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify';
|
||||
public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy';
|
||||
public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove';
|
||||
|
||||
/**
|
||||
* Creates a new, empty collection object
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return ICollectionMutable
|
||||
*/
|
||||
public function collectionFresh(): ICollectionMutable;
|
||||
|
||||
/**
|
||||
* Creates a new collection at the specified location
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $location The parent collection to create this collection in, or empty string for root
|
||||
* @param ICollectionMutable $collection The collection to create
|
||||
* @param array $options Additional options for the collection creation
|
||||
*
|
||||
* @return ICollectionBase
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): ICollectionBase;
|
||||
|
||||
/**
|
||||
* Modifies an existing collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $identifier The ID of the collection to modify
|
||||
* @param ICollectionMutable $collection The collection with modifications
|
||||
*
|
||||
* @return ICollectionBase
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionModify(string|int $identifier, ICollectionMutable $collection): ICollectionBase;
|
||||
|
||||
/**
|
||||
* Destroys an existing collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $identifier The ID of the collection to destroy
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionDestroy(string|int $identifier): bool;
|
||||
|
||||
}
|
||||
81
shared/lib/Chrono/Service/IServiceEntityMutable.php
Normal file
81
shared/lib/Chrono/Service/IServiceEntityMutable.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Service;
|
||||
|
||||
use KTXF\Chrono\Entity\IEntityMutable;
|
||||
|
||||
interface IServiceEntityMutable extends IServiceBase {
|
||||
|
||||
public const CAPABILITY_ENTITY_CREATE = 'EntityCreate';
|
||||
public const CAPABILITY_ENTITY_MODIFY = 'EntityModify';
|
||||
public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy';
|
||||
public const CAPABILITY_ENTITY_COPY = 'EntityCopy';
|
||||
public const CAPABILITY_ENTITY_MOVE = 'EntityMove';
|
||||
|
||||
/**
|
||||
* Creates a fresh entity of the specified type
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return IEntityMutable
|
||||
*/
|
||||
public function entityFresh(): IEntityMutable;
|
||||
|
||||
/**
|
||||
* Creates a new entity in the specified collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $collection The collection to create this entity in
|
||||
* @param IEntityMutable $entity The entity to create
|
||||
* @param array $options Additional options for the entity creation
|
||||
*
|
||||
* @return IEntityMutable
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): IEntityMutable;
|
||||
|
||||
/**
|
||||
* Modifies an existing entity in the specified collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $collection The collection containing the entity to modify
|
||||
* @param string $identifier The ID of the entity to modify
|
||||
* @param IEntityMutable $entity The entity with modifications
|
||||
*
|
||||
* @return IEntityMutable
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): IEntityMutable;
|
||||
|
||||
/**
|
||||
* Destroys an existing entity in the specified collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $collection The collection containing the entity to destroy
|
||||
* @param string $identifier The ID of the entity to destroy
|
||||
*
|
||||
* @return IEntityMutable
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityDestroy(string|int $collection, string|int $identifier): IEntityMutable;
|
||||
|
||||
}
|
||||
30
shared/lib/Chrono/Service/IServiceMutable.php
Normal file
30
shared/lib/Chrono/Service/IServiceMutable.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Service;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
interface IServiceMutable extends IServiceBase, JsonDeserializable {
|
||||
|
||||
/**
|
||||
* Sets the localized human friendly name of this service (e.g. ACME Company Calendar Service)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setLabel(string $value): self;
|
||||
|
||||
/**
|
||||
* Sets the active status of this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setEnabled(bool $value): self;
|
||||
|
||||
}
|
||||
8
shared/lib/Controller/ControllerAbstract.php
Normal file
8
shared/lib/Controller/ControllerAbstract.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace KTXF\Controller;
|
||||
|
||||
abstract class ControllerAbstract
|
||||
{
|
||||
|
||||
}
|
||||
146
shared/lib/Event/Event.php
Normal file
146
shared/lib/Event/Event.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Event;
|
||||
|
||||
/**
|
||||
* Base event class for the event bus system
|
||||
*/
|
||||
class Event
|
||||
{
|
||||
private bool $propagationStopped = false;
|
||||
private array $data = [];
|
||||
private float $timestamp;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $identityId = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $name,
|
||||
array $data = []
|
||||
) {
|
||||
$this->data = $data;
|
||||
$this->timestamp = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event name
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a data value by key
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a data value
|
||||
*/
|
||||
public function set(string $key, mixed $value): self
|
||||
{
|
||||
$this->data[$key] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a data key exists
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all data
|
||||
*/
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for getData() for backward compatibility
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event timestamp
|
||||
*/
|
||||
public function getTimestamp(): float
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop event propagation to subsequent listeners
|
||||
*/
|
||||
public function stopPropagation(): void
|
||||
{
|
||||
$this->propagationStopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if propagation is stopped
|
||||
*/
|
||||
public function isPropagationStopped(): bool
|
||||
{
|
||||
return $this->propagationStopped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant ID for multi-tenant context
|
||||
*/
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tenant ID for multi-tenant context
|
||||
*/
|
||||
public function setTenantId(?string $tenantId): self
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get identity ID (user who triggered the event)
|
||||
*/
|
||||
public function getIdentityId(): ?string
|
||||
{
|
||||
return $this->identityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set identity ID
|
||||
*/
|
||||
public function setIdentityId(?string $identityId): self
|
||||
{
|
||||
$this->identityId = $identityId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert event to array for serialization/logging
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'data' => $this->data,
|
||||
'timestamp' => $this->timestamp,
|
||||
'tenantId' => $this->tenantId,
|
||||
'identityId' => $this->identityId,
|
||||
];
|
||||
}
|
||||
}
|
||||
186
shared/lib/Event/EventBus.php
Normal file
186
shared/lib/Event/EventBus.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Event;
|
||||
|
||||
/**
|
||||
* Simple event bus for decoupled pub/sub communication between services
|
||||
*
|
||||
* Features:
|
||||
* - Priority-based listener ordering
|
||||
* - Synchronous and asynchronous (deferred) event handling
|
||||
* - Event propagation control
|
||||
*/
|
||||
class EventBus
|
||||
{
|
||||
/** @var array<string, array<array{callback: callable, priority: int}>> */
|
||||
private array $listeners = [];
|
||||
|
||||
/** @var array<string, array<callable>> */
|
||||
private array $asyncListeners = [];
|
||||
|
||||
/** @var Event[] */
|
||||
private array $deferredEvents = [];
|
||||
|
||||
/**
|
||||
* Subscribe to an event with optional priority
|
||||
* Higher priority listeners are called first
|
||||
*/
|
||||
public function subscribe(string $eventName, callable $listener, int $priority = 0): self
|
||||
{
|
||||
$this->listeners[$eventName][] = [
|
||||
'callback' => $listener,
|
||||
'priority' => $priority,
|
||||
];
|
||||
|
||||
// Sort by priority (higher first)
|
||||
usort(
|
||||
$this->listeners[$eventName],
|
||||
fn($a, $b) => $b['priority'] <=> $a['priority']
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event for async/deferred processing
|
||||
* These handlers run at the end of the request cycle
|
||||
*/
|
||||
public function subscribeAsync(string $eventName, callable $listener): self
|
||||
{
|
||||
$this->asyncListeners[$eventName][] = $listener;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a listener from an event
|
||||
*/
|
||||
public function unsubscribe(string $eventName, callable $listener): self
|
||||
{
|
||||
if (isset($this->listeners[$eventName])) {
|
||||
$this->listeners[$eventName] = array_filter(
|
||||
$this->listeners[$eventName],
|
||||
fn($item) => $item['callback'] !== $listener
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->asyncListeners[$eventName])) {
|
||||
$this->asyncListeners[$eventName] = array_filter(
|
||||
$this->asyncListeners[$eventName],
|
||||
fn($item) => $item !== $listener
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to all subscribers
|
||||
*/
|
||||
public function publish(Event $event): self
|
||||
{
|
||||
$eventName = $event->getName();
|
||||
|
||||
// Execute synchronous listeners
|
||||
if (isset($this->listeners[$eventName])) {
|
||||
foreach ($this->listeners[$eventName] as $listenerData) {
|
||||
if ($event->isPropagationStopped()) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
call_user_func($listenerData['callback'], $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't break the chain
|
||||
error_log(sprintf(
|
||||
'Event listener error for %s: %s',
|
||||
$eventName,
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for async processing if there are async listeners
|
||||
if (isset($this->asyncListeners[$eventName]) && !empty($this->asyncListeners[$eventName])) {
|
||||
$this->deferredEvents[] = $event;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process deferred/async events
|
||||
* Call this at the end of the request cycle
|
||||
*/
|
||||
public function processDeferred(): int
|
||||
{
|
||||
$processed = 0;
|
||||
|
||||
foreach ($this->deferredEvents as $event) {
|
||||
$eventName = $event->getName();
|
||||
|
||||
if (!isset($this->asyncListeners[$eventName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->asyncListeners[$eventName] as $listener) {
|
||||
try {
|
||||
call_user_func($listener, $event);
|
||||
$processed++;
|
||||
} catch (\Throwable $e) {
|
||||
// Log but don't fail - these are non-critical
|
||||
error_log(sprintf(
|
||||
'Async event handler error for %s: %s',
|
||||
$eventName,
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->deferredEvents = [];
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event has any listeners
|
||||
*/
|
||||
public function hasListeners(string $eventName): bool
|
||||
{
|
||||
return !empty($this->listeners[$eventName]) || !empty($this->asyncListeners[$eventName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of listeners for an event
|
||||
*/
|
||||
public function getListenerCount(string $eventName): int
|
||||
{
|
||||
$sync = isset($this->listeners[$eventName]) ? count($this->listeners[$eventName]) : 0;
|
||||
$async = isset($this->asyncListeners[$eventName]) ? count($this->asyncListeners[$eventName]) : 0;
|
||||
|
||||
return $sync + $async;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending deferred events
|
||||
*/
|
||||
public function getDeferredCount(): int
|
||||
{
|
||||
return count($this->deferredEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all listeners (useful for testing)
|
||||
*/
|
||||
public function clear(): self
|
||||
{
|
||||
$this->listeners = [];
|
||||
$this->asyncListeners = [];
|
||||
$this->deferredEvents = [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
303
shared/lib/Event/SecurityEvent.php
Normal file
303
shared/lib/Event/SecurityEvent.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Event;
|
||||
|
||||
/**
|
||||
* Security-specific event for authentication and access control events
|
||||
*/
|
||||
class SecurityEvent extends Event
|
||||
{
|
||||
// Event names
|
||||
public const AUTH_SUCCESS = 'security.auth.success';
|
||||
public const AUTH_FAILURE = 'security.auth.failure';
|
||||
public const AUTH_LOGOUT = 'security.auth.logout';
|
||||
public const TOKEN_REFRESH = 'security.token.refresh';
|
||||
public const TOKEN_REVOKED = 'security.token.revoked';
|
||||
|
||||
public const ACCESS_DENIED = 'security.access.denied';
|
||||
public const ACCESS_GRANTED = 'security.access.granted';
|
||||
|
||||
public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected';
|
||||
public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded';
|
||||
public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity';
|
||||
|
||||
public const IP_BLOCKED = 'security.ip.blocked';
|
||||
public const IP_ALLOWED = 'security.ip.allowed';
|
||||
public const DEVICE_BLOCKED = 'security.device.blocked';
|
||||
|
||||
private ?string $ipAddress = null;
|
||||
private ?string $deviceFingerprint = null;
|
||||
private ?string $userAgent = null;
|
||||
private ?string $requestPath = null;
|
||||
private ?string $requestMethod = null;
|
||||
private ?string $userId = null;
|
||||
private ?string $reason = null;
|
||||
private int $severity = self::SEVERITY_INFO;
|
||||
|
||||
// Severity levels
|
||||
public const SEVERITY_DEBUG = 0;
|
||||
public const SEVERITY_INFO = 1;
|
||||
public const SEVERITY_WARNING = 2;
|
||||
public const SEVERITY_ERROR = 3;
|
||||
public const SEVERITY_CRITICAL = 4;
|
||||
|
||||
/**
|
||||
* Create a security event with common parameters
|
||||
*/
|
||||
public static function create(
|
||||
string $name,
|
||||
?string $ipAddress = null,
|
||||
?string $deviceFingerprint = null,
|
||||
array $data = []
|
||||
): self {
|
||||
$event = new self($name, $data);
|
||||
$event->ipAddress = $ipAddress;
|
||||
$event->deviceFingerprint = $deviceFingerprint;
|
||||
|
||||
// Set default severity based on event type
|
||||
$event->severity = self::getSeverityForEvent($name);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authentication failure event
|
||||
*/
|
||||
public static function authFailure(
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null,
|
||||
?string $userId = null,
|
||||
?string $reason = null
|
||||
): self {
|
||||
$event = self::create(self::AUTH_FAILURE, $ipAddress, $deviceFingerprint, [
|
||||
'userId' => $userId,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$event->userId = $userId;
|
||||
$event->reason = $reason;
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authentication success event
|
||||
*/
|
||||
public static function authSuccess(
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null,
|
||||
string $userId = null
|
||||
): self {
|
||||
$event = self::create(self::AUTH_SUCCESS, $ipAddress, $deviceFingerprint, [
|
||||
'userId' => $userId,
|
||||
]);
|
||||
$event->userId = $userId;
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brute force detection event
|
||||
*/
|
||||
public static function bruteForceDetected(
|
||||
string $ipAddress,
|
||||
int $failureCount,
|
||||
int $windowSeconds
|
||||
): self {
|
||||
$event = self::create(self::BRUTE_FORCE_DETECTED, $ipAddress, null, [
|
||||
'failureCount' => $failureCount,
|
||||
'windowSeconds' => $windowSeconds,
|
||||
]);
|
||||
$event->reason = sprintf(
|
||||
'%d failed attempts in %d seconds',
|
||||
$failureCount,
|
||||
$windowSeconds
|
||||
);
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rate limit exceeded event
|
||||
*/
|
||||
public static function rateLimitExceeded(
|
||||
string $ipAddress,
|
||||
int $requestCount,
|
||||
int $windowSeconds,
|
||||
?string $endpoint = null
|
||||
): self {
|
||||
$event = self::create(self::RATE_LIMIT_EXCEEDED, $ipAddress, null, [
|
||||
'requestCount' => $requestCount,
|
||||
'windowSeconds' => $windowSeconds,
|
||||
'endpoint' => $endpoint,
|
||||
]);
|
||||
$event->requestPath = $endpoint;
|
||||
$event->reason = sprintf(
|
||||
'%d requests in %d seconds',
|
||||
$requestCount,
|
||||
$windowSeconds
|
||||
);
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an access denied event
|
||||
*/
|
||||
public static function accessDenied(
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null,
|
||||
?string $ruleId = null,
|
||||
?string $reason = null
|
||||
): self {
|
||||
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
|
||||
'ruleId' => $ruleId,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$event->reason = $reason;
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default severity for event types
|
||||
*/
|
||||
private static function getSeverityForEvent(string $eventName): int
|
||||
{
|
||||
return match ($eventName) {
|
||||
self::AUTH_SUCCESS,
|
||||
self::ACCESS_GRANTED,
|
||||
self::TOKEN_REFRESH => self::SEVERITY_INFO,
|
||||
|
||||
self::AUTH_FAILURE,
|
||||
self::ACCESS_DENIED,
|
||||
self::AUTH_LOGOUT,
|
||||
self::TOKEN_REVOKED => self::SEVERITY_WARNING,
|
||||
|
||||
self::RATE_LIMIT_EXCEEDED,
|
||||
self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR,
|
||||
|
||||
self::BRUTE_FORCE_DETECTED,
|
||||
self::IP_BLOCKED,
|
||||
self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL,
|
||||
|
||||
default => self::SEVERITY_INFO,
|
||||
};
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function setIpAddress(?string $ipAddress): self
|
||||
{
|
||||
$this->ipAddress = $ipAddress;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return $this->deviceFingerprint;
|
||||
}
|
||||
|
||||
public function setDeviceFingerprint(?string $deviceFingerprint): self
|
||||
{
|
||||
$this->deviceFingerprint = $deviceFingerprint;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(?string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return $this->requestPath;
|
||||
}
|
||||
|
||||
public function setRequestPath(?string $requestPath): self
|
||||
{
|
||||
$this->requestPath = $requestPath;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return $this->requestMethod;
|
||||
}
|
||||
|
||||
public function setRequestMethod(?string $requestMethod): self
|
||||
{
|
||||
$this->requestMethod = $requestMethod;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function setUserId(?string $userId): self
|
||||
{
|
||||
$this->userId = $userId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): self
|
||||
{
|
||||
$this->reason = $reason;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSeverity(): int
|
||||
{
|
||||
return $this->severity;
|
||||
}
|
||||
|
||||
public function setSeverity(int $severity): self
|
||||
{
|
||||
$this->severity = $severity;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSeverityLabel(): string
|
||||
{
|
||||
return match ($this->severity) {
|
||||
self::SEVERITY_DEBUG => 'DEBUG',
|
||||
self::SEVERITY_INFO => 'INFO',
|
||||
self::SEVERITY_WARNING => 'WARNING',
|
||||
self::SEVERITY_ERROR => 'ERROR',
|
||||
self::SEVERITY_CRITICAL => 'CRITICAL',
|
||||
default => 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override toArray to include security-specific fields
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_merge(parent::toArray(), [
|
||||
'ipAddress' => $this->ipAddress,
|
||||
'deviceFingerprint' => $this->deviceFingerprint,
|
||||
'userAgent' => $this->userAgent,
|
||||
'requestPath' => $this->requestPath,
|
||||
'requestMethod' => $this->requestMethod,
|
||||
'userId' => $this->userId,
|
||||
'reason' => $this->reason,
|
||||
'severity' => $this->severity,
|
||||
'severityLabel' => $this->getSeverityLabel(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
175
shared/lib/Exception/BaseException.php
Normal file
175
shared/lib/Exception/BaseException.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace KTXF\Exception;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception is the base class for
|
||||
* all Exceptions.
|
||||
* @link https://php.net/manual/en/class.exception.php
|
||||
*/
|
||||
class BaseException implements Throwable
|
||||
{
|
||||
/** The error message */
|
||||
protected string $message = '';
|
||||
|
||||
/** The error code */
|
||||
protected int $code = 0;
|
||||
|
||||
/** The filename where the error happened */
|
||||
protected string $file = '';
|
||||
|
||||
/** The line where the error happened */
|
||||
protected int $line = 0;
|
||||
|
||||
/** Previous throwable in chain */
|
||||
protected ?Throwable $previous = null;
|
||||
|
||||
/** Captured stack trace frames (excluding constructor frame) */
|
||||
protected array $trace = [];
|
||||
|
||||
/**
|
||||
* Construct the exception. Note: The message is NOT binary safe.
|
||||
* @link https://php.net/manual/en/exception.construct.php
|
||||
* @param string $message [optional] The Exception message to throw.
|
||||
* @param int $code [optional] The Exception code.
|
||||
* @param null|Throwable $previous [optional] The previous throwable used for the exception chaining.
|
||||
*/
|
||||
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
$this->message = $message;
|
||||
$this->code = $code;
|
||||
$this->previous = $previous;
|
||||
|
||||
// Capture backtrace; first element is this constructor call site
|
||||
$bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
|
||||
if (!empty($bt)) {
|
||||
$first = $bt[0];
|
||||
$this->file = $first['file'] ?? 'unknown';
|
||||
$this->line = $first['line'] ?? 0;
|
||||
}
|
||||
// Exclude current frame for readability
|
||||
$this->trace = array_slice($bt, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the exception
|
||||
* Tries to clone the Exception, which results in Fatal error.
|
||||
* @link https://php.net/manual/en/exception.clone.php
|
||||
* @return void
|
||||
*/
|
||||
public function __clone(): void
|
||||
{
|
||||
// Mimic internal Exception behavior: cloning not allowed.
|
||||
trigger_error('Trying to clone an uncloneable object of class ' . static::class, E_USER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation of the exception
|
||||
* @link https://php.net/manual/en/exception.tostring.php
|
||||
* @return string the string representation of the exception.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf(
|
||||
"%s: %s in %s:%d\nStack trace:\n%s",
|
||||
static::class,
|
||||
$this->getMessage(),
|
||||
$this->getFile(),
|
||||
$this->getLine(),
|
||||
$this->getTraceAsString()
|
||||
);
|
||||
}
|
||||
|
||||
public function __wakeup(): void
|
||||
{
|
||||
// On wakeup we don't have original trace; reset trace to empty
|
||||
$this->trace = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Exception message
|
||||
* @link https://php.net/manual/en/exception.getmessage.php
|
||||
* @return string the Exception message as a string.
|
||||
*/
|
||||
final public function getMessage(): string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Exception code
|
||||
* @link https://php.net/manual/en/exception.getcode.php
|
||||
* @return mixed|int the exception code as integer in
|
||||
* <b>Exception</b> but possibly as other type in
|
||||
* <b>Exception</b> descendants (for example as
|
||||
* string in <b>PDOException</b>).
|
||||
*/
|
||||
final public function getCode(): int
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file in which the exception occurred
|
||||
* @link https://php.net/manual/en/exception.getfile.php
|
||||
* @return string the filename in which the exception was created.
|
||||
*/
|
||||
final public function getFile(): string
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the line in which the exception occurred
|
||||
* @link https://php.net/manual/en/exception.getline.php
|
||||
* @return int the line number where the exception was created.
|
||||
*/
|
||||
final public function getLine(): int
|
||||
{
|
||||
return $this->line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stack trace
|
||||
* @link https://php.net/manual/en/exception.gettrace.php
|
||||
* @return array the Exception stack trace as an array.
|
||||
*/
|
||||
final public function getTrace(): array
|
||||
{
|
||||
return $this->trace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns previous Exception
|
||||
* @link https://php.net/manual/en/exception.getprevious.php
|
||||
* @return null|Throwable Returns the previous {@see Throwable} if available, or <b>NULL</b> otherwise.
|
||||
* or null otherwise.
|
||||
*/
|
||||
final public function getPrevious(): ?Throwable
|
||||
{
|
||||
return $this->previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stack trace as a string
|
||||
* @link https://php.net/manual/en/exception.gettraceasstring.php
|
||||
* @return string the Exception stack trace as a string.
|
||||
*/
|
||||
final public function getTraceAsString(): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($this->trace as $i => $frame) {
|
||||
$file = $frame['file'] ?? '[internal function]';
|
||||
$line = $frame['line'] ?? 0;
|
||||
$func = $frame['function'] ?? '';
|
||||
$class = $frame['class'] ?? '';
|
||||
$type = $frame['type'] ?? '';
|
||||
$lines[] = sprintf('#%d %s(%s): %s%s%s()', $i, $file, $line, $class, $type, $func);
|
||||
}
|
||||
$lines[] = sprintf('#%d {main}', count($lines));
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
}
|
||||
9
shared/lib/Exception/RuntimeException.php
Normal file
9
shared/lib/Exception/RuntimeException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace KTXF\Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown if an error which can only be found on runtime occurs.
|
||||
* @link https://php.net/manual/en/class.runtimeexception.php
|
||||
*/
|
||||
class RuntimeException extends BaseException {}
|
||||
105
shared/lib/Files/Node/INodeBase.php
Normal file
105
shared/lib/Files/Node/INodeBase.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Node;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface INodeBase extends \JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'files.node';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_IN = 'in';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_CREATED_ON = 'createdOn';
|
||||
public const JSON_PROPERTY_CREATED_BY = 'createdBy';
|
||||
public const JSON_PROPERTY_MODIFIED_ON = 'modifiedOn';
|
||||
public const JSON_PROPERTY_MODIFIED_BY = 'modifiedBy';
|
||||
public const JSON_PROPERTY_OWNER = 'owner';
|
||||
public const JSON_PROPERTY_SIGNATURE = 'signature';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
|
||||
/**
|
||||
* Unique identifier of the parent node (folder) this node belongs to
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function in(): string|int|null;
|
||||
|
||||
/**
|
||||
* Unique identifier of this node
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Node type (collection or entity)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function type(): NodeType;
|
||||
|
||||
/**
|
||||
* Creator user ID
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function createdBy(): string|null;
|
||||
|
||||
/**
|
||||
* Creation timestamp
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function createdOn(): DateTimeImmutable|null;
|
||||
|
||||
/**
|
||||
* Last modifier user ID
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function modifiedBy(): string|null;
|
||||
|
||||
/**
|
||||
* Last modification timestamp
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function modifiedOn(): DateTimeImmutable|null;
|
||||
|
||||
/**
|
||||
* Signature/etag for sync and caching
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function signature(): string|null;
|
||||
|
||||
/**
|
||||
* Check if this node is a collection (folder)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function isCollection(): bool;
|
||||
|
||||
/**
|
||||
* Check if this node is an entity (file)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function isEntity(): bool;
|
||||
|
||||
/**
|
||||
* Human-readable name/label of this node
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function getLabel(): string|null;
|
||||
|
||||
}
|
||||
22
shared/lib/Files/Node/INodeCollectionBase.php
Normal file
22
shared/lib/Files/Node/INodeCollectionBase.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Node;
|
||||
|
||||
/**
|
||||
* Interface for collection (folder) nodes
|
||||
*
|
||||
* Collections are containers that can hold other nodes (both collections and entities).
|
||||
* They inherit common properties from INodeBase and add collection-specific properties.
|
||||
*/
|
||||
interface INodeCollectionBase extends INodeBase {
|
||||
|
||||
public const JSON_TYPE = 'files.collection';
|
||||
|
||||
}
|
||||
35
shared/lib/Files/Node/INodeCollectionMutable.php
Normal file
35
shared/lib/Files/Node/INodeCollectionMutable.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Node;
|
||||
|
||||
/**
|
||||
* Interface for mutable collection (folder) nodes
|
||||
*/
|
||||
interface INodeCollectionMutable extends INodeCollectionBase {
|
||||
|
||||
/**
|
||||
* Deserialize from JSON data
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param array|string $data JSON data to deserialize
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function jsonDeserialize(array|string $data): static;
|
||||
|
||||
/**
|
||||
* Sets the human-readable name/label of this collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setLabel(string $value): static;
|
||||
|
||||
}
|
||||
54
shared/lib/Files/Node/INodeEntityBase.php
Normal file
54
shared/lib/Files/Node/INodeEntityBase.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Node;
|
||||
|
||||
/**
|
||||
* Interface for entity (file) nodes
|
||||
*
|
||||
* Entities are leaf nodes that contain actual file data.
|
||||
* They inherit common properties from INodeBase and add file-specific properties.
|
||||
*/
|
||||
interface INodeEntityBase extends INodeBase {
|
||||
|
||||
public const JSON_TYPE = 'files.entity';
|
||||
public const JSON_PROPERTY_SIZE = 'size';
|
||||
public const JSON_PROPERTY_MIME = 'mime';
|
||||
public const JSON_PROPERTY_FORMAT = 'format';
|
||||
public const JSON_PROPERTY_ENCODING = 'encoding';
|
||||
|
||||
/**
|
||||
* File size in bytes
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function size(): int;
|
||||
|
||||
/**
|
||||
* MIME type of the file (e.g., 'application/pdf', 'image/png')
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function getMime(): string|null;
|
||||
|
||||
/**
|
||||
* File format/extension (e.g., 'pdf', 'png', 'txt')
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function getFormat(): string|null;
|
||||
|
||||
/**
|
||||
* Character encoding (e.g., 'utf-8')
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function getEncoding(): string|null;
|
||||
|
||||
}
|
||||
56
shared/lib/Files/Node/INodeEntityMutable.php
Normal file
56
shared/lib/Files/Node/INodeEntityMutable.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Node;
|
||||
|
||||
/**
|
||||
* Interface for mutable entity (file) nodes
|
||||
*/
|
||||
interface INodeEntityMutable extends INodeEntityBase {
|
||||
|
||||
/**
|
||||
* Deserialize from JSON data
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param array|string $data JSON data to deserialize
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function jsonDeserialize(array|string $data): static;
|
||||
|
||||
/**
|
||||
* Sets the human-readable name/label of this entity
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setLabel(string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the MIME type of the file
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setMime(string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the file format/extension
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setFormat(string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the character encoding
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setEncoding(string $value): static;
|
||||
|
||||
}
|
||||
23
shared/lib/Files/Node/NodeType.php
Normal file
23
shared/lib/Files/Node/NodeType.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Node;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum NodeType: string implements JsonSerializable {
|
||||
|
||||
case Collection = 'C';
|
||||
case Entity = 'E';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
96
shared/lib/Files/Provider/IProviderBase.php
Normal file
96
shared/lib/Files/Provider/IProviderBase.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Provider;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Files\Service\IServiceBase;
|
||||
|
||||
interface IProviderBase extends JsonSerializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_LIST = 'ServiceList';
|
||||
public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch';
|
||||
public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant';
|
||||
|
||||
public const JSON_TYPE = 'files.provider';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
|
||||
/**
|
||||
* Confirms if specific capability is supported (e.g. 'ServiceList')
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function id(): string;
|
||||
|
||||
/**
|
||||
* The localized human friendly name of this provider (e.g. System File Provider)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Retrieve collection of services for a specific user
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param array $filter filter criteria
|
||||
*
|
||||
* @return array<string,IServiceBase> collection of service objects
|
||||
*/
|
||||
public function serviceList(string $tenantId, string $userId, array $filter): array;
|
||||
|
||||
/**
|
||||
* Determine if any services are configured for a specific user
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param int|string ...$identifiers variadic collection of service identifiers
|
||||
*
|
||||
* @return array<string,bool> collection of service identifiers with boolean values indicating if the service is available
|
||||
*/
|
||||
public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Retrieve a service with a specific identifier
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param string|int $identifier service identifier
|
||||
*
|
||||
* @return IServiceBase|null returns service object or null if non found
|
||||
*/
|
||||
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase;
|
||||
|
||||
}
|
||||
333
shared/lib/Files/Service/IServiceBase.php
Normal file
333
shared/lib/Files/Service/IServiceBase.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Service;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Files\Node\INodeBase;
|
||||
use KTXF\Files\Node\INodeCollectionBase;
|
||||
use KTXF\Files\Node\INodeEntityBase;
|
||||
use KTXF\Resource\Filter\IFilter;
|
||||
use KTXF\Resource\Range\IRange;
|
||||
use KTXF\Resource\Range\RangeType;
|
||||
use KTXF\Resource\Sort\ISort;
|
||||
|
||||
interface IServiceBase extends JsonSerializable {
|
||||
|
||||
// Collection Capabilities
|
||||
public const CAPABILITY_COLLECTION_LIST = 'CollectionList';
|
||||
public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter';
|
||||
public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort';
|
||||
public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant';
|
||||
public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch';
|
||||
|
||||
// Entity Capabilities
|
||||
public const CAPABILITY_ENTITY_LIST = 'EntityList';
|
||||
public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter';
|
||||
public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort';
|
||||
public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange';
|
||||
public const CAPABILITY_ENTITY_DELTA = 'EntityDelta';
|
||||
public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant';
|
||||
public const CAPABILITY_ENTITY_FETCH = 'EntityFetch';
|
||||
public const CAPABILITY_ENTITY_READ = 'EntityRead';
|
||||
public const CAPABILITY_ENTITY_READ_STREAM = 'EntityReadStream';
|
||||
public const CAPABILITY_ENTITY_READ_CHUNK = 'EntityReadChunk';
|
||||
|
||||
// Node Capabilities (recursive/unified)
|
||||
public const CAPABILITY_NODE_LIST = 'NodeList';
|
||||
public const CAPABILITY_NODE_LIST_FILTER = 'NodeListFilter';
|
||||
public const CAPABILITY_NODE_LIST_SORT = 'NodeListSort';
|
||||
public const CAPABILITY_NODE_LIST_RANGE = 'NodeListRange';
|
||||
public const CAPABILITY_NODE_DELTA = 'NodeDelta';
|
||||
|
||||
// JSON Constants
|
||||
public const JSON_TYPE = 'files.service';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_PROVIDER = 'provider';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
public const JSON_PROPERTY_ENABLED = 'enabled';
|
||||
|
||||
/**
|
||||
* Confirms if specific capability is supported
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string $value required ability e.g. 'EntityList'
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* Unique identifier of the provider this service belongs to
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function in(): string;
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the localized human friendly name of this service (e.g. ACME Company File Service)
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function getLabel(): string;
|
||||
|
||||
/**
|
||||
* Gets the active status of this service
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function getEnabled(): bool;
|
||||
|
||||
// ==================== Collection Methods ====================
|
||||
|
||||
/**
|
||||
* List of accessible collections at a specific location
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $location Parent collection identifier, null for root
|
||||
*
|
||||
* @return array<string|int,INodeCollectionBase>
|
||||
*/
|
||||
public function collectionList(string|int|null $location = null, ?IFilter $filter = null, ?ISort $sort = null): array;
|
||||
|
||||
/**
|
||||
* Fresh filter for collection list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function collectionListFilter(): IFilter;
|
||||
|
||||
/**
|
||||
* Fresh sort for collection list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function collectionListSort(): ISort;
|
||||
|
||||
/**
|
||||
* Confirms if specific collection exists
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $identifier Collection identifier
|
||||
*/
|
||||
public function collectionExtant(string|int|null $identifier): bool;
|
||||
|
||||
/**
|
||||
* Fetches details about a specific collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $identifier Collection identifier
|
||||
*/
|
||||
public function collectionFetch(string|int|null $identifier): ?INodeCollectionBase;
|
||||
|
||||
// ==================== Entity Methods ====================
|
||||
|
||||
/**
|
||||
* Lists all entities in a specific collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
*
|
||||
* @return array<string|int,INodeEntityBase>
|
||||
*/
|
||||
public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array;
|
||||
|
||||
/**
|
||||
* Fresh filter for entity list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function entityListFilter(): IFilter;
|
||||
|
||||
/**
|
||||
* Fresh sort for entity list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function entityListSort(): ISort;
|
||||
|
||||
/**
|
||||
* Fresh range for entity list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function entityListRange(RangeType $type): IRange;
|
||||
|
||||
/**
|
||||
* Lists all changes from a specific signature
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string $signature Sync token signature
|
||||
* @param string $detail Detail level: ids | meta | full
|
||||
*
|
||||
* @return array{
|
||||
* added: array<string|int>,
|
||||
* updated: array<string|int>,
|
||||
* deleted: array<string|int>,
|
||||
* signature: string
|
||||
* }
|
||||
*/
|
||||
public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): array;
|
||||
|
||||
/**
|
||||
* Confirms if specific entities exist in a collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int ...$identifiers Entity identifiers
|
||||
*
|
||||
* @return array<string|int,bool>
|
||||
*/
|
||||
public function entityExtant(string|int|null $collection, string|int ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Fetches details about specific entities in a collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int ...$identifiers Entity identifiers
|
||||
*
|
||||
* @return array<string|int,INodeEntityBase>
|
||||
*/
|
||||
public function entityFetch(string|int|null $collection, string|int ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Reads the entire content of an entity as a string
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
*
|
||||
* @return string|null File content or null if not found
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityRead(string|int|null $collection, string|int $identifier): ?string;
|
||||
|
||||
/**
|
||||
* Opens a stream to read the content of an entity
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
*
|
||||
* @return resource|null Stream resource or null if not found
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityReadStream(string|int|null $collection, string|int $identifier);
|
||||
|
||||
/**
|
||||
* Reads a chunk of content from an entity
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
* @param int $offset Starting byte position (0-indexed)
|
||||
* @param int $length Number of bytes to read
|
||||
*
|
||||
* @return string|null Chunk content or null if not found
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityReadChunk(string|int|null $collection, string|int $identifier, int $offset, int $length): ?string;
|
||||
|
||||
// ==================== Node Methods (Recursive/Unified) ====================
|
||||
|
||||
/**
|
||||
* Lists all nodes (collections and entities) at a location, optionally recursive
|
||||
* Returns a flat list with parent references via in()
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $location Starting location, null for root
|
||||
* @param bool $recursive Whether to list recursively
|
||||
*
|
||||
* @return array<string|int,INodeBase>
|
||||
*/
|
||||
public function nodeList(string|int|null $location = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array;
|
||||
|
||||
/**
|
||||
* Fresh filter for node list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function nodeListFilter(): IFilter;
|
||||
|
||||
/**
|
||||
* Fresh sort for node list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function nodeListSort(): ISort;
|
||||
|
||||
/**
|
||||
* Fresh range for node list
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function nodeListRange(RangeType $type): IRange;
|
||||
|
||||
/**
|
||||
* Lists all node changes from a specific signature, optionally recursive
|
||||
* Returns flat list with parent references
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $location Starting location, null for root
|
||||
* @param string $signature Sync token signature
|
||||
* @param bool $recursive Whether to include recursive changes
|
||||
* @param string $detail Detail level: ids | meta | full
|
||||
*
|
||||
* @return array{
|
||||
* added: array<string|int,INodeBase>|array<string|int>,
|
||||
* updated: array<string|int,INodeBase>|array<string|int>,
|
||||
* deleted: array<string|int>,
|
||||
* signature: string
|
||||
* }
|
||||
*/
|
||||
public function nodeDelta(string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array;
|
||||
|
||||
}
|
||||
100
shared/lib/Files/Service/IServiceCollectionMutable.php
Normal file
100
shared/lib/Files/Service/IServiceCollectionMutable.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Service;
|
||||
|
||||
use KTXF\Files\Node\INodeCollectionBase;
|
||||
use KTXF\Files\Node\INodeCollectionMutable;
|
||||
|
||||
interface IServiceCollectionMutable extends IServiceBase {
|
||||
|
||||
public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate';
|
||||
public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify';
|
||||
public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy';
|
||||
public const CAPABILITY_COLLECTION_COPY = 'CollectionCopy';
|
||||
public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove';
|
||||
|
||||
/**
|
||||
* Creates a new, empty collection node
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function collectionFresh(): INodeCollectionMutable;
|
||||
|
||||
/**
|
||||
* Creates a new collection at the specified location
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $location Parent collection, null for root
|
||||
* @param INodeCollectionMutable $collection The collection to create
|
||||
* @param array $options Additional options
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionCreate(string|int|null $location, INodeCollectionMutable $collection, array $options = []): INodeCollectionBase;
|
||||
|
||||
/**
|
||||
* Modifies an existing collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int $identifier Collection identifier
|
||||
* @param INodeCollectionMutable $collection The collection with modifications
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionModify(string|int $identifier, INodeCollectionMutable $collection): INodeCollectionBase;
|
||||
|
||||
/**
|
||||
* Destroys an existing collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int $identifier Collection identifier
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionDestroy(string|int $identifier): bool;
|
||||
|
||||
/**
|
||||
* Copies an existing collection to a new location
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int $identifier Collection identifier
|
||||
* @param string|int|null $location Destination parent collection, null for root
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionCopy(string|int $identifier, string|int|null $location): INodeCollectionBase;
|
||||
|
||||
/**
|
||||
* Moves an existing collection to a new location
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int $identifier Collection identifier
|
||||
* @param string|int|null $location Destination parent collection, null for root
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function collectionMove(string|int $identifier, string|int|null $location): INodeCollectionBase;
|
||||
|
||||
}
|
||||
158
shared/lib/Files/Service/IServiceEntityMutable.php
Normal file
158
shared/lib/Files/Service/IServiceEntityMutable.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Service;
|
||||
|
||||
use KTXF\Files\Node\INodeEntityBase;
|
||||
use KTXF\Files\Node\INodeEntityMutable;
|
||||
|
||||
interface IServiceEntityMutable extends IServiceBase {
|
||||
|
||||
public const CAPABILITY_ENTITY_CREATE = 'EntityCreate';
|
||||
public const CAPABILITY_ENTITY_MODIFY = 'EntityModify';
|
||||
public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy';
|
||||
public const CAPABILITY_ENTITY_COPY = 'EntityCopy';
|
||||
public const CAPABILITY_ENTITY_MOVE = 'EntityMove';
|
||||
public const CAPABILITY_ENTITY_WRITE = 'EntityWrite';
|
||||
public const CAPABILITY_ENTITY_WRITE_STREAM = 'EntityWriteStream';
|
||||
public const CAPABILITY_ENTITY_WRITE_CHUNK = 'EntityWriteChunk';
|
||||
|
||||
/**
|
||||
* Creates a new, empty entity node
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function entityFresh(): INodeEntityMutable;
|
||||
|
||||
/**
|
||||
* Creates a new entity in the specified collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier, null for root
|
||||
* @param INodeEntityMutable $entity The entity to create
|
||||
* @param array $options Additional options
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityCreate(string|int|null $collection, INodeEntityMutable $entity, array $options = []): INodeEntityBase;
|
||||
|
||||
/**
|
||||
* Modifies an existing entity in the specified collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
* @param INodeEntityMutable $entity The entity with modifications
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityModify(string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): INodeEntityBase;
|
||||
|
||||
/**
|
||||
* Destroys an existing entity in the specified collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityDestroy(string|int|null $collection, string|int $identifier): bool;
|
||||
|
||||
/**
|
||||
* Copies an existing entity to a new collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Source collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
* @param string|int|null $destination Destination collection identifier, null for root
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase;
|
||||
|
||||
/**
|
||||
* Moves an existing entity to a new collection
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Source collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
* @param string|int|null $destination Destination collection identifier, null for root
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase;
|
||||
|
||||
/**
|
||||
* Writes the entire content of an entity from a string
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
* @param string $data Content to write
|
||||
*
|
||||
* @return int Number of bytes written
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityWrite(string|int|null $collection, string|int $identifier, string $data): int;
|
||||
|
||||
/**
|
||||
* Opens a stream to write the content of an entity
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
*
|
||||
* @return resource Stream resource for writing
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityWriteStream(string|int|null $collection, string|int $identifier);
|
||||
|
||||
/**
|
||||
* Writes a chunk of content to an entity at a specific position
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*
|
||||
* @param string|int|null $collection Collection identifier
|
||||
* @param string|int $identifier Entity identifier
|
||||
* @param int $offset Starting byte position (0-indexed)
|
||||
* @param string $data Chunk content to write
|
||||
*
|
||||
* @return int Number of bytes written
|
||||
*
|
||||
* @throws \KTXF\Resource\Exceptions\InvalidArgumentException
|
||||
* @throws \KTXF\Resource\Exceptions\UnsupportedException
|
||||
* @throws \KTXF\Resource\Exceptions\UnauthorizedException
|
||||
*/
|
||||
public function entityWriteChunk(string|int|null $collection, string|int $identifier, int $offset, string $data): int;
|
||||
|
||||
}
|
||||
30
shared/lib/Files/Service/IServiceMutable.php
Normal file
30
shared/lib/Files/Service/IServiceMutable.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Files\Service;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
interface IServiceMutable extends IServiceBase, JsonDeserializable {
|
||||
|
||||
/**
|
||||
* Sets the localized human friendly name of this service
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setLabel(string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the active status of this service
|
||||
*
|
||||
* @since 2025.11.01
|
||||
*/
|
||||
public function setEnabled(bool $value): static;
|
||||
|
||||
}
|
||||
275
shared/lib/IpUtils.php
Normal file
275
shared/lib/IpUtils.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXF;
|
||||
|
||||
/**
|
||||
* Http utility functions.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class IpUtils
|
||||
{
|
||||
public const PRIVATE_SUBNETS = [
|
||||
'127.0.0.0/8', // RFC1700 (Loopback)
|
||||
'10.0.0.0/8', // RFC1918
|
||||
'192.168.0.0/16', // RFC1918
|
||||
'172.16.0.0/12', // RFC1918
|
||||
'169.254.0.0/16', // RFC3927
|
||||
'0.0.0.0/8', // RFC5735
|
||||
'240.0.0.0/4', // RFC1112
|
||||
'::1/128', // Loopback
|
||||
'fc00::/7', // Unique Local Address
|
||||
'fe80::/10', // Link Local Address
|
||||
'::ffff:0:0/96', // IPv4 translations
|
||||
'::/128', // Unspecified address
|
||||
];
|
||||
|
||||
private static array $checkedIps = [];
|
||||
|
||||
/**
|
||||
* This class should not be instantiated.
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IPv4 or IPv6 address is contained in the list of given IPs or subnets.
|
||||
*
|
||||
* @param string|array $ips List of IPs or subnets (can be a string if only a single one)
|
||||
*/
|
||||
public static function checkIp(string $requestIp, string|array $ips): bool
|
||||
{
|
||||
if (!\is_array($ips)) {
|
||||
$ips = [$ips];
|
||||
}
|
||||
|
||||
$method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4';
|
||||
|
||||
foreach ($ips as $ip) {
|
||||
if (self::$method($requestIp, $ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two IPv4 addresses.
|
||||
* In case a subnet is given, it checks if it contains the request IP.
|
||||
*
|
||||
* @param string $ip IPv4 address or subnet in CIDR notation
|
||||
*
|
||||
* @return bool Whether the request IP matches the IP, or whether the request IP is within the CIDR subnet
|
||||
*/
|
||||
public static function checkIp4(string $requestIp, string $ip): bool
|
||||
{
|
||||
$cacheKey = $requestIp.'-'.$ip.'-v4';
|
||||
if (null !== $cacheValue = self::getCacheResult($cacheKey)) {
|
||||
return $cacheValue;
|
||||
}
|
||||
|
||||
if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
|
||||
if (str_contains($ip, '/')) {
|
||||
[$address, $netmask] = explode('/', $ip, 2);
|
||||
|
||||
if ('0' === $netmask) {
|
||||
return self::setCacheResult($cacheKey, false !== filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4));
|
||||
}
|
||||
|
||||
if ($netmask < 0 || $netmask > 32) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
} else {
|
||||
$address = $ip;
|
||||
$netmask = 32;
|
||||
}
|
||||
|
||||
if (false === ip2long($address)) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
|
||||
return self::setCacheResult($cacheKey, 0 === substr_compare(\sprintf('%032b', ip2long($requestIp)), \sprintf('%032b', ip2long($address)), 0, $netmask));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two IPv6 addresses.
|
||||
* In case a subnet is given, it checks if it contains the request IP.
|
||||
*
|
||||
* @author David Soria Parra <dsp at php dot net>
|
||||
*
|
||||
* @see https://github.com/dsp/v6tools
|
||||
*
|
||||
* @param string $ip IPv6 address or subnet in CIDR notation
|
||||
*
|
||||
* @throws \RuntimeException When IPV6 support is not enabled
|
||||
*/
|
||||
public static function checkIp6(string $requestIp, string $ip): bool
|
||||
{
|
||||
$cacheKey = $requestIp.'-'.$ip.'-v6';
|
||||
if (null !== $cacheValue = self::getCacheResult($cacheKey)) {
|
||||
return $cacheValue;
|
||||
}
|
||||
|
||||
if (!((\extension_loaded('sockets') && \defined('AF_INET6')) || @inet_pton('::1'))) {
|
||||
throw new \RuntimeException('Unable to check Ipv6. Check that PHP was not compiled with option "disable-ipv6".');
|
||||
}
|
||||
|
||||
// Check to see if we were given a IP4 $requestIp or $ip by mistake
|
||||
if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
|
||||
if (str_contains($ip, '/')) {
|
||||
[$address, $netmask] = explode('/', $ip, 2);
|
||||
|
||||
if (!filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
|
||||
if ('0' === $netmask) {
|
||||
return (bool) unpack('n*', @inet_pton($address));
|
||||
}
|
||||
|
||||
if ($netmask < 1 || $netmask > 128) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
} else {
|
||||
if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
|
||||
$address = $ip;
|
||||
$netmask = 128;
|
||||
}
|
||||
|
||||
$bytesAddr = unpack('n*', @inet_pton($address));
|
||||
$bytesTest = unpack('n*', @inet_pton($requestIp));
|
||||
|
||||
if (!$bytesAddr || !$bytesTest) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
|
||||
for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) {
|
||||
$left = $netmask - 16 * ($i - 1);
|
||||
$left = ($left <= 16) ? $left : 16;
|
||||
$mask = ~(0xFFFF >> $left) & 0xFFFF;
|
||||
if (($bytesAddr[$i] & $mask) != ($bytesTest[$i] & $mask)) {
|
||||
return self::setCacheResult($cacheKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
return self::setCacheResult($cacheKey, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymizes an IP/IPv6.
|
||||
*
|
||||
* Removes the last bytes of IPv4 and IPv6 addresses (1 byte for IPv4 and 8 bytes for IPv6 by default).
|
||||
*
|
||||
* @param int<0, 4> $v4Bytes
|
||||
* @param int<0, 16> $v6Bytes
|
||||
*/
|
||||
public static function anonymize(string $ip/* , int $v4Bytes = 1, int $v6Bytes = 8 */): string
|
||||
{
|
||||
$v4Bytes = 1 < \func_num_args() ? func_get_arg(1) : 1;
|
||||
$v6Bytes = 2 < \func_num_args() ? func_get_arg(2) : 8;
|
||||
|
||||
if ($v4Bytes < 0 || $v6Bytes < 0) {
|
||||
throw new \InvalidArgumentException('Cannot anonymize less than 0 bytes.');
|
||||
}
|
||||
|
||||
if ($v4Bytes > 4 || $v6Bytes > 16) {
|
||||
throw new \InvalidArgumentException('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.');
|
||||
}
|
||||
|
||||
/**
|
||||
* If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007
|
||||
* In that case, we only care about the part before the % symbol, as the following functions, can only work with
|
||||
* the IP address itself. As the scope can leak information (containing interface name), we do not want to
|
||||
* include it in our anonymized IP data.
|
||||
*/
|
||||
if (str_contains($ip, '%')) {
|
||||
$ip = substr($ip, 0, strpos($ip, '%'));
|
||||
}
|
||||
|
||||
$wrappedIPv6 = false;
|
||||
if (str_starts_with($ip, '[') && str_ends_with($ip, ']')) {
|
||||
$wrappedIPv6 = true;
|
||||
$ip = substr($ip, 1, -1);
|
||||
}
|
||||
|
||||
$mappedIpV4MaskGenerator = function (string $mask, int $bytesToAnonymize) {
|
||||
$mask .= str_repeat('ff', 4 - $bytesToAnonymize);
|
||||
$mask .= str_repeat('00', $bytesToAnonymize);
|
||||
|
||||
return '::'.implode(':', str_split($mask, 4));
|
||||
};
|
||||
|
||||
$packedAddress = inet_pton($ip);
|
||||
if (4 === \strlen($packedAddress)) {
|
||||
$mask = rtrim(str_repeat('255.', 4 - $v4Bytes).str_repeat('0.', $v4Bytes), '.');
|
||||
} elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff:ffff'))) {
|
||||
$mask = $mappedIpV4MaskGenerator('ffff', $v4Bytes);
|
||||
} elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff'))) {
|
||||
$mask = $mappedIpV4MaskGenerator('', $v4Bytes);
|
||||
} else {
|
||||
$mask = str_repeat('ff', 16 - $v6Bytes).str_repeat('00', $v6Bytes);
|
||||
$mask = implode(':', str_split($mask, 4));
|
||||
}
|
||||
$ip = inet_ntop($packedAddress & inet_pton($mask));
|
||||
|
||||
if ($wrappedIPv6) {
|
||||
$ip = '['.$ip.']';
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IPv4 or IPv6 address is contained in the list of private IP subnets.
|
||||
*/
|
||||
public static function isPrivateIp(string $requestIp): bool
|
||||
{
|
||||
return self::checkIp($requestIp, self::PRIVATE_SUBNETS);
|
||||
}
|
||||
|
||||
private static function getCacheResult(string $cacheKey): ?bool
|
||||
{
|
||||
if (isset(self::$checkedIps[$cacheKey])) {
|
||||
// Move the item last in cache (LRU)
|
||||
$value = self::$checkedIps[$cacheKey];
|
||||
unset(self::$checkedIps[$cacheKey]);
|
||||
self::$checkedIps[$cacheKey] = $value;
|
||||
|
||||
return self::$checkedIps[$cacheKey];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function setCacheResult(string $cacheKey, bool $result): bool
|
||||
{
|
||||
if (1000 < \count(self::$checkedIps)) {
|
||||
// stop memory leak if there are many keys
|
||||
self::$checkedIps = \array_slice(self::$checkedIps, 500, null, true);
|
||||
}
|
||||
|
||||
return self::$checkedIps[$cacheKey] = $result;
|
||||
}
|
||||
}
|
||||
11
shared/lib/Json/JsonDeserializable.php
Normal file
11
shared/lib/Json/JsonDeserializable.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Json;
|
||||
|
||||
interface JsonDeserializable {
|
||||
|
||||
public function jsonDeserialize(array|string $data): static;
|
||||
|
||||
}
|
||||
11
shared/lib/Json/JsonSerializable.php
Normal file
11
shared/lib/Json/JsonSerializable.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Json;
|
||||
|
||||
interface JsonSerializable extends \JsonSerializable {
|
||||
|
||||
public function jsonSerialize(): mixed;
|
||||
|
||||
}
|
||||
68
shared/lib/Json/JsonSerializableCollection.php
Normal file
68
shared/lib/Json/JsonSerializableCollection.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Json;
|
||||
|
||||
use KTXF\Json\JsonSerializable;
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
use KTXF\Utile\Collection\CollectionAbstract;
|
||||
|
||||
abstract class JsonSerializableCollection extends CollectionAbstract implements JsonSerializable, JsonDeserializable {
|
||||
|
||||
protected array $primitiveTypes = [
|
||||
self::TYPE_STRING,
|
||||
self::TYPE_INT,
|
||||
self::TYPE_FLOAT,
|
||||
self::TYPE_BOOL,
|
||||
self::TYPE_ARRAY,
|
||||
self::TYPE_DATE,
|
||||
];
|
||||
|
||||
public function jsonSerialize(): mixed {
|
||||
return $this->getArrayCopy();
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
$this->exchangeArray([]);
|
||||
|
||||
if (in_array($this->typeValue, $this->primitiveTypes)) {
|
||||
if ($this->associative) {
|
||||
foreach ($data as $key => $value) {
|
||||
$this[$key] = $value;
|
||||
}
|
||||
} else {
|
||||
foreach ($data as $value) {
|
||||
$this[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array($this->typeValue, $this->primitiveTypes) && class_exists($this->typeValue)) {
|
||||
$reflection = new \ReflectionClass($this->typeValue);
|
||||
if ($reflection->implementsInterface(JsonDeserializable::class)) {
|
||||
if ($this->associative) {
|
||||
foreach ($data as $key => $value) {
|
||||
$instance = $reflection->newInstance();
|
||||
/** @var JsonDeserializable $instance */
|
||||
$this[$key] = $instance->jsonDeserialize($value);
|
||||
}
|
||||
} else {
|
||||
foreach ($data as $value) {
|
||||
$instance = $reflection->newInstance();
|
||||
/** @var JsonDeserializable $instance */
|
||||
$this[] = $instance->jsonDeserialize($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
165
shared/lib/Json/JsonSerializableObject.php
Normal file
165
shared/lib/Json/JsonSerializableObject.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Json;
|
||||
|
||||
use DateTimeInterface;
|
||||
use DateTimeZone;
|
||||
use DateInterval;
|
||||
|
||||
use KTXF\Json\JsonSerializable;
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
abstract class JsonSerializableObject implements JsonSerializable, JsonDeserializable {
|
||||
|
||||
protected string $dateTimeFormat = 'c'; // ISO 8601 format by default
|
||||
protected array $serializableProperties = []; // Empty array means serialize all properties
|
||||
protected array $nonSerializableProperties = ['dateTimeFormat', 'serializableProperties', 'nonSerializableProperties']; // Properties to exclude from serialization
|
||||
|
||||
public function jsonSerialize(): mixed {
|
||||
$vars = get_object_vars($this);
|
||||
|
||||
// Filter properties based on serializableProperties if specified
|
||||
if (!empty($this->serializableProperties)) {
|
||||
$vars = array_filter($vars, function($key) {
|
||||
return in_array($key, $this->serializableProperties);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
}
|
||||
|
||||
// Process each property for special types
|
||||
foreach ($vars as $key => $value) {
|
||||
// Skip internal control properties
|
||||
if (in_array($key, $this->nonSerializableProperties)) {
|
||||
unset($vars[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle DateTimeInterface (DateTime/DateTimeImmutable)
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
$vars[$key] = $value->format($this->dateTimeFormat);
|
||||
}
|
||||
// Handle DateTimeZone
|
||||
elseif ($value instanceof DateTimeZone) {
|
||||
$vars[$key] = $value->getName();
|
||||
}
|
||||
// Handle DateInterval
|
||||
elseif ($value instanceof DateInterval) {
|
||||
$vars[$key] = $this->fromDateInterval($value);
|
||||
}
|
||||
// Handle backed enums
|
||||
elseif ($value instanceof \BackedEnum) {
|
||||
$vars[$key] = $value->value;
|
||||
}
|
||||
// Handle JsonSerializable objects
|
||||
elseif ($value instanceof JsonSerializable) {
|
||||
$vars[$key] = $value->jsonSerialize();
|
||||
}
|
||||
}
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (property_exists($this, $key)) {
|
||||
// Skip internal control properties
|
||||
if (in_array($key, $this->nonSerializableProperties)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if property should be deserialized (if serializableProperties is set)
|
||||
if (!empty($this->serializableProperties) && !in_array($key, $this->serializableProperties)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = gettype($this->$key);
|
||||
|
||||
// Handle JsonDeserializable objects
|
||||
if ($type === 'object' && $this->$key instanceof JsonDeserializable) {
|
||||
$this->$key = $this->$key->jsonDeserialize($value);
|
||||
}
|
||||
// Handle DateTimeInterface (DateTime/DateTimeImmutable)
|
||||
elseif ($type === 'object' && $this->$key instanceof DateTimeInterface) {
|
||||
$this->$key = new \DateTimeImmutable($value);
|
||||
}
|
||||
// Handle DateTimeZone
|
||||
elseif ($type === 'object' && $this->$key instanceof DateTimeZone) {
|
||||
$this->$key = new DateTimeZone($value);
|
||||
}
|
||||
// Handle DateInterval
|
||||
elseif ($type === 'object' && $this->$key instanceof DateInterval) {
|
||||
$this->$key = $this->toDateInterval($value);
|
||||
}
|
||||
// Handle backed enums
|
||||
elseif ($type === 'object' && $this->$key instanceof \BackedEnum) {
|
||||
$enumClass = get_class($this->$key);
|
||||
$this->$key = $enumClass::from($value);
|
||||
}
|
||||
// Handle regular values
|
||||
else {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function fromDateInterval(DateInterval $interval): string {
|
||||
$spec = '';
|
||||
|
||||
// Handle negative intervals
|
||||
if ($interval->invert === 1) {
|
||||
$spec = '-';
|
||||
}
|
||||
|
||||
$spec .= 'P';
|
||||
|
||||
if ($interval->y > 0) $spec .= $interval->y . 'Y';
|
||||
if ($interval->m > 0) $spec .= $interval->m . 'M';
|
||||
if ($interval->d > 0) $spec .= $interval->d . 'D';
|
||||
|
||||
$timePart = '';
|
||||
if ($interval->h > 0) $timePart .= $interval->h . 'H';
|
||||
if ($interval->i > 0) $timePart .= $interval->i . 'M';
|
||||
if ($interval->s > 0) $timePart .= $interval->s . 'S';
|
||||
|
||||
if (!empty($timePart)) {
|
||||
$spec .= 'T' . $timePart;
|
||||
}
|
||||
|
||||
// Handle edge case of zero duration
|
||||
if ($spec === 'P' || $spec === '-P') {
|
||||
$spec = 'PT0S';
|
||||
}
|
||||
|
||||
return $spec;
|
||||
}
|
||||
|
||||
protected function toDateInterval(string $value): DateInterval {
|
||||
$isNegative = false;
|
||||
|
||||
// Check for negative interval
|
||||
if (str_starts_with($value, '-')) {
|
||||
$isNegative = true;
|
||||
$value = substr($value, 1);
|
||||
}
|
||||
|
||||
// Create the interval
|
||||
$interval = new DateInterval($value);
|
||||
|
||||
// Set invert property for negative intervals
|
||||
if ($isNegative) {
|
||||
$interval->invert = 1;
|
||||
}
|
||||
|
||||
return $interval;
|
||||
}
|
||||
|
||||
}
|
||||
37
shared/lib/Mail/Collection/CollectionRoles.php
Normal file
37
shared/lib/Mail/Collection/CollectionRoles.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Collection;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Collection Roles
|
||||
*
|
||||
* Standard mailbox/folder roles for mail collections.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
enum CollectionRoles: string implements JsonSerializable {
|
||||
|
||||
case Inbox = 'inbox';
|
||||
case Drafts = 'drafts';
|
||||
case Sent = 'sent';
|
||||
case Trash = 'trash';
|
||||
case Junk = 'junk';
|
||||
case Archive = 'archive';
|
||||
case Outbox = 'outbox';
|
||||
case Queue = 'queue';
|
||||
case Custom = 'custom';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
117
shared/lib/Mail/Collection/ICollectionBase.php
Normal file
117
shared/lib/Mail/Collection/ICollectionBase.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Collection;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Collection Base Interface
|
||||
*
|
||||
* Represents a mailbox/folder in a mail service.
|
||||
* For future use with full mail providers (IMAP, JMAP, etc.)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface ICollectionBase extends JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'mail.collection';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_PROVIDER = 'provider';
|
||||
public const JSON_PROPERTY_SERVICE = 'service';
|
||||
public const JSON_PROPERTY_IN = 'in';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_ROLE = 'role';
|
||||
public const JSON_PROPERTY_TOTAL = 'total';
|
||||
public const JSON_PROPERTY_UNREAD = 'unread';
|
||||
|
||||
/**
|
||||
* Gets the parent collection identifier (null for root)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|int|null
|
||||
*/
|
||||
public function in(): string|int|null;
|
||||
|
||||
/**
|
||||
* Gets the collection identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|int
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the collection label/name
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLabel(): string;
|
||||
|
||||
/**
|
||||
* Gets the collection role
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return CollectionRoles
|
||||
*/
|
||||
public function getRole(): CollectionRoles;
|
||||
|
||||
/**
|
||||
* Gets the total message count
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function getTotal(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the unread message count
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function getUnread(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the collection signature/sync token
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getSignature(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the creation date
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return DateTimeImmutable|null
|
||||
*/
|
||||
public function created(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the modification date
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return DateTimeImmutable|null
|
||||
*/
|
||||
public function modified(): ?DateTimeImmutable;
|
||||
|
||||
}
|
||||
141
shared/lib/Mail/Entity/Address.php
Normal file
141
shared/lib/Mail/Entity/Address.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
/**
|
||||
* Mail Address Implementation
|
||||
*
|
||||
* Concrete implementation of IAddress for email addresses.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class Address implements IAddress {
|
||||
|
||||
/**
|
||||
* @param string $address Email address
|
||||
* @param string|null $name Display name
|
||||
*/
|
||||
public function __construct(
|
||||
private string $address = '',
|
||||
private ?string $name = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates an Address from a formatted string
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $value Formatted as "Name <address>" or just "address"
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromString(string $value): self {
|
||||
$value = trim($value);
|
||||
|
||||
// Match "Name <address>" format
|
||||
if (preg_match('/^(.+?)\s*<([^>]+)>$/', $value, $matches)) {
|
||||
return new self(trim($matches[2]), trim($matches[1], ' "\''));
|
||||
}
|
||||
|
||||
// Match "<address>" format
|
||||
if (preg_match('/^<([^>]+)>$/', $value, $matches)) {
|
||||
return new self(trim($matches[1]));
|
||||
}
|
||||
|
||||
// Assume plain address
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Address from an array
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array $data Array with 'address' and optional 'name' keys
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self {
|
||||
return new self(
|
||||
$data[self::JSON_PROPERTY_ADDRESS] ?? $data['address'] ?? '',
|
||||
$data[self::JSON_PROPERTY_NAME] ?? $data['name'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getAddress(): string {
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the email address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $address
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setAddress(string $address): self {
|
||||
$this->address = $address;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getName(): ?string {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the display name
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|null $name
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setName(?string $name): self {
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toString(): string {
|
||||
if ($this->name !== null && $this->name !== '') {
|
||||
return sprintf('"%s" <%s>', $this->name, $this->address);
|
||||
}
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
return array_filter([
|
||||
self::JSON_PROPERTY_ADDRESS => $this->address,
|
||||
self::JSON_PROPERTY_NAME => $this->name,
|
||||
], fn($v) => $v !== null && $v !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string {
|
||||
return $this->toString();
|
||||
}
|
||||
|
||||
}
|
||||
195
shared/lib/Mail/Entity/Attachment.php
Normal file
195
shared/lib/Mail/Entity/Attachment.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
/**
|
||||
* Mail Attachment Implementation
|
||||
*
|
||||
* Concrete implementation of IAttachment for mail attachments.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class Attachment implements IAttachment {
|
||||
|
||||
/**
|
||||
* @param string $name File name
|
||||
* @param string $mimeType MIME type
|
||||
* @param string $content Binary content
|
||||
* @param string|null $id Attachment ID
|
||||
* @param int|null $size Size in bytes
|
||||
* @param string|null $contentId Content-ID for inline attachments
|
||||
* @param bool $inline Whether inline attachment
|
||||
*/
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private string $mimeType,
|
||||
private string $content,
|
||||
private ?string $id = null,
|
||||
private ?int $size = null,
|
||||
private ?string $contentId = null,
|
||||
private bool $inline = false,
|
||||
) {
|
||||
if ($this->size === null) {
|
||||
$this->size = strlen($this->content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an attachment from a file path
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $path File path
|
||||
* @param string|null $name Override file name
|
||||
* @param string|null $mimeType Override MIME type
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromFile(string $path, ?string $name = null, ?string $mimeType = null): self {
|
||||
$content = file_get_contents($path);
|
||||
$name = $name ?? basename($path);
|
||||
$mimeType = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream';
|
||||
|
||||
return new self($name, $mimeType, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an attachment from base64 encoded content
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $name File name
|
||||
* @param string $mimeType MIME type
|
||||
* @param string $base64Content Base64 encoded content
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromBase64(string $name, string $mimeType, string $base64Content): self {
|
||||
return new self($name, $mimeType, base64_decode($base64Content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an inline attachment for embedding in HTML
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $name File name
|
||||
* @param string $mimeType MIME type
|
||||
* @param string $content Binary content
|
||||
* @param string $contentId Content-ID (without cid: prefix)
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function inline(string $name, string $mimeType, string $content, string $contentId): self {
|
||||
return new self($name, $mimeType, $content, null, null, $contentId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates from array data
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self {
|
||||
$content = $data['content'] ?? '';
|
||||
if (isset($data['contentBase64'])) {
|
||||
$content = base64_decode($data['contentBase64']);
|
||||
}
|
||||
|
||||
return new self(
|
||||
$data[self::JSON_PROPERTY_NAME] ?? $data['name'] ?? '',
|
||||
$data[self::JSON_PROPERTY_MIME_TYPE] ?? $data['mimeType'] ?? 'application/octet-stream',
|
||||
$content,
|
||||
$data[self::JSON_PROPERTY_ID] ?? $data['id'] ?? null,
|
||||
$data[self::JSON_PROPERTY_SIZE] ?? $data['size'] ?? null,
|
||||
$data[self::JSON_PROPERTY_CONTENT_ID] ?? $data['contentId'] ?? null,
|
||||
$data[self::JSON_PROPERTY_INLINE] ?? $data['inline'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getId(): ?string {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getMimeType(): string {
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getSize(): ?int {
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getContentId(): ?string {
|
||||
return $this->contentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function isInline(): bool {
|
||||
return $this->inline;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getContent(): string {
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content as base64 encoded string
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getContentBase64(): string {
|
||||
return base64_encode($this->content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
return array_filter([
|
||||
self::JSON_PROPERTY_ID => $this->id,
|
||||
self::JSON_PROPERTY_NAME => $this->name,
|
||||
self::JSON_PROPERTY_MIME_TYPE => $this->mimeType,
|
||||
self::JSON_PROPERTY_SIZE => $this->size,
|
||||
self::JSON_PROPERTY_CONTENT_ID => $this->contentId,
|
||||
self::JSON_PROPERTY_INLINE => $this->inline ?: null,
|
||||
'contentBase64' => $this->getContentBase64(),
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
}
|
||||
53
shared/lib/Mail/Entity/IAddress.php
Normal file
53
shared/lib/Mail/Entity/IAddress.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Address Interface
|
||||
*
|
||||
* Represents an email address with optional display name.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IAddress extends JsonSerializable {
|
||||
|
||||
public const JSON_PROPERTY_ADDRESS = 'address';
|
||||
public const JSON_PROPERTY_NAME = 'name';
|
||||
|
||||
/**
|
||||
* Gets the email address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string Email address (e.g., "user@example.com")
|
||||
*/
|
||||
public function getAddress(): string;
|
||||
|
||||
/**
|
||||
* Gets the display name
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null Display name (e.g., "John Doe") or null
|
||||
*/
|
||||
public function getName(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the formatted address string
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string Formatted as "Name <address>" or just "address" if no name
|
||||
*/
|
||||
public function toString(): string;
|
||||
|
||||
}
|
||||
93
shared/lib/Mail/Entity/IAttachment.php
Normal file
93
shared/lib/Mail/Entity/IAttachment.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Attachment Interface
|
||||
*
|
||||
* Represents a file attachment on a mail message.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IAttachment extends JsonSerializable {
|
||||
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_NAME = 'name';
|
||||
public const JSON_PROPERTY_MIME_TYPE = 'mimeType';
|
||||
public const JSON_PROPERTY_SIZE = 'size';
|
||||
public const JSON_PROPERTY_CONTENT_ID = 'contentId';
|
||||
public const JSON_PROPERTY_INLINE = 'inline';
|
||||
|
||||
/**
|
||||
* Gets the attachment identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null Attachment ID or null for new attachments
|
||||
*/
|
||||
public function getId(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the file name
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string File name (e.g., "document.pdf")
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Gets the MIME type
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string MIME type (e.g., "application/pdf")
|
||||
*/
|
||||
public function getMimeType(): string;
|
||||
|
||||
/**
|
||||
* Gets the file size in bytes
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return int|null Size in bytes or null if unknown
|
||||
*/
|
||||
public function getSize(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the Content-ID for inline attachments
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null Content-ID for referencing in HTML body (e.g., "cid:image1")
|
||||
*/
|
||||
public function getContentId(): ?string;
|
||||
|
||||
/**
|
||||
* Checks if this is an inline attachment (embedded in body)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool True if inline, false if regular attachment
|
||||
*/
|
||||
public function isInline(): bool;
|
||||
|
||||
/**
|
||||
* Gets the attachment content
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string Binary content of the attachment
|
||||
*/
|
||||
public function getContent(): string;
|
||||
|
||||
}
|
||||
176
shared/lib/Mail/Entity/IMessageBase.php
Normal file
176
shared/lib/Mail/Entity/IMessageBase.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Message Base Interface
|
||||
*
|
||||
* Read-only interface for mail message entities.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IMessageBase extends JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'mail.message';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_SUBJECT = 'subject';
|
||||
public const JSON_PROPERTY_FROM = 'from';
|
||||
public const JSON_PROPERTY_REPLY_TO = 'replyTo';
|
||||
public const JSON_PROPERTY_TO = 'to';
|
||||
public const JSON_PROPERTY_CC = 'cc';
|
||||
public const JSON_PROPERTY_BCC = 'bcc';
|
||||
public const JSON_PROPERTY_DATE = 'date';
|
||||
public const JSON_PROPERTY_BODY_TEXT = 'bodyText';
|
||||
public const JSON_PROPERTY_BODY_HTML = 'bodyHtml';
|
||||
public const JSON_PROPERTY_ATTACHMENTS = 'attachments';
|
||||
public const JSON_PROPERTY_HEADERS = 'headers';
|
||||
|
||||
/**
|
||||
* Gets the message identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null Message ID or null for unsent messages
|
||||
*/
|
||||
public function getId(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the message subject
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getSubject(): string;
|
||||
|
||||
/**
|
||||
* Gets the sender address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return IAddress|null
|
||||
*/
|
||||
public function getFrom(): ?IAddress;
|
||||
|
||||
/**
|
||||
* Gets the reply-to address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return IAddress|null
|
||||
*/
|
||||
public function getReplyTo(): ?IAddress;
|
||||
|
||||
/**
|
||||
* Gets the primary recipients (To)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<int, IAddress>
|
||||
*/
|
||||
public function getTo(): array;
|
||||
|
||||
/**
|
||||
* Gets the carbon copy recipients (CC)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<int, IAddress>
|
||||
*/
|
||||
public function getCc(): array;
|
||||
|
||||
/**
|
||||
* Gets the blind carbon copy recipients (BCC)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<int, IAddress>
|
||||
*/
|
||||
public function getBcc(): array;
|
||||
|
||||
/**
|
||||
* Gets the message date
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return DateTimeImmutable|null
|
||||
*/
|
||||
public function getDate(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the plain text body
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getBodyText(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the HTML body
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getBodyHtml(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the attachments
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<int, IAttachment>
|
||||
*/
|
||||
public function getAttachments(): array;
|
||||
|
||||
/**
|
||||
* Gets custom headers
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string, string> Header name => value
|
||||
*/
|
||||
public function getHeaders(): array;
|
||||
|
||||
/**
|
||||
* Gets a specific header value
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $name Header name
|
||||
*
|
||||
* @return string|null Header value or null if not set
|
||||
*/
|
||||
public function getHeader(string $name): ?string;
|
||||
|
||||
/**
|
||||
* Checks if the message has any recipients
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool True if To, CC, or BCC has at least one recipient
|
||||
*/
|
||||
public function hasRecipients(): bool;
|
||||
|
||||
/**
|
||||
* Checks if the message has any body content
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool True if text or HTML body is set
|
||||
*/
|
||||
public function hasBody(): bool;
|
||||
|
||||
}
|
||||
211
shared/lib/Mail/Entity/IMessageMutable.php
Normal file
211
shared/lib/Mail/Entity/IMessageMutable.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Mail Message Mutable Interface
|
||||
*
|
||||
* Interface for composing and modifying mail messages with fluent setters.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IMessageMutable extends IMessageBase {
|
||||
|
||||
/**
|
||||
* Sets the message identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|null $id
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setId(?string $id): self;
|
||||
|
||||
/**
|
||||
* Sets the message subject
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $subject
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setSubject(string $subject): self;
|
||||
|
||||
/**
|
||||
* Sets the sender address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IAddress|null $from
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setFrom(?IAddress $from): self;
|
||||
|
||||
/**
|
||||
* Sets the reply-to address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IAddress|null $replyTo
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setReplyTo(?IAddress $replyTo): self;
|
||||
|
||||
/**
|
||||
* Sets the primary recipients (To)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array<int, IAddress> $to
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setTo(array $to): self;
|
||||
|
||||
/**
|
||||
* Adds a primary recipient (To)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IAddress $address
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function addTo(IAddress $address): self;
|
||||
|
||||
/**
|
||||
* Sets the carbon copy recipients (CC)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array<int, IAddress> $cc
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setCc(array $cc): self;
|
||||
|
||||
/**
|
||||
* Adds a carbon copy recipient (CC)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IAddress $address
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function addCc(IAddress $address): self;
|
||||
|
||||
/**
|
||||
* Sets the blind carbon copy recipients (BCC)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array<int, IAddress> $bcc
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setBcc(array $bcc): self;
|
||||
|
||||
/**
|
||||
* Adds a blind carbon copy recipient (BCC)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IAddress $address
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function addBcc(IAddress $address): self;
|
||||
|
||||
/**
|
||||
* Sets the message date
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param DateTimeImmutable|null $date
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setDate(?DateTimeImmutable $date): self;
|
||||
|
||||
/**
|
||||
* Sets the plain text body
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|null $text
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setBodyText(?string $text): self;
|
||||
|
||||
/**
|
||||
* Sets the HTML body
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|null $html
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setBodyHtml(?string $html): self;
|
||||
|
||||
/**
|
||||
* Sets the attachments
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array<int, IAttachment> $attachments
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setAttachments(array $attachments): self;
|
||||
|
||||
/**
|
||||
* Adds an attachment
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IAttachment $attachment
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function addAttachment(IAttachment $attachment): self;
|
||||
|
||||
/**
|
||||
* Sets custom headers
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array<string, string> $headers Header name => value
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setHeaders(array $headers): self;
|
||||
|
||||
/**
|
||||
* Sets a specific header value
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $name Header name
|
||||
* @param string $value Header value
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setHeader(string $name, string $value): self;
|
||||
|
||||
}
|
||||
383
shared/lib/Mail/Entity/Message.php
Normal file
383
shared/lib/Mail/Entity/Message.php
Normal file
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Entity;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Mail Message Implementation
|
||||
*
|
||||
* Concrete implementation of IMessageMutable for composing mail messages.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class Message implements IMessageMutable {
|
||||
|
||||
private ?string $id = null;
|
||||
private string $subject = '';
|
||||
private ?IAddress $from = null;
|
||||
private ?IAddress $replyTo = null;
|
||||
/** @var array<int, IAddress> */
|
||||
private array $to = [];
|
||||
/** @var array<int, IAddress> */
|
||||
private array $cc = [];
|
||||
/** @var array<int, IAddress> */
|
||||
private array $bcc = [];
|
||||
private ?DateTimeImmutable $date = null;
|
||||
private ?string $bodyText = null;
|
||||
private ?string $bodyHtml = null;
|
||||
/** @var array<int, IAttachment> */
|
||||
private array $attachments = [];
|
||||
/** @var array<string, string> */
|
||||
private array $headers = [];
|
||||
|
||||
/**
|
||||
* Creates a message from array data
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self {
|
||||
$message = new self();
|
||||
|
||||
if (isset($data[self::JSON_PROPERTY_ID])) {
|
||||
$message->setId($data[self::JSON_PROPERTY_ID]);
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_SUBJECT])) {
|
||||
$message->setSubject($data[self::JSON_PROPERTY_SUBJECT]);
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_FROM])) {
|
||||
$message->setFrom(Address::fromArray($data[self::JSON_PROPERTY_FROM]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_REPLY_TO])) {
|
||||
$message->setReplyTo(Address::fromArray($data[self::JSON_PROPERTY_REPLY_TO]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_TO])) {
|
||||
$message->setTo(array_map(fn($a) => Address::fromArray($a), $data[self::JSON_PROPERTY_TO]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_CC])) {
|
||||
$message->setCc(array_map(fn($a) => Address::fromArray($a), $data[self::JSON_PROPERTY_CC]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_BCC])) {
|
||||
$message->setBcc(array_map(fn($a) => Address::fromArray($a), $data[self::JSON_PROPERTY_BCC]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_DATE])) {
|
||||
$message->setDate(new DateTimeImmutable($data[self::JSON_PROPERTY_DATE]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_BODY_TEXT])) {
|
||||
$message->setBodyText($data[self::JSON_PROPERTY_BODY_TEXT]);
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_BODY_HTML])) {
|
||||
$message->setBodyHtml($data[self::JSON_PROPERTY_BODY_HTML]);
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_ATTACHMENTS])) {
|
||||
$message->setAttachments(array_map(fn($a) => Attachment::fromArray($a), $data[self::JSON_PROPERTY_ATTACHMENTS]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_HEADERS])) {
|
||||
$message->setHeaders($data[self::JSON_PROPERTY_HEADERS]);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getId(): ?string {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setId(?string $id): self {
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getSubject(): string {
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setSubject(string $subject): self {
|
||||
$this->subject = $subject;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFrom(): ?IAddress {
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setFrom(?IAddress $from): self {
|
||||
$this->from = $from;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getReplyTo(): ?IAddress {
|
||||
return $this->replyTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setReplyTo(?IAddress $replyTo): self {
|
||||
$this->replyTo = $replyTo;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getTo(): array {
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setTo(array $to): self {
|
||||
$this->to = $to;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function addTo(IAddress $address): self {
|
||||
$this->to[] = $address;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getCc(): array {
|
||||
return $this->cc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setCc(array $cc): self {
|
||||
$this->cc = $cc;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function addCc(IAddress $address): self {
|
||||
$this->cc[] = $address;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getBcc(): array {
|
||||
return $this->bcc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setBcc(array $bcc): self {
|
||||
$this->bcc = $bcc;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function addBcc(IAddress $address): self {
|
||||
$this->bcc[] = $address;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getDate(): ?DateTimeImmutable {
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setDate(?DateTimeImmutable $date): self {
|
||||
$this->date = $date;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getBodyText(): ?string {
|
||||
return $this->bodyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setBodyText(?string $text): self {
|
||||
$this->bodyText = $text;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getBodyHtml(): ?string {
|
||||
return $this->bodyHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setBodyHtml(?string $html): self {
|
||||
$this->bodyHtml = $html;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getAttachments(): array {
|
||||
return $this->attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setAttachments(array $attachments): self {
|
||||
$this->attachments = $attachments;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function addAttachment(IAttachment $attachment): self {
|
||||
$this->attachments[] = $attachment;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getHeaders(): array {
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getHeader(string $name): ?string {
|
||||
return $this->headers[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setHeaders(array $headers): self {
|
||||
$this->headers = $headers;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setHeader(string $name, string $value): self {
|
||||
$this->headers[$name] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function hasRecipients(): bool {
|
||||
return !empty($this->to) || !empty($this->cc) || !empty($this->bcc);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function hasBody(): bool {
|
||||
return ($this->bodyText !== null && $this->bodyText !== '')
|
||||
|| ($this->bodyHtml !== null && $this->bodyHtml !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
$data = [
|
||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
||||
];
|
||||
|
||||
if ($this->id !== null) {
|
||||
$data[self::JSON_PROPERTY_ID] = $this->id;
|
||||
}
|
||||
|
||||
$data[self::JSON_PROPERTY_SUBJECT] = $this->subject;
|
||||
|
||||
if ($this->from !== null) {
|
||||
$data[self::JSON_PROPERTY_FROM] = $this->from;
|
||||
}
|
||||
if ($this->replyTo !== null) {
|
||||
$data[self::JSON_PROPERTY_REPLY_TO] = $this->replyTo;
|
||||
}
|
||||
if (!empty($this->to)) {
|
||||
$data[self::JSON_PROPERTY_TO] = $this->to;
|
||||
}
|
||||
if (!empty($this->cc)) {
|
||||
$data[self::JSON_PROPERTY_CC] = $this->cc;
|
||||
}
|
||||
if (!empty($this->bcc)) {
|
||||
$data[self::JSON_PROPERTY_BCC] = $this->bcc;
|
||||
}
|
||||
if ($this->date !== null) {
|
||||
$data[self::JSON_PROPERTY_DATE] = $this->date->format('c');
|
||||
}
|
||||
if ($this->bodyText !== null) {
|
||||
$data[self::JSON_PROPERTY_BODY_TEXT] = $this->bodyText;
|
||||
}
|
||||
if ($this->bodyHtml !== null) {
|
||||
$data[self::JSON_PROPERTY_BODY_HTML] = $this->bodyHtml;
|
||||
}
|
||||
if (!empty($this->attachments)) {
|
||||
$data[self::JSON_PROPERTY_ATTACHMENTS] = $this->attachments;
|
||||
}
|
||||
if (!empty($this->headers)) {
|
||||
$data[self::JSON_PROPERTY_HEADERS] = $this->headers;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
}
|
||||
68
shared/lib/Mail/Exception/SendException.php
Normal file
68
shared/lib/Mail/Exception/SendException.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Exception;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Mail Send Exception
|
||||
*
|
||||
* Exception thrown when mail delivery fails.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class SendException extends Exception {
|
||||
|
||||
/**
|
||||
* @param string $message Error message
|
||||
* @param int $code Error code
|
||||
* @param Exception|null $previous Previous exception
|
||||
* @param string|null $recipient Specific recipient that failed (if applicable)
|
||||
* @param bool $permanent Whether this is a permanent failure (no retry)
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
int $code = 0,
|
||||
?Exception $previous = null,
|
||||
public readonly ?string $recipient = null,
|
||||
public readonly bool $permanent = false,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a permanent failure exception (no retry)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|null $recipient
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function permanent(string $message, ?string $recipient = null): self {
|
||||
return new self($message, 0, null, $recipient, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary failure exception (will retry)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $message
|
||||
* @param Exception|null $previous
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function temporary(string $message, ?Exception $previous = null): self {
|
||||
return new self($message, 0, $previous, null, false);
|
||||
}
|
||||
|
||||
}
|
||||
132
shared/lib/Mail/Provider/IProviderBase.php
Normal file
132
shared/lib/Mail/Provider/IProviderBase.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Provider;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Mail\Selector\ServiceSelector;
|
||||
use KTXF\Mail\Service\IServiceBase;
|
||||
|
||||
/**
|
||||
* Mail Provider Base Interface
|
||||
*
|
||||
* Core interface for mail providers with context-aware service discovery.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IProviderBase extends JsonSerializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_LIST = 'ServiceList';
|
||||
public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch';
|
||||
public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant';
|
||||
public const CAPABILITY_SERVICE_FIND_BY_ADDRESS = 'ServiceFindByAddress';
|
||||
|
||||
public const JSON_TYPE = 'mail.provider';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
|
||||
/**
|
||||
* Confirms if a specific capability is supported
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $value Required capability e.g. 'ServiceList'
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* Gets the unique identifier for this provider
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string Provider ID (e.g., 'smtp', 'imap', 'jmap')
|
||||
*/
|
||||
public function id(): string;
|
||||
|
||||
/**
|
||||
* Gets the localized human-friendly name of this provider
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string Provider label (e.g., 'SMTP Mail Provider')
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Lists services for a tenant, optionally filtered by user context
|
||||
*
|
||||
* When userId is null, returns only System-scoped services.
|
||||
* When userId is provided, returns System-scoped services plus
|
||||
* User-scoped services owned by that user.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId User identifier for context (null = system only)
|
||||
* @param ServiceSelector|null $selector Optional filter criteria
|
||||
*
|
||||
* @return array<string|int, IServiceBase>
|
||||
*/
|
||||
public function serviceList(string $tenantId, ?string $userId = null, ?ServiceSelector $selector = null): array;
|
||||
|
||||
/**
|
||||
* Checks if specific services exist
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId User identifier for context
|
||||
* @param string|int ...$identifiers Service identifiers to check
|
||||
*
|
||||
* @return array<string|int, bool> Identifier => exists
|
||||
*/
|
||||
public function serviceExtant(string $tenantId, ?string $userId, string|int ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Fetches a specific service by identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId User identifier for context
|
||||
* @param string|int $identifier Service identifier
|
||||
*
|
||||
* @return IServiceBase|null
|
||||
*/
|
||||
public function serviceFetch(string $tenantId, ?string $userId, string|int $identifier): ?IServiceBase;
|
||||
|
||||
/**
|
||||
* Finds a service that handles a specific email address
|
||||
*
|
||||
* Searches within the appropriate scope based on userId context.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId User identifier for context
|
||||
* @param string $address Email address to find service for
|
||||
*
|
||||
* @return IServiceBase|null Service handling the address, or null
|
||||
*/
|
||||
public function serviceFindByAddress(string $tenantId, ?string $userId, string $address): ?IServiceBase;
|
||||
|
||||
}
|
||||
77
shared/lib/Mail/Provider/IProviderServiceMutate.php
Normal file
77
shared/lib/Mail/Provider/IProviderServiceMutate.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Provider;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
use KTXF\Mail\Service\IServiceBase;
|
||||
|
||||
/**
|
||||
* Mail Provider Service Mutate Interface
|
||||
*
|
||||
* Optional interface for providers that support service CRUD operations.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IProviderServiceMutate extends JsonDeserializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh';
|
||||
public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate';
|
||||
public const CAPABILITY_SERVICE_MODIFY = 'ServiceModify';
|
||||
public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy';
|
||||
|
||||
/**
|
||||
* Creates a new blank service instance for configuration
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return IServiceBase Fresh service object
|
||||
*/
|
||||
public function serviceFresh(): IServiceBase;
|
||||
|
||||
/**
|
||||
* Creates a new service configuration
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId Owner user ID (null for system services)
|
||||
* @param IServiceBase $service Service configuration to create
|
||||
*
|
||||
* @return string|int Created service identifier
|
||||
*/
|
||||
public function serviceCreate(string $tenantId, ?string $userId, IServiceBase $service): string|int;
|
||||
|
||||
/**
|
||||
* Modifies an existing service configuration
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId User identifier for authorization context
|
||||
* @param IServiceBase $service Service configuration to update
|
||||
*
|
||||
* @return string|int Updated service identifier
|
||||
*/
|
||||
public function serviceModify(string $tenantId, ?string $userId, IServiceBase $service): string|int;
|
||||
|
||||
/**
|
||||
* Destroys a service configuration
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId Tenant identifier
|
||||
* @param string|null $userId User identifier for authorization context
|
||||
* @param IServiceBase $service Service to destroy
|
||||
*
|
||||
* @return bool True if destroyed, false if not found
|
||||
*/
|
||||
public function serviceDestroy(string $tenantId, ?string $userId, IServiceBase $service): bool;
|
||||
|
||||
}
|
||||
81
shared/lib/Mail/Queue/SendOptions.php
Normal file
81
shared/lib/Mail/Queue/SendOptions.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Queue;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Send Options
|
||||
*
|
||||
* Configuration options for message delivery behavior.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class SendOptions implements JsonSerializable {
|
||||
|
||||
/**
|
||||
* @param bool $immediate Send immediately bypassing queue (for 2FA, etc.)
|
||||
* @param int $priority Queue priority (-100 to 100, higher = sooner)
|
||||
* @param int $retryCount Maximum retry attempts on failure
|
||||
* @param int|null $delaySeconds Delay before first send attempt
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly bool $immediate = false,
|
||||
public readonly int $priority = 0,
|
||||
public readonly int $retryCount = 3,
|
||||
public readonly ?int $delaySeconds = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates options for immediate delivery (bypasses queue)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function immediate(): self {
|
||||
return new self(immediate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates options for high-priority queued delivery
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function highPriority(): self {
|
||||
return new self(priority: 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates options for low-priority queued delivery (bulk mail)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function lowPriority(): self {
|
||||
return new self(priority: -50);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'immediate' => $this->immediate,
|
||||
'priority' => $this->priority,
|
||||
'retryCount' => $this->retryCount,
|
||||
'delaySeconds' => $this->delaySeconds,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
183
shared/lib/Mail/Selector/ServiceSelector.php
Normal file
183
shared/lib/Mail/Selector/ServiceSelector.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Selector;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Mail\Service\ServiceScope;
|
||||
|
||||
/**
|
||||
* Mail Service Selector
|
||||
*
|
||||
* Filter criteria for selecting mail services from providers.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class ServiceSelector implements JsonSerializable {
|
||||
|
||||
private ?ServiceScope $scope = null;
|
||||
private ?string $owner = null;
|
||||
private ?string $address = null;
|
||||
private ?array $capabilities = null;
|
||||
private ?bool $enabled = null;
|
||||
|
||||
/**
|
||||
* Filter by service scope
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param ServiceScope|null $scope
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setScope(?ServiceScope $scope): self {
|
||||
$this->scope = $scope;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the scope filter
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return ServiceScope|null
|
||||
*/
|
||||
public function getScope(): ?ServiceScope {
|
||||
return $this->scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by owner user ID
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|null $owner
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setOwner(?string $owner): self {
|
||||
$this->owner = $owner;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the owner filter
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getOwner(): ?string {
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by email address (matches primary or secondary)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|null $address
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setAddress(?string $address): self {
|
||||
$this->address = $address;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the address filter
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getAddress(): ?string {
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by required capabilities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array<string>|null $capabilities
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setCapabilities(?array $capabilities): self {
|
||||
$this->capabilities = $capabilities;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the capabilities filter
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string>|null
|
||||
*/
|
||||
public function getCapabilities(): ?array {
|
||||
return $this->capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by enabled status
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param bool|null $enabled
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setEnabled(?bool $enabled): self {
|
||||
$this->enabled = $enabled;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enabled filter
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getEnabled(): ?bool {
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any filter criteria are set
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasFilters(): bool {
|
||||
return $this->scope !== null
|
||||
|| $this->owner !== null
|
||||
|| $this->address !== null
|
||||
|| $this->capabilities !== null
|
||||
|| $this->enabled !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
return array_filter([
|
||||
'scope' => $this->scope?->value,
|
||||
'owner' => $this->owner,
|
||||
'address' => $this->address,
|
||||
'capabilities' => $this->capabilities,
|
||||
'enabled' => $this->enabled,
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
}
|
||||
139
shared/lib/Mail/Service/IServiceBase.php
Normal file
139
shared/lib/Mail/Service/IServiceBase.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Mail\Entity\IAddress;
|
||||
|
||||
/**
|
||||
* Mail Service Base Interface
|
||||
*
|
||||
* Core interface for mail services providing identity, addressing, and capability information.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceBase extends JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'mail.service';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_PROVIDER = 'provider';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_SCOPE = 'scope';
|
||||
public const JSON_PROPERTY_OWNER = 'owner';
|
||||
public const JSON_PROPERTY_ENABLED = 'enabled';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
public const JSON_PROPERTY_PRIMARY_ADDRESS = 'primaryAddress';
|
||||
public const JSON_PROPERTY_SECONDARY_ADDRESSES = 'secondaryAddresses';
|
||||
|
||||
/**
|
||||
* Confirms if a specific capability is supported
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $value Required capability e.g. 'Send'
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* Gets the unique identifier of the provider this service belongs to
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function in(): string;
|
||||
|
||||
/**
|
||||
* Gets the unique identifier for this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|int
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the localized human-friendly name of this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLabel(): string;
|
||||
|
||||
/**
|
||||
* Gets the scope of this service (System or User)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return ServiceScope
|
||||
*/
|
||||
public function getScope(): ServiceScope;
|
||||
|
||||
/**
|
||||
* Gets the owner user ID for User-scoped services
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null User ID or null for System scope
|
||||
*/
|
||||
public function getOwner(): ?string;
|
||||
|
||||
/**
|
||||
* Gets whether this service is enabled
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Gets the primary mailing address for this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return IAddress
|
||||
*/
|
||||
public function getPrimaryAddress(): IAddress;
|
||||
|
||||
/**
|
||||
* Gets the secondary mailing addresses (aliases) for this service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<int, IAddress>
|
||||
*/
|
||||
public function getSecondaryAddresses(): array;
|
||||
|
||||
/**
|
||||
* Checks if this service handles a specific email address
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $address Email address to check
|
||||
*
|
||||
* @return bool True if address matches primary or any secondary address
|
||||
*/
|
||||
public function handlesAddress(string $address): bool;
|
||||
|
||||
}
|
||||
36
shared/lib/Mail/Service/IServiceIdentity.php
Normal file
36
shared/lib/Mail/Service/IServiceIdentity.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Service Identity Interface
|
||||
*
|
||||
* Base interface for authentication credentials used by mail services.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceIdentity extends JsonSerializable {
|
||||
|
||||
public const TYPE_BASIC = 'basic';
|
||||
public const TYPE_OAUTH = 'oauth';
|
||||
public const TYPE_APIKEY = 'apikey';
|
||||
|
||||
/**
|
||||
* Gets the identity/authentication type
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string One of: 'basic', 'oauth', 'apikey'
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
}
|
||||
39
shared/lib/Mail/Service/IServiceIdentityApiKey.php
Normal file
39
shared/lib/Mail/Service/IServiceIdentityApiKey.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
/**
|
||||
* Mail Service API Key Identity Interface
|
||||
*
|
||||
* API key authentication for transactional mail services (SendGrid, Mailgun, etc.)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceIdentityApiKey extends IServiceIdentity {
|
||||
|
||||
/**
|
||||
* Gets the API key
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getApiKey(): string;
|
||||
|
||||
/**
|
||||
* Gets the optional API key identifier/name
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApiKeyId(): ?string;
|
||||
|
||||
}
|
||||
39
shared/lib/Mail/Service/IServiceIdentityBasic.php
Normal file
39
shared/lib/Mail/Service/IServiceIdentityBasic.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
/**
|
||||
* Mail Service Basic Authentication Identity Interface
|
||||
*
|
||||
* Username/password authentication for traditional mail services.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceIdentityBasic extends IServiceIdentity {
|
||||
|
||||
/**
|
||||
* Gets the username/login identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUsername(): string;
|
||||
|
||||
/**
|
||||
* Gets the password/secret
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPassword(): string;
|
||||
|
||||
}
|
||||
68
shared/lib/Mail/Service/IServiceIdentityOAuth.php
Normal file
68
shared/lib/Mail/Service/IServiceIdentityOAuth.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Mail Service OAuth Identity Interface
|
||||
*
|
||||
* OAuth2 token-based authentication for modern mail APIs.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceIdentityOAuth extends IServiceIdentity {
|
||||
|
||||
/**
|
||||
* Gets the OAuth access token
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getAccessToken(): string;
|
||||
|
||||
/**
|
||||
* Gets the OAuth refresh token
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getRefreshToken(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the token expiration time
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return DateTimeImmutable|null
|
||||
*/
|
||||
public function getExpiresAt(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the granted OAuth scopes
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getScopes(): array;
|
||||
|
||||
/**
|
||||
* Checks if the access token has expired
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isExpired(): bool;
|
||||
|
||||
}
|
||||
105
shared/lib/Mail/Service/IServiceLocation.php
Normal file
105
shared/lib/Mail/Service/IServiceLocation.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Mail Service Location Interface
|
||||
*
|
||||
* Unified interface supporting both URI-based (API services) and socket-based
|
||||
* (traditional IMAP/SMTP) connection configurations.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceLocation extends JsonSerializable {
|
||||
|
||||
public const TYPE_URI = 'uri';
|
||||
public const TYPE_SOCKET_SINGLE = 'socket-single';
|
||||
public const TYPE_SOCKET_SPLIT = 'socket-split';
|
||||
|
||||
public const SECURITY_NONE = 'none';
|
||||
public const SECURITY_SSL = 'ssl';
|
||||
public const SECURITY_TLS = 'tls';
|
||||
public const SECURITY_STARTTLS = 'starttls';
|
||||
|
||||
/**
|
||||
* Gets the location type
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string One of: 'uri', 'socket-single', 'socket-split'
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* Gets the URI for API-based services (JMAP, EWS, Graph API, HTTP relay)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null URI or null if not URI-based
|
||||
*/
|
||||
public function getUri(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the inbound/primary host for socket-based services
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null Hostname or null if URI-based
|
||||
*/
|
||||
public function getInboundHost(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the inbound/primary port for socket-based services
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return int|null Port number or null if URI-based
|
||||
*/
|
||||
public function getInboundPort(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the inbound/primary security mode
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null One of: 'none', 'ssl', 'tls', 'starttls'
|
||||
*/
|
||||
public function getInboundSecurity(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the outbound host for split-socket services (e.g., SMTP separate from IMAP)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null Hostname or null if not split-socket
|
||||
*/
|
||||
public function getOutboundHost(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the outbound port for split-socket services
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return int|null Port number or null if not split-socket
|
||||
*/
|
||||
public function getOutboundPort(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the outbound security mode for split-socket services
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return string|null One of: 'none', 'ssl', 'tls', 'starttls'
|
||||
*/
|
||||
public function getOutboundSecurity(): ?string;
|
||||
|
||||
}
|
||||
49
shared/lib/Mail/Service/IServiceSend.php
Normal file
49
shared/lib/Mail/Service/IServiceSend.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use KTXF\Mail\Entity\IMessageMutable;
|
||||
use KTXF\Mail\Exception\SendException;
|
||||
|
||||
/**
|
||||
* Mail Service Send Interface
|
||||
*
|
||||
* Interface for mail services capable of sending outbound messages.
|
||||
* This is the minimum capability for an outbound-only mail service.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface IServiceSend extends IServiceBase {
|
||||
|
||||
public const CAPABILITY_SEND = 'Send';
|
||||
|
||||
/**
|
||||
* Creates a new fresh message object
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return IMessageMutable Fresh message object for composing
|
||||
*/
|
||||
public function messageFresh(): IMessageMutable;
|
||||
|
||||
/**
|
||||
* Sends an outbound message
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param IMessageMutable $message Message to send
|
||||
*
|
||||
* @return string Message ID assigned by the transport
|
||||
*
|
||||
* @throws SendException On delivery failure
|
||||
*/
|
||||
public function messageSend(IMessageMutable $message): string;
|
||||
|
||||
}
|
||||
78
shared/lib/Mail/Service/ServiceIdentityApiKey.php
Normal file
78
shared/lib/Mail/Service/ServiceIdentityApiKey.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
/**
|
||||
* Mail Service API Key Identity Implementation
|
||||
*
|
||||
* API key authentication for transactional mail services.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class ServiceIdentityApiKey implements IServiceIdentityApiKey {
|
||||
|
||||
/**
|
||||
* @param string $apiKey API key
|
||||
* @param string|null $apiKeyId Optional API key identifier
|
||||
*/
|
||||
public function __construct(
|
||||
private string $apiKey,
|
||||
private ?string $apiKeyId = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates from array data
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self {
|
||||
return new self(
|
||||
$data['apiKey'] ?? '',
|
||||
$data['apiKeyId'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getType(): string {
|
||||
return self::TYPE_APIKEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getApiKey(): string {
|
||||
return $this->apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getApiKeyId(): ?string {
|
||||
return $this->apiKeyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
return array_filter([
|
||||
'type' => self::TYPE_APIKEY,
|
||||
'apiKeyId' => $this->apiKeyId,
|
||||
// API key intentionally omitted from serialization for security
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
}
|
||||
78
shared/lib/Mail/Service/ServiceIdentityBasic.php
Normal file
78
shared/lib/Mail/Service/ServiceIdentityBasic.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
/**
|
||||
* Mail Service Basic Identity Implementation
|
||||
*
|
||||
* Username/password authentication credentials.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class ServiceIdentityBasic implements IServiceIdentityBasic {
|
||||
|
||||
/**
|
||||
* @param string $username Login username
|
||||
* @param string $password Login password
|
||||
*/
|
||||
public function __construct(
|
||||
private string $username,
|
||||
private string $password,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates from array data
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self {
|
||||
return new self(
|
||||
$data['username'] ?? '',
|
||||
$data['password'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getType(): string {
|
||||
return self::TYPE_BASIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getUsername(): string {
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPassword(): string {
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'type' => self::TYPE_BASIC,
|
||||
'username' => $this->username,
|
||||
// Password intentionally omitted from serialization for security
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
211
shared/lib/Mail/Service/ServiceLocation.php
Normal file
211
shared/lib/Mail/Service/ServiceLocation.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
/**
|
||||
* Mail Service Location Implementation
|
||||
*
|
||||
* Unified implementation supporting URI-based and socket-based connections.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
class ServiceLocation implements IServiceLocation {
|
||||
|
||||
/**
|
||||
* @param string $type Location type (uri, socket-single, socket-split)
|
||||
* @param string|null $uri URI for API-based services
|
||||
* @param string|null $inboundHost Inbound/primary host
|
||||
* @param int|null $inboundPort Inbound/primary port
|
||||
* @param string|null $inboundSecurity Inbound security (none, ssl, tls, starttls)
|
||||
* @param string|null $outboundHost Outbound host (for split-socket)
|
||||
* @param int|null $outboundPort Outbound port (for split-socket)
|
||||
* @param string|null $outboundSecurity Outbound security (for split-socket)
|
||||
*/
|
||||
public function __construct(
|
||||
private string $type,
|
||||
private ?string $uri = null,
|
||||
private ?string $inboundHost = null,
|
||||
private ?int $inboundPort = null,
|
||||
private ?string $inboundSecurity = null,
|
||||
private ?string $outboundHost = null,
|
||||
private ?int $outboundPort = null,
|
||||
private ?string $outboundSecurity = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a URI-based location (for API services)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $uri
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function uri(string $uri): self {
|
||||
return new self(self::TYPE_URI, $uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single-socket location (e.g., SMTP only)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $host
|
||||
* @param int $port
|
||||
* @param string $security
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function socket(string $host, int $port, string $security = self::SECURITY_TLS): self {
|
||||
return new self(
|
||||
self::TYPE_SOCKET_SINGLE,
|
||||
null,
|
||||
$host,
|
||||
$port,
|
||||
$security
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a split-socket location (IMAP + SMTP)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $inboundHost IMAP host
|
||||
* @param int $inboundPort IMAP port
|
||||
* @param string $inboundSecurity IMAP security
|
||||
* @param string $outboundHost SMTP host
|
||||
* @param int $outboundPort SMTP port
|
||||
* @param string $outboundSecurity SMTP security
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function splitSocket(
|
||||
string $inboundHost,
|
||||
int $inboundPort,
|
||||
string $inboundSecurity,
|
||||
string $outboundHost,
|
||||
int $outboundPort,
|
||||
string $outboundSecurity
|
||||
): self {
|
||||
return new self(
|
||||
self::TYPE_SOCKET_SPLIT,
|
||||
null,
|
||||
$inboundHost,
|
||||
$inboundPort,
|
||||
$inboundSecurity,
|
||||
$outboundHost,
|
||||
$outboundPort,
|
||||
$outboundSecurity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates from array data
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self {
|
||||
return new self(
|
||||
$data['type'] ?? self::TYPE_SOCKET_SINGLE,
|
||||
$data['uri'] ?? null,
|
||||
$data['inboundHost'] ?? $data['host'] ?? null,
|
||||
$data['inboundPort'] ?? $data['port'] ?? null,
|
||||
$data['inboundSecurity'] ?? $data['security'] ?? null,
|
||||
$data['outboundHost'] ?? null,
|
||||
$data['outboundPort'] ?? null,
|
||||
$data['outboundSecurity'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getType(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getUri(): ?string {
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getInboundHost(): ?string {
|
||||
return $this->inboundHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getInboundPort(): ?int {
|
||||
return $this->inboundPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getInboundSecurity(): ?string {
|
||||
return $this->inboundSecurity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getOutboundHost(): ?string {
|
||||
return $this->outboundHost ?? $this->inboundHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getOutboundPort(): ?int {
|
||||
return $this->outboundPort ?? $this->inboundPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getOutboundSecurity(): ?string {
|
||||
return $this->outboundSecurity ?? $this->inboundSecurity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
$data = ['type' => $this->type];
|
||||
|
||||
if ($this->type === self::TYPE_URI) {
|
||||
$data['uri'] = $this->uri;
|
||||
} else {
|
||||
$data['inboundHost'] = $this->inboundHost;
|
||||
$data['inboundPort'] = $this->inboundPort;
|
||||
$data['inboundSecurity'] = $this->inboundSecurity;
|
||||
|
||||
if ($this->type === self::TYPE_SOCKET_SPLIT) {
|
||||
$data['outboundHost'] = $this->outboundHost;
|
||||
$data['outboundPort'] = $this->outboundPort;
|
||||
$data['outboundSecurity'] = $this->outboundSecurity;
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($data, fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
}
|
||||
37
shared/lib/Mail/Service/ServiceScope.php
Normal file
37
shared/lib/Mail/Service/ServiceScope.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Defines the scope/context of a mail service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
enum ServiceScope: string implements JsonSerializable {
|
||||
|
||||
/**
|
||||
* System-level service for tenant-wide communications
|
||||
* (e.g., notifications@, reports@, noreply@)
|
||||
*/
|
||||
case System = 'system';
|
||||
|
||||
/**
|
||||
* User-level service for personal mail accounts
|
||||
* (e.g., user's own IMAP/SMTP accounts)
|
||||
*/
|
||||
case User = 'user';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
49
shared/lib/Module/ModuleInstanceAbstract.php
Normal file
49
shared/lib/Module/ModuleInstanceAbstract.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace KTXF\Module;
|
||||
|
||||
abstract class ModuleInstanceAbstract implements ModuleInstanceInterface
|
||||
{
|
||||
// mandatory methods that must be implemented by each concrete module
|
||||
abstract public function handle(): string;
|
||||
|
||||
abstract public function version(): string;
|
||||
|
||||
abstract public function label(): string;
|
||||
|
||||
abstract public function description(): string;
|
||||
|
||||
abstract public function author(): string;
|
||||
|
||||
// optional methods that can be overridden by specific modules
|
||||
public function install(): void
|
||||
{
|
||||
// Override in specific modules if needed
|
||||
}
|
||||
|
||||
public function uninstall(): void
|
||||
{
|
||||
// Override in specific modules if needed
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
// Override in specific modules if needed
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
{
|
||||
// Override in specific modules if needed
|
||||
}
|
||||
|
||||
public function upgrade(): void
|
||||
{
|
||||
// Override in specific modules if needed
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Override in specific modules if needed
|
||||
}
|
||||
|
||||
}
|
||||
61
shared/lib/Module/ModuleInstanceInterface.php
Normal file
61
shared/lib/Module/ModuleInstanceInterface.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace KTXF\Module;
|
||||
|
||||
interface ModuleInstanceInterface
|
||||
{
|
||||
/**
|
||||
* Get module version
|
||||
*/
|
||||
public function handle(): string;
|
||||
|
||||
/**
|
||||
* Get module version
|
||||
*/
|
||||
public function version(): string;
|
||||
|
||||
/**
|
||||
* Get module label
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Get module description
|
||||
*/
|
||||
public function description(): string;
|
||||
|
||||
/**
|
||||
* Get module author
|
||||
*/
|
||||
public function author(): string;
|
||||
|
||||
/**
|
||||
* Install the module
|
||||
*/
|
||||
public function install(): void;
|
||||
|
||||
/**
|
||||
* Uninstall the module
|
||||
*/
|
||||
public function uninstall(): void;
|
||||
|
||||
/**
|
||||
* Upgrade the module
|
||||
*/
|
||||
public function upgrade(): void;
|
||||
|
||||
/**
|
||||
* Enable the module
|
||||
*/
|
||||
public function enable(): void;
|
||||
|
||||
/**
|
||||
* Disable the module
|
||||
*/
|
||||
public function disable(): void;
|
||||
|
||||
/**
|
||||
* Bootstrap the module when enabled
|
||||
*/
|
||||
public function boot(): void;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user