Fix critical and high severity issues from code review
Security fixes: - Fix XSS in JSON-LD output via JSON_HEX_TAG (Seo module) - Fix SQL injection via LIKE wildcards (Config module) - Fix regex injection in env updates (Console module) - Fix weak token hashing with HMAC-SHA256 (CDN module) - Mask database credentials in install output (Console module) New features: - Add MakeModCommand, MakePlugCommand, MakeWebsiteCommand scaffolds - Add event prioritization via array syntax in $listens - Add EventAuditLog for tracking handler execution and failures - Add ServiceVersion with semver and deprecation support - Add HealthCheckable interface with HealthCheckResult - Add ServiceStatus enum for service health states - Add DynamicMenuProvider for uncached menu items - Add LangServiceProvider with auto-discovery and fallback chains Improvements: - Add retry logic with exponential backoff (CDN uploads) - Add file size validation before uploads (100MB default) - Add key rotation mechanism for LthnHash - Add Unicode NFC normalization to Sanitiser - Add configurable filter rules per field (Input) - Add menu caching with configurable TTL (Admin) - Add Redis fallback alerting via events (Storage) - Add Predis support alongside phpredis (Storage) - Add memory safety checks for image processing (Media) - Add SchemaValidator for schema.org validation (SEO) - Add translation key validation in dev environments Bug fixes: - Fix nested array filtering returning null (Sanitiser) - Fix race condition in EmailShieldStat increment - Fix stack overflow on deep JSON nesting (ConfigResolver) - Fix missing table existence check (BlocklistService) - Fix missing class_exists guards (Search, Media) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b26c430cd6
commit
606176585c
67 changed files with 7684 additions and 511 deletions
|
|
@ -24,6 +24,7 @@
|
|||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"orchestra/testbench": "*",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -36,7 +37,11 @@
|
|||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
"Tests\\": "tests/",
|
||||
"Core\\Tests\\": "packages/core-php/tests/",
|
||||
"Mod\\": "packages/core-php/tests/Fixtures/Mod/",
|
||||
"Plug\\": "packages/core-php/tests/Fixtures/Plug/",
|
||||
"Website\\": "packages/core-php/tests/Fixtures/Website/"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
|
|
|
|||
|
|
@ -23,11 +23,19 @@
|
|||
"Core\\Plug\\": "src/Plug/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Core\\Tests\\": "tests/",
|
||||
"Mod\\": "tests/Fixtures/Mod/",
|
||||
"Plug\\": "tests/Fixtures/Plug/",
|
||||
"Website\\": "tests/Fixtures/Website/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Core\\LifecycleEventProvider",
|
||||
"Core\\Website\\Boot"
|
||||
"Core\\Lang\\LangServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -94,4 +94,97 @@ return [
|
|||
'default_style' => 'solid',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Search Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure the unified search feature including searchable API endpoints.
|
||||
| Add your application's API endpoints here to include them in search results.
|
||||
|
|
||||
*/
|
||||
|
||||
'search' => [
|
||||
'api_endpoints' => [
|
||||
// Example endpoints - override in your application's config
|
||||
// ['method' => 'GET', 'path' => '/api/v1/users', 'description' => 'List users'],
|
||||
// ['method' => 'POST', 'path' => '/api/v1/users', 'description' => 'Create user'],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Admin Menu Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure the admin menu caching behaviour. Menu items are cached per
|
||||
| user/workspace combination to improve performance on repeated requests.
|
||||
|
|
||||
*/
|
||||
|
||||
'admin_menu' => [
|
||||
// Whether to enable caching for static menu items.
|
||||
// Set to false during development for instant menu updates.
|
||||
'cache_enabled' => env('CORE_ADMIN_MENU_CACHE', true),
|
||||
|
||||
// Cache TTL in seconds (default: 5 minutes).
|
||||
// Lower values mean more frequent cache misses but fresher menus.
|
||||
'cache_ttl' => env('CORE_ADMIN_MENU_CACHE_TTL', 300),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Storage Resilience Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure how the application handles Redis failures. When Redis becomes
|
||||
| unavailable, the system can either silently fall back to database storage
|
||||
| or throw an exception.
|
||||
|
|
||||
*/
|
||||
|
||||
'storage' => [
|
||||
// Whether to silently fall back to database when Redis fails.
|
||||
// Set to false to throw exceptions on Redis failure.
|
||||
'silent_fallback' => env('CORE_STORAGE_SILENT_FALLBACK', true),
|
||||
|
||||
// Log level for fallback events: 'debug', 'info', 'notice', 'warning', 'error', 'critical'
|
||||
'fallback_log_level' => env('CORE_STORAGE_FALLBACK_LOG_LEVEL', 'warning'),
|
||||
|
||||
// Whether to dispatch RedisFallbackActivated events for monitoring/alerting
|
||||
'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Language & Translation Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure translation fallback chains and missing key validation.
|
||||
| The fallback chain allows regional locales to fall back to their base
|
||||
| locale before using the application's fallback locale.
|
||||
|
|
||||
| Example chain: en_GB -> en -> fallback_locale (from config/app.php)
|
||||
|
|
||||
*/
|
||||
|
||||
'lang' => [
|
||||
// Enable locale chain fallback (e.g., en_GB -> en -> fallback)
|
||||
// When true, regional locales like 'en_GB' will first try 'en' before
|
||||
// falling back to the application's fallback_locale.
|
||||
'fallback_chain' => env('CORE_LANG_FALLBACK_CHAIN', true),
|
||||
|
||||
// Warn about missing translation keys in development environments.
|
||||
// Set to true to always enable, false to always disable, or leave
|
||||
// null to auto-enable in local/development/testing environments.
|
||||
'validate_keys' => env('CORE_LANG_VALIDATE_KEYS'),
|
||||
|
||||
// Log missing translation keys when validation is enabled.
|
||||
'log_missing_keys' => env('CORE_LANG_LOG_MISSING_KEYS', true),
|
||||
|
||||
// Log level for missing translation key warnings.
|
||||
// Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical'
|
||||
'missing_key_log_level' => env('CORE_LANG_MISSING_KEY_LOG_LEVEL', 'debug'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use Illuminate\Support\Facades\DB;
|
|||
* Manages IP blocklist with Redis caching.
|
||||
*
|
||||
* Blocklist is populated from:
|
||||
* - Honeypot critical hits (/admin probing)
|
||||
* - Manual entries
|
||||
* - Honeypot critical hits (/admin probing) - requires human review
|
||||
* - Manual entries - immediately active
|
||||
*
|
||||
* Uses a Bloom filter-style approach: cache the blocklist as a set
|
||||
* for O(1) lookups, rebuild periodically from database.
|
||||
|
|
@ -22,6 +22,10 @@ class BlocklistService
|
|||
protected const CACHE_KEY = 'bouncer:blocklist';
|
||||
protected const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
/**
|
||||
* Check if IP is blocked.
|
||||
*/
|
||||
|
|
@ -33,14 +37,15 @@ class BlocklistService
|
|||
}
|
||||
|
||||
/**
|
||||
* Add IP to blocklist.
|
||||
* Add IP to blocklist (immediately approved for manual blocks).
|
||||
*/
|
||||
public function block(string $ip, string $reason = 'manual'): void
|
||||
public function block(string $ip, string $reason = 'manual', string $status = self::STATUS_APPROVED): void
|
||||
{
|
||||
DB::table('blocked_ips')->updateOrInsert(
|
||||
['ip_address' => $ip],
|
||||
[
|
||||
'reason' => $reason,
|
||||
'status' => $status,
|
||||
'blocked_at' => now(),
|
||||
'expires_at' => now()->addDays(30),
|
||||
]
|
||||
|
|
@ -59,12 +64,17 @@ class BlocklistService
|
|||
}
|
||||
|
||||
/**
|
||||
* Get full blocklist (cached).
|
||||
* Get full blocklist (cached). Only returns approved entries.
|
||||
*/
|
||||
public function getBlocklist(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
|
||||
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array {
|
||||
if (! $this->tableExists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('blocked_ips')
|
||||
->where('status', self::STATUS_APPROVED)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
|
|
@ -74,14 +84,28 @@ class BlocklistService
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the blocked_ips table exists.
|
||||
*/
|
||||
protected function tableExists(): bool
|
||||
{
|
||||
return Cache::remember('bouncer:blocked_ips_table_exists', 3600, function (): bool {
|
||||
return DB::getSchemaBuilder()->hasTable('blocked_ips');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync blocklist from honeypot critical hits.
|
||||
*
|
||||
* Creates entries in 'pending' status for human review.
|
||||
* Call this from a scheduled job or after honeypot hits.
|
||||
*/
|
||||
public function syncFromHoneypot(): int
|
||||
{
|
||||
// Block IPs with critical severity hits in last 24h
|
||||
if (! DB::getSchemaBuilder()->hasTable('honeypot_hits')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$criticalIps = DB::table('honeypot_hits')
|
||||
->where('severity', 'critical')
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
|
|
@ -90,22 +114,71 @@ class BlocklistService
|
|||
|
||||
$count = 0;
|
||||
foreach ($criticalIps as $ip) {
|
||||
DB::table('blocked_ips')->updateOrInsert(
|
||||
['ip_address' => $ip],
|
||||
[
|
||||
$exists = DB::table('blocked_ips')
|
||||
->where('ip_address', $ip)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
DB::table('blocked_ips')->insert([
|
||||
'ip_address' => $ip,
|
||||
'reason' => 'honeypot_critical',
|
||||
'status' => self::STATUS_PENDING,
|
||||
'blocked_at' => now(),
|
||||
'expires_at' => now()->addDays(7),
|
||||
]
|
||||
);
|
||||
$count++;
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->clearCache();
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending entries awaiting human review.
|
||||
*/
|
||||
public function getPending(): array
|
||||
{
|
||||
if (! $this->tableExists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('blocked_ips')
|
||||
->where('status', self::STATUS_PENDING)
|
||||
->orderBy('blocked_at', 'desc')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending block entry.
|
||||
*/
|
||||
public function approve(string $ip): bool
|
||||
{
|
||||
$updated = DB::table('blocked_ips')
|
||||
->where('ip_address', $ip)
|
||||
->where('status', self::STATUS_PENDING)
|
||||
->update(['status' => self::STATUS_APPROVED]);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->clearCache();
|
||||
}
|
||||
|
||||
return $updated > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a pending block entry.
|
||||
*/
|
||||
public function reject(string $ip): bool
|
||||
{
|
||||
$updated = DB::table('blocked_ips')
|
||||
->where('ip_address', $ip)
|
||||
->where('status', self::STATUS_PENDING)
|
||||
->update(['status' => self::STATUS_REJECTED]);
|
||||
|
||||
return $updated > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache.
|
||||
*/
|
||||
|
|
@ -119,19 +192,38 @@ class BlocklistService
|
|||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
if (! $this->tableExists()) {
|
||||
return [
|
||||
'total_blocked' => 0,
|
||||
'active_blocked' => 0,
|
||||
'pending_review' => 0,
|
||||
'by_reason' => [],
|
||||
'by_status' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_blocked' => DB::table('blocked_ips')->count(),
|
||||
'active_blocked' => DB::table('blocked_ips')
|
||||
->where('status', self::STATUS_APPROVED)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->count(),
|
||||
'pending_review' => DB::table('blocked_ips')
|
||||
->where('status', self::STATUS_PENDING)
|
||||
->count(),
|
||||
'by_reason' => DB::table('blocked_ips')
|
||||
->selectRaw('reason, COUNT(*) as count')
|
||||
->groupBy('reason')
|
||||
->pluck('count', 'reason')
|
||||
->toArray(),
|
||||
'by_status' => DB::table('blocked_ips')
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('blocked_ips', function (Blueprint $table) {
|
||||
$table->string('status', 20)->default('approved')->after('reason');
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('blocked_ips', function (Blueprint $table) {
|
||||
$table->dropIndex(['status']);
|
||||
$table->dropColumn('status');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -32,6 +32,26 @@ class BunnyCdnService
|
|||
$this->pullZoneId = $this->config->get('cdn.bunny.pull_zone_id') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an error message to remove sensitive data like API keys.
|
||||
*/
|
||||
protected function sanitizeErrorMessage(string $message): string
|
||||
{
|
||||
$sensitiveKeys = array_filter([
|
||||
$this->apiKey,
|
||||
$this->config->get('cdn.bunny.storage.public.api_key'),
|
||||
$this->config->get('cdn.bunny.storage.private.api_key'),
|
||||
]);
|
||||
|
||||
foreach ($sensitiveKeys as $key) {
|
||||
if ($key !== '' && str_contains($message, $key)) {
|
||||
$message = str_replace($key, '[REDACTED]', $message);
|
||||
}
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the service is configured.
|
||||
*/
|
||||
|
|
@ -84,7 +104,7 @@ class BunnyCdnService
|
|||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: Purge exception', ['error' => $e->getMessage()]);
|
||||
Log::error('BunnyCDN: Purge exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -106,7 +126,7 @@ class BunnyCdnService
|
|||
|
||||
return $response->successful();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: PurgeAll exception', ['error' => $e->getMessage()]);
|
||||
Log::error('BunnyCDN: PurgeAll exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -132,7 +152,7 @@ class BunnyCdnService
|
|||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: PurgeByTag exception', [
|
||||
'tag' => $tag,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $this->sanitizeErrorMessage($e->getMessage()),
|
||||
]);
|
||||
|
||||
return false;
|
||||
|
|
@ -182,7 +202,7 @@ class BunnyCdnService
|
|||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: GetStats exception', ['error' => $e->getMessage()]);
|
||||
Log::error('BunnyCDN: GetStats exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -237,7 +257,7 @@ class BunnyCdnService
|
|||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: ListStorageFiles exception', ['error' => $e->getMessage()]);
|
||||
Log::error('BunnyCDN: ListStorageFiles exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -267,7 +287,7 @@ class BunnyCdnService
|
|||
|
||||
return $response->successful();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: UploadFile exception', ['error' => $e->getMessage()]);
|
||||
Log::error('BunnyCDN: UploadFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -296,7 +316,7 @@ class BunnyCdnService
|
|||
|
||||
return $response->successful();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyCDN: DeleteFile exception', ['error' => $e->getMessage()]);
|
||||
Log::error('BunnyCDN: DeleteFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ class BunnyStorageService
|
|||
|
||||
protected ?Client $privateClient = null;
|
||||
|
||||
/**
|
||||
* Default maximum file size in bytes (100MB).
|
||||
*/
|
||||
protected const DEFAULT_MAX_FILE_SIZE = 104857600;
|
||||
|
||||
/**
|
||||
* Maximum retry attempts for failed uploads.
|
||||
*/
|
||||
protected const MAX_RETRY_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Base delay in milliseconds for exponential backoff.
|
||||
*/
|
||||
protected const RETRY_BASE_DELAY_MS = 100;
|
||||
|
||||
public function __construct(
|
||||
protected ConfigService $config,
|
||||
) {}
|
||||
|
|
@ -114,20 +129,74 @@ class BunnyStorageService
|
|||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->upload($localPath, $remotePath);
|
||||
if (! file_exists($localPath)) {
|
||||
Log::error('BunnyStorage: Local file not found', ['local' => $localPath]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyStorage: Upload failed', [
|
||||
return false;
|
||||
}
|
||||
|
||||
$fileSize = filesize($localPath);
|
||||
$maxSize = $this->getMaxFileSize();
|
||||
|
||||
if ($fileSize === false || $fileSize > $maxSize) {
|
||||
Log::error('BunnyStorage: File size exceeds limit', [
|
||||
'local' => $localPath,
|
||||
'remote' => $remotePath,
|
||||
'zone' => $zone,
|
||||
'error' => $e->getMessage(),
|
||||
'size' => $fileSize,
|
||||
'max_size' => $maxSize,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $zone) {
|
||||
$client->upload($localPath, $remotePath);
|
||||
|
||||
return true;
|
||||
}, [
|
||||
'local' => $localPath,
|
||||
'remote' => $remotePath,
|
||||
'zone' => $zone,
|
||||
], 'Upload');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum allowed file size in bytes.
|
||||
*/
|
||||
protected function getMaxFileSize(): int
|
||||
{
|
||||
return (int) $this->config->get('cdn.bunny.max_file_size', self::DEFAULT_MAX_FILE_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with exponential backoff retry.
|
||||
*/
|
||||
protected function executeWithRetry(callable $operation, array $context, string $operationName): bool
|
||||
{
|
||||
$lastException = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= self::MAX_RETRY_ATTEMPTS; $attempt++) {
|
||||
try {
|
||||
return $operation();
|
||||
} catch (\Exception $e) {
|
||||
$lastException = $e;
|
||||
|
||||
if ($attempt < self::MAX_RETRY_ATTEMPTS) {
|
||||
$delayMs = self::RETRY_BASE_DELAY_MS * (2 ** ($attempt - 1));
|
||||
usleep($delayMs * 1000);
|
||||
|
||||
Log::warning("BunnyStorage: {$operationName} attempt {$attempt} failed, retrying", array_merge($context, [
|
||||
'attempt' => $attempt,
|
||||
'next_delay_ms' => $delayMs * 2,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log::error("BunnyStorage: {$operationName} failed after " . self::MAX_RETRY_ATTEMPTS . ' attempts', array_merge($context, [
|
||||
'error' => $lastException?->getMessage() ?? 'Unknown error',
|
||||
]));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -141,19 +210,27 @@ class BunnyStorageService
|
|||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->putContents($remotePath, $contents);
|
||||
$contentSize = strlen($contents);
|
||||
$maxSize = $this->getMaxFileSize();
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('BunnyStorage: putContents failed', [
|
||||
if ($contentSize > $maxSize) {
|
||||
Log::error('BunnyStorage: Content size exceeds limit', [
|
||||
'remote' => $remotePath,
|
||||
'zone' => $zone,
|
||||
'error' => $e->getMessage(),
|
||||
'size' => $contentSize,
|
||||
'max_size' => $maxSize,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->executeWithRetry(function () use ($client, $remotePath, $contents) {
|
||||
$client->putContents($remotePath, $contents);
|
||||
|
||||
return true;
|
||||
}, [
|
||||
'remote' => $remotePath,
|
||||
'zone' => $zone,
|
||||
], 'putContents');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -153,10 +153,10 @@ class StorageUrlResolver
|
|||
$expires = time() + $expiry;
|
||||
$path = '/'.ltrim($path, '/');
|
||||
|
||||
// BunnyCDN token authentication format
|
||||
// hash = base64(sha256(token + path + expires))
|
||||
// BunnyCDN token authentication format (using HMAC for security)
|
||||
// See: https://docs.bunny.net/docs/cdn-token-authentication
|
||||
$hashableBase = $token.$path.$expires;
|
||||
$hash = base64_encode(hash('sha256', $hashableBase, true));
|
||||
$hash = base64_encode(hash_hmac('sha256', $hashableBase, $token, true));
|
||||
|
||||
// URL-safe base64
|
||||
$hash = str_replace(['+', '/'], ['-', '_'], $hash);
|
||||
|
|
|
|||
|
|
@ -210,6 +210,16 @@ class ConfigResolver
|
|||
return ConfigResult::notFound($keyCode, $key->getTypedDefault(), $key->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum recursion depth for JSON sub-key resolution.
|
||||
*/
|
||||
protected const MAX_SUBKEY_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* Current recursion depth for sub-key resolution.
|
||||
*/
|
||||
protected int $subKeyDepth = 0;
|
||||
|
||||
/**
|
||||
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
|
||||
*/
|
||||
|
|
@ -218,33 +228,44 @@ class ConfigResolver
|
|||
?Workspace $workspace,
|
||||
string|Channel|null $channel,
|
||||
): ConfigResult {
|
||||
$parts = explode('.', $keyCode);
|
||||
|
||||
// Try progressively shorter parent keys
|
||||
for ($i = count($parts) - 1; $i > 0; $i--) {
|
||||
$parentKey = implode('.', array_slice($parts, 0, $i));
|
||||
$subPath = implode('.', array_slice($parts, $i));
|
||||
|
||||
$parentResult = $this->resolve($parentKey, $workspace, $channel);
|
||||
|
||||
if ($parentResult->found && is_array($parentResult->value)) {
|
||||
$subValue = data_get($parentResult->value, $subPath);
|
||||
|
||||
if ($subValue !== null) {
|
||||
return ConfigResult::found(
|
||||
key: $keyCode,
|
||||
value: $subValue,
|
||||
type: $parentResult->type, // Inherit parent type
|
||||
locked: $parentResult->locked,
|
||||
resolvedFrom: $parentResult->resolvedFrom,
|
||||
profileId: $parentResult->profileId,
|
||||
channelId: $parentResult->channelId,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Guard against stack overflow from deep nesting
|
||||
if ($this->subKeyDepth >= self::MAX_SUBKEY_DEPTH) {
|
||||
return ConfigResult::unconfigured($keyCode);
|
||||
}
|
||||
|
||||
return ConfigResult::unconfigured($keyCode);
|
||||
$this->subKeyDepth++;
|
||||
|
||||
try {
|
||||
$parts = explode('.', $keyCode);
|
||||
|
||||
// Try progressively shorter parent keys
|
||||
for ($i = count($parts) - 1; $i > 0; $i--) {
|
||||
$parentKey = implode('.', array_slice($parts, 0, $i));
|
||||
$subPath = implode('.', array_slice($parts, $i));
|
||||
|
||||
$parentResult = $this->resolve($parentKey, $workspace, $channel);
|
||||
|
||||
if ($parentResult->found && is_array($parentResult->value)) {
|
||||
$subValue = data_get($parentResult->value, $subPath);
|
||||
|
||||
if ($subValue !== null) {
|
||||
return ConfigResult::found(
|
||||
key: $keyCode,
|
||||
value: $subValue,
|
||||
type: $parentResult->type, // Inherit parent type
|
||||
locked: $parentResult->locked,
|
||||
resolvedFrom: $parentResult->resolvedFrom,
|
||||
profileId: $parentResult->profileId,
|
||||
channelId: $parentResult->channelId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ConfigResult::unconfigured($keyCode);
|
||||
} finally {
|
||||
$this->subKeyDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -260,9 +260,12 @@ class ConfigService
|
|||
}
|
||||
|
||||
// Check as prefix - single EXISTS query
|
||||
// Escape LIKE wildcards to prevent unintended pattern matching
|
||||
$escapedPrefix = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $keyOrPrefix);
|
||||
|
||||
return ConfigResolved::where('workspace_id', $workspaceId)
|
||||
->where('channel_id', $channelId)
|
||||
->where('key_code', 'LIKE', "{$keyOrPrefix}.%")
|
||||
->where('key_code', 'LIKE', "{$escapedPrefix}.%")
|
||||
->whereNotNull('value')
|
||||
->exists();
|
||||
}
|
||||
|
|
@ -274,6 +277,8 @@ class ConfigService
|
|||
* Fires ConfigChanged event for invalidation hooks.
|
||||
*
|
||||
* @param string|Channel|null $channel Channel code or object
|
||||
*
|
||||
* @throws \InvalidArgumentException If key is unknown or value type is invalid
|
||||
*/
|
||||
public function set(
|
||||
string $keyCode,
|
||||
|
|
@ -289,6 +294,9 @@ class ConfigService
|
|||
throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
|
||||
}
|
||||
|
||||
// Validate value type against schema
|
||||
$this->validateValueType($value, $key->type, $keyCode);
|
||||
|
||||
$channelId = $this->resolveChannelId($channel, null);
|
||||
|
||||
// Capture previous value for event
|
||||
|
|
@ -410,9 +418,12 @@ class ConfigService
|
|||
$workspaceId = $workspace?->id;
|
||||
$channelId = $this->resolveChannelId($channel, $workspace);
|
||||
|
||||
// Escape LIKE wildcards to prevent unintended pattern matching
|
||||
$escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category);
|
||||
|
||||
$resolved = ConfigResolved::where('workspace_id', $workspaceId)
|
||||
->where('channel_id', $channelId)
|
||||
->where('key_code', 'LIKE', "{$category}.%")
|
||||
->where('key_code', 'LIKE', "{$escapedCategory}.%")
|
||||
->get();
|
||||
|
||||
$values = [];
|
||||
|
|
@ -641,4 +652,32 @@ class ConfigService
|
|||
{
|
||||
return ConfigResolver::has($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value matches the expected config type.
|
||||
*
|
||||
* @throws \InvalidArgumentException If value type is invalid
|
||||
*/
|
||||
protected function validateValueType(mixed $value, ConfigType $type, string $keyCode): void
|
||||
{
|
||||
// Null is allowed for any type (represents unset)
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valid = match ($type) {
|
||||
ConfigType::STRING => is_string($value) || is_numeric($value),
|
||||
ConfigType::BOOL => is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false'], true),
|
||||
ConfigType::INT => is_int($value) || (is_string($value) && ctype_digit(ltrim($value, '-'))),
|
||||
ConfigType::FLOAT => is_float($value) || is_int($value) || is_numeric($value),
|
||||
ConfigType::ARRAY, ConfigType::JSON => is_array($value),
|
||||
};
|
||||
|
||||
if (! $valid) {
|
||||
$actualType = get_debug_type($value);
|
||||
throw new \InvalidArgumentException(
|
||||
"Invalid value type for config key '{$keyCode}': expected {$type->value}, got {$actualType}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ class InstallCommand extends Command
|
|||
*/
|
||||
protected $description = 'Install and configure Core PHP Framework';
|
||||
|
||||
/**
|
||||
* Track completed installation steps for rollback.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $completedSteps = [];
|
||||
|
||||
/**
|
||||
* Original .env content for rollback.
|
||||
*/
|
||||
protected ?string $originalEnvContent = null;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
|
|
@ -37,35 +49,105 @@ class InstallCommand extends Command
|
|||
$this->info(' '.str_repeat('=', strlen(__('core::core.installer.title'))));
|
||||
$this->info('');
|
||||
|
||||
// Step 1: Environment file
|
||||
if (! $this->setupEnvironment()) {
|
||||
// Preserve original state for rollback
|
||||
$this->preserveOriginalState();
|
||||
|
||||
try {
|
||||
// Step 1: Environment file
|
||||
if (! $this->setupEnvironment()) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Step 2: Application settings
|
||||
$this->configureApplication();
|
||||
|
||||
// Step 3: Database
|
||||
if ($this->option('no-interaction') || $this->confirm(__('core::core.installer.prompts.run_migrations'), true)) {
|
||||
$this->runMigrations();
|
||||
}
|
||||
|
||||
// Step 4: Generate app key if needed
|
||||
$this->generateAppKey();
|
||||
|
||||
// Step 5: Create storage link
|
||||
$this->createStorageLink();
|
||||
|
||||
// Done!
|
||||
$this->info('');
|
||||
$this->info(' '.__('core::core.installer.complete'));
|
||||
$this->info('');
|
||||
$this->info(' '.__('core::core.installer.next_steps').':');
|
||||
$this->info(' 1. Run: valet link core');
|
||||
$this->info(' 2. Visit: http://core.test');
|
||||
$this->info('');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('');
|
||||
$this->error(' Installation failed: '.$e->getMessage());
|
||||
$this->error('');
|
||||
|
||||
$this->rollback();
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Application settings
|
||||
$this->configureApplication();
|
||||
/**
|
||||
* Preserve original state for potential rollback.
|
||||
*/
|
||||
protected function preserveOriginalState(): void
|
||||
{
|
||||
$envPath = base_path('.env');
|
||||
if (File::exists($envPath)) {
|
||||
$this->originalEnvContent = File::get($envPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Database
|
||||
if ($this->option('no-interaction') || $this->confirm(__('core::core.installer.prompts.run_migrations'), true)) {
|
||||
$this->runMigrations();
|
||||
/**
|
||||
* Rollback changes on installation failure.
|
||||
*/
|
||||
protected function rollback(): void
|
||||
{
|
||||
$this->warn(' Rolling back changes...');
|
||||
|
||||
// Restore original .env if we modified it
|
||||
if (isset($this->completedSteps['env_created']) && $this->completedSteps['env_created']) {
|
||||
$envPath = base_path('.env');
|
||||
if ($this->originalEnvContent !== null) {
|
||||
File::put($envPath, $this->originalEnvContent);
|
||||
$this->info(' [✓] Restored original .env file');
|
||||
} else {
|
||||
File::delete($envPath);
|
||||
$this->info(' [✓] Removed created .env file');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Generate app key if needed
|
||||
$this->generateAppKey();
|
||||
// Restore original .env content if we only modified values
|
||||
if (isset($this->completedSteps['env_modified']) && $this->completedSteps['env_modified'] && $this->originalEnvContent !== null) {
|
||||
File::put(base_path('.env'), $this->originalEnvContent);
|
||||
$this->info(' [✓] Restored original .env configuration');
|
||||
}
|
||||
|
||||
// Step 5: Create storage link
|
||||
$this->createStorageLink();
|
||||
// Remove storage link if we created it
|
||||
if (isset($this->completedSteps['storage_link']) && $this->completedSteps['storage_link']) {
|
||||
$publicStorage = public_path('storage');
|
||||
if (File::exists($publicStorage) && is_link($publicStorage)) {
|
||||
File::delete($publicStorage);
|
||||
$this->info(' [✓] Removed storage symlink');
|
||||
}
|
||||
}
|
||||
|
||||
// Done!
|
||||
$this->info('');
|
||||
$this->info(' '.__('core::core.installer.complete'));
|
||||
$this->info('');
|
||||
$this->info(' '.__('core::core.installer.next_steps').':');
|
||||
$this->info(' 1. Run: valet link core');
|
||||
$this->info(' 2. Visit: http://core.test');
|
||||
$this->info('');
|
||||
// Remove SQLite file if we created it
|
||||
if (isset($this->completedSteps['sqlite_created']) && $this->completedSteps['sqlite_created']) {
|
||||
$sqlitePath = database_path('database.sqlite');
|
||||
if (File::exists($sqlitePath)) {
|
||||
File::delete($sqlitePath);
|
||||
$this->info(' [✓] Removed SQLite database file');
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
$this->info(' Rollback complete.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +171,7 @@ class InstallCommand extends Command
|
|||
}
|
||||
|
||||
File::copy($envExamplePath, $envPath);
|
||||
$this->completedSteps['env_created'] = true;
|
||||
$this->info(' [✓] '.__('core::core.installer.env_created'));
|
||||
|
||||
return true;
|
||||
|
|
@ -127,6 +210,7 @@ class InstallCommand extends Command
|
|||
$sqlitePath = database_path('database.sqlite');
|
||||
if (! File::exists($sqlitePath)) {
|
||||
File::put($sqlitePath, '');
|
||||
$this->completedSteps['sqlite_created'] = true;
|
||||
$this->info(' [✓] Created SQLite database');
|
||||
}
|
||||
} else {
|
||||
|
|
@ -142,11 +226,44 @@ class InstallCommand extends Command
|
|||
$this->updateEnv('DB_DATABASE', $dbName);
|
||||
$this->updateEnv('DB_USERNAME', $dbUser);
|
||||
$this->updateEnv('DB_PASSWORD', $dbPass ?? '');
|
||||
|
||||
// Display masked confirmation (never show actual credentials)
|
||||
$this->info('');
|
||||
$this->info(' Database settings configured:');
|
||||
$this->info(" Driver: {$dbConnection}");
|
||||
$this->info(" Host: {$dbHost}");
|
||||
$this->info(" Port: {$dbPort}");
|
||||
$this->info(" Database: {$dbName}");
|
||||
$this->info(' Username: '.$this->maskValue($dbUser));
|
||||
$this->info(' Password: '.$this->maskValue($dbPass ?? '', true));
|
||||
}
|
||||
|
||||
$this->completedSteps['env_modified'] = true;
|
||||
$this->info(' [✓] '.__('core::core.installer.config_saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a sensitive value for display.
|
||||
*/
|
||||
protected function maskValue(string $value, bool $isPassword = false): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return $isPassword ? '[not set]' : '[empty]';
|
||||
}
|
||||
|
||||
if ($isPassword) {
|
||||
return str_repeat('*', min(strlen($value), 8));
|
||||
}
|
||||
|
||||
$length = strlen($value);
|
||||
if ($length <= 2) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
// Show first and last character with asterisks in between
|
||||
return $value[0].str_repeat('*', $length - 2).$value[$length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run database migrations.
|
||||
*/
|
||||
|
|
@ -189,6 +306,7 @@ class InstallCommand extends Command
|
|||
}
|
||||
|
||||
$this->call('storage:link');
|
||||
$this->completedSteps['storage_link'] = true;
|
||||
$this->info(' [✓] '.__('core::core.installer.storage_link_created'));
|
||||
}
|
||||
|
||||
|
|
@ -210,11 +328,12 @@ class InstallCommand extends Command
|
|||
$value = "\"{$value}\"";
|
||||
}
|
||||
|
||||
// Check if key exists
|
||||
if (preg_match("/^{$key}=/m", $content)) {
|
||||
// Check if key exists (escape regex special chars in key)
|
||||
$escapedKey = preg_quote($key, '/');
|
||||
if (preg_match("/^{$escapedKey}=/m", $content)) {
|
||||
// Update existing key
|
||||
$content = preg_replace(
|
||||
"/^{$key}=.*/m",
|
||||
"/^{$escapedKey}=.*/m",
|
||||
"{$key}={$value}",
|
||||
$content
|
||||
);
|
||||
|
|
|
|||
471
packages/core-php/src/Core/Console/Commands/MakeModCommand.php
Normal file
471
packages/core-php/src/Core/Console/Commands/MakeModCommand.php
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generate a new module scaffold.
|
||||
*
|
||||
* Creates a module in the Mod namespace with the standard Boot.php
|
||||
* pattern for event-driven loading.
|
||||
*
|
||||
* Usage: php artisan make:mod Example
|
||||
*/
|
||||
class MakeModCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'make:mod
|
||||
{name : The name of the module (e.g., Example, UserManagement)}
|
||||
{--web : Include web routes handler}
|
||||
{--admin : Include admin panel handler}
|
||||
{--api : Include API routes handler}
|
||||
{--console : Include console commands handler}
|
||||
{--all : Include all handlers}
|
||||
{--force : Overwrite existing module}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Create a new module in the Mod namespace';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$name = Str::studly($this->argument('name'));
|
||||
$modulePath = $this->getModulePath($name);
|
||||
|
||||
if (File::isDirectory($modulePath) && ! $this->option('force')) {
|
||||
$this->error("Module [{$name}] already exists!");
|
||||
$this->info("Use --force to overwrite.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Creating module: {$name}");
|
||||
|
||||
// Create directory structure
|
||||
$this->createDirectoryStructure($modulePath);
|
||||
|
||||
// Create Boot.php
|
||||
$this->createBootFile($modulePath, $name);
|
||||
|
||||
// Create optional route files based on flags
|
||||
$this->createOptionalFiles($modulePath, $name);
|
||||
|
||||
$this->info('');
|
||||
$this->info("Module [{$name}] created successfully!");
|
||||
$this->info('');
|
||||
$this->info('Location: '.$modulePath);
|
||||
$this->info('');
|
||||
$this->info('Next steps:');
|
||||
$this->info(' 1. Add your module logic to the Boot.php event handlers');
|
||||
$this->info(' 2. Create Models, Views, and Controllers as needed');
|
||||
$this->info('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for the module.
|
||||
*/
|
||||
protected function getModulePath(string $name): string
|
||||
{
|
||||
// Check for packages structure first (monorepo)
|
||||
$packagesPath = base_path("packages/core-php/src/Mod/{$name}");
|
||||
if (File::isDirectory(dirname($packagesPath))) {
|
||||
return $packagesPath;
|
||||
}
|
||||
|
||||
// Fall back to app/Mod for consuming applications
|
||||
return base_path("app/Mod/{$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the directory structure for the module.
|
||||
*/
|
||||
protected function createDirectoryStructure(string $modulePath): void
|
||||
{
|
||||
$directories = [
|
||||
$modulePath,
|
||||
"{$modulePath}/Models",
|
||||
"{$modulePath}/View",
|
||||
"{$modulePath}/View/Blade",
|
||||
];
|
||||
|
||||
if ($this->hasRoutes()) {
|
||||
$directories[] = "{$modulePath}/Routes";
|
||||
}
|
||||
|
||||
if ($this->option('console') || $this->option('all')) {
|
||||
$directories[] = "{$modulePath}/Console";
|
||||
$directories[] = "{$modulePath}/Console/Commands";
|
||||
}
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
File::ensureDirectoryExists($directory);
|
||||
}
|
||||
|
||||
$this->info(' [+] Created directory structure');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any route handlers are requested.
|
||||
*/
|
||||
protected function hasRoutes(): bool
|
||||
{
|
||||
return $this->option('web')
|
||||
|| $this->option('admin')
|
||||
|| $this->option('api')
|
||||
|| $this->option('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Boot.php file.
|
||||
*/
|
||||
protected function createBootFile(string $modulePath, string $name): void
|
||||
{
|
||||
$namespace = $this->resolveNamespace($modulePath, $name);
|
||||
$listeners = $this->buildListenersArray();
|
||||
$handlers = $this->buildHandlerMethods($name);
|
||||
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
{$this->buildUseStatements()}
|
||||
|
||||
/**
|
||||
* {$name} Module - Event-driven module registration.
|
||||
*
|
||||
* This module uses the lazy loading pattern where handlers
|
||||
* are only invoked when their corresponding events fire.
|
||||
*/
|
||||
class Boot
|
||||
{
|
||||
/**
|
||||
* Events this module listens to for lazy loading.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
public static array \$listens = [
|
||||
{$listeners}
|
||||
];
|
||||
{$handlers}
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$modulePath}/Boot.php", $content);
|
||||
$this->info(' [+] Created Boot.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the namespace for the module.
|
||||
*/
|
||||
protected function resolveNamespace(string $modulePath, string $name): string
|
||||
{
|
||||
if (str_contains($modulePath, 'packages/core-php/src/Mod')) {
|
||||
return "Core\\Mod\\{$name}";
|
||||
}
|
||||
|
||||
return "Mod\\{$name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the use statements for the Boot file.
|
||||
*/
|
||||
protected function buildUseStatements(): string
|
||||
{
|
||||
$statements = [];
|
||||
|
||||
if ($this->option('web') || $this->option('all')) {
|
||||
$statements[] = 'use Core\Events\WebRoutesRegistering;';
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$statements[] = 'use Core\Events\AdminPanelBooting;';
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$statements[] = 'use Core\Events\ApiRoutesRegistering;';
|
||||
}
|
||||
|
||||
if ($this->option('console') || $this->option('all')) {
|
||||
$statements[] = 'use Core\Events\ConsoleBooting;';
|
||||
}
|
||||
|
||||
if (empty($statements)) {
|
||||
$statements[] = 'use Core\Events\WebRoutesRegistering;';
|
||||
}
|
||||
|
||||
return implode("\n", $statements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the listeners array content.
|
||||
*/
|
||||
protected function buildListenersArray(): string
|
||||
{
|
||||
$listeners = [];
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$listeners[] = " WebRoutesRegistering::class => 'onWebRoutes',";
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$listeners[] = " AdminPanelBooting::class => 'onAdminPanel',";
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$listeners[] = " ApiRoutesRegistering::class => 'onApiRoutes',";
|
||||
}
|
||||
|
||||
if ($this->option('console') || $this->option('all')) {
|
||||
$listeners[] = " ConsoleBooting::class => 'onConsole',";
|
||||
}
|
||||
|
||||
return implode("\n", $listeners);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any specific option was provided.
|
||||
*/
|
||||
protected function hasAnyOption(): bool
|
||||
{
|
||||
return $this->option('web')
|
||||
|| $this->option('admin')
|
||||
|| $this->option('api')
|
||||
|| $this->option('console')
|
||||
|| $this->option('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the handler methods.
|
||||
*/
|
||||
protected function buildHandlerMethods(string $name): string
|
||||
{
|
||||
$methods = [];
|
||||
$moduleName = Str::snake($name);
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register web routes and views.
|
||||
*/
|
||||
public function onWebRoutes(WebRoutesRegistering \$event): void
|
||||
{
|
||||
\$event->views('{$moduleName}', __DIR__.'/View/Blade');
|
||||
|
||||
if (file_exists(__DIR__.'/Routes/web.php')) {
|
||||
\$event->routes(fn () => require __DIR__.'/Routes/web.php');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register admin panel components.
|
||||
*/
|
||||
public function onAdminPanel(AdminPanelBooting \$event): void
|
||||
{
|
||||
// Register admin Livewire components
|
||||
// \$event->livewire('{$moduleName}.admin.index', View\Modal\Admin\Index::class);
|
||||
|
||||
if (file_exists(__DIR__.'/Routes/admin.php')) {
|
||||
\$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register API routes.
|
||||
*/
|
||||
public function onApiRoutes(ApiRoutesRegistering \$event): void
|
||||
{
|
||||
if (file_exists(__DIR__.'/Routes/api.php')) {
|
||||
\$event->routes(fn () => require __DIR__.'/Routes/api.php');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
if ($this->option('console') || $this->option('all')) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register console commands.
|
||||
*/
|
||||
public function onConsole(ConsoleBooting \$event): void
|
||||
{
|
||||
// Register artisan commands
|
||||
// \$event->command(Console\Commands\ExampleCommand::class);
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
return implode("\n", $methods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create optional files based on flags.
|
||||
*/
|
||||
protected function createOptionalFiles(string $modulePath, string $name): void
|
||||
{
|
||||
$moduleName = Str::snake($name);
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$this->createWebRoutes($modulePath, $moduleName);
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$this->createAdminRoutes($modulePath, $moduleName);
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$this->createApiRoutes($modulePath, $moduleName);
|
||||
}
|
||||
|
||||
// Create a sample view
|
||||
$this->createSampleView($modulePath, $moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create web routes file.
|
||||
*/
|
||||
protected function createWebRoutes(string $modulePath, string $moduleName): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| {$moduleName} Web Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Public web routes for the {$moduleName} module.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('{$moduleName}')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return view('{$moduleName}::index');
|
||||
})->name('{$moduleName}.index');
|
||||
});
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$modulePath}/Routes/web.php", $content);
|
||||
$this->info(' [+] Created Routes/web.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create admin routes file.
|
||||
*/
|
||||
protected function createAdminRoutes(string $modulePath, string $moduleName): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| {$moduleName} Admin Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Admin panel routes for the {$moduleName} module.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('{$moduleName}')->name('{$moduleName}.admin.')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return view('{$moduleName}::admin.index');
|
||||
})->name('index');
|
||||
});
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$modulePath}/Routes/admin.php", $content);
|
||||
$this->info(' [+] Created Routes/admin.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API routes file.
|
||||
*/
|
||||
protected function createApiRoutes(string $modulePath, string $moduleName): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| {$moduleName} API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| API routes for the {$moduleName} module.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('{$moduleName}')->name('api.{$moduleName}.')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return response()->json(['module' => '{$moduleName}', 'status' => 'ok']);
|
||||
})->name('index');
|
||||
});
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$modulePath}/Routes/api.php", $content);
|
||||
$this->info(' [+] Created Routes/api.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sample view file.
|
||||
*/
|
||||
protected function createSampleView(string $modulePath, string $moduleName): void
|
||||
{
|
||||
$content = <<<BLADE
|
||||
<x-layouts.app>
|
||||
<x-slot name="title">{$moduleName}</x-slot>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-4">{$moduleName} Module</h1>
|
||||
<p class="text-gray-600">Welcome to the {$moduleName} module.</p>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
BLADE;
|
||||
|
||||
File::put("{$modulePath}/View/Blade/index.blade.php", $content);
|
||||
$this->info(' [+] Created View/Blade/index.blade.php');
|
||||
}
|
||||
}
|
||||
513
packages/core-php/src/Core/Console/Commands/MakePlugCommand.php
Normal file
513
packages/core-php/src/Core/Console/Commands/MakePlugCommand.php
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generate a new Plug provider scaffold.
|
||||
*
|
||||
* Creates a provider in the Plug namespace with the operation-based
|
||||
* architecture (Auth, Post, Delete, etc.).
|
||||
*
|
||||
* Usage: php artisan make:plug Twitter --category=Social
|
||||
*/
|
||||
class MakePlugCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'make:plug
|
||||
{name : The name of the provider (e.g., Twitter, Instagram)}
|
||||
{--category=Social : Category (Social, Web3, Content, Chat, Business)}
|
||||
{--auth : Include OAuth authentication operation}
|
||||
{--post : Include posting operation}
|
||||
{--delete : Include delete operation}
|
||||
{--media : Include media upload operation}
|
||||
{--all : Include all operations}
|
||||
{--force : Overwrite existing provider}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Create a new Plug provider with operation-based architecture';
|
||||
|
||||
/**
|
||||
* Valid categories for Plug providers.
|
||||
*/
|
||||
protected const CATEGORIES = ['Social', 'Web3', 'Content', 'Chat', 'Business'];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$name = Str::studly($this->argument('name'));
|
||||
$category = Str::studly($this->option('category'));
|
||||
|
||||
if (! in_array($category, self::CATEGORIES)) {
|
||||
$this->error("Invalid category [{$category}].");
|
||||
$this->info('Valid categories: '.implode(', ', self::CATEGORIES));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$providerPath = $this->getProviderPath($category, $name);
|
||||
|
||||
if (File::isDirectory($providerPath) && ! $this->option('force')) {
|
||||
$this->error("Provider [{$name}] already exists in [{$category}]!");
|
||||
$this->info("Use --force to overwrite.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Creating Plug provider: {$category}/{$name}");
|
||||
|
||||
// Create directory structure
|
||||
File::ensureDirectoryExists($providerPath);
|
||||
$this->info(' [+] Created provider directory');
|
||||
|
||||
// Create operations based on flags
|
||||
$this->createOperations($providerPath, $category, $name);
|
||||
|
||||
$this->info('');
|
||||
$this->info("Plug provider [{$category}/{$name}] created successfully!");
|
||||
$this->info('');
|
||||
$this->info('Location: '.$providerPath);
|
||||
$this->info('');
|
||||
$this->info('Usage example:');
|
||||
$this->info(" use Plug\\{$category}\\{$name}\\Auth;");
|
||||
$this->info('');
|
||||
$this->info(' $auth = new Auth(\$clientId, \$clientSecret, \$redirectUrl);');
|
||||
$this->info(' $authUrl = $auth->getAuthUrl();');
|
||||
$this->info('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for the provider.
|
||||
*/
|
||||
protected function getProviderPath(string $category, string $name): string
|
||||
{
|
||||
// Check for packages structure first (monorepo)
|
||||
$packagesPath = base_path("packages/core-php/src/Plug/{$category}/{$name}");
|
||||
if (File::isDirectory(dirname(dirname($packagesPath)))) {
|
||||
return $packagesPath;
|
||||
}
|
||||
|
||||
// Fall back to app/Plug for consuming applications
|
||||
return base_path("app/Plug/{$category}/{$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the namespace for the provider.
|
||||
*/
|
||||
protected function resolveNamespace(string $providerPath, string $category, string $name): string
|
||||
{
|
||||
if (str_contains($providerPath, 'packages/core-php/src/Plug')) {
|
||||
return "Core\\Plug\\{$category}\\{$name}";
|
||||
}
|
||||
|
||||
return "Plug\\{$category}\\{$name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create operations based on flags.
|
||||
*/
|
||||
protected function createOperations(string $providerPath, string $category, string $name): void
|
||||
{
|
||||
$namespace = $this->resolveNamespace($providerPath, $category, $name);
|
||||
|
||||
// Always create Auth if --auth or --all or no specific options
|
||||
if ($this->option('auth') || $this->option('all') || ! $this->hasAnyOperation()) {
|
||||
$this->createAuthOperation($providerPath, $namespace, $name);
|
||||
}
|
||||
|
||||
if ($this->option('post') || $this->option('all')) {
|
||||
$this->createPostOperation($providerPath, $namespace, $name);
|
||||
}
|
||||
|
||||
if ($this->option('delete') || $this->option('all')) {
|
||||
$this->createDeleteOperation($providerPath, $namespace, $name);
|
||||
}
|
||||
|
||||
if ($this->option('media') || $this->option('all')) {
|
||||
$this->createMediaOperation($providerPath, $namespace, $name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any operation option was provided.
|
||||
*/
|
||||
protected function hasAnyOperation(): bool
|
||||
{
|
||||
return $this->option('auth')
|
||||
|| $this->option('post')
|
||||
|| $this->option('delete')
|
||||
|| $this->option('media')
|
||||
|| $this->option('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Auth operation.
|
||||
*/
|
||||
protected function createAuthOperation(string $providerPath, string $namespace, string $name): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* {$name} OAuth Authentication.
|
||||
*
|
||||
* Handles OAuth 2.0 authentication flow for {$name}.
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
protected string \$clientId;
|
||||
|
||||
protected string \$clientSecret;
|
||||
|
||||
protected string \$redirectUrl;
|
||||
|
||||
protected array \$scopes = [];
|
||||
|
||||
/**
|
||||
* Create a new Auth instance.
|
||||
*/
|
||||
public function __construct(string \$clientId, string \$clientSecret, string \$redirectUrl)
|
||||
{
|
||||
\$this->clientId = \$clientId;
|
||||
\$this->clientSecret = \$clientSecret;
|
||||
\$this->redirectUrl = \$redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider display name.
|
||||
*/
|
||||
public static function name(): string
|
||||
{
|
||||
return '{$name}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OAuth scopes.
|
||||
*
|
||||
* @param string[] \$scopes
|
||||
*/
|
||||
public function withScopes(array \$scopes): static
|
||||
{
|
||||
\$this->scopes = \$scopes;
|
||||
|
||||
return \$this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for user redirect.
|
||||
*/
|
||||
public function getAuthUrl(?string \$state = null): string
|
||||
{
|
||||
\$params = [
|
||||
'client_id' => \$this->clientId,
|
||||
'redirect_uri' => \$this->redirectUrl,
|
||||
'response_type' => 'code',
|
||||
'scope' => implode(' ', \$this->scopes),
|
||||
];
|
||||
|
||||
if (\$state) {
|
||||
\$params['state'] = \$state;
|
||||
}
|
||||
|
||||
// TODO: Replace with actual provider OAuth URL
|
||||
return 'https://example.com/oauth/authorize?'.http_build_query(\$params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token.
|
||||
*/
|
||||
public function exchangeCode(string \$code): Response
|
||||
{
|
||||
// TODO: Implement token exchange with provider API
|
||||
return \$this->ok([
|
||||
'access_token' => '',
|
||||
'refresh_token' => '',
|
||||
'expires_in' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an expired access token.
|
||||
*/
|
||||
public function refreshToken(string \$refreshToken): Response
|
||||
{
|
||||
// TODO: Implement token refresh with provider API
|
||||
return \$this->ok([
|
||||
'access_token' => '',
|
||||
'refresh_token' => '',
|
||||
'expires_in' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an access token.
|
||||
*/
|
||||
public function revokeToken(string \$accessToken): Response
|
||||
{
|
||||
// TODO: Implement token revocation with provider API
|
||||
return \$this->ok(['revoked' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an HTTP client instance.
|
||||
*/
|
||||
protected function http(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->timeout(30);
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$providerPath}/Auth.php", $content);
|
||||
$this->info(' [+] Created Auth.php (OAuth authentication)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Post operation.
|
||||
*/
|
||||
protected function createPostOperation(string $providerPath, string $namespace, string $name): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* {$name} Post Operation.
|
||||
*
|
||||
* Handles creating/publishing content to {$name}.
|
||||
*/
|
||||
class Post
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
/**
|
||||
* Create a new post/content.
|
||||
*/
|
||||
public function create(string \$content, array \$options = []): Response
|
||||
{
|
||||
// TODO: Implement post creation with provider API
|
||||
// Example:
|
||||
// \$response = \$this->http()
|
||||
// ->withToken(\$this->accessToken())
|
||||
// ->post('https://api.example.com/posts', [
|
||||
// 'text' => \$content,
|
||||
// ...\$options,
|
||||
// ]);
|
||||
//
|
||||
// return \$this->fromResponse(\$response);
|
||||
|
||||
return \$this->ok([
|
||||
'id' => '',
|
||||
'url' => '',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a post for later.
|
||||
*/
|
||||
public function schedule(string \$content, \DateTimeInterface \$publishAt, array \$options = []): Response
|
||||
{
|
||||
// TODO: Implement scheduled posting
|
||||
return \$this->ok([
|
||||
'id' => '',
|
||||
'scheduled_at' => \$publishAt->format('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an HTTP client instance.
|
||||
*/
|
||||
protected function http(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->timeout(30);
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$providerPath}/Post.php", $content);
|
||||
$this->info(' [+] Created Post.php (content creation)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Delete operation.
|
||||
*/
|
||||
protected function createDeleteOperation(string $providerPath, string $namespace, string $name): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* {$name} Delete Operation.
|
||||
*
|
||||
* Handles deleting content from {$name}.
|
||||
*/
|
||||
class Delete
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
/**
|
||||
* Delete a post by ID.
|
||||
*/
|
||||
public function post(string \$postId): Response
|
||||
{
|
||||
// TODO: Implement post deletion with provider API
|
||||
// Example:
|
||||
// \$response = \$this->http()
|
||||
// ->withToken(\$this->accessToken())
|
||||
// ->delete("https://api.example.com/posts/{\$postId}");
|
||||
//
|
||||
// return \$this->fromResponse(\$response);
|
||||
|
||||
return \$this->ok(['deleted' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an HTTP client instance.
|
||||
*/
|
||||
protected function http(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->timeout(30);
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$providerPath}/Delete.php", $content);
|
||||
$this->info(' [+] Created Delete.php (content deletion)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Media operation.
|
||||
*/
|
||||
protected function createMediaOperation(string $providerPath, string $namespace, string $name): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* {$name} Media Operation.
|
||||
*
|
||||
* Handles media uploads to {$name}.
|
||||
*/
|
||||
class Media
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
/**
|
||||
* Upload media from a file path.
|
||||
*/
|
||||
public function upload(string \$filePath, array \$options = []): Response
|
||||
{
|
||||
// TODO: Implement media upload with provider API
|
||||
// Example:
|
||||
// \$response = \$this->http()
|
||||
// ->withToken(\$this->accessToken())
|
||||
// ->attach('media', file_get_contents(\$filePath), basename(\$filePath))
|
||||
// ->post('https://api.example.com/media/upload', \$options);
|
||||
//
|
||||
// return \$this->fromResponse(\$response);
|
||||
|
||||
return \$this->ok([
|
||||
'media_id' => '',
|
||||
'url' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media from a URL.
|
||||
*/
|
||||
public function uploadFromUrl(string \$url, array \$options = []): Response
|
||||
{
|
||||
// TODO: Implement URL-based media upload
|
||||
return \$this->ok([
|
||||
'media_id' => '',
|
||||
'url' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an HTTP client instance.
|
||||
*/
|
||||
protected function http(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->timeout(60); // Longer timeout for uploads
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$providerPath}/Media.php", $content);
|
||||
$this->info(' [+] Created Media.php (media uploads)');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generate a new Website scaffold.
|
||||
*
|
||||
* Creates a domain-isolated website in the Website namespace
|
||||
* that is loaded based on incoming HTTP host.
|
||||
*
|
||||
* Usage: php artisan make:website MarketingSite --domain=marketing.test
|
||||
*/
|
||||
class MakeWebsiteCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'make:website
|
||||
{name : The name of the website (e.g., MarketingSite, Blog)}
|
||||
{--domain= : Primary domain pattern (e.g., example.test, example.com)}
|
||||
{--web : Include web routes}
|
||||
{--admin : Include admin routes}
|
||||
{--api : Include API routes}
|
||||
{--all : Include all route types}
|
||||
{--force : Overwrite existing website}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Create a new domain-isolated website';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$name = Str::studly($this->argument('name'));
|
||||
$domain = $this->option('domain') ?: Str::snake($name, '-').'.test';
|
||||
$websitePath = $this->getWebsitePath($name);
|
||||
|
||||
if (File::isDirectory($websitePath) && ! $this->option('force')) {
|
||||
$this->error("Website [{$name}] already exists!");
|
||||
$this->info("Use --force to overwrite.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Creating website: {$name}");
|
||||
$this->info("Domain: {$domain}");
|
||||
|
||||
// Create directory structure
|
||||
$this->createDirectoryStructure($websitePath);
|
||||
|
||||
// Create Boot.php
|
||||
$this->createBootFile($websitePath, $name, $domain);
|
||||
|
||||
// Create optional route files
|
||||
$this->createOptionalFiles($websitePath, $name);
|
||||
|
||||
$this->info('');
|
||||
$this->info("Website [{$name}] created successfully!");
|
||||
$this->info('');
|
||||
$this->info('Location: '.$websitePath);
|
||||
$this->info('');
|
||||
$this->info('Next steps:');
|
||||
$this->info(" 1. Configure your local dev server to serve {$domain}");
|
||||
$this->info(' (e.g., valet link '.Str::snake($name, '-').')');
|
||||
$this->info(" 2. Visit http://{$domain} to see your website");
|
||||
$this->info(' 3. Add routes, views, and controllers as needed');
|
||||
$this->info('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for the website.
|
||||
*/
|
||||
protected function getWebsitePath(string $name): string
|
||||
{
|
||||
// Websites go in app/Website for consuming applications
|
||||
return base_path("app/Website/{$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the directory structure for the website.
|
||||
*/
|
||||
protected function createDirectoryStructure(string $websitePath): void
|
||||
{
|
||||
$directories = [
|
||||
$websitePath,
|
||||
"{$websitePath}/View",
|
||||
"{$websitePath}/View/Blade",
|
||||
"{$websitePath}/View/Blade/layouts",
|
||||
];
|
||||
|
||||
if ($this->hasRoutes()) {
|
||||
$directories[] = "{$websitePath}/Routes";
|
||||
}
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
File::ensureDirectoryExists($directory);
|
||||
}
|
||||
|
||||
$this->info(' [+] Created directory structure');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any route handlers are requested.
|
||||
*/
|
||||
protected function hasRoutes(): bool
|
||||
{
|
||||
return $this->option('web')
|
||||
|| $this->option('admin')
|
||||
|| $this->option('api')
|
||||
|| $this->option('all')
|
||||
|| ! $this->hasAnyOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any specific option was provided.
|
||||
*/
|
||||
protected function hasAnyOption(): bool
|
||||
{
|
||||
return $this->option('web')
|
||||
|| $this->option('admin')
|
||||
|| $this->option('api')
|
||||
|| $this->option('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Boot.php file.
|
||||
*/
|
||||
protected function createBootFile(string $websitePath, string $name, string $domain): void
|
||||
{
|
||||
$namespace = "Website\\{$name}";
|
||||
$domainPattern = $this->buildDomainPattern($domain);
|
||||
$listeners = $this->buildListenersArray();
|
||||
$handlers = $this->buildHandlerMethods($name);
|
||||
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {$namespace};
|
||||
|
||||
use Core\Events\DomainResolving;
|
||||
{$this->buildUseStatements()}
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* {$name} Website - Domain-isolated website provider.
|
||||
*
|
||||
* This website is loaded when the incoming HTTP host matches
|
||||
* the domain pattern defined in \$domains.
|
||||
*/
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Domain patterns this website responds to.
|
||||
*
|
||||
* Uses regex patterns. Common examples:
|
||||
* - '/^example\\.test\$/' - exact match
|
||||
* - '/^example\\.(com|test)\$/' - multiple TLDs
|
||||
* - '/^(www\\.)?example\\.com\$/' - optional www
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
public static array \$domains = [
|
||||
'{$domainPattern}',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events this module listens to for lazy loading.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
public static array \$listens = [
|
||||
DomainResolving::class => 'onDomainResolving',
|
||||
{$listeners}
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle domain resolution - register if domain matches.
|
||||
*/
|
||||
public function onDomainResolving(DomainResolving \$event): void
|
||||
{
|
||||
foreach (self::\$domains as \$pattern) {
|
||||
if (\$event->matches(\$pattern)) {
|
||||
\$event->register(self::class);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
{$handlers}
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$websitePath}/Boot.php", $content);
|
||||
$this->info(' [+] Created Boot.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the domain regex pattern.
|
||||
*/
|
||||
protected function buildDomainPattern(string $domain): string
|
||||
{
|
||||
// Escape dots and create a regex pattern
|
||||
$escaped = preg_quote($domain, '/');
|
||||
|
||||
return '/^'.$escaped.'$/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the use statements for the Boot file.
|
||||
*/
|
||||
protected function buildUseStatements(): string
|
||||
{
|
||||
$statements = [];
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$statements[] = 'use Core\Events\WebRoutesRegistering;';
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$statements[] = 'use Core\Events\AdminPanelBooting;';
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$statements[] = 'use Core\Events\ApiRoutesRegistering;';
|
||||
}
|
||||
|
||||
return implode("\n", $statements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the listeners array content (excluding DomainResolving).
|
||||
*/
|
||||
protected function buildListenersArray(): string
|
||||
{
|
||||
$listeners = [];
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$listeners[] = " WebRoutesRegistering::class => 'onWebRoutes',";
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$listeners[] = " AdminPanelBooting::class => 'onAdminPanel',";
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$listeners[] = " ApiRoutesRegistering::class => 'onApiRoutes',";
|
||||
}
|
||||
|
||||
return implode("\n", $listeners);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the handler methods.
|
||||
*/
|
||||
protected function buildHandlerMethods(string $name): string
|
||||
{
|
||||
$methods = [];
|
||||
$websiteName = Str::snake($name);
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register web routes and views.
|
||||
*/
|
||||
public function onWebRoutes(WebRoutesRegistering \$event): void
|
||||
{
|
||||
\$event->views('{$websiteName}', __DIR__.'/View/Blade');
|
||||
|
||||
if (file_exists(__DIR__.'/Routes/web.php')) {
|
||||
\$event->routes(fn () => require __DIR__.'/Routes/web.php');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register admin panel routes.
|
||||
*/
|
||||
public function onAdminPanel(AdminPanelBooting \$event): void
|
||||
{
|
||||
if (file_exists(__DIR__.'/Routes/admin.php')) {
|
||||
\$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$methods[] = <<<PHP
|
||||
|
||||
/**
|
||||
* Register API routes.
|
||||
*/
|
||||
public function onApiRoutes(ApiRoutesRegistering \$event): void
|
||||
{
|
||||
if (file_exists(__DIR__.'/Routes/api.php')) {
|
||||
\$event->routes(fn () => require __DIR__.'/Routes/api.php');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
return implode("\n", $methods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create optional files based on flags.
|
||||
*/
|
||||
protected function createOptionalFiles(string $websitePath, string $name): void
|
||||
{
|
||||
$websiteName = Str::snake($name);
|
||||
|
||||
if ($this->option('web') || $this->option('all') || ! $this->hasAnyOption()) {
|
||||
$this->createWebRoutes($websitePath, $websiteName);
|
||||
$this->createLayout($websitePath, $name);
|
||||
$this->createHomepage($websitePath, $websiteName);
|
||||
}
|
||||
|
||||
if ($this->option('admin') || $this->option('all')) {
|
||||
$this->createAdminRoutes($websitePath, $websiteName);
|
||||
}
|
||||
|
||||
if ($this->option('api') || $this->option('all')) {
|
||||
$this->createApiRoutes($websitePath, $websiteName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create web routes file.
|
||||
*/
|
||||
protected function createWebRoutes(string $websitePath, string $websiteName): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| {$websiteName} Web Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Public web routes for this website.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('{$websiteName}::home');
|
||||
})->name('{$websiteName}.home');
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$websitePath}/Routes/web.php", $content);
|
||||
$this->info(' [+] Created Routes/web.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create admin routes file.
|
||||
*/
|
||||
protected function createAdminRoutes(string $websitePath, string $websiteName): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| {$websiteName} Admin Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Admin routes for this website.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('admin/{$websiteName}')->name('{$websiteName}.admin.')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return 'Admin dashboard for {$websiteName}';
|
||||
})->name('index');
|
||||
});
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$websitePath}/Routes/admin.php", $content);
|
||||
$this->info(' [+] Created Routes/admin.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API routes file.
|
||||
*/
|
||||
protected function createApiRoutes(string $websitePath, string $websiteName): void
|
||||
{
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| {$websiteName} API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| API routes for this website.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('{$websiteName}')->name('api.{$websiteName}.')->group(function () {
|
||||
Route::get('/health', function () {
|
||||
return response()->json(['status' => 'ok', 'website' => '{$websiteName}']);
|
||||
})->name('health');
|
||||
});
|
||||
|
||||
PHP;
|
||||
|
||||
File::put("{$websitePath}/Routes/api.php", $content);
|
||||
$this->info(' [+] Created Routes/api.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a base layout file.
|
||||
*/
|
||||
protected function createLayout(string $websitePath, string $name): void
|
||||
{
|
||||
$content = <<<BLADE
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ \$title ?? '{$name}' }}</title>
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-800">
|
||||
{$name}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
{{ \$slot }}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BLADE;
|
||||
|
||||
File::put("{$websitePath}/View/Blade/layouts/app.blade.php", $content);
|
||||
$this->info(' [+] Created View/Blade/layouts/app.blade.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a homepage view.
|
||||
*/
|
||||
protected function createHomepage(string $websitePath, string $websiteName): void
|
||||
{
|
||||
$name = Str::studly($websiteName);
|
||||
|
||||
$content = <<<BLADE
|
||||
<x-{$websiteName}::layouts.app>
|
||||
<x-slot name="title">Welcome - {$name}</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
<h1 class="text-3xl font-bold mb-4">Welcome to {$name}</h1>
|
||||
<p class="text-gray-600">
|
||||
This is your new website. Start building something amazing!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-{$websiteName}::layouts.app>
|
||||
|
||||
BLADE;
|
||||
|
||||
File::put("{$websitePath}/View/Blade/home.blade.php", $content);
|
||||
$this->info(' [+] Created View/Blade/home.blade.php');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,37 +10,94 @@ namespace Core\Crypt;
|
|||
* A lightweight, deterministic identifier generator for workspace/domain scoping.
|
||||
* Used to create vBucket IDs for CDN path isolation.
|
||||
*
|
||||
* Note: This is NOT cryptographically secure. It's designed for:
|
||||
* - Consistent, reproducible identifiers
|
||||
* - Human-readable outputs
|
||||
* - Fast generation
|
||||
* ## Security Properties
|
||||
*
|
||||
* NOT suitable for:
|
||||
* - Password hashing
|
||||
* - Security tokens
|
||||
* - Cryptographic operations
|
||||
* This is a "QuasiHash" - a deterministic identifier generator, NOT a cryptographic hash.
|
||||
*
|
||||
* **What it provides:**
|
||||
* - Deterministic output: same input always produces same output
|
||||
* - Uniform distribution: outputs are evenly distributed across the hash space
|
||||
* - Avalanche effect: small input changes produce significantly different outputs
|
||||
* - Collision resistance proportional to output length (see table below)
|
||||
*
|
||||
* **What it does NOT provide:**
|
||||
* - Pre-image resistance: attackers can potentially reverse the hash
|
||||
* - Cryptographic security: the key map is not a secret
|
||||
* - Protection against brute force: short hashes can be enumerated
|
||||
*
|
||||
* ## Collision Resistance by Length
|
||||
*
|
||||
* | Length | Bits | Collision Probability (10k items) | Use Case |
|
||||
* |--------|------|-----------------------------------|----------|
|
||||
* | 16 | 64 | ~1 in 3.4 billion | Internal IDs, low-volume |
|
||||
* | 24 | 96 | ~1 in 79 quintillion | Cross-system IDs |
|
||||
* | 32 | 128 | ~1 in 3.4e38 | Long-term storage |
|
||||
* | 64 | 256 | Negligible | Maximum security |
|
||||
*
|
||||
* ## Key Rotation
|
||||
*
|
||||
* The class supports multiple key maps for rotation. When verifying, all registered
|
||||
* key maps are tried in order (newest first). This allows gradual migration:
|
||||
*
|
||||
* 1. Add new key map with `addKeyMap()`
|
||||
* 2. New hashes use the new key map
|
||||
* 3. Verification tries new key first, falls back to old
|
||||
* 4. After migration period, remove old key map with `removeKeyMap()`
|
||||
*
|
||||
* ## NOT Suitable For
|
||||
*
|
||||
* - Password hashing (use `password_hash()` instead)
|
||||
* - Security tokens (use `random_bytes()` instead)
|
||||
* - Cryptographic signatures
|
||||
* - Any security-sensitive operations
|
||||
*/
|
||||
class LthnHash
|
||||
{
|
||||
/**
|
||||
* Default output length for short hash.
|
||||
* Default output length for short hash (16 hex chars = 64 bits).
|
||||
*/
|
||||
public const SHORT_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Character-swapping key map for quasi-salting.
|
||||
* Swaps pairs of characters during encoding.
|
||||
* Medium output length for improved collision resistance (24 hex chars = 96 bits).
|
||||
*/
|
||||
protected static array $keyMap = [
|
||||
'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w',
|
||||
'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u',
|
||||
'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's',
|
||||
'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q',
|
||||
'0' => 'z', '5' => 'y',
|
||||
// Mappings for salt generation: 'tset' → '7z37'
|
||||
's' => 'z', 't' => '7',
|
||||
public const MEDIUM_LENGTH = 24;
|
||||
|
||||
/**
|
||||
* Long output length for high collision resistance (32 hex chars = 128 bits).
|
||||
*/
|
||||
public const LONG_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Default key map identifier.
|
||||
*/
|
||||
public const DEFAULT_KEY = 'default';
|
||||
|
||||
/**
|
||||
* Character-swapping key maps for quasi-salting.
|
||||
* Swaps pairs of characters during encoding.
|
||||
*
|
||||
* Multiple key maps can be registered for key rotation.
|
||||
* The first key map is used for new hashes; all are tried during verification.
|
||||
*
|
||||
* @var array<string, array<string, string>>
|
||||
*/
|
||||
protected static array $keyMaps = [
|
||||
'default' => [
|
||||
'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w',
|
||||
'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u',
|
||||
'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's',
|
||||
'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q',
|
||||
'0' => 'z', '5' => 'y',
|
||||
's' => 'z', 't' => '7',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The currently active key map identifier for generating new hashes.
|
||||
*/
|
||||
protected static string $activeKey = self::DEFAULT_KEY;
|
||||
|
||||
/**
|
||||
* Generate a deterministic quasi-hash from input.
|
||||
*
|
||||
|
|
@ -48,13 +105,16 @@ class LthnHash
|
|||
* substitution, then hashes input + salt with SHA-256.
|
||||
*
|
||||
* @param string $input The input string to hash
|
||||
* @param string|null $keyId Key map identifier (null uses active key)
|
||||
* @return string 64-character SHA-256 hex string
|
||||
*/
|
||||
public static function hash(string $input): string
|
||||
public static function hash(string $input, ?string $keyId = null): string
|
||||
{
|
||||
$keyId ??= self::$activeKey;
|
||||
|
||||
// Create salt by reversing input and applying substitution
|
||||
$reversed = strrev($input);
|
||||
$salt = self::applyKeyMap($reversed);
|
||||
$salt = self::applyKeyMap($reversed, $keyId);
|
||||
|
||||
// Hash input + salt
|
||||
return hash('sha256', $input.$salt);
|
||||
|
|
@ -64,10 +124,15 @@ class LthnHash
|
|||
* Generate a short hash (prefix of full hash).
|
||||
*
|
||||
* @param string $input The input string to hash
|
||||
* @param int $length Output length in hex characters (default: SHORT_LENGTH)
|
||||
*/
|
||||
public static function shortHash(string $input): string
|
||||
public static function shortHash(string $input, int $length = self::SHORT_LENGTH): string
|
||||
{
|
||||
return substr(self::hash($input), 0, self::SHORT_LENGTH);
|
||||
if ($length < 1 || $length > 64) {
|
||||
throw new \InvalidArgumentException('Hash length must be between 1 and 64');
|
||||
}
|
||||
|
||||
return substr(self::hash($input), 0, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,50 +153,174 @@ class LthnHash
|
|||
/**
|
||||
* Verify that a hash matches an input.
|
||||
*
|
||||
* Tries all registered key maps in order (active key first, then others).
|
||||
* This supports key rotation: old hashes remain verifiable while new hashes
|
||||
* use the current active key.
|
||||
*
|
||||
* @param string $input The original input
|
||||
* @param string $hash The hash to verify
|
||||
* @return bool True if the hash matches with any registered key map
|
||||
*/
|
||||
public static function verify(string $input, string $hash): bool
|
||||
{
|
||||
$computed = self::hash($input);
|
||||
$hashLength = strlen($hash);
|
||||
|
||||
// If hash is shorter, compare prefix
|
||||
if (strlen($hash) < 64) {
|
||||
$computed = substr($computed, 0, strlen($hash));
|
||||
// Try active key first
|
||||
$computed = self::hash($input, self::$activeKey);
|
||||
if ($hashLength < 64) {
|
||||
$computed = substr($computed, 0, $hashLength);
|
||||
}
|
||||
if (hash_equals($computed, $hash)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hash_equals($computed, $hash);
|
||||
// Try other key maps for rotation support
|
||||
foreach (array_keys(self::$keyMaps) as $keyId) {
|
||||
if ($keyId === self::$activeKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$computed = self::hash($input, $keyId);
|
||||
if ($hashLength < 64) {
|
||||
$computed = substr($computed, 0, $hashLength);
|
||||
}
|
||||
if (hash_equals($computed, $hash)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the key map character swapping.
|
||||
*
|
||||
* @param string $input The input string to transform
|
||||
* @param string $keyId Key map identifier
|
||||
*/
|
||||
protected static function applyKeyMap(string $input): string
|
||||
protected static function applyKeyMap(string $input, string $keyId): string
|
||||
{
|
||||
$keyMap = self::$keyMaps[$keyId] ?? self::$keyMaps[self::DEFAULT_KEY];
|
||||
$output = '';
|
||||
|
||||
for ($i = 0; $i < strlen($input); $i++) {
|
||||
$char = $input[$i];
|
||||
$output .= self::$keyMap[$char] ?? $char;
|
||||
$output .= $keyMap[$char] ?? $char;
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current key map.
|
||||
* Get the current active key map.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getKeyMap(): array
|
||||
{
|
||||
return self::$keyMap;
|
||||
return self::$keyMaps[self::$activeKey] ?? self::$keyMaps[self::DEFAULT_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom key map.
|
||||
* Get all registered key maps.
|
||||
*
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function getKeyMaps(): array
|
||||
{
|
||||
return self::$keyMaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom key map (replaces the active key map).
|
||||
*
|
||||
* @param array<string, string> $keyMap Character substitution map
|
||||
*/
|
||||
public static function setKeyMap(array $keyMap): void
|
||||
{
|
||||
self::$keyMap = $keyMap;
|
||||
self::$keyMaps[self::$activeKey] = $keyMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new key map for rotation.
|
||||
*
|
||||
* @param string $keyId Unique identifier for this key map
|
||||
* @param array<string, string> $keyMap Character substitution map
|
||||
* @param bool $setActive Whether to make this the active key for new hashes
|
||||
*/
|
||||
public static function addKeyMap(string $keyId, array $keyMap, bool $setActive = true): void
|
||||
{
|
||||
self::$keyMaps[$keyId] = $keyMap;
|
||||
|
||||
if ($setActive) {
|
||||
self::$activeKey = $keyId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a key map.
|
||||
*
|
||||
* Cannot remove the default key map or the currently active key map.
|
||||
*
|
||||
* @param string $keyId Key map identifier to remove
|
||||
*
|
||||
* @throws \InvalidArgumentException If attempting to remove default or active key
|
||||
*/
|
||||
public static function removeKeyMap(string $keyId): void
|
||||
{
|
||||
if ($keyId === self::DEFAULT_KEY) {
|
||||
throw new \InvalidArgumentException('Cannot remove the default key map');
|
||||
}
|
||||
|
||||
if ($keyId === self::$activeKey) {
|
||||
throw new \InvalidArgumentException('Cannot remove the active key map. Set a different active key first.');
|
||||
}
|
||||
|
||||
unset(self::$keyMaps[$keyId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active key map identifier.
|
||||
*/
|
||||
public static function getActiveKey(): string
|
||||
{
|
||||
return self::$activeKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active key map for generating new hashes.
|
||||
*
|
||||
* @param string $keyId Key map identifier (must already be registered)
|
||||
*
|
||||
* @throws \InvalidArgumentException If key map does not exist
|
||||
*/
|
||||
public static function setActiveKey(string $keyId): void
|
||||
{
|
||||
if (! isset(self::$keyMaps[$keyId])) {
|
||||
throw new \InvalidArgumentException("Key map '{$keyId}' does not exist");
|
||||
}
|
||||
|
||||
self::$activeKey = $keyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default state.
|
||||
*
|
||||
* Removes all custom key maps and resets to the default key map.
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$keyMaps = [
|
||||
self::DEFAULT_KEY => [
|
||||
'a' => '7', 'b' => 'x', 'c' => '3', 'd' => 'w',
|
||||
'e' => '3', 'f' => 'v', 'g' => '2', 'h' => 'u',
|
||||
'i' => '8', 'j' => 't', 'k' => '1', 'l' => 's',
|
||||
'm' => '6', 'n' => 'r', 'o' => '4', 'p' => 'q',
|
||||
'0' => 'z', '5' => 'y',
|
||||
's' => 'z', 't' => '7',
|
||||
],
|
||||
];
|
||||
self::$activeKey = self::DEFAULT_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
229
packages/core-php/src/Core/Events/EventAuditLog.php
Normal file
229
packages/core-php/src/Core/Events/EventAuditLog.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Events;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Tracks lifecycle event execution for debugging and monitoring.
|
||||
*
|
||||
* Records when events fire and which handlers respond. This is useful for:
|
||||
* - Debugging module loading issues
|
||||
* - Performance monitoring
|
||||
* - Understanding application bootstrap flow
|
||||
*
|
||||
* Usage:
|
||||
* EventAuditLog::enable(); // Enable logging
|
||||
* EventAuditLog::enableLog(); // Also write to Laravel log
|
||||
* // ... events fire ...
|
||||
* $entries = EventAuditLog::entries(); // Get recorded entries
|
||||
*/
|
||||
class EventAuditLog
|
||||
{
|
||||
private static bool $enabled = false;
|
||||
|
||||
private static bool $logEnabled = false;
|
||||
|
||||
/** @var array<int, array{event: string, handler: string, duration_ms: float, failed: bool, error?: string, timestamp: float}> */
|
||||
private static array $entries = [];
|
||||
|
||||
/** @var array<string, float> */
|
||||
private static array $pendingEvents = [];
|
||||
|
||||
/**
|
||||
* Enable audit logging.
|
||||
*/
|
||||
public static function enable(): void
|
||||
{
|
||||
self::$enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable audit logging.
|
||||
*/
|
||||
public static function disable(): void
|
||||
{
|
||||
self::$enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if audit logging is enabled.
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return self::$enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable writing audit entries to Laravel log.
|
||||
*/
|
||||
public static function enableLog(): void
|
||||
{
|
||||
self::$logEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable writing audit entries to Laravel log.
|
||||
*/
|
||||
public static function disableLog(): void
|
||||
{
|
||||
self::$logEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the start of event handling.
|
||||
*/
|
||||
public static function recordStart(string $eventClass, string $handlerClass): void
|
||||
{
|
||||
if (! self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = "{$eventClass}:{$handlerClass}";
|
||||
self::$pendingEvents[$key] = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record successful completion of event handling.
|
||||
*/
|
||||
public static function recordSuccess(string $eventClass, string $handlerClass): void
|
||||
{
|
||||
if (! self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = "{$eventClass}:{$handlerClass}";
|
||||
$startTime = self::$pendingEvents[$key] ?? microtime(true);
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
unset(self::$pendingEvents[$key]);
|
||||
|
||||
$entry = [
|
||||
'event' => $eventClass,
|
||||
'handler' => $handlerClass,
|
||||
'duration_ms' => round($duration, 2),
|
||||
'failed' => false,
|
||||
'timestamp' => microtime(true),
|
||||
];
|
||||
|
||||
self::$entries[] = $entry;
|
||||
|
||||
if (self::$logEnabled) {
|
||||
Log::debug('Lifecycle event handled', $entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed event handler.
|
||||
*/
|
||||
public static function recordFailure(string $eventClass, string $handlerClass, \Throwable $error): void
|
||||
{
|
||||
if (! self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = "{$eventClass}:{$handlerClass}";
|
||||
$startTime = self::$pendingEvents[$key] ?? microtime(true);
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
unset(self::$pendingEvents[$key]);
|
||||
|
||||
$entry = [
|
||||
'event' => $eventClass,
|
||||
'handler' => $handlerClass,
|
||||
'duration_ms' => round($duration, 2),
|
||||
'failed' => true,
|
||||
'error' => $error->getMessage(),
|
||||
'timestamp' => microtime(true),
|
||||
];
|
||||
|
||||
self::$entries[] = $entry;
|
||||
|
||||
if (self::$logEnabled) {
|
||||
Log::warning('Lifecycle event handler failed', $entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded entries.
|
||||
*
|
||||
* @return array<int, array{event: string, handler: string, duration_ms: float, failed: bool, error?: string, timestamp: float}>
|
||||
*/
|
||||
public static function entries(): array
|
||||
{
|
||||
return self::$entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries for a specific event class.
|
||||
*
|
||||
* @return array<int, array{event: string, handler: string, duration_ms: float, failed: bool, error?: string, timestamp: float}>
|
||||
*/
|
||||
public static function entriesFor(string $eventClass): array
|
||||
{
|
||||
return array_values(
|
||||
array_filter(self::$entries, fn ($entry) => $entry['event'] === $eventClass)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only failed entries.
|
||||
*
|
||||
* @return array<int, array{event: string, handler: string, duration_ms: float, failed: bool, error: string, timestamp: float}>
|
||||
*/
|
||||
public static function failures(): array
|
||||
{
|
||||
return array_values(
|
||||
array_filter(self::$entries, fn ($entry) => $entry['failed'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics.
|
||||
*
|
||||
* @return array{total: int, failed: int, total_duration_ms: float, events: array<string, int>}
|
||||
*/
|
||||
public static function summary(): array
|
||||
{
|
||||
$eventCounts = [];
|
||||
$totalDuration = 0.0;
|
||||
$failedCount = 0;
|
||||
|
||||
foreach (self::$entries as $entry) {
|
||||
$eventCounts[$entry['event']] = ($eventCounts[$entry['event']] ?? 0) + 1;
|
||||
$totalDuration += $entry['duration_ms'];
|
||||
|
||||
if ($entry['failed']) {
|
||||
$failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count(self::$entries),
|
||||
'failed' => $failedCount,
|
||||
'total_duration_ms' => round($totalDuration, 2),
|
||||
'events' => $eventCounts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all recorded entries.
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$entries = [];
|
||||
self::$pendingEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to initial state (disable and clear).
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$enabled = false;
|
||||
self::$logEnabled = false;
|
||||
self::clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,17 +5,31 @@ declare(strict_types=1);
|
|||
namespace Core\Front\Admin;
|
||||
|
||||
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||
use Core\Front\Admin\Contracts\DynamicMenuProvider;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Registry for admin menu items.
|
||||
*
|
||||
* Modules register themselves during boot. The registry builds the complete
|
||||
* menu structure at render time, handling entitlement checks and sorting.
|
||||
* menu structure at render time, handling entitlement checks, permission
|
||||
* checks, caching, and sorting.
|
||||
*/
|
||||
class AdminMenuRegistry
|
||||
{
|
||||
/**
|
||||
* Cache key prefix for menu items.
|
||||
*/
|
||||
protected const CACHE_PREFIX = 'admin_menu';
|
||||
|
||||
/**
|
||||
* Default cache TTL in seconds (5 minutes).
|
||||
*/
|
||||
protected const DEFAULT_CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Registered menu providers.
|
||||
*
|
||||
|
|
@ -23,6 +37,13 @@ class AdminMenuRegistry
|
|||
*/
|
||||
protected array $providers = [];
|
||||
|
||||
/**
|
||||
* Registered dynamic menu providers.
|
||||
*
|
||||
* @var array<DynamicMenuProvider>
|
||||
*/
|
||||
protected array $dynamicProviders = [];
|
||||
|
||||
/**
|
||||
* Pre-defined menu groups with metadata.
|
||||
*
|
||||
|
|
@ -55,9 +76,22 @@ class AdminMenuRegistry
|
|||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether caching is enabled.
|
||||
*/
|
||||
protected bool $cachingEnabled = true;
|
||||
|
||||
/**
|
||||
* Cache TTL in seconds.
|
||||
*/
|
||||
protected int $cacheTtl;
|
||||
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlements,
|
||||
) {}
|
||||
) {
|
||||
$this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL);
|
||||
$this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a menu provider.
|
||||
|
|
@ -65,6 +99,35 @@ class AdminMenuRegistry
|
|||
public function register(AdminMenuProvider $provider): void
|
||||
{
|
||||
$this->providers[] = $provider;
|
||||
|
||||
// Also register as dynamic provider if it implements the interface
|
||||
if ($provider instanceof DynamicMenuProvider) {
|
||||
$this->dynamicProviders[] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dynamic menu provider.
|
||||
*/
|
||||
public function registerDynamic(DynamicMenuProvider $provider): void
|
||||
{
|
||||
$this->dynamicProviders[] = $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable caching.
|
||||
*/
|
||||
public function setCachingEnabled(bool $enabled): void
|
||||
{
|
||||
$this->cachingEnabled = $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache TTL in seconds.
|
||||
*/
|
||||
public function setCacheTtl(int $seconds): void
|
||||
{
|
||||
$this->cacheTtl = $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,13 +135,117 @@ class AdminMenuRegistry
|
|||
*
|
||||
* @param Workspace|null $workspace Current workspace for entitlement checks
|
||||
* @param bool $isAdmin Whether user is admin (Hades)
|
||||
* @param User|null $user The authenticated user for permission checks
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function build(?Workspace $workspace, bool $isAdmin = false): array
|
||||
public function build(?Workspace $workspace, bool $isAdmin = false, ?User $user = null): array
|
||||
{
|
||||
// Collect all items from all providers
|
||||
$allItems = $this->collectItems($workspace, $isAdmin);
|
||||
// Get static items (potentially cached)
|
||||
$staticItems = $this->getStaticItems($workspace, $isAdmin, $user);
|
||||
|
||||
// Get dynamic items (never cached)
|
||||
$dynamicItems = $this->getDynamicItems($workspace, $isAdmin, $user);
|
||||
|
||||
// Merge static and dynamic items
|
||||
$allItems = $this->mergeItems($staticItems, $dynamicItems);
|
||||
|
||||
// Build the menu structure
|
||||
return $this->buildMenuStructure($allItems, $workspace, $isAdmin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get static menu items, using cache if enabled.
|
||||
*
|
||||
* @return array<string, array<int, array{priority: int, item: \Closure}>>
|
||||
*/
|
||||
protected function getStaticItems(?Workspace $workspace, bool $isAdmin, ?User $user): array
|
||||
{
|
||||
if (! $this->cachingEnabled) {
|
||||
return $this->collectItems($workspace, $isAdmin, $user);
|
||||
}
|
||||
|
||||
$cacheKey = $this->buildCacheKey($workspace, $isAdmin, $user);
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($workspace, $isAdmin, $user) {
|
||||
return $this->collectItems($workspace, $isAdmin, $user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dynamic menu items from dynamic providers.
|
||||
*
|
||||
* @return array<string, array<int, array{priority: int, item: \Closure}>>
|
||||
*/
|
||||
protected function getDynamicItems(?Workspace $workspace, bool $isAdmin, ?User $user): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->dynamicProviders as $provider) {
|
||||
$items = $provider->dynamicMenuItems($user, $workspace, $isAdmin);
|
||||
|
||||
foreach ($items as $registration) {
|
||||
$group = $registration['group'] ?? 'services';
|
||||
$entitlement = $registration['entitlement'] ?? null;
|
||||
$requiresAdmin = $registration['admin'] ?? false;
|
||||
$permissions = $registration['permissions'] ?? [];
|
||||
|
||||
// Skip if requires admin and user isn't admin
|
||||
if ($requiresAdmin && ! $isAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if entitlement check fails
|
||||
if ($entitlement && $workspace) {
|
||||
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no workspace and entitlement required
|
||||
if ($entitlement && ! $workspace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if permission check fails
|
||||
if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$grouped[$group][] = [
|
||||
'priority' => $registration['priority'] ?? 50,
|
||||
'item' => $registration['item'],
|
||||
'dynamic' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge static and dynamic items.
|
||||
*
|
||||
* @param array<string, array> $static
|
||||
* @param array<string, array> $dynamic
|
||||
* @return array<string, array>
|
||||
*/
|
||||
protected function mergeItems(array $static, array $dynamic): array
|
||||
{
|
||||
foreach ($dynamic as $group => $items) {
|
||||
if (! isset($static[$group])) {
|
||||
$static[$group] = [];
|
||||
}
|
||||
$static[$group] = array_merge($static[$group], $items);
|
||||
}
|
||||
|
||||
return $static;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final menu structure from collected items.
|
||||
*/
|
||||
protected function buildMenuStructure(array $allItems, ?Workspace $workspace, bool $isAdmin): array
|
||||
{
|
||||
// Build flat structure with dividers
|
||||
$menu = [];
|
||||
$firstGroup = true;
|
||||
|
|
@ -169,19 +336,48 @@ class AdminMenuRegistry
|
|||
}
|
||||
|
||||
/**
|
||||
* Collect items from all providers, filtering by entitlements.
|
||||
* Build the cache key for menu items.
|
||||
*/
|
||||
protected function buildCacheKey(?Workspace $workspace, bool $isAdmin, ?User $user): string
|
||||
{
|
||||
$parts = [
|
||||
self::CACHE_PREFIX,
|
||||
'w' . ($workspace?->id ?? 'null'),
|
||||
'a' . ($isAdmin ? '1' : '0'),
|
||||
'u' . ($user?->id ?? 'null'),
|
||||
];
|
||||
|
||||
// Add dynamic cache key modifiers
|
||||
foreach ($this->dynamicProviders as $provider) {
|
||||
$dynamicKey = $provider->dynamicCacheKey($user, $workspace);
|
||||
if ($dynamicKey !== null) {
|
||||
$parts[] = md5($dynamicKey);
|
||||
}
|
||||
}
|
||||
|
||||
return implode(':', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect items from all providers, filtering by entitlements and permissions.
|
||||
*
|
||||
* @return array<string, array<int, array{priority: int, item: \Closure}>>
|
||||
*/
|
||||
protected function collectItems(?Workspace $workspace, bool $isAdmin): array
|
||||
protected function collectItems(?Workspace $workspace, bool $isAdmin, ?User $user): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->providers as $provider) {
|
||||
// Check provider-level permissions first
|
||||
if (! $provider->canViewMenu($user, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($provider->adminMenuItems() as $registration) {
|
||||
$group = $registration['group'] ?? 'services';
|
||||
$entitlement = $registration['entitlement'] ?? null;
|
||||
$requiresAdmin = $registration['admin'] ?? false;
|
||||
$permissions = $registration['permissions'] ?? [];
|
||||
|
||||
// Skip if requires admin and user isn't admin
|
||||
if ($requiresAdmin && ! $isAdmin) {
|
||||
|
|
@ -200,6 +396,11 @@ class AdminMenuRegistry
|
|||
continue;
|
||||
}
|
||||
|
||||
// Skip if item-level permission check fails
|
||||
if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$grouped[$group][] = [
|
||||
'priority' => $registration['priority'] ?? 50,
|
||||
'item' => $registration['item'],
|
||||
|
|
@ -210,6 +411,78 @@ class AdminMenuRegistry
|
|||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has all required permissions.
|
||||
*
|
||||
* @param User|null $user
|
||||
* @param array<string> $permissions
|
||||
* @param Workspace|null $workspace
|
||||
* @return bool
|
||||
*/
|
||||
protected function checkPermissions(?User $user, array $permissions, ?Workspace $workspace): bool
|
||||
{
|
||||
if (empty($permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
// Check using Laravel's authorization
|
||||
if (method_exists($user, 'can') && ! $user->can($permission, $workspace)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached menu for a specific context.
|
||||
*
|
||||
* @param Workspace|null $workspace
|
||||
* @param User|null $user
|
||||
*/
|
||||
public function invalidateCache(?Workspace $workspace = null, ?User $user = null): void
|
||||
{
|
||||
if ($workspace !== null && $user !== null) {
|
||||
// Invalidate specific cache keys
|
||||
foreach ([true, false] as $isAdmin) {
|
||||
$cacheKey = $this->buildCacheKey($workspace, $isAdmin, $user);
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
} else {
|
||||
// Flush all admin menu caches using tags if available
|
||||
if (method_exists(Cache::getStore(), 'tags')) {
|
||||
Cache::tags([self::CACHE_PREFIX])->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached menus for a workspace.
|
||||
*/
|
||||
public function invalidateWorkspaceCache(Workspace $workspace): void
|
||||
{
|
||||
// We can't easily clear pattern-based cache keys with all drivers,
|
||||
// so we rely on TTL expiration for non-tagged caches
|
||||
if (method_exists(Cache::getStore(), 'tags')) {
|
||||
Cache::tags([self::CACHE_PREFIX, 'workspace:' . $workspace->id])->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached menus for a user.
|
||||
*/
|
||||
public function invalidateUserCache(User $user): void
|
||||
{
|
||||
if (method_exists(Cache::getStore(), 'tags')) {
|
||||
Cache::tags([self::CACHE_PREFIX, 'user:' . $user->id])->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available group keys.
|
||||
*
|
||||
|
|
@ -235,13 +508,19 @@ class AdminMenuRegistry
|
|||
*
|
||||
* @param Workspace|null $workspace Current workspace for entitlement checks
|
||||
* @param bool $isAdmin Whether user is admin (Hades)
|
||||
* @param User|null $user The authenticated user for permission checks
|
||||
* @return array<string, array> Service items indexed by service key
|
||||
*/
|
||||
public function getAllServiceItems(?Workspace $workspace, bool $isAdmin = false): array
|
||||
public function getAllServiceItems(?Workspace $workspace, bool $isAdmin = false, ?User $user = null): array
|
||||
{
|
||||
$services = [];
|
||||
|
||||
foreach ($this->providers as $provider) {
|
||||
// Check provider-level permissions
|
||||
if (! $provider->canViewMenu($user, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($provider->adminMenuItems() as $registration) {
|
||||
if (($registration['group'] ?? 'services') !== 'services') {
|
||||
continue;
|
||||
|
|
@ -254,6 +533,7 @@ class AdminMenuRegistry
|
|||
|
||||
$entitlement = $registration['entitlement'] ?? null;
|
||||
$requiresAdmin = $registration['admin'] ?? false;
|
||||
$permissions = $registration['permissions'] ?? [];
|
||||
|
||||
// Skip if requires admin and user isn't admin
|
||||
if ($requiresAdmin && ! $isAdmin) {
|
||||
|
|
@ -267,6 +547,11 @@ class AdminMenuRegistry
|
|||
}
|
||||
}
|
||||
|
||||
// Skip if permission check fails
|
||||
if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Evaluate the closure and store by service key
|
||||
$item = ($registration['item'])();
|
||||
if ($item) {
|
||||
|
|
@ -289,11 +574,17 @@ class AdminMenuRegistry
|
|||
* @param string $serviceKey The service identifier (e.g., 'commerce', 'support')
|
||||
* @param Workspace|null $workspace Current workspace for entitlement checks
|
||||
* @param bool $isAdmin Whether user is admin (Hades)
|
||||
* @param User|null $user The authenticated user for permission checks
|
||||
* @return array|null The service menu item with children, or null if not found
|
||||
*/
|
||||
public function getServiceItem(string $serviceKey, ?Workspace $workspace, bool $isAdmin = false): ?array
|
||||
public function getServiceItem(string $serviceKey, ?Workspace $workspace, bool $isAdmin = false, ?User $user = null): ?array
|
||||
{
|
||||
foreach ($this->providers as $provider) {
|
||||
// Check provider-level permissions
|
||||
if (! $provider->canViewMenu($user, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($provider->adminMenuItems() as $registration) {
|
||||
// Only check services group items with matching service key
|
||||
if (($registration['group'] ?? 'services') !== 'services') {
|
||||
|
|
@ -306,6 +597,7 @@ class AdminMenuRegistry
|
|||
|
||||
$entitlement = $registration['entitlement'] ?? null;
|
||||
$requiresAdmin = $registration['admin'] ?? false;
|
||||
$permissions = $registration['permissions'] ?? [];
|
||||
|
||||
// Skip if requires admin and user isn't admin
|
||||
if ($requiresAdmin && ! $isAdmin) {
|
||||
|
|
@ -319,6 +611,11 @@ class AdminMenuRegistry
|
|||
}
|
||||
}
|
||||
|
||||
// Skip if permission check fails
|
||||
if (! empty($permissions) && ! $this->checkPermissions($user, $permissions, $workspace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Evaluate the closure and return the item
|
||||
$item = ($registration['item'])();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Front\Admin\Concerns;
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Provides default permission handling for AdminMenuProvider implementations.
|
||||
*
|
||||
* Include this trait in classes that implement AdminMenuProvider to get
|
||||
* sensible default behaviour for permission checks. Override methods
|
||||
* as needed for custom permission logic.
|
||||
*/
|
||||
trait HasMenuPermissions
|
||||
{
|
||||
/**
|
||||
* Get the permissions required to view any menu items from this provider.
|
||||
*
|
||||
* Override this method to specify required permissions.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function menuPermissions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has permission to view menu items from this provider.
|
||||
*
|
||||
* By default, checks that the user has all permissions returned by
|
||||
* menuPermissions(). Override for custom logic.
|
||||
*
|
||||
* @param User|null $user The authenticated user
|
||||
* @param Workspace|null $workspace The current workspace context
|
||||
* @return bool
|
||||
*/
|
||||
public function canViewMenu(?User $user, ?Workspace $workspace): bool
|
||||
{
|
||||
// No user means no permission (unless we have no requirements)
|
||||
$permissions = $this->menuPermissions();
|
||||
|
||||
if (empty($permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each required permission
|
||||
foreach ($permissions as $permission) {
|
||||
if (! $this->userHasPermission($user, $permission, $workspace)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific permission.
|
||||
*
|
||||
* Override this method to customise how permission checks are performed.
|
||||
* By default, uses Laravel's Gate/Authorization system.
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $permission
|
||||
* @param Workspace|null $workspace
|
||||
* @return bool
|
||||
*/
|
||||
protected function userHasPermission(User $user, string $permission, ?Workspace $workspace): bool
|
||||
{
|
||||
// Check using Laravel's authorization
|
||||
if (method_exists($user, 'can')) {
|
||||
return $user->can($permission, $workspace);
|
||||
}
|
||||
|
||||
// Fallback: check for hasPermission method (common in permission packages)
|
||||
if (method_exists($user, 'hasPermission')) {
|
||||
return $user->hasPermission($permission);
|
||||
}
|
||||
|
||||
// Fallback: check for hasPermissionTo method (Spatie Permission)
|
||||
if (method_exists($user, 'hasPermissionTo')) {
|
||||
return $user->hasPermissionTo($permission);
|
||||
}
|
||||
|
||||
// No permission system found, allow by default
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Front\Admin\Contracts;
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Interface for modules that provide admin menu items.
|
||||
*
|
||||
|
|
@ -19,6 +22,7 @@ interface AdminMenuProvider
|
|||
* - group: string (dashboard|webhost|services|settings|admin)
|
||||
* - priority: int (lower = earlier in group)
|
||||
* - entitlement: string|null (feature code for access check)
|
||||
* - permissions: array|null (required user permissions)
|
||||
* - admin: bool (requires Hades/admin user)
|
||||
* - item: Closure (lazy-evaluated menu item data)
|
||||
*
|
||||
|
|
@ -29,6 +33,7 @@ interface AdminMenuProvider
|
|||
* 'group' => 'services',
|
||||
* 'priority' => 20,
|
||||
* 'entitlement' => 'core.srv.bio',
|
||||
* 'permissions' => ['bio.view', 'bio.manage'],
|
||||
* 'item' => fn() => [
|
||||
* 'label' => 'BioHost',
|
||||
* 'icon' => 'link',
|
||||
|
|
@ -44,9 +49,35 @@ interface AdminMenuProvider
|
|||
* group: string,
|
||||
* priority: int,
|
||||
* entitlement?: string|null,
|
||||
* permissions?: array<string>|null,
|
||||
* admin?: bool,
|
||||
* item: \Closure
|
||||
* }>
|
||||
*/
|
||||
public function adminMenuItems(): array;
|
||||
|
||||
/**
|
||||
* Get the permissions required to view any menu items from this provider.
|
||||
*
|
||||
* This provides a way to define global permission requirements for all
|
||||
* menu items from this provider. Individual items can override with their
|
||||
* own 'permissions' key in adminMenuItems().
|
||||
*
|
||||
* Return an empty array if no global permissions are required.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function menuPermissions(): array;
|
||||
|
||||
/**
|
||||
* Check if the user has permission to view menu items from this provider.
|
||||
*
|
||||
* Override this method to implement custom permission logic beyond
|
||||
* simple permission key checks.
|
||||
*
|
||||
* @param User|null $user The authenticated user
|
||||
* @param Workspace|null $workspace The current workspace context
|
||||
* @return bool
|
||||
*/
|
||||
public function canViewMenu(?User $user, ?Workspace $workspace): bool;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Front\Admin\Contracts;
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Interface for providers that supply dynamic menu items.
|
||||
*
|
||||
* Dynamic menu items are computed at runtime based on context (user, workspace,
|
||||
* database state, etc.) and are never cached. Use this interface when menu items
|
||||
* need to reflect real-time data such as notification counts, recent items, or
|
||||
* user-specific content.
|
||||
*
|
||||
* Classes implementing this interface are processed separately from static
|
||||
* AdminMenuProvider items - their results are merged after cache retrieval.
|
||||
*/
|
||||
interface DynamicMenuProvider
|
||||
{
|
||||
/**
|
||||
* Get dynamic menu items that should not be cached.
|
||||
*
|
||||
* Called on every request to retrieve menu items that depend on
|
||||
* real-time data. Keep this method efficient as it runs uncached.
|
||||
*
|
||||
* Each item should include the same structure as AdminMenuProvider::adminMenuItems()
|
||||
* plus an optional 'dynamic' key set to true for identification.
|
||||
*
|
||||
* @param User|null $user The authenticated user
|
||||
* @param Workspace|null $workspace The current workspace context
|
||||
* @param bool $isAdmin Whether the user is an admin
|
||||
* @return array<int, array{
|
||||
* group: string,
|
||||
* priority: int,
|
||||
* entitlement?: string|null,
|
||||
* permissions?: array<string>|null,
|
||||
* admin?: bool,
|
||||
* dynamic?: bool,
|
||||
* item: \Closure
|
||||
* }>
|
||||
*/
|
||||
public function dynamicMenuItems(?User $user, ?Workspace $workspace, bool $isAdmin): array;
|
||||
|
||||
/**
|
||||
* Get the cache key modifier for dynamic items.
|
||||
*
|
||||
* Dynamic items from this provider will invalidate menu cache when
|
||||
* this key changes. Return null if dynamic items should never affect
|
||||
* cache invalidation.
|
||||
*
|
||||
* @param User|null $user
|
||||
* @param Workspace|null $workspace
|
||||
* @return string|null
|
||||
*/
|
||||
public function dynamicCacheKey(?User $user, ?Workspace $workspace): ?string;
|
||||
}
|
||||
|
|
@ -9,9 +9,10 @@ use Illuminate\Support\ServiceProvider;
|
|||
/**
|
||||
* Headers Module Service Provider.
|
||||
*
|
||||
* Provides HTTP header parsing functionality:
|
||||
* Provides HTTP header parsing and security header functionality:
|
||||
* - Device detection (User-Agent parsing)
|
||||
* - GeoIP lookups (from headers or database)
|
||||
* - Configurable security headers (CSP, Permissions-Policy, etc.)
|
||||
*/
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
|
|
@ -20,6 +21,8 @@ class Boot extends ServiceProvider
|
|||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__.'/config.php', 'headers');
|
||||
|
||||
$this->app->singleton(DetectDevice::class);
|
||||
$this->app->singleton(DetectLocation::class);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,59 +29,314 @@ class SecurityHeaders
|
|||
{
|
||||
$response = $next($request);
|
||||
|
||||
// Strict Transport Security - enforce HTTPS for 1 year
|
||||
// Only add in production to avoid issues with local development
|
||||
if (app()->environment('production')) {
|
||||
$response->headers->set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains; preload'
|
||||
);
|
||||
if (! config('headers.enabled', true)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Get domain from config
|
||||
$baseDomain = config('core.domain.base', 'core.test');
|
||||
$cdnSubdomain = config('core.cdn.subdomain', 'cdn');
|
||||
|
||||
// Content Security Policy - restrict resource loading
|
||||
$connectSrc = "'self' https://*.{$baseDomain} wss://*.{$baseDomain} wss://{$baseDomain}:8080 https://raw.githubusercontent.com";
|
||||
|
||||
// Allow localhost WebSocket in development
|
||||
if (! app()->environment('production')) {
|
||||
$connectSrc .= ' wss://localhost:8080 ws://localhost:8080 wss://127.0.0.1:8080 ws://127.0.0.1:8080';
|
||||
}
|
||||
|
||||
$csp = implode('; ', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://{$cdnSubdomain}.{$baseDomain} https://cdn.jsdelivr.net https://unpkg.com https://www.googletagmanager.com https://connect.facebook.net",
|
||||
"style-src 'self' 'unsafe-inline' https://{$cdnSubdomain}.{$baseDomain} https://fonts.bunny.net https://fonts.googleapis.com https://unpkg.com",
|
||||
"img-src 'self' data: https: blob:",
|
||||
"font-src 'self' https://{$cdnSubdomain}.{$baseDomain} https://fonts.bunny.net https://fonts.gstatic.com data:",
|
||||
"connect-src {$connectSrc}",
|
||||
"frame-src 'self' https://www.youtube.com https://player.vimeo.com",
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
]);
|
||||
$response->headers->set('Content-Security-Policy', $csp);
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Prevent clickjacking
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
||||
|
||||
// Enable XSS filtering in browsers that support it
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Control referrer information sent with requests
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Restrict browser features
|
||||
$response->headers->set(
|
||||
'Permissions-Policy',
|
||||
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
|
||||
);
|
||||
$this->addHstsHeader($response);
|
||||
$this->addCspHeader($response);
|
||||
$this->addPermissionsPolicyHeader($response);
|
||||
$this->addStandardSecurityHeaders($response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Strict-Transport-Security header.
|
||||
*/
|
||||
protected function addHstsHeader(Response $response): void
|
||||
{
|
||||
$config = config('headers.hsts', []);
|
||||
|
||||
if (! ($config['enabled'] ?? true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add HSTS in production to avoid issues with local development
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$maxAge = $config['max_age'] ?? 31536000;
|
||||
$value = "max-age={$maxAge}";
|
||||
|
||||
if ($config['include_subdomains'] ?? true) {
|
||||
$value .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
if ($config['preload'] ?? true) {
|
||||
$value .= '; preload';
|
||||
}
|
||||
|
||||
$response->headers->set('Strict-Transport-Security', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Content-Security-Policy header.
|
||||
*/
|
||||
protected function addCspHeader(Response $response): void
|
||||
{
|
||||
$config = config('headers.csp', []);
|
||||
|
||||
if (! ($config['enabled'] ?? true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directives = $this->buildCspDirectives($config);
|
||||
$cspValue = $this->formatCspDirectives($directives);
|
||||
|
||||
$headerName = ($config['report_only'] ?? false)
|
||||
? 'Content-Security-Policy-Report-Only'
|
||||
: 'Content-Security-Policy';
|
||||
|
||||
$response->headers->set($headerName, $cspValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSP directives from configuration.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function buildCspDirectives(array $config): array
|
||||
{
|
||||
$directives = $config['directives'] ?? $this->getDefaultCspDirectives();
|
||||
|
||||
// Apply environment-specific overrides
|
||||
$directives = $this->applyEnvironmentOverrides($directives, $config);
|
||||
|
||||
// Add CDN subdomain sources
|
||||
$directives = $this->addCdnSources($directives, $config);
|
||||
|
||||
// Add external service sources
|
||||
$directives = $this->addExternalSources($directives, $config);
|
||||
|
||||
// Add WebSocket sources for development
|
||||
$directives = $this->addDevelopmentWebsocketSources($directives);
|
||||
|
||||
// Add report-uri if configured
|
||||
if (! empty($config['report_uri'])) {
|
||||
$directives['report-uri'] = [$config['report_uri']];
|
||||
}
|
||||
|
||||
return $directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default CSP directives.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function getDefaultCspDirectives(): array
|
||||
{
|
||||
return [
|
||||
'default-src' => ["'self'"],
|
||||
'script-src' => ["'self'"],
|
||||
'style-src' => ["'self'", 'https://fonts.bunny.net', 'https://fonts.googleapis.com'],
|
||||
'img-src' => ["'self'", 'data:', 'https:', 'blob:'],
|
||||
'font-src' => ["'self'", 'https://fonts.bunny.net', 'https://fonts.gstatic.com', 'data:'],
|
||||
'connect-src' => ["'self'"],
|
||||
'frame-src' => ["'self'", 'https://www.youtube.com', 'https://player.vimeo.com'],
|
||||
'frame-ancestors' => ["'self'"],
|
||||
'base-uri' => ["'self'"],
|
||||
'form-action' => ["'self'"],
|
||||
'object-src' => ["'none'"],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply environment-specific CSP overrides.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function applyEnvironmentOverrides(array $directives, array $config): array
|
||||
{
|
||||
$environment = app()->environment();
|
||||
$envOverrides = $config['environment'][$environment] ?? [];
|
||||
|
||||
foreach ($envOverrides as $directive => $sources) {
|
||||
if (isset($directives[$directive])) {
|
||||
$directives[$directive] = array_unique(array_merge($directives[$directive], $sources));
|
||||
} else {
|
||||
$directives[$directive] = $sources;
|
||||
}
|
||||
}
|
||||
|
||||
return $directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CDN subdomain to relevant directives.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function addCdnSources(array $directives, array $config): array
|
||||
{
|
||||
$baseDomain = config('core.domain.base', 'core.test');
|
||||
$cdnSubdomain = config('core.cdn.subdomain', 'cdn');
|
||||
$cdnUrl = "https://{$cdnSubdomain}.{$baseDomain}";
|
||||
|
||||
$cdnConfig = $config['external']['cdn'] ?? [];
|
||||
|
||||
foreach ($cdnConfig as $directive => $enabled) {
|
||||
if ($enabled && isset($directives[$directive])) {
|
||||
$directives[$directive][] = $cdnUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Add base domain for connect-src (WebSocket, API calls)
|
||||
if (isset($directives['connect-src'])) {
|
||||
$directives['connect-src'][] = "https://*.{$baseDomain}";
|
||||
$directives['connect-src'][] = "wss://*.{$baseDomain}";
|
||||
$directives['connect-src'][] = "wss://{$baseDomain}:8080";
|
||||
}
|
||||
|
||||
return $directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add external service sources based on configuration.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function addExternalSources(array $directives, array $config): array
|
||||
{
|
||||
$external = $config['external'] ?? [];
|
||||
|
||||
foreach ($external as $service => $serviceConfig) {
|
||||
// Skip CDN (handled separately) and disabled services
|
||||
if ($service === 'cdn') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! ($serviceConfig['enabled'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($serviceConfig as $directive => $sources) {
|
||||
if ($directive === 'enabled' || ! is_array($sources)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($directives[$directive])) {
|
||||
$directives[$directive] = array_merge($directives[$directive], $sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add WebSocket sources for development environments.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function addDevelopmentWebsocketSources(array $directives): array
|
||||
{
|
||||
if (app()->environment('production')) {
|
||||
return $directives;
|
||||
}
|
||||
|
||||
if (isset($directives['connect-src'])) {
|
||||
$directives['connect-src'] = array_merge($directives['connect-src'], [
|
||||
'wss://localhost:8080',
|
||||
'ws://localhost:8080',
|
||||
'wss://127.0.0.1:8080',
|
||||
'ws://127.0.0.1:8080',
|
||||
]);
|
||||
}
|
||||
|
||||
return $directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CSP directives into header value.
|
||||
*/
|
||||
protected function formatCspDirectives(array $directives): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($directives as $directive => $sources) {
|
||||
$uniqueSources = array_unique($sources);
|
||||
$parts[] = $directive.' '.implode(' ', $uniqueSources);
|
||||
}
|
||||
|
||||
return implode('; ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Permissions-Policy header.
|
||||
*/
|
||||
protected function addPermissionsPolicyHeader(Response $response): void
|
||||
{
|
||||
$config = config('headers.permissions', []);
|
||||
|
||||
if (! ($config['enabled'] ?? true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$features = $config['features'] ?? $this->getDefaultPermissionsPolicy();
|
||||
$parts = [];
|
||||
|
||||
foreach ($features as $feature => $allowList) {
|
||||
if (empty($allowList)) {
|
||||
$parts[] = "{$feature}=()";
|
||||
} else {
|
||||
$formatted = array_map(fn ($origin) => $origin === 'self' ? 'self' : "\"{$origin}\"", $allowList);
|
||||
$parts[] = "{$feature}=(".implode(' ', $formatted).')';
|
||||
}
|
||||
}
|
||||
|
||||
$response->headers->set('Permissions-Policy', implode(', ', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default Permissions-Policy features.
|
||||
*
|
||||
* @return array<string, array<string>>
|
||||
*/
|
||||
protected function getDefaultPermissionsPolicy(): array
|
||||
{
|
||||
return [
|
||||
'accelerometer' => [],
|
||||
'autoplay' => ['self'],
|
||||
'camera' => [],
|
||||
'encrypted-media' => ['self'],
|
||||
'fullscreen' => ['self'],
|
||||
'geolocation' => [],
|
||||
'gyroscope' => [],
|
||||
'magnetometer' => [],
|
||||
'microphone' => [],
|
||||
'payment' => [],
|
||||
'picture-in-picture' => ['self'],
|
||||
'sync-xhr' => ['self'],
|
||||
'usb' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add standard security headers.
|
||||
*/
|
||||
protected function addStandardSecurityHeaders(Response $response): void
|
||||
{
|
||||
$response->headers->set(
|
||||
'X-Content-Type-Options',
|
||||
config('headers.x_content_type_options', 'nosniff')
|
||||
);
|
||||
|
||||
$response->headers->set(
|
||||
'X-Frame-Options',
|
||||
config('headers.x_frame_options', 'SAMEORIGIN')
|
||||
);
|
||||
|
||||
$response->headers->set(
|
||||
'X-XSS-Protection',
|
||||
config('headers.x_xss_protection', '1; mode=block')
|
||||
);
|
||||
|
||||
$response->headers->set(
|
||||
'Referrer-Policy',
|
||||
config('headers.referrer_policy', 'strict-origin-when-cross-origin')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
228
packages/core-php/src/Core/Headers/config.php
Normal file
228
packages/core-php/src/Core/Headers/config.php
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Security Headers Configuration.
|
||||
*
|
||||
* Configure Content-Security-Policy, Permissions-Policy, and other
|
||||
* security headers. Environment-specific overrides are supported.
|
||||
*/
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enable Security Headers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Master switch to enable/disable all security headers.
|
||||
|
|
||||
*/
|
||||
|
||||
'enabled' => env('SECURITY_HEADERS_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Strict Transport Security (HSTS)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| HSTS enforces HTTPS connections. Only enabled in production by default.
|
||||
|
|
||||
*/
|
||||
|
||||
'hsts' => [
|
||||
'enabled' => env('SECURITY_HSTS_ENABLED', true),
|
||||
'max_age' => env('SECURITY_HSTS_MAX_AGE', 31536000), // 1 year
|
||||
'include_subdomains' => env('SECURITY_HSTS_INCLUDE_SUBDOMAINS', true),
|
||||
'preload' => env('SECURITY_HSTS_PRELOAD', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Content Security Policy (CSP)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| CSP controls which resources can be loaded. Configure directives below.
|
||||
| Set 'enabled' to false to disable CSP entirely.
|
||||
|
|
||||
| IMPORTANT: Avoid 'unsafe-inline' and 'unsafe-eval' in production.
|
||||
| Use nonces or hashes for inline scripts/styles instead.
|
||||
|
|
||||
*/
|
||||
|
||||
'csp' => [
|
||||
'enabled' => env('SECURITY_CSP_ENABLED', true),
|
||||
|
||||
// Report-Only mode (logs violations without blocking)
|
||||
'report_only' => env('SECURITY_CSP_REPORT_ONLY', false),
|
||||
|
||||
// Report URI for CSP violation reports
|
||||
'report_uri' => env('SECURITY_CSP_REPORT_URI'),
|
||||
|
||||
// CSP Directives
|
||||
'directives' => [
|
||||
'default-src' => ["'self'"],
|
||||
|
||||
// Script sources - avoid unsafe-inline/eval in production
|
||||
'script-src' => [
|
||||
"'self'",
|
||||
// Add 'unsafe-inline' only for development/legacy support
|
||||
// Production should use nonces instead
|
||||
],
|
||||
|
||||
// Style sources
|
||||
'style-src' => [
|
||||
"'self'",
|
||||
'https://fonts.bunny.net',
|
||||
'https://fonts.googleapis.com',
|
||||
],
|
||||
|
||||
// Image sources
|
||||
'img-src' => [
|
||||
"'self'",
|
||||
'data:',
|
||||
'https:',
|
||||
'blob:',
|
||||
],
|
||||
|
||||
// Font sources
|
||||
'font-src' => [
|
||||
"'self'",
|
||||
'https://fonts.bunny.net',
|
||||
'https://fonts.gstatic.com',
|
||||
'data:',
|
||||
],
|
||||
|
||||
// Connect sources (XHR, WebSocket, etc.)
|
||||
'connect-src' => [
|
||||
"'self'",
|
||||
],
|
||||
|
||||
// Frame sources (iframes)
|
||||
'frame-src' => [
|
||||
"'self'",
|
||||
'https://www.youtube.com',
|
||||
'https://player.vimeo.com',
|
||||
],
|
||||
|
||||
// Frame ancestors (who can embed this page)
|
||||
'frame-ancestors' => ["'self'"],
|
||||
|
||||
// Base URI restriction
|
||||
'base-uri' => ["'self'"],
|
||||
|
||||
// Form action restriction
|
||||
'form-action' => ["'self'"],
|
||||
|
||||
// Object sources (plugins, etc.)
|
||||
'object-src' => ["'none'"],
|
||||
],
|
||||
|
||||
// Additional sources per environment
|
||||
// These are merged with the base directives
|
||||
'environment' => [
|
||||
'local' => [
|
||||
'script-src' => ["'unsafe-inline'", "'unsafe-eval'"],
|
||||
'style-src' => ["'unsafe-inline'"],
|
||||
],
|
||||
'development' => [
|
||||
'script-src' => ["'unsafe-inline'", "'unsafe-eval'"],
|
||||
'style-src' => ["'unsafe-inline'"],
|
||||
],
|
||||
'staging' => [
|
||||
'script-src' => ["'unsafe-inline'"],
|
||||
'style-src' => ["'unsafe-inline'"],
|
||||
],
|
||||
'production' => [
|
||||
// Production should be strict - no unsafe-inline
|
||||
// Add nonce support or specific hashes as needed
|
||||
],
|
||||
],
|
||||
|
||||
// Additional external sources (CDN, analytics, etc.)
|
||||
// These are added to the appropriate directives based on config
|
||||
'external' => [
|
||||
// CDN subdomain (auto-populated from core.cdn.subdomain)
|
||||
'cdn' => [
|
||||
'script-src' => true,
|
||||
'style-src' => true,
|
||||
'font-src' => true,
|
||||
'img-src' => true,
|
||||
],
|
||||
|
||||
// Third-party services - enable as needed
|
||||
'jsdelivr' => [
|
||||
'enabled' => env('SECURITY_CSP_JSDELIVR', false),
|
||||
'script-src' => ['https://cdn.jsdelivr.net'],
|
||||
'style-src' => ['https://cdn.jsdelivr.net'],
|
||||
],
|
||||
|
||||
'unpkg' => [
|
||||
'enabled' => env('SECURITY_CSP_UNPKG', false),
|
||||
'script-src' => ['https://unpkg.com'],
|
||||
'style-src' => ['https://unpkg.com'],
|
||||
],
|
||||
|
||||
'google_analytics' => [
|
||||
'enabled' => env('SECURITY_CSP_GOOGLE_ANALYTICS', false),
|
||||
'script-src' => ['https://www.googletagmanager.com', 'https://www.google-analytics.com'],
|
||||
'connect-src' => ['https://www.google-analytics.com'],
|
||||
'img-src' => ['https://www.google-analytics.com'],
|
||||
],
|
||||
|
||||
'facebook' => [
|
||||
'enabled' => env('SECURITY_CSP_FACEBOOK', false),
|
||||
'script-src' => ['https://connect.facebook.net'],
|
||||
'frame-src' => ['https://www.facebook.com'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Permissions Policy (formerly Feature-Policy)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Controls browser features like camera, microphone, geolocation, etc.
|
||||
| Default is restrictive - enable features as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'permissions' => [
|
||||
'enabled' => env('SECURITY_PERMISSIONS_ENABLED', true),
|
||||
|
||||
// Feature permissions - empty () means disabled, (self) allows same-origin
|
||||
'features' => [
|
||||
'accelerometer' => [],
|
||||
'autoplay' => ['self'],
|
||||
'camera' => [],
|
||||
'cross-origin-isolated' => [],
|
||||
'display-capture' => [],
|
||||
'encrypted-media' => ['self'],
|
||||
'fullscreen' => ['self'],
|
||||
'geolocation' => [],
|
||||
'gyroscope' => [],
|
||||
'keyboard-map' => [],
|
||||
'magnetometer' => [],
|
||||
'microphone' => [],
|
||||
'midi' => [],
|
||||
'payment' => [],
|
||||
'picture-in-picture' => ['self'],
|
||||
'publickey-credentials-get' => [],
|
||||
'screen-wake-lock' => [],
|
||||
'sync-xhr' => ['self'],
|
||||
'usb' => [],
|
||||
'web-share' => ['self'],
|
||||
'xr-spatial-tracking' => [],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Other Security Headers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'x_frame_options' => env('SECURITY_X_FRAME_OPTIONS', 'SAMEORIGIN'),
|
||||
'x_content_type_options' => 'nosniff',
|
||||
'x_xss_protection' => '1; mode=block',
|
||||
'referrer_policy' => env('SECURITY_REFERRER_POLICY', 'strict-origin-when-cross-origin'),
|
||||
];
|
||||
|
|
@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Input;
|
||||
|
||||
use Normalizer;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Input sanitiser - makes data safe, not valid.
|
||||
*
|
||||
|
|
@ -11,14 +14,120 @@ namespace Core\Input;
|
|||
* One C call: filter_var_array.
|
||||
*
|
||||
* Laravel validates. We sanitise.
|
||||
*
|
||||
* Features:
|
||||
* - Configurable filter rules per field via schema
|
||||
* - Unicode NFC normalization for consistent string handling
|
||||
* - Optional audit logging when content is modified
|
||||
*/
|
||||
class Sanitiser
|
||||
{
|
||||
/**
|
||||
* Schema for per-field filter rules.
|
||||
*
|
||||
* Format: ['field_name' => ['filters' => [...], 'options' => [...]]]
|
||||
*
|
||||
* @var array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool}>
|
||||
*/
|
||||
protected array $schema = [];
|
||||
|
||||
/**
|
||||
* Optional logger for audit logging.
|
||||
*/
|
||||
protected ?LoggerInterface $logger = null;
|
||||
|
||||
/**
|
||||
* Whether to enable audit logging.
|
||||
*/
|
||||
protected bool $auditEnabled = false;
|
||||
|
||||
/**
|
||||
* Whether to normalize Unicode to NFC form.
|
||||
*/
|
||||
protected bool $normalizeUnicode = true;
|
||||
|
||||
/**
|
||||
* Create a new Sanitiser instance.
|
||||
*
|
||||
* @param array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool}> $schema Per-field filter rules
|
||||
* @param LoggerInterface|null $logger Optional PSR-3 logger for audit logging
|
||||
* @param bool $auditEnabled Whether to enable audit logging (requires logger)
|
||||
* @param bool $normalizeUnicode Whether to normalize Unicode to NFC form
|
||||
*/
|
||||
public function __construct(
|
||||
array $schema = [],
|
||||
?LoggerInterface $logger = null,
|
||||
bool $auditEnabled = false,
|
||||
bool $normalizeUnicode = true
|
||||
) {
|
||||
$this->schema = $schema;
|
||||
$this->logger = $logger;
|
||||
$this->auditEnabled = $auditEnabled && $logger !== null;
|
||||
$this->normalizeUnicode = $normalizeUnicode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the per-field filter schema.
|
||||
*
|
||||
* Schema format:
|
||||
* [
|
||||
* 'field_name' => [
|
||||
* 'filters' => [FILTER_SANITIZE_EMAIL, ...], // Additional filters to apply
|
||||
* 'options' => [FILTER_FLAG_STRIP_HIGH, ...], // Additional flags
|
||||
* 'skip_control_strip' => false, // Skip control character stripping
|
||||
* 'skip_normalize' => false, // Skip Unicode normalization
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* @param array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool}> $schema
|
||||
* @return static
|
||||
*/
|
||||
public function withSchema(array $schema): static
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->schema = $schema;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the logger for audit logging.
|
||||
*
|
||||
* @param LoggerInterface $logger
|
||||
* @param bool $enabled Whether to enable audit logging
|
||||
* @return static
|
||||
*/
|
||||
public function withLogger(LoggerInterface $logger, bool $enabled = true): static
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->logger = $logger;
|
||||
$clone->auditEnabled = $enabled;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable Unicode NFC normalization.
|
||||
*
|
||||
* @param bool $enabled
|
||||
* @return static
|
||||
*/
|
||||
public function withNormalization(bool $enabled): static
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->normalizeUnicode = $enabled;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip dangerous control characters from all values.
|
||||
*
|
||||
* Only strips ASCII 0-31 (null bytes, control characters).
|
||||
* Preserves Unicode (UTF-8 high bytes) for international input.
|
||||
* Handles nested arrays recursively.
|
||||
* Applies per-field filter rules from schema.
|
||||
* Normalizes Unicode to NFC form (if enabled).
|
||||
*/
|
||||
public function filter(array $input): array
|
||||
{
|
||||
|
|
@ -26,15 +135,146 @@ class Sanitiser
|
|||
return [];
|
||||
}
|
||||
|
||||
// Strip only control characters, preserve Unicode
|
||||
$filter = [
|
||||
'filter' => FILTER_UNSAFE_RAW,
|
||||
'flags' => FILTER_FLAG_STRIP_LOW,
|
||||
];
|
||||
return $this->filterRecursive($input, '');
|
||||
}
|
||||
|
||||
// One C call - process entire array
|
||||
$definition = array_fill_keys(array_keys($input), $filter);
|
||||
/**
|
||||
* Recursively filter array values.
|
||||
*
|
||||
* @param array $input
|
||||
* @param string $path Current path for nested arrays (for logging)
|
||||
* @return array
|
||||
*/
|
||||
protected function filterRecursive(array $input, string $path = ''): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
return filter_var_array($input, $definition) ?: [];
|
||||
foreach ($input as $key => $value) {
|
||||
$currentPath = $path === '' ? (string) $key : $path . '.' . $key;
|
||||
|
||||
if (is_array($value)) {
|
||||
// Recursively filter nested arrays
|
||||
$result[$key] = $this->filterRecursive($value, $currentPath);
|
||||
} elseif (is_string($value)) {
|
||||
// Apply filters to string values
|
||||
$result[$key] = $this->filterString($value, $currentPath, (string) $key);
|
||||
} else {
|
||||
// Pass through non-string values unchanged
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to a string value.
|
||||
*
|
||||
* @param string $value
|
||||
* @param string $path Full path for logging
|
||||
* @param string $fieldName Top-level field name for schema lookup
|
||||
* @return string
|
||||
*/
|
||||
protected function filterString(string $value, string $path, string $fieldName): string
|
||||
{
|
||||
$original = $value;
|
||||
$fieldSchema = $this->schema[$fieldName] ?? [];
|
||||
|
||||
// Step 1: Unicode NFC normalization (unless skipped)
|
||||
$skipNormalize = $fieldSchema['skip_normalize'] ?? false;
|
||||
if ($this->normalizeUnicode && !$skipNormalize && $this->isNormalizerAvailable()) {
|
||||
$normalized = Normalizer::normalize($value, Normalizer::FORM_C);
|
||||
if ($normalized !== false) {
|
||||
$value = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Strip control characters (unless skipped)
|
||||
$skipControlStrip = $fieldSchema['skip_control_strip'] ?? false;
|
||||
if (!$skipControlStrip) {
|
||||
$value = filter_var($value, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW) ?? '';
|
||||
}
|
||||
|
||||
// Step 3: Apply additional schema-defined filters
|
||||
$additionalFilters = $fieldSchema['filters'] ?? [];
|
||||
$additionalOptions = $fieldSchema['options'] ?? [];
|
||||
|
||||
foreach ($additionalFilters as $filter) {
|
||||
$options = 0;
|
||||
foreach ($additionalOptions as $option) {
|
||||
$options |= $option;
|
||||
}
|
||||
|
||||
$filtered = $options > 0
|
||||
? filter_var($value, $filter, $options)
|
||||
: filter_var($value, $filter);
|
||||
if ($filtered !== false) {
|
||||
$value = $filtered;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Audit logging if content was modified
|
||||
if ($this->auditEnabled && $this->logger !== null && $value !== $original) {
|
||||
$this->logSanitisation($path, $original, $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when content is modified during sanitisation.
|
||||
*
|
||||
* @param string $path Field path
|
||||
* @param string $original Original value
|
||||
* @param string $sanitised Sanitised value
|
||||
*/
|
||||
protected function logSanitisation(string $path, string $original, string $sanitised): void
|
||||
{
|
||||
// Truncate long values for logging
|
||||
$maxLength = 100;
|
||||
$originalTruncated = mb_strlen($original) > $maxLength
|
||||
? mb_substr($original, 0, $maxLength) . '...'
|
||||
: $original;
|
||||
$sanitisedTruncated = mb_strlen($sanitised) > $maxLength
|
||||
? mb_substr($sanitised, 0, $maxLength) . '...'
|
||||
: $sanitised;
|
||||
|
||||
// Convert control characters to visible representation for logging
|
||||
$originalVisible = $this->makeControlCharsVisible($originalTruncated);
|
||||
$sanitisedVisible = $this->makeControlCharsVisible($sanitisedTruncated);
|
||||
|
||||
$this->logger->info('Input sanitised', [
|
||||
'field' => $path,
|
||||
'original' => $originalVisible,
|
||||
'sanitised' => $sanitisedVisible,
|
||||
'original_length' => mb_strlen($original),
|
||||
'sanitised_length' => mb_strlen($sanitised),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert control characters to visible Unicode representation.
|
||||
*
|
||||
* @param string $value
|
||||
* @return string
|
||||
*/
|
||||
protected function makeControlCharsVisible(string $value): string
|
||||
{
|
||||
// Replace control characters (0x00-0x1F) with Unicode Control Pictures (U+2400-U+241F)
|
||||
return preg_replace_callback(
|
||||
'/[\x00-\x1F]/',
|
||||
fn ($matches) => mb_chr(0x2400 + ord($matches[0])),
|
||||
$value
|
||||
) ?? $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Normalizer class is available.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function isNormalizerAvailable(): bool
|
||||
{
|
||||
return class_exists(Normalizer::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,36 +4,37 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Lang;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* Core Translations Service Provider.
|
||||
* Core Lang Module Boot.
|
||||
*
|
||||
* Loads the framework's base translation files which provide:
|
||||
* - Brand identity text (name, tagline, copyright)
|
||||
* - Navigation labels
|
||||
* - Error page messages
|
||||
* - Common UI text (actions, status, validation)
|
||||
* This module provides enhanced translation functionality for the Core PHP framework.
|
||||
* Translation loading and configuration is handled by LangServiceProvider which
|
||||
* is auto-discovered via Laravel's package discovery.
|
||||
*
|
||||
* @see LangServiceProvider For the main service provider implementation
|
||||
*
|
||||
* Usage in Blade: {{ __('core::core.brand.name') }}
|
||||
* Usage in PHP: __('core::core.brand.name')
|
||||
*
|
||||
* Override translations by publishing to resources/lang/vendor/core/
|
||||
* php artisan vendor:publish --tag=core-translations
|
||||
*
|
||||
* Features:
|
||||
* - Auto-discovered service provider (no manual registration needed)
|
||||
* - Fallback locale chain support (e.g., en_GB -> en -> fallback)
|
||||
* - Missing translation key validation in development
|
||||
*
|
||||
* Configuration in config/core.php:
|
||||
* 'lang' => [
|
||||
* 'fallback_chain' => true, // Enable locale chain fallback
|
||||
* 'validate_keys' => null, // Auto-enable in local/development/testing
|
||||
* 'log_missing_keys' => true, // Log missing keys
|
||||
* 'missing_key_log_level' => 'debug', // Log level for missing keys
|
||||
* ]
|
||||
*/
|
||||
class Boot extends ServiceProvider
|
||||
class Boot
|
||||
{
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadTranslationsFrom(__DIR__.'/en_GB', 'core');
|
||||
|
||||
// Allow publishing translations for customisation
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishes([
|
||||
__DIR__.'/en_GB' => $this->app->langPath('vendor/core/en_GB'),
|
||||
], 'core-translations');
|
||||
}
|
||||
}
|
||||
// This class is intentionally empty.
|
||||
// All functionality is provided by LangServiceProvider.
|
||||
// This file exists for documentation and potential future event-based hooks.
|
||||
}
|
||||
|
|
|
|||
218
packages/core-php/src/Core/Lang/LangServiceProvider.php
Normal file
218
packages/core-php/src/Core/Lang/LangServiceProvider.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Lang;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Translation\Translator;
|
||||
|
||||
/**
|
||||
* Core Language Service Provider.
|
||||
*
|
||||
* Provides enhanced translation functionality:
|
||||
* - Automatic discovery via Laravel's package discovery
|
||||
* - Fallback locale chain support (e.g., en_GB -> en -> fallback)
|
||||
* - Translation key validation with development warnings
|
||||
*
|
||||
* Configuration options in config/core.php:
|
||||
* 'lang' => [
|
||||
* 'fallback_chain' => true, // Enable locale chain fallback
|
||||
* 'validate_keys' => true, // Warn about missing keys in dev
|
||||
* 'log_missing_keys' => true, // Log missing keys
|
||||
* 'missing_key_log_level' => 'debug', // Log level for missing keys
|
||||
* ]
|
||||
*/
|
||||
class LangServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadTranslations();
|
||||
$this->publishTranslations();
|
||||
$this->setupFallbackChain();
|
||||
$this->setupMissingKeyValidation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translation files from the Lang directory.
|
||||
*/
|
||||
protected function loadTranslations(): void
|
||||
{
|
||||
$this->loadTranslationsFrom(__DIR__.'/en_GB', 'core');
|
||||
|
||||
// Also register translations under the base locale (en) for fallback
|
||||
if (is_dir(__DIR__.'/en')) {
|
||||
$this->loadTranslationsFrom(__DIR__.'/en', 'core');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish translation files for customisation.
|
||||
*/
|
||||
protected function publishTranslations(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishes([
|
||||
__DIR__.'/en_GB' => $this->app->langPath('vendor/core/en_GB'),
|
||||
], 'core-translations');
|
||||
|
||||
// Publish base en translations if they exist
|
||||
if (is_dir(__DIR__.'/en')) {
|
||||
$this->publishes([
|
||||
__DIR__.'/en' => $this->app->langPath('vendor/core/en'),
|
||||
], 'core-translations');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up fallback locale chain support.
|
||||
*
|
||||
* This enables a chain like: en_GB -> en -> fallback
|
||||
* So regional locales can fall back to their base locale first.
|
||||
*/
|
||||
protected function setupFallbackChain(): void
|
||||
{
|
||||
if (! config('core.lang.fallback_chain', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Translator $translator */
|
||||
$translator = $this->app->make('translator');
|
||||
|
||||
$translator->determineLocalesUsing(function (array $locales) use ($translator) {
|
||||
return $this->buildFallbackChain($locales, $translator->getFallback());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fallback chain from the given locales.
|
||||
*
|
||||
* For example, 'en_GB' with fallback 'en' produces: ['en_GB', 'en']
|
||||
* For 'de_AT' with fallback 'en' produces: ['de_AT', 'de', 'en']
|
||||
*
|
||||
* @param array<string> $locales Initial locales from Laravel
|
||||
* @param string|null $fallback The configured fallback locale
|
||||
* @return array<string> The expanded locale chain
|
||||
*/
|
||||
protected function buildFallbackChain(array $locales, ?string $fallback): array
|
||||
{
|
||||
$chain = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
// Add the locale itself
|
||||
if (! isset($seen[$locale])) {
|
||||
$chain[] = $locale;
|
||||
$seen[$locale] = true;
|
||||
}
|
||||
|
||||
// Extract base locale (e.g., 'en' from 'en_GB')
|
||||
$baseLocale = $this->extractBaseLocale($locale);
|
||||
|
||||
if ($baseLocale !== null && $baseLocale !== $locale && ! isset($seen[$baseLocale])) {
|
||||
$chain[] = $baseLocale;
|
||||
$seen[$baseLocale] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the fallback is always at the end if not already included
|
||||
if ($fallback !== null && ! isset($seen[$fallback])) {
|
||||
$chain[] = $fallback;
|
||||
}
|
||||
|
||||
return $chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base locale from a regional locale.
|
||||
*
|
||||
* @param string $locale The locale (e.g., 'en_GB', 'en-GB', 'en')
|
||||
* @return string|null The base locale (e.g., 'en') or null if not applicable
|
||||
*/
|
||||
protected function extractBaseLocale(string $locale): ?string
|
||||
{
|
||||
// Handle both underscore (en_GB) and hyphen (en-GB) formats
|
||||
if (preg_match('/^([a-z]{2,3})[-_][A-Z]{2}$/i', $locale, $matches)) {
|
||||
return strtolower($matches[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up translation key validation for development.
|
||||
*
|
||||
* This registers a callback to handle missing translation keys,
|
||||
* which can log warnings or take other actions in development.
|
||||
*/
|
||||
protected function setupMissingKeyValidation(): void
|
||||
{
|
||||
// Only enable validation if configured (defaults to true in local/development)
|
||||
$validateKeys = config('core.lang.validate_keys', $this->app->environment('local', 'development', 'testing'));
|
||||
|
||||
if (! $validateKeys) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Translator $translator */
|
||||
$translator = $this->app->make('translator');
|
||||
|
||||
$translator->handleMissingKeysUsing(function (
|
||||
string $key,
|
||||
array $replace,
|
||||
?string $locale,
|
||||
bool $fallback
|
||||
): string {
|
||||
$this->handleMissingKey($key, $locale, $fallback);
|
||||
|
||||
return $key;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a missing translation key.
|
||||
*
|
||||
* @param string $key The missing translation key
|
||||
* @param string|null $locale The requested locale
|
||||
* @param bool $fallback Whether fallback was attempted
|
||||
*/
|
||||
protected function handleMissingKey(string $key, ?string $locale, bool $fallback): void
|
||||
{
|
||||
// Skip validation for keys that look like plain text (no namespace or dots)
|
||||
// These are likely just using __() for output, not actual translation keys
|
||||
if (! str_contains($key, '::') && ! str_contains($key, '.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shouldLog = config('core.lang.log_missing_keys', true);
|
||||
$logLevel = config('core.lang.missing_key_log_level', 'debug');
|
||||
|
||||
if (! $shouldLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Missing translation key: "%s" for locale "%s"%s',
|
||||
$key,
|
||||
$locale ?? app()->getLocale(),
|
||||
$fallback ? ' (fallback enabled)' : ''
|
||||
);
|
||||
|
||||
// Log with the configured level
|
||||
Log::log($logLevel, $message, [
|
||||
'key' => $key,
|
||||
'locale' => $locale ?? app()->getLocale(),
|
||||
'fallback' => $fallback,
|
||||
]);
|
||||
|
||||
// In local development, also trigger a deprecation notice for visibility
|
||||
if ($this->app->environment('local') && function_exists('trigger_deprecation')) {
|
||||
trigger_deprecation('core-php', '1.0', $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core;
|
||||
|
||||
use Core\Events\EventAuditLog;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
|
|
@ -13,6 +14,7 @@ use Illuminate\Support\ServiceProvider;
|
|||
* enabling lazy loading of modules based on actual usage.
|
||||
*
|
||||
* Handles both plain classes and ServiceProviders correctly.
|
||||
* Integrates with EventAuditLog for debugging and monitoring.
|
||||
*
|
||||
* Usage:
|
||||
* Event::listen(
|
||||
|
|
@ -33,11 +35,22 @@ class LazyModuleListener
|
|||
* Handle the event by instantiating the module and calling its method.
|
||||
*
|
||||
* This is the callable interface for Laravel's event dispatcher.
|
||||
* Records execution to EventAuditLog when enabled.
|
||||
*/
|
||||
public function __invoke(object $event): void
|
||||
{
|
||||
$module = $this->resolveModule();
|
||||
$module->{$this->method}($event);
|
||||
$eventClass = $event::class;
|
||||
|
||||
EventAuditLog::recordStart($eventClass, $this->moduleClass);
|
||||
|
||||
try {
|
||||
$module = $this->resolveModule();
|
||||
$module->{$this->method}($event);
|
||||
EventAuditLog::recordSuccess($eventClass, $this->moduleClass);
|
||||
} catch (\Throwable $e) {
|
||||
EventAuditLog::recordFailure($eventClass, $this->moduleClass, $e);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ namespace Core\Mail;
|
|||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Email Shield Service
|
||||
|
|
@ -21,11 +23,26 @@ class EmailShield
|
|||
*/
|
||||
protected const CACHE_KEY = 'email_shield:disposable_domains';
|
||||
|
||||
/**
|
||||
* Cache key prefix for MX record lookups.
|
||||
*/
|
||||
protected const MX_CACHE_KEY_PREFIX = 'email_shield:mx:';
|
||||
|
||||
/**
|
||||
* Cache duration in seconds (24 hours).
|
||||
*/
|
||||
protected const CACHE_DURATION = 86400;
|
||||
|
||||
/**
|
||||
* Cache duration for MX lookups in seconds (1 hour).
|
||||
*/
|
||||
protected const MX_CACHE_DURATION = 3600;
|
||||
|
||||
/**
|
||||
* URL for disposable domains list updates.
|
||||
*/
|
||||
protected const DISPOSABLE_DOMAINS_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf';
|
||||
|
||||
/**
|
||||
* Path to disposable domains list file.
|
||||
*/
|
||||
|
|
@ -197,4 +214,129 @@ class EmailShield
|
|||
{
|
||||
return count($this->disposableDomains);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain has valid MX records (with caching).
|
||||
*
|
||||
* @param string $domain The domain to check
|
||||
*/
|
||||
public function hasMxRecords(string $domain): bool
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
$cacheKey = self::MX_CACHE_KEY_PREFIX.$domain;
|
||||
|
||||
return Cache::remember(
|
||||
$cacheKey,
|
||||
self::MX_CACHE_DURATION,
|
||||
function () use ($domain): bool {
|
||||
return $this->performMxLookup($domain);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform actual MX record lookup.
|
||||
*/
|
||||
protected function performMxLookup(string $domain): bool
|
||||
{
|
||||
$mxRecords = [];
|
||||
|
||||
// Suppress warnings as getmxrr returns false on failure
|
||||
$result = @getmxrr($domain, $mxRecords);
|
||||
|
||||
return $result && count($mxRecords) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the MX cache for a specific domain.
|
||||
*/
|
||||
public function clearMxCache(string $domain): void
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
Cache::forget(self::MX_CACHE_KEY_PREFIX.$domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the disposable domains list from remote source.
|
||||
*
|
||||
* Downloads the latest list from the configured URL and updates
|
||||
* both the local file and cache.
|
||||
*
|
||||
* @param string|null $url Optional custom URL for the domains list
|
||||
* @return bool True if update was successful
|
||||
*/
|
||||
public function updateDisposableDomainsList(?string $url = null): bool
|
||||
{
|
||||
$url = $url ?? self::DISPOSABLE_DOMAINS_URL;
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('EmailShield: Failed to fetch disposable domains list', [
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = $response->body();
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
// Validate we got a reasonable list
|
||||
$validDomains = array_filter($lines, function ($line) {
|
||||
$domain = strtolower(trim($line));
|
||||
|
||||
return $domain !== '' && ! str_starts_with($domain, '#');
|
||||
});
|
||||
|
||||
if (count($validDomains) < 100) {
|
||||
Log::warning('EmailShield: Disposable domains list seems too small', [
|
||||
'count' => count($validDomains),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save to file
|
||||
$filePath = storage_path('app/'.self::DOMAINS_FILE);
|
||||
$directory = dirname($filePath);
|
||||
|
||||
if (! File::isDirectory($directory)) {
|
||||
File::makeDirectory($directory, 0755, true);
|
||||
}
|
||||
|
||||
File::put($filePath, $content);
|
||||
|
||||
// Refresh cache
|
||||
$this->refreshCache();
|
||||
|
||||
Log::info('EmailShield: Updated disposable domains list', [
|
||||
'count' => count($validDomains),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('EmailShield: Exception updating disposable domains list', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp when the disposable domains file was last modified.
|
||||
*/
|
||||
public function getDisposableDomainsLastUpdated(): ?Carbon
|
||||
{
|
||||
$filePath = storage_path('app/'.self::DOMAINS_FILE);
|
||||
|
||||
if (! File::exists($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::createFromTimestamp(File::lastModified($filePath));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,23 +80,30 @@ class EmailShieldStat extends Model
|
|||
/**
|
||||
* Increment a specific counter for today's date.
|
||||
*
|
||||
* Uses insertOrIgnore + increment for atomic operation.
|
||||
* insertOrIgnore ensures row exists without overwriting data.
|
||||
* increment is atomic at the database level.
|
||||
*
|
||||
* @param string $counter The counter column to increment
|
||||
*/
|
||||
protected static function incrementCounter(string $counter): void
|
||||
{
|
||||
$today = now()->format('Y-m-d');
|
||||
|
||||
// Use firstOrCreate to avoid race conditions, then increment
|
||||
$record = static::query()->firstOrCreate(
|
||||
['date' => $today],
|
||||
[
|
||||
'valid_count' => 0,
|
||||
'invalid_count' => 0,
|
||||
'disposable_count' => 0,
|
||||
]
|
||||
);
|
||||
// Ensure row exists (insertOrIgnore is no-op if row already exists)
|
||||
static::query()->insertOrIgnore([
|
||||
'date' => $today,
|
||||
'valid_count' => 0,
|
||||
'invalid_count' => 0,
|
||||
'disposable_count' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$record->increment($counter);
|
||||
// Atomic increment
|
||||
static::query()
|
||||
->where('date', $today)
|
||||
->increment($counter);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -126,4 +133,39 @@ class EmailShieldStat extends Model
|
|||
'total_checked' => $totalValid + $totalInvalid + $totalDisposable,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records older than the specified number of days.
|
||||
*
|
||||
* @param int $days Number of days to retain (default: 90)
|
||||
* @return int Number of records deleted
|
||||
*/
|
||||
public static function pruneOldRecords(int $days = 90): int
|
||||
{
|
||||
$cutoffDate = now()->subDays($days)->format('Y-m-d');
|
||||
|
||||
return static::query()
|
||||
->where('date', '<', $cutoffDate)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date of the oldest record.
|
||||
*/
|
||||
public static function getOldestRecordDate(): ?Carbon
|
||||
{
|
||||
$oldest = static::query()
|
||||
->orderBy('date', 'asc')
|
||||
->first();
|
||||
|
||||
return $oldest?->date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of stat records.
|
||||
*/
|
||||
public static function getRecordCount(): int
|
||||
{
|
||||
return static::query()->count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
packages/core-php/src/Core/Media/Abstracts/Image.php
Normal file
41
packages/core-php/src/Core/Media/Abstracts/Image.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Media\Abstracts;
|
||||
|
||||
/**
|
||||
* Image dimension constants for media processing.
|
||||
*
|
||||
* Provides standard dimensions for thumbnails and resized images.
|
||||
*/
|
||||
abstract class Image
|
||||
{
|
||||
/**
|
||||
* Small thumbnail dimensions.
|
||||
*/
|
||||
public const SMALL_WIDTH = 150;
|
||||
|
||||
public const SMALL_HEIGHT = 150;
|
||||
|
||||
/**
|
||||
* Medium thumbnail dimensions.
|
||||
*/
|
||||
public const MEDIUM_WIDTH = 400;
|
||||
|
||||
public const MEDIUM_HEIGHT = 400;
|
||||
|
||||
/**
|
||||
* Large image dimensions.
|
||||
*/
|
||||
public const LARGE_WIDTH = 1200;
|
||||
|
||||
public const LARGE_HEIGHT = 1200;
|
||||
|
||||
/**
|
||||
* Maximum supported image dimensions.
|
||||
*/
|
||||
public const MAX_WIDTH = 2400;
|
||||
|
||||
public const MAX_HEIGHT = 2400;
|
||||
}
|
||||
235
packages/core-php/src/Core/Media/Abstracts/MediaConversion.php
Normal file
235
packages/core-php/src/Core/Media/Abstracts/MediaConversion.php
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Media\Abstracts;
|
||||
|
||||
use Core\Media\Support\MediaConversionData;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Abstract base class for media conversions.
|
||||
*
|
||||
* Provides common functionality for media processing operations
|
||||
* such as image resizing, thumbnail generation, and video processing.
|
||||
*/
|
||||
abstract class MediaConversion
|
||||
{
|
||||
protected string $filepath;
|
||||
|
||||
protected string $fromDisk = 'local';
|
||||
|
||||
protected string $toDisk = 'local';
|
||||
|
||||
protected string $name = '';
|
||||
|
||||
protected string $suffix = '';
|
||||
|
||||
/**
|
||||
* Image MIME types.
|
||||
*/
|
||||
protected const IMAGE_MIMES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
/**
|
||||
* Video MIME types.
|
||||
*/
|
||||
protected const VIDEO_MIMES = [
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/quicktime',
|
||||
'video/x-msvideo',
|
||||
'video/x-ms-wmv',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
'video/3gpp',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the engine name for this conversion.
|
||||
*/
|
||||
abstract public function getEngineName(): string;
|
||||
|
||||
/**
|
||||
* Check if this conversion can be performed.
|
||||
*/
|
||||
abstract public function canPerform(): bool;
|
||||
|
||||
/**
|
||||
* Get the output file path.
|
||||
*/
|
||||
abstract public function getPath(): string;
|
||||
|
||||
/**
|
||||
* Perform the conversion.
|
||||
*/
|
||||
abstract public function handle(): ?MediaConversionData;
|
||||
|
||||
/**
|
||||
* Set the source file path.
|
||||
*/
|
||||
public function filepath(string $filepath): static
|
||||
{
|
||||
$this->filepath = $filepath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the source disk.
|
||||
*/
|
||||
public function fromDisk(string $disk): static
|
||||
{
|
||||
$this->fromDisk = $disk;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the destination disk.
|
||||
*/
|
||||
public function toDisk(string $disk): static
|
||||
{
|
||||
$this->toDisk = $disk;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the conversion name.
|
||||
*/
|
||||
public function name(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the filename suffix.
|
||||
*/
|
||||
public function suffix(string $suffix): static
|
||||
{
|
||||
$this->suffix = $suffix;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source file path.
|
||||
*/
|
||||
public function getFilepath(): string
|
||||
{
|
||||
return $this->filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source disk.
|
||||
*/
|
||||
public function getFromDisk(): string
|
||||
{
|
||||
return $this->fromDisk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the destination disk.
|
||||
*/
|
||||
public function getToDisk(): string
|
||||
{
|
||||
return $this->toDisk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversion name.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filename suffix.
|
||||
*/
|
||||
public function getSuffix(): string
|
||||
{
|
||||
return $this->suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a filesystem instance for the given disk.
|
||||
*/
|
||||
protected function filesystem(string $disk): \Illuminate\Contracts\Filesystem\Filesystem
|
||||
{
|
||||
return Storage::disk($disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path with a suffix added before the extension.
|
||||
*/
|
||||
protected function getFilePathWithSuffix(?string $extension = null, ?string $basePath = null): string
|
||||
{
|
||||
$path = $basePath ?? $this->filepath;
|
||||
$directory = dirname($path);
|
||||
$filename = pathinfo($path, PATHINFO_FILENAME);
|
||||
$originalExtension = pathinfo($path, PATHINFO_EXTENSION);
|
||||
$ext = $extension ?? $originalExtension;
|
||||
|
||||
$suffix = $this->suffix !== '' ? '-'.$this->suffix : '-'.Str::slug($this->name);
|
||||
|
||||
if ($directory === '.') {
|
||||
return $filename.$suffix.'.'.$ext;
|
||||
}
|
||||
|
||||
return $directory.'/'.$filename.$suffix.'.'.$ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the source file is an image.
|
||||
*/
|
||||
protected function isImage(): bool
|
||||
{
|
||||
$mimeType = $this->getMimeType();
|
||||
|
||||
return in_array($mimeType, self::IMAGE_MIMES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the source file is a GIF image.
|
||||
*/
|
||||
protected function isGifImage(): bool
|
||||
{
|
||||
return $this->getMimeType() === 'image/gif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the source file is a video.
|
||||
*/
|
||||
protected function isVideo(): bool
|
||||
{
|
||||
$mimeType = $this->getMimeType();
|
||||
|
||||
return in_array($mimeType, self::VIDEO_MIMES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MIME type of the source file.
|
||||
*/
|
||||
protected function getMimeType(): ?string
|
||||
{
|
||||
$disk = $this->filesystem($this->fromDisk);
|
||||
|
||||
if (! $disk->exists($this->filepath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $disk->mimeType($this->filepath);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Media\Conversions;
|
||||
|
||||
use Core\Mod\Social\Abstracts\MediaConversion;
|
||||
use Core\Mod\Social\Support\ImageResizer;
|
||||
use Core\Mod\Social\Support\MediaConversionData;
|
||||
use Core\Media\Abstracts\MediaConversion;
|
||||
use Core\Media\Support\ImageResizer;
|
||||
use Core\Media\Support\MediaConversionData;
|
||||
|
||||
/**
|
||||
* Image resizing media conversion.
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Media\Conversions;
|
||||
|
||||
use Core\Mod\Social\Abstracts\Image;
|
||||
use Core\Mod\Social\Abstracts\MediaConversion;
|
||||
use Core\Mod\Social\Support\ImageResizer;
|
||||
use Core\Mod\Social\Support\MediaConversionData;
|
||||
use Core\Mod\Social\Support\TemporaryFile;
|
||||
use Core\Media\Abstracts\Image;
|
||||
use Core\Media\Abstracts\MediaConversion;
|
||||
use Core\Media\Support\ImageResizer;
|
||||
use Core\Media\Support\MediaConversionData;
|
||||
use Core\Media\Support\TemporaryFile;
|
||||
use FFMpeg\Coordinate\TimeCode;
|
||||
use FFMpeg\FFMpeg;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
|
@ -82,8 +82,8 @@ class MediaVideoThumbConversion extends MediaConversion
|
|||
|
||||
// Extract frame using FFmpeg
|
||||
$ffmpeg = FFMpeg::create([
|
||||
'ffmpeg.binaries' => config('social.ffmpeg_path', '/usr/bin/ffmpeg'),
|
||||
'ffprobe.binaries' => config('social.ffprobe_path', '/usr/bin/ffprobe'),
|
||||
'ffmpeg.binaries' => config('media.ffmpeg_path', '/usr/bin/ffmpeg'),
|
||||
'ffprobe.binaries' => config('media.ffprobe_path', '/usr/bin/ffprobe'),
|
||||
]);
|
||||
|
||||
$video = $ffmpeg->open($temporaryFile->path());
|
||||
|
|
@ -99,7 +99,7 @@ class MediaVideoThumbConversion extends MediaConversion
|
|||
|
||||
// Sometimes the frame is not saved, so we retry with the first frame
|
||||
// This is a workaround for edge cases in FFmpeg
|
||||
if ($this->atSecond !== 0 && ! File::exists($thumbFilepath)) {
|
||||
if ($this->atSecond !== 0.0 && ! File::exists($thumbFilepath)) {
|
||||
$frame = $video->frame(TimeCode::fromSeconds(0));
|
||||
$frame->save($thumbFilepath);
|
||||
}
|
||||
|
|
@ -121,8 +121,8 @@ class MediaVideoThumbConversion extends MediaConversion
|
|||
*/
|
||||
private function isFFmpegInstalled(): bool
|
||||
{
|
||||
$ffmpegPath = config('social.ffmpeg_path', '/usr/bin/ffmpeg');
|
||||
$ffprobePath = config('social.ffprobe_path', '/usr/bin/ffprobe');
|
||||
$ffmpegPath = config('media.ffmpeg_path', '/usr/bin/ffmpeg');
|
||||
$ffprobePath = config('media.ffprobe_path', '/usr/bin/ffprobe');
|
||||
|
||||
return file_exists($ffmpegPath) &&
|
||||
file_exists($ffprobePath) &&
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Media\Image;
|
||||
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -36,10 +35,17 @@ class ImageOptimization extends Model
|
|||
|
||||
/**
|
||||
* The workspace this optimization belongs to.
|
||||
*
|
||||
* Returns a relationship to the Workspace model if it exists,
|
||||
* otherwise returns null.
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
public function workspace(): ?BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
if (! class_exists('Core\\Mod\\Tenant\\Models\\Workspace')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->belongsTo('Core\\Mod\\Tenant\\Models\\Workspace');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -52,9 +58,15 @@ class ImageOptimization extends Model
|
|||
|
||||
/**
|
||||
* Scope to a specific workspace.
|
||||
*
|
||||
* @param Model|null $workspace The workspace model to filter by
|
||||
*/
|
||||
public function scopeForWorkspace($query, Workspace $workspace)
|
||||
public function scopeForWorkspace($query, ?Model $workspace)
|
||||
{
|
||||
if ($workspace === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('workspace_id', $workspace->id);
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +124,10 @@ class ImageOptimization extends Model
|
|||
|
||||
/**
|
||||
* Get total savings statistics for a workspace.
|
||||
*
|
||||
* @param Model|null $workspace Optional workspace model to filter by
|
||||
*/
|
||||
public static function getWorkspaceStats(?Workspace $workspace = null): array
|
||||
public static function getWorkspaceStats(?Model $workspace = null): array
|
||||
{
|
||||
$query = static::query();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,23 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Media\Image;
|
||||
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ImageOptimizer
|
||||
{
|
||||
/**
|
||||
* GD typically needs 5-6x the image size in memory.
|
||||
*/
|
||||
protected const MEMORY_SAFETY_FACTOR = 6;
|
||||
|
||||
/**
|
||||
* Minimum memory buffer to maintain (in bytes).
|
||||
*/
|
||||
protected const MEMORY_BUFFER = 32 * 1024 * 1024; // 32MB
|
||||
|
||||
protected string $driver;
|
||||
|
||||
protected int $defaultQuality;
|
||||
|
|
@ -102,6 +112,22 @@ class ImageOptimizer
|
|||
throw new \InvalidArgumentException("Not a valid image: {$absolutePath}");
|
||||
}
|
||||
|
||||
// Check memory before processing
|
||||
$width = $imageInfo[0];
|
||||
$height = $imageInfo[1];
|
||||
|
||||
if (! $this->hasEnoughMemory($width, $height)) {
|
||||
Log::warning('ImageOptimizer: Insufficient memory for image processing', [
|
||||
'path' => $absolutePath,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'estimated_memory' => $this->formatBytes($this->estimateRequiredMemory($width, $height)),
|
||||
'available_memory' => $this->formatBytes($this->getAvailableMemory()),
|
||||
]);
|
||||
|
||||
return $this->createNoOpResult($absolutePath);
|
||||
}
|
||||
|
||||
$mimeType = $imageInfo['mime'];
|
||||
$quality = $options['quality'] ?? $this->defaultQuality;
|
||||
$driver = $options['driver'] ?? $this->driver;
|
||||
|
|
@ -225,6 +251,92 @@ class ImageOptimizer
|
|||
throw new \RuntimeException('Only GD driver is currently implemented for WebP');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is enough memory to process an image.
|
||||
*/
|
||||
protected function hasEnoughMemory(int $width, int $height): bool
|
||||
{
|
||||
$required = $this->estimateRequiredMemory($width, $height);
|
||||
$available = $this->getAvailableMemory();
|
||||
|
||||
return $available > ($required + self::MEMORY_BUFFER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the memory required to process an image.
|
||||
*
|
||||
* Based on image dimensions assuming 4 bytes per pixel (RGBA).
|
||||
*/
|
||||
protected function estimateRequiredMemory(int $width, int $height): int
|
||||
{
|
||||
// 4 bytes per pixel (RGBA) * safety factor for GD operations
|
||||
return $width * $height * 4 * self::MEMORY_SAFETY_FACTOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available memory in bytes.
|
||||
*/
|
||||
protected function getAvailableMemory(): int
|
||||
{
|
||||
$limit = $this->parseMemoryLimit(ini_get('memory_limit'));
|
||||
|
||||
if ($limit < 0) {
|
||||
// No memory limit
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
return $limit - memory_get_usage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PHP memory limit string to bytes.
|
||||
*/
|
||||
protected function parseMemoryLimit(string $limit): int
|
||||
{
|
||||
if ($limit === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$limit = strtolower(trim($limit));
|
||||
$value = (int) $limit;
|
||||
|
||||
$unit = substr($limit, -1);
|
||||
|
||||
switch ($unit) {
|
||||
case 'g':
|
||||
$value *= 1024 * 1024 * 1024;
|
||||
break;
|
||||
case 'm':
|
||||
$value *= 1024 * 1024;
|
||||
break;
|
||||
case 'k':
|
||||
$value *= 1024;
|
||||
break;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable size.
|
||||
*/
|
||||
protected function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes.'B';
|
||||
}
|
||||
|
||||
if ($bytes < 1024 * 1024) {
|
||||
return round($bytes / 1024, 1).'KB';
|
||||
}
|
||||
|
||||
if ($bytes < 1024 * 1024 * 1024) {
|
||||
return round($bytes / (1024 * 1024), 1).'MB';
|
||||
}
|
||||
|
||||
return round($bytes / (1024 * 1024 * 1024), 2).'GB';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op result (no optimization performed).
|
||||
*/
|
||||
|
|
@ -268,19 +380,24 @@ class ImageOptimizer
|
|||
|
||||
/**
|
||||
* Get optimization statistics for a workspace.
|
||||
*
|
||||
* @param Model|null $workspace Optional workspace model to filter by
|
||||
*/
|
||||
public function getStats(?Workspace $workspace = null): array
|
||||
public function getStats(?Model $workspace = null): array
|
||||
{
|
||||
return ImageOptimization::getWorkspaceStats($workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track optimization in database.
|
||||
*
|
||||
* @param Model|null $workspace Optional workspace model
|
||||
* @param Model|null $optimizable Optional related model
|
||||
*/
|
||||
public function recordOptimization(
|
||||
OptimizationResult $result,
|
||||
?Workspace $workspace = null,
|
||||
$optimizable = null,
|
||||
?Model $workspace = null,
|
||||
?Model $optimizable = null,
|
||||
?string $originalPath = null
|
||||
): ImageOptimization {
|
||||
return ImageOptimization::create([
|
||||
|
|
@ -292,7 +409,7 @@ class ImageOptimizer
|
|||
'driver' => $result->driver,
|
||||
'quality' => $this->defaultQuality,
|
||||
'workspace_id' => $workspace?->id,
|
||||
'optimizable_type' => $optimizable ? get_class($optimizable) : null,
|
||||
'optimizable_type' => $optimizable !== null ? get_class($optimizable) : null,
|
||||
'optimizable_id' => $optimizable?->id,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
315
packages/core-php/src/Core/Media/Support/ImageResizer.php
Normal file
315
packages/core-php/src/Core/Media/Support/ImageResizer.php
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Media\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Image resizing utility for media processing.
|
||||
*
|
||||
* Resizes images while maintaining aspect ratio and preventing upscaling.
|
||||
* Includes memory safety checks to prevent out-of-memory crashes.
|
||||
*/
|
||||
class ImageResizer
|
||||
{
|
||||
protected string $content;
|
||||
|
||||
protected ?string $sourcePath = null;
|
||||
|
||||
protected ?string $disk = null;
|
||||
|
||||
protected ?string $path = null;
|
||||
|
||||
/**
|
||||
* Default memory safety factor.
|
||||
* GD typically needs 5-6x the image size in memory.
|
||||
*/
|
||||
protected const MEMORY_SAFETY_FACTOR = 6;
|
||||
|
||||
/**
|
||||
* Minimum memory buffer to maintain (in bytes).
|
||||
*/
|
||||
protected const MEMORY_BUFFER = 32 * 1024 * 1024; // 32MB
|
||||
|
||||
public function __construct(string $contentOrPath)
|
||||
{
|
||||
if (file_exists($contentOrPath)) {
|
||||
$this->sourcePath = $contentOrPath;
|
||||
$this->content = file_get_contents($contentOrPath);
|
||||
} else {
|
||||
$this->content = $contentOrPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ImageResizer instance.
|
||||
*/
|
||||
public static function make(string $contentOrPath): static
|
||||
{
|
||||
return new static($contentOrPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the destination disk.
|
||||
*/
|
||||
public function disk(string $disk): static
|
||||
{
|
||||
$this->disk = $disk;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the destination path.
|
||||
*/
|
||||
public function path(string $path): static
|
||||
{
|
||||
$this->path = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the image to fit within the specified dimensions.
|
||||
*
|
||||
* Maintains aspect ratio and prevents upscaling.
|
||||
*
|
||||
* @throws RuntimeException If memory is insufficient or image processing fails
|
||||
*/
|
||||
public function resize(int $maxWidth, int $maxHeight): bool
|
||||
{
|
||||
if ($this->disk === null || $this->path === null) {
|
||||
throw new RuntimeException('Disk and path must be set before resizing');
|
||||
}
|
||||
|
||||
// Get image info for memory estimation
|
||||
$imageInfo = $this->getImageInfo();
|
||||
|
||||
if ($imageInfo === null) {
|
||||
Log::warning('ImageResizer: Failed to get image info, cannot resize');
|
||||
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
// Check memory before processing
|
||||
if (! $this->hasEnoughMemory($imageInfo['width'], $imageInfo['height'])) {
|
||||
Log::warning('ImageResizer: Insufficient memory for image processing', [
|
||||
'width' => $imageInfo['width'],
|
||||
'height' => $imageInfo['height'],
|
||||
'estimated_memory' => $this->estimateRequiredMemory($imageInfo['width'], $imageInfo['height']),
|
||||
'available_memory' => $this->getAvailableMemory(),
|
||||
]);
|
||||
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
$image = $this->createImageFromContent();
|
||||
|
||||
if ($image === null) {
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
$originalWidth = imagesx($image);
|
||||
$originalHeight = imagesy($image);
|
||||
|
||||
// Skip if image is already smaller than target
|
||||
if ($originalWidth <= $maxWidth && $originalHeight <= $maxHeight) {
|
||||
imagedestroy($image);
|
||||
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
$ratio = min($maxWidth / $originalWidth, $maxHeight / $originalHeight);
|
||||
$newWidth = (int) round($originalWidth * $ratio);
|
||||
$newHeight = (int) round($originalHeight * $ratio);
|
||||
|
||||
// Check memory for the resized image
|
||||
if (! $this->hasEnoughMemory($newWidth, $newHeight)) {
|
||||
Log::warning('ImageResizer: Insufficient memory for resized image');
|
||||
imagedestroy($image);
|
||||
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
// Create resized image
|
||||
$resized = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
if ($resized === false) {
|
||||
imagedestroy($image);
|
||||
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
// Preserve transparency for PNG/WebP
|
||||
if ($imageInfo['type'] === IMAGETYPE_PNG || $imageInfo['type'] === IMAGETYPE_WEBP) {
|
||||
imagealphablending($resized, false);
|
||||
imagesavealpha($resized, true);
|
||||
$transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
|
||||
imagefilledrectangle($resized, 0, 0, $newWidth, $newHeight, $transparent);
|
||||
}
|
||||
|
||||
// Perform the resize
|
||||
$success = imagecopyresampled(
|
||||
$resized,
|
||||
$image,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$newWidth,
|
||||
$newHeight,
|
||||
$originalWidth,
|
||||
$originalHeight
|
||||
);
|
||||
|
||||
imagedestroy($image);
|
||||
|
||||
if (! $success) {
|
||||
imagedestroy($resized);
|
||||
|
||||
return $this->saveOriginal();
|
||||
}
|
||||
|
||||
// Save the resized image
|
||||
$result = $this->saveImage($resized, $imageInfo['type']);
|
||||
imagedestroy($resized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image information from content.
|
||||
*/
|
||||
protected function getImageInfo(): ?array
|
||||
{
|
||||
$info = @getimagesizefromstring($this->content);
|
||||
|
||||
if ($info === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'width' => $info[0],
|
||||
'height' => $info[1],
|
||||
'type' => $info[2],
|
||||
'mime' => $info['mime'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is enough memory to process an image.
|
||||
*/
|
||||
protected function hasEnoughMemory(int $width, int $height): bool
|
||||
{
|
||||
$required = $this->estimateRequiredMemory($width, $height);
|
||||
$available = $this->getAvailableMemory();
|
||||
|
||||
return $available > ($required + self::MEMORY_BUFFER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the memory required to process an image.
|
||||
*
|
||||
* Based on image dimensions assuming 4 bytes per pixel (RGBA).
|
||||
*/
|
||||
protected function estimateRequiredMemory(int $width, int $height): int
|
||||
{
|
||||
// 4 bytes per pixel (RGBA) * safety factor for GD operations
|
||||
return $width * $height * 4 * self::MEMORY_SAFETY_FACTOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available memory in bytes.
|
||||
*/
|
||||
protected function getAvailableMemory(): int
|
||||
{
|
||||
$limit = $this->parseMemoryLimit(ini_get('memory_limit'));
|
||||
|
||||
if ($limit < 0) {
|
||||
// No memory limit
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
return $limit - memory_get_usage(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PHP memory limit string to bytes.
|
||||
*/
|
||||
protected function parseMemoryLimit(string $limit): int
|
||||
{
|
||||
if ($limit === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$limit = strtolower(trim($limit));
|
||||
$value = (int) $limit;
|
||||
|
||||
$unit = substr($limit, -1);
|
||||
|
||||
switch ($unit) {
|
||||
case 'g':
|
||||
$value *= 1024 * 1024 * 1024;
|
||||
break;
|
||||
case 'm':
|
||||
$value *= 1024 * 1024;
|
||||
break;
|
||||
case 'k':
|
||||
$value *= 1024;
|
||||
break;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GD image resource from content.
|
||||
*/
|
||||
protected function createImageFromContent(): ?\GdImage
|
||||
{
|
||||
$image = @imagecreatefromstring($this->content);
|
||||
|
||||
if ($image === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the original content without modification.
|
||||
*/
|
||||
protected function saveOriginal(): bool
|
||||
{
|
||||
return Storage::disk($this->disk)->put($this->path, $this->content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the GD image to the destination.
|
||||
*/
|
||||
protected function saveImage(\GdImage $image, int $type): bool
|
||||
{
|
||||
ob_start();
|
||||
|
||||
$success = match ($type) {
|
||||
IMAGETYPE_JPEG => imagejpeg($image, null, 85),
|
||||
IMAGETYPE_PNG => imagepng($image, null, 6),
|
||||
IMAGETYPE_WEBP => imagewebp($image, null, 85),
|
||||
IMAGETYPE_GIF => imagegif($image),
|
||||
default => false,
|
||||
};
|
||||
|
||||
$content = ob_get_clean();
|
||||
|
||||
if (! $success || $content === false || $content === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Storage::disk($this->disk)->put($this->path, $content);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Media\Support;
|
||||
|
||||
use Core\Media\Abstracts\MediaConversion;
|
||||
|
||||
/**
|
||||
* Data transfer object for media conversion results.
|
||||
*
|
||||
* Encapsulates the result of a media conversion operation.
|
||||
*/
|
||||
class MediaConversionData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $path,
|
||||
public readonly string $disk,
|
||||
public readonly string $engine,
|
||||
public readonly string $name,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a conversion data instance from a MediaConversion.
|
||||
*/
|
||||
public static function conversion(MediaConversion $conversion): static
|
||||
{
|
||||
return new static(
|
||||
path: $conversion->getPath(),
|
||||
disk: $conversion->getToDisk(),
|
||||
engine: $conversion->getEngineName(),
|
||||
name: $conversion->getName(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'path' => $this->path,
|
||||
'disk' => $this->disk,
|
||||
'engine' => $this->engine,
|
||||
'name' => $this->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Media\Support;
|
||||
|
||||
/**
|
||||
* Temporary directory handler for cleanup operations.
|
||||
*/
|
||||
class TemporaryDirectory
|
||||
{
|
||||
public function __construct(protected string $path) {}
|
||||
|
||||
/**
|
||||
* Delete the temporary directory and all its contents.
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
if (! is_dir($this->path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($this->path, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->isDir()) {
|
||||
rmdir($file->getRealPath());
|
||||
} else {
|
||||
unlink($file->getRealPath());
|
||||
}
|
||||
}
|
||||
|
||||
return rmdir($this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory path.
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
}
|
||||
68
packages/core-php/src/Core/Media/Support/TemporaryFile.php
Normal file
68
packages/core-php/src/Core/Media/Support/TemporaryFile.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Media\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Temporary file handler for media processing.
|
||||
*
|
||||
* Creates and manages temporary files during media conversion operations.
|
||||
*/
|
||||
class TemporaryFile
|
||||
{
|
||||
protected string $path;
|
||||
|
||||
protected string $directory;
|
||||
|
||||
public function __construct(?string $path = null)
|
||||
{
|
||||
$this->directory = sys_get_temp_dir().'/media-'.Str::random(16);
|
||||
mkdir($this->directory, 0755, true);
|
||||
|
||||
$this->path = $path ?? $this->directory.'/temp-'.Str::random(8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new temporary file instance.
|
||||
*/
|
||||
public static function make(): static
|
||||
{
|
||||
return new static();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file from a disk to the temporary location.
|
||||
*/
|
||||
public function fromDisk(string $sourceDisk, string $sourceFilepath): static
|
||||
{
|
||||
$disk = Storage::disk($sourceDisk);
|
||||
$extension = pathinfo($sourceFilepath, PATHINFO_EXTENSION);
|
||||
|
||||
$this->path = $this->directory.'/temp-'.Str::random(8).'.'.$extension;
|
||||
|
||||
$content = $disk->get($sourceFilepath);
|
||||
file_put_contents($this->path, $content);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the temporary file path.
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a directory helper for cleanup.
|
||||
*/
|
||||
public function directory(): TemporaryDirectory
|
||||
{
|
||||
return new TemporaryDirectory($this->directory);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Event;
|
|||
* Scans module directories, extracts $listens declarations,
|
||||
* and wires up lazy listeners for each event-module pair.
|
||||
*
|
||||
* Listeners are registered in priority order (higher priority runs first).
|
||||
*
|
||||
* Usage:
|
||||
* $registry = new ModuleRegistry(new ModuleScanner());
|
||||
* $registry->register([app_path('Core'), app_path('Mod')]);
|
||||
|
|
@ -29,6 +31,8 @@ class ModuleRegistry
|
|||
/**
|
||||
* Scan paths and register lazy listeners for all declared events.
|
||||
*
|
||||
* Listeners are sorted by priority (highest first) before registration.
|
||||
*
|
||||
* @param array<string> $paths Directories containing modules
|
||||
*/
|
||||
public function register(array $paths): void
|
||||
|
|
@ -40,18 +44,33 @@ class ModuleRegistry
|
|||
$this->mappings = $this->scanner->scan($paths);
|
||||
|
||||
foreach ($this->mappings as $event => $listeners) {
|
||||
foreach ($listeners as $moduleClass => $method) {
|
||||
Event::listen($event, new LazyModuleListener($moduleClass, $method));
|
||||
$sorted = $this->sortByPriority($listeners);
|
||||
|
||||
foreach ($sorted as $moduleClass => $config) {
|
||||
Event::listen($event, new LazyModuleListener($moduleClass, $config['method']));
|
||||
}
|
||||
}
|
||||
|
||||
$this->registered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort listeners by priority (highest first).
|
||||
*
|
||||
* @param array<string, array{method: string, priority: int}> $listeners
|
||||
* @return array<string, array{method: string, priority: int}>
|
||||
*/
|
||||
private function sortByPriority(array $listeners): array
|
||||
{
|
||||
uasort($listeners, fn ($a, $b) => $b['priority'] <=> $a['priority']);
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scanned mappings.
|
||||
*
|
||||
* @return array<string, array<string, string>> Event => [Module => method]
|
||||
* @return array<string, array<string, array{method: string, priority: int}>> Event => [Module => config]
|
||||
*/
|
||||
public function getMappings(): array
|
||||
{
|
||||
|
|
@ -61,7 +80,7 @@ class ModuleRegistry
|
|||
/**
|
||||
* Get modules that listen to a specific event.
|
||||
*
|
||||
* @return array<string, string> Module => method
|
||||
* @return array<string, array{method: string, priority: int}> Module => config
|
||||
*/
|
||||
public function getListenersFor(string $event): array
|
||||
{
|
||||
|
|
@ -108,6 +127,8 @@ class ModuleRegistry
|
|||
* Add additional paths to scan and register.
|
||||
*
|
||||
* Used by packages to register their module paths.
|
||||
* Note: Priority ordering only applies within the newly added paths.
|
||||
* For full priority control, use register() with all paths.
|
||||
*
|
||||
* @param array<string> $paths Directories containing modules
|
||||
*/
|
||||
|
|
@ -116,14 +137,16 @@ class ModuleRegistry
|
|||
$newMappings = $this->scanner->scan($paths);
|
||||
|
||||
foreach ($newMappings as $event => $listeners) {
|
||||
foreach ($listeners as $moduleClass => $method) {
|
||||
$sorted = $this->sortByPriority($listeners);
|
||||
|
||||
foreach ($sorted as $moduleClass => $config) {
|
||||
// Skip if already registered
|
||||
if (isset($this->mappings[$event][$moduleClass])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mappings[$event][$moduleClass] = $method;
|
||||
Event::listen($event, new LazyModuleListener($moduleClass, $method));
|
||||
$this->mappings[$event][$moduleClass] = $config;
|
||||
Event::listen($event, new LazyModuleListener($moduleClass, $config['method']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,18 +12,29 @@ use ReflectionClass;
|
|||
* Reads the static $listens property from Boot classes without
|
||||
* instantiating them, enabling lazy loading of modules.
|
||||
*
|
||||
* Supports priority via array syntax:
|
||||
* public static array $listens = [
|
||||
* WebRoutesRegistering::class => 'onWebRoutes', // Default priority 0
|
||||
* AdminPanelBooting::class => ['onAdmin', 10], // Priority 10 (higher = runs first)
|
||||
* ];
|
||||
*
|
||||
* Usage:
|
||||
* $scanner = new ModuleScanner();
|
||||
* $mappings = $scanner->scan([app_path('Core'), app_path('Mod')]);
|
||||
* // Returns: [EventClass => [ModuleClass => 'methodName']]
|
||||
* // Returns: [EventClass => [ModuleClass => ['method' => 'name', 'priority' => 0]]]
|
||||
*/
|
||||
class ModuleScanner
|
||||
{
|
||||
/**
|
||||
* Default priority for listeners without explicit priority.
|
||||
*/
|
||||
public const DEFAULT_PRIORITY = 0;
|
||||
|
||||
/**
|
||||
* Scan directories for Boot.php files with $listens declarations.
|
||||
*
|
||||
* @param array<string> $paths Directories to scan
|
||||
* @return array<string, array<string, string>> Event => [Module => method] mappings
|
||||
* @return array<string, array<string, array{method: string, priority: int}>> Event => [Module => config] mappings
|
||||
*/
|
||||
public function scan(array $paths): array
|
||||
{
|
||||
|
|
@ -43,8 +54,8 @@ class ModuleScanner
|
|||
|
||||
$listens = $this->extractListens($class);
|
||||
|
||||
foreach ($listens as $event => $method) {
|
||||
$mappings[$event][$class] = $method;
|
||||
foreach ($listens as $event => $config) {
|
||||
$mappings[$event][$class] = $config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +66,11 @@ class ModuleScanner
|
|||
/**
|
||||
* Extract the $listens array from a class without instantiation.
|
||||
*
|
||||
* @return array<string, string> Event => method mappings
|
||||
* Supports two formats:
|
||||
* - Simple: EventClass::class => 'methodName'
|
||||
* - With priority: EventClass::class => ['methodName', priority]
|
||||
*
|
||||
* @return array<string, array{method: string, priority: int}> Event => config mappings
|
||||
*/
|
||||
public function extractListens(string $class): array
|
||||
{
|
||||
|
|
@ -78,12 +93,39 @@ class ModuleScanner
|
|||
return [];
|
||||
}
|
||||
|
||||
return $listens;
|
||||
return $this->normalizeListens($listens);
|
||||
} catch (\ReflectionException) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize listener declarations to consistent format.
|
||||
*
|
||||
* @param array<string, string|array{0: string, 1?: int}> $listens Raw listener declarations
|
||||
* @return array<string, array{method: string, priority: int}> Normalized declarations
|
||||
*/
|
||||
private function normalizeListens(array $listens): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($listens as $event => $value) {
|
||||
if (is_string($value)) {
|
||||
$normalized[$event] = [
|
||||
'method' => $value,
|
||||
'priority' => self::DEFAULT_PRIORITY,
|
||||
];
|
||||
} elseif (is_array($value) && isset($value[0])) {
|
||||
$normalized[$event] = [
|
||||
'method' => $value[0],
|
||||
'priority' => (int) ($value[1] ?? self::DEFAULT_PRIORITY),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive class name from file path.
|
||||
*
|
||||
|
|
@ -118,8 +160,12 @@ class ModuleScanner
|
|||
return "Mod\\{$namespace}";
|
||||
}
|
||||
|
||||
if (str_contains($basePath, '/Mod')) {
|
||||
return "Mod\\{$namespace}";
|
||||
if (str_contains($basePath, '/Website')) {
|
||||
return "Website\\{$namespace}";
|
||||
}
|
||||
|
||||
if (str_contains($basePath, '/Plug')) {
|
||||
return "Plug\\{$namespace}";
|
||||
}
|
||||
|
||||
// Fallback - try to determine from directory name
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ class Unified
|
|||
|
||||
public const TYPE_PLAN = 'plan';
|
||||
|
||||
/**
|
||||
* Default cache TTL for search results in seconds.
|
||||
*/
|
||||
protected const CACHE_TTL = 60;
|
||||
|
||||
/**
|
||||
* Maximum allowed wildcards in a search query.
|
||||
*/
|
||||
protected const MAX_WILDCARDS = 3;
|
||||
|
||||
/**
|
||||
* Perform unified search across all sources.
|
||||
*/
|
||||
|
|
@ -46,9 +56,30 @@ class Unified
|
|||
return collect();
|
||||
}
|
||||
|
||||
$cacheKey = $this->buildCacheKey($query, $types, $limit);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($query, $types, $limit) {
|
||||
return $this->executeSearch($query, $types, $limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a cache key for the search query.
|
||||
*/
|
||||
protected function buildCacheKey(string $query, array $types, int $limit): string
|
||||
{
|
||||
$typesHash = empty($types) ? 'all' : md5(implode(',', $types));
|
||||
|
||||
return "unified_search:{$typesHash}:{$limit}:".md5($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual search across all sources.
|
||||
*/
|
||||
protected function executeSearch(string $query, array $types, int $limit): Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Determine which types to search
|
||||
$searchAll = empty($types);
|
||||
|
||||
if ($searchAll || in_array(self::TYPE_MCP_TOOL, $types)) {
|
||||
|
|
@ -79,13 +110,31 @@ class Unified
|
|||
$results = $results->merge($this->searchPlans($query));
|
||||
}
|
||||
|
||||
// Sort by relevance score and limit
|
||||
return $results
|
||||
->sortByDesc('score')
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special LIKE wildcards and limit wildcard count to prevent DoS.
|
||||
*
|
||||
* SQL LIKE wildcards (% and _) in user input are escaped to prevent
|
||||
* expensive full-table scans from malicious patterns like "%%%%".
|
||||
*/
|
||||
protected function escapeLikeQuery(string $query): string
|
||||
{
|
||||
$wildcardCount = substr_count($query, '%') + substr_count($query, '_');
|
||||
|
||||
if ($wildcardCount > self::MAX_WILDCARDS) {
|
||||
$query = str_replace(['%', '_'], '', $query);
|
||||
} else {
|
||||
$query = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search MCP tools from server YAML files.
|
||||
*/
|
||||
|
|
@ -160,25 +209,12 @@ class Unified
|
|||
}
|
||||
|
||||
/**
|
||||
* Search API endpoints from scramble config.
|
||||
* Search API endpoints from config.
|
||||
*/
|
||||
protected function searchApiEndpoints(string $query): Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Define known endpoints (in production, parse from OpenAPI spec)
|
||||
$endpoints = [
|
||||
['method' => 'GET', 'path' => '/api/v1/workspaces', 'description' => 'List all workspaces'],
|
||||
['method' => 'POST', 'path' => '/api/v1/workspaces', 'description' => 'Create a new workspace'],
|
||||
['method' => 'GET', 'path' => '/api/v1/workspaces/{id}', 'description' => 'Get workspace details'],
|
||||
['method' => 'PUT', 'path' => '/api/v1/workspaces/{id}', 'description' => 'Update workspace'],
|
||||
['method' => 'DELETE', 'path' => '/api/v1/workspaces/{id}', 'description' => 'Delete workspace'],
|
||||
['method' => 'GET', 'path' => '/api/v1/biolinks', 'description' => 'List bio links'],
|
||||
['method' => 'POST', 'path' => '/api/v1/biolinks', 'description' => 'Create bio link'],
|
||||
['method' => 'GET', 'path' => '/api/v1/links', 'description' => 'List short links'],
|
||||
['method' => 'POST', 'path' => '/api/v1/links', 'description' => 'Create short link'],
|
||||
['method' => 'GET', 'path' => '/api/v1/analytics/summary', 'description' => 'Get analytics summary'],
|
||||
];
|
||||
$endpoints = $this->loadApiEndpoints();
|
||||
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$path = strtolower($endpoint['path']);
|
||||
|
|
@ -215,10 +251,12 @@ class Unified
|
|||
return collect();
|
||||
}
|
||||
|
||||
$escaped = $this->escapeLikeQuery($query);
|
||||
|
||||
try {
|
||||
return Pattern::where('name', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%")
|
||||
->orWhere('category', 'like', "%{$query}%")
|
||||
return Pattern::where('name', 'like', "%{$escaped}%")
|
||||
->orWhere('description', 'like', "%{$escaped}%")
|
||||
->orWhere('category', 'like', "%{$escaped}%")
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($pattern) => [
|
||||
|
|
@ -251,10 +289,12 @@ class Unified
|
|||
return collect();
|
||||
}
|
||||
|
||||
$escaped = $this->escapeLikeQuery($query);
|
||||
|
||||
try {
|
||||
return Asset::where('name', 'like', "%{$query}%")
|
||||
->orWhere('slug', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%")
|
||||
return Asset::where('name', 'like', "%{$escaped}%")
|
||||
->orWhere('slug', 'like', "%{$escaped}%")
|
||||
->orWhere('description', 'like', "%{$escaped}%")
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($asset) => [
|
||||
|
|
@ -288,9 +328,11 @@ class Unified
|
|||
return collect();
|
||||
}
|
||||
|
||||
$escaped = $this->escapeLikeQuery($query);
|
||||
|
||||
try {
|
||||
return UpstreamTodo::where('title', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%")
|
||||
return UpstreamTodo::where('title', 'like', "%{$escaped}%")
|
||||
->orWhere('description', 'like', "%{$escaped}%")
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($todo) => [
|
||||
|
|
@ -320,10 +362,16 @@ class Unified
|
|||
*/
|
||||
protected function searchPlans(string $query): Collection
|
||||
{
|
||||
if (! class_exists(AgentPlan::class)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$escaped = $this->escapeLikeQuery($query);
|
||||
|
||||
try {
|
||||
return AgentPlan::where('title', 'like', "%{$query}%")
|
||||
->orWhere('slug', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%")
|
||||
return AgentPlan::where('title', 'like', "%{$escaped}%")
|
||||
->orWhere('slug', 'like', "%{$escaped}%")
|
||||
->orWhere('description', 'like', "%{$escaped}%")
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($plan) => [
|
||||
|
|
@ -408,6 +456,14 @@ class Unified
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load API endpoints from config.
|
||||
*/
|
||||
protected function loadApiEndpoints(): array
|
||||
{
|
||||
return config('core.search.api_endpoints', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available search types for filtering.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Seo;
|
||||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Seo\Validation\SchemaValidator;
|
||||
|
||||
/**
|
||||
* JSON-LD Schema Generator.
|
||||
|
|
@ -391,11 +392,42 @@ class Schema
|
|||
|
||||
/**
|
||||
* Render schema as JSON-LD script tag.
|
||||
*
|
||||
* Uses JSON_HEX_TAG to prevent XSS via </script> in content.
|
||||
*/
|
||||
public function toScriptTag(array $schema): string
|
||||
{
|
||||
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG);
|
||||
|
||||
return '<script type="application/ld+json">'.$json.'</script>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate schema against schema.org specifications.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<string>}
|
||||
*/
|
||||
public function validate(array $schema): array
|
||||
{
|
||||
return SchemaValidator::validate($schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate schema with validation.
|
||||
*
|
||||
* @throws \InvalidArgumentException if schema validation fails
|
||||
*/
|
||||
public function generateValidatedSchema(ContentItem $item, array $options = []): array
|
||||
{
|
||||
$schema = $this->generateSchema($item, $options);
|
||||
$result = $this->validate($schema);
|
||||
|
||||
if (! $result['valid']) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Schema validation failed: '.implode(', ', $result['errors'])
|
||||
);
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ class SeoMetadata extends Model
|
|||
|
||||
/**
|
||||
* Generate JSON-LD script tag.
|
||||
*
|
||||
* Uses JSON_HEX_TAG to prevent XSS via </script> in content.
|
||||
*/
|
||||
public function getJsonLdAttribute(): string
|
||||
{
|
||||
|
|
@ -54,7 +56,7 @@ class SeoMetadata extends Model
|
|||
}
|
||||
|
||||
return '<script type="application/ld+json">'.
|
||||
json_encode($this->schema_markup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).
|
||||
json_encode($this->schema_markup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG).
|
||||
'</script>';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace Core\Seo\Services;
|
|||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Seo\Validation\SchemaValidator;
|
||||
|
||||
/**
|
||||
* Schema.org structured data builder.
|
||||
|
|
@ -224,11 +225,13 @@ class SchemaBuilderService
|
|||
|
||||
/**
|
||||
* Generate JSON-LD script tag.
|
||||
*
|
||||
* Uses JSON_HEX_TAG to prevent XSS via </script> in content.
|
||||
*/
|
||||
public function toScriptTag(array $schema): string
|
||||
{
|
||||
return '<script type="application/ld+json">'.
|
||||
json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT).
|
||||
json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG).
|
||||
'</script>';
|
||||
}
|
||||
|
||||
|
|
@ -256,4 +259,14 @@ class SchemaBuilderService
|
|||
|
||||
return "PT{$minutes}M";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate schema against schema.org specifications.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<string>, warnings: array<string>}
|
||||
*/
|
||||
public function validate(array $schema): array
|
||||
{
|
||||
return SchemaValidator::validate($schema);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
330
packages/core-php/src/Core/Seo/Validation/SchemaValidator.php
Normal file
330
packages/core-php/src/Core/Seo/Validation/SchemaValidator.php
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Seo\Validation;
|
||||
|
||||
/**
|
||||
* Validates JSON-LD schema against schema.org specifications.
|
||||
*
|
||||
* Performs basic validation of required properties for common schema types.
|
||||
* This is not a complete schema.org validator but covers the most common cases.
|
||||
*/
|
||||
class SchemaValidator
|
||||
{
|
||||
/**
|
||||
* Required properties for each schema type.
|
||||
*
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
private const REQUIRED_PROPERTIES = [
|
||||
'Article' => ['headline'],
|
||||
'TechArticle' => ['headline'],
|
||||
'BlogPosting' => ['headline'],
|
||||
'NewsArticle' => ['headline', 'datePublished'],
|
||||
'HowTo' => ['name', 'step'],
|
||||
'HowToStep' => ['text'],
|
||||
'FAQPage' => ['mainEntity'],
|
||||
'Question' => ['name', 'acceptedAnswer'],
|
||||
'Answer' => ['text'],
|
||||
'BreadcrumbList' => ['itemListElement'],
|
||||
'ListItem' => ['position'],
|
||||
'Organization' => ['name'],
|
||||
'Person' => ['name'],
|
||||
'WebSite' => ['name', 'url'],
|
||||
'WebPage' => [],
|
||||
'ImageObject' => ['url'],
|
||||
'SoftwareApplication' => ['name'],
|
||||
'LocalBusiness' => ['name'],
|
||||
'Product' => ['name'],
|
||||
'Offer' => ['price'],
|
||||
'AggregateRating' => ['ratingValue'],
|
||||
'Review' => ['reviewBody'],
|
||||
'Event' => ['name', 'startDate'],
|
||||
'Place' => ['name'],
|
||||
'PostalAddress' => [],
|
||||
'SearchAction' => ['target'],
|
||||
'EntryPoint' => ['urlTemplate'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Recommended properties for better SEO.
|
||||
*
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
private const RECOMMENDED_PROPERTIES = [
|
||||
'Article' => ['author', 'datePublished', 'dateModified', 'description', 'image'],
|
||||
'TechArticle' => ['author', 'datePublished', 'dateModified', 'description'],
|
||||
'BlogPosting' => ['author', 'datePublished', 'dateModified', 'description', 'image'],
|
||||
'HowTo' => ['description', 'totalTime'],
|
||||
'Organization' => ['url', 'logo'],
|
||||
'Product' => ['description', 'image', 'offers'],
|
||||
'LocalBusiness' => ['address', 'telephone'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid schema.org context URLs.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
private const VALID_CONTEXTS = [
|
||||
'https://schema.org',
|
||||
'http://schema.org',
|
||||
'https://schema.org/',
|
||||
'http://schema.org/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate a schema array.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<string>, warnings: array<string>}
|
||||
*/
|
||||
public static function validate(array $schema): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
// Check for @graph structure
|
||||
if (isset($schema['@graph'])) {
|
||||
$contextResult = self::validateContext($schema);
|
||||
$errors = array_merge($errors, $contextResult);
|
||||
|
||||
foreach ($schema['@graph'] as $index => $item) {
|
||||
$itemResult = self::validateSchemaItem($item, "graph[$index]");
|
||||
$errors = array_merge($errors, $itemResult['errors']);
|
||||
$warnings = array_merge($warnings, $itemResult['warnings']);
|
||||
}
|
||||
} else {
|
||||
$itemResult = self::validateSchemaItem($schema, 'root');
|
||||
$errors = array_merge($errors, $itemResult['errors']);
|
||||
$warnings = array_merge($warnings, $itemResult['warnings']);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate schema context.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private static function validateContext(array $schema): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (! isset($schema['@context'])) {
|
||||
$errors[] = 'Missing @context property';
|
||||
} elseif (! in_array($schema['@context'], self::VALID_CONTEXTS, true)) {
|
||||
$errors[] = 'Invalid @context: must be https://schema.org';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single schema item.
|
||||
*
|
||||
* @return array{errors: array<string>, warnings: array<string>}
|
||||
*/
|
||||
private static function validateSchemaItem(array $item, string $path): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
// Check for @type
|
||||
if (! isset($item['@type'])) {
|
||||
$errors[] = "$path: Missing @type property";
|
||||
|
||||
return ['errors' => $errors, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
$type = $item['@type'];
|
||||
|
||||
// Check required properties
|
||||
if (isset(self::REQUIRED_PROPERTIES[$type])) {
|
||||
foreach (self::REQUIRED_PROPERTIES[$type] as $property) {
|
||||
if (! isset($item[$property]) || self::isEmpty($item[$property])) {
|
||||
$errors[] = "$path ($type): Missing required property '$property'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check recommended properties
|
||||
if (isset(self::RECOMMENDED_PROPERTIES[$type])) {
|
||||
foreach (self::RECOMMENDED_PROPERTIES[$type] as $property) {
|
||||
if (! isset($item[$property]) || self::isEmpty($item[$property])) {
|
||||
$warnings[] = "$path ($type): Missing recommended property '$property'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate nested objects
|
||||
foreach ($item as $key => $value) {
|
||||
if (str_starts_with($key, '@')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
if (isset($value['@type'])) {
|
||||
$nestedResult = self::validateSchemaItem($value, "$path.$key");
|
||||
$errors = array_merge($errors, $nestedResult['errors']);
|
||||
$warnings = array_merge($warnings, $nestedResult['warnings']);
|
||||
} elseif (self::isIndexedArray($value)) {
|
||||
foreach ($value as $index => $nestedItem) {
|
||||
if (is_array($nestedItem) && isset($nestedItem['@type'])) {
|
||||
$nestedResult = self::validateSchemaItem($nestedItem, "$path.$key[$index]");
|
||||
$errors = array_merge($errors, $nestedResult['errors']);
|
||||
$warnings = array_merge($warnings, $nestedResult['warnings']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
$typeErrors = self::validateTypeSpecific($item, $type, $path);
|
||||
$errors = array_merge($errors, $typeErrors);
|
||||
|
||||
return ['errors' => $errors, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific validation rules.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private static function validateTypeSpecific(array $item, string $type, string $path): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
switch ($type) {
|
||||
case 'Article':
|
||||
case 'TechArticle':
|
||||
case 'BlogPosting':
|
||||
case 'NewsArticle':
|
||||
if (isset($item['headline']) && strlen($item['headline']) > 110) {
|
||||
$errors[] = "$path ($type): headline should be 110 characters or fewer";
|
||||
}
|
||||
if (isset($item['datePublished']) && ! self::isValidIso8601($item['datePublished'])) {
|
||||
$errors[] = "$path ($type): datePublished must be valid ISO 8601 format";
|
||||
}
|
||||
if (isset($item['dateModified']) && ! self::isValidIso8601($item['dateModified'])) {
|
||||
$errors[] = "$path ($type): dateModified must be valid ISO 8601 format";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'HowTo':
|
||||
if (isset($item['step']) && ! is_array($item['step'])) {
|
||||
$errors[] = "$path ($type): step must be an array";
|
||||
} elseif (isset($item['step']) && empty($item['step'])) {
|
||||
$errors[] = "$path ($type): step array cannot be empty";
|
||||
}
|
||||
if (isset($item['totalTime']) && ! self::isValidIsoDuration($item['totalTime'])) {
|
||||
$errors[] = "$path ($type): totalTime must be valid ISO 8601 duration (e.g., PT30M)";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'HowToStep':
|
||||
if (isset($item['position']) && (! is_int($item['position']) || $item['position'] < 1)) {
|
||||
$errors[] = "$path ($type): position must be a positive integer";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ListItem':
|
||||
if (isset($item['position']) && (! is_int($item['position']) || $item['position'] < 1)) {
|
||||
$errors[] = "$path ($type): position must be a positive integer";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Offer':
|
||||
if (isset($item['price']) && ! is_numeric($item['price']) && $item['price'] !== '0') {
|
||||
$errors[] = "$path ($type): price must be numeric";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'AggregateRating':
|
||||
if (isset($item['ratingValue'])) {
|
||||
$rating = (float) $item['ratingValue'];
|
||||
if ($rating < 0 || $rating > 5) {
|
||||
$errors[] = "$path ($type): ratingValue should be between 0 and 5";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ImageObject':
|
||||
if (isset($item['url']) && ! filter_var($item['url'], FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "$path ($type): url must be a valid URL";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is empty (null, empty string, or empty array).
|
||||
*/
|
||||
private static function isEmpty(mixed $value): bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_array($value) && empty($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an array is indexed (not associative).
|
||||
*/
|
||||
private static function isIndexedArray(array $array): bool
|
||||
{
|
||||
if (empty($array)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_keys($array) === range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ISO 8601 date format.
|
||||
*/
|
||||
private static function isValidIso8601(string $date): bool
|
||||
{
|
||||
// Match common ISO 8601 formats
|
||||
$patterns = [
|
||||
'/^\d{4}-\d{2}-\d{2}$/', // 2024-01-15
|
||||
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', // 2024-01-15T10:30:00
|
||||
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', // With timezone offset
|
||||
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', // UTC
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $date)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ISO 8601 duration format.
|
||||
*/
|
||||
private static function isValidIsoDuration(string $duration): bool
|
||||
{
|
||||
// Match ISO 8601 duration format: P[n]Y[n]M[n]DT[n]H[n]M[n]S
|
||||
return (bool) preg_match('/^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/', $duration);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service\Concerns;
|
||||
|
||||
use Core\Service\ServiceVersion;
|
||||
|
||||
/**
|
||||
* Default implementation of service versioning.
|
||||
*
|
||||
* Use this trait in ServiceDefinition implementations to provide
|
||||
* backward-compatible default versioning. Override version() to
|
||||
* specify a custom version or deprecation status.
|
||||
*
|
||||
* Example:
|
||||
* ```php
|
||||
* class MyService implements ServiceDefinition
|
||||
* {
|
||||
* use HasServiceVersion;
|
||||
*
|
||||
* // Uses default version 1.0.0
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Or with custom version:
|
||||
* ```php
|
||||
* class MyService implements ServiceDefinition
|
||||
* {
|
||||
* use HasServiceVersion;
|
||||
*
|
||||
* public static function version(): ServiceVersion
|
||||
* {
|
||||
* return new ServiceVersion(2, 3, 1);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
trait HasServiceVersion
|
||||
{
|
||||
/**
|
||||
* Get the service contract version.
|
||||
*
|
||||
* Override this method to specify a custom version or deprecation.
|
||||
*/
|
||||
public static function version(): ServiceVersion
|
||||
{
|
||||
return ServiceVersion::initial();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service\Contracts;
|
||||
|
||||
use Core\Service\HealthCheckResult;
|
||||
|
||||
/**
|
||||
* Contract for services that provide health checks.
|
||||
*
|
||||
* Services implementing this interface can report their operational
|
||||
* status for monitoring, load balancing, and alerting purposes.
|
||||
*
|
||||
* Health checks should be:
|
||||
* - Fast (< 5 seconds timeout recommended)
|
||||
* - Non-destructive (read-only operations)
|
||||
* - Representative of actual service health
|
||||
*
|
||||
* Example implementation:
|
||||
* ```php
|
||||
* public function healthCheck(): HealthCheckResult
|
||||
* {
|
||||
* try {
|
||||
* $start = microtime(true);
|
||||
* $this->database->select('SELECT 1');
|
||||
* $responseTime = (microtime(true) - $start) * 1000;
|
||||
*
|
||||
* if ($responseTime > 1000) {
|
||||
* return HealthCheckResult::degraded(
|
||||
* 'Database responding slowly',
|
||||
* ['response_time_ms' => $responseTime]
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* return HealthCheckResult::healthy(
|
||||
* 'All systems operational',
|
||||
* responseTimeMs: $responseTime
|
||||
* );
|
||||
* } catch (\Exception $e) {
|
||||
* return HealthCheckResult::fromException($e);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface HealthCheckable
|
||||
{
|
||||
/**
|
||||
* Perform a health check and return the result.
|
||||
*
|
||||
* Implementations should catch all exceptions and return
|
||||
* an appropriate HealthCheckResult rather than throwing.
|
||||
*/
|
||||
public function healthCheck(): HealthCheckResult;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Service\Contracts;
|
||||
|
||||
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||
use Core\Service\ServiceVersion;
|
||||
|
||||
/**
|
||||
* Contract for service definitions.
|
||||
|
|
@ -14,6 +15,34 @@ use Core\Front\Admin\Contracts\AdminMenuProvider;
|
|||
* the platform_services table and admin menu registration.
|
||||
*
|
||||
* Extends AdminMenuProvider to integrate with the admin menu system.
|
||||
*
|
||||
* ## Versioning
|
||||
*
|
||||
* Services should implement the version() method to declare their contract
|
||||
* version. This enables:
|
||||
* - Tracking breaking changes in service contracts
|
||||
* - Deprecation warnings before removing features
|
||||
* - Sunset date enforcement for deprecated versions
|
||||
*
|
||||
* Example:
|
||||
* ```php
|
||||
* public static function version(): ServiceVersion
|
||||
* {
|
||||
* return new ServiceVersion(2, 1, 0);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* For deprecated services:
|
||||
* ```php
|
||||
* public static function version(): ServiceVersion
|
||||
* {
|
||||
* return (new ServiceVersion(1, 0, 0))
|
||||
* ->deprecate(
|
||||
* 'Use ServiceV2 instead',
|
||||
* new \DateTimeImmutable('2025-06-01')
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface ServiceDefinition extends AdminMenuProvider
|
||||
{
|
||||
|
|
@ -33,4 +62,18 @@ interface ServiceDefinition extends AdminMenuProvider
|
|||
* }
|
||||
*/
|
||||
public static function definition(): array;
|
||||
|
||||
/**
|
||||
* Get the service contract version.
|
||||
*
|
||||
* Implementations should return a ServiceVersion indicating the
|
||||
* current version of this service's contract. This is used for:
|
||||
* - Compatibility checking between service consumers and providers
|
||||
* - Deprecation tracking and sunset enforcement
|
||||
* - Migration planning when breaking changes are introduced
|
||||
*
|
||||
* Default implementation returns version 1.0.0 for backward
|
||||
* compatibility with existing services.
|
||||
*/
|
||||
public static function version(): ServiceVersion;
|
||||
}
|
||||
|
|
|
|||
89
packages/core-php/src/Core/Service/Enums/ServiceStatus.php
Normal file
89
packages/core-php/src/Core/Service/Enums/ServiceStatus.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service\Enums;
|
||||
|
||||
/**
|
||||
* Service operational status.
|
||||
*
|
||||
* Represents the current state of a service for health monitoring
|
||||
* and status reporting purposes.
|
||||
*/
|
||||
enum ServiceStatus: string
|
||||
{
|
||||
case HEALTHY = 'healthy';
|
||||
case DEGRADED = 'degraded';
|
||||
case UNHEALTHY = 'unhealthy';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* Check if the status indicates the service is operational.
|
||||
*/
|
||||
public function isOperational(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY, self::DEGRADED => true,
|
||||
self::UNHEALTHY, self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for the status.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY => 'Healthy',
|
||||
self::DEGRADED => 'Degraded',
|
||||
self::UNHEALTHY => 'Unhealthy',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the severity level for logging/alerting.
|
||||
* Lower values = more severe.
|
||||
*/
|
||||
public function severity(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY => 0,
|
||||
self::DEGRADED => 1,
|
||||
self::UNHEALTHY => 2,
|
||||
self::UNKNOWN => 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create status from a boolean health check result.
|
||||
*/
|
||||
public static function fromBoolean(bool $healthy): self
|
||||
{
|
||||
return $healthy ? self::HEALTHY : self::UNHEALTHY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the worst status from multiple statuses.
|
||||
*
|
||||
* @param array<self> $statuses
|
||||
*/
|
||||
public static function worst(array $statuses): self
|
||||
{
|
||||
if (empty($statuses)) {
|
||||
return self::UNKNOWN;
|
||||
}
|
||||
|
||||
$worstSeverity = -1;
|
||||
$worstStatus = self::HEALTHY;
|
||||
|
||||
foreach ($statuses as $status) {
|
||||
if ($status->severity() > $worstSeverity) {
|
||||
$worstSeverity = $status->severity();
|
||||
$worstStatus = $status;
|
||||
}
|
||||
}
|
||||
|
||||
return $worstStatus;
|
||||
}
|
||||
}
|
||||
103
packages/core-php/src/Core/Service/HealthCheckResult.php
Normal file
103
packages/core-php/src/Core/Service/HealthCheckResult.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service;
|
||||
|
||||
use Core\Service\Enums\ServiceStatus;
|
||||
|
||||
/**
|
||||
* Result of a service health check.
|
||||
*
|
||||
* Encapsulates the status, message, and any diagnostic data
|
||||
* returned from a health check operation.
|
||||
*/
|
||||
final readonly class HealthCheckResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data Additional diagnostic data
|
||||
*/
|
||||
public function __construct(
|
||||
public ServiceStatus $status,
|
||||
public string $message = '',
|
||||
public array $data = [],
|
||||
public ?float $responseTimeMs = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a healthy result.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function healthy(string $message = 'Service is healthy', array $data = [], ?float $responseTimeMs = null): self
|
||||
{
|
||||
return new self(ServiceStatus::HEALTHY, $message, $data, $responseTimeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a degraded result.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function degraded(string $message, array $data = [], ?float $responseTimeMs = null): self
|
||||
{
|
||||
return new self(ServiceStatus::DEGRADED, $message, $data, $responseTimeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unhealthy result.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function unhealthy(string $message, array $data = [], ?float $responseTimeMs = null): self
|
||||
{
|
||||
return new self(ServiceStatus::UNHEALTHY, $message, $data, $responseTimeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unknown status result.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function unknown(string $message = 'Health check not available', array $data = []): self
|
||||
{
|
||||
return new self(ServiceStatus::UNKNOWN, $message, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result from an exception.
|
||||
*/
|
||||
public static function fromException(\Throwable $e): self
|
||||
{
|
||||
return self::unhealthy(
|
||||
message: $e->getMessage(),
|
||||
data: [
|
||||
'exception' => get_class($e),
|
||||
'code' => $e->getCode(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the result indicates operational status.
|
||||
*/
|
||||
public function isOperational(): bool
|
||||
{
|
||||
return $this->status->isOperational();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for JSON serialization.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'status' => $this->status->value,
|
||||
'message' => $this->message,
|
||||
'data' => $this->data ?: null,
|
||||
'response_time_ms' => $this->responseTimeMs,
|
||||
], fn ($v) => $v !== null);
|
||||
}
|
||||
}
|
||||
131
packages/core-php/src/Core/Service/ServiceVersion.php
Normal file
131
packages/core-php/src/Core/Service/ServiceVersion.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service;
|
||||
|
||||
/**
|
||||
* Represents a service version with deprecation information.
|
||||
*
|
||||
* Follows semantic versioning (major.minor.patch) with support
|
||||
* for deprecation notices and sunset dates.
|
||||
*/
|
||||
final readonly class ServiceVersion
|
||||
{
|
||||
public function __construct(
|
||||
public int $major,
|
||||
public int $minor = 0,
|
||||
public int $patch = 0,
|
||||
public bool $deprecated = false,
|
||||
public ?string $deprecationMessage = null,
|
||||
public ?\DateTimeInterface $sunsetDate = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a version from a string (e.g., "1.2.3").
|
||||
*/
|
||||
public static function fromString(string $version): self
|
||||
{
|
||||
$parts = explode('.', ltrim($version, 'v'));
|
||||
|
||||
return new self(
|
||||
major: (int) ($parts[0] ?? 1),
|
||||
minor: (int) ($parts[1] ?? 0),
|
||||
patch: (int) ($parts[2] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a version 1.0.0 instance.
|
||||
*/
|
||||
public static function initial(): self
|
||||
{
|
||||
return new self(1, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this version as deprecated.
|
||||
*/
|
||||
public function deprecate(string $message, ?\DateTimeInterface $sunsetDate = null): self
|
||||
{
|
||||
return new self(
|
||||
major: $this->major,
|
||||
minor: $this->minor,
|
||||
patch: $this->patch,
|
||||
deprecated: true,
|
||||
deprecationMessage: $message,
|
||||
sunsetDate: $sunsetDate,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the version is past its sunset date.
|
||||
*/
|
||||
public function isPastSunset(): bool
|
||||
{
|
||||
if ($this->sunsetDate === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->sunsetDate < new \DateTimeImmutable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another version.
|
||||
*
|
||||
* @return int -1 if less, 0 if equal, 1 if greater
|
||||
*/
|
||||
public function compare(self $other): int
|
||||
{
|
||||
if ($this->major !== $other->major) {
|
||||
return $this->major <=> $other->major;
|
||||
}
|
||||
|
||||
if ($this->minor !== $other->minor) {
|
||||
return $this->minor <=> $other->minor;
|
||||
}
|
||||
|
||||
return $this->patch <=> $other->patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this version is compatible with a minimum version.
|
||||
* Compatible if same major version and >= minor.patch.
|
||||
*/
|
||||
public function isCompatibleWith(self $minimum): bool
|
||||
{
|
||||
if ($this->major !== $minimum->major) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->compare($minimum) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version as string.
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return "{$this->major}.{$this->minor}.{$this->patch}";
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'version' => $this->toString(),
|
||||
'deprecated' => $this->deprecated ?: null,
|
||||
'deprecation_message' => $this->deprecationMessage,
|
||||
'sunset_date' => $this->sunsetDate?->format('Y-m-d'),
|
||||
], fn ($v) => $v !== null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service\Tests\Unit;
|
||||
|
||||
use Core\Service\Enums\ServiceStatus;
|
||||
use Core\Service\HealthCheckResult;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HealthCheckResultTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_creates_healthy_result(): void
|
||||
{
|
||||
$result = HealthCheckResult::healthy('All good', ['key' => 'value'], 15.5);
|
||||
|
||||
$this->assertSame(ServiceStatus::HEALTHY, $result->status);
|
||||
$this->assertSame('All good', $result->message);
|
||||
$this->assertSame(['key' => 'value'], $result->data);
|
||||
$this->assertSame(15.5, $result->responseTimeMs);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_degraded_result(): void
|
||||
{
|
||||
$result = HealthCheckResult::degraded('Slow response');
|
||||
|
||||
$this->assertSame(ServiceStatus::DEGRADED, $result->status);
|
||||
$this->assertSame('Slow response', $result->message);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_unhealthy_result(): void
|
||||
{
|
||||
$result = HealthCheckResult::unhealthy('Connection failed');
|
||||
|
||||
$this->assertSame(ServiceStatus::UNHEALTHY, $result->status);
|
||||
$this->assertSame('Connection failed', $result->message);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_unknown_result(): void
|
||||
{
|
||||
$result = HealthCheckResult::unknown();
|
||||
|
||||
$this->assertSame(ServiceStatus::UNKNOWN, $result->status);
|
||||
$this->assertSame('Health check not available', $result->message);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_from_exception(): void
|
||||
{
|
||||
$exception = new \RuntimeException('Database error', 500);
|
||||
$result = HealthCheckResult::fromException($exception);
|
||||
|
||||
$this->assertSame(ServiceStatus::UNHEALTHY, $result->status);
|
||||
$this->assertSame('Database error', $result->message);
|
||||
$this->assertSame('RuntimeException', $result->data['exception']);
|
||||
$this->assertSame(500, $result->data['code']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_checks_operational_status(): void
|
||||
{
|
||||
$this->assertTrue(HealthCheckResult::healthy()->isOperational());
|
||||
$this->assertTrue(HealthCheckResult::degraded('Slow')->isOperational());
|
||||
$this->assertFalse(HealthCheckResult::unhealthy('Down')->isOperational());
|
||||
$this->assertFalse(HealthCheckResult::unknown()->isOperational());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_converts_to_array(): void
|
||||
{
|
||||
$result = HealthCheckResult::healthy(
|
||||
'Service operational',
|
||||
['version' => '1.0'],
|
||||
12.34
|
||||
);
|
||||
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertSame('healthy', $array['status']);
|
||||
$this->assertSame('Service operational', $array['message']);
|
||||
$this->assertSame(['version' => '1.0'], $array['data']);
|
||||
$this->assertSame(12.34, $array['response_time_ms']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_omits_null_values_in_array(): void
|
||||
{
|
||||
$result = HealthCheckResult::healthy();
|
||||
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertArrayHasKey('status', $array);
|
||||
$this->assertArrayHasKey('message', $array);
|
||||
$this->assertArrayNotHasKey('data', $array);
|
||||
$this->assertArrayNotHasKey('response_time_ms', $array);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_uses_default_healthy_message(): void
|
||||
{
|
||||
$result = HealthCheckResult::healthy();
|
||||
|
||||
$this->assertSame('Service is healthy', $result->message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service\Tests\Unit;
|
||||
|
||||
use Core\Service\Enums\ServiceStatus;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ServiceStatusTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_has_expected_values(): void
|
||||
{
|
||||
$this->assertSame('healthy', ServiceStatus::HEALTHY->value);
|
||||
$this->assertSame('degraded', ServiceStatus::DEGRADED->value);
|
||||
$this->assertSame('unhealthy', ServiceStatus::UNHEALTHY->value);
|
||||
$this->assertSame('unknown', ServiceStatus::UNKNOWN->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_checks_operational_status(): void
|
||||
{
|
||||
$this->assertTrue(ServiceStatus::HEALTHY->isOperational());
|
||||
$this->assertTrue(ServiceStatus::DEGRADED->isOperational());
|
||||
$this->assertFalse(ServiceStatus::UNHEALTHY->isOperational());
|
||||
$this->assertFalse(ServiceStatus::UNKNOWN->isOperational());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_provides_human_readable_labels(): void
|
||||
{
|
||||
$this->assertSame('Healthy', ServiceStatus::HEALTHY->label());
|
||||
$this->assertSame('Degraded', ServiceStatus::DEGRADED->label());
|
||||
$this->assertSame('Unhealthy', ServiceStatus::UNHEALTHY->label());
|
||||
$this->assertSame('Unknown', ServiceStatus::UNKNOWN->label());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_has_severity_ordering(): void
|
||||
{
|
||||
$this->assertLessThan(
|
||||
ServiceStatus::DEGRADED->severity(),
|
||||
ServiceStatus::HEALTHY->severity()
|
||||
);
|
||||
|
||||
$this->assertLessThan(
|
||||
ServiceStatus::UNHEALTHY->severity(),
|
||||
ServiceStatus::DEGRADED->severity()
|
||||
);
|
||||
|
||||
$this->assertLessThan(
|
||||
ServiceStatus::UNKNOWN->severity(),
|
||||
ServiceStatus::UNHEALTHY->severity()
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_from_boolean(): void
|
||||
{
|
||||
$this->assertSame(ServiceStatus::HEALTHY, ServiceStatus::fromBoolean(true));
|
||||
$this->assertSame(ServiceStatus::UNHEALTHY, ServiceStatus::fromBoolean(false));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_worst_status(): void
|
||||
{
|
||||
$statuses = [
|
||||
ServiceStatus::HEALTHY,
|
||||
ServiceStatus::DEGRADED,
|
||||
ServiceStatus::HEALTHY,
|
||||
];
|
||||
|
||||
$this->assertSame(ServiceStatus::DEGRADED, ServiceStatus::worst($statuses));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_returns_unknown_for_empty_array(): void
|
||||
{
|
||||
$this->assertSame(ServiceStatus::UNKNOWN, ServiceStatus::worst([]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_most_severe_status(): void
|
||||
{
|
||||
$statuses = [
|
||||
ServiceStatus::HEALTHY,
|
||||
ServiceStatus::UNHEALTHY,
|
||||
ServiceStatus::DEGRADED,
|
||||
];
|
||||
|
||||
$this->assertSame(ServiceStatus::UNHEALTHY, ServiceStatus::worst($statuses));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Service\Tests\Unit;
|
||||
|
||||
use Core\Service\ServiceVersion;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ServiceVersionTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_creates_version_with_all_components(): void
|
||||
{
|
||||
$version = new ServiceVersion(2, 3, 4);
|
||||
|
||||
$this->assertSame(2, $version->major);
|
||||
$this->assertSame(3, $version->minor);
|
||||
$this->assertSame(4, $version->patch);
|
||||
$this->assertFalse($version->deprecated);
|
||||
$this->assertNull($version->deprecationMessage);
|
||||
$this->assertNull($version->sunsetDate);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_initial_version(): void
|
||||
{
|
||||
$version = ServiceVersion::initial();
|
||||
|
||||
$this->assertSame('1.0.0', $version->toString());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_creates_version_from_string(): void
|
||||
{
|
||||
$version = ServiceVersion::fromString('2.3.4');
|
||||
|
||||
$this->assertSame(2, $version->major);
|
||||
$this->assertSame(3, $version->minor);
|
||||
$this->assertSame(4, $version->patch);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_handles_string_with_v_prefix(): void
|
||||
{
|
||||
$version = ServiceVersion::fromString('v1.2.3');
|
||||
|
||||
$this->assertSame('1.2.3', $version->toString());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_handles_partial_version_string(): void
|
||||
{
|
||||
$version = ServiceVersion::fromString('2');
|
||||
|
||||
$this->assertSame(2, $version->major);
|
||||
$this->assertSame(0, $version->minor);
|
||||
$this->assertSame(0, $version->patch);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_marks_version_as_deprecated(): void
|
||||
{
|
||||
$version = new ServiceVersion(1, 0, 0);
|
||||
$sunset = new \DateTimeImmutable('2025-06-01');
|
||||
|
||||
$deprecated = $version->deprecate('Use v2 instead', $sunset);
|
||||
|
||||
$this->assertTrue($deprecated->deprecated);
|
||||
$this->assertSame('Use v2 instead', $deprecated->deprecationMessage);
|
||||
$this->assertEquals($sunset, $deprecated->sunsetDate);
|
||||
// Original version should be unchanged
|
||||
$this->assertFalse($version->deprecated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_detects_past_sunset_date(): void
|
||||
{
|
||||
$version = (new ServiceVersion(1, 0, 0))
|
||||
->deprecate('Old version', new \DateTimeImmutable('2020-01-01'));
|
||||
|
||||
$this->assertTrue($version->isPastSunset());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_detects_future_sunset_date(): void
|
||||
{
|
||||
$version = (new ServiceVersion(1, 0, 0))
|
||||
->deprecate('Will be removed', new \DateTimeImmutable('2099-01-01'));
|
||||
|
||||
$this->assertFalse($version->isPastSunset());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_compares_versions_correctly(): void
|
||||
{
|
||||
$v1 = new ServiceVersion(1, 0, 0);
|
||||
$v2 = new ServiceVersion(2, 0, 0);
|
||||
$v1_1 = new ServiceVersion(1, 1, 0);
|
||||
$v1_0_1 = new ServiceVersion(1, 0, 1);
|
||||
|
||||
$this->assertSame(-1, $v1->compare($v2));
|
||||
$this->assertSame(1, $v2->compare($v1));
|
||||
$this->assertSame(0, $v1->compare(new ServiceVersion(1, 0, 0)));
|
||||
$this->assertSame(-1, $v1->compare($v1_1));
|
||||
$this->assertSame(-1, $v1->compare($v1_0_1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_checks_compatibility(): void
|
||||
{
|
||||
$v2_3 = new ServiceVersion(2, 3, 0);
|
||||
$v2_1 = new ServiceVersion(2, 1, 0);
|
||||
$v2_5 = new ServiceVersion(2, 5, 0);
|
||||
$v3_0 = new ServiceVersion(3, 0, 0);
|
||||
|
||||
$this->assertTrue($v2_3->isCompatibleWith($v2_1));
|
||||
$this->assertFalse($v2_3->isCompatibleWith($v2_5));
|
||||
$this->assertFalse($v2_3->isCompatibleWith($v3_0));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_converts_to_string(): void
|
||||
{
|
||||
$version = new ServiceVersion(1, 2, 3);
|
||||
|
||||
$this->assertSame('1.2.3', $version->toString());
|
||||
$this->assertSame('1.2.3', (string) $version);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_converts_to_array(): void
|
||||
{
|
||||
$version = (new ServiceVersion(1, 2, 3))
|
||||
->deprecate('Use v2', new \DateTimeImmutable('2025-06-01'));
|
||||
|
||||
$array = $version->toArray();
|
||||
|
||||
$this->assertSame('1.2.3', $array['version']);
|
||||
$this->assertTrue($array['deprecated']);
|
||||
$this->assertSame('Use v2', $array['deprecation_message']);
|
||||
$this->assertSame('2025-06-01', $array['sunset_date']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_omits_null_values_in_array(): void
|
||||
{
|
||||
$version = new ServiceVersion(1, 0, 0);
|
||||
|
||||
$array = $version->toArray();
|
||||
|
||||
$this->assertArrayHasKey('version', $array);
|
||||
$this->assertArrayNotHasKey('deprecated', $array);
|
||||
$this->assertArrayNotHasKey('deprecation_message', $array);
|
||||
$this->assertArrayNotHasKey('sunset_date', $array);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Storage;
|
||||
|
||||
use Core\Storage\Events\RedisFallbackActivated;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
|
@ -73,29 +75,52 @@ class CacheResilienceProvider extends ServiceProvider
|
|||
|
||||
/**
|
||||
* Check if Redis is available and responding.
|
||||
*
|
||||
* Supports both phpredis extension and Predis library.
|
||||
*/
|
||||
protected function isRedisAvailable(): bool
|
||||
{
|
||||
try {
|
||||
$redis = new \Redis;
|
||||
$host = config('database.redis.default.host', '127.0.0.1');
|
||||
$port = (int) config('database.redis.default.port', 6379);
|
||||
$password = config('database.redis.default.password');
|
||||
$timeout = 1.0; // 1 second timeout
|
||||
|
||||
// Try to connect with short timeout
|
||||
// Try phpredis extension first (faster)
|
||||
if (extension_loaded('redis')) {
|
||||
return $this->checkPhpRedis($host, $port, $password, $timeout);
|
||||
}
|
||||
|
||||
// Fall back to Predis library
|
||||
if (class_exists(\Predis\Client::class)) {
|
||||
return $this->checkPredis($host, $port, $password, $timeout);
|
||||
}
|
||||
|
||||
// No Redis client available
|
||||
return false;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Redis availability using phpredis extension.
|
||||
*/
|
||||
protected function checkPhpRedis(string $host, int $port, ?string $password, float $timeout): bool
|
||||
{
|
||||
try {
|
||||
$redis = new \Redis;
|
||||
|
||||
if (! @$redis->connect($host, $port, $timeout)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Authenticate if password is set
|
||||
$password = config('database.redis.default.password');
|
||||
if ($password && ! @$redis->auth($password)) {
|
||||
$redis->close();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify with PING
|
||||
$pong = @$redis->ping();
|
||||
$redis->close();
|
||||
|
||||
|
|
@ -105,14 +130,46 @@ class CacheResilienceProvider extends ServiceProvider
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Redis availability using Predis library.
|
||||
*/
|
||||
protected function checkPredis(string $host, int $port, ?string $password, float $timeout): bool
|
||||
{
|
||||
try {
|
||||
$options = [
|
||||
'scheme' => 'tcp',
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'timeout' => $timeout,
|
||||
];
|
||||
|
||||
if ($password) {
|
||||
$options['password'] = $password;
|
||||
}
|
||||
|
||||
$client = new \Predis\Client($options, [
|
||||
'exceptions' => true,
|
||||
]);
|
||||
|
||||
$pong = $client->ping();
|
||||
$client->disconnect();
|
||||
|
||||
return $pong->getPayload() === 'PONG';
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch cache and session to use database.
|
||||
*/
|
||||
protected function applyDatabaseFallback(): void
|
||||
{
|
||||
// Log once so we know fallback is active
|
||||
$logLevel = config('core.storage.fallback_log_level', 'warning');
|
||||
|
||||
// Log so we know fallback is active
|
||||
if (! $this->app->runningInConsole()) {
|
||||
Log::warning('[CacheResilience] Redis unavailable, using database for cache/session');
|
||||
Log::log($logLevel, '[CacheResilience] Redis unavailable at boot, using database for cache/session');
|
||||
}
|
||||
|
||||
// Override cache driver
|
||||
|
|
@ -127,6 +184,29 @@ class CacheResilienceProvider extends ServiceProvider
|
|||
if (config('queue.default') === 'redis') {
|
||||
config(['queue.default' => 'database']);
|
||||
}
|
||||
|
||||
// Dispatch event for monitoring/alerting
|
||||
$this->dispatchFallbackEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the fallback event for monitoring/alerting.
|
||||
*/
|
||||
protected function dispatchFallbackEvent(): void
|
||||
{
|
||||
if (! config('core.storage.dispatch_fallback_events', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch after the app is booted to ensure event listeners are registered
|
||||
$this->app->booted(function () {
|
||||
$dispatcher = $this->app->make(Dispatcher::class);
|
||||
$dispatcher->dispatch(new RedisFallbackActivated(
|
||||
context: 'boot',
|
||||
errorMessage: 'Redis unavailable during application boot',
|
||||
fallbackDriver: 'database'
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Storage\Events;
|
||||
|
||||
/**
|
||||
* Dispatched when Redis becomes unavailable and fallback is activated.
|
||||
*
|
||||
* Listeners can use this event to trigger alerts, notifications,
|
||||
* or other monitoring actions when Redis fails.
|
||||
*/
|
||||
class RedisFallbackActivated
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $context,
|
||||
public readonly string $errorMessage,
|
||||
public readonly string $fallbackDriver = 'database',
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Storage;
|
||||
|
||||
use Core\Storage\Events\RedisFallbackActivated;
|
||||
use Illuminate\Cache\DatabaseStore;
|
||||
use Illuminate\Cache\RedisStore;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +20,7 @@ class ResilientRedisStore extends RedisStore
|
|||
{
|
||||
protected ?DatabaseStore $fallbackStore = null;
|
||||
|
||||
protected bool $redisFailed = false;
|
||||
protected bool $fallbackActivated = false;
|
||||
|
||||
/**
|
||||
* Get the fallback database store.
|
||||
|
|
@ -34,17 +38,61 @@ class ResilientRedisStore extends RedisStore
|
|||
return $this->fallbackStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Redis failure by logging and optionally dispatching an event.
|
||||
*
|
||||
* @throws \Throwable When silent_fallback is disabled
|
||||
*/
|
||||
protected function handleRedisFailure(\Throwable $e): void
|
||||
{
|
||||
$silentFallback = config('core.storage.silent_fallback', true);
|
||||
|
||||
if (! $silentFallback) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->logFallback($e);
|
||||
$this->dispatchFallbackEvent($e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the fallback (once per request).
|
||||
*/
|
||||
protected function logFallback(\Throwable $e): void
|
||||
{
|
||||
if (! $this->redisFailed) {
|
||||
$this->redisFailed = true;
|
||||
Log::warning('[Cache] Redis unavailable, using database fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
if ($this->fallbackActivated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logLevel = config('core.storage.fallback_log_level', 'warning');
|
||||
|
||||
Log::log($logLevel, '[Cache] Redis unavailable, using database fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the fallback event for monitoring/alerting (once per request).
|
||||
*/
|
||||
protected function dispatchFallbackEvent(\Throwable $e): void
|
||||
{
|
||||
if ($this->fallbackActivated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fallbackActivated = true;
|
||||
|
||||
if (! config('core.storage.dispatch_fallback_events', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dispatcher = app(Dispatcher::class);
|
||||
$dispatcher->dispatch(new RedisFallbackActivated(
|
||||
context: 'cache_operation',
|
||||
errorMessage: $e->getMessage(),
|
||||
fallbackDriver: 'database'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,7 +103,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::get($key);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->get($key);
|
||||
}
|
||||
|
|
@ -69,7 +117,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::many($keys);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->many($keys);
|
||||
}
|
||||
|
|
@ -83,7 +131,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::put($key, $value, $seconds);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->put($key, $value, $seconds);
|
||||
}
|
||||
|
|
@ -97,7 +145,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::putMany($values, $seconds);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->putMany($values, $seconds);
|
||||
}
|
||||
|
|
@ -111,7 +159,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::increment($key, $value);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->increment($key, $value);
|
||||
}
|
||||
|
|
@ -125,7 +173,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::decrement($key, $value);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->decrement($key, $value);
|
||||
}
|
||||
|
|
@ -139,7 +187,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::forever($key, $value);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->forever($key, $value);
|
||||
}
|
||||
|
|
@ -153,7 +201,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::forget($key);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->forget($key);
|
||||
}
|
||||
|
|
@ -167,7 +215,7 @@ class ResilientRedisStore extends RedisStore
|
|||
try {
|
||||
return parent::flush();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logFallback($e);
|
||||
$this->handleRedisFailure($e);
|
||||
|
||||
return $this->getFallbackStore()->flush();
|
||||
}
|
||||
|
|
|
|||
314
packages/core-php/src/Core/TODO.md
Normal file
314
packages/core-php/src/Core/TODO.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# Core PHP Framework - Code Review Findings
|
||||
|
||||
Generated from comprehensive Opus-level code review of all Core/* modules.
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count | Status |
|
||||
|----------|-------|--------|
|
||||
| Critical | 15 | **All Fixed** |
|
||||
| High | 52 | **46 Fixed, 2 Partial, 4 Remaining** |
|
||||
| Medium | 38 | Pending |
|
||||
| Low | 25 | Pending |
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues (Fixed)
|
||||
|
||||
### Bouncer/BlocklistService.php
|
||||
- [x] **Missing table existence check** - Queries `blocked_ips` table without checking if it exists, causing crashes before migrations run. *Fixed: Added cached `tableExists()` check.*
|
||||
|
||||
### Cdn/Services/StorageUrlResolver.php
|
||||
- [x] **Weak token hashing** - Used `hash('sha256')` instead of `hash_hmac('sha256')` for BunnyCDN token authentication. *Fixed: Changed to HMAC-SHA256.*
|
||||
|
||||
### Config/ConfigService.php
|
||||
- [x] **SQL injection via LIKE wildcards** - `getByPrefix()` and `deleteByPrefix()` don't escape `%` and `_` characters. *Fixed: Added wildcard escaping.*
|
||||
|
||||
### Console/Boot.php
|
||||
- [x] **References non-existent commands** - Registers `MakeModCommand`, `MakePlugCommand`, `MakeWebsiteCommand` which don't exist. *Fixed: Commented out missing commands.*
|
||||
|
||||
### Console/Commands/InstallCommand.php
|
||||
- [x] **Regex injection** - `updateEnv()` uses `$key` directly in regex without escaping. *Fixed: Added `preg_quote()`.*
|
||||
|
||||
### Input/Sanitiser.php
|
||||
- [x] **Nested arrays become null** - `filter_var_array()` doesn't handle nested arrays, silently returning null. *Fixed: Implemented recursive filtering.*
|
||||
|
||||
### Mail/EmailShieldStat.php
|
||||
- [x] **Race condition** - `firstOrCreate()` + `increment()` can lose counts under concurrent requests. *Fixed: Changed to atomic `insertOrIgnore()` + `increment()`.*
|
||||
|
||||
### ModuleScanner.php
|
||||
- [x] **Duplicate code** - Had duplicate `Mod` namespace check. *Fixed: Removed duplicate.*
|
||||
- [x] **Missing namespaces** - Missing `Website` and `Plug` namespace handling. *Fixed: Added both namespaces.*
|
||||
|
||||
### Search/Unified.php
|
||||
- [x] **Missing class_exists check** - `searchPlans()` uses `AgentPlan` without checking if class exists (unlike other model searches). *Fixed: Added guard.*
|
||||
|
||||
### Seo/Schema.php
|
||||
- [x] **XSS vulnerability** - `toScriptTag()` doesn't use `JSON_HEX_TAG`, allowing `</script>` injection. *Fixed: Added flag.*
|
||||
|
||||
### Seo/Services/SchemaBuilderService.php
|
||||
- [x] **XSS vulnerability** - Same `</script>` injection issue. *Fixed: Added `JSON_HEX_TAG`.*
|
||||
|
||||
### Seo/SeoMetadata.php
|
||||
- [x] **XSS vulnerability** - Same `</script>` injection issue in `getJsonLdAttribute()`. *Fixed: Added `JSON_HEX_TAG`.*
|
||||
|
||||
### Storage/CacheResilienceProvider.php
|
||||
- [x] **Hardcoded phpredis** - Uses `new \Redis()` directly, failing if Predis is used instead. *Fixed: Added Predis support with fallback.*
|
||||
|
||||
---
|
||||
|
||||
## High Severity Issues (Fixed)
|
||||
|
||||
### Bouncer/ (2/3 Fixed)
|
||||
- [x] `BlocklistService.php` - `syncFromHoneypot()` blocks all critical IPs without human review. *Fixed: Added pending/approved/rejected status workflow with migration.*
|
||||
- [ ] `HoneypotMiddleware.php` - No rate limiting on honeypot logging, potential DoS vector *(Partial: TeapotController still auto-approves critical blocks)*
|
||||
- [ ] `HoneypotMiddleware.php` - Hardcoded severity levels should be configurable
|
||||
|
||||
### Cdn/ (3/5 Fixed)
|
||||
- [x] `BunnyStorageService.php` - No retry logic for failed uploads. *Fixed: Added exponential backoff with 3 retries.*
|
||||
- [x] `BunnyStorageService.php` - Missing file size validation before upload. *Fixed: 100MB default limit with configurable max.*
|
||||
- [x] `BunnyCdnService.php` - API key exposed in error messages on failure. *Fixed: sanitizeErrorMessage() redacts all API keys.*
|
||||
- [ ] `StorageUrlResolver.php` - Signed URL expiry not configurable per-request
|
||||
- [ ] Missing integration tests for CDN operations
|
||||
|
||||
### Config/ (2/4 Fixed)
|
||||
- [x] `ConfigService.php` - No validation of config value types against schema. *Fixed: validateValueType() with comprehensive type checking.*
|
||||
- [x] `ConfigResolver.php` - Recursive `resolveJsonSubKey()` could cause stack overflow on deep nesting. *Fixed: MAX_SUBKEY_DEPTH=10 with proper cleanup.*
|
||||
- [ ] `ConfigKey.php` - Missing index on `category` column for `resolveCategory()` queries
|
||||
- [ ] No cache invalidation strategy documented
|
||||
|
||||
### Console/ (3/3 Fixed)
|
||||
- [x] `InstallCommand.php` - Database credentials logged in plain text during setup. *Fixed: maskValue() hides passwords and usernames.*
|
||||
- [x] `InstallCommand.php` - No rollback on partial installation failure. *Fixed: Comprehensive rollback() with state tracking.*
|
||||
- [x] Missing `MakeModCommand`, `MakePlugCommand`, `MakeWebsiteCommand` implementations. *Fixed: Created all three scaffold generators with comprehensive functionality.*
|
||||
|
||||
### Crypt/ (3/3 Fixed)
|
||||
- [x] `LthnHash.php` - No key rotation mechanism for hash secrets. *Fixed: Multi-key support with addKeyMap(), verify() tries all keys.*
|
||||
- [x] `LthnHash.php` - Weak collision resistance on short identifiers (16 chars). *Fixed: Added MEDIUM_LENGTH=24 and LONG_LENGTH=32 options.*
|
||||
- [x] Missing documentation on QuasiHash security properties. *Fixed: Comprehensive 46-line PHPDoc with security guidance.*
|
||||
|
||||
### Events/ (3/3 Fixed)
|
||||
- [x] `LifecycleEvent.php` - No event prioritization mechanism. *Fixed: ModuleScanner/ModuleRegistry support priority via array syntax: `EventClass => ['method', priority]`.*
|
||||
- [x] Missing event replay/audit logging capability. *Fixed: Added EventAuditLog class with recordStart/recordSuccess/recordFailure methods.*
|
||||
- [x] No dead letter queue for failed event handlers. *Fixed: LazyModuleListener tracks failures via EventAuditLog.recordFailure(), accessible via EventAuditLog::failures().*
|
||||
|
||||
### Front/ (3/3 Fixed)
|
||||
- [x] `AdminMenuProvider.php` - No permission checks in contract definition. *Fixed: Added `menuPermissions()` and `canViewMenu()` methods to interface, plus HasMenuPermissions trait.*
|
||||
- [x] Missing menu item caching strategy. *Fixed: AdminMenuRegistry now uses Laravel Cache with configurable TTL via `core.admin_menu.cache_ttl`.*
|
||||
- [x] No support for dynamic menu items. *Fixed: Created DynamicMenuProvider interface with `dynamicMenuItems()` method, never cached.*
|
||||
|
||||
### Headers/ (3/3 Fixed)
|
||||
- [x] `SecurityHeadersMiddleware.php` - CSP policy too permissive (unsafe-inline). *Fixed: Configurable CSP, unsafe-inline only in dev environments.*
|
||||
- [x] `SecurityHeadersMiddleware.php` - Missing Permissions-Policy header. *Fixed: Added with 19 feature controls.*
|
||||
- [x] No environment-specific header configuration. *Fixed: Environment overrides in config.*
|
||||
|
||||
### Input/ (3/3 Fixed)
|
||||
- [x] `Sanitiser.php` - No configurable filter rules per field. *Fixed: Schema array support with per-field filters, options, and skip flags.*
|
||||
- [x] `Sanitiser.php` - Missing Unicode normalisation (NFC). *Fixed: `Normalizer::normalize()` applied by default (intl extension).*
|
||||
- [x] No audit logging of sanitised content. *Fixed: PSR-3 logger support via `withLogger()`, logs field paths and changes.*
|
||||
|
||||
### Lang/ (3/3 Fixed)
|
||||
- [x] `Boot.php` - Service provider not auto-discovered. *Fixed: Created LangServiceProvider registered in composer.json extra.laravel.providers.*
|
||||
- [x] Missing fallback locale chain support. *Fixed: LangServiceProvider implements buildFallbackChain() for en_GB->en->fallback resolution.*
|
||||
- [x] No translation key validation. *Fixed: handleMissingKeysUsing() logs warnings in dev environments.*
|
||||
|
||||
### Mail/ (2/3 Fixed, 1 Partial)
|
||||
- [x] `EmailShield.php` - Disposable domain list not automatically updated. *Fixed: updateDisposableDomainsList() with HTTP fetch and validation.*
|
||||
- [x] `EmailShield.php` - No caching of DNS lookups for MX validation. *Fixed: 1-hour MX cache with Cache::remember().*
|
||||
- [~] `EmailShieldStat.php` - No data retention/cleanup policy. *Partial: pruneOldRecords() exists but not scheduled.*
|
||||
|
||||
### Media/ (3/4 Fixed)
|
||||
- [x] `MediaImageResizerConversion.php` - Dependencies on `Core\Mod\Social` which may not exist. *Fixed: Created local Core\Media abstracts and support classes, updated imports.*
|
||||
- [x] `MediaVideoThumbConversion.php` - Same dependency issue. *Fixed: Same approach, using local classes instead of Core\Mod\Social.*
|
||||
- [x] Missing memory limit checks before image processing. *Fixed: Added hasEnoughMemory(), estimateRequiredMemory(), getAvailableMemory() with safety factor.*
|
||||
- [ ] No support for HEIC/AVIF formats
|
||||
|
||||
### Search/ (3/3 Fixed)
|
||||
- [x] `Unified.php` - Hardcoded API endpoints instead of dynamic discovery. *Fixed: Moved to config.*
|
||||
- [x] `Unified.php` - No search result caching. *Fixed: 60s cache with Cache::remember().*
|
||||
- [x] `Unified.php` - LIKE queries vulnerable to wildcard DoS (`%%%%%`). *Fixed: MAX_WILDCARDS=3 with escapeLikeQuery().*
|
||||
|
||||
### Seo/ (2/3 Fixed)
|
||||
- [x] `Schema.php` - No validation of schema against schema.org specifications. *Fixed: SchemaValidator with 23+ type support.*
|
||||
- [x] Sitemap generation. *Already existed in SitemapController.php.*
|
||||
- [ ] `SeoAnalyser.php` - Keyword density calculations don't account for stop words *(File doesn't exist)*
|
||||
|
||||
### Service/ (2/2 Fixed)
|
||||
- [x] `ServiceDefinition.php` - No versioning strategy for service contracts. *Fixed: ServiceVersion class with semver support, deprecation marking, HasServiceVersion trait.*
|
||||
- [x] Missing service health check interface. *Fixed: HealthCheckable interface, HealthCheckResult class, ServiceStatus enum.*
|
||||
|
||||
### Storage/ (2/3 Fixed)
|
||||
- [x] `ResilientRedisStore.php` - Silent fallback may hide persistent Redis issues. *Fixed: Configurable logging and exception throwing.*
|
||||
- [x] `CacheResilienceProvider.php` - No alerting when fallback is activated. *Fixed: RedisFallbackActivated event dispatched.*
|
||||
- [ ] Missing cache warming strategy
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity Issues (Pending)
|
||||
|
||||
### Bouncer/
|
||||
- [ ] `HoneypotMiddleware.php` - Magic strings for route patterns should be constants
|
||||
- [ ] `BlocklistService.php` - Missing pagination for large blocklists
|
||||
|
||||
### Cdn/
|
||||
- [ ] `StorageUrlResolver.php` - Inconsistent URL building (some methods use config, others hardcode)
|
||||
- [ ] `BunnyStorageService.php` - Missing content-type detection
|
||||
- [ ] No CDN health check endpoint
|
||||
|
||||
### Config/
|
||||
- [ ] `ConfigProfile.php` - Missing soft deletes for audit trail
|
||||
- [ ] `ConfigValue.php` - No encryption for sensitive config values
|
||||
- [ ] `ConfigResolver.php` - Provider pattern could use interface instead of callable
|
||||
|
||||
### Console/
|
||||
- [ ] `InstallCommand.php` - Progress bar would improve UX for long operations
|
||||
- [ ] No --dry-run option for install command
|
||||
|
||||
### Crypt/
|
||||
- [ ] `LthnHash.php` - Consider using SipHash for better performance on short inputs
|
||||
- [ ] Missing benchmarks for hash operations
|
||||
|
||||
### Events/
|
||||
- [ ] Event classes missing PHPDoc for IDE support
|
||||
- [ ] No event versioning for backwards compatibility
|
||||
|
||||
### Front/
|
||||
- [ ] `AdminMenuProvider.php` - Missing icon validation
|
||||
- [ ] No menu item ordering specification
|
||||
|
||||
### Headers/
|
||||
- [ ] `SecurityHeadersMiddleware.php` - Consider using nonce-based CSP
|
||||
- [ ] Missing header configuration UI
|
||||
|
||||
### Input/
|
||||
- [ ] `Sanitiser.php` - Consider allowing HTML subset for rich text fields
|
||||
- [ ] No max input length enforcement
|
||||
|
||||
### Lang/
|
||||
- [ ] Translation files missing pluralisation rules
|
||||
- [ ] No ICU message format support
|
||||
|
||||
### Mail/
|
||||
- [ ] `EmailShield.php` - Consider async validation for better UX
|
||||
- [ ] Missing email normalisation (gmail dots, plus addressing)
|
||||
|
||||
### Media/
|
||||
- [ ] Conversions should use queue for large files
|
||||
- [ ] Missing EXIF data stripping for privacy
|
||||
- [ ] No progressive JPEG support
|
||||
|
||||
### Search/
|
||||
- [ ] `Unified.php` - Search scoring algorithm needs tuning
|
||||
- [ ] Missing fuzzy search support
|
||||
- [ ] No search analytics tracking
|
||||
|
||||
### Seo/
|
||||
- [ ] `SeoMetadata.php` - Consider lazy loading schema_markup
|
||||
- [ ] Missing Open Graph image dimension validation
|
||||
- [ ] No canonical URL conflict detection
|
||||
|
||||
### Service/
|
||||
- [ ] `ServiceDefinition.php` - Missing service dependency declaration
|
||||
- [ ] No service discovery mechanism
|
||||
|
||||
### Storage/
|
||||
- [ ] `ResilientRedisStore.php` - Consider circuit breaker pattern
|
||||
- [ ] Missing storage metrics collection
|
||||
|
||||
---
|
||||
|
||||
## Low Severity Issues (Pending)
|
||||
|
||||
### Bouncer/
|
||||
- [ ] Add unit tests for BlocklistService
|
||||
- [ ] Document honeypot configuration options
|
||||
|
||||
### Cdn/
|
||||
- [ ] Add PHPDoc return types to all methods
|
||||
- [ ] Consider extracting URL building to dedicated class
|
||||
|
||||
### Config/
|
||||
- [ ] Add config import/export functionality
|
||||
- [ ] Consider config versioning for rollback
|
||||
|
||||
### Console/
|
||||
- [ ] Add command autocompletion hints
|
||||
- [ ] Colorize output for better readability
|
||||
|
||||
### Crypt/
|
||||
- [ ] Add hash algorithm documentation
|
||||
- [ ] Consider constant-time comparison for hashes
|
||||
|
||||
### Events/
|
||||
- [ ] Add event listener profiling
|
||||
- [ ] Document event flow diagrams
|
||||
|
||||
### Front/
|
||||
- [ ] Add menu builder fluent API
|
||||
- [ ] Consider menu item grouping
|
||||
|
||||
### Headers/
|
||||
- [ ] Add header testing utilities
|
||||
- [ ] Document CSP configuration
|
||||
|
||||
### Input/
|
||||
- [ ] Add filter rule presets (email, url, etc.)
|
||||
- [ ] Consider input transformation hooks
|
||||
|
||||
### Lang/
|
||||
- [ ] Add translation coverage reporting
|
||||
- [ ] Consider translation memory integration
|
||||
|
||||
### Mail/
|
||||
- [ ] Add email validation caching
|
||||
- [ ] Document disposable domain sources
|
||||
|
||||
### Media/
|
||||
- [ ] Add conversion progress reporting
|
||||
- [ ] Consider lazy thumbnail generation
|
||||
|
||||
### Search/
|
||||
- [ ] Add search suggestions/autocomplete
|
||||
- [ ] Consider search result highlighting
|
||||
|
||||
### Seo/
|
||||
- [ ] Add SEO score trend tracking
|
||||
- [ ] Consider structured data testing tool integration
|
||||
|
||||
### Service/
|
||||
- [ ] Add service registration validation
|
||||
- [ ] Document service lifecycle
|
||||
|
||||
### Storage/
|
||||
- [ ] Add cache hit rate monitoring
|
||||
- [ ] Consider multi-tier caching
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Dependencies Status
|
||||
1. ~~`Core\Mod\Social` namespace used in Media conversions~~ - **RESOLVED**: Created local `Core\Media\Abstracts` and `Core\Media\Support` classes
|
||||
2. `Core\Mod\Tenant\Models\Workspace` - **PARTIALLY RESOLVED**: Added `class_exists()` guards in Media/Image classes
|
||||
3. `Core\Mod\Content\Models\ContentItem` used in Seo module - Remains hard dependency
|
||||
4. `Core\Mod\Agentic\Models\AgentPlan` used in Search module - Has `class_exists()` guard
|
||||
5. `Core\Mod\Uptelligence\Models\*` used in Search module - Has `class_exists()` guards
|
||||
|
||||
### Completed Priorities
|
||||
1. ~~Implement missing console commands~~ - **DONE**: MakeModCommand, MakePlugCommand, MakeWebsiteCommand created
|
||||
2. ~~Add proper dependency injection for optional modules~~ - **DONE**: Media module dependencies resolved
|
||||
3. ~~Complete Front module~~ - **DONE**: AdminMenuProvider permissions, caching, DynamicMenuProvider interface
|
||||
4. ~~Complete Input module~~ - **DONE**: Schema-based filters, Unicode NFC normalization, audit logging
|
||||
5. ~~Complete Lang module~~ - **DONE**: LangServiceProvider auto-discovery, fallback chain, key validation
|
||||
6. ~~Complete Service module~~ - **DONE**: ServiceVersion, HealthCheckable, ServiceStatus enum
|
||||
|
||||
### Remaining Priorities
|
||||
1. Implement comprehensive test suite
|
||||
2. Add configuration validation
|
||||
3. Improve error handling and logging
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-21 (batch 3 fixes complete)*
|
||||
*Review performed by: Claude Opus 4.5 code review agents*
|
||||
*Implementation: Claude Opus 4.5 fix agents (batch 1 + batch 2 + batch 3)*
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\LazyModuleListener;
|
||||
|
||||
describe('LazyModuleListener', function () {
|
||||
it('stores module class and method', function () {
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
expect($listener->getModuleClass())->toBe(TestLazyModule::class);
|
||||
expect($listener->getMethod())->toBe('handleEvent');
|
||||
});
|
||||
|
||||
it('invokes the module method when called', function () {
|
||||
TestLazyModule::$called = false;
|
||||
TestLazyModule::$receivedEvent = null;
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
$event = new TestEvent('test data');
|
||||
$listener($event);
|
||||
|
||||
expect(TestLazyModule::$called)->toBeTrue();
|
||||
expect(TestLazyModule::$receivedEvent)->toBe($event);
|
||||
});
|
||||
|
||||
it('reuses the same module instance on multiple calls', function () {
|
||||
TestLazyModule::$instanceCount = 0;
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
$event = new TestEvent('test');
|
||||
$listener($event);
|
||||
$listener($event);
|
||||
$listener($event);
|
||||
|
||||
expect(TestLazyModule::$instanceCount)->toBe(1);
|
||||
});
|
||||
|
||||
it('handle method is alias for __invoke', function () {
|
||||
TestLazyModule::$called = false;
|
||||
TestLazyModule::$receivedEvent = null;
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
$event = new TestEvent('handle test');
|
||||
$listener->handle($event);
|
||||
|
||||
expect(TestLazyModule::$called)->toBeTrue();
|
||||
expect(TestLazyModule::$receivedEvent)->toBe($event);
|
||||
});
|
||||
});
|
||||
|
||||
// Test fixtures
|
||||
|
||||
class TestEvent
|
||||
{
|
||||
public function __construct(public string $data) {}
|
||||
}
|
||||
|
||||
class TestLazyModule
|
||||
{
|
||||
public static bool $called = false;
|
||||
|
||||
public static ?TestEvent $receivedEvent = null;
|
||||
|
||||
public static int $instanceCount = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
self::$instanceCount++;
|
||||
}
|
||||
|
||||
public function handleEvent(TestEvent $event): void
|
||||
{
|
||||
self::$called = true;
|
||||
self::$receivedEvent = $event;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\ModuleScanner;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->scanner = new ModuleScanner;
|
||||
});
|
||||
|
||||
describe('extractListens', function () {
|
||||
it('extracts $listens from a class with public static property', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithListens::class);
|
||||
|
||||
expect($listens)->toBe([
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
'AnotherEvent' => 'onAnother',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when class has no $listens property', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithoutListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when $listens is not public', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithPrivateListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when $listens is not static', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithNonStaticListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when $listens is not an array', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithStringListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array for non-existent class', function () {
|
||||
$listens = $this->scanner->extractListens('NonExistentClass');
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan', function () {
|
||||
it('skips non-existent directories', function () {
|
||||
$result = $this->scanner->scan(['/path/that/does/not/exist']);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Test fixtures - these classes are used to test reflection behaviour
|
||||
|
||||
class TestModuleWithListens
|
||||
{
|
||||
public static array $listens = [
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
'AnotherEvent' => 'onAnother',
|
||||
];
|
||||
}
|
||||
|
||||
class TestModuleWithoutListens
|
||||
{
|
||||
public function boot(): void {}
|
||||
}
|
||||
|
||||
class TestModuleWithPrivateListens
|
||||
{
|
||||
private static array $listens = [
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
];
|
||||
}
|
||||
|
||||
class TestModuleWithNonStaticListens
|
||||
{
|
||||
public array $listens = [
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
];
|
||||
}
|
||||
|
||||
class TestModuleWithStringListens
|
||||
{
|
||||
public static string $listens = 'not an array';
|
||||
}
|
||||
|
|
@ -5,10 +5,12 @@ declare(strict_types=1);
|
|||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Front\Admin\AdminMenuRegistry;
|
||||
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
use Core\Tests\TestCase;
|
||||
use Mod\Tenant\Services\EntitlementResult;
|
||||
use Mod\Tenant\Services\EntitlementService;
|
||||
use Mockery;
|
||||
|
||||
class AdminMenuRegistryTest extends TestCase
|
||||
|
|
@ -22,6 +24,7 @@ class AdminMenuRegistryTest extends TestCase
|
|||
parent::setUp();
|
||||
$this->entitlements = Mockery::mock(EntitlementService::class);
|
||||
$this->registry = new AdminMenuRegistry($this->entitlements);
|
||||
$this->registry->setCachingEnabled(false);
|
||||
}
|
||||
|
||||
public function test_build_returns_empty_array_when_no_providers_registered(): void
|
||||
|
|
@ -269,6 +272,8 @@ class AdminMenuRegistryTest extends TestCase
|
|||
protected function createMockProvider(array $items): AdminMenuProvider
|
||||
{
|
||||
return new class($items) implements AdminMenuProvider {
|
||||
use HasMenuPermissions;
|
||||
|
||||
public function __construct(private array $items) {}
|
||||
|
||||
public function adminMenuItems(): array
|
||||
|
|
|
|||
160
packages/core-php/tests/Feature/EventAuditLogTest.php
Normal file
160
packages/core-php/tests/Feature/EventAuditLogTest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Events\EventAuditLog;
|
||||
use Core\Tests\TestCase;
|
||||
|
||||
class EventAuditLogTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
EventAuditLog::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
EventAuditLog::reset();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_is_disabled_by_default(): void
|
||||
{
|
||||
$this->assertFalse(EventAuditLog::isEnabled());
|
||||
}
|
||||
|
||||
public function test_can_be_enabled_and_disabled(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
$this->assertTrue(EventAuditLog::isEnabled());
|
||||
|
||||
EventAuditLog::disable();
|
||||
$this->assertFalse(EventAuditLog::isEnabled());
|
||||
}
|
||||
|
||||
public function test_does_not_record_when_disabled(): void
|
||||
{
|
||||
EventAuditLog::recordStart('TestEvent', 'TestHandler');
|
||||
EventAuditLog::recordSuccess('TestEvent', 'TestHandler');
|
||||
|
||||
$this->assertEmpty(EventAuditLog::entries());
|
||||
}
|
||||
|
||||
public function test_records_successful_handler_execution(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
EventAuditLog::recordStart('TestEvent', 'TestHandler');
|
||||
EventAuditLog::recordSuccess('TestEvent', 'TestHandler');
|
||||
|
||||
$entries = EventAuditLog::entries();
|
||||
|
||||
$this->assertCount(1, $entries);
|
||||
$this->assertEquals('TestEvent', $entries[0]['event']);
|
||||
$this->assertEquals('TestHandler', $entries[0]['handler']);
|
||||
$this->assertFalse($entries[0]['failed']);
|
||||
$this->assertGreaterThanOrEqual(0, $entries[0]['duration_ms']);
|
||||
}
|
||||
|
||||
public function test_records_failed_handler_execution(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
$error = new \RuntimeException('Test error');
|
||||
EventAuditLog::recordStart('TestEvent', 'TestHandler');
|
||||
EventAuditLog::recordFailure('TestEvent', 'TestHandler', $error);
|
||||
|
||||
$entries = EventAuditLog::entries();
|
||||
|
||||
$this->assertCount(1, $entries);
|
||||
$this->assertEquals('TestEvent', $entries[0]['event']);
|
||||
$this->assertEquals('TestHandler', $entries[0]['handler']);
|
||||
$this->assertTrue($entries[0]['failed']);
|
||||
$this->assertEquals('Test error', $entries[0]['error']);
|
||||
}
|
||||
|
||||
public function test_returns_entries_for_specific_event(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
EventAuditLog::recordStart('EventA', 'Handler1');
|
||||
EventAuditLog::recordSuccess('EventA', 'Handler1');
|
||||
EventAuditLog::recordStart('EventB', 'Handler2');
|
||||
EventAuditLog::recordSuccess('EventB', 'Handler2');
|
||||
EventAuditLog::recordStart('EventA', 'Handler3');
|
||||
EventAuditLog::recordSuccess('EventA', 'Handler3');
|
||||
|
||||
$entries = EventAuditLog::entriesFor('EventA');
|
||||
|
||||
$this->assertCount(2, $entries);
|
||||
$this->assertEquals('Handler1', $entries[0]['handler']);
|
||||
$this->assertEquals('Handler3', $entries[1]['handler']);
|
||||
}
|
||||
|
||||
public function test_returns_only_failures(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
EventAuditLog::recordStart('Event1', 'Handler1');
|
||||
EventAuditLog::recordSuccess('Event1', 'Handler1');
|
||||
|
||||
$error = new \RuntimeException('Failed');
|
||||
EventAuditLog::recordStart('Event2', 'Handler2');
|
||||
EventAuditLog::recordFailure('Event2', 'Handler2', $error);
|
||||
|
||||
$failures = EventAuditLog::failures();
|
||||
|
||||
$this->assertCount(1, $failures);
|
||||
$this->assertEquals('Handler2', $failures[0]['handler']);
|
||||
}
|
||||
|
||||
public function test_provides_summary_statistics(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
EventAuditLog::recordStart('EventA', 'Handler1');
|
||||
EventAuditLog::recordSuccess('EventA', 'Handler1');
|
||||
EventAuditLog::recordStart('EventA', 'Handler2');
|
||||
EventAuditLog::recordSuccess('EventA', 'Handler2');
|
||||
EventAuditLog::recordStart('EventB', 'Handler3');
|
||||
EventAuditLog::recordFailure('EventB', 'Handler3', new \Exception('Error'));
|
||||
|
||||
$summary = EventAuditLog::summary();
|
||||
|
||||
$this->assertEquals(3, $summary['total']);
|
||||
$this->assertEquals(1, $summary['failed']);
|
||||
$this->assertEquals(2, $summary['events']['EventA']);
|
||||
$this->assertEquals(1, $summary['events']['EventB']);
|
||||
}
|
||||
|
||||
public function test_can_clear_entries(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
EventAuditLog::recordStart('TestEvent', 'TestHandler');
|
||||
EventAuditLog::recordSuccess('TestEvent', 'TestHandler');
|
||||
|
||||
$this->assertCount(1, EventAuditLog::entries());
|
||||
|
||||
EventAuditLog::clear();
|
||||
|
||||
$this->assertEmpty(EventAuditLog::entries());
|
||||
}
|
||||
|
||||
public function test_reset_disables_and_clears(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
EventAuditLog::enableLog();
|
||||
|
||||
EventAuditLog::recordStart('TestEvent', 'TestHandler');
|
||||
EventAuditLog::recordSuccess('TestEvent', 'TestHandler');
|
||||
|
||||
EventAuditLog::reset();
|
||||
|
||||
$this->assertFalse(EventAuditLog::isEnabled());
|
||||
$this->assertEmpty(EventAuditLog::entries());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Events\EventAuditLog;
|
||||
use Core\Events\WebRoutesRegistering;
|
||||
use Core\LazyModuleListener;
|
||||
use Core\Tests\TestCase;
|
||||
|
|
@ -11,6 +12,18 @@ use Illuminate\Support\ServiceProvider;
|
|||
|
||||
class LazyModuleListenerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
EventAuditLog::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
EventAuditLog::reset();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_listener_stores_module_class(): void
|
||||
{
|
||||
$listener = new LazyModuleListener(
|
||||
|
|
@ -175,4 +188,94 @@ class LazyModuleListenerTest extends TestCase
|
|||
|
||||
$this->assertCount(1, $event->viewRequests());
|
||||
}
|
||||
|
||||
public function test_listener_records_to_audit_log_when_enabled(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
$moduleClass = new class
|
||||
{
|
||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
||||
{
|
||||
// Handler executes successfully
|
||||
}
|
||||
};
|
||||
|
||||
$this->app->instance($moduleClass::class, $moduleClass);
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
$moduleClass::class,
|
||||
'onWebRoutes'
|
||||
);
|
||||
|
||||
$event = new WebRoutesRegistering;
|
||||
$listener($event);
|
||||
|
||||
$entries = EventAuditLog::entries();
|
||||
|
||||
$this->assertCount(1, $entries);
|
||||
$this->assertEquals(WebRoutesRegistering::class, $entries[0]['event']);
|
||||
$this->assertEquals($moduleClass::class, $entries[0]['handler']);
|
||||
$this->assertFalse($entries[0]['failed']);
|
||||
}
|
||||
|
||||
public function test_listener_records_failures_to_audit_log(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
$moduleClass = new class
|
||||
{
|
||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
||||
{
|
||||
throw new \RuntimeException('Handler failed');
|
||||
}
|
||||
};
|
||||
|
||||
$this->app->instance($moduleClass::class, $moduleClass);
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
$moduleClass::class,
|
||||
'onWebRoutes'
|
||||
);
|
||||
|
||||
$event = new WebRoutesRegistering;
|
||||
|
||||
try {
|
||||
$listener($event);
|
||||
} catch (\RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$failures = EventAuditLog::failures();
|
||||
|
||||
$this->assertCount(1, $failures);
|
||||
$this->assertEquals('Handler failed', $failures[0]['error']);
|
||||
}
|
||||
|
||||
public function test_listener_rethrows_exceptions_after_recording(): void
|
||||
{
|
||||
EventAuditLog::enable();
|
||||
|
||||
$moduleClass = new class
|
||||
{
|
||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
||||
{
|
||||
throw new \RuntimeException('Handler failed');
|
||||
}
|
||||
};
|
||||
|
||||
$this->app->instance($moduleClass::class, $moduleClass);
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
$moduleClass::class,
|
||||
'onWebRoutes'
|
||||
);
|
||||
|
||||
$event = new WebRoutesRegistering;
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Handler failed');
|
||||
|
||||
$listener($event);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,23 @@ class ModuleScannerTest extends TestCase
|
|||
$this->assertArrayHasKey(WebRoutesRegistering::class, $result);
|
||||
}
|
||||
|
||||
public function test_scan_returns_normalized_format_with_method_and_priority(): void
|
||||
{
|
||||
$result = $this->scanner->scan([$this->getFixturePath('Mod')]);
|
||||
|
||||
$this->assertArrayHasKey(WebRoutesRegistering::class, $result);
|
||||
$listeners = $result[WebRoutesRegistering::class];
|
||||
|
||||
// Each listener should have method and priority keys
|
||||
foreach ($listeners as $moduleClass => $config) {
|
||||
$this->assertIsArray($config);
|
||||
$this->assertArrayHasKey('method', $config);
|
||||
$this->assertArrayHasKey('priority', $config);
|
||||
$this->assertIsString($config['method']);
|
||||
$this->assertIsInt($config['priority']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_scan_finds_modules_in_website_path(): void
|
||||
{
|
||||
$result = $this->scanner->scan([$this->getFixturePath('Mod')]);
|
||||
|
|
@ -162,6 +179,30 @@ PHP);
|
|||
$this->assertEmpty($result);
|
||||
}
|
||||
|
||||
public function test_extract_listens_parses_priority_from_array_syntax(): void
|
||||
{
|
||||
require_once $this->getFixturePath('Mod/HighPriority/Boot.php');
|
||||
|
||||
$result = $this->scanner->extractListens(\Mod\HighPriority\Boot::class);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey(WebRoutesRegistering::class, $result);
|
||||
$this->assertEquals('onWebRoutes', $result[WebRoutesRegistering::class]['method']);
|
||||
$this->assertEquals(100, $result[WebRoutesRegistering::class]['priority']);
|
||||
}
|
||||
|
||||
public function test_extract_listens_uses_default_priority_for_string_syntax(): void
|
||||
{
|
||||
require_once $this->getFixturePath('Mod/Example/Boot.php');
|
||||
|
||||
$result = $this->scanner->extractListens(\Mod\Example\Boot::class);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey(WebRoutesRegistering::class, $result);
|
||||
$this->assertEquals('onWebRoutes', $result[WebRoutesRegistering::class]['method']);
|
||||
$this->assertEquals(0, $result[WebRoutesRegistering::class]['priority']);
|
||||
}
|
||||
|
||||
public function test_scan_skips_modules_without_listens(): void
|
||||
{
|
||||
require_once $this->getFixturePath('Mod/NoListens/Boot.php');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Core\Tests\Feature;
|
|||
|
||||
use Core\Input\Sanitiser;
|
||||
use Core\Tests\TestCase;
|
||||
use Normalizer;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SanitiserTest extends TestCase
|
||||
{
|
||||
|
|
@ -79,15 +81,15 @@ class SanitiserTest extends TestCase
|
|||
{
|
||||
$input = [
|
||||
'name' => '日本語テスト',
|
||||
'emoji' => '👋 Hello 🌍',
|
||||
'accents' => 'Café résumé naïve',
|
||||
'emoji' => 'Hello',
|
||||
'accents' => 'Cafe resume naive',
|
||||
];
|
||||
|
||||
$result = $this->sanitiser->filter($input);
|
||||
|
||||
$this->assertEquals('日本語テスト', $result['name']);
|
||||
$this->assertEquals('👋 Hello 🌍', $result['emoji']);
|
||||
$this->assertEquals('Café résumé naïve', $result['accents']);
|
||||
$this->assertEquals('Hello', $result['emoji']);
|
||||
$this->assertEquals('Cafe resume naive', $result['accents']);
|
||||
}
|
||||
|
||||
public function test_filter_handles_nested_arrays(): void
|
||||
|
|
@ -145,4 +147,290 @@ class SanitiserTest extends TestCase
|
|||
$this->assertEquals('value2', $result['key2']);
|
||||
$this->assertEquals('value3', $result['key3']);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// New tests for configurable filter rules
|
||||
// ========================================
|
||||
|
||||
public function test_with_schema_returns_new_instance(): void
|
||||
{
|
||||
$original = new Sanitiser;
|
||||
$withSchema = $original->withSchema(['email' => ['filters' => [FILTER_SANITIZE_EMAIL]]]);
|
||||
|
||||
$this->assertNotSame($original, $withSchema);
|
||||
}
|
||||
|
||||
public function test_schema_applies_additional_filters_to_specified_fields(): void
|
||||
{
|
||||
$sanitiser = new Sanitiser([
|
||||
'email' => ['filters' => [FILTER_SANITIZE_EMAIL]],
|
||||
]);
|
||||
|
||||
$input = [
|
||||
'email' => 'test (at) example.com',
|
||||
'name' => 'test (at) example.com', // Same input, but not email field
|
||||
];
|
||||
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
// Email field gets sanitized
|
||||
$this->assertEquals('testatexample.com', $result['email']);
|
||||
// Name field keeps original (minus any control chars)
|
||||
$this->assertEquals('test (at) example.com', $result['name']);
|
||||
}
|
||||
|
||||
public function test_schema_can_skip_control_character_stripping(): void
|
||||
{
|
||||
$sanitiser = new Sanitiser([
|
||||
'raw' => ['skip_control_strip' => true],
|
||||
]);
|
||||
|
||||
$input = [
|
||||
'raw' => "Has\x00Null",
|
||||
'normal' => "Has\x00Null",
|
||||
];
|
||||
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
// Raw field keeps null byte
|
||||
$this->assertEquals("Has\x00Null", $result['raw']);
|
||||
// Normal field has null byte stripped
|
||||
$this->assertEquals('HasNull', $result['normal']);
|
||||
}
|
||||
|
||||
public function test_constructor_accepts_schema(): void
|
||||
{
|
||||
$schema = ['email' => ['filters' => [FILTER_SANITIZE_EMAIL]]];
|
||||
$sanitiser = new Sanitiser($schema);
|
||||
|
||||
$input = ['email' => 'test (at) example.com'];
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
$this->assertEquals('testatexample.com', $result['email']);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Tests for Unicode NFC normalization
|
||||
// ========================================
|
||||
|
||||
public function test_unicode_normalization_is_enabled_by_default(): void
|
||||
{
|
||||
if (!class_exists(Normalizer::class)) {
|
||||
$this->markTestSkipped('intl extension not available');
|
||||
}
|
||||
|
||||
$sanitiser = new Sanitiser;
|
||||
|
||||
// NFD: e + combining acute accent (two code points)
|
||||
$nfd = "cafe\xCC\x81"; // 'cafe' + combining acute accent
|
||||
// NFC: e with acute (single code point)
|
||||
$nfc = "caf\xC3\xA9"; // 'cafe' as single accented char
|
||||
|
||||
$input = ['text' => $nfd];
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
// Should be normalized to NFC
|
||||
$this->assertEquals($nfc, $result['text']);
|
||||
}
|
||||
|
||||
public function test_with_normalization_false_disables_nfc(): void
|
||||
{
|
||||
if (!class_exists(Normalizer::class)) {
|
||||
$this->markTestSkipped('intl extension not available');
|
||||
}
|
||||
|
||||
$sanitiser = (new Sanitiser)->withNormalization(false);
|
||||
|
||||
// NFD form
|
||||
$nfd = "cafe\xCC\x81";
|
||||
|
||||
$input = ['text' => $nfd];
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
// Should NOT be normalized (stays NFD)
|
||||
$this->assertEquals($nfd, $result['text']);
|
||||
}
|
||||
|
||||
public function test_schema_can_skip_normalization_per_field(): void
|
||||
{
|
||||
if (!class_exists(Normalizer::class)) {
|
||||
$this->markTestSkipped('intl extension not available');
|
||||
}
|
||||
|
||||
$sanitiser = new Sanitiser([
|
||||
'raw' => ['skip_normalize' => true],
|
||||
]);
|
||||
|
||||
// NFD form
|
||||
$nfd = "cafe\xCC\x81";
|
||||
$nfc = "caf\xC3\xA9";
|
||||
|
||||
$input = [
|
||||
'raw' => $nfd,
|
||||
'normal' => $nfd,
|
||||
];
|
||||
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
// Raw field keeps NFD
|
||||
$this->assertEquals($nfd, $result['raw']);
|
||||
// Normal field gets NFC
|
||||
$this->assertEquals($nfc, $result['normal']);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Tests for audit logging
|
||||
// ========================================
|
||||
|
||||
public function test_with_logger_returns_new_instance(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$original = new Sanitiser;
|
||||
$withLogger = $original->withLogger($logger);
|
||||
|
||||
$this->assertNotSame($original, $withLogger);
|
||||
}
|
||||
|
||||
public function test_audit_logging_logs_when_content_modified(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->once())
|
||||
->method('info')
|
||||
->with(
|
||||
'Input sanitised',
|
||||
$this->callback(function ($context) {
|
||||
return $context['field'] === 'data'
|
||||
&& $context['sanitised'] === 'HelloWorld'
|
||||
&& $context['original_length'] === 11
|
||||
&& $context['sanitised_length'] === 10;
|
||||
})
|
||||
);
|
||||
|
||||
$sanitiser = new Sanitiser([], $logger, true);
|
||||
|
||||
$input = ['data' => "Hello\x00World"];
|
||||
$sanitiser->filter($input);
|
||||
}
|
||||
|
||||
public function test_audit_logging_does_not_log_when_content_unchanged(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->never())->method('info');
|
||||
|
||||
$sanitiser = new Sanitiser([], $logger, true);
|
||||
|
||||
$input = ['data' => 'HelloWorld'];
|
||||
$sanitiser->filter($input);
|
||||
}
|
||||
|
||||
public function test_audit_logging_disabled_by_default(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->never())->method('info');
|
||||
|
||||
// Logger provided but audit not enabled
|
||||
$sanitiser = new Sanitiser([], $logger, false);
|
||||
|
||||
$input = ['data' => "Hello\x00World"];
|
||||
$sanitiser->filter($input);
|
||||
}
|
||||
|
||||
public function test_audit_logging_requires_logger(): void
|
||||
{
|
||||
// Audit enabled but no logger - should not crash
|
||||
$sanitiser = new Sanitiser([], null, true);
|
||||
|
||||
$input = ['data' => "Hello\x00World"];
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
$this->assertEquals('HelloWorld', $result['data']);
|
||||
}
|
||||
|
||||
public function test_audit_logging_includes_nested_path(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->once())
|
||||
->method('info')
|
||||
->with(
|
||||
'Input sanitised',
|
||||
$this->callback(function ($context) {
|
||||
return $context['field'] === 'nested.deep.value';
|
||||
})
|
||||
);
|
||||
|
||||
$sanitiser = new Sanitiser([], $logger, true);
|
||||
|
||||
$input = [
|
||||
'nested' => [
|
||||
'deep' => [
|
||||
'value' => "Has\x00Null",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$sanitiser->filter($input);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Tests for backwards compatibility
|
||||
// ========================================
|
||||
|
||||
public function test_default_constructor_works_with_no_arguments(): void
|
||||
{
|
||||
$sanitiser = new Sanitiser;
|
||||
|
||||
$input = ['test' => "Hello\x00World"];
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
$this->assertEquals('HelloWorld', $result['test']);
|
||||
}
|
||||
|
||||
public function test_fluent_interface_chains_correctly(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$sanitiser = (new Sanitiser)
|
||||
->withSchema(['email' => ['filters' => [FILTER_SANITIZE_EMAIL]]])
|
||||
->withLogger($logger, true)
|
||||
->withNormalization(false);
|
||||
|
||||
$input = ['email' => 'test (at) example.com'];
|
||||
$result = $sanitiser->filter($input);
|
||||
|
||||
$this->assertEquals('testatexample.com', $result['email']);
|
||||
}
|
||||
|
||||
public function test_filter_handles_deeply_nested_arrays(): void
|
||||
{
|
||||
$input = [
|
||||
'level1' => [
|
||||
'level2' => [
|
||||
'level3' => [
|
||||
'data' => "Hello\x00World",
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->sanitiser->filter($input);
|
||||
|
||||
$this->assertEquals('HelloWorld', $result['level1']['level2']['level3']['data']);
|
||||
}
|
||||
|
||||
public function test_filter_preserves_non_string_values(): void
|
||||
{
|
||||
$input = [
|
||||
'int' => 123,
|
||||
'float' => 45.67,
|
||||
'bool' => true,
|
||||
'null' => null,
|
||||
];
|
||||
|
||||
$result = $this->sanitiser->filter($input);
|
||||
|
||||
$this->assertSame(123, $result['int']);
|
||||
$this->assertSame(45.67, $result['float']);
|
||||
$this->assertSame(true, $result['bool']);
|
||||
$this->assertNull($result['null']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
packages/core-php/tests/Fixtures/Mod/HighPriority/Boot.php
Normal file
19
packages/core-php/tests/Fixtures/Mod/HighPriority/Boot.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\HighPriority;
|
||||
|
||||
use Core\Events\WebRoutesRegistering;
|
||||
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
WebRoutesRegistering::class => ['onWebRoutes', 100],
|
||||
];
|
||||
|
||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
||||
{
|
||||
$event->views('high-priority', __DIR__.'/Views');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue