Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 4ae6befc7b
422 changed files with 47225 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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,
};
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}

View 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;
}

View 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';
}

View 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();
}
}

View 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');
}
}

View 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;
}

View 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');
}
}

View 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;
}

View 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');
}
}

View 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;
}

View 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';
}

View 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');
}
}

View 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;
}

View 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';
}

View 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';
}

View 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();
}
}

View 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() {
}
}

View 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';
}

View 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';
}

View 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;
}

View 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');
}
}

View 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();
}
}

View 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';
}

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

View 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';
}

View 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';
}

View 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';
}

View 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';
}

View 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');
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,8 @@
<?php
namespace KTXF\Controller;
abstract class ControllerAbstract
{
}

146
shared/lib/Event/Event.php Normal file
View 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,
];
}
}

View 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;
}
}

View 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(),
]);
}
}

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

View 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 {}

View 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;
}

View 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';
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}
}

View 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\Files\Provider;
use KTXF\Resource\Provider\ResourceProviderInterface;
interface IProviderBase extends ResourceProviderInterface {
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXF\Json;
interface JsonDeserializable {
public function jsonDeserialize(array|string $data): static;
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXF\Json;
interface JsonSerializable extends \JsonSerializable {
public function jsonSerialize(): mixed;
}

View 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;
}
}

View 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;
}
}

View 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\Mail\Collection;
use KTXF\Resource\Provider\Node\NodeBaseAbstract;
/**
* Abstract Mail Collection Base Class
*
* Provides common implementation for mail collections
*
* @since 2025.05.01
*/
abstract class CollectionBase extends NodeBaseAbstract implements CollectionBaseInterface {
protected CollectionPropertiesBaseAbstract $properties;
/**
* @inheritDoc
*/
public function getProperties(): CollectionPropertiesBaseInterface {
return $this->properties;
}
}

View 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\Mail\Collection;
use KTXF\Resource\Provider\Node\NodeBaseInterface;
/**
* Mail Collection Base Interface
*
* Interface represents a mailbox/folder in a mail service
*
* @since 2025.05.01
*/
interface CollectionBaseInterface extends NodeBaseInterface {
/**
* Gets the collection properties
*
* @since 2025.05.01
*/
public function getProperties(): CollectionPropertiesBaseInterface|CollectionPropertiesMutableInterface;
}

View 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\Collection;
use KTXF\Resource\Provider\Node\NodeMutableAbstract;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
/**
* Abstract Mail Collection Mutable Class
*
* Provides common implementation for mutable mail collections
*
* @since 2025.05.01
*/
abstract class CollectionMutableAbstract extends NodeMutableAbstract implements CollectionMutableInterface {
protected CollectionPropertiesMutableAbstract $properties;
/**
* @inheritDoc
*/
public function getProperties(): CollectionPropertiesMutableInterface {
return $this->properties;
}
/**
* @inheritDoc
*/
public function setProperties(NodePropertiesMutableInterface $value): static {
if (!$value instanceof CollectionPropertiesMutableInterface) {
throw new \InvalidArgumentException('Properties must implement CollectionPropertiesMutableInterface');
}
// Copy all property values
$this->properties->setLabel($value->getLabel());
$this->properties->setRole($value->getRole());
$this->properties->setRank($value->getRank());
$this->properties->setSubscription($value->getSubscription());
return $this;
}
}

View File

@@ -0,0 +1,32 @@
<?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 KTXF\Resource\Provider\Node\NodeMutableInterface;
/**
* Mail Collection Mutable Interface
*
* Interface for altering mailbox/folder properties in a mail service
*
* @since 2025.05.01
*
* @method static setProperties(CollectionPropertiesMutableInterface $value)
*/
interface CollectionMutableInterface extends CollectionBaseInterface, NodeMutableInterface {
/**
* Gets the collection properties (mutable)
*
* @since 2025.05.01
*/
public function getProperties(): CollectionPropertiesMutableInterface;
}

View 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\Collection;
use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract;
/**
* Abstract Mail Collection Properties Base Class
*
* Provides common implementation for mail collection properties
*
* @since 2025.05.01
*/
abstract class CollectionPropertiesBaseAbstract extends NodePropertiesBaseAbstract implements CollectionPropertiesBaseInterface {
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
/**
* @inheritDoc
*/
public function total(): int {
return $this->data['total'] ?? 0;
}
/**
* @inheritDoc
*/
public function unread(): int {
return $this->data['unread'] ?? 0;
}
/**
* @inheritDoc
*/
public function getLabel(): string {
return $this->data['label'] ?? '';
}
/**
* @inheritDoc
*/
public function getRole(): CollectionRoles {
return isset($this->data['role'])
? ($this->data['role'] instanceof CollectionRoles ? $this->data['role'] : CollectionRoles::from($this->data['role']))
: CollectionRoles::Custom;
}
/**
* @inheritDoc
*/
public function getRank(): int {
return $this->data['rank'] ?? 0;
}
/**
* @inheritDoc
*/
public function getSubscription(): bool {
return $this->data['subscribed'] ?? false;
}
}

View 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\Mail\Collection;
use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface;
interface CollectionPropertiesBaseInterface extends NodePropertiesBaseInterface {
public const JSON_TYPE = 'mail.collection';
public const JSON_PROPERTY_TOTAL = 'total';
public const JSON_PROPERTY_UNREAD = 'unread';
public const JSON_PROPERTY_LABEL = 'label';
public const JSON_PROPERTY_ROLE = 'role';
public const JSON_PROPERTY_RANK = 'rank';
public const JSON_PROPERTY_SUBSCRIPTION = 'subscription';
public function total(): int;
public function unread(): int;
public function getLabel(): string;
public function getRole(): CollectionRoles;
public function getRank(): int;
public function getSubscription(): bool;
}

View File

@@ -0,0 +1,65 @@
<?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 KTXF\Resource\Provider\Node\NodePropertiesMutableAbstract;
/**
* Abstract Mail Collection Properties Mutable Class
*/
abstract class CollectionPropertiesMutableAbstract extends CollectionPropertiesBaseAbstract implements CollectionPropertiesMutableInterface {
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
/**
* @inheritDoc
*/
public function jsonDeserialize(array|string $data): static {
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->data = $data;
return $this;
}
/**
* @inheritDoc
*/
public function setLabel(string $value): static {
$this->data['label'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setRole(CollectionRoles $value): static {
$this->data['role'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setRank(int $value): static {
$this->data['rank'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setSubscription(bool $value): static {
$this->data['subscription'] = $value;
return $this;
}
}

View 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\Mail\Collection;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
interface CollectionPropertiesMutableInterface extends CollectionPropertiesBaseInterface, NodePropertiesMutableInterface {
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
public function setLabel(string $value);
public function setRole(CollectionRoles $value): static;
public function setRank(int $value): static;
public function setSubscription(bool $value): static;
}

View 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;
}
}

View File

@@ -0,0 +1,32 @@
<?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 KTXF\Mail\Object\MessagePropertiesBaseInterface;
use KTXF\Resource\Provider\Node\NodeBaseAbstract;
/**
* Abstract Mail Entity Base Class
*
* Provides common implementation for mail entities
*
* @since 2025.05.01
*/
abstract class EntityBaseAbstract extends NodeBaseAbstract implements EntityBaseInterface {
protected MessagePropertiesBaseInterface $properties;
/**
* @inheritDoc
*/
public function getProperties(): MessagePropertiesBaseInterface {
return $this->properties;
}
}

View File

@@ -0,0 +1,27 @@
<?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 KTXF\Mail\Object\MessagePropertiesBaseInterface;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Resource\Provider\Node\NodeBaseInterface;
interface EntityBaseInterface extends NodeBaseInterface {
public const JSON_TYPE = 'mail.entity';
/**
* Gets the entity properties
*
* @since 2025.05.01
*/
public function getProperties(): MessagePropertiesBaseInterface|MessagePropertiesMutableInterface;
}

View File

@@ -0,0 +1,48 @@
<?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 KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Resource\Provider\Node\NodeMutableAbstract;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
/**
* Abstract Mail Entity Mutable Class
*
* Provides common implementation for mutable mail entities
*
* @since 2025.05.01
*/
abstract class EntityMutableAbstract extends NodeMutableAbstract implements EntityMutableInterface {
public const JSON_TYPE = EntityMutableInterface::JSON_TYPE;
protected MessagePropertiesMutableInterface $properties;
/**
* @inheritDoc
*/
public function getProperties(): MessagePropertiesMutableInterface {
return $this->properties;
}
/**
* @inheritDoc
*/
public function setProperties(NodePropertiesMutableInterface $value): static {
if (!$value instanceof MessagePropertiesMutableInterface) {
throw new \InvalidArgumentException('Properties must implement MessagePropertiesMutableInterface');
}
$this->properties = $value;
return $this;
}
}

View File

@@ -0,0 +1,29 @@
<?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 KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Resource\Provider\Node\NodeMutableInterface;
/**
* @method static setProperties(MessagePropertiesMutableInterface $value)
*/
interface EntityMutableInterface extends EntityBaseInterface, NodeMutableInterface {
public const JSON_TYPE = EntityBaseInterface::JSON_TYPE;
/**
* Gets the entity properties (mutable)
*
* @since 2025.05.01
*/
public function getProperties(): MessagePropertiesMutableInterface;
}

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

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
/**
* Address Implementation
*
* @since 2025.05.01
*/
class Address implements AddressInterface {
/**
* @param string $address Email address
* @param string|null $name Display name
*/
public function __construct(
private string $address = '',
private ?string $name = null,
) {}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
return array_filter([
self::JSON_PROPERTY_ADDRESS => $this->address,
self::JSON_PROPERTY_LABEL => $this->name,
], fn($v) => $v !== null && $v !== '');
}
/**
* 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_LABEL] ?? $data['name'] ?? null,
);
}
/**
* @inheritDoc
*/
public function getAddress(): string {
return $this->address;
}
/**
* @inheritDoc
*/
public function setAddress(string $address): static {
$this->address = $address;
return $this;
}
/**
* @inheritDoc
*/
public function getLabel(): ?string {
return $this->name;
}
/**
* @inheritDoc
*/
public function setLabel(?string $label): static {
$this->name = $label;
return $this;
}
/**
* @inheritDoc
*/
public function toString(): string {
if ($this->name !== null && $this->name !== '') {
return sprintf('"%s" <%s>', $this->name, $this->address);
}
return $this->address;
}
/**
* String representation
*/
public function __toString(): string {
return $this->toString();
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
use KTXF\Json\JsonSerializable;
/**
* Address Interface
*
* Represents an email address with optional display name.
*
* @since 2025.05.01
*/
interface AddressInterface extends JsonSerializable {
public const JSON_PROPERTY_ADDRESS = 'address';
public const JSON_PROPERTY_LABEL = 'label';
/**
* Gets the email address
*
* @since 2025.05.01
*/
public function getAddress(): string;
/**
* Sets the email address
*
* @since 2025.05.01
*/
public function setAddress(string $value): static;
/**
* Gets the display name
*
* @since 2025.05.01
*/
public function getLabel(): ?string;
/**
* Sets the display name
*
* @since 2025.05.01
*/
public function setLabel(?string $value): static;
/**
* 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;
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
/**
* Attachment Implementation
*
* @since 2025.05.01
*/
class Attachment implements AttachmentInterface {
/**
* @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);
}
}
/**
* @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);
}
/**
* 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);
}
}

View 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\Object;
use KTXF\Json\JsonSerializable;
/**
* Attachment Interface
*
* Represents a file attachment on a mail message.
*
* @since 2025.05.01
*/
interface AttachmentInterface 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;
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
/**
* Abstract Message Part Base Class
*
* Provides common implementation for message parts (read-only)
*
* @since 2025.05.01
*/
abstract class MessagePartBaseAbstract implements MessagePartInterface {
/**
* Internal data storage
*/
protected array $data = [];
/**
* Sub-parts storage
* @var array<int,MessagePartInterface>
*/
protected array $parts = [];
/**
* Constructor
*
* @param array &$data Reference to data array
*/
public function __construct(array &$data = null) {
if ($data === null) {
$data = [];
}
$this->data = &$data;
}
/**
* @inheritDoc
*/
public function getBlobId(): ?string {
return $this->data['blobId'] ?? null;
}
/**
* @inheritDoc
*/
public function getId(): ?string {
return $this->data['partId'] ?? null;
}
/**
* @inheritDoc
*/
public function getType(): ?string {
return $this->data['type'] ?? null;
}
/**
* @inheritDoc
*/
public function getDisposition(): ?string {
return $this->data['disposition'] ?? null;
}
/**
* @inheritDoc
*/
public function getName(): ?string {
return $this->data['name'] ?? null;
}
/**
* @inheritDoc
*/
public function getCharset(): ?string {
return $this->data['charset'] ?? null;
}
/**
* @inheritDoc
*/
public function getLanguage(): ?string {
return $this->data['language'] ?? null;
}
/**
* @inheritDoc
*/
public function getLocation(): ?string {
return $this->data['location'] ?? null;
}
/**
* @inheritDoc
*/
public function getParts(): array {
return $this->parts;
}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
$result = $this->data;
if (!empty($this->parts)) {
$result['subParts'] = [];
foreach ($this->parts as $part) {
$result['subParts'][] = $part->jsonSerialize();
}
}
return $result;
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
use KTXF\Json\JsonSerializable;
/**
* Message Part Interface
*
* Represents a MIME part of a message (body, attachment, etc.)
*
* @since 2025.05.01
*/
interface MessagePartInterface extends JsonSerializable {
/**
* Gets the blob identifier
*
* @since 2025.05.01
*
* @return string|null
*/
public function getBlobId(): ?string;
/**
* Gets the part identifier
*
* @since 2025.05.01
*
* @return string|null
*/
public function getId(): ?string;
/**
* Gets the MIME type
*
* @since 2025.05.01
*
* @return string|null
*/
public function getType(): ?string;
/**
* Gets the content disposition (inline, attachment)
*
* @since 2025.05.01
*
* @return string|null
*/
public function getDisposition(): ?string;
/**
* Gets the part name
*
* @since 2025.05.01
*
* @return string|null
*/
public function getName(): ?string;
/**
* Gets the character set
*
* @since 2025.05.01
*
* @return string|null
*/
public function getCharset(): ?string;
/**
* Gets the language
*
* @since 2025.05.01
*
* @return string|null
*/
public function getLanguage(): ?string;
/**
* Gets the location
*
* @since 2025.05.01
*
* @return string|null
*/
public function getLocation(): ?string;
/**
* Gets the sub-parts
*
* @since 2025.05.01
*
* @return array<int,MessagePartInterface>
*/
public function getParts(): array;
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
/**
* Abstract Message Part Mutable Class
*
* Provides common implementation for mutable message parts
*
* @since 2025.05.01
*/
abstract class MessagePartMutableAbstract extends MessagePartBaseAbstract {
/**
* Sets the blob identifier
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setBlobId(string $value): static {
$this->data['blobId'] = $value;
return $this;
}
/**
* Sets the part identifier
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setId(string $value): static {
$this->data['partId'] = $value;
return $this;
}
/**
* Sets the MIME type
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setType(string $value): static {
$this->data['type'] = $value;
return $this;
}
/**
* Sets the content disposition
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setDisposition(string $value): static {
$this->data['disposition'] = $value;
return $this;
}
/**
* Sets the part name
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setName(string $value): static {
$this->data['name'] = $value;
return $this;
}
/**
* Sets the character set
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setCharset(string $value): static {
$this->data['charset'] = $value;
return $this;
}
/**
* Sets the language
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setLanguage(string $value): static {
$this->data['language'] = $value;
return $this;
}
/**
* Sets the location
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setLocation(string $value): static {
$this->data['location'] = $value;
return $this;
}
/**
* Sets the sub-parts
*
* @since 2025.05.01
*
* @param MessagePartInterface ...$value
*
* @return static
*/
public function setParts(MessagePartInterface ...$value): static {
$this->parts = $value;
return $this;
}
}

View File

@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
use DateTimeImmutable;
use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract;
/**
* Abstract Message Properties Base Class
*
* Provides common implementation for message properties (read-only)
*
* @since 2025.05.01
*/
abstract class MessagePropertiesBaseAbstract extends NodePropertiesBaseAbstract implements MessagePropertiesBaseInterface {
public const JSON_TYPE = MessagePropertiesBaseInterface::JSON_TYPE;
/**
* @inheritDoc
*/
public function version(): int {
return $this->data['version'] ?? 1;
}
/**
* @inheritDoc
*/
public function getHeaders(): array {
return $this->data['headers'] ?? [];
}
/**
* @inheritDoc
*/
public function getHeader(string $name): string|array|null {
return $this->data['headers'][$name] ?? null;
}
/**
* @inheritDoc
*/
public function getUrid(): ?string {
return $this->data['urid'] ?? null;
}
/**
* @inheritDoc
*/
public function getCreated(): ?DateTimeImmutable {
return $this->data['created'] ?? null;
}
/**
* @inheritDoc
*/
public function getModified(): ?DateTimeImmutable {
return $this->data['modified'] ?? null;
}
/**
* @inheritDoc
*/
public function getDate(): ?DateTimeImmutable {
return $this->data['date'] ?? null;
}
/**
* @inheritDoc
*/
public function getReceived(): ?DateTimeImmutable {
return $this->data['received'] ?? null;
}
/**
* @inheritDoc
*/
public function getSize(): ?int {
return $this->data['size'] ?? null;
}
/**
* @inheritDoc
*/
public function getSender(): ?AddressInterface {
return $this->data['sender'] ?? null;
}
/**
* @inheritDoc
*/
public function getFrom(): ?AddressInterface {
return $this->data['from'] ?? null;
}
/**
* @inheritDoc
*/
public function getReplyTo(): array {
return $this->data['replyTo'] ?? [];
}
/**
* @inheritDoc
*/
public function getTo(): array {
return $this->data['to'] ?? [];
}
/**
* @inheritDoc
*/
public function getCc(): array {
return $this->data['cc'] ?? [];
}
/**
* @inheritDoc
*/
public function getBcc(): array {
return $this->data['bcc'] ?? [];
}
/**
* @inheritDoc
*/
public function getInReplyTo(): ?string {
return $this->data['inReplyTo'] ?? null;
}
/**
* @inheritDoc
*/
public function getReferences(): array {
return $this->data['references'] ?? [];
}
/**
* @inheritDoc
*/
public function getSubject(): string {
return $this->data['subject'] ?? '';
}
/**
* @inheritDoc
*/
public function getSnippet(): ?string {
return $this->data['snippet'] ?? null;
}
/**
* @inheritDoc
*/
public function getBodyText(): ?string {
return $this->data['bodyText'] ?? null;
}
/**
* @inheritDoc
*/
public function getBodyTextCharset(): ?string {
return $this->data['bodyTextCharset'] ?? null;
}
/**
* @inheritDoc
*/
public function getBodyTextSize(): ?int {
return $this->data['bodyTextSize'] ?? null;
}
/**
* @inheritDoc
*/
public function getBodyHtml(): ?string {
return $this->data['bodyHtml'] ?? null;
}
/**
* @inheritDoc
*/
public function getBodyHtmlCharset(): ?string {
return $this->data['bodyHtmlCharset'] ?? null;
}
/**
* @inheritDoc
*/
public function getBodyHtmlSize(): ?int {
return $this->data['bodyHtmlSize'] ?? null;
}
/**
* @inheritDoc
*/
public function getAttachments(): array {
return $this->data['attachments'] ?? [];
}
/**
* @inheritDoc
*/
public function getFlags(): array {
return $this->data['flags'] ?? [
'read' => false,
'starred' => false,
'important' => false,
'answered' => false,
'forwarded' => false,
'draft' => false,
'deleted' => false,
'flagged' => false,
];
}
/**
* @inheritDoc
*/
public function getFlag(string $name): bool {
return $this->data['flags'][$name] ?? false;
}
/**
* Gets message labels
*
* @since 2025.05.01
*
* @return array<int, string>
*/
public function getLabels(): array {
return $this->data['labels'] ?? [];
}
/**
* Gets message tags
*
* @since 2025.05.01
*
* @return array<int, string>
*/
public function getTags(): array {
return $this->data['tags'] ?? [];
}
/**
* Gets message priority
*
* @since 2025.05.01
*
* @return string
*/
public function getPriority(): string {
return $this->data['priority'] ?? 'normal';
}
/**
* Gets message sensitivity
*
* @since 2025.05.01
*
* @return string
*/
public function getSensitivity(): string {
return $this->data['sensitivity'] ?? 'normal';
}
/**
* Gets encryption information
*
* @since 2025.05.01
*
* @return array{method: string|null, signed: bool, encrypted: bool}
*/
public function getEncryption(): array {
return $this->data['encryption'] ?? [
'method' => null,
'signed' => false,
'encrypted' => false,
];
}
/**
* Checks if delivery receipt is requested
*
* @since 2025.05.01
*
* @return bool
*/
public function isDeliveryReceipt(): bool {
return $this->data['deliveryReceipt'] ?? false;
}
/**
* Checks if read receipt is requested
*
* @since 2025.05.01
*
* @return bool
*/
public function isReadReceipt(): bool {
return $this->data['readReceipt'] ?? false;
}
/**
* @inheritDoc
*/
public function hasRecipients(): bool {
return !empty($this->data['to']) || !empty($this->data['cc']) || !empty($this->data['bcc']);
}
/**
* @inheritDoc
*/
public function hasBody(): bool {
return ($this->data['bodyText'] !== null && $this->data['bodyText'] !== '')
|| ($this->data['bodyHtml'] !== null && $this->data['bodyHtml'] !== '');
}
/**
* @inheritDoc
*/
public function getBody(): ?MessagePartInterface {
return $this->data['body'] ?? null;
}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
$data = [
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_VERSION => $this->data['version'] ?? 1,
];
if (!empty($this->data['headers'])) {
$data[self::JSON_PROPERTY_HEADERS] = $this->data['headers'];
}
if (isset($this->data['urid']) && $this->data['urid'] !== null) {
$data[self::JSON_PROPERTY_URID] = $this->data['urid'];
}
if (isset($this->data['date']) && $this->data['date'] !== null) {
$data[self::JSON_PROPERTY_DATE] = $this->data['date'] instanceof DateTimeImmutable
? $this->data['date']->format('c')
: $this->data['date'];
}
if (isset($this->data['received']) && $this->data['received'] !== null) {
$data[self::JSON_PROPERTY_RECEIVED] = $this->data['received'] instanceof DateTimeImmutable
? $this->data['received']->format('c')
: $this->data['received'];
}
if (isset($this->data['size']) && $this->data['size'] !== null) {
$data[self::JSON_PROPERTY_SIZE] = $this->data['size'];
}
if (isset($this->data['sender']) && $this->data['sender'] !== null) {
$data[self::JSON_PROPERTY_SENDER] = $this->data['sender'];
}
if (isset($this->data['from']) && $this->data['from'] !== null) {
$data[self::JSON_PROPERTY_FROM] = $this->data['from'];
}
if (!empty($this->data['replyTo'])) {
$data[self::JSON_PROPERTY_REPLY_TO] = $this->data['replyTo'];
}
if (!empty($this->data['to'])) {
$data[self::JSON_PROPERTY_TO] = $this->data['to'];
}
if (!empty($this->data['cc'])) {
$data[self::JSON_PROPERTY_CC] = $this->data['cc'];
}
if (!empty($this->data['bcc'])) {
$data[self::JSON_PROPERTY_BCC] = $this->data['bcc'];
}
if (isset($this->data['inReplyTo']) && $this->data['inReplyTo'] !== null) {
$data[self::JSON_PROPERTY_IN_REPLY_TO] = $this->data['inReplyTo'];
}
if (!empty($this->data['references'])) {
$data[self::JSON_PROPERTY_REFERENCES] = $this->data['references'];
}
if (isset($this->data['snippet']) && $this->data['snippet'] !== null) {
$data[self::JSON_PROPERTY_SNIPPET] = $this->data['snippet'];
}
if (!empty($this->data['attachments'])) {
$data[self::JSON_PROPERTY_ATTACHMENTS] = $this->data['attachments'];
}
$data[self::JSON_PROPERTY_SUBJECT] = $this->data['subject'] ?? null;
$data[self::JSON_PROPERTY_BODY] = $this->data['body'] ?? null;
return $data;
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
use DateTimeImmutable;
use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface;
/**
* Message Properties Base Interface
*
* @since 2025.05.01
*/
interface MessagePropertiesBaseInterface extends NodePropertiesBaseInterface {
public const JSON_TYPE = 'mail.message';
public const JSON_PROPERTY_HEADERS = 'headers';
public const JSON_PROPERTY_URID = 'urid';
public const JSON_PROPERTY_DATE = 'date';
public const JSON_PROPERTY_RECEIVED = 'received';
public const JSON_PROPERTY_SIZE = 'size';
public const JSON_PROPERTY_SENDER = 'sender';
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_IN_REPLY_TO = 'inReplyTo';
public const JSON_PROPERTY_REFERENCES = 'references';
public const JSON_PROPERTY_SUBJECT = 'subject';
public const JSON_PROPERTY_SNIPPET = 'snippet';
public const JSON_PROPERTY_BODY = 'body';
public const JSON_PROPERTY_ATTACHMENTS = 'attachments';
public const JSON_PROPERTY_TAGS = 'tags';
/**
* 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|array<int,string>|null Header value(s) or null if not set
*/
public function getHeader(string $name): string|array|null;
/**
* Gets the universal resource identifier (URN)
*
* @since 2025.05.01
*
* @return string|null
*/
public function getUrid(): ?string;
/**
* Gets the message date
*
* @since 2025.05.01
*
* @return DateTimeImmutable|null
*/
public function getDate(): ?DateTimeImmutable;
/**
* Gets the received date
*
* @since 2025.05.01
*
* @return DateTimeImmutable|null
*/
public function getReceived(): ?DateTimeImmutable;
/**
* Gets the message size in bytes
*
* @since 2025.05.01
*
* @return int|null
*/
public function getSize(): ?int;
/**
* Gets the sender address (actual sender, may differ from From)
*
* @since 2025.05.01
*
* @return AddressInterface|null
*/
public function getSender(): ?AddressInterface;
/**
* Gets the sender address
*
* @since 2025.05.01
*
* @return AddressInterface|null
*/
public function getFrom(): ?AddressInterface;
/**
* Gets the reply-to addresses
*
* @since 2025.05.01
*
* @return array<int,AddressInterface>
*/
public function getReplyTo(): array;
/**
* Gets the primary recipients (To)
*
* @since 2025.05.01
*
* @return array<int,AddressInterface>
*/
public function getTo(): array;
/**
* Gets the carbon copy recipients (CC)
*
* @since 2025.05.01
*
* @return array<int,AddressInterface>
*/
public function getCc(): array;
/**
* Gets the blind carbon copy recipients (BCC)
*
* @since 2025.05.01
*
* @return array<int,AddressInterface>
*/
public function getBcc(): array;
/**
* Gets the message ID this is replying to
*
* @since 2025.05.01
*
* @return string|null
*/
public function getInReplyTo(): ?string;
/**
* Gets the references (message IDs in thread)
*
* @since 2025.05.01
*
* @return array<int,string>
*/
public function getReferences(): array;
/**
* Gets the message subject
*
* @since 2025.05.01
*
* @return string
*/
public function getSubject(): string;
/**
* Gets the message snippet/preview
*
* @since 2025.05.01
*
* @return string|null
*/
public function getSnippet(): ?string;
/**
* 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;
/**
* Gets the message body structure
*
* @since 2025.05.01
*
* @return MessagePartInterface|null The body structure or null if no body
*/
public function getBody(): ?MessagePartInterface;
/**
* Gets the plain text body content
*
* @since 2025.05.01
*
* @return string|null
*/
public function getBodyText(): ?string;
/**
* Gets the HTML body content
*
* @since 2025.05.01
*
* @return string|null
*/
public function getBodyHtml(): ?string;
/**
* Gets the attachments
*
* @since 2025.05.01
*
* @return array<int,AttachmentInterface>
*/
public function getAttachments(): array;
/**
* Gets message flags
*
* @since 2025.05.01
*
* @return array{read:bool,starred:bool,important:bool,answered:bool,forwarded:bool,draft:bool,deleted:bool,flagged:bool}
*/
public function getFlags(): array;
/**
* Gets a specific flag value
*
* @since 2025.05.01
*
* @param string $name Flag name (read, starred, important, answered, forwarded, draft, deleted, flagged)
*
* @return bool
*/
public function getFlag(string $name): bool;
}

View File

@@ -0,0 +1,455 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
use DateTimeImmutable;
/**
* Abstract Message Properties Mutable Class
*
* Provides common implementation for mutable message properties
*
* @since 2025.05.01
*/
abstract class MessagePropertiesMutableAbstract extends MessagePropertiesBaseAbstract implements MessagePropertiesMutableInterface {
public const JSON_TYPE = MessagePropertiesBaseInterface::JSON_TYPE;
/**
* @inheritDoc
*/
public function setHeaders(array $value): static {
$this->data['headers'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setHeader(string $name, string|array $value): static {
$this->data['headers'][$name] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setUrid(?string $value): static {
$this->data['urid'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setCreated(DateTimeImmutable $value): static {
$this->data['created'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setModified(DateTimeImmutable $value): static {
$this->data['modified'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setDate(DateTimeImmutable $value): static {
$this->data['date'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setReceived(?DateTimeImmutable $value): static {
$this->data['received'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setSize(?int $value): static {
$this->data['size'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setSender(?AddressInterface $value): static {
$this->data['sender'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setFrom(AddressInterface $value): static {
$this->data['from'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setReplyTo(AddressInterface ...$value): static {
$this->data['replyTo'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setTo(AddressInterface ...$value): static {
$this->data['to'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setCc(AddressInterface ...$value): static {
$this->data['cc'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setBcc(AddressInterface ...$value): static {
$this->data['bcc'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setInReplyTo(?string $value): static {
$this->data['inReplyTo'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setReferences(string ...$value): static {
$this->data['references'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setSubject(string $value): static {
$this->data['subject'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setSnippet(?string $value): static {
$this->data['snippet'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setBodyText(?string $value): static {
$this->data['bodyText'] = $value;
return $this;
}
/**
* Sets the plain text body charset
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setBodyTextCharset(string $value): static {
$this->data['bodyTextCharset'] = $value;
return $this;
}
/**
* Sets the plain text body size
*
* @since 2025.05.01
*
* @param int $value
*
* @return static
*/
public function setBodyTextSize(int $value): static {
$this->data['bodyTextSize'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setBodyHtml(?string $value): static {
$this->data['bodyHtml'] = $value;
return $this;
}
/**
* Sets the HTML body charset
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setBodyHtmlCharset(string $value): static {
$this->data['bodyHtmlCharset'] = $value;
return $this;
}
/**
* Sets the HTML body size
*
* @since 2025.05.01
*
* @param int $value
*
* @return static
*/
public function setBodyHtmlSize(int $value): static {
$this->data['bodyHtmlSize'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setAttachments(AttachmentInterface ...$value): static {
$this->data['attachments'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function addAttachment(AttachmentInterface $value): static {
$this->data['attachments'][] = $value;
return $this;
}
/**
* Sets message flags
*
* @since 2025.05.01
*
* @param array $value
*
* @return static
*/
public function setFlags(array $value): static {
if (!isset($this->data['flags'])) {
$this->data['flags'] = [
'read' => false,
'starred' => false,
'important' => false,
'answered' => false,
'forwarded' => false,
'draft' => false,
'deleted' => false,
'flagged' => false,
];
}
$this->data['flags'] = array_merge($this->data['flags'], $value);
return $this;
}
/**
* @inheritDoc
*/
public function setFlag(string $name, bool $value): static {
if (!isset($this->data['flags'])) {
$this->data['flags'] = [
'read' => false,
'starred' => false,
'important' => false,
'answered' => false,
'forwarded' => false,
'draft' => false,
'deleted' => false,
'flagged' => false,
];
}
if (array_key_exists($name, $this->data['flags'])) {
$this->data['flags'][$name] = $value;
}
return $this;
}
/**
* Sets message labels
*
* @since 2025.05.01
*
* @param string ...$value
*
* @return static
*/
public function setLabels(string ...$value): static {
$this->data['labels'] = $value;
return $this;
}
/**
* Adds a message label
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function addLabel(string $value): static {
$this->data['labels'][] = $value;
return $this;
}
/**
* Sets message tags
*
* @since 2025.05.01
*
* @param string ...$value
*
* @return static
*/
public function setTags(string ...$value): static {
$this->data['tags'] = $value;
return $this;
}
/**
* Adds a message tag
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function addTag(string $value): static {
$this->data['tags'][] = $value;
return $this;
}
/**
* Sets message priority
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setPriority(string $value): static {
$this->data['priority'] = $value;
return $this;
}
/**
* Sets message sensitivity
*
* @since 2025.05.01
*
* @param string $value
*
* @return static
*/
public function setSensitivity(string $value): static {
$this->data['sensitivity'] = $value;
return $this;
}
/**
* Sets encryption information
*
* @since 2025.05.01
*
* @param array $value
*
* @return static
*/
public function setEncryption(array $value): static {
if (!isset($this->data['encryption'])) {
$this->data['encryption'] = [
'method' => null,
'signed' => false,
'encrypted' => false,
];
}
$this->data['encryption'] = array_merge($this->data['encryption'], $value);
return $this;
}
/**
* Sets delivery receipt flag
*
* @since 2025.05.01
*
* @param bool $value
*
* @return static
*/
public function setDeliveryReceipt(bool $value): static {
$this->data['deliveryReceipt'] = $value;
return $this;
}
/**
* Sets read receipt flag
*
* @since 2025.05.01
*
* @param bool $value
*
* @return static
*/
public function setReadReceipt(bool $value): static {
$this->data['readReceipt'] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function jsonDeserialize(array|string $data): static {
if (is_string($data)) {
$data = json_decode($data, true);
}
// Merge deserialized data into internal storage
foreach ($data as $key => $value) {
if (!in_array($key, ['collection', 'identifier', 'signature', 'created', 'modified'])) {
$this->data[$key] = $value;
}
}
return $this;
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Mail\Object;
use DateTimeImmutable;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
/**
* Message Properties Mutable Interface
*
* @since 2025.05.01
*/
interface MessagePropertiesMutableInterface extends MessagePropertiesBaseInterface, NodePropertiesMutableInterface {
public const JSON_TYPE = MessagePropertiesBaseInterface::JSON_TYPE;
/**
* Sets custom headers
*
* @since 2025.05.01
*
* @param array<string,string|array<int,string>> $value Header name => value
*
* @return self
*/
public function setHeaders(array $value): static;
/**
* Sets a specific header value
*
* @since 2025.05.01
*
* @param string $name Header name
* @param string|array<int,string> $value Header value(s)
*
* @return self
*/
public function setHeader(string $name, string|array $value): static;
/**
* Sets the universal resource identifier (URN)
*
* @since 2025.05.01
*
* @param string|null $value
*
* @return self
*/
public function setUrid(?string $value): static;
/**
* Sets the message date
*
* @since 2025.05.01
*
* @param DateTimeImmutable $value
*
* @return self
*/
public function setDate(DateTimeImmutable $value): static;
/**
* Sets the received date
*
* @since 2025.05.01
*
* @param DateTimeImmutable|null $value
*
* @return self
*/
public function setReceived(?DateTimeImmutable $value): static;
/**
* Sets the message size in bytes
*
* @since 2025.05.01
*
* @param int|null $value
*
* @return self
*/
public function setSize(?int $value): static;
/**
* Sets the sender address (actual sender, may differ from From)
*
* @since 2025.05.01
*
* @param AddressInterface|null $value
*
* @return self
*/
public function setSender(?AddressInterface $value): static;
/**
* Sets the sender address
*
* @since 2025.05.01
*
* @param AddressInterface $value
*
* @return self
*/
public function setFrom(AddressInterface $value): static;
/**
* Sets the reply-to addresses
*
* @since 2025.05.01
*
* @param AddressInterface ...$value
*
* @return self
*/
public function setReplyTo(AddressInterface ...$value): static;
/**
* Sets the primary recipients (To)
*
* @since 2025.05.01
*
* @param AddressInterface ...$value
*
* @return self
*/
public function setTo(AddressInterface ...$value): static;
/**
* Sets the carbon copy recipients (CC)
*
* @since 2025.05.01
*
* @param AddressInterface ...$value
*
* @return self
*/
public function setCc(AddressInterface ...$value): static;
/**
* Sets the blind carbon copy recipients (BCC)
*
* @since 2025.05.01
*
* @param AddressInterface ...$value
*
* @return self
*/
public function setBcc(AddressInterface ...$value): static;
/**
* Sets the message ID this is replying to
*
* @since 2025.05.01
*
* @param string|null $value
*
* @return self
*/
public function setInReplyTo(?string $value): static;
/**
* Sets the references (message IDs in thread)
*
* @since 2025.05.01
*
* @param string ...$value
*
* @return self
*/
public function setReferences(string ...$value): static;
/**
* Sets the message subject
*
* @since 2025.05.01
*
* @param string $value
*
* @return self
*/
public function setSubject(string $value): static;
/**
* Sets the message snippet/preview
*
* @since 2025.05.01
*
* @param string|null $value
*
* @return self
*/
public function setSnippet(?string $value): static;
/**
* Sets the plain text body content
*
* @since 2025.05.01
*
* @param string|null $value
*
* @return self
*/
public function setBodyText(?string $value): static;
/**
* Sets the HTML body content
*
* @since 2025.05.01
*
* @param string|null $value
*
* @return self
*/
public function setBodyHtml(?string $value): static;
/**
* Sets the attachments
*
* @since 2025.05.01
*
* @param AttachmentInterface ...$value
*
* @return self
*/
public function setAttachments(AttachmentInterface ...$value): static;
/**
* Adds an attachment
*
* @since 2025.05.01
*
* @param AttachmentInterface $value
*
* @return self
*/
public function addAttachment(AttachmentInterface $value): static;
/**
* Sets message tags
*
* @since 2025.05.01
*
* @param array{read: bool, starred: bool, important: bool, answered: bool, forwarded: bool, draft: bool, deleted: bool, flagged: bool} $value
*
* @return self
*/
public function setFlag(string $label, bool $value): static;
}

View File

@@ -0,0 +1,41 @@
<?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\Mail\Service\ServiceBaseInterface;
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
/**
* Mail Provider Base Interface
*
* Core interface for mail providers with context-aware service discovery.
*
* @since 2025.05.01
*/
interface ProviderBaseInterface extends ResourceProviderBaseInterface{
public const JSON_TYPE = 'mail.provider';
/**
* 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 $userId User identifier
* @param string $address Email address to find service for
*
* @return ServiceBaseInterface|null Service handling the address, or null
*/
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBaseInterface;
}

View File

@@ -0,0 +1,52 @@
<?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\Resource\Provider\ResourceServiceLocationInterface;
/**
* Mail Provider Autodiscovery Interface
*
* Optional interface for mail providers that support automatic service discovery
* from email addresses or domains. Providers implementing this interface can
* discover mail service configurations using various methods specific to their
* protocol or provider type.
*
* Examples:
* - IMAP/SMTP providers: Mozilla Autoconfig, DNS SRV, well-known URIs
* - JMAP providers: Well-known JMAP endpoint discovery
* - Provider-specific: Gmail, Outlook, etc. with known configurations
*
* @since 2025.05.01
*/
interface ProviderServiceDiscoverInterface extends ProviderBaseInterface {
/**
* Attempts to discover service configuration using provider-specific methods.
*
* @since 2025.05.01
*
* @param string $tenantId Tenant identifier
* @param string $userId User identifier
* @param string $identity Identity to discover configuration for (e.g., email address)
* @param string|null $location Optional hostname to test directly (bypasses DNS lookup)
* @param string|null $secret Optional password/token to validate discovered service
*
* @return ResourceServiceLocationInterface|null Discovered location or null if not found
*/
public function serviceDiscover(
string $tenantId,
string $userId,
string $identity,
?string $location = null,
?string $secret = null
): ResourceServiceLocationInterface|null;
}

Some files were not shown because too many files have changed in this diff Show More