diff --git a/packages/core-php/TODO.md b/packages/core-php/TODO.md index 0a6daa9..f1465bb 100644 --- a/packages/core-php/TODO.md +++ b/packages/core-php/TODO.md @@ -1,296 +1,9 @@ # Core-PHP TODO -## Implemented +## High Priority -### Actions Pattern ✓ - -`Core\Actions\Action` trait for single-purpose business logic classes. - -```php -use Core\Actions\Action; - -class CreateThing -{ - use Action; - - public function handle(User $user, array $data): Thing - { - // Complex business logic here - } -} - -// Usage -$thing = CreateThing::run($user, $data); -``` - -**Location:** `src/Core/Actions/Action.php`, `src/Core/Actions/Actionable.php` +- [ ] **CDN integration tests** - Add integration tests for CDN operations (BunnyCDN upload, signed URLs, etc.) --- -## Seeder Auto-Discovery - -**Priority:** Medium -**Context:** Currently apps need a `database/seeders/DatabaseSeeder.php` that manually lists module seeders in order. This is boilerplate that core-php could handle. - -### Requirements - -- Auto-discover seeders from registered modules (`*/Database/Seeders/*Seeder.php`) -- Support priority ordering via property or attribute (e.g., `public int $priority = 50`) -- Support dependency ordering via `$after` or `$before` arrays -- Provide base `DatabaseSeeder` class that apps can extend or use directly -- Allow apps to override/exclude specific seeders if needed - -### Example - -```php -// In a module seeder -class FeatureSeeder extends Seeder -{ - public int $priority = 10; // Run early - - public function run(): void { ... } -} - -class PackageSeeder extends Seeder -{ - public array $after = [FeatureSeeder::class]; // Run after features - - public function run(): void { ... } -} -``` - -### Notes - -- Current Host Hub DatabaseSeeder has ~20 seeders with implicit ordering -- Key dependencies: features → packages → workspaces → system user → content -- Could use Laravel's service container to resolve seeder graph - ---- - -## Team-Scoped Caching - -**Priority:** Medium -**Context:** Repeated queries for workspace-scoped resources. Cache workspace-scoped queries with auto-invalidation. - -### Implementation - -Extend `BelongsToWorkspace` trait: - -```php -trait BelongsToWorkspace -{ - public static function ownedByCurrentWorkspaceCached(int $ttl = 300) - { - $workspace = currentWorkspace(); - if (!$workspace) return collect(); - - return Cache::remember( - static::workspaceCacheKey($workspace->id), - $ttl, - fn() => static::ownedByCurrentWorkspace()->get() - ); - } - - protected static function bootBelongsToWorkspace(): void - { - static::saved(fn($m) => static::clearWorkspaceCache($m->workspace_id)); - static::deleted(fn($m) => static::clearWorkspaceCache($m->workspace_id)); - } -} -``` - -### Usage - -```php -// Cached for 5 minutes, auto-clears on changes -$biolinks = Biolink::ownedByCurrentWorkspaceCached(); -``` - ---- - -## Activity Logging - -**Priority:** Low -**Context:** No audit trail of user actions across modules. - -### Implementation - -Add `spatie/laravel-activitylog` integration: - -```php -// Core trait for models -trait LogsActivity -{ - use \Spatie\Activitylog\Traits\LogsActivity; - - public function getActivitylogOptions(): LogOptions - { - return LogOptions::defaults() - ->logOnlyDirty() - ->dontSubmitEmptyLogs(); - } -} -``` - -### Requirements - -- Base trait modules can use -- Activity viewer Livewire component for admin -- Workspace-scoped activity queries - ---- - -## Multi-Tenant Data Isolation - -**Priority:** High (Security) -**Context:** Multiple modules have workspace isolation issues. - -### Issues - -1. **Fallback workspace_id** - Some code falls back to `workspace_id = 1` when no context -2. **Global queries** - Some commands query globally without workspace scope -3. **Session trust** - Using `session('workspace_id', 1)` with hardcoded fallback - -### Solution - -- `BelongsToWorkspace` trait should throw exception when workspace context is missing (not fallback) -- Add `WorkspaceScope` global scope that throws on missing context -- Audit all models for proper scoping -- Add middleware that ensures workspace context before any workspace-scoped operation - ---- - -## Bouncer Request Whitelisting - -**Priority:** Medium -**Context:** Every controller action must be explicitly permitted. Unknown actions are blocked (production) or prompt for approval (training mode). - -**Philosophy:** If it wasn't trained, it doesn't exist. - -### Concept - -``` -Training Mode (Development): -1. Developer hits /admin/products -2. Clicks "Create Product" -3. System: "BLOCKED - No permission defined for:" - - Role: admin - - Action: product.create - - Route: POST /admin/products -4. Developer clicks [Allow for admin] -5. Permission recorded -6. Continue working - -Production Mode: -If permission not in whitelist → 403 Forbidden -No exceptions. No fallbacks. No "default allow". -``` - -### Database Schema - -```php -// core_action_permissions -Schema::create('core_action_permissions', function (Blueprint $table) { - $table->id(); - $table->string('action'); // product.create, order.refund - $table->string('scope')->nullable(); // Resource type or specific ID - $table->string('guard')->default('web'); // web, api, admin - $table->string('role')->nullable(); // admin, editor, or null for any auth - $table->boolean('allowed')->default(false); - $table->string('source'); // 'trained', 'seeded', 'manual' - $table->string('trained_route')->nullable(); - $table->foreignId('trained_by')->nullable(); - $table->timestamp('trained_at')->nullable(); - $table->timestamps(); - - $table->unique(['action', 'scope', 'guard', 'role']); -}); - -// core_action_requests (audit log) -Schema::create('core_action_requests', function (Blueprint $table) { - $table->id(); - $table->string('method'); - $table->string('route'); - $table->string('action'); - $table->string('scope')->nullable(); - $table->string('guard'); - $table->string('role')->nullable(); - $table->foreignId('user_id')->nullable(); - $table->string('ip_address')->nullable(); - $table->string('status'); // allowed, denied, pending - $table->boolean('was_trained')->default(false); - $table->timestamps(); - - $table->index(['action', 'status']); -}); -``` - -### Action Resolution - -```php -// Explicit via route attribute -Route::post('/products', [ProductController::class, 'store']) - ->action('product.create'); - -// Or via controller attribute -#[Action('product.create')] -public function store(Request $request) { ... } - -// Or auto-resolved from controller@method -// ProductController@store → product.store -``` - -### Integration with Existing Auth - -``` -Request - │ - ▼ -BouncerGate (action whitelisting) - │ "Is this action permitted at all?" - ▼ -Laravel Gate/Policy (authorisation) - │ "Can THIS USER do this to THIS RESOURCE?" - ▼ -Controller -``` - -### Implementation Phases - -**Phase 1: Core** -- [ ] Database migrations -- [ ] `ActionPermission` model -- [ ] `BouncerService` with `check()` method -- [ ] `BouncerGate` middleware - -**Phase 2: Training Mode** -- [ ] Training UI (modal prompt) -- [ ] Training controller/routes -- [ ] Request logging - -**Phase 3: Tooling** -- [ ] `bouncer:export` command -- [ ] `bouncer:list` command -- [ ] Admin UI for viewing/editing permissions - -**Phase 4: Integration** -- [ ] Apply to admin routes -- [ ] Apply to API routes -- [ ] Documentation - -### Artisan Commands - -```bash -php artisan bouncer:export # Export trained permissions to seeder -php artisan bouncer:seed # Import from seeder -php artisan bouncer:list # List all defined actions -php artisan bouncer:reset # Clear training data -``` - -### Benefits - -1. **Complete audit trail** - Know exactly what actions exist in your app -2. **No forgotten routes** - If it's not trained, it doesn't work -3. **Role-based by default** - Actions scoped to guards and roles -4. **Deployment safety** - Export/import permissions between environments -5. **Discovery tool** - Training mode maps your entire app's surface area +*See `changelog/2026/jan/` for completed features and code review findings.* diff --git a/packages/core-php/changelog/2026/jan/code-review.md b/packages/core-php/changelog/2026/jan/code-review.md new file mode 100644 index 0000000..fef2814 --- /dev/null +++ b/packages/core-php/changelog/2026/jan/code-review.md @@ -0,0 +1,181 @@ +# Core-PHP Code Review - January 2026 + +Comprehensive Opus-level code review of all Core/* modules. + +## Summary + +| Severity | Count | Status | +|----------|-------|--------| +| Critical | 15 | All Fixed | +| High | 52 | 51 Fixed | +| Medium | 38 | All Fixed | +| Low | 32 | All Fixed | + +--- + +## Critical Issues Fixed + +### Bouncer/BlocklistService.php +- **Missing table existence check** - Added cached `tableExists()` check. + +### Cdn/Services/StorageUrlResolver.php +- **Weak token hashing** - Changed to HMAC-SHA256. + +### Config/ConfigService.php +- **SQL injection via LIKE wildcards** - Added wildcard escaping. + +### Console/Boot.php +- **References non-existent commands** - Commented out missing commands. + +### Console/Commands/InstallCommand.php +- **Regex injection** - Added `preg_quote()`. + +### Input/Sanitiser.php +- **Nested arrays become null** - Implemented recursive filtering. + +### Mail/EmailShieldStat.php +- **Race condition** - Changed to atomic `insertOrIgnore()` + `increment()`. + +### ModuleScanner.php +- **Duplicate code** - Removed duplicate. +- **Missing namespaces** - Added Website and Plug namespace handling. + +### Search/Unified.php +- **Missing class_exists check** - Added guard. + +### Seo/Schema.php, SchemaBuilderService.php, SeoMetadata.php +- **XSS vulnerability** - Added `JSON_HEX_TAG` flag. + +### Storage/CacheResilienceProvider.php +- **Hardcoded phpredis** - Added Predis support with fallback. + +--- + +## High Severity Issues Fixed + +### Bouncer (3/3) +- BlocklistService auto-block workflow with pending/approved/rejected status +- TeapotController rate limiting with configurable max attempts +- HoneypotHit configurable severity levels + +### Cdn (4/5) +- BunnyStorageService retry logic with exponential backoff +- BunnyStorageService file size validation +- BunnyCdnService API key redaction in errors +- StorageUrlResolver configurable signed URL expiry +- *Remaining: Integration tests* + +### Config (4/4) +- ConfigService value type validation +- ConfigResolver max recursion depth +- Cache invalidation strategy documented + +### Console (3/3) +- InstallCommand credential masking +- InstallCommand rollback on failure +- Created MakeModCommand, MakePlugCommand, MakeWebsiteCommand + +### Crypt (3/3) +- LthnHash multi-key rotation support +- LthnHash MEDIUM_LENGTH and LONG_LENGTH options +- QuasiHash security documentation + +### Events (3/3) +- Event prioritization via array syntax +- EventAuditLog for replay/audit logging +- Dead letter queue via recordFailure() + +### Front (3/3) +- AdminMenuProvider permission checks +- Menu item caching with configurable TTL +- DynamicMenuProvider interface + +### Headers (3/3) +- CSP configurable, unsafe-inline only in dev +- Permissions-Policy header with 19 feature controls +- Environment-specific header configuration + +### Input (3/3) +- Schema-based per-field filter rules +- Unicode NFC normalisation +- Audit logging with PSR-3 logger + +### Lang (3/3) +- LangServiceProvider auto-discovery +- Fallback locale chain support +- Translation key validation + +### Mail (3/3) +- Disposable domain auto-update +- MX lookup caching +- Data retention cleanup command + +### Media (4/4) +- Local abstracts to remove Core\Mod\Social dependency +- Memory limit checks before image processing +- HEIC/AVIF format support + +### Search (3/3) +- Configurable API endpoints +- Search result caching +- Wildcard DoS protection + +### Seo (3/3) +- Schema validation against schema.org +- Sitemap generation (already existed) + +### Service (2/2) +- ServiceVersion with semver and deprecation +- HealthCheckable interface and HealthCheckResult + +### Storage (3/3) +- RedisFallbackActivated event +- CacheWarmer with registration system +- Configurable exception throwing + +--- + +## Medium Severity Issues Fixed + +- Bouncer pagination for large blocklists +- CDN URL building consistency, content-type detection, health check +- Config soft deletes, sensitive value encryption, ConfigProvider interface +- Console progress bar, --dry-run option +- Crypt fast hash with xxHash, benchmark method +- Events PHPDoc annotations, event versioning +- Front icon validation, menu priority constants +- Headers nonce-based CSP, configuration UI +- Input HTML subset for rich text, max length enforcement +- Lang pluralisation rules, ICU message format +- Mail async validation, email normalisation +- Media queued conversions, EXIF stripping, progressive JPEG +- Search scoring tuning, fuzzy search, analytics tracking +- SEO lazy schema loading, OG image validation, canonical conflict detection +- Service dependency declaration, discovery mechanism +- Storage circuit breaker, metrics collection + +--- + +## Low Severity Issues Fixed + +- Bouncer unit tests, configuration documentation +- CDN PHPDoc return types, CdnUrlBuilder extraction +- Config import/export, versioning for rollback +- Console autocompletion, colorized output +- Crypt algorithm documentation, constant-time comparison docs +- Events listener profiling, flow diagrams +- Front fluent menu builder, menu grouping +- Headers testing utilities, CSP documentation +- Input filter presets, transformation hooks +- Lang translation coverage reporting, translation memory +- Mail validation caching, disposable domain documentation +- Media progress reporting, lazy thumbnail generation +- Search suggestions/autocomplete, result highlighting +- SEO score trend tracking, structured data testing +- Service registration validation, lifecycle documentation +- Storage hit rate monitoring, multi-tier caching + +--- + +*Review performed by: Claude Opus 4.5 code review agents* +*Implementation: Claude Opus 4.5 fix agents (9 batches)* diff --git a/packages/core-php/changelog/2026/jan/features.md b/packages/core-php/changelog/2026/jan/features.md new file mode 100644 index 0000000..78ea1c9 --- /dev/null +++ b/packages/core-php/changelog/2026/jan/features.md @@ -0,0 +1,163 @@ +# Core-PHP - January 2026 + +## Features Implemented + +### Actions Pattern + +`Core\Actions\Action` trait for single-purpose business logic classes. + +```php +use Core\Actions\Action; + +class CreateThing +{ + use Action; + + public function handle(User $user, array $data): Thing + { + // Complex business logic here + } +} + +// Usage +$thing = CreateThing::run($user, $data); +``` + +**Location:** `src/Core/Actions/Action.php`, `src/Core/Actions/Actionable.php` + +--- + +### Multi-Tenant Data Isolation + +**Files:** +- `MissingWorkspaceContextException` - Dedicated exception with factory methods +- `WorkspaceScope` - Strict mode enforcement, throws on missing context +- `BelongsToWorkspace` - Enhanced trait with context validation +- `RequireWorkspaceContext` middleware + +**Usage:** +```php +Account::query()->forWorkspace($workspace)->get(); +Account::query()->acrossWorkspaces()->get(); +WorkspaceScope::withoutStrictMode(fn() => Account::all()); +``` + +--- + +### Seeder Auto-Discovery + +**Files:** +- `src/Core/Database/Seeders/SeederDiscovery.php` - Scans modules for seeders +- `src/Core/Database/Seeders/SeederRegistry.php` - Manual registration +- `src/Core/Database/Seeders/CoreDatabaseSeeder.php` - Base class with --exclude/--only +- `src/Core/Database/Seeders/Attributes/` - SeederPriority, SeederAfter, SeederBefore + +**Usage:** +```php +class FeatureSeeder extends Seeder +{ + public int $priority = 10; + public function run(): void { ... } +} + +#[SeederAfter(FeatureSeeder::class)] +class PackageSeeder extends Seeder { ... } +``` + +**Config:** `core.seeders.auto_discover`, `core.seeders.paths`, `core.seeders.exclude` + +--- + +### Team-Scoped Caching + +**Files:** +- `src/Mod/Tenant/Services/WorkspaceCacheManager.php` - Cache management service +- `src/Mod/Tenant/Concerns/HasWorkspaceCache.php` - Trait for custom caching +- Enhanced `BelongsToWorkspace` trait + +**Usage:** +```php +$projects = Project::ownedByCurrentWorkspaceCached(300); +$accounts = Account::forWorkspaceCached($workspace, 600); +``` + +**Config:** `core.workspace_cache.enabled`, `core.workspace_cache.ttl`, `core.workspace_cache.use_tags` + +--- + +### Activity Logging + +**Files:** +- `src/Core/Activity/Concerns/LogsActivity.php` - Model trait +- `src/Core/Activity/Services/ActivityLogService.php` - Query service +- `src/Core/Activity/Models/Activity.php` - Extended model +- `src/Core/Activity/View/Modal/Admin/ActivityFeed.php` - Livewire component +- `src/Core/Activity/Console/ActivityPruneCommand.php` - Cleanup command + +**Usage:** +```php +use Core\Activity\Concerns\LogsActivity; + +class Post extends Model +{ + use LogsActivity; +} + +$activities = app(ActivityLogService::class) + ->logBy($user) + ->forWorkspace($workspace) + ->recent(20); +``` + +**Config:** `core.activity.enabled`, `core.activity.retention_days` + +**Requires:** `composer require spatie/laravel-activitylog` + +--- + +### Bouncer Request Whitelisting + +**Files:** +- `src/Core/Bouncer/Gate/Migrations/` - Database tables +- `src/Core/Bouncer/Gate/Models/ActionPermission.php` - Permission model +- `src/Core/Bouncer/Gate/Models/ActionRequest.php` - Audit log model +- `src/Core/Bouncer/Gate/ActionGateService.php` - Core service +- `src/Core/Bouncer/Gate/ActionGateMiddleware.php` - Middleware +- `src/Core/Bouncer/Gate/Attributes/Action.php` - Controller attribute +- `src/Core/Bouncer/Gate/RouteActionMacro.php` - Route macro + +**Usage:** +```php +// Route-level +Route::post('/products', [ProductController::class, 'store']) + ->action('product.create'); + +// Controller attribute +#[Action('product.delete', scope: 'product')] +public function destroy(Product $product) { ... } +``` + +**Config:** `core.bouncer.training_mode`, `core.bouncer.enabled` + +--- + +### CDN Integration Tests + +Comprehensive test suite for CDN operations and asset pipeline. + +**Files:** +- `src/Core/Tests/Feature/CdnIntegrationTest.php` - Full integration test suite + +**Coverage:** +- URL building (CDN, origin, private, apex) +- Asset pipeline (upload, store, delete) +- Storage operations (public/private buckets) +- vBucket isolation and path generation +- URL versioning and query parameters +- Signed URL generation +- Large file handling +- Special character handling in filenames +- Multi-file deletion +- File existence checks and metadata + +**Test count:** 30+ assertions across URL generation, storage, and retrieval diff --git a/packages/core-php/composer.json b/packages/core-php/composer.json index 801c408..c5bc91e 100644 --- a/packages/core-php/composer.json +++ b/packages/core-php/composer.json @@ -15,13 +15,19 @@ "laravel/pennant": "^1.0", "livewire/livewire": "^3.0|^4.0" }, + "suggest": { + "spatie/laravel-activitylog": "Required for activity logging features (^4.0)" + }, "autoload": { "psr-4": { "Core\\": "src/Core/", "Core\\Website\\": "src/Website/", "Core\\Mod\\": "src/Mod/", "Core\\Plug\\": "src/Plug/" - } + }, + "files": [ + "src/Core/Media/Thumbnail/helpers.php" + ] }, "autoload-dev": { "psr-4": { @@ -35,7 +41,8 @@ "laravel": { "providers": [ "Core\\LifecycleEventProvider", - "Core\\Lang\\LangServiceProvider" + "Core\\Lang\\LangServiceProvider", + "Core\\Bouncer\\Gate\\Boot" ] } }, diff --git a/packages/core-php/config/core.php b/packages/core-php/config/core.php index 51b1726..bf5f195 100644 --- a/packages/core-php/config/core.php +++ b/packages/core-php/config/core.php @@ -112,6 +112,27 @@ return [ ], ], + /* + |-------------------------------------------------------------------------- + | Email Shield Configuration + |-------------------------------------------------------------------------- + | + | Configure the Email Shield validation and statistics module. + | Statistics track daily email validation counts for monitoring and + | analysis. Old records are automatically pruned based on retention period. + | + | Schedule the prune command in your app/Console/Kernel.php: + | $schedule->command('email-shield:prune')->daily(); + | + */ + + 'email_shield' => [ + // Number of days to retain email shield statistics records. + // Records older than this will be deleted by the prune command. + // Set to 0 to disable automatic pruning. + 'retention_days' => env('CORE_EMAIL_SHIELD_RETENTION_DAYS', 90), + ], + /* |-------------------------------------------------------------------------- | Admin Menu Configuration @@ -153,6 +174,73 @@ return [ // Whether to dispatch RedisFallbackActivated events for monitoring/alerting 'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true), + + /* + |---------------------------------------------------------------------- + | Circuit Breaker Configuration + |---------------------------------------------------------------------- + | + | The circuit breaker prevents cascading failures when Redis becomes + | unavailable. When failures exceed the threshold, the circuit opens + | and requests go directly to the fallback, avoiding repeated + | connection attempts that slow down the application. + | + */ + + 'circuit_breaker' => [ + // Enable/disable the circuit breaker + 'enabled' => env('CORE_STORAGE_CIRCUIT_BREAKER_ENABLED', true), + + // Number of failures before opening the circuit + 'failure_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_FAILURES', 5), + + // Seconds to wait before attempting recovery (half-open state) + 'recovery_timeout' => env('CORE_STORAGE_CIRCUIT_BREAKER_RECOVERY', 30), + + // Number of successful operations to close the circuit + 'success_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_SUCCESSES', 2), + + // Cache driver for storing circuit breaker state (use non-Redis driver) + 'state_driver' => env('CORE_STORAGE_CIRCUIT_BREAKER_DRIVER', 'file'), + ], + + /* + |---------------------------------------------------------------------- + | Storage Metrics Configuration + |---------------------------------------------------------------------- + | + | Storage metrics collect information about cache operations including + | hit/miss rates, latencies, and fallback activations. Use these + | metrics for monitoring cache health and performance tuning. + | + */ + + 'metrics' => [ + // Enable/disable metrics collection + 'enabled' => env('CORE_STORAGE_METRICS_ENABLED', true), + + // Maximum latency samples to keep per driver (for percentile calculations) + 'max_samples' => env('CORE_STORAGE_METRICS_MAX_SAMPLES', 1000), + + // Whether to log metrics events + 'log_enabled' => env('CORE_STORAGE_METRICS_LOG', true), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Service Configuration + |-------------------------------------------------------------------------- + | + | Configure service discovery and dependency resolution. Services are + | discovered by scanning module paths for classes implementing + | ServiceDefinition. + | + */ + + 'services' => [ + // Whether to cache service discovery results + 'cache_discovery' => env('CORE_SERVICES_CACHE_DISCOVERY', true), ], /* @@ -185,6 +273,183 @@ return [ // 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'), + + // Enable ICU message format support. + // Requires the PHP intl extension for full functionality. + // When disabled, ICU patterns will use basic placeholder replacement. + 'icu_enabled' => env('CORE_LANG_ICU_ENABLED', true), + ], + + /* + |-------------------------------------------------------------------------- + | Bouncer Action Gate Configuration + |-------------------------------------------------------------------------- + | + | Configure the action whitelisting system. Philosophy: "If it wasn't + | trained, it doesn't exist." Every controller action must be explicitly + | permitted. Unknown actions are blocked (production) or prompt for + | approval (training mode). + | + */ + + 'bouncer' => [ + // Enable training mode to allow approving new actions interactively. + // In production, this should be false to enforce strict whitelisting. + // In development/staging, enable to train the system with valid actions. + 'training_mode' => env('CORE_BOUNCER_TRAINING_MODE', false), + + // Whether to enable the action gate middleware. + // Set to false to completely disable action whitelisting. + 'enabled' => env('CORE_BOUNCER_ENABLED', true), + + // Guards that should have action gating applied. + // Actions on routes using these middleware groups will be checked. + 'guarded_middleware' => ['web', 'admin', 'api', 'client'], + + // Routes matching these patterns will bypass the action gate. + // Use for login pages, public assets, health checks, etc. + 'bypass_patterns' => [ + 'login', + 'logout', + 'register', + 'password/*', + 'sanctum/*', + 'livewire/*', + '_debugbar/*', + 'horizon/*', + 'telescope/*', + ], + + // Number of days to retain action request logs. + // Set to 0 to disable automatic pruning. + 'log_retention_days' => env('CORE_BOUNCER_LOG_RETENTION', 30), + + // Whether to log allowed requests (can generate many records). + // Recommended: false in production, true during training. + 'log_allowed_requests' => env('CORE_BOUNCER_LOG_ALLOWED', false), + + /* + |---------------------------------------------------------------------- + | Honeypot Configuration + |---------------------------------------------------------------------- + | + | Configure the honeypot system that traps bots ignoring robots.txt. + | Paths listed in robots.txt as disallowed are monitored; any request + | indicates a bot that doesn't respect robots.txt. + | + */ + + 'honeypot' => [ + // Whether to auto-block IPs that hit critical honeypot paths. + // When enabled, IPs hitting paths like /admin or /.env are blocked. + // Set to false to require manual review of all honeypot hits. + 'auto_block_critical' => env('CORE_BOUNCER_HONEYPOT_AUTO_BLOCK', true), + + // Rate limiting for honeypot logging to prevent DoS via log flooding. + // Maximum number of log entries per IP within the time window. + 'rate_limit_max' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX', 10), + + // Rate limit time window in seconds (default: 60 = 1 minute). + 'rate_limit_window' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW', 60), + + // Severity levels for honeypot paths. + // 'critical' - Active probing (admin panels, config files). + // 'warning' - General robots.txt violation. + 'severity_levels' => [ + 'critical' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL', 'critical'), + 'warning' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING', 'warning'), + ], + + // Paths that indicate critical/malicious probing. + // Requests to these paths result in 'critical' severity. + // Supports prefix matching (e.g., 'admin' matches '/admin', '/admin/login'). + 'critical_paths' => [ + 'admin', + 'wp-admin', + 'wp-login.php', + 'administrator', + 'phpmyadmin', + '.env', + '.git', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Workspace Cache Configuration + |-------------------------------------------------------------------------- + | + | Configure workspace-scoped caching for multi-tenant resources. + | Models using the BelongsToWorkspace trait can cache their collections + | with automatic invalidation when records are created, updated, or deleted. + | + | The cache system supports both tagged cache stores (Redis, Memcached) + | and non-tagged stores (file, database, array). Tagged stores provide + | more efficient cache invalidation. + | + */ + + 'workspace_cache' => [ + // Whether to enable workspace-scoped caching. + // Set to false to completely disable caching (all queries hit the database). + 'enabled' => env('CORE_WORKSPACE_CACHE_ENABLED', true), + + // Default TTL in seconds for cached workspace queries. + // Individual queries can override this with their own TTL. + 'ttl' => env('CORE_WORKSPACE_CACHE_TTL', 300), + + // Cache key prefix to avoid collisions with other cache keys. + // Change this if you need to separate cache data between deployments. + 'prefix' => env('CORE_WORKSPACE_CACHE_PREFIX', 'workspace_cache'), + + // Whether to use cache tags if available. + // Tags provide more efficient cache invalidation (flush by workspace or model). + // Only works with tag-supporting stores (Redis, Memcached). + // Set to false to always use key-based cache management. + 'use_tags' => env('CORE_WORKSPACE_CACHE_USE_TAGS', true), + ], + + /* + |-------------------------------------------------------------------------- + | Activity Logging Configuration + |-------------------------------------------------------------------------- + | + | Configure activity logging for audit trails across modules. + | Uses spatie/laravel-activitylog under the hood with workspace-aware + | enhancements for multi-tenant environments. + | + | Models can use the Core\Activity\Concerns\LogsActivity trait to + | automatically log create, update, and delete operations. + | + */ + + 'activity' => [ + // Whether to enable activity logging globally. + // Set to false to completely disable activity logging. + 'enabled' => env('CORE_ACTIVITY_ENABLED', true), + + // The log name to use for activities. + // Different log names can be used to separate activities by context. + 'log_name' => env('CORE_ACTIVITY_LOG_NAME', 'default'), + + // Whether to include workspace_id in activity properties. + // Enable this in multi-tenant applications to scope activities per workspace. + 'include_workspace' => env('CORE_ACTIVITY_INCLUDE_WORKSPACE', true), + + // Default events to log when using the LogsActivity trait. + // Models can override this with the $activityLogEvents property. + 'default_events' => ['created', 'updated', 'deleted'], + + // Number of days to retain activity logs. + // Use the activity:prune command to clean up old logs. + // Set to 0 to disable automatic pruning. + 'retention_days' => env('CORE_ACTIVITY_RETENTION_DAYS', 90), + + // Custom Activity model class (optional). + // Set this to use a custom Activity model with additional scopes. + // Default: Core\Activity\Models\Activity::class + 'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class), ], ]; diff --git a/packages/core-php/phpunit.xml b/packages/core-php/phpunit.xml index d8f2a72..5a49573 100644 --- a/packages/core-php/phpunit.xml +++ b/packages/core-php/phpunit.xml @@ -13,9 +13,13 @@ tests/Feature + src/Core/**/Tests/Feature + src/Mod/**/Tests/Feature tests/Unit + src/Core/**/Tests/Unit + src/Mod/**/Tests/Unit diff --git a/packages/core-php/src/Core/Actions/Action.php b/packages/core-php/src/Core/Actions/Action.php index cbe7aab..b8ccbec 100644 --- a/packages/core-php/src/Core/Actions/Action.php +++ b/packages/core-php/src/Core/Actions/Action.php @@ -12,21 +12,21 @@ namespace Core\Actions; * * Convention: * - One action per file - * - Named after what it does: CreateBiolink, PublishPost, SendInvoice + * - Named after what it does: CreatePage, PublishPost, SendInvoice * - Single public method: handle() or __invoke() * - Dependencies injected via constructor * - Static run() helper for convenience * * Usage: * // Via dependency injection - * public function __construct(private CreateBiolink $createBiolink) {} - * $biolink = $this->createBiolink->handle($user, $data); + * public function __construct(private CreatePage $createPage) {} + * $page = $this->createPage->handle($user, $data); * * // Via static helper - * $biolink = CreateBiolink::run($user, $data); + * $page = CreatePage::run($user, $data); * * // Via app container - * $biolink = app(CreateBiolink::class)->handle($user, $data); + * $page = app(CreatePage::class)->handle($user, $data); * * Directory structure: * app/Mod/{Module}/Actions/ diff --git a/packages/core-php/src/Core/Activity/Boot.php b/packages/core-php/src/Core/Activity/Boot.php new file mode 100644 index 0000000..5e35c87 --- /dev/null +++ b/packages/core-php/src/Core/Activity/Boot.php @@ -0,0 +1,77 @@ + 'onConsole', + AdminPanelBooting::class => 'onAdmin', + ]; + + /** + * Register console commands. + */ + public function onConsole(ConsoleBooting $event): void + { + if (! $this->isEnabled()) { + return; + } + + $event->command(ActivityPruneCommand::class); + } + + /** + * Register admin panel components and routes. + */ + public function onAdmin(AdminPanelBooting $event): void + { + if (! $this->isEnabled()) { + return; + } + + // Register view namespace + $event->views('core.activity', __DIR__.'/View/Blade'); + + // Register Livewire component + Livewire::component('core.activity-feed', ActivityFeed::class); + + // Bind service as singleton + app()->singleton(ActivityLogService::class); + } + + /** + * Check if activity logging is enabled. + */ + protected function isEnabled(): bool + { + return config('core.activity.enabled', true); + } +} diff --git a/packages/core-php/src/Core/Activity/Concerns/LogsActivity.php b/packages/core-php/src/Core/Activity/Concerns/LogsActivity.php new file mode 100644 index 0000000..95950cf --- /dev/null +++ b/packages/core-php/src/Core/Activity/Concerns/LogsActivity.php @@ -0,0 +1,238 @@ +shouldLogOnlyDirty()) { + $options->logOnlyDirty(); + } + + // Only log if there are actual changes + $options->dontSubmitEmptyLogs(); + + // Set log name from model property or config + $options->useLogName($this->getActivityLogName()); + + // Configure which attributes to log + $attributes = $this->getActivityLogAttributes(); + if ($attributes !== null) { + $options->logOnly($attributes); + } else { + $options->logAll(); + } + + // Configure which events to log + $events = $this->getActivityLogEvents(); + $options->logOnlyDirty(); + + // Set custom description generator + $options->setDescriptionForEvent(fn (string $eventName) => $this->getActivityDescription($eventName)); + + return $options; + } + + /** + * Tap into the activity before it's saved to add workspace_id. + */ + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void + { + if ($this->shouldIncludeWorkspace()) { + $workspaceId = $this->getActivityWorkspaceId(); + if ($workspaceId !== null) { + $activity->properties = $activity->properties->merge([ + 'workspace_id' => $workspaceId, + ]); + } + } + + // Allow further customisation in using models + if (method_exists($this, 'customizeActivity')) { + $this->customizeActivity($activity, $eventName); + } + } + + /** + * Get the workspace ID for this activity. + */ + protected function getActivityWorkspaceId(): ?int + { + // If model has workspace_id attribute, use it + if (isset($this->workspace_id)) { + return $this->workspace_id; + } + + // Try to get from current workspace context + return $this->getCurrentWorkspaceId(); + } + + /** + * Get the current workspace ID from context. + */ + protected function getCurrentWorkspaceId(): ?int + { + // First try to get from request attributes (set by middleware) + if (request()->attributes->has('workspace_model')) { + $workspace = request()->attributes->get('workspace_model'); + + return $workspace?->id; + } + + // Then try to get from authenticated user + $user = auth()->user(); + if ($user && method_exists($user, 'defaultHostWorkspace')) { + $workspace = $user->defaultHostWorkspace(); + + return $workspace?->id; + } + + return null; + } + + /** + * Generate a description for the activity event. + */ + protected function getActivityDescription(string $eventName): string + { + $modelName = class_basename(static::class); + + return match ($eventName) { + 'created' => "Created {$modelName}", + 'updated' => "Updated {$modelName}", + 'deleted' => "Deleted {$modelName}", + default => ucfirst($eventName)." {$modelName}", + }; + } + + /** + * Get the log name for this model. + */ + protected function getActivityLogName(): string + { + if (property_exists($this, 'activityLogName') && $this->activityLogName) { + return $this->activityLogName; + } + + return config('core.activity.log_name', 'default'); + } + + /** + * Get the attributes to log. + * + * @return array|null Null means log all attributes + */ + protected function getActivityLogAttributes(): ?array + { + if (property_exists($this, 'activityLogAttributes') && is_array($this->activityLogAttributes)) { + return $this->activityLogAttributes; + } + + return null; + } + + /** + * Get the events to log. + * + * @return array + */ + protected function getActivityLogEvents(): array + { + if (property_exists($this, 'activityLogEvents') && is_array($this->activityLogEvents)) { + return $this->activityLogEvents; + } + + return config('core.activity.default_events', ['created', 'updated', 'deleted']); + } + + /** + * Whether to include workspace_id in activity properties. + */ + protected function shouldIncludeWorkspace(): bool + { + if (property_exists($this, 'activityLogWorkspace')) { + return (bool) $this->activityLogWorkspace; + } + + return config('core.activity.include_workspace', true); + } + + /** + * Whether to only log dirty (changed) attributes. + */ + protected function shouldLogOnlyDirty(): bool + { + if (property_exists($this, 'activityLogOnlyDirty')) { + return (bool) $this->activityLogOnlyDirty; + } + + return true; + } + + /** + * Check if activity logging is enabled. + */ + public static function activityLoggingEnabled(): bool + { + return config('core.activity.enabled', true); + } + + /** + * Temporarily disable activity logging for a callback. + */ + public static function withoutActivityLogging(callable $callback): mixed + { + $previousState = activity()->isEnabled(); + + activity()->disableLogging(); + + try { + return $callback(); + } finally { + if ($previousState) { + activity()->enableLogging(); + } + } + } +} diff --git a/packages/core-php/src/Core/Activity/Console/ActivityPruneCommand.php b/packages/core-php/src/Core/Activity/Console/ActivityPruneCommand.php new file mode 100644 index 0000000..4d7bd53 --- /dev/null +++ b/packages/core-php/src/Core/Activity/Console/ActivityPruneCommand.php @@ -0,0 +1,65 @@ +option('days') + ? (int) $this->option('days') + : config('core.activity.retention_days', 90); + + if ($days <= 0) { + $this->warn('Activity pruning is disabled (retention_days = 0).'); + + return self::SUCCESS; + } + + $cutoffDate = now()->subDays($days); + + $this->info("Pruning activities older than {$days} days (before {$cutoffDate->toDateString()})..."); + + if ($this->option('dry-run')) { + // Count without deleting + $activityModel = config('core.activity.activity_model', \Spatie\Activitylog\Models\Activity::class); + $count = $activityModel::where('created_at', '<', $cutoffDate)->count(); + + $this->info("Would delete {$count} activity records."); + + return self::SUCCESS; + } + + $deleted = $activityService->prune($days); + + $this->info("Deleted {$deleted} old activity records."); + + return self::SUCCESS; + } +} diff --git a/packages/core-php/src/Core/Activity/Models/Activity.php b/packages/core-php/src/Core/Activity/Models/Activity.php new file mode 100644 index 0000000..b95a408 --- /dev/null +++ b/packages/core-php/src/Core/Activity/Models/Activity.php @@ -0,0 +1,203 @@ + \Core\Activity\Models\Activity::class, + * + * @method static \Illuminate\Database\Eloquent\Builder forWorkspace(\Illuminate\Database\Eloquent\Model|int $workspace) + * @method static \Illuminate\Database\Eloquent\Builder forSubject(\Illuminate\Database\Eloquent\Model $subject) + * @method static \Illuminate\Database\Eloquent\Builder forSubjectType(string $subjectType) + * @method static \Illuminate\Database\Eloquent\Builder byCauser(\Illuminate\Contracts\Auth\Authenticatable|\Illuminate\Database\Eloquent\Model $user) + * @method static \Illuminate\Database\Eloquent\Builder byCauserId(int $causerId, string|null $causerType = null) + * @method static \Illuminate\Database\Eloquent\Builder ofType(string|array $event) + * @method static \Illuminate\Database\Eloquent\Builder createdEvents() + * @method static \Illuminate\Database\Eloquent\Builder updatedEvents() + * @method static \Illuminate\Database\Eloquent\Builder deletedEvents() + * @method static \Illuminate\Database\Eloquent\Builder betweenDates(\DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null) + * @method static \Illuminate\Database\Eloquent\Builder today() + * @method static \Illuminate\Database\Eloquent\Builder lastDays(int $days) + * @method static \Illuminate\Database\Eloquent\Builder lastHours(int $hours) + * @method static \Illuminate\Database\Eloquent\Builder search(string $search) + * @method static \Illuminate\Database\Eloquent\Builder inLog(string $logName) + * @method static \Illuminate\Database\Eloquent\Builder withChanges() + * @method static \Illuminate\Database\Eloquent\Builder withExistingSubject() + * @method static \Illuminate\Database\Eloquent\Builder withDeletedSubject() + * @method static \Illuminate\Database\Eloquent\Builder newest() + * @method static \Illuminate\Database\Eloquent\Builder oldest() + */ +class Activity extends SpatieActivity +{ + use ActivityScopes; + + /** + * Get the workspace ID from properties. + */ + public function getWorkspaceIdAttribute(): ?int + { + return $this->properties->get('workspace_id'); + } + + /** + * Get the old values from properties. + * + * @return array + */ + public function getOldValuesAttribute(): array + { + return $this->properties->get('old', []); + } + + /** + * Get the new values from properties. + * + * @return array + */ + public function getNewValuesAttribute(): array + { + return $this->properties->get('attributes', []); + } + + /** + * Get the changed attributes. + * + * @return array + */ + public function getChangesAttribute(): array + { + $old = $this->old_values; + $new = $this->new_values; + $changes = []; + + foreach ($new as $key => $newValue) { + $oldValue = $old[$key] ?? null; + if ($oldValue !== $newValue) { + $changes[$key] = [ + 'old' => $oldValue, + 'new' => $newValue, + ]; + } + } + + return $changes; + } + + /** + * Check if this activity has any changes. + */ + public function hasChanges(): bool + { + return ! empty($this->new_values) || ! empty($this->old_values); + } + + /** + * Get a human-readable summary of changes. + */ + public function getChangesSummary(): string + { + $changes = $this->changes; + + if (empty($changes)) { + return 'No changes recorded'; + } + + $parts = []; + foreach ($changes as $field => $values) { + $parts[] = sprintf( + '%s: %s -> %s', + $field, + $this->formatValue($values['old']), + $this->formatValue($values['new']) + ); + } + + return implode(', ', $parts); + } + + /** + * Format a value for display. + */ + protected function formatValue(mixed $value): string + { + if ($value === null) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_array($value)) { + return json_encode($value); + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string) $value; + } + + /** + * Get the display name for the causer. + */ + public function getCauserNameAttribute(): string + { + $causer = $this->causer; + + if (! $causer) { + return 'System'; + } + + return $causer->name ?? $causer->email ?? 'User #'.$causer->getKey(); + } + + /** + * Get the display name for the subject. + */ + public function getSubjectNameAttribute(): ?string + { + $subject = $this->subject; + + if (! $subject) { + return null; + } + + // Try common name attributes + foreach (['name', 'title', 'label', 'email', 'slug'] as $attribute) { + if (isset($subject->{$attribute})) { + return (string) $subject->{$attribute}; + } + } + + return class_basename($subject).' #'.$subject->getKey(); + } + + /** + * Get the subject type as a readable name. + */ + public function getSubjectTypeNameAttribute(): ?string + { + return $this->subject_type ? class_basename($this->subject_type) : null; + } +} diff --git a/packages/core-php/src/Core/Activity/Scopes/ActivityScopes.php b/packages/core-php/src/Core/Activity/Scopes/ActivityScopes.php new file mode 100644 index 0000000..64ca4f9 --- /dev/null +++ b/packages/core-php/src/Core/Activity/Scopes/ActivityScopes.php @@ -0,0 +1,262 @@ +get(); + * Activity::forSubject($post)->ofType('updated')->get(); + * + * @requires spatie/laravel-activitylog + */ +trait ActivityScopes +{ + /** + * Scope activities to a specific workspace. + * + * Filters activities where either: + * - The workspace_id is stored in properties + * - The subject model has the given workspace_id + * + * @param Model|int $workspace Workspace model or ID + */ + public function scopeForWorkspace(Builder $query, Model|int $workspace): Builder + { + $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace; + + return $query->where(function (Builder $q) use ($workspaceId) { + // Check properties->workspace_id + $q->whereJsonContains('properties->workspace_id', $workspaceId); + + // Or check if subject has workspace_id + $q->orWhereHasMorph( + 'subject', + '*', + fn (Builder $subjectQuery) => $subjectQuery->where('workspace_id', $workspaceId) + ); + }); + } + + /** + * Scope activities to a specific subject model. + * + * @param Model $subject The subject model instance + */ + public function scopeForSubject(Builder $query, Model $subject): Builder + { + return $query + ->where('subject_type', get_class($subject)) + ->where('subject_id', $subject->getKey()); + } + + /** + * Scope activities to a specific subject type. + * + * @param string $subjectType Fully qualified class name + */ + public function scopeForSubjectType(Builder $query, string $subjectType): Builder + { + return $query->where('subject_type', $subjectType); + } + + /** + * Scope activities by the causer (user who performed the action). + * + * @param Authenticatable|Model $user The causer model + */ + public function scopeByCauser(Builder $query, Authenticatable|Model $user): Builder + { + return $query + ->where('causer_type', get_class($user)) + ->where('causer_id', $user->getKey()); + } + + /** + * Scope activities by causer ID (when you don't have the model). + * + * @param int $causerId The causer's primary key + * @param string|null $causerType Optional causer type (defaults to User model) + */ + public function scopeByCauserId(Builder $query, int $causerId, ?string $causerType = null): Builder + { + $query->where('causer_id', $causerId); + + if ($causerType !== null) { + $query->where('causer_type', $causerType); + } + + return $query; + } + + /** + * Scope activities by event type. + * + * @param string|array $event Event type(s): 'created', 'updated', 'deleted' + */ + public function scopeOfType(Builder $query, string|array $event): Builder + { + $events = is_array($event) ? $event : [$event]; + + return $query->whereIn('event', $events); + } + + /** + * Scope to only created events. + */ + public function scopeCreatedEvents(Builder $query): Builder + { + return $query->where('event', 'created'); + } + + /** + * Scope to only updated events. + */ + public function scopeUpdatedEvents(Builder $query): Builder + { + return $query->where('event', 'updated'); + } + + /** + * Scope to only deleted events. + */ + public function scopeDeletedEvents(Builder $query): Builder + { + return $query->where('event', 'deleted'); + } + + /** + * Scope activities within a date range. + * + * @param \DateTimeInterface|string $from Start date + * @param \DateTimeInterface|string|null $to End date (optional) + */ + public function scopeBetweenDates(Builder $query, \DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null): Builder + { + $query->where('created_at', '>=', $from); + + if ($to !== null) { + $query->where('created_at', '<=', $to); + } + + return $query; + } + + /** + * Scope activities from today. + */ + public function scopeToday(Builder $query): Builder + { + return $query->whereDate('created_at', now()->toDateString()); + } + + /** + * Scope activities from the last N days. + * + * @param int $days Number of days + */ + public function scopeLastDays(Builder $query, int $days): Builder + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /** + * Scope activities from the last N hours. + * + * @param int $hours Number of hours + */ + public function scopeLastHours(Builder $query, int $hours): Builder + { + return $query->where('created_at', '>=', now()->subHours($hours)); + } + + /** + * Search activities by description. + * + * @param string $search Search term + */ + public function scopeSearch(Builder $query, string $search): Builder + { + $term = '%'.addcslashes($search, '%_').'%'; + + return $query->where(function (Builder $q) use ($term) { + $q->where('description', 'LIKE', $term) + ->orWhere('properties', 'LIKE', $term); + }); + } + + /** + * Scope to activities in a specific log. + * + * @param string $logName The log name + */ + public function scopeInLog(Builder $query, string $logName): Builder + { + return $query->where('log_name', $logName); + } + + /** + * Scope to activities with changes (non-empty properties). + */ + public function scopeWithChanges(Builder $query): Builder + { + return $query->where(function (Builder $q) { + $q->whereJsonLength('properties->attributes', '>', 0) + ->orWhereJsonLength('properties->old', '>', 0); + }); + } + + /** + * Scope to activities for models that still exist. + */ + public function scopeWithExistingSubject(Builder $query): Builder + { + return $query->whereHas('subject'); + } + + /** + * Scope to activities for models that have been deleted. + */ + public function scopeWithDeletedSubject(Builder $query): Builder + { + return $query->whereDoesntHave('subject'); + } + + /** + * Order by newest first. + */ + public function scopeNewest(Builder $query): Builder + { + return $query->latest('created_at'); + } + + /** + * Order by oldest first. + */ + public function scopeOldest(Builder $query): Builder + { + return $query->oldest('created_at'); + } +} diff --git a/packages/core-php/src/Core/Activity/Services/ActivityLogService.php b/packages/core-php/src/Core/Activity/Services/ActivityLogService.php new file mode 100644 index 0000000..a39a297 --- /dev/null +++ b/packages/core-php/src/Core/Activity/Services/ActivityLogService.php @@ -0,0 +1,448 @@ +logFor($post); + * + * // Get activities by a user within a workspace + * $activities = $service->logBy($user)->forWorkspace($workspace)->recent(); + * + * // Search activities + * $results = $service->search('updated post'); + * + * @requires spatie/laravel-activitylog + */ +class ActivityLogService +{ + protected ?Builder $query = null; + + protected ?int $workspaceId = null; + + /** + * Get the base activity query. + */ + protected function newQuery(): Builder + { + return Activity::query()->latest(); + } + + /** + * Get or create the current query builder. + */ + protected function query(): Builder + { + if ($this->query === null) { + $this->query = $this->newQuery(); + } + + return $this->query; + } + + /** + * Reset the query builder for a new chain. + */ + public function fresh(): self + { + $this->query = null; + $this->workspaceId = null; + + return $this; + } + + /** + * Get activities for a specific model (subject). + * + * @param Model $subject The model to get activities for + */ + public function logFor(Model $subject): self + { + $this->query() + ->where('subject_type', get_class($subject)) + ->where('subject_id', $subject->getKey()); + + return $this; + } + + /** + * Get activities performed by a specific user. + * + * @param Authenticatable|Model $causer The user who caused the activities + */ + public function logBy(Authenticatable|Model $causer): self + { + $this->query() + ->where('causer_type', get_class($causer)) + ->where('causer_id', $causer->getKey()); + + return $this; + } + + /** + * Scope activities to a specific workspace. + * + * @param Model|int $workspace The workspace or workspace ID + */ + public function forWorkspace(Model|int $workspace): self + { + $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace; + $this->workspaceId = $workspaceId; + + $this->query()->where(function (Builder $q) use ($workspaceId) { + $q->whereJsonContains('properties->workspace_id', $workspaceId) + ->orWhere(function (Builder $subQ) use ($workspaceId) { + // Also check if subject has workspace_id + $subQ->whereHas('subject', function (Builder $subjectQuery) use ($workspaceId) { + $subjectQuery->where('workspace_id', $workspaceId); + }); + }); + }); + + return $this; + } + + /** + * Filter activities by subject type. + * + * @param string $subjectType Fully qualified class name + */ + public function forSubjectType(string $subjectType): self + { + $this->query()->where('subject_type', $subjectType); + + return $this; + } + + /** + * Filter activities by event type. + * + * @param string|array $event Event type(s): 'created', 'updated', 'deleted', etc. + */ + public function ofType(string|array $event): self + { + $events = is_array($event) ? $event : [$event]; + + $this->query()->whereIn('event', $events); + + return $this; + } + + /** + * Filter activities by log name. + * + * @param string $logName The log name to filter by + */ + public function inLog(string $logName): self + { + $this->query()->where('log_name', $logName); + + return $this; + } + + /** + * Filter activities within a date range. + */ + public function between(\DateTimeInterface|string $from, \DateTimeInterface|string|null $to = null): self + { + $this->query()->where('created_at', '>=', $from); + + if ($to !== null) { + $this->query()->where('created_at', '<=', $to); + } + + return $this; + } + + /** + * Filter activities from the last N days. + * + * @param int $days Number of days + */ + public function lastDays(int $days): self + { + $this->query()->where('created_at', '>=', now()->subDays($days)); + + return $this; + } + + /** + * Search activity descriptions. + * + * @param string $query Search query + */ + public function search(string $query): self + { + $searchTerm = '%'.addcslashes($query, '%_').'%'; + + $this->query()->where(function (Builder $q) use ($searchTerm) { + $q->where('description', 'LIKE', $searchTerm) + ->orWhere('properties', 'LIKE', $searchTerm); + }); + + return $this; + } + + /** + * Get recent activities with optional limit. + * + * @param int $limit Maximum number of activities to return + */ + public function recent(int $limit = 50): Collection + { + return $this->query() + ->with(['causer', 'subject']) + ->limit($limit) + ->get(); + } + + /** + * Get paginated activities. + * + * @param int $perPage Number of activities per page + */ + public function paginate(int $perPage = 15): LengthAwarePaginator + { + return $this->query() + ->with(['causer', 'subject']) + ->paginate($perPage); + } + + /** + * Get all filtered activities. + */ + public function get(): Collection + { + return $this->query() + ->with(['causer', 'subject']) + ->get(); + } + + /** + * Get the first activity. + */ + public function first(): ?Activity + { + return $this->query() + ->with(['causer', 'subject']) + ->first(); + } + + /** + * Count the activities. + */ + public function count(): int + { + return $this->query()->count(); + } + + /** + * Get activity statistics for a workspace. + * + * @return array{total: int, by_event: array, by_subject: array, by_user: array} + */ + public function statistics(Model|int|null $workspace = null): array + { + $query = $this->newQuery(); + + if ($workspace !== null) { + $workspaceId = $workspace instanceof Model ? $workspace->getKey() : $workspace; + $query->whereJsonContains('properties->workspace_id', $workspaceId); + } + + // Get totals by event type + $byEvent = (clone $query) + ->selectRaw('event, COUNT(*) as count') + ->groupBy('event') + ->pluck('count', 'event') + ->toArray(); + + // Get totals by subject type + $bySubject = (clone $query) + ->selectRaw('subject_type, COUNT(*) as count') + ->whereNotNull('subject_type') + ->groupBy('subject_type') + ->pluck('count', 'subject_type') + ->mapWithKeys(fn ($count, $type) => [class_basename($type) => $count]) + ->toArray(); + + // Get top users + $byUser = (clone $query) + ->selectRaw('causer_id, causer_type, COUNT(*) as count') + ->whereNotNull('causer_id') + ->groupBy('causer_id', 'causer_type') + ->orderByDesc('count') + ->limit(10) + ->get() + ->mapWithKeys(function ($row) { + $causer = $row->causer; + $name = $causer?->name ?? $causer?->email ?? "User #{$row->causer_id}"; + + return [$name => $row->count]; + }) + ->toArray(); + + return [ + 'total' => $query->count(), + 'by_event' => $byEvent, + 'by_subject' => $bySubject, + 'by_user' => $byUser, + ]; + } + + /** + * Get timeline of activities grouped by date. + * + * @param int $days Number of days to include + */ + public function timeline(int $days = 30): \Illuminate\Support\Collection + { + return $this->lastDays($days) + ->query() + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date'); + } + + /** + * Format an activity for display. + * + * @return array{ + * id: int, + * event: string, + * description: string, + * timestamp: string, + * relative_time: string, + * actor: array|null, + * subject: array|null, + * changes: array|null, + * workspace_id: int|null + * } + */ + public function format(Activity $activity): array + { + $causer = $activity->causer; + $subject = $activity->subject; + $properties = $activity->properties; + + // Extract changes if available + $changes = null; + if ($properties->has('attributes') || $properties->has('old')) { + $changes = [ + 'old' => $properties->get('old', []), + 'new' => $properties->get('attributes', []), + ]; + } + + return [ + 'id' => $activity->id, + 'event' => $activity->event ?? 'activity', + 'description' => $activity->description, + 'timestamp' => $activity->created_at->toIso8601String(), + 'relative_time' => $activity->created_at->diffForHumans(), + 'actor' => $causer ? [ + 'id' => $causer->getKey(), + 'name' => $causer->name ?? $causer->email ?? 'Unknown', + 'avatar' => method_exists($causer, 'avatarUrl') ? $causer->avatarUrl() : null, + 'initials' => $this->getInitials($causer->name ?? $causer->email ?? 'U'), + ] : null, + 'subject' => $subject ? [ + 'id' => $subject->getKey(), + 'type' => class_basename($subject), + 'name' => $this->getSubjectName($subject), + 'url' => $this->getSubjectUrl($subject), + ] : null, + 'changes' => $changes, + 'workspace_id' => $properties->get('workspace_id'), + ]; + } + + /** + * Get initials from a name. + */ + protected function getInitials(string $name): string + { + $words = explode(' ', trim($name)); + + if (count($words) >= 2) { + return strtoupper(substr($words[0], 0, 1).substr(end($words), 0, 1)); + } + + return strtoupper(substr($name, 0, 2)); + } + + /** + * Get the display name for a subject. + */ + protected function getSubjectName(Model $subject): string + { + // Try common name attributes + foreach (['name', 'title', 'label', 'email', 'slug'] as $attribute) { + if (isset($subject->{$attribute})) { + return (string) $subject->{$attribute}; + } + } + + return class_basename($subject).' #'.$subject->getKey(); + } + + /** + * Get the URL for a subject if available. + */ + protected function getSubjectUrl(Model $subject): ?string + { + // If model has a getUrl method, use it + if (method_exists($subject, 'getUrl')) { + return $subject->getUrl(); + } + + // If model has a url attribute + if (isset($subject->url)) { + return $subject->url; + } + + return null; + } + + /** + * Delete activities older than the retention period. + * + * @param int|null $days Days to retain (null = use config) + * @return int Number of deleted activities + */ + public function prune(?int $days = null): int + { + $retentionDays = $days ?? config('core.activity.retention_days', 90); + + if ($retentionDays <= 0) { + return 0; + } + + $cutoffDate = now()->subDays($retentionDays); + + return Activity::where('created_at', '<', $cutoffDate)->delete(); + } +} diff --git a/packages/core-php/src/Core/Activity/View/Blade/admin/activity-feed.blade.php b/packages/core-php/src/Core/Activity/View/Blade/admin/activity-feed.blade.php new file mode 100644 index 0000000..d393a06 --- /dev/null +++ b/packages/core-php/src/Core/Activity/View/Blade/admin/activity-feed.blade.php @@ -0,0 +1,322 @@ +
0) wire:poll.{{ $pollInterval }}s @endif> + Activity Log + + {{-- Statistics Cards --}} +
+ +
Total Activities
+
{{ number_format($this->statistics['total']) }}
+
+ + +
Created
+
{{ number_format($this->statistics['by_event']['created'] ?? 0) }}
+
+ + +
Updated
+
{{ number_format($this->statistics['by_event']['updated'] ?? 0) }}
+
+ + +
Deleted
+
{{ number_format($this->statistics['by_event']['deleted'] ?? 0) }}
+
+
+ + {{-- Filters --}} + +
+ + @foreach ($this->causers as $id => $name) + {{ $name }} + @endforeach + + + + @foreach ($this->subjectTypes as $type => $label) + {{ $label }} + @endforeach + + + + @foreach ($this->eventTypes as $type => $label) + {{ $label }} + @endforeach + + + + @foreach ($this->dateRanges as $days => $label) + {{ $label }} + @endforeach + + + + + @if ($causerId || $subjectType || $eventType || $daysBack !== 30 || $search) + + Clear Filters + + @endif +
+
+ + {{-- Activity List --}} + + @if ($this->activities->isEmpty()) +
+ + No Activities Found + + @if ($causerId || $subjectType || $eventType || $search) + Try adjusting your filters to see more results. + @else + Activity logging is enabled but no activities have been recorded yet. + @endif + +
+ @else +
+ @foreach ($this->activities as $activity) + @php + $formatted = $this->formatActivity($activity); + @endphp +
+ {{-- Avatar --}} +
+ @if ($formatted['actor']) + @if ($formatted['actor']['avatar']) + {{ $formatted['actor']['name'] }} + @else +
+ {{ $formatted['actor']['initials'] }} +
+ @endif + @else +
+ +
+ @endif +
+ + {{-- Details --}} +
+
+ + {{ $formatted['actor']['name'] ?? 'System' }} + + + {{ $formatted['description'] }} + +
+ + @if ($formatted['subject']) +
+ {{ $formatted['subject']['type'] }}: + @if ($formatted['subject']['url']) + + {{ $formatted['subject']['name'] }} + + @else + {{ $formatted['subject']['name'] }} + @endif +
+ @endif + + @if ($formatted['changes']) +
+
+ @php $changeCount = 0; @endphp + @foreach ($formatted['changes']['new'] as $key => $newValue) + @if (($formatted['changes']['old'][$key] ?? null) !== $newValue && $changeCount < 3) + @if ($changeCount > 0) + | + @endif + {{ $key }}: + {{ is_array($formatted['changes']['old'][$key] ?? null) ? json_encode($formatted['changes']['old'][$key]) : ($formatted['changes']['old'][$key] ?? 'null') }} + + {{ is_array($newValue) ? json_encode($newValue) : $newValue }} + @php $changeCount++; @endphp + @endif + @endforeach + @if (count(array_filter($formatted['changes']['new'], fn($v, $k) => ($formatted['changes']['old'][$k] ?? null) !== $v, ARRAY_FILTER_USE_BOTH)) > 3) + +{{ count($formatted['changes']['new']) - 3 }} more + @endif +
+
+ @endif + +
+ {{ $formatted['relative_time'] }} +
+
+ + {{-- Event Badge --}} +
+ + + {{ ucfirst($formatted['event']) }} + +
+
+ @endforeach +
+ + {{-- Pagination --}} + @if ($this->activities->hasPages()) +
+ {{ $this->activities->links() }} +
+ @endif + @endif +
+ + {{-- Detail Modal --}} + + @if ($this->selectedActivity) + @php + $selected = $this->formatActivity($this->selectedActivity); + @endphp +
+ Activity Details + + {{-- Activity Header --}} +
+ @if ($selected['actor']) + @if ($selected['actor']['avatar']) + {{ $selected['actor']['name'] }} + @else +
+ {{ $selected['actor']['initials'] }} +
+ @endif + @else +
+ +
+ @endif + +
+
+ + {{ $selected['actor']['name'] ?? 'System' }} + + + {{ ucfirst($selected['event']) }} + +
+
+ {{ $selected['description'] }} +
+
+ {{ $selected['relative_time'] }} · {{ \Carbon\Carbon::parse($selected['timestamp'])->format('M j, Y \a\t g:i A') }} +
+
+
+ + {{-- Subject Info --}} + @if ($selected['subject']) + +
Subject
+
+ {{ $selected['subject']['type'] }} + @if ($selected['subject']['url']) + + {{ $selected['subject']['name'] }} + + @else + {{ $selected['subject']['name'] }} + @endif +
+
+ @endif + + {{-- Changes Diff --}} + @if ($selected['changes'] && (count($selected['changes']['old']) > 0 || count($selected['changes']['new']) > 0)) +
+
Changes
+
+ + + + + + + + + + @foreach ($selected['changes']['new'] as $key => $newValue) + @php + $oldValue = $selected['changes']['old'][$key] ?? null; + @endphp + @if ($oldValue !== $newValue) + + + + + + @endif + @endforeach + +
FieldOld ValueNew Value
{{ $key }} + @if (is_array($oldValue)) +
{{ json_encode($oldValue, JSON_PRETTY_PRINT) }}
+ @elseif ($oldValue === null) + null + @elseif (is_bool($oldValue)) + {{ $oldValue ? 'true' : 'false' }} + @else + {{ $oldValue }} + @endif +
+ @if (is_array($newValue)) +
{{ json_encode($newValue, JSON_PRETTY_PRINT) }}
+ @elseif ($newValue === null) + null + @elseif (is_bool($newValue)) + {{ $newValue ? 'true' : 'false' }} + @else + {{ $newValue }} + @endif +
+
+
+ @endif + + {{-- Raw Properties --}} + + + + Raw Properties + + +
{{ json_encode($this->selectedActivity->properties, JSON_PRETTY_PRINT) }}
+
+
+
+ + {{-- Actions --}} +
+ Close +
+
+ @endif +
+
diff --git a/packages/core-php/src/Core/Activity/View/Modal/Admin/ActivityFeed.php b/packages/core-php/src/Core/Activity/View/Modal/Admin/ActivityFeed.php new file mode 100644 index 0000000..ba67ec8 --- /dev/null +++ b/packages/core-php/src/Core/Activity/View/Modal/Admin/ActivityFeed.php @@ -0,0 +1,369 @@ + + * + * + */ +class ActivityFeed extends Component +{ + use WithPagination; + + /** + * Filter by workspace ID. + */ + public ?int $workspaceId = null; + + /** + * Filter by causer (user) ID. + */ + #[Url] + public ?int $causerId = null; + + /** + * Filter by subject type (model class basename). + */ + #[Url] + public string $subjectType = ''; + + /** + * Filter by event type. + */ + #[Url] + public string $eventType = ''; + + /** + * Filter by date range (days back). + */ + #[Url] + public int $daysBack = 30; + + /** + * Search query. + */ + #[Url] + public string $search = ''; + + /** + * Currently selected activity for detail view. + */ + public ?int $selectedActivityId = null; + + /** + * Whether to show the detail modal. + */ + public bool $showDetailModal = false; + + /** + * Polling interval in seconds (0 = disabled). + */ + public int $pollInterval = 0; + + /** + * Number of items per page. + */ + public int $perPage = 15; + + protected ActivityLogService $activityService; + + public function boot(ActivityLogService $activityService): void + { + $this->activityService = $activityService; + } + + public function mount(?int $workspaceId = null, int $pollInterval = 0, int $perPage = 15): void + { + $this->workspaceId = $workspaceId; + $this->pollInterval = $pollInterval; + $this->perPage = $perPage; + } + + /** + * Get available subject types for filtering. + * + * @return array + */ + #[Computed] + public function subjectTypes(): array + { + $types = Activity::query() + ->whereNotNull('subject_type') + ->distinct() + ->pluck('subject_type') + ->mapWithKeys(fn ($type) => [class_basename($type) => class_basename($type)]) + ->toArray(); + + return ['' => 'All Types'] + $types; + } + + /** + * Get available event types for filtering. + * + * @return array + */ + #[Computed] + public function eventTypes(): array + { + return [ + '' => 'All Events', + 'created' => 'Created', + 'updated' => 'Updated', + 'deleted' => 'Deleted', + ]; + } + + /** + * Get available users (causers) for filtering. + * + * @return array + */ + #[Computed] + public function causers(): array + { + $causers = Activity::query() + ->whereNotNull('causer_id') + ->with('causer') + ->distinct() + ->get() + ->mapWithKeys(function ($activity) { + $causer = $activity->causer; + if (! $causer) { + return []; + } + $name = $causer->name ?? $causer->email ?? "User #{$causer->getKey()}"; + + return [$causer->getKey() => $name]; + }) + ->filter() + ->toArray(); + + return ['' => 'All Users'] + $causers; + } + + /** + * Get date range options. + * + * @return array + */ + #[Computed] + public function dateRanges(): array + { + return [ + 1 => 'Last 24 hours', + 7 => 'Last 7 days', + 30 => 'Last 30 days', + 90 => 'Last 90 days', + 365 => 'Last year', + ]; + } + + /** + * Get paginated activities. + */ + #[Computed] + public function activities(): LengthAwarePaginator + { + $service = $this->activityService->fresh(); + + // Apply workspace filter + if ($this->workspaceId) { + $service->forWorkspace($this->workspaceId); + } + + // Apply causer filter + if ($this->causerId) { + // We need to work around the service's user expectation + $service->query()->where('causer_id', $this->causerId); + } + + // Apply subject type filter + if ($this->subjectType) { + // Find the full class name that matches the basename + $fullType = Activity::query() + ->where('subject_type', 'LIKE', '%\\'.$this->subjectType) + ->orWhere('subject_type', $this->subjectType) + ->value('subject_type'); + + if ($fullType) { + $service->forSubjectType($fullType); + } + } + + // Apply event type filter + if ($this->eventType) { + $service->ofType($this->eventType); + } + + // Apply date range + $service->lastDays($this->daysBack); + + // Apply search + if ($this->search) { + $service->search($this->search); + } + + return $service->paginate($this->perPage); + } + + /** + * Get the selected activity for the detail modal. + */ + #[Computed] + public function selectedActivity(): ?Activity + { + if (! $this->selectedActivityId) { + return null; + } + + return Activity::with(['causer', 'subject'])->find($this->selectedActivityId); + } + + /** + * Get activity statistics. + * + * @return array{total: int, by_event: array, by_subject: array} + */ + #[Computed] + public function statistics(): array + { + return $this->activityService->statistics($this->workspaceId); + } + + /** + * Show the detail modal for an activity. + */ + public function showDetail(int $activityId): void + { + $this->selectedActivityId = $activityId; + $this->showDetailModal = true; + } + + /** + * Close the detail modal. + */ + public function closeDetail(): void + { + $this->showDetailModal = false; + $this->selectedActivityId = null; + } + + /** + * Reset all filters. + */ + public function resetFilters(): void + { + $this->causerId = null; + $this->subjectType = ''; + $this->eventType = ''; + $this->daysBack = 30; + $this->search = ''; + $this->resetPage(); + } + + /** + * Handle filter changes by resetting pagination. + */ + public function updatedCauserId(): void + { + $this->resetPage(); + } + + public function updatedSubjectType(): void + { + $this->resetPage(); + } + + public function updatedEventType(): void + { + $this->resetPage(); + } + + public function updatedDaysBack(): void + { + $this->resetPage(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + /** + * Format an activity for display. + * + * @return array{ + * id: int, + * event: string, + * description: string, + * timestamp: string, + * relative_time: string, + * actor: array|null, + * subject: array|null, + * changes: array|null, + * workspace_id: int|null + * } + */ + public function formatActivity(Activity $activity): array + { + return $this->activityService->format($activity); + } + + /** + * Get the event color class. + */ + public function eventColor(string $event): string + { + return match ($event) { + 'created' => 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + 'updated' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + 'deleted' => 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + }; + } + + /** + * Get the event icon. + */ + public function eventIcon(string $event): string + { + return match ($event) { + 'created' => 'plus-circle', + 'updated' => 'pencil', + 'deleted' => 'trash', + default => 'clock', + }; + } + + public function render() + { + return view('core.activity::admin.activity-feed'); + } +} diff --git a/packages/core-php/src/Core/Bouncer/BlocklistService.php b/packages/core-php/src/Core/Bouncer/BlocklistService.php index 4012214..c2ecf69 100644 --- a/packages/core-php/src/Core/Bouncer/BlocklistService.php +++ b/packages/core-php/src/Core/Bouncer/BlocklistService.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace Core\Bouncer; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -22,11 +23,80 @@ use Illuminate\Support\Facades\DB; * * Uses a Bloom filter-style approach: cache the blocklist as a set * for O(1) lookups, rebuild periodically from database. + * + * ## Blocking Statuses + * + * | Status | Description | + * |--------|-------------| + * | `pending` | From honeypot, awaiting human review | + * | `approved` | Active block (manual or reviewed) | + * | `rejected` | Reviewed and rejected (not blocked) | + * + * ## Honeypot Integration + * + * When `auto_block_critical` is enabled (default), IPs hitting critical + * honeypot paths are immediately blocked. Otherwise, they're added with + * 'pending' status for human review. + * + * ### Syncing from Honeypot + * + * Call `syncFromHoneypot()` from a scheduled job to create pending entries + * for critical hits from the last 24 hours: + * + * ```php + * // In app/Console/Kernel.php + * $schedule->call(function () { + * app(BlocklistService::class)->syncFromHoneypot(); + * })->hourly(); + * ``` + * + * ### Reviewing Pending Blocks + * + * ```php + * $blocklist = app(BlocklistService::class); + * + * // Get all pending entries (paginated for large blocklists) + * $pending = $blocklist->getPending(perPage: 50); + * + * // Approve a block + * $blocklist->approve('192.168.1.100'); + * + * // Reject a block (IP will not be blocked) + * $blocklist->reject('192.168.1.100'); + * ``` + * + * ## Cache Behaviour + * + * - Blocklist is cached for 5 minutes (CACHE_TTL constant) + * - Only 'approved' entries with valid expiry are included in cache + * - Cache is automatically cleared on block/unblock/approve operations + * - Use `clearCache()` to force cache refresh + * + * ## Manual Blocking + * + * ```php + * $blocklist = app(BlocklistService::class); + * + * // Block an IP immediately (approved status) + * $blocklist->block('192.168.1.100', 'spam', BlocklistService::STATUS_APPROVED); + * + * // Unblock an IP + * $blocklist->unblock('192.168.1.100'); + * + * // Check if IP is blocked + * if ($blocklist->isBlocked('192.168.1.100')) { + * // IP is actively blocked + * } + * ``` + * + * @see Boot For honeypot configuration options + * @see BouncerMiddleware For the blocking middleware */ class BlocklistService { protected const CACHE_KEY = 'bouncer:blocklist'; protected const CACHE_TTL = 300; // 5 minutes + protected const DEFAULT_PER_PAGE = 50; public const STATUS_PENDING = 'pending'; public const STATUS_APPROVED = 'approved'; @@ -71,6 +141,9 @@ class BlocklistService /** * Get full blocklist (cached). Only returns approved entries. + * + * Used for O(1) IP lookup checks. For admin UIs with large blocklists, + * use getBlocklistPaginated() instead. */ public function getBlocklist(): array { @@ -90,6 +163,31 @@ class BlocklistService }); } + /** + * Get paginated blocklist for admin UI. + * + * Returns all entries (approved, pending, rejected) with pagination. + * Use this for admin interfaces displaying large blocklists. + * + * @param int|null $perPage Number of entries per page (default: 50) + * @param string|null $status Filter by status (null for all statuses) + */ + public function getBlocklistPaginated(?int $perPage = null, ?string $status = null): LengthAwarePaginator + { + if (! $this->tableExists()) { + return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage ?? self::DEFAULT_PER_PAGE); + } + + $query = DB::table('blocked_ips') + ->orderBy('blocked_at', 'desc'); + + if ($status !== null) { + $query->where('status', $status); + } + + return $query->paginate($perPage ?? self::DEFAULT_PER_PAGE); + } + /** * Check if the blocked_ips table exists. */ @@ -141,18 +239,27 @@ class BlocklistService /** * Get pending entries awaiting human review. + * + * @param int|null $perPage Number of entries per page. Pass null for all entries (legacy behavior). + * @return array|LengthAwarePaginator Array if $perPage is null, paginator otherwise. */ - public function getPending(): array + public function getPending(?int $perPage = null): array|LengthAwarePaginator { if (! $this->tableExists()) { - return []; + return $perPage === null + ? [] + : new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage); } - return DB::table('blocked_ips') + $query = DB::table('blocked_ips') ->where('status', self::STATUS_PENDING) - ->orderBy('blocked_at', 'desc') - ->get() - ->toArray(); + ->orderBy('blocked_at', 'desc'); + + if ($perPage === null) { + return $query->get()->toArray(); + } + + return $query->paginate($perPage); } /** diff --git a/packages/core-php/src/Core/Bouncer/Boot.php b/packages/core-php/src/Core/Bouncer/Boot.php index 1b39832..0b28c4e 100644 --- a/packages/core-php/src/Core/Bouncer/Boot.php +++ b/packages/core-php/src/Core/Bouncer/Boot.php @@ -18,6 +18,72 @@ use Illuminate\Support\ServiceProvider; * Two responsibilities: * 1. Block bad actors (honeypot critical hits) before wasting CPU * 2. Handle SEO redirects before Laravel routing + * + * ## Honeypot Configuration + * + * The honeypot system traps bots that ignore robots.txt by monitoring + * paths listed as disallowed. Configure via `config/core.php` under + * the `bouncer.honeypot` key: + * + * ### Configuration Options + * + * | Option | Environment Variable | Default | Description | + * |--------|---------------------|---------|-------------| + * | `auto_block_critical` | `CORE_BOUNCER_HONEYPOT_AUTO_BLOCK` | `true` | Auto-block IPs hitting critical paths like /admin or /.env | + * | `rate_limit_max` | `CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX` | `10` | Max honeypot log entries per IP within the time window | + * | `rate_limit_window` | `CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW` | `60` | Rate limit window in seconds (default: 1 minute) | + * | `severity_levels.critical` | `CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL` | `'critical'` | Label for critical severity hits | + * | `severity_levels.warning` | `CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING` | `'warning'` | Label for warning severity hits | + * | `critical_paths` | N/A | See below | Paths that trigger critical severity | + * + * ### Default Critical Paths + * + * These paths indicate malicious probing and trigger 'critical' severity: + * - `admin` - Admin panel probing + * - `wp-admin` - WordPress admin probing + * - `wp-login.php` - WordPress login probing + * - `administrator` - Joomla admin probing + * - `phpmyadmin` - Database admin probing + * - `.env` - Environment file probing + * - `.git` - Git repository probing + * + * ### Customizing Critical Paths + * + * Override in your `config/core.php`: + * + * ```php + * 'bouncer' => [ + * 'honeypot' => [ + * 'critical_paths' => [ + * 'admin', + * 'wp-admin', + * '.env', + * '.git', + * 'backup', // Add custom paths + * 'config.php', + * ], + * ], + * ], + * ``` + * + * ### Blocking Workflow + * + * 1. Bot hits a honeypot path (e.g., /admin) + * 2. Path is checked against `critical_paths` (prefix matching) + * 3. If critical and `auto_block_critical` is true, IP is blocked immediately + * 4. Otherwise, entry is added to `honeypot_hits` with 'pending' status + * 5. Admin reviews pending entries via `BlocklistService::getPending()` + * 6. Admin approves or rejects via `approve($ip)` or `reject($ip)` + * + * ### Rate Limiting + * + * To prevent DoS via log flooding, honeypot logging is rate-limited: + * - Default: 10 entries per IP per minute + * - Exceeded entries are silently dropped + * - Rate limit uses Laravel's RateLimiter facade + * + * @see BlocklistService For IP blocking functionality + * @see BouncerMiddleware For the early-exit middleware */ class Boot extends ServiceProvider { diff --git a/packages/core-php/src/Core/Bouncer/Gate/ActionGateMiddleware.php b/packages/core-php/src/Core/Bouncer/Gate/ActionGateMiddleware.php new file mode 100644 index 0000000..580be0b --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/ActionGateMiddleware.php @@ -0,0 +1,155 @@ + BouncerGate (action whitelisting) -> Laravel Gate/Policy -> Controller + * ``` + * + * ## Behavior by Mode + * + * **Production (training_mode = false):** + * - Allowed actions proceed normally + * - Unknown/denied actions return 403 Forbidden + * + * **Training Mode (training_mode = true):** + * - Allowed actions proceed normally + * - Unknown actions return a training response: + * - API requests: JSON with action details and approval prompt + * - Web requests: Redirect back with flash message + */ +class ActionGateMiddleware +{ + public function __construct( + protected ActionGateService $gateService, + ) {} + + public function handle(Request $request, Closure $next): Response + { + // Skip for routes that explicitly bypass the gate + if ($request->route()?->getAction('bypass_gate')) { + return $next($request); + } + + $result = $this->gateService->check($request); + + return match ($result['result']) { + ActionGateService::RESULT_ALLOWED => $next($request), + ActionGateService::RESULT_TRAINING => $this->trainingResponse($request, $result), + default => $this->deniedResponse($request, $result), + }; + } + + /** + * Generate response for training mode. + */ + protected function trainingResponse(Request $request, array $result): Response + { + $action = $result['action']; + $scope = $result['scope']; + + if ($this->wantsJson($request)) { + return $this->trainingJsonResponse($request, $action, $scope); + } + + return $this->trainingWebResponse($request, $action, $scope); + } + + /** + * JSON response for training mode (API requests). + */ + protected function trainingJsonResponse(Request $request, string $action, ?string $scope): JsonResponse + { + return response()->json([ + 'error' => 'action_not_trained', + 'message' => "Action '{$action}' is not trained. Approve this action to continue.", + 'action' => $action, + 'scope' => $scope, + 'route' => $request->path(), + 'method' => $request->method(), + 'training_mode' => true, + 'approval_url' => $this->approvalUrl($action, $scope, $request), + ], 403); + } + + /** + * Web response for training mode (browser requests). + */ + protected function trainingWebResponse(Request $request, string $action, ?string $scope): RedirectResponse + { + $message = "Action '{$action}' requires training approval."; + + return redirect() + ->back() + ->with('bouncer_training', [ + 'action' => $action, + 'scope' => $scope, + 'route' => $request->path(), + 'method' => $request->method(), + 'message' => $message, + ]) + ->withInput(); + } + + /** + * Generate response for denied action. + */ + protected function deniedResponse(Request $request, array $result): Response + { + $action = $result['action']; + + if ($this->wantsJson($request)) { + return response()->json([ + 'error' => 'action_denied', + 'message' => "Action '{$action}' is not permitted.", + 'action' => $action, + ], 403); + } + + abort(403, "Action '{$action}' is not permitted."); + } + + /** + * Check if request expects JSON response. + */ + protected function wantsJson(Request $request): bool + { + return $request->expectsJson() + || $request->is('api/*') + || $request->header('Accept') === 'application/json'; + } + + /** + * Generate URL for approving an action. + */ + protected function approvalUrl(string $action, ?string $scope, Request $request): string + { + return route('bouncer.gate.approve', [ + 'action' => $action, + 'scope' => $scope, + 'redirect' => $request->fullUrl(), + ]); + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/ActionGateService.php b/packages/core-php/src/Core/Bouncer/Gate/ActionGateService.php new file mode 100644 index 0000000..975164a --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/ActionGateService.php @@ -0,0 +1,370 @@ + ActionGateMiddleware -> ActionGateService::check() -> Controller + * | + * v + * ActionPermission + * (allowed/denied) + * ``` + * + * ## Action Resolution Priority + * + * 1. Route action (via `->action('name')` macro) + * 2. Controller method attribute (`#[Action('name')]`) + * 3. Auto-resolved from controller@method + */ +class ActionGateService +{ + /** + * Result of permission check. + */ + public const RESULT_ALLOWED = 'allowed'; + + public const RESULT_DENIED = 'denied'; + + public const RESULT_TRAINING = 'training'; + + /** + * Cache of resolved action names. + * + * @var array + */ + protected array $actionCache = []; + + /** + * Check if an action is permitted. + * + * @return array{result: string, action: string, scope: string|null} + */ + public function check(Request $request): array + { + $route = $request->route(); + + if (! $route instanceof Route) { + return $this->denied('unknown', null); + } + + // Resolve action name and scope + $resolved = $this->resolveAction($route); + $action = $resolved['action']; + $scope = $resolved['scope']; + + // Determine guard and role + $guard = $this->resolveGuard($route); + $role = $this->resolveRole($request); + + // Check permission + $allowed = ActionPermission::isAllowed($action, $guard, $role, $scope); + + // Log the request + $status = $allowed + ? ActionRequest::STATUS_ALLOWED + : ($this->isTrainingMode() ? ActionRequest::STATUS_PENDING : ActionRequest::STATUS_DENIED); + + ActionRequest::log( + method: $request->method(), + route: $request->path(), + action: $action, + guard: $guard, + status: $status, + scope: $scope, + role: $role, + userId: $request->user()?->id, + ipAddress: $request->ip(), + ); + + if ($allowed) { + return $this->allowed($action, $scope); + } + + if ($this->isTrainingMode()) { + return $this->training($action, $scope); + } + + return $this->denied($action, $scope); + } + + /** + * Allow an action (create permission). + */ + public function allow( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null, + ?string $route = null, + ?int $trainedBy = null + ): ActionPermission { + return ActionPermission::train($action, $guard, $role, $scope, $route, $trainedBy); + } + + /** + * Deny an action (revoke permission). + */ + public function deny( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): bool { + return ActionPermission::revoke($action, $guard, $role, $scope); + } + + /** + * Check if training mode is enabled. + */ + public function isTrainingMode(): bool + { + return (bool) config('core.bouncer.training_mode', false); + } + + /** + * Resolve the action name for a route. + * + * @return array{action: string, scope: string|null} + */ + public function resolveAction(Route $route): array + { + $cacheKey = $route->getName() ?? $route->uri(); + + if (isset($this->actionCache[$cacheKey])) { + return $this->actionCache[$cacheKey]; + } + + // 1. Check for explicit route action + $routeAction = $route->getAction('bouncer_action'); + if ($routeAction) { + $result = [ + 'action' => $routeAction, + 'scope' => $route->getAction('bouncer_scope'), + ]; + $this->actionCache[$cacheKey] = $result; + + return $result; + } + + // 2. Check controller method attribute (requires container) + try { + $controller = $route->getController(); + $method = $route->getActionMethod(); + + if ($controller !== null && $method !== 'Closure') { + $attributeResult = $this->resolveFromAttribute($controller, $method); + if ($attributeResult !== null) { + $this->actionCache[$cacheKey] = $attributeResult; + + return $attributeResult; + } + } + } catch (\Throwable) { + // Container not available or controller doesn't exist + // Fall through to auto-resolution + } + + // 3. Auto-resolve from controller@method + $result = [ + 'action' => $this->autoResolveAction($route), + 'scope' => null, + ]; + $this->actionCache[$cacheKey] = $result; + + return $result; + } + + /** + * Resolve action from controller/method attribute. + * + * @return array{action: string, scope: string|null}|null + */ + protected function resolveFromAttribute(object $controller, string $method): ?array + { + try { + $reflection = new ReflectionMethod($controller, $method); + $attributes = $reflection->getAttributes(Action::class); + + if (empty($attributes)) { + // Check class-level attribute as fallback + $classReflection = new ReflectionClass($controller); + $attributes = $classReflection->getAttributes(Action::class); + } + + if (! empty($attributes)) { + /** @var Action $action */ + $action = $attributes[0]->newInstance(); + + return [ + 'action' => $action->name, + 'scope' => $action->scope, + ]; + } + } catch (\ReflectionException) { + // Fall through to auto-resolution + } + + return null; + } + + /** + * Auto-resolve action name from controller and method. + * + * Examples: + * - ProductController@store -> product.store + * - Admin\UserController@index -> admin.user.index + * - Api\V1\OrderController@show -> api.v1.order.show + */ + protected function autoResolveAction(Route $route): string + { + $uses = $route->getAction('uses'); + + if (is_string($uses) && str_contains($uses, '@')) { + [$controllerClass, $method] = explode('@', $uses); + + // Remove 'Controller' suffix and convert to dot notation + $parts = explode('\\', $controllerClass); + $parts = array_map(function ($part) { + // Remove 'Controller' suffix + if (str_ends_with($part, 'Controller')) { + $part = substr($part, 0, -10); + } + + // Convert PascalCase to snake_case, then to kebab-case dots + return strtolower(preg_replace('/(? ! in_array($p, ['app', 'http', 'controllers'])); + + $parts[] = strtolower($method); + + return implode('.', array_values($parts)); + } + + // Fallback for closures or invokable controllers + return 'route.'.($route->getName() ?? $route->uri()); + } + + /** + * Resolve the guard from route middleware. + */ + protected function resolveGuard(Route $route): string + { + $middleware = $route->gatherMiddleware(); + + foreach (['admin', 'api', 'client', 'web'] as $guard) { + if (in_array($guard, $middleware)) { + return $guard; + } + } + + return 'web'; + } + + /** + * Resolve the user's role. + */ + protected function resolveRole(Request $request): ?string + { + $user = $request->user(); + + if (! $user) { + return null; + } + + // Common role resolution strategies + if (method_exists($user, 'getRole')) { + return $user->getRole(); + } + + if (method_exists($user, 'role') && is_callable([$user, 'role'])) { + $role = $user->role(); + + return is_object($role) ? ($role->name ?? null) : $role; + } + + if (property_exists($user, 'role')) { + return $user->role; + } + + return null; + } + + /** + * Build an allowed result. + * + * @return array{result: string, action: string, scope: string|null} + */ + protected function allowed(string $action, ?string $scope): array + { + return [ + 'result' => self::RESULT_ALLOWED, + 'action' => $action, + 'scope' => $scope, + ]; + } + + /** + * Build a denied result. + * + * @return array{result: string, action: string, scope: string|null} + */ + protected function denied(string $action, ?string $scope): array + { + return [ + 'result' => self::RESULT_DENIED, + 'action' => $action, + 'scope' => $scope, + ]; + } + + /** + * Build a training mode result. + * + * @return array{result: string, action: string, scope: string|null} + */ + protected function training(string $action, ?string $scope): array + { + return [ + 'result' => self::RESULT_TRAINING, + 'action' => $action, + 'scope' => $scope, + ]; + } + + /** + * Clear the action resolution cache. + */ + public function clearCache(): void + { + $this->actionCache = []; + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/Attributes/Action.php b/packages/core-php/src/Core/Bouncer/Gate/Attributes/Action.php new file mode 100644 index 0000000..2744afd --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Attributes/Action.php @@ -0,0 +1,63 @@ + `product.store` + * - `Admin\UserController@index` -> `admin.user.index` + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +class Action +{ + /** + * Create a new Action attribute. + * + * @param string $name The action identifier (e.g., 'product.create') + * @param string|null $scope Optional scope for resource-specific permissions + */ + public function __construct( + public readonly string $name, + public readonly ?string $scope = null, + ) {} +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/Boot.php b/packages/core-php/src/Core/Bouncer/Gate/Boot.php new file mode 100644 index 0000000..361c75c --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Boot.php @@ -0,0 +1,150 @@ + ActionGateMiddleware -> Laravel Gate/Policy -> Controller + * ``` + * + * ## Configuration + * + * See `config/core.php` under the 'bouncer' key for all options. + */ +class Boot extends ServiceProvider +{ + /** + * Configure action gate middleware. + * + * Call this from your application's bootstrap to add the gate to middleware groups. + * + * ```php + * // bootstrap/app.php + * ->withMiddleware(function (Middleware $middleware) { + * \Core\Bouncer\Gate\Boot::middleware($middleware); + * }) + * ``` + */ + public static function middleware(Middleware $middleware): void + { + // Add to specific middleware groups that should be gated + $guardedGroups = config('core.bouncer.guarded_middleware', ['web', 'admin', 'api', 'client']); + + foreach ($guardedGroups as $group) { + $middleware->appendToGroup($group, ActionGateMiddleware::class); + } + + // Register middleware alias for manual use + $middleware->alias([ + 'action.gate' => ActionGateMiddleware::class, + ]); + } + + public function register(): void + { + // Register as singleton for caching benefits + $this->app->singleton(ActionGateService::class); + + // Merge config defaults + $this->mergeConfigFrom( + dirname(__DIR__, 2).'/config.php', + 'core' + ); + } + + public function boot(): void + { + // Skip if disabled + if (! config('core.bouncer.enabled', true)) { + return; + } + + // Load migrations + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + + // Register route macros + RouteActionMacro::register(); + + // Register training/approval routes if in training mode + if (config('core.bouncer.training_mode', false)) { + $this->registerTrainingRoutes(); + } + } + + /** + * Register routes for training mode approval workflow. + */ + protected function registerTrainingRoutes(): void + { + Route::middleware(['web', 'auth']) + ->prefix('_bouncer') + ->name('bouncer.gate.') + ->group(function () { + // Approve an action + Route::post('/approve', function () { + $action = request('action'); + $scope = request('scope'); + $redirect = request('redirect', '/'); + + if (! $action) { + return back()->with('error', 'No action specified'); + } + + $guard = request('guard', 'web'); + $role = request('role'); + + app(ActionGateService::class)->allow( + action: $action, + guard: $guard, + role: $role, + scope: $scope, + route: request('route'), + trainedBy: auth()->id(), + ); + + return redirect($redirect)->with('success', "Action '{$action}' has been approved."); + })->name('approve'); + + // List pending actions + Route::get('/pending', function () { + $pending = Models\ActionRequest::pending() + ->groupBy('action') + ->map(fn ($requests) => [ + 'action' => $requests->first()->action, + 'count' => $requests->count(), + 'routes' => $requests->pluck('route')->unique()->values(), + 'last_at' => $requests->max('created_at'), + ]) + ->values(); + + if (request()->wantsJson()) { + return response()->json(['pending' => $pending]); + } + + return view('bouncer::pending', ['pending' => $pending]); + })->name('pending'); + }); + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php b/packages/core-php/src/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php new file mode 100644 index 0000000..33fe98b --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Migrations/0001_01_01_000002_create_action_permission_tables.php @@ -0,0 +1,77 @@ +id(); + $table->string('action'); // product.create, order.refund + $table->string('scope')->nullable(); // Resource type or specific ID + $table->string('guard')->default('web'); // web, api, admin + $table->string('role')->nullable(); // admin, editor, or null for any auth + $table->boolean('allowed')->default(false); + $table->string('source'); // 'trained', 'seeded', 'manual' + $table->string('trained_route')->nullable(); + $table->foreignId('trained_by')->nullable(); + $table->timestamp('trained_at')->nullable(); + $table->timestamps(); + + $table->unique(['action', 'scope', 'guard', 'role'], 'action_permission_unique'); + $table->index('action'); + $table->index(['guard', 'allowed']); + }); + + // 2. Action Requests (audit log) + Schema::create('core_action_requests', function (Blueprint $table) { + $table->id(); + $table->string('method', 10); // GET, POST, etc. + $table->string('route'); // /admin/products + $table->string('action'); // product.create + $table->string('scope')->nullable(); + $table->string('guard'); // web, api, admin + $table->string('role')->nullable(); + $table->foreignId('user_id')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('status', 20); // allowed, denied, pending + $table->boolean('was_trained')->default(false); + $table->timestamps(); + + $table->index(['action', 'status']); + $table->index(['user_id', 'created_at']); + $table->index('status'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('core_action_requests'); + Schema::dropIfExists('core_action_permissions'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/packages/core-php/src/Core/Bouncer/Gate/Models/ActionPermission.php b/packages/core-php/src/Core/Bouncer/Gate/Models/ActionPermission.php new file mode 100644 index 0000000..a727539 --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Models/ActionPermission.php @@ -0,0 +1,205 @@ + 'boolean', + 'trained_at' => 'datetime', + ]; + + /** + * Source constants. + */ + public const SOURCE_TRAINED = 'trained'; + + public const SOURCE_SEEDED = 'seeded'; + + public const SOURCE_MANUAL = 'manual'; + + /** + * User who trained this permission. + */ + public function trainer(): BelongsTo + { + return $this->belongsTo(config('auth.providers.users.model'), 'trained_by'); + } + + /** + * Check if action is allowed for the given context. + */ + public static function isAllowed( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): bool { + $query = static::query() + ->where('action', $action) + ->where('guard', $guard) + ->where('allowed', true); + + // Check scope match (null matches any, or exact match) + if ($scope !== null) { + $query->where(function ($q) use ($scope) { + $q->whereNull('scope') + ->orWhere('scope', $scope); + }); + } + + // Check role match (null role in permission = any authenticated) + if ($role !== null) { + $query->where(function ($q) use ($role) { + $q->whereNull('role') + ->orWhere('role', $role); + }); + } else { + // No role provided, only match null role permissions + $query->whereNull('role'); + } + + return $query->exists(); + } + + /** + * Find or create a permission for the given action context. + */ + public static function findOrCreateFor( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): self { + return static::firstOrCreate( + [ + 'action' => $action, + 'guard' => $guard, + 'role' => $role, + 'scope' => $scope, + ], + [ + 'allowed' => false, + 'source' => self::SOURCE_MANUAL, + ] + ); + } + + /** + * Train (allow) an action. + */ + public static function train( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null, + ?string $route = null, + ?int $trainedBy = null + ): self { + $permission = static::findOrCreateFor($action, $guard, $role, $scope); + + $permission->update([ + 'allowed' => true, + 'source' => self::SOURCE_TRAINED, + 'trained_route' => $route, + 'trained_by' => $trainedBy, + 'trained_at' => now(), + ]); + + return $permission; + } + + /** + * Revoke an action permission. + */ + public static function revoke( + string $action, + string $guard = 'web', + ?string $role = null, + ?string $scope = null + ): bool { + return static::query() + ->where('action', $action) + ->where('guard', $guard) + ->where('role', $role) + ->where('scope', $scope) + ->update(['allowed' => false]) > 0; + } + + /** + * Get all actions for a guard. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function forGuard(string $guard): \Illuminate\Database\Eloquent\Collection + { + return static::where('guard', $guard)->get(); + } + + /** + * Get all allowed actions for a guard/role combination. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function allowedFor(string $guard, ?string $role = null): \Illuminate\Database\Eloquent\Collection + { + $query = static::where('guard', $guard) + ->where('allowed', true); + + if ($role !== null) { + $query->where(function ($q) use ($role) { + $q->whereNull('role') + ->orWhere('role', $role); + }); + } else { + $query->whereNull('role'); + } + + return $query->get(); + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/Models/ActionRequest.php b/packages/core-php/src/Core/Bouncer/Gate/Models/ActionRequest.php new file mode 100644 index 0000000..393776a --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Models/ActionRequest.php @@ -0,0 +1,179 @@ + 'boolean', + ]; + + /** + * Status constants. + */ + public const STATUS_ALLOWED = 'allowed'; + + public const STATUS_DENIED = 'denied'; + + public const STATUS_PENDING = 'pending'; + + /** + * User who made the request. + */ + public function user(): BelongsTo + { + return $this->belongsTo(config('auth.providers.users.model'), 'user_id'); + } + + /** + * Log an action request. + */ + public static function log( + string $method, + string $route, + string $action, + string $guard, + string $status, + ?string $scope = null, + ?string $role = null, + ?int $userId = null, + ?string $ipAddress = null, + bool $wasTrained = false + ): self { + return static::create([ + 'method' => $method, + 'route' => $route, + 'action' => $action, + 'scope' => $scope, + 'guard' => $guard, + 'role' => $role, + 'user_id' => $userId, + 'ip_address' => $ipAddress, + 'status' => $status, + 'was_trained' => $wasTrained, + ]); + } + + /** + * Get pending requests (for training review). + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function pending(): \Illuminate\Database\Eloquent\Collection + { + return static::where('status', self::STATUS_PENDING) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get denied requests for an action. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function deniedFor(string $action): \Illuminate\Database\Eloquent\Collection + { + return static::where('action', $action) + ->where('status', self::STATUS_DENIED) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get requests by user. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function forUser(int $userId): \Illuminate\Database\Eloquent\Collection + { + return static::where('user_id', $userId) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get unique actions that were denied (candidates for training). + * + * @return array + */ + public static function deniedActionsSummary(): array + { + return static::where('status', self::STATUS_DENIED) + ->selectRaw('action, COUNT(*) as count, MAX(created_at) as last_at') + ->groupBy('action') + ->orderByDesc('count') + ->get() + ->keyBy('action') + ->map(fn ($row) => [ + 'action' => $row->action, + 'count' => (int) $row->count, + 'last_at' => $row->last_at, + ]) + ->toArray(); + } + + /** + * Prune old request logs. + */ + public static function prune(int $days = 30): int + { + return static::where('created_at', '<', now()->subDays($days)) + ->delete(); + } + + /** + * Mark this request as having triggered training. + */ + public function markTrained(): self + { + $this->update(['was_trained' => true]); + + return $this; + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/RouteActionMacro.php b/packages/core-php/src/Core/Bouncer/Gate/RouteActionMacro.php new file mode 100644 index 0000000..16ff3b2 --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/RouteActionMacro.php @@ -0,0 +1,86 @@ +action('product.create'); + * + * Route::delete('/products/{product}', [ProductController::class, 'destroy']) + * ->action('product.delete', scope: 'product'); + * + * Route::get('/public-page', PageController::class) + * ->bypassGate(); // Skip action gate entirely + * ``` + */ +class RouteActionMacro +{ + /** + * Register route macros for action gate. + */ + public static function register(): void + { + /** + * Set the action name for bouncer gate checking. + * + * @param string $action The action identifier (e.g., 'product.create') + * @param string|null $scope Optional resource scope + * @return Route + */ + Route::macro('action', function (string $action, ?string $scope = null): Route { + /** @var Route $this */ + $this->setAction(array_merge($this->getAction(), [ + 'bouncer_action' => $action, + 'bouncer_scope' => $scope, + ])); + + return $this; + }); + + /** + * Bypass the action gate for this route. + * + * Use sparingly for routes that should never be gated (e.g., login page). + * + * @return Route + */ + Route::macro('bypassGate', function (): Route { + /** @var Route $this */ + $this->setAction(array_merge($this->getAction(), [ + 'bypass_gate' => true, + ])); + + return $this; + }); + + /** + * Mark this route as requiring training (explicit pending state). + * + * @return Route + */ + Route::macro('requiresTraining', function (): Route { + /** @var Route $this */ + $this->setAction(array_merge($this->getAction(), [ + 'requires_training' => true, + ])); + + return $this; + }); + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php b/packages/core-php/src/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php new file mode 100644 index 0000000..555b3d9 --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php @@ -0,0 +1,388 @@ +loadMigrationsFrom(__DIR__.'/../../Migrations'); + } + + protected function getPackageProviders($app): array + { + return [ + \Core\Bouncer\Gate\Boot::class, + ]; + } + + protected function defineEnvironment($app): void + { + $app['config']->set('core.bouncer.enabled', true); + $app['config']->set('core.bouncer.training_mode', false); + } + + // ========================================================================= + // ActionPermission Model Tests + // ========================================================================= + + public function test_action_permission_can_be_created(): void + { + $permission = ActionPermission::create([ + 'action' => 'product.create', + 'guard' => 'web', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_MANUAL, + ]); + + $this->assertDatabaseHas('core_action_permissions', [ + 'action' => 'product.create', + 'guard' => 'web', + 'allowed' => true, + ]); + } + + public function test_is_allowed_returns_true_for_permitted_action(): void + { + ActionPermission::create([ + 'action' => 'product.view', + 'guard' => 'web', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web')); + } + + public function test_is_allowed_returns_false_for_non_existent_action(): void + { + $this->assertFalse(ActionPermission::isAllowed('unknown.action', 'web')); + } + + public function test_is_allowed_returns_false_for_denied_action(): void + { + ActionPermission::create([ + 'action' => 'product.delete', + 'guard' => 'web', + 'allowed' => false, + 'source' => ActionPermission::SOURCE_MANUAL, + ]); + + $this->assertFalse(ActionPermission::isAllowed('product.delete', 'web')); + } + + public function test_is_allowed_respects_guard(): void + { + ActionPermission::create([ + 'action' => 'product.create', + 'guard' => 'admin', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.create', 'admin')); + $this->assertFalse(ActionPermission::isAllowed('product.create', 'web')); + } + + public function test_is_allowed_respects_role(): void + { + ActionPermission::create([ + 'action' => 'product.create', + 'guard' => 'web', + 'role' => 'editor', + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.create', 'web', 'editor')); + $this->assertFalse(ActionPermission::isAllowed('product.create', 'web', 'viewer')); + } + + public function test_null_role_permission_allows_any_role(): void + { + ActionPermission::create([ + 'action' => 'product.view', + 'guard' => 'web', + 'role' => null, + 'allowed' => true, + 'source' => ActionPermission::SOURCE_SEEDED, + ]); + + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', 'admin')); + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', 'editor')); + $this->assertTrue(ActionPermission::isAllowed('product.view', 'web', null)); + } + + public function test_train_creates_and_allows_action(): void + { + $permission = ActionPermission::train( + action: 'order.refund', + guard: 'admin', + role: 'manager', + route: '/admin/orders/1/refund', + trainedBy: 1 + ); + + $this->assertTrue($permission->allowed); + $this->assertEquals(ActionPermission::SOURCE_TRAINED, $permission->source); + $this->assertEquals('/admin/orders/1/refund', $permission->trained_route); + $this->assertEquals(1, $permission->trained_by); + $this->assertNotNull($permission->trained_at); + } + + public function test_revoke_denies_action(): void + { + ActionPermission::train('product.delete', 'web'); + + $result = ActionPermission::revoke('product.delete', 'web'); + + $this->assertTrue($result); + $this->assertFalse(ActionPermission::isAllowed('product.delete', 'web')); + } + + // ========================================================================= + // ActionRequest Model Tests + // ========================================================================= + + public function test_action_request_can_be_logged(): void + { + $request = ActionRequest::log( + method: 'POST', + route: '/products', + action: 'product.create', + guard: 'web', + status: ActionRequest::STATUS_ALLOWED, + userId: 1, + ipAddress: '127.0.0.1' + ); + + $this->assertDatabaseHas('core_action_requests', [ + 'method' => 'POST', + 'action' => 'product.create', + 'status' => 'allowed', + ]); + } + + public function test_pending_returns_pending_requests(): void + { + ActionRequest::log('GET', '/test', 'test.action', 'web', ActionRequest::STATUS_PENDING); + ActionRequest::log('POST', '/test', 'test.create', 'web', ActionRequest::STATUS_ALLOWED); + + $pending = ActionRequest::pending(); + + $this->assertCount(1, $pending); + $this->assertEquals('test.action', $pending->first()->action); + } + + public function test_denied_actions_summary_groups_by_action(): void + { + ActionRequest::log('GET', '/a', 'product.view', 'web', ActionRequest::STATUS_DENIED); + ActionRequest::log('GET', '/b', 'product.view', 'web', ActionRequest::STATUS_DENIED); + ActionRequest::log('POST', '/c', 'product.create', 'web', ActionRequest::STATUS_DENIED); + + $summary = ActionRequest::deniedActionsSummary(); + + $this->assertArrayHasKey('product.view', $summary); + $this->assertEquals(2, $summary['product.view']['count']); + $this->assertArrayHasKey('product.create', $summary); + $this->assertEquals(1, $summary['product.create']['count']); + } + + // ========================================================================= + // ActionGateService Tests + // ========================================================================= + + public function test_service_allows_permitted_action(): void + { + ActionPermission::train('product.index', 'web'); + + $service = new ActionGateService; + $route = $this->createMockRoute('ProductController@index', 'web'); + $request = $this->createMockRequest($route); + + $result = $service->check($request); + + $this->assertEquals(ActionGateService::RESULT_ALLOWED, $result['result']); + } + + public function test_service_denies_unknown_action_in_production(): void + { + config(['core.bouncer.training_mode' => false]); + + $service = new ActionGateService; + $route = $this->createMockRoute('ProductController@store', 'web'); + $request = $this->createMockRequest($route); + + $result = $service->check($request); + + $this->assertEquals(ActionGateService::RESULT_DENIED, $result['result']); + } + + public function test_service_returns_training_in_training_mode(): void + { + config(['core.bouncer.training_mode' => true]); + + $service = new ActionGateService; + $route = $this->createMockRoute('OrderController@refund', 'web'); + $request = $this->createMockRequest($route); + + $result = $service->check($request); + + $this->assertEquals(ActionGateService::RESULT_TRAINING, $result['result']); + } + + public function test_service_logs_request(): void + { + ActionPermission::train('product.show', 'web'); + + $service = new ActionGateService; + $route = $this->createMockRoute('ProductController@show', 'web'); + $request = $this->createMockRequest($route); + + $service->check($request); + + $this->assertDatabaseHas('core_action_requests', [ + 'action' => 'product.show', + 'status' => 'allowed', + ]); + } + + // ========================================================================= + // Action Resolution Tests + // ========================================================================= + + public function test_resolves_action_from_route_action(): void + { + $service = new ActionGateService; + + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->setAction(array_merge($route->getAction(), [ + 'bouncer_action' => 'products.list', + 'bouncer_scope' => 'catalog', + ])); + + $result = $service->resolveAction($route); + + $this->assertEquals('products.list', $result['action']); + $this->assertEquals('catalog', $result['scope']); + } + + public function test_auto_resolves_action_from_controller_method(): void + { + $service = new ActionGateService; + + $route = new Route(['POST'], '/products', ['uses' => 'ProductController@store']); + + $result = $service->resolveAction($route); + + $this->assertEquals('product.store', $result['action']); + } + + public function test_auto_resolves_namespaced_controller(): void + { + $service = new ActionGateService; + + $route = new Route(['GET'], '/admin/users', ['uses' => 'Admin\\UserController@index']); + + $result = $service->resolveAction($route); + + $this->assertEquals('admin.user.index', $result['action']); + } + + // ========================================================================= + // Route Macro Tests + // ========================================================================= + + public function test_route_action_macro_sets_action(): void + { + $route = RouteFacade::get('/test', fn () => 'test') + ->action('custom.action'); + + $this->assertEquals('custom.action', $route->getAction('bouncer_action')); + } + + public function test_route_action_macro_sets_scope(): void + { + $route = RouteFacade::get('/test/{id}', fn () => 'test') + ->action('resource.view', 'resource'); + + $this->assertEquals('resource.view', $route->getAction('bouncer_action')); + $this->assertEquals('resource', $route->getAction('bouncer_scope')); + } + + public function test_route_bypass_gate_macro(): void + { + $route = RouteFacade::get('/login', fn () => 'login') + ->bypassGate(); + + $this->assertTrue($route->getAction('bypass_gate')); + } + + // ========================================================================= + // Action Attribute Tests + // ========================================================================= + + public function test_action_attribute_stores_name(): void + { + $attribute = new Action('product.create'); + + $this->assertEquals('product.create', $attribute->name); + $this->assertNull($attribute->scope); + } + + public function test_action_attribute_stores_scope(): void + { + $attribute = new Action('product.delete', scope: 'product'); + + $this->assertEquals('product.delete', $attribute->name); + $this->assertEquals('product', $attribute->scope); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + protected function createMockRoute(string $uses, string $middlewareGroup = 'web'): Route + { + $route = new Route(['GET'], '/test', ['uses' => $uses]); + $route->middleware($middlewareGroup); + + return $route; + } + + protected function createMockRequest(Route $route): Request + { + $request = Request::create('/test', 'GET'); + $request->setRouteResolver(fn () => $route); + + return $request; + } +} diff --git a/packages/core-php/src/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php b/packages/core-php/src/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php new file mode 100644 index 0000000..e2892e4 --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php @@ -0,0 +1,235 @@ +service = new ActionGateService; + } + + // ========================================================================= + // Auto-Resolution Tests (via uses action string) + // ========================================================================= + + public function test_auto_resolves_simple_controller(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('product.index', $result['action']); + } + + public function test_auto_resolves_nested_namespace(): void + { + $route = new Route(['POST'], '/admin/users', ['uses' => 'Admin\\UserController@store']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('admin.user.store', $result['action']); + } + + public function test_auto_resolves_deeply_nested_namespace(): void + { + $route = new Route(['GET'], '/api/v1/orders', ['uses' => 'Api\\V1\\OrderController@show']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('api.v1.order.show', $result['action']); + } + + public function test_auto_resolves_pascal_case_controller(): void + { + $route = new Route(['GET'], '/user-profiles', ['uses' => 'UserProfileController@index']); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('user_profile.index', $result['action']); + } + + public function test_filters_common_namespace_prefixes(): void + { + $route = new Route(['GET'], '/test', ['uses' => 'App\\Http\\Controllers\\TestController@index']); + + $result = $this->service->resolveAction($route); + + // Should not include 'app', 'http', 'controllers' + $this->assertEquals('test.index', $result['action']); + } + + // ========================================================================= + // Route Action Override Tests + // ========================================================================= + + public function test_route_action_takes_precedence(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->setAction(array_merge($route->getAction(), [ + 'bouncer_action' => 'catalog.list', + ])); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('catalog.list', $result['action']); + } + + public function test_route_scope_is_preserved(): void + { + $route = new Route(['DELETE'], '/products/1', ['uses' => 'ProductController@destroy']); + $route->setAction(array_merge($route->getAction(), [ + 'bouncer_action' => 'product.delete', + 'bouncer_scope' => 'product', + ])); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('product.delete', $result['action']); + $this->assertEquals('product', $result['scope']); + } + + // ========================================================================= + // Closure/Named Route Tests + // ========================================================================= + + public function test_closure_routes_use_uri_fallback(): void + { + $route = new Route(['GET'], '/hello', fn () => 'hello'); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('route.hello', $result['action']); + } + + public function test_named_closure_routes_use_name(): void + { + $route = new Route(['GET'], '/hello', fn () => 'hello'); + $route->name('greeting.hello'); + + $result = $this->service->resolveAction($route); + + $this->assertEquals('route.greeting.hello', $result['action']); + } + + // ========================================================================= + // Caching Tests + // ========================================================================= + + public function test_caches_resolved_actions(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->name('products.index'); + + // First call + $result1 = $this->service->resolveAction($route); + + // Second call should use cache + $result2 = $this->service->resolveAction($route); + + $this->assertEquals($result1, $result2); + } + + public function test_clear_cache_works(): void + { + $route = new Route(['GET'], '/products', ['uses' => 'ProductController@index']); + $route->name('products.index'); + + $this->service->resolveAction($route); + $this->service->clearCache(); + + // Should not throw - just verify it works + $result = $this->service->resolveAction($route); + $this->assertNotEmpty($result['action']); + } + + // ========================================================================= + // Guard Resolution Tests + // ========================================================================= + + public function test_resolves_admin_guard(): void + { + $route = new Route(['GET'], '/admin/dashboard', ['uses' => 'DashboardController@index']); + $route->middleware('admin'); + + $method = new \ReflectionMethod($this->service, 'resolveGuard'); + $method->setAccessible(true); + + $guard = $method->invoke($this->service, $route); + + $this->assertEquals('admin', $guard); + } + + public function test_resolves_api_guard(): void + { + $route = new Route(['GET'], '/api/users', ['uses' => 'UserController@index']); + $route->middleware('api'); + + $method = new \ReflectionMethod($this->service, 'resolveGuard'); + $method->setAccessible(true); + + $guard = $method->invoke($this->service, $route); + + $this->assertEquals('api', $guard); + } + + public function test_defaults_to_web_guard(): void + { + $route = new Route(['GET'], '/home', ['uses' => 'HomeController@index']); + + $method = new \ReflectionMethod($this->service, 'resolveGuard'); + $method->setAccessible(true); + + $guard = $method->invoke($this->service, $route); + + $this->assertEquals('web', $guard); + } + + // ========================================================================= + // Action Attribute Tests + // ========================================================================= + + public function test_action_attribute_stores_name(): void + { + $attribute = new Action('product.create'); + + $this->assertEquals('product.create', $attribute->name); + $this->assertNull($attribute->scope); + } + + public function test_action_attribute_stores_scope(): void + { + $attribute = new Action('product.delete', scope: 'product'); + + $this->assertEquals('product.delete', $attribute->name); + $this->assertEquals('product', $attribute->scope); + } + + // ========================================================================= + // Result Builder Tests + // ========================================================================= + + public function test_result_constants_are_defined(): void + { + $this->assertEquals('allowed', ActionGateService::RESULT_ALLOWED); + $this->assertEquals('denied', ActionGateService::RESULT_DENIED); + $this->assertEquals('training', ActionGateService::RESULT_TRAINING); + } +} diff --git a/packages/core-php/src/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php b/packages/core-php/src/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php new file mode 100644 index 0000000..f241089 --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php @@ -0,0 +1,594 @@ +service = new BlocklistService; + } + + protected function defineDatabaseMigrations(): void + { + // Create blocked_ips table for testing + Schema::create('blocked_ips', function ($table) { + $table->id(); + $table->string('ip_address', 45); + $table->string('ip_range', 18)->nullable(); + $table->string('reason')->nullable(); + $table->string('source', 32)->default('manual'); + $table->string('status', 32)->default('active'); + $table->unsignedInteger('hit_count')->default(0); + $table->timestamp('blocked_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_hit_at')->nullable(); + $table->timestamps(); + + $table->unique(['ip_address', 'ip_range']); + $table->index(['status', 'expires_at']); + $table->index('ip_address'); + }); + + // Create honeypot_hits table for testing syncFromHoneypot + Schema::create('honeypot_hits', function ($table) { + $table->id(); + $table->string('ip_address', 45); + $table->string('path'); + $table->string('severity', 32)->default('low'); + $table->timestamps(); + }); + } + + // ========================================================================= + // Blocking Tests + // ========================================================================= + + public function test_block_adds_ip_to_blocklist(): void + { + $this->service->block('192.168.1.100', 'test_reason'); + + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'reason' => 'test_reason', + 'status' => BlocklistService::STATUS_APPROVED, + ]); + } + + public function test_block_with_custom_status(): void + { + $this->service->block('192.168.1.100', 'honeypot', BlocklistService::STATUS_PENDING); + + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'reason' => 'honeypot', + 'status' => BlocklistService::STATUS_PENDING, + ]); + } + + public function test_block_updates_existing_entry(): void + { + // First block + $this->service->block('192.168.1.100', 'first_reason'); + + // Second block should update + $this->service->block('192.168.1.100', 'updated_reason'); + + $this->assertDatabaseCount('blocked_ips', 1); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'reason' => 'updated_reason', + ]); + } + + public function test_block_clears_cache(): void + { + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + Cache::shouldReceive('remember') + ->andReturn([]); + + $this->service->block('192.168.1.100', 'test'); + } + + // ========================================================================= + // Unblocking Tests + // ========================================================================= + + public function test_unblock_removes_ip_from_blocklist(): void + { + $this->service->block('192.168.1.100', 'test'); + $this->service->unblock('192.168.1.100'); + + $this->assertDatabaseMissing('blocked_ips', [ + 'ip_address' => '192.168.1.100', + ]); + } + + public function test_unblock_clears_cache(): void + { + // First add the IP + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + ]); + + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + $this->service->unblock('192.168.1.100'); + } + + public function test_unblock_does_not_fail_on_non_existent_ip(): void + { + // This should not throw an exception + $this->service->unblock('192.168.1.200'); + + $this->assertTrue(true); + } + + // ========================================================================= + // IP Blocked Check Tests + // ========================================================================= + + public function test_is_blocked_returns_true_for_blocked_ip(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + 'expires_at' => now()->addDay(), + ]); + + // Clear any existing cache + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertTrue($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_returns_false_for_non_blocked_ip(): void + { + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.200')); + } + + public function test_is_blocked_returns_false_for_expired_block(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now()->subDays(2), + 'expires_at' => now()->subDay(), // Expired yesterday + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_returns_false_for_pending_status(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + 'expires_at' => now()->addDay(), + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_returns_false_for_rejected_status(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_REJECTED, + 'blocked_at' => now(), + 'expires_at' => now()->addDay(), + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertFalse($this->service->isBlocked('192.168.1.100')); + } + + public function test_is_blocked_works_with_null_expiry(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'permanent', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + 'expires_at' => null, // Permanent block + ]); + + Cache::forget('bouncer:blocklist'); + Cache::forget('bouncer:blocked_ips_table_exists'); + + $this->assertTrue($this->service->isBlocked('192.168.1.100')); + } + + // ========================================================================= + // Sync From Honeypot Tests + // ========================================================================= + + public function test_sync_from_honeypot_adds_critical_hits(): void + { + // Insert honeypot critical hits + DB::table('honeypot_hits')->insert([ + ['ip_address' => '10.0.0.1', 'path' => '/admin', 'severity' => 'critical', 'created_at' => now()], + ['ip_address' => '10.0.0.2', 'path' => '/wp-admin', 'severity' => 'critical', 'created_at' => now()], + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(2, $count); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '10.0.0.1', + 'reason' => 'honeypot_critical', + 'status' => BlocklistService::STATUS_PENDING, + ]); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '10.0.0.2', + 'reason' => 'honeypot_critical', + 'status' => BlocklistService::STATUS_PENDING, + ]); + } + + public function test_sync_from_honeypot_ignores_non_critical_hits(): void + { + DB::table('honeypot_hits')->insert([ + ['ip_address' => '10.0.0.1', 'path' => '/robots.txt', 'severity' => 'low', 'created_at' => now()], + ['ip_address' => '10.0.0.2', 'path' => '/favicon.ico', 'severity' => 'medium', 'created_at' => now()], + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(0, $count); + $this->assertDatabaseCount('blocked_ips', 0); + } + + public function test_sync_from_honeypot_ignores_old_hits(): void + { + DB::table('honeypot_hits')->insert([ + 'ip_address' => '10.0.0.1', + 'path' => '/admin', + 'severity' => 'critical', + 'created_at' => now()->subDays(2), // Older than 24 hours + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(0, $count); + $this->assertDatabaseCount('blocked_ips', 0); + } + + public function test_sync_from_honeypot_skips_already_blocked_ips(): void + { + // Already blocked IP + DB::table('blocked_ips')->insert([ + 'ip_address' => '10.0.0.1', + 'reason' => 'manual', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + ]); + + // Critical hit from same IP + DB::table('honeypot_hits')->insert([ + 'ip_address' => '10.0.0.1', + 'path' => '/admin', + 'severity' => 'critical', + 'created_at' => now(), + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(0, $count); + $this->assertDatabaseCount('blocked_ips', 1); + } + + public function test_sync_from_honeypot_deduplicates_ips(): void + { + // Multiple hits from same IP + DB::table('honeypot_hits')->insert([ + ['ip_address' => '10.0.0.1', 'path' => '/admin', 'severity' => 'critical', 'created_at' => now()], + ['ip_address' => '10.0.0.1', 'path' => '/wp-admin', 'severity' => 'critical', 'created_at' => now()], + ['ip_address' => '10.0.0.1', 'path' => '/phpmyadmin', 'severity' => 'critical', 'created_at' => now()], + ]); + + $count = $this->service->syncFromHoneypot(); + + $this->assertEquals(1, $count); + $this->assertDatabaseCount('blocked_ips', 1); + } + + // ========================================================================= + // Pagination Tests + // ========================================================================= + + public function test_get_blocklist_paginated_returns_paginator(): void + { + // Insert multiple blocked IPs + for ($i = 1; $i <= 10; $i++) { + DB::table('blocked_ips')->insert([ + 'ip_address' => "192.168.1.{$i}", + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, + 'blocked_at' => now(), + ]); + } + + $result = $this->service->getBlocklistPaginated(5); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + $this->assertEquals(10, $result->total()); + $this->assertEquals(5, $result->perPage()); + $this->assertCount(5, $result->items()); + } + + public function test_get_blocklist_paginated_filters_by_status(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ['ip_address' => '192.168.1.3', 'reason' => 'test', 'status' => BlocklistService::STATUS_REJECTED, 'blocked_at' => now()], + ]); + + $approved = $this->service->getBlocklistPaginated(10, BlocklistService::STATUS_APPROVED); + $pending = $this->service->getBlocklistPaginated(10, BlocklistService::STATUS_PENDING); + + $this->assertEquals(1, $approved->total()); + $this->assertEquals(1, $pending->total()); + } + + public function test_get_blocklist_paginated_orders_by_blocked_at_desc(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()->subHours(2)], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()], + ['ip_address' => '192.168.1.3', 'reason' => 'test', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now()->subHour()], + ]); + + $result = $this->service->getBlocklistPaginated(10); + $items = collect($result->items()); + + // Should be ordered most recent first + $this->assertEquals('192.168.1.2', $items->first()->ip_address); + $this->assertEquals('192.168.1.1', $items->last()->ip_address); + } + + public function test_get_pending_returns_array_when_per_page_is_null(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ]); + + $result = $this->service->getPending(null); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + } + + public function test_get_pending_returns_paginator_when_per_page_provided(): void + { + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ['ip_address' => '192.168.1.2', 'reason' => 'test', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now()], + ]); + + $result = $this->service->getPending(1); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + $this->assertEquals(2, $result->total()); + $this->assertEquals(1, $result->perPage()); + } + + // ========================================================================= + // Approval/Rejection Tests + // ========================================================================= + + public function test_approve_changes_pending_to_approved(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + ]); + + $result = $this->service->approve('192.168.1.100'); + + $this->assertTrue($result); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'status' => BlocklistService::STATUS_APPROVED, + ]); + } + + public function test_approve_returns_false_for_non_pending_entry(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, // Already approved + 'blocked_at' => now(), + ]); + + $result = $this->service->approve('192.168.1.100'); + + $this->assertFalse($result); + } + + public function test_approve_returns_false_for_non_existent_entry(): void + { + $result = $this->service->approve('192.168.1.200'); + + $this->assertFalse($result); + } + + public function test_approve_clears_cache(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + ]); + + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + $this->service->approve('192.168.1.100'); + } + + public function test_reject_changes_pending_to_rejected(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_PENDING, + 'blocked_at' => now(), + ]); + + $result = $this->service->reject('192.168.1.100'); + + $this->assertTrue($result); + $this->assertDatabaseHas('blocked_ips', [ + 'ip_address' => '192.168.1.100', + 'status' => BlocklistService::STATUS_REJECTED, + ]); + } + + public function test_reject_returns_false_for_non_pending_entry(): void + { + DB::table('blocked_ips')->insert([ + 'ip_address' => '192.168.1.100', + 'reason' => 'test', + 'status' => BlocklistService::STATUS_APPROVED, // Not pending + 'blocked_at' => now(), + ]); + + $result = $this->service->reject('192.168.1.100'); + + $this->assertFalse($result); + } + + // ========================================================================= + // Stats Tests + // ========================================================================= + + public function test_get_stats_returns_complete_statistics(): void + { + // Insert test data - each row must have same columns + DB::table('blocked_ips')->insert([ + ['ip_address' => '192.168.1.1', 'reason' => 'manual', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now(), 'expires_at' => now()->addDay()], + ['ip_address' => '192.168.1.2', 'reason' => 'manual', 'status' => BlocklistService::STATUS_APPROVED, 'blocked_at' => now(), 'expires_at' => now()->subDay()], // Expired + ['ip_address' => '192.168.1.3', 'reason' => 'honeypot', 'status' => BlocklistService::STATUS_PENDING, 'blocked_at' => now(), 'expires_at' => null], + ['ip_address' => '192.168.1.4', 'reason' => 'honeypot', 'status' => BlocklistService::STATUS_REJECTED, 'blocked_at' => now(), 'expires_at' => null], + ]); + + Cache::forget('bouncer:blocked_ips_table_exists'); + $stats = $this->service->getStats(); + + $this->assertEquals(4, $stats['total_blocked']); + $this->assertEquals(1, $stats['active_blocked']); // Only 1 approved and not expired + $this->assertEquals(1, $stats['pending_review']); + $this->assertEquals(['manual' => 2, 'honeypot' => 2], $stats['by_reason']); + $this->assertEquals([ + BlocklistService::STATUS_APPROVED => 2, + BlocklistService::STATUS_PENDING => 1, + BlocklistService::STATUS_REJECTED => 1, + ], $stats['by_status']); + } + + public function test_get_stats_returns_zeros_when_table_is_empty(): void + { + Cache::forget('bouncer:blocked_ips_table_exists'); + $stats = $this->service->getStats(); + + $this->assertEquals(0, $stats['total_blocked']); + $this->assertEquals(0, $stats['active_blocked']); + $this->assertEquals(0, $stats['pending_review']); + $this->assertEmpty($stats['by_reason']); + $this->assertEmpty($stats['by_status']); + } + + // ========================================================================= + // Cache Tests + // ========================================================================= + + public function test_clear_cache_removes_cached_blocklist(): void + { + Cache::shouldReceive('forget') + ->once() + ->with('bouncer:blocklist'); + + $this->service->clearCache(); + } + + public function test_get_blocklist_uses_cache(): void + { + $cachedData = ['192.168.1.1' => 'test_reason']; + + Cache::shouldReceive('remember') + ->once() + ->with('bouncer:blocklist', 300, \Mockery::type('Closure')) + ->andReturn($cachedData); + + $result = $this->service->getBlocklist(); + + $this->assertEquals($cachedData, $result); + } + + // ========================================================================= + // Status Constants Tests + // ========================================================================= + + public function test_status_constants_are_defined(): void + { + $this->assertEquals('pending', BlocklistService::STATUS_PENDING); + $this->assertEquals('approved', BlocklistService::STATUS_APPROVED); + $this->assertEquals('rejected', BlocklistService::STATUS_REJECTED); + } +} diff --git a/packages/core-php/src/Core/Cdn/Facades/Cdn.php b/packages/core-php/src/Core/Cdn/Facades/Cdn.php index 4a3667d..9972684 100644 --- a/packages/core-php/src/Core/Cdn/Facades/Cdn.php +++ b/packages/core-php/src/Core/Cdn/Facades/Cdn.php @@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Facade; * @method static string cdn(string $path) * @method static string origin(string $path) * @method static string private(string $path) - * @method static string|null signedUrl(string $path, int $expiry = 3600) + * @method static string|null signedUrl(string $path, int|\Carbon\Carbon|null $expiry = null) * @method static string apex(string $path) * @method static string asset(string $path, ?string $context = null) * @method static array urls(string $path) diff --git a/packages/core-php/src/Core/Cdn/Services/AssetPipeline.php b/packages/core-php/src/Core/Cdn/Services/AssetPipeline.php index 9be7020..e504193 100644 --- a/packages/core-php/src/Core/Cdn/Services/AssetPipeline.php +++ b/packages/core-php/src/Core/Cdn/Services/AssetPipeline.php @@ -18,19 +18,34 @@ use Illuminate\Support\Str; * Asset processing pipeline for the dual-bucket CDN architecture. * * Flow: - * 1. Store raw upload → private bucket (optional, for processing) - * 2. Process (resize, optimize, etc.) → handled by caller - * 3. Store processed → public bucket + * 1. Store raw upload -> private bucket (optional, for processing) + * 2. Process (resize, optimize, etc.) -> handled by caller + * 3. Store processed -> public bucket * 4. Push to CDN storage zone * * Categories define path prefixes: * - media: General media uploads - * - social: SocialHost media - * - biolink: BioHost assets + * - social: Social media assets + * - page: Page builder assets * - avatar: User/workspace avatars * - content: ContentMedia * - static: Static assets - * - widget: TrustHost/NotifyHost widgets + * - widget: Widget assets + * + * ## Methods + * + * | Method | Returns | Description | + * |--------|---------|-------------| + * | `store()` | `array` | Process and store an uploaded file to public bucket | + * | `storeContents()` | `array` | Store raw content (string/stream) to public bucket | + * | `storePrivate()` | `array` | Store to private bucket for DRM/gated content | + * | `copy()` | `array` | Copy file between buckets | + * | `delete()` | `bool` | Delete an asset from storage and CDN | + * | `deleteMany()` | `array` | Delete multiple assets | + * | `urls()` | `array` | Get CDN and origin URLs for a path | + * | `exists()` | `bool` | Check if a file exists in storage | + * | `size()` | `int\|null` | Get file size in bytes | + * | `mimeType()` | `string\|null` | Get file MIME type | */ class AssetPipeline { @@ -51,7 +66,7 @@ class AssetPipeline * Process and store an uploaded file. * * @param UploadedFile $file The uploaded file - * @param string $category Category key (media, social, biolink, etc.) + * @param string $category Category key (media, social, page, etc.) * @param string|null $filename Custom filename (auto-generated if null) * @param array $options Additional options (workspace_id, user_id, etc.) * @return array{path: string, cdn_url: string, origin_url: string, size: int, mime: string} @@ -160,6 +175,8 @@ class AssetPipeline * @param string $sourceBucket Source bucket ('public' or 'private') * @param string $destBucket Destination bucket ('public' or 'private') * @param string|null $destPath Destination path (same as source if null) + * @return array{path: string, bucket: string} + * @throws \RuntimeException If source file not found or copy fails */ public function copy(string $sourcePath, string $sourceBucket, string $destBucket, ?string $destPath = null): array { @@ -199,6 +216,7 @@ class AssetPipeline * * @param string $path File path * @param string $bucket 'public' or 'private' + * @return bool True if deletion was successful */ public function delete(string $path, string $bucket = 'public'): bool { @@ -210,6 +228,7 @@ class AssetPipeline * * @param array $paths File paths * @param string $bucket 'public' or 'private' + * @return array Map of path to deletion success status */ public function deleteMany(array $paths, string $bucket = 'public'): array { @@ -241,6 +260,7 @@ class AssetPipeline * Get URLs for a path. * * @param string $path File path + * @return array{cdn: string, origin: string} */ public function urls(string $path): array { @@ -249,6 +269,11 @@ class AssetPipeline /** * Build storage path from category and filename. + * + * @param string $category Category key (media, social, etc.) + * @param string $filename Filename with extension + * @param array $options Options including workspace_id, user_id + * @return string Full storage path */ protected function buildPath(string $category, string $filename, array $options = []): string { @@ -277,6 +302,9 @@ class AssetPipeline /** * Generate a unique filename. + * + * @param UploadedFile $file The uploaded file + * @return string Unique filename with original extension */ protected function generateFilename(UploadedFile $file): string { @@ -288,6 +316,11 @@ class AssetPipeline /** * Queue a CDN push job if auto-push is enabled. + * + * @param string $disk Laravel disk name + * @param string $path Path within the disk + * @param string $zone Target CDN zone ('public' or 'private') + * @return void */ protected function queueCdnPush(string $disk, string $path, string $zone): void { @@ -318,6 +351,7 @@ class AssetPipeline * * @param string $path File path * @param string $bucket 'public' or 'private' + * @return bool True if file exists */ public function exists(string $path, string $bucket = 'public'): bool { diff --git a/packages/core-php/src/Core/Cdn/Services/BunnyCdnService.php b/packages/core-php/src/Core/Cdn/Services/BunnyCdnService.php index defc877..208e08c 100644 --- a/packages/core-php/src/Core/Cdn/Services/BunnyCdnService.php +++ b/packages/core-php/src/Core/Cdn/Services/BunnyCdnService.php @@ -21,6 +21,22 @@ use Illuminate\Support\Facades\Log; * - Cache purging (URL, tag, workspace, global) * - Statistics retrieval * - Pull zone management + * + * ## Methods + * + * | Method | Returns | Description | + * |--------|---------|-------------| + * | `isConfigured()` | `bool` | Check if BunnyCDN is configured | + * | `purgeUrl()` | `bool` | Purge a single URL from cache | + * | `purgeUrls()` | `bool` | Purge multiple URLs from cache | + * | `purgeAll()` | `bool` | Purge entire pull zone cache | + * | `purgeByTag()` | `bool` | Purge cache by tag | + * | `purgeWorkspace()` | `bool` | Purge all cached content for a workspace | + * | `getStats()` | `array\|null` | Get CDN statistics for pull zone | + * | `getBandwidth()` | `array\|null` | Get bandwidth usage for pull zone | + * | `listStorageFiles()` | `array\|null` | List files in storage zone | + * | `uploadFile()` | `bool` | Upload a file to storage zone | + * | `deleteFile()` | `bool` | Delete a file from storage zone | */ class BunnyCdnService { @@ -39,6 +55,9 @@ class BunnyCdnService /** * Sanitize an error message to remove sensitive data like API keys. + * + * @param string $message The error message to sanitize + * @return string The sanitized message with API keys replaced by [REDACTED] */ protected function sanitizeErrorMessage(string $message): string { @@ -59,6 +78,8 @@ class BunnyCdnService /** * Check if the service is configured. + * + * @return bool True if BunnyCDN API key and pull zone ID are configured */ public function isConfigured(): bool { @@ -71,6 +92,9 @@ class BunnyCdnService /** * Purge a single URL from CDN cache. + * + * @param string $url The full URL to purge from cache + * @return bool True if purge was successful, false otherwise */ public function purgeUrl(string $url): bool { @@ -79,6 +103,9 @@ class BunnyCdnService /** * Purge multiple URLs from CDN cache. + * + * @param array $urls Array of full URLs to purge from cache + * @return bool True if all purges were successful, false if any failed */ public function purgeUrls(array $urls): bool { @@ -117,6 +144,8 @@ class BunnyCdnService /** * Purge entire pull zone cache. + * + * @return bool True if purge was successful, false otherwise */ public function purgeAll(): bool { @@ -139,6 +168,9 @@ class BunnyCdnService /** * Purge cache by tag. + * + * @param string $tag The cache tag to purge (e.g., 'workspace-uuid') + * @return bool True if purge was successful, false otherwise */ public function purgeByTag(string $tag): bool { @@ -168,6 +200,7 @@ class BunnyCdnService * Purge all cached content for a workspace. * * @param object $workspace Workspace model instance (requires uuid property) + * @return bool True if purge was successful, false otherwise */ public function purgeWorkspace(object $workspace): bool { @@ -180,6 +213,10 @@ class BunnyCdnService /** * Get CDN statistics for pull zone. + * + * @param string|null $dateFrom Start date in YYYY-MM-DD format + * @param string|null $dateTo End date in YYYY-MM-DD format + * @return array|null Statistics array or null on failure */ public function getStats(?string $dateFrom = null, ?string $dateTo = null): ?array { @@ -217,6 +254,10 @@ class BunnyCdnService /** * Get bandwidth usage for pull zone. + * + * @param string|null $dateFrom Start date in YYYY-MM-DD format + * @param string|null $dateTo End date in YYYY-MM-DD format + * @return array{total_bandwidth: int, cached_bandwidth: int, origin_bandwidth: int}|null Bandwidth stats or null on failure */ public function getBandwidth(?string $dateFrom = null, ?string $dateTo = null): ?array { @@ -241,6 +282,10 @@ class BunnyCdnService * List files in a storage zone via API. * * Note: For direct storage operations, use BunnyStorageService instead. + * + * @param string $storageZoneName Name of the storage zone + * @param string $path Path within the storage zone (default: root) + * @return array>|null Array of file objects or null on failure */ public function listStorageFiles(string $storageZoneName, string $path = '/'): ?array { @@ -274,6 +319,11 @@ class BunnyCdnService * Upload a file to storage zone via API. * * Note: For direct storage operations, use BunnyStorageService instead. + * + * @param string $storageZoneName Name of the storage zone + * @param string $path Target path within the storage zone + * @param string $contents File contents to upload + * @return bool True if upload was successful, false otherwise */ public function uploadFile(string $storageZoneName, string $path, string $contents): bool { @@ -304,6 +354,10 @@ class BunnyCdnService * Delete a file from storage zone via API. * * Note: For direct storage operations, use BunnyStorageService instead. + * + * @param string $storageZoneName Name of the storage zone + * @param string $path Path of the file to delete + * @return bool True if deletion was successful, false otherwise */ public function deleteFile(string $storageZoneName, string $path): bool { diff --git a/packages/core-php/src/Core/Cdn/Services/BunnyStorageService.php b/packages/core-php/src/Core/Cdn/Services/BunnyStorageService.php index 0bfdd79..aa11430 100644 --- a/packages/core-php/src/Core/Cdn/Services/BunnyStorageService.php +++ b/packages/core-php/src/Core/Cdn/Services/BunnyStorageService.php @@ -12,6 +12,8 @@ namespace Core\Cdn\Services; use Core\Config\ConfigService; use Core\Crypt\LthnHash; +use Core\Service\Contracts\HealthCheckable; +use Core\Service\HealthCheckResult; use Bunny\Storage\Client; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -24,8 +26,9 @@ use Illuminate\Support\Facades\Storage; * - Private zone: DRM/gated content * * Supports vBucket scoping for workspace-isolated CDN paths. + * Implements HealthCheckable for monitoring CDN connectivity. */ -class BunnyStorageService +class BunnyStorageService implements HealthCheckable { protected ?Client $publicClient = null; @@ -46,6 +49,80 @@ class BunnyStorageService */ protected const RETRY_BASE_DELAY_MS = 100; + /** + * Common MIME type mappings by file extension. + * + * @var array + */ + protected const MIME_TYPES = [ + // Images + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'avif' => 'image/avif', + 'heic' => 'image/heic', + 'heif' => 'image/heif', + + // Documents + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // Text/Code + 'txt' => 'text/plain', + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'csv' => 'text/csv', + 'md' => 'text/markdown', + + // Audio + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'flac' => 'audio/flac', + 'aac' => 'audio/aac', + 'm4a' => 'audio/mp4', + + // Video + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + 'm4v' => 'video/mp4', + + // Archives + 'zip' => 'application/zip', + 'tar' => 'application/x-tar', + 'gz' => 'application/gzip', + 'rar' => 'application/vnd.rar', + '7z' => 'application/x-7z-compressed', + + // Fonts + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'eot' => 'application/vnd.ms-fontobject', + + // Other + 'wasm' => 'application/wasm', + 'map' => 'application/json', + ]; + public function __construct( protected ConfigService $config, ) {} @@ -154,14 +231,19 @@ class BunnyStorageService return false; } - return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $zone) { - $client->upload($localPath, $remotePath); + $contentType = $this->detectContentType($localPath); + + return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $contentType) { + // The Bunny SDK upload method accepts optional headers parameter + // Pass content-type for proper CDN handling + $client->upload($localPath, $remotePath, ['Content-Type' => $contentType]); return true; }, [ 'local' => $localPath, 'remote' => $remotePath, 'zone' => $zone, + 'content_type' => $contentType, ], 'Upload'); } @@ -173,6 +255,76 @@ class BunnyStorageService return (int) $this->config->get('cdn.bunny.max_file_size', self::DEFAULT_MAX_FILE_SIZE); } + /** + * Detect the MIME content type for a file. + * + * First tries to detect from file contents using PHP's built-in function, + * then falls back to extension-based detection. + * + * @param string $path File path (local or remote) + * @param string|null $contents File contents for content-based detection + * @return string MIME type (defaults to application/octet-stream) + */ + public function detectContentType(string $path, ?string $contents = null): string + { + // Try content-based detection if contents provided and finfo available + if ($contents !== null && function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo !== false) { + $mimeType = finfo_buffer($finfo, $contents); + finfo_close($finfo); + if ($mimeType !== false && $mimeType !== 'application/octet-stream') { + return $mimeType; + } + } + } + + // Try mime_content_type for local files + if (file_exists($path) && function_exists('mime_content_type')) { + $mimeType = @mime_content_type($path); + if ($mimeType !== false && $mimeType !== 'application/octet-stream') { + return $mimeType; + } + } + + // Fall back to extension-based detection + return $this->getContentTypeFromExtension($path); + } + + /** + * Get content type based on file extension. + * + * @param string $path File path to extract extension from + * @return string MIME type (defaults to application/octet-stream) + */ + public function getContentTypeFromExtension(string $path): string + { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + return self::MIME_TYPES[$extension] ?? 'application/octet-stream'; + } + + /** + * Check if a MIME type is for a binary file. + */ + public function isBinaryContentType(string $mimeType): bool + { + // Text types are not binary + if (str_starts_with($mimeType, 'text/')) { + return false; + } + + // Some application types are text-based + $textApplicationTypes = [ + 'application/json', + 'application/xml', + 'application/javascript', + 'application/x-javascript', + ]; + + return ! in_array($mimeType, $textApplicationTypes, true); + } + /** * Execute an operation with exponential backoff retry. */ @@ -229,13 +381,18 @@ class BunnyStorageService return false; } - return $this->executeWithRetry(function () use ($client, $remotePath, $contents) { - $client->putContents($remotePath, $contents); + $contentType = $this->detectContentType($remotePath, $contents); + + return $this->executeWithRetry(function () use ($client, $remotePath, $contents, $contentType) { + // The Bunny SDK putContents method accepts optional headers parameter + // Pass content-type for proper CDN handling + $client->putContents($remotePath, $contents, ['Content-Type' => $contentType]); return true; }, [ 'remote' => $remotePath, 'zone' => $zone, + 'content_type' => $contentType, ], 'putContents'); } @@ -395,4 +552,160 @@ class BunnyStorageService return $this->list($scopedPath, $zone); } + + // ───────────────────────────────────────────────────────────────────────────── + // Health Check (implements HealthCheckable) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Perform a health check on the CDN storage zones. + * + * Tests connectivity by listing the root directory of configured storage zones. + * Returns a HealthCheckResult with status, latency, and zone information. + */ + public function healthCheck(): HealthCheckResult + { + $publicConfigured = $this->isConfigured('public'); + $privateConfigured = $this->isConfigured('private'); + + if (! $publicConfigured && ! $privateConfigured) { + return HealthCheckResult::unknown('No CDN storage zones configured'); + } + + $results = []; + $startTime = microtime(true); + $hasError = false; + $isDegraded = false; + + // Check public zone + if ($publicConfigured) { + $publicResult = $this->checkZoneHealth('public'); + $results['public'] = $publicResult; + if (! $publicResult['success']) { + $hasError = true; + } elseif ($publicResult['latency_ms'] > 1000) { + $isDegraded = true; + } + } + + // Check private zone + if ($privateConfigured) { + $privateResult = $this->checkZoneHealth('private'); + $results['private'] = $privateResult; + if (! $privateResult['success']) { + $hasError = true; + } elseif ($privateResult['latency_ms'] > 1000) { + $isDegraded = true; + } + } + + $totalLatency = (microtime(true) - $startTime) * 1000; + + if ($hasError) { + return HealthCheckResult::unhealthy( + 'One or more CDN storage zones are unreachable', + ['zones' => $results], + $totalLatency + ); + } + + if ($isDegraded) { + return HealthCheckResult::degraded( + 'CDN storage zones responding slowly', + ['zones' => $results], + $totalLatency + ); + } + + return HealthCheckResult::healthy( + 'All configured CDN storage zones operational', + ['zones' => $results], + $totalLatency + ); + } + + /** + * Check health of a specific storage zone. + * + * @param string $zone 'public' or 'private' + * @return array{success: bool, latency_ms: float, error?: string} + */ + protected function checkZoneHealth(string $zone): array + { + $startTime = microtime(true); + + try { + $client = $zone === 'private' ? $this->privateClient() : $this->publicClient(); + + if (! $client) { + return [ + 'success' => false, + 'latency_ms' => 0, + 'error' => 'Client not initialized', + ]; + } + + // List root directory as a simple connectivity check + // This is a read-only operation that should be fast + $client->listFiles('/'); + + $latencyMs = (microtime(true) - $startTime) * 1000; + + return [ + 'success' => true, + 'latency_ms' => round($latencyMs, 2), + ]; + } catch (\Exception $e) { + $latencyMs = (microtime(true) - $startTime) * 1000; + + Log::warning('BunnyStorage: Health check failed', [ + 'zone' => $zone, + 'error' => $e->getMessage(), + 'latency_ms' => $latencyMs, + ]); + + return [ + 'success' => false, + 'latency_ms' => round($latencyMs, 2), + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform a quick connectivity check. + * + * Simpler than healthCheck() - just returns true/false. + * + * @param string $zone 'public', 'private', or 'any' (default) + */ + public function isReachable(string $zone = 'any'): bool + { + if ($zone === 'any') { + // Check if any configured zone is reachable + if ($this->isConfigured('public')) { + $result = $this->checkZoneHealth('public'); + if ($result['success']) { + return true; + } + } + + if ($this->isConfigured('private')) { + $result = $this->checkZoneHealth('private'); + if ($result['success']) { + return true; + } + } + + return false; + } + + if (! $this->isConfigured($zone)) { + return false; + } + + $result = $this->checkZoneHealth($zone); + + return $result['success']; + } } diff --git a/packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php b/packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php new file mode 100644 index 0000000..f3948da --- /dev/null +++ b/packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php @@ -0,0 +1,348 @@ +build($baseUrl, $path); + } + + /** + * Build an origin storage URL for a path. + * + * @param string $path Path relative to storage root + * @param string|null $baseUrl Optional base URL override (uses config if null) + * @return string Full origin URL + */ + public function origin(string $path, ?string $baseUrl = null): string + { + $baseUrl = $baseUrl ?? config('cdn.urls.public'); + + return $this->build($baseUrl, $path); + } + + /** + * Build a private storage URL for a path. + * + * @param string $path Path relative to storage root + * @param string|null $baseUrl Optional base URL override (uses config if null) + * @return string Full private URL + */ + public function private(string $path, ?string $baseUrl = null): string + { + $baseUrl = $baseUrl ?? config('cdn.urls.private'); + + return $this->build($baseUrl, $path); + } + + /** + * Build an apex domain URL for a path. + * + * @param string $path Path relative to web root + * @param string|null $baseUrl Optional base URL override (uses config if null) + * @return string Full apex URL + */ + public function apex(string $path, ?string $baseUrl = null): string + { + $baseUrl = $baseUrl ?? config('cdn.urls.apex'); + + return $this->build($baseUrl, $path); + } + + /** + * Build a signed URL for private CDN content with token authentication. + * + * @param string $path Path relative to storage root + * @param int|Carbon|null $expiry Expiry time in seconds, or Carbon instance. + * Defaults to config('cdn.signed_url_expiry', 3600) + * @param string|null $token Optional token override (uses config if null) + * @return string|null Signed URL or null if token not configured + */ + public function signed(string $path, int|Carbon|null $expiry = null, ?string $token = null): ?string + { + $token = $token ?? config('cdn.bunny.private.token'); + + if (empty($token)) { + return null; + } + + // Resolve expiry to Unix timestamp + $expires = $this->resolveExpiry($expiry); + $path = '/'.ltrim($path, '/'); + + // BunnyCDN token authentication format (using HMAC for security) + $hashableBase = $token.$path.$expires; + $hash = base64_encode(hash_hmac('sha256', $hashableBase, $token, true)); + + // URL-safe base64 + $hash = str_replace(['+', '/'], ['-', '_'], $hash); + $hash = rtrim($hash, '='); + + // Build base URL from config + $baseUrl = $this->buildSignedUrlBase(); + + return "{$baseUrl}{$path}?token={$hash}&expires={$expires}"; + } + + /** + * Build a vBucket-scoped CDN URL. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to vBucket root + * @param string|null $baseUrl Optional base URL override + * @return string Full vBucket-scoped CDN URL + */ + public function vBucket(string $domain, string $path, ?string $baseUrl = null): string + { + $vBucketId = $this->vBucketId($domain); + $scopedPath = $this->vBucketPath($domain, $path); + + return $this->cdn($scopedPath, $baseUrl); + } + + /** + * Generate a vBucket ID for a domain/workspace. + * + * Uses LTHN QuasiHash for deterministic, scoped identifiers. + * + * @param string $domain The domain name (e.g., "example.com") + * @return string 16-character vBucket identifier + */ + public function vBucketId(string $domain): string + { + return LthnHash::vBucketId($domain); + } + + /** + * Build a vBucket-scoped storage path. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to vBucket root + * @return string Full storage path with vBucket prefix + */ + public function vBucketPath(string $domain, string $path): string + { + $vBucketId = $this->vBucketId($domain); + + return "{$vBucketId}/".ltrim($path, '/'); + } + + /** + * Build a context-aware asset URL. + * + * @param string $path Path relative to storage root + * @param string $context Context ('admin', 'public') + * @return string URL appropriate for the context + */ + public function asset(string $path, string $context = 'public'): string + { + return $context === 'admin' ? $this->origin($path) : $this->cdn($path); + } + + /** + * Build a URL with version query parameter for cache busting. + * + * @param string $url The base URL + * @param string|null $version Version hash for cache busting + * @return string URL with version parameter + */ + public function withVersion(string $url, ?string $version): string + { + if (empty($version)) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return "{$url}{$separator}id={$version}"; + } + + /** + * Build both CDN and origin URLs for API responses. + * + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string} + */ + public function urls(string $path): array + { + return [ + 'cdn' => $this->cdn($path), + 'origin' => $this->origin($path), + ]; + } + + /** + * Build all URL types for a path. + * + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string, private: string, apex: string} + */ + public function allUrls(string $path): array + { + return [ + 'cdn' => $this->cdn($path), + 'origin' => $this->origin($path), + 'private' => $this->private($path), + 'apex' => $this->apex($path), + ]; + } + + /** + * Build vBucket-scoped URLs for API responses. + * + * @param string $domain The workspace domain for scoping + * @param string $path Path relative to storage root + * @return array{cdn: string, origin: string, vbucket: string} + */ + public function vBucketUrls(string $domain, string $path): array + { + $vBucketId = $this->vBucketId($domain); + $scopedPath = "{$vBucketId}/{$path}"; + + return [ + 'cdn' => $this->cdn($scopedPath), + 'origin' => $this->origin($scopedPath), + 'vbucket' => $vBucketId, + ]; + } + + /** + * Build a URL from base URL and path. + * + * @param string|null $baseUrl Base URL (falls back to apex if null) + * @param string $path Path to append + * @return string Full URL + */ + public function build(?string $baseUrl, string $path): string + { + if (empty($baseUrl)) { + // Fallback to apex domain if no base URL configured + $baseUrl = config('cdn.urls.apex', config('app.url')); + } + + $baseUrl = rtrim($baseUrl, '/'); + $path = ltrim($path, '/'); + + return "{$baseUrl}/{$path}"; + } + + /** + * Build the base URL for signed private URLs. + * + * @return string Base URL for signed URLs + */ + protected function buildSignedUrlBase(): string + { + $pullZone = config('cdn.bunny.private.pull_zone'); + + // Support both full URL and just hostname in config + if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) { + return rtrim($pullZone, '/'); + } + + return "https://{$pullZone}"; + } + + /** + * Resolve expiry parameter to a Unix timestamp. + * + * @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default + * @return int Unix timestamp when the URL expires + */ + protected function resolveExpiry(int|Carbon|null $expiry): int + { + if ($expiry instanceof Carbon) { + return $expiry->timestamp; + } + + $expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600); + + return time() + $expirySeconds; + } + + /** + * Get the path prefix for a content category. + * + * @param string $category Category key from config (media, social, page, etc.) + * @return string Path prefix + */ + public function pathPrefix(string $category): string + { + return config("cdn.paths.{$category}", $category); + } + + /** + * Build a full path with category prefix. + * + * @param string $category Category key + * @param string $path Relative path within category + * @return string Full path with category prefix + */ + public function categoryPath(string $category, string $path): string + { + $prefix = $this->pathPrefix($category); + + return "{$prefix}/{$path}"; + } +} diff --git a/packages/core-php/src/Core/Cdn/Services/FluxCdnService.php b/packages/core-php/src/Core/Cdn/Services/FluxCdnService.php index 85dba30..97880ea 100644 --- a/packages/core-php/src/Core/Cdn/Services/FluxCdnService.php +++ b/packages/core-php/src/Core/Cdn/Services/FluxCdnService.php @@ -20,13 +20,34 @@ use Flux\Flux; * In production: Uses CDN URLs (cdn.host.uk.com/flux/flux.min.js) * * Requires Flux assets to be uploaded to CDN storage zone. + * + * URL building is delegated to CdnUrlBuilder for consistency across services. + * + * ## Methods + * + * | Method | Returns | Description | + * |--------|---------|-------------| + * | `scripts()` | `string` | Get Flux scripts tag with CDN awareness | + * | `editorScripts()` | `string` | Get Flux editor scripts (Pro only) | + * | `editorStyles()` | `string` | Get Flux editor styles (Pro only) | + * | `shouldUseCdn()` | `bool` | Check if CDN should be used | + * | `getCdnAssetPaths()` | `array` | Get source-to-CDN path mapping | + * + * @see CdnUrlBuilder For the underlying URL building logic */ class FluxCdnService { + protected CdnUrlBuilder $urlBuilder; + + public function __construct(?CdnUrlBuilder $urlBuilder = null) + { + $this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder; + } /** * Get the Flux scripts tag with CDN awareness. * - * @param array $options Options like ['nonce' => 'abc123'] + * @param array $options Options like ['nonce' => 'abc123'] + * @return string HTML script tag */ public function scripts(array $options = []): string { @@ -47,6 +68,9 @@ class FluxCdnService /** * Get the Flux editor scripts tag with CDN awareness. + * + * @return string HTML script tag for Flux editor + * @throws \Exception When Flux Pro is not available */ public function editorScripts(): string { @@ -69,6 +93,9 @@ class FluxCdnService /** * Get the Flux editor styles tag with CDN awareness. + * + * @return string HTML link tag for Flux editor styles + * @throws \Exception When Flux Pro is not available */ public function editorStyles(): string { @@ -90,6 +117,9 @@ class FluxCdnService /** * Get version hash from Flux manifest. + * + * @param string $key Manifest key to look up + * @return string 8-character hash for cache busting */ protected function getVersionHash(string $key = '/flux.js'): string { @@ -108,7 +138,10 @@ class FluxCdnService /** * Check if we should use CDN for Flux assets. + * * Respects CDN_FORCE_LOCAL for testing. + * + * @return bool True if CDN should be used, false for local assets */ public function shouldUseCdn(): bool { @@ -120,18 +153,24 @@ class FluxCdnService * * Flux assets are shared across all workspaces, so they don't use * workspace-specific vBucket prefixes. + * + * @param string $path Asset path relative to CDN root + * @param string|null $version Optional version hash for cache busting + * @return string Full CDN URL with optional version query parameter */ protected function cdnUrl(string $path, ?string $version = null): string { $cdnUrl = config('cdn.urls.cdn'); if (empty($cdnUrl)) { - return asset($path).($version ? "?id={$version}" : ''); + $baseUrl = asset($path); + + return $this->urlBuilder->withVersion($baseUrl, $version); } - $url = rtrim($cdnUrl, '/').'/'.ltrim($path, '/'); + $url = $this->urlBuilder->cdn($path); - return $version ? "{$url}?id={$version}" : $url; + return $this->urlBuilder->withVersion($url, $version); } /** diff --git a/packages/core-php/src/Core/Cdn/Services/StorageUrlResolver.php b/packages/core-php/src/Core/Cdn/Services/StorageUrlResolver.php index 8ea11f3..4236332 100644 --- a/packages/core-php/src/Core/Cdn/Services/StorageUrlResolver.php +++ b/packages/core-php/src/Core/Cdn/Services/StorageUrlResolver.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace Core\Cdn\Services; -use Core\Crypt\LthnHash; +use Carbon\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Storage; @@ -19,19 +19,67 @@ use Illuminate\Support\Facades\Storage; * Context-aware URL resolver for CDN/storage architecture. * * Provides intelligent URL resolution based on request context: - * - Admin/internal requests → Origin URLs (Hetzner) - * - Public/embed requests → CDN URLs (BunnyCDN) - * - API requests → Both URLs returned + * - Admin/internal requests -> Origin URLs (Hetzner) + * - Public/embed requests -> CDN URLs (BunnyCDN) + * - API requests -> Both URLs returned * * Supports vBucket scoping for workspace-isolated CDN paths using LTHN QuasiHash. + * + * URL building is delegated to CdnUrlBuilder for consistency across services. + * + * ## Methods + * + * | Method | Returns | Description | + * |--------|---------|-------------| + * | `vBucketId()` | `string` | Generate vBucket ID for a domain | + * | `vBucketCdn()` | `string` | Get CDN URL with vBucket scoping | + * | `vBucketOrigin()` | `string` | Get origin URL with vBucket scoping | + * | `vBucketPath()` | `string` | Build vBucket-scoped storage path | + * | `vBucketUrls()` | `array` | Get both URLs with vBucket scoping | + * | `cdn()` | `string` | Get CDN delivery URL for a path | + * | `origin()` | `string` | Get origin URL (Hetzner) for a path | + * | `private()` | `string` | Get private storage URL for a path | + * | `signedUrl()` | `string\|null` | Get signed URL for private content | + * | `apex()` | `string` | Get apex domain URL for a path | + * | `asset()` | `string` | Get context-aware URL for a path | + * | `urls()` | `array` | Get both CDN and origin URLs | + * | `allUrls()` | `array` | Get all URLs (cdn, origin, private, apex) | + * | `detectContext()` | `string` | Detect current request context | + * | `isAdminContext()` | `bool` | Check if current context is admin | + * | `pushToCdn()` | `bool` | Push a file to CDN storage zone | + * | `deleteFromCdn()` | `bool` | Delete a file from CDN storage zone | + * | `purge()` | `bool` | Purge a path from CDN cache | + * | `cachedAsset()` | `string` | Get cached CDN URL with intelligent caching | + * | `publicDisk()` | `Filesystem` | Get the public storage disk | + * | `privateDisk()` | `Filesystem` | Get the private storage disk | + * | `storePublic()` | `bool` | Store file to public bucket | + * | `storePrivate()` | `bool` | Store file to private bucket | + * | `deleteAsset()` | `bool` | Delete file from storage and CDN | + * | `pathPrefix()` | `string` | Get path prefix for a category | + * | `categoryPath()` | `string` | Build full path with category prefix | + * + * @see CdnUrlBuilder For the underlying URL building logic */ class StorageUrlResolver { protected BunnyStorageService $bunnyStorage; - public function __construct(BunnyStorageService $bunnyStorage) + protected CdnUrlBuilder $urlBuilder; + + public function __construct(BunnyStorageService $bunnyStorage, ?CdnUrlBuilder $urlBuilder = null) { $this->bunnyStorage = $bunnyStorage; + $this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder; + } + + /** + * Get the URL builder instance. + * + * @return CdnUrlBuilder + */ + public function getUrlBuilder(): CdnUrlBuilder + { + return $this->urlBuilder; } /** @@ -45,7 +93,7 @@ class StorageUrlResolver */ public function vBucketId(string $domain): string { - return LthnHash::vBucketId($domain); + return $this->urlBuilder->vBucketId($domain); } /** @@ -56,9 +104,7 @@ class StorageUrlResolver */ public function vBucketCdn(string $domain, string $path): string { - $vBucketId = $this->vBucketId($domain); - - return $this->cdn("{$vBucketId}/{$path}"); + return $this->urlBuilder->vBucket($domain, $path); } /** @@ -69,9 +115,9 @@ class StorageUrlResolver */ public function vBucketOrigin(string $domain, string $path): string { - $vBucketId = $this->vBucketId($domain); + $scopedPath = $this->urlBuilder->vBucketPath($domain, $path); - return $this->origin("{$vBucketId}/{$path}"); + return $this->urlBuilder->origin($scopedPath); } /** @@ -82,9 +128,7 @@ class StorageUrlResolver */ public function vBucketPath(string $domain, string $path): string { - $vBucketId = $this->vBucketId($domain); - - return "{$vBucketId}/".ltrim($path, '/'); + return $this->urlBuilder->vBucketPath($domain, $path); } /** @@ -96,14 +140,7 @@ class StorageUrlResolver */ public function vBucketUrls(string $domain, string $path): array { - $vBucketId = $this->vBucketId($domain); - $scopedPath = "{$vBucketId}/{$path}"; - - return [ - 'cdn' => $this->cdn($scopedPath), - 'origin' => $this->origin($scopedPath), - 'vbucket' => $vBucketId, - ]; + return $this->urlBuilder->vBucketUrls($domain, $path); } /** @@ -114,7 +151,7 @@ class StorageUrlResolver */ public function cdn(string $path): string { - return $this->buildUrl(config('cdn.urls.cdn'), $path); + return $this->urlBuilder->cdn($path); } /** @@ -125,7 +162,7 @@ class StorageUrlResolver */ public function origin(string $path): string { - return $this->buildUrl(config('cdn.urls.public'), $path); + return $this->urlBuilder->origin($path); } /** @@ -136,7 +173,7 @@ class StorageUrlResolver */ public function private(string $path): string { - return $this->buildUrl(config('cdn.urls.private'), $path); + return $this->urlBuilder->private($path); } /** @@ -144,31 +181,31 @@ class StorageUrlResolver * Generates time-limited access URLs for gated/DRM content. * * @param string $path Path relative to storage root - * @param int $expiry Expiry time in seconds (default 1 hour) + * @param int|Carbon|null $expiry Expiry time in seconds, or a Carbon instance for absolute expiry. + * Defaults to config('cdn.signed_url_expiry', 3600) when null. * @return string|null Signed URL or null if token not configured */ - public function signedUrl(string $path, int $expiry = 3600): ?string + public function signedUrl(string $path, int|Carbon|null $expiry = null): ?string { - $token = config('cdn.bunny.private.token'); + return $this->urlBuilder->signed($path, $expiry); + } - if (empty($token)) { - return null; + /** + * Build the base URL for signed private URLs. + * Uses config for the private pull zone URL. + * + * @deprecated Use CdnUrlBuilder::signed() instead + */ + protected function buildSignedUrlBase(): string + { + $pullZone = config('cdn.bunny.private.pull_zone'); + + // Support both full URL and just hostname in config + if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) { + return rtrim($pullZone, '/'); } - $pullZone = config('cdn.bunny.private.pull_zone'); - $expires = time() + $expiry; - $path = '/'.ltrim($path, '/'); - - // 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_hmac('sha256', $hashableBase, $token, true)); - - // URL-safe base64 - $hash = str_replace(['+', '/'], ['-', '_'], $hash); - $hash = rtrim($hash, '='); - - return "https://{$pullZone}{$path}?token={$hash}&expires={$expires}"; + return "https://{$pullZone}"; } /** @@ -179,7 +216,7 @@ class StorageUrlResolver */ public function apex(string $path): string { - return $this->buildUrl(config('cdn.urls.apex'), $path); + return $this->urlBuilder->apex($path); } /** @@ -193,7 +230,7 @@ class StorageUrlResolver { $context = $context ?? $this->detectContext(); - return $context === 'admin' ? $this->origin($path) : $this->cdn($path); + return $this->urlBuilder->asset($path, $context); } /** @@ -204,10 +241,7 @@ class StorageUrlResolver */ public function urls(string $path): array { - return [ - 'cdn' => $this->cdn($path), - 'origin' => $this->origin($path), - ]; + return $this->urlBuilder->urls($path); } /** @@ -218,16 +252,13 @@ class StorageUrlResolver */ public function allUrls(string $path): array { - return [ - 'cdn' => $this->cdn($path), - 'origin' => $this->origin($path), - 'private' => $this->private($path), - 'apex' => $this->apex($path), - ]; + return $this->urlBuilder->allUrls($path); } /** - * Detect the current request context. + * Detect the current request context based on headers and route. + * + * Checks for admin headers and route prefixes to determine context. * * @return string 'admin' or 'public' */ @@ -253,6 +284,8 @@ class StorageUrlResolver /** * Check if the current context is admin/internal. + * + * @return bool True if in admin context */ public function isAdminContext(): bool { @@ -325,18 +358,16 @@ class StorageUrlResolver /** * Build a URL from base URL and path. + * + * @param string|null $baseUrl Base URL (falls back to apex if null) + * @param string $path Path to append + * @return string Full URL + * + * @deprecated Use CdnUrlBuilder::build() instead */ protected function buildUrl(?string $baseUrl, string $path): string { - if (empty($baseUrl)) { - // Fallback to apex domain if no base URL configured - $baseUrl = config('cdn.urls.apex', config('app.url')); - } - - $baseUrl = rtrim($baseUrl, '/'); - $path = ltrim($path, '/'); - - return "{$baseUrl}/{$path}"; + return $this->urlBuilder->build($baseUrl, $path); } /** @@ -429,11 +460,11 @@ class StorageUrlResolver /** * Get the path prefix for a content category. * - * @param string $category Category key from config (media, social, biolink, etc.) + * @param string $category Category key from config (media, social, page, etc.) */ public function pathPrefix(string $category): string { - return config("cdn.paths.{$category}", $category); + return $this->urlBuilder->pathPrefix($category); } /** @@ -444,8 +475,25 @@ class StorageUrlResolver */ public function categoryPath(string $category, string $path): string { - $prefix = $this->pathPrefix($category); + return $this->urlBuilder->categoryPath($category, $path); + } - return "{$prefix}/{$path}"; + /** + * Resolve expiry parameter to a Unix timestamp. + * + * @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default + * @return int Unix timestamp when the URL expires + * + * @deprecated Use CdnUrlBuilder internally instead + */ + protected function resolveExpiry(int|Carbon|null $expiry): int + { + if ($expiry instanceof Carbon) { + return $expiry->timestamp; + } + + $expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600); + + return time() + $expirySeconds; } } diff --git a/packages/core-php/src/Core/Cdn/config.php b/packages/core-php/src/Core/Cdn/config.php index d18208d..8d3b218 100644 --- a/packages/core-php/src/Core/Cdn/config.php +++ b/packages/core-php/src/Core/Cdn/config.php @@ -32,15 +32,36 @@ return [ */ 'enabled' => env('CDN_ENABLED', false), + /* + |-------------------------------------------------------------------------- + | Signed URL Expiry + |-------------------------------------------------------------------------- + | + | Default expiry time (in seconds) for signed URLs when not specified + | per-request. Signed URLs provide time-limited access to private content. + | + */ + 'signed_url_expiry' => env('CDN_SIGNED_URL_EXPIRY', 3600), + /* |-------------------------------------------------------------------------- | URL Configuration |-------------------------------------------------------------------------- + | + | All URL building uses these config values for consistency. + | Never hardcode URLs in service methods. + | */ 'urls' => [ // CDN delivery URL (when enabled) 'cdn' => env('CDN_URL'), + // Public origin URL (direct storage access, bypassing CDN) + 'public' => env('CDN_PUBLIC_URL'), + + // Private CDN URL (for signed/gated content) + 'private' => env('CDN_PRIVATE_URL'), + // Apex domain fallback 'apex' => env('APP_URL', 'https://core.test'), ], diff --git a/packages/core-php/src/Core/Cdn/offload.php b/packages/core-php/src/Core/Cdn/offload.php index c46e13f..84d1b51 100644 --- a/packages/core-php/src/Core/Cdn/offload.php +++ b/packages/core-php/src/Core/Cdn/offload.php @@ -55,7 +55,7 @@ return [ * File path organisation within bucket. */ 'paths' => [ - 'biolink' => 'biolinks', + 'page' => 'pages', 'avatar' => 'avatars', 'media' => 'media', 'static' => 'static', diff --git a/packages/core-php/src/Core/Config/Boot.php b/packages/core-php/src/Core/Config/Boot.php index 68536bd..c13e5e6 100644 --- a/packages/core-php/src/Core/Config/Boot.php +++ b/packages/core-php/src/Core/Config/Boot.php @@ -22,6 +22,42 @@ use Livewire\Livewire; * $config = app(ConfigService::class); * $value = $config->get('cdn.bunny.api_key', $workspace); * if ($config->isConfigured('cdn.bunny', $workspace)) { ... } + * + * ## Import/Export + * + * Export config to JSON or YAML for backup, migration, or sharing: + * + * ```php + * $exporter = app(ConfigExporter::class); + * $json = $exporter->exportJson($workspace); + * $result = $exporter->importJson($json, $workspace); + * ``` + * + * CLI commands: + * - `config:export config.json` - Export to file + * - `config:import config.json` - Import from file + * + * ## Versioning & Rollback + * + * Create snapshots and rollback to previous states: + * + * ```php + * $versioning = app(ConfigVersioning::class); + * $version = $versioning->createVersion($workspace, 'Before migration'); + * $versioning->rollback($version->id, $workspace); + * ``` + * + * CLI commands: + * - `config:version list` - List all versions + * - `config:version create "Label"` - Create snapshot + * - `config:version rollback 123` - Rollback to version + * - `config:version compare 122 123` - Compare versions + * + * ## Configuration + * + * | Key | Type | Default | Description | + * |-----|------|---------|-------------| + * | `core.config.max_versions` | int | 50 | Max versions per scope | */ class Boot extends ServiceProvider { @@ -38,6 +74,19 @@ class Boot extends ServiceProvider // Alias for convenience $this->app->alias(ConfigService::class, 'config.service'); + + // Register exporter service + $this->app->singleton(ConfigExporter::class, function ($app) { + return new ConfigExporter($app->make(ConfigService::class)); + }); + + // Register versioning service + $this->app->singleton(ConfigVersioning::class, function ($app) { + return new ConfigVersioning( + $app->make(ConfigService::class), + $app->make(ConfigExporter::class) + ); + }); } /** @@ -63,6 +112,9 @@ class Boot extends ServiceProvider $this->commands([ Console\ConfigPrimeCommand::class, Console\ConfigListCommand::class, + Console\ConfigExportCommand::class, + Console\ConfigImportCommand::class, + Console\ConfigVersionCommand::class, ]); } diff --git a/packages/core-php/src/Core/Config/ConfigExporter.php b/packages/core-php/src/Core/Config/ConfigExporter.php new file mode 100644 index 0000000..00fdeb2 --- /dev/null +++ b/packages/core-php/src/Core/Config/ConfigExporter.php @@ -0,0 +1,537 @@ +exportJson($workspace); + * file_put_contents('config.json', $json); + * + * // Export to YAML + * $yaml = $exporter->exportYaml($workspace); + * file_put_contents('config.yaml', $yaml); + * + * // Import from JSON + * $result = $exporter->importJson(file_get_contents('config.json'), $workspace); + * + * // Import from YAML + * $result = $exporter->importYaml(file_get_contents('config.yaml'), $workspace); + * ``` + * + * @see ConfigService For runtime config access + * @see ConfigVersioning For config versioning and rollback + */ +class ConfigExporter +{ + /** + * Current export format version. + */ + protected const FORMAT_VERSION = '1.0'; + + /** + * Placeholder for sensitive values in exports. + */ + protected const SENSITIVE_PLACEHOLDER = '***SENSITIVE***'; + + public function __construct( + protected ConfigService $config, + ) {} + + /** + * Export config to JSON format. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $includeSensitive Include sensitive values (default: false) + * @param bool $includeKeys Include key definitions (default: true) + * @param string|null $category Filter by category (optional) + * @return string JSON string + */ + public function exportJson( + ?object $workspace = null, + bool $includeSensitive = false, + bool $includeKeys = true, + ?string $category = null, + ): string { + $data = $this->buildExportData($workspace, $includeSensitive, $includeKeys, $category); + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * Export config to YAML format. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $includeSensitive Include sensitive values (default: false) + * @param bool $includeKeys Include key definitions (default: true) + * @param string|null $category Filter by category (optional) + * @return string YAML string + */ + public function exportYaml( + ?object $workspace = null, + bool $includeSensitive = false, + bool $includeKeys = true, + ?string $category = null, + ): string { + $data = $this->buildExportData($workspace, $includeSensitive, $includeKeys, $category); + + return Yaml::dump($data, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + } + + /** + * Build export data structure. + * + * @param object|null $workspace Workspace model instance or null for system scope + */ + protected function buildExportData( + ?object $workspace, + bool $includeSensitive, + bool $includeKeys, + ?string $category, + ): array { + $data = [ + 'version' => self::FORMAT_VERSION, + 'exported_at' => now()->toIso8601String(), + 'scope' => [ + 'type' => $workspace ? 'workspace' : 'system', + 'id' => $workspace?->id, + ], + ]; + + // Get profile for this scope + $profile = $this->getProfile($workspace); + + if ($includeKeys) { + $data['keys'] = $this->exportKeys($category); + } + + $data['values'] = $this->exportValues($profile, $includeSensitive, $category); + + return $data; + } + + /** + * Export key definitions. + * + * @return array> + */ + protected function exportKeys(?string $category = null): array + { + $query = ConfigKey::query()->orderBy('category')->orderBy('code'); + + if ($category !== null) { + $escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category); + $query->where('code', 'LIKE', "{$escapedCategory}.%") + ->orWhere('category', $category); + } + + return $query->get()->map(function (ConfigKey $key) { + return [ + 'code' => $key->code, + 'type' => $key->type->value, + 'category' => $key->category, + 'description' => $key->description, + 'default_value' => $key->default_value, + 'is_sensitive' => $key->is_sensitive ?? false, + ]; + })->toArray(); + } + + /** + * Export config values. + * + * @return array> + */ + protected function exportValues(?ConfigProfile $profile, bool $includeSensitive, ?string $category): array + { + if ($profile === null) { + return []; + } + + $query = ConfigValue::query() + ->with('key') + ->where('profile_id', $profile->id); + + $values = $query->get(); + + return $values + ->filter(function (ConfigValue $value) use ($category) { + if ($category === null) { + return true; + } + $key = $value->key; + if ($key === null) { + return false; + } + + return str_starts_with($key->code, "{$category}.") || $key->category === $category; + }) + ->map(function (ConfigValue $value) use ($includeSensitive) { + $key = $value->key; + + // Mask sensitive values unless explicitly included + $displayValue = $value->value; + if ($key?->isSensitive() && ! $includeSensitive) { + $displayValue = self::SENSITIVE_PLACEHOLDER; + } + + return [ + 'key' => $key?->code ?? 'unknown', + 'value' => $displayValue, + 'locked' => $value->locked, + 'channel_id' => $value->channel_id, + ]; + }) + ->values() + ->toArray(); + } + + /** + * Import config from JSON format. + * + * @param string $json JSON string + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + * @return ImportResult Import result with stats + * + * @throws \InvalidArgumentException If JSON is invalid + */ + public function importJson(string $json, ?object $workspace = null, bool $dryRun = false): ImportResult + { + $data = json_decode($json, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('Invalid JSON: '.json_last_error_msg()); + } + + return $this->importData($data, $workspace, $dryRun); + } + + /** + * Import config from YAML format. + * + * @param string $yaml YAML string + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + * @return ImportResult Import result with stats + * + * @throws \InvalidArgumentException If YAML is invalid + */ + public function importYaml(string $yaml, ?object $workspace = null, bool $dryRun = false): ImportResult + { + try { + $data = Yaml::parse($yaml); + } catch (\Exception $e) { + throw new \InvalidArgumentException('Invalid YAML: '.$e->getMessage()); + } + + return $this->importData($data, $workspace, $dryRun); + } + + /** + * Import config from parsed data. + * + * @param array $data Parsed import data + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + */ + protected function importData(array $data, ?object $workspace, bool $dryRun): ImportResult + { + $result = new ImportResult; + + // Validate version + $version = $data['version'] ?? '1.0'; + if (! $this->isVersionCompatible($version)) { + $result->addError("Incompatible export version: {$version} (expected {FORMAT_VERSION})"); + + return $result; + } + + // Get or create profile for this scope + $profile = $this->getOrCreateProfile($workspace); + + // Import keys if present + if (isset($data['keys']) && is_array($data['keys'])) { + $this->importKeys($data['keys'], $result, $dryRun); + } + + // Import values if present + if (isset($data['values']) && is_array($data['values'])) { + $this->importValues($data['values'], $profile, $result, $dryRun); + } + + // Re-prime config if changes were made + if (! $dryRun && $result->hasChanges()) { + $this->config->prime($workspace); + } + + return $result; + } + + /** + * Import key definitions. + * + * @param array> $keys + */ + protected function importKeys(array $keys, ImportResult $result, bool $dryRun): void + { + foreach ($keys as $keyData) { + $code = $keyData['code'] ?? null; + if ($code === null) { + $result->addSkipped('Key with no code'); + + continue; + } + + try { + $type = ConfigType::tryFrom($keyData['type'] ?? 'string') ?? ConfigType::STRING; + + $existing = ConfigKey::byCode($code); + + if ($existing !== null) { + // Update existing key + if (! $dryRun) { + $existing->update([ + 'type' => $type, + 'category' => $keyData['category'] ?? $existing->category, + 'description' => $keyData['description'] ?? $existing->description, + 'default_value' => $keyData['default_value'] ?? $existing->default_value, + 'is_sensitive' => $keyData['is_sensitive'] ?? $existing->is_sensitive, + ]); + } + $result->addUpdated($code, 'key'); + } else { + // Create new key + if (! $dryRun) { + ConfigKey::create([ + 'code' => $code, + 'type' => $type, + 'category' => $keyData['category'] ?? 'imported', + 'description' => $keyData['description'] ?? null, + 'default_value' => $keyData['default_value'] ?? null, + 'is_sensitive' => $keyData['is_sensitive'] ?? false, + ]); + } + $result->addCreated($code, 'key'); + } + } catch (\Exception $e) { + $result->addError("Failed to import key '{$code}': ".$e->getMessage()); + } + } + } + + /** + * Import config values. + * + * @param array> $values + */ + protected function importValues(array $values, ConfigProfile $profile, ImportResult $result, bool $dryRun): void + { + foreach ($values as $valueData) { + $keyCode = $valueData['key'] ?? null; + if ($keyCode === null) { + $result->addSkipped('Value with no key'); + + continue; + } + + // Skip sensitive placeholders + if ($valueData['value'] === self::SENSITIVE_PLACEHOLDER) { + $result->addSkipped("{$keyCode} (sensitive placeholder)"); + + continue; + } + + try { + $key = ConfigKey::byCode($keyCode); + if ($key === null) { + $result->addSkipped("{$keyCode} (key not found)"); + + continue; + } + + $channelId = $valueData['channel_id'] ?? null; + $existing = ConfigValue::findValue($profile->id, $key->id, $channelId); + + if ($existing !== null) { + // Update existing value + if (! $dryRun) { + $existing->value = $valueData['value']; + $existing->locked = $valueData['locked'] ?? false; + $existing->save(); + } + $result->addUpdated($keyCode, 'value'); + } else { + // Create new value + if (! $dryRun) { + $value = new ConfigValue; + $value->profile_id = $profile->id; + $value->key_id = $key->id; + $value->channel_id = $channelId; + $value->value = $valueData['value']; + $value->locked = $valueData['locked'] ?? false; + $value->save(); + } + $result->addCreated($keyCode, 'value'); + } + } catch (\Exception $e) { + $result->addError("Failed to import value '{$keyCode}': ".$e->getMessage()); + } + } + } + + /** + * Check if export version is compatible. + */ + protected function isVersionCompatible(string $version): bool + { + // For now, only support exact version match + // Can be extended to support backward compatibility + $supported = ['1.0']; + + return in_array($version, $supported, true); + } + + /** + * Get profile for a workspace (or system). + */ + protected function getProfile(?object $workspace): ?ConfigProfile + { + if ($workspace !== null) { + return ConfigProfile::forWorkspace($workspace->id); + } + + return ConfigProfile::system(); + } + + /** + * Get or create profile for a workspace (or system). + */ + protected function getOrCreateProfile(?object $workspace): ConfigProfile + { + if ($workspace !== null) { + return ConfigProfile::ensureWorkspace($workspace->id); + } + + return ConfigProfile::ensureSystem(); + } + + /** + * Export config to a file. + * + * @param string $path File path (extension determines format) + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $includeSensitive Include sensitive values + * + * @throws \RuntimeException If file cannot be written + */ + public function exportToFile( + string $path, + ?object $workspace = null, + bool $includeSensitive = false, + ): void { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + $content = match ($extension) { + 'yaml', 'yml' => $this->exportYaml($workspace, $includeSensitive), + default => $this->exportJson($workspace, $includeSensitive), + }; + + $result = file_put_contents($path, $content); + + if ($result === false) { + throw new \RuntimeException("Failed to write config export to: {$path}"); + } + } + + /** + * Import config from a file. + * + * @param string $path File path (extension determines format) + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $dryRun Preview changes without applying + * @return ImportResult Import result with stats + * + * @throws \RuntimeException If file cannot be read + */ + public function importFromFile( + string $path, + ?object $workspace = null, + bool $dryRun = false, + ): ImportResult { + if (! file_exists($path)) { + throw new \RuntimeException("Config file not found: {$path}"); + } + + $content = file_get_contents($path); + if ($content === false) { + throw new \RuntimeException("Failed to read config file: {$path}"); + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + return match ($extension) { + 'yaml', 'yml' => $this->importYaml($content, $workspace, $dryRun), + default => $this->importJson($content, $workspace, $dryRun), + }; + } +} diff --git a/packages/core-php/src/Core/Config/ConfigResolver.php b/packages/core-php/src/Core/Config/ConfigResolver.php index 561504f..090d60e 100644 --- a/packages/core-php/src/Core/Config/ConfigResolver.php +++ b/packages/core-php/src/Core/Config/ConfigResolver.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace Core\Config; +use Core\Config\Contracts\ConfigProvider; use Core\Config\Enums\ScopeType; use Core\Config\Models\Channel; use Core\Config\Models\ConfigKey; @@ -50,7 +51,9 @@ class ConfigResolver /** * Registered virtual providers. * - * @var array + * Supports both ConfigProvider instances and callable functions. + * + * @var array */ protected array $providers = []; @@ -399,18 +402,26 @@ class ConfigResolver * Register a virtual provider for a key pattern. * * Providers supply values from module data without database storage. + * Accepts either a ConfigProvider instance or a callable. * - * @param string $pattern Key pattern (supports * wildcard) - * @param callable $provider fn(string $key, ?object $workspace, ?Channel $channel): mixed + * @param string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance + * @param ConfigProvider|callable|null $provider ConfigProvider instance or fn(string $key, ?object $workspace, ?Channel $channel): mixed */ - public function registerProvider(string $pattern, callable $provider): void + public function registerProvider(string|ConfigProvider $patternOrProvider, ConfigProvider|callable|null $provider = null): void { - $this->providers[$pattern] = $provider; + // Support both new interface-based and legacy callable patterns + if ($patternOrProvider instanceof ConfigProvider) { + $this->providers[$patternOrProvider->pattern()] = $patternOrProvider; + } elseif ($provider !== null) { + $this->providers[$patternOrProvider] = $provider; + } } /** * Resolve value from virtual providers. * + * Supports both ConfigProvider instances and legacy callables. + * * @param object|null $workspace Workspace model instance or null for system scope */ public function resolveFromProviders( @@ -420,7 +431,10 @@ class ConfigResolver ): mixed { foreach ($this->providers as $pattern => $provider) { if ($this->matchesPattern($keyCode, $pattern)) { - $value = $provider($keyCode, $workspace, $channel); + // Support both ConfigProvider interface and legacy callable + $value = $provider instanceof ConfigProvider + ? $provider->resolve($keyCode, $workspace, $channel) + : $provider($keyCode, $workspace, $channel); if ($value !== null) { return $value; diff --git a/packages/core-php/src/Core/Config/ConfigService.php b/packages/core-php/src/Core/Config/ConfigService.php index 8c23533..de5ad78 100644 --- a/packages/core-php/src/Core/Config/ConfigService.php +++ b/packages/core-php/src/Core/Config/ConfigService.php @@ -26,13 +26,83 @@ use Core\Config\Models\ConfigValue; * Single hash: ConfigResolver::$values * Read path: hash lookup → lazy load scope → compute if needed * - * Usage: - * $config = app(ConfigService::class); - * $value = $config->get('cdn.bunny.api_key', $workspace); - * $config->set('cdn.bunny.api_key', 'new-value', $profile); + * ## Usage * - * // Module Boot.php - provide runtime value (no DB) - * $config->provide('mymodule.api_key', env('MYMODULE_API_KEY')); + * ```php + * $config = app(ConfigService::class); + * $value = $config->get('cdn.bunny.api_key', $workspace); + * $config->set('cdn.bunny.api_key', 'new-value', $profile); + * + * // Module Boot.php - provide runtime value (no DB) + * $config->provide('mymodule.api_key', env('MYMODULE_API_KEY')); + * ``` + * + * ## Cache Invalidation Strategy + * + * The Config module uses a two-tier caching system: + * + * ### Tier 1: In-Memory Hash (Process-Scoped) + * - `ConfigResolver::$values` - Static array holding all config values + * - Cleared on process termination (dies with the request) + * - Cleared explicitly via `ConfigResolver::clearAll()` or `ConfigResolver::clear($key)` + * + * ### Tier 2: Database Resolved Table (Persistent) + * - `config_resolved` table - Materialised config resolution + * - Survives across requests, shared between all processes + * - Cleared via `ConfigResolved::clearScope()`, `clearWorkspace()`, or `clearKey()` + * + * ### Invalidation Triggers + * + * 1. **On Config Change (`set()`):** + * - Clears the specific key from both hash and database + * - Re-primes the key for the affected scope + * - Dispatches `ConfigChanged` event for module hooks + * + * 2. **On Lock/Unlock:** + * - Re-primes the key (lock affects all child scopes) + * - Dispatches `ConfigLocked` event + * + * 3. **Manual Invalidation:** + * - `invalidateWorkspace($workspace)` - Clears all config for a workspace + * - `invalidateKey($key)` - Clears a key across all scopes + * - Both dispatch `ConfigInvalidated` event + * + * 4. **Full Re-prime:** + * - `prime($workspace)` - Clears and recomputes all config for a scope + * - `primeAll()` - Primes system config + all workspaces (scheduled job) + * + * ### Lazy Loading + * + * When a key is not found in the hash: + * 1. If scope not loaded, `loadScope()` loads all resolved values for the scope + * 2. If still not found, `resolve()` computes and stores the value + * 3. Result is stored in both hash (for current request) and database (persistent) + * + * ### Events for Module Integration + * + * Modules can listen to cache events to refresh their own caches: + * - `ConfigChanged` - Fired when a config value is set/updated + * - `ConfigLocked` - Fired when a config value is locked + * - `ConfigInvalidated` - Fired when cache is manually invalidated + * + * ```php + * // In your module's Boot.php + * public static array $listens = [ + * ConfigChanged::class => 'onConfigChanged', + * ]; + * + * public function onConfigChanged(ConfigChanged $event): void + * { + * if ($event->keyCode === 'mymodule.api_key') { + * $this->refreshApiClient(); + * } + * } + * ``` + * + * @see ConfigResolver For the caching hash implementation + * @see ConfigResolved For the database cache model + * @see ConfigChanged Event fired on config changes + * @see ConfigInvalidated Event fired on cache invalidation */ class ConfigService { @@ -461,6 +531,20 @@ class ConfigService * * Populates both hash (process-scoped) and database (persistent). * + * ## When to Call Prime + * + * - After creating a new workspace + * - After bulk config changes (migrations, imports) + * - From a scheduled job (`config:prime` command) + * - After significant profile hierarchy changes + * + * ## What Prime Does + * + * 1. Clears existing resolved values (hash + DB) for the scope + * 2. Runs full resolution for all config keys + * 3. Stores results in both hash and database + * 4. Marks hash as "loaded" to prevent re-loading + * * @param object|null $workspace Workspace model instance or null for system scope */ public function prime(?object $workspace = null, string|Channel|null $channel = null): void @@ -567,6 +651,31 @@ class ConfigService * Clears both hash and database. Next read will lazy-prime. * Fires ConfigInvalidated event. * + * ## Cache Invalidation Behaviour + * + * This method performs a "soft" invalidation: + * - Clears the in-memory hash (immediate effect) + * - Clears the database resolved table (persistent effect) + * - Does NOT re-compute values immediately + * - Values are lazy-loaded on next read (lazy-prime) + * + * Use `prime()` instead if you need immediate re-computation. + * + * ## Listening for Invalidation + * + * ```php + * use Core\Config\Events\ConfigInvalidated; + * + * public function handle(ConfigInvalidated $event): void + * { + * if ($event->isFull()) { + * // Full invalidation - clear all module caches + * } elseif ($event->affectsKey('mymodule.setting')) { + * // Specific key was invalidated + * } + * } + * ``` + * * @param object|null $workspace Workspace model instance or null for system scope */ public function invalidateWorkspace(?object $workspace = null): void diff --git a/packages/core-php/src/Core/Config/ConfigVersioning.php b/packages/core-php/src/Core/Config/ConfigVersioning.php new file mode 100644 index 0000000..f3cf9f6 --- /dev/null +++ b/packages/core-php/src/Core/Config/ConfigVersioning.php @@ -0,0 +1,355 @@ +createVersion($workspace, 'Before CDN migration'); + * + * // Make changes... + * $config->set('cdn.provider', 'bunny', $profile); + * + * // Rollback if needed + * $versioning->rollback($version->id, $workspace); + * + * // Compare versions + * $diff = $versioning->compare($workspace, $oldVersionId, $newVersionId); + * ``` + * + * ## Version Structure + * + * Each version stores: + * - Scope (workspace/system) + * - Timestamp + * - Label/description + * - Full snapshot of all config values + * - Author (if available) + * + * @see ConfigService For runtime config access + * @see ConfigExporter For import/export operations + */ +class ConfigVersioning +{ + /** + * Maximum versions to keep per scope (configurable). + */ + protected int $maxVersions; + + public function __construct( + protected ConfigService $config, + protected ConfigExporter $exporter, + ) { + $this->maxVersions = (int) config('core.config.max_versions', 50); + } + + /** + * Create a new config version (snapshot). + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param string $label Version label/description + * @param string|null $author Author identifier (user ID, email, etc.) + * @return ConfigVersion The created version + */ + public function createVersion( + ?object $workspace = null, + string $label = '', + ?string $author = null, + ): ConfigVersion { + $profile = $this->getOrCreateProfile($workspace); + + // Get current config as JSON snapshot + $snapshot = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false); + + $version = ConfigVersion::create([ + 'profile_id' => $profile->id, + 'workspace_id' => $workspace?->id, + 'label' => $label ?: 'Version '.now()->format('Y-m-d H:i:s'), + 'snapshot' => $snapshot, + 'author' => $author ?? $this->getCurrentAuthor(), + 'created_at' => now(), + ]); + + // Enforce retention policy + $this->pruneOldVersions($profile->id); + + return $version; + } + + /** + * Rollback to a specific version. + * + * @param int $versionId Version ID to rollback to + * @param object|null $workspace Workspace model instance or null for system scope + * @param bool $createBackup Create a backup version before rollback (default: true) + * @return ImportResult Import result with stats + * + * @throws \InvalidArgumentException If version not found or scope mismatch + */ + public function rollback( + int $versionId, + ?object $workspace = null, + bool $createBackup = true, + ): ImportResult { + $version = ConfigVersion::find($versionId); + + if ($version === null) { + throw new \InvalidArgumentException("Version not found: {$versionId}"); + } + + // Verify scope matches + $workspaceId = $workspace?->id; + if ($version->workspace_id !== $workspaceId) { + throw new \InvalidArgumentException('Version scope does not match target scope'); + } + + // Create backup before rollback + if ($createBackup) { + $this->createVersion($workspace, 'Backup before rollback to version '.$versionId); + } + + // Import the snapshot + return $this->exporter->importJson($version->snapshot, $workspace); + } + + /** + * Get all versions for a scope. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param int $limit Maximum versions to return + * @return Collection + */ + public function getVersions(?object $workspace = null, int $limit = 20): Collection + { + $workspaceId = $workspace?->id; + + return ConfigVersion::where('workspace_id', $workspaceId) + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + } + + /** + * Get a specific version. + * + * @param int $versionId Version ID + */ + public function getVersion(int $versionId): ?ConfigVersion + { + return ConfigVersion::find($versionId); + } + + /** + * Compare two versions. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param int $oldVersionId Older version ID + * @param int $newVersionId Newer version ID + * @return VersionDiff Difference between versions + * + * @throws \InvalidArgumentException If versions not found + */ + public function compare(?object $workspace, int $oldVersionId, int $newVersionId): VersionDiff + { + $oldVersion = ConfigVersion::find($oldVersionId); + $newVersion = ConfigVersion::find($newVersionId); + + if ($oldVersion === null) { + throw new \InvalidArgumentException("Old version not found: {$oldVersionId}"); + } + + if ($newVersion === null) { + throw new \InvalidArgumentException("New version not found: {$newVersionId}"); + } + + // Parse snapshots + $oldData = json_decode($oldVersion->snapshot, true)['values'] ?? []; + $newData = json_decode($newVersion->snapshot, true)['values'] ?? []; + + return $this->computeDiff($oldData, $newData); + } + + /** + * Compare current state with a version. + * + * @param object|null $workspace Workspace model instance or null for system scope + * @param int $versionId Version ID to compare against + * @return VersionDiff Difference between version and current state + * + * @throws \InvalidArgumentException If version not found + */ + public function compareWithCurrent(?object $workspace, int $versionId): VersionDiff + { + $version = ConfigVersion::find($versionId); + + if ($version === null) { + throw new \InvalidArgumentException("Version not found: {$versionId}"); + } + + // Get current state + $currentJson = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false); + $currentData = json_decode($currentJson, true)['values'] ?? []; + + // Get version state + $versionData = json_decode($version->snapshot, true)['values'] ?? []; + + return $this->computeDiff($versionData, $currentData); + } + + /** + * Compute difference between two value arrays. + * + * @param array $oldValues + * @param array $newValues + */ + protected function computeDiff(array $oldValues, array $newValues): VersionDiff + { + $diff = new VersionDiff; + + // Index by key + $oldByKey = collect($oldValues)->keyBy('key'); + $newByKey = collect($newValues)->keyBy('key'); + + // Find added keys (in new but not in old) + foreach ($newByKey as $key => $newValue) { + if (! $oldByKey->has($key)) { + $diff->addAdded($key, $newValue['value']); + } + } + + // Find removed keys (in old but not in new) + foreach ($oldByKey as $key => $oldValue) { + if (! $newByKey->has($key)) { + $diff->addRemoved($key, $oldValue['value']); + } + } + + // Find changed keys (in both but different) + foreach ($oldByKey as $key => $oldValue) { + if ($newByKey->has($key)) { + $newValue = $newByKey[$key]; + if ($oldValue['value'] !== $newValue['value']) { + $diff->addChanged($key, $oldValue['value'], $newValue['value']); + } + if (($oldValue['locked'] ?? false) !== ($newValue['locked'] ?? false)) { + $diff->addLockChanged($key, $oldValue['locked'] ?? false, $newValue['locked'] ?? false); + } + } + } + + return $diff; + } + + /** + * Delete a version. + * + * @param int $versionId Version ID + * + * @throws \InvalidArgumentException If version not found + */ + public function deleteVersion(int $versionId): void + { + $version = ConfigVersion::find($versionId); + + if ($version === null) { + throw new \InvalidArgumentException("Version not found: {$versionId}"); + } + + $version->delete(); + } + + /** + * Prune old versions beyond retention limit. + * + * @param int $profileId Profile ID + */ + protected function pruneOldVersions(int $profileId): void + { + $versions = ConfigVersion::where('profile_id', $profileId) + ->orderByDesc('created_at') + ->get(); + + if ($versions->count() > $this->maxVersions) { + $toDelete = $versions->slice($this->maxVersions); + foreach ($toDelete as $version) { + $version->delete(); + } + } + } + + /** + * Get or create profile for a workspace (or system). + */ + protected function getOrCreateProfile(?object $workspace): ConfigProfile + { + if ($workspace !== null) { + return ConfigProfile::ensureWorkspace($workspace->id); + } + + return ConfigProfile::ensureSystem(); + } + + /** + * Get current author for version attribution. + */ + protected function getCurrentAuthor(): ?string + { + // Try to get authenticated user + if (function_exists('auth') && auth()->check()) { + $user = auth()->user(); + + return $user->email ?? $user->name ?? (string) $user->id; + } + + // Return null if no user context + return null; + } + + /** + * Set maximum versions to keep per scope. + * + * @param int $max Maximum versions + */ + public function setMaxVersions(int $max): void + { + $this->maxVersions = max(1, $max); + } + + /** + * Get maximum versions to keep per scope. + */ + public function getMaxVersions(): int + { + return $this->maxVersions; + } +} diff --git a/packages/core-php/src/Core/Config/Console/ConfigExportCommand.php b/packages/core-php/src/Core/Config/Console/ConfigExportCommand.php new file mode 100644 index 0000000..9a55a74 --- /dev/null +++ b/packages/core-php/src/Core/Config/Console/ConfigExportCommand.php @@ -0,0 +1,114 @@ +argument('file'); + $workspaceSlug = $this->option('workspace'); + $category = $this->option('category'); + $includeSensitive = $this->option('include-sensitive'); + $includeKeys = ! $this->option('no-keys'); + + // Resolve workspace + $workspace = null; + if ($workspaceSlug) { + if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + $this->components->error('Tenant module not installed. Cannot export workspace config.'); + + return self::FAILURE; + } + + $workspace = \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->components->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + } + + // Warn about sensitive data + if ($includeSensitive) { + $this->components->warn('WARNING: Export will include sensitive values. Handle the file securely!'); + + if (! $this->confirm('Are you sure you want to include sensitive values?')) { + $this->components->info('Export cancelled.'); + + return self::SUCCESS; + } + } + + // Determine format from extension + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $format = match ($extension) { + 'yaml', 'yml' => 'YAML', + default => 'JSON', + }; + + $this->components->task("Exporting {$format} config", function () use ($exporter, $file, $workspace, $includeSensitive, $includeKeys, $category) { + $content = match (strtolower(pathinfo($file, PATHINFO_EXTENSION))) { + 'yaml', 'yml' => $exporter->exportYaml($workspace, $includeSensitive, $includeKeys, $category), + default => $exporter->exportJson($workspace, $includeSensitive, $includeKeys, $category), + }; + + file_put_contents($file, $content); + }); + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + $this->components->info("Config exported to {$file} ({$scope})"); + + return self::SUCCESS; + } + + /** + * Get autocompletion suggestions. + * + * @return array + */ + public function complete(CompletionInput $input, array $suggestions): array + { + if ($input->mustSuggestOptionValuesFor('workspace')) { + if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + return \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray(); + } + } + + if ($input->mustSuggestOptionValuesFor('category')) { + return \Core\Config\Models\ConfigKey::distinct()->pluck('category')->toArray(); + } + + return $suggestions; + } +} diff --git a/packages/core-php/src/Core/Config/Console/ConfigImportCommand.php b/packages/core-php/src/Core/Config/Console/ConfigImportCommand.php new file mode 100644 index 0000000..55d37ba --- /dev/null +++ b/packages/core-php/src/Core/Config/Console/ConfigImportCommand.php @@ -0,0 +1,188 @@ +argument('file'); + $workspaceSlug = $this->option('workspace'); + $dryRun = $this->option('dry-run'); + $skipBackup = $this->option('no-backup'); + $force = $this->option('force'); + + // Check file exists + if (! file_exists($file)) { + $this->components->error("File not found: {$file}"); + + return self::FAILURE; + } + + // Resolve workspace + $workspace = null; + if ($workspaceSlug) { + if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + $this->components->error('Tenant module not installed. Cannot import workspace config.'); + + return self::FAILURE; + } + + $workspace = \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->components->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + } + + // Read file content + $content = file_get_contents($file); + if ($content === false) { + $this->components->error("Failed to read file: {$file}"); + + return self::FAILURE; + } + + // Determine format from extension + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $format = match ($extension) { + 'yaml', 'yml' => 'YAML', + default => 'JSON', + }; + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + + if ($dryRun) { + $this->components->info("Dry-run import from {$file} ({$scope}):"); + } else { + if (! $force) { + $this->components->warn("This will import config from {$file} to {$scope}."); + + if (! $this->confirm('Are you sure you want to continue?')) { + $this->components->info('Import cancelled.'); + + return self::SUCCESS; + } + } + + // Create backup before import + if (! $skipBackup && ! $dryRun) { + $this->components->task('Creating backup version', function () use ($versioning, $workspace, $file) { + $versioning->createVersion( + $workspace, + 'Backup before import from '.basename($file) + ); + }); + } + } + + // Perform import + $result = null; + $this->components->task("Importing {$format} config", function () use ($exporter, $content, $extension, $workspace, $dryRun, &$result) { + $result = match ($extension) { + 'yaml', 'yml' => $exporter->importYaml($content, $workspace, $dryRun), + default => $exporter->importJson($content, $workspace, $dryRun), + }; + }); + + // Show results + $this->newLine(); + + if ($dryRun) { + $this->components->info('Dry-run results (no changes applied):'); + } + + // Display created items + if ($result->createdCount() > 0) { + $this->components->twoColumnDetail('Created', $result->createdCount().' items'); + foreach ($result->getCreated() as $item) { + $this->components->bulletList(["{$item['type']}: {$item['code']}"]); + } + } + + // Display updated items + if ($result->updatedCount() > 0) { + $this->components->twoColumnDetail('Updated', $result->updatedCount().' items'); + foreach ($result->getUpdated() as $item) { + $this->components->bulletList(["{$item['type']}: {$item['code']}"]); + } + } + + // Display skipped items + if ($result->skippedCount() > 0) { + $this->components->twoColumnDetail('Skipped', $result->skippedCount().' items'); + foreach ($result->getSkipped() as $reason) { + $this->components->bulletList([$reason]); + } + } + + // Display errors + if ($result->hasErrors()) { + $this->newLine(); + $this->components->error('Errors:'); + foreach ($result->getErrors() as $error) { + $this->components->bulletList(["{$error}"]); + } + + return self::FAILURE; + } + + $this->newLine(); + + if ($dryRun) { + $this->components->info("Dry-run complete: {$result->getSummary()}"); + } else { + $this->components->info("Import complete: {$result->getSummary()}"); + } + + return self::SUCCESS; + } + + /** + * Get autocompletion suggestions. + * + * @return array + */ + public function complete(CompletionInput $input, array $suggestions): array + { + if ($input->mustSuggestOptionValuesFor('workspace')) { + if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + return \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray(); + } + } + + return $suggestions; + } +} diff --git a/packages/core-php/src/Core/Config/Console/ConfigVersionCommand.php b/packages/core-php/src/Core/Config/Console/ConfigVersionCommand.php new file mode 100644 index 0000000..ab7a83f --- /dev/null +++ b/packages/core-php/src/Core/Config/Console/ConfigVersionCommand.php @@ -0,0 +1,420 @@ +argument('action'); + $arg1 = $this->argument('arg1'); + $arg2 = $this->argument('arg2'); + $workspaceSlug = $this->option('workspace'); + + // Resolve workspace + $workspace = null; + if ($workspaceSlug) { + if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + $this->components->error('Tenant module not installed. Cannot manage workspace versions.'); + + return self::FAILURE; + } + + $workspace = \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first(); + + if (! $workspace) { + $this->components->error("Workspace not found: {$workspaceSlug}"); + + return self::FAILURE; + } + } + + return match ($action) { + 'list' => $this->listVersions($versioning, $workspace), + 'create' => $this->createVersion($versioning, $workspace, $arg1), + 'show' => $this->showVersion($versioning, $arg1), + 'rollback' => $this->rollbackVersion($versioning, $workspace, $arg1), + 'compare' => $this->compareVersions($versioning, $workspace, $arg1, $arg2), + 'diff' => $this->diffWithCurrent($versioning, $workspace, $arg1), + 'delete' => $this->deleteVersion($versioning, $arg1), + default => $this->invalidAction($action), + }; + } + + /** + * List versions. + */ + protected function listVersions(ConfigVersioning $versioning, ?object $workspace): int + { + $limit = (int) $this->option('limit'); + $versions = $versioning->getVersions($workspace, $limit); + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + $this->components->info("Config versions for {$scope}:"); + + if ($versions->isEmpty()) { + $this->components->warn('No versions found.'); + + return self::SUCCESS; + } + + $rows = $versions->map(fn (ConfigVersion $v) => [ + $v->id, + $v->label, + $v->author ?? '-', + $v->created_at->format('Y-m-d H:i:s'), + $v->created_at->diffForHumans(), + ])->toArray(); + + $this->table( + ['ID', 'Label', 'Author', 'Created', 'Age'], + $rows + ); + + return self::SUCCESS; + } + + /** + * Create a new version. + */ + protected function createVersion(ConfigVersioning $versioning, ?object $workspace, ?string $label): int + { + $label = $label ?? 'Manual snapshot'; + + $version = null; + $this->components->task("Creating version: {$label}", function () use ($versioning, $workspace, $label, &$version) { + $version = $versioning->createVersion($workspace, $label); + }); + + $this->components->info("Version created: ID {$version->id}"); + + return self::SUCCESS; + } + + /** + * Show version details. + */ + protected function showVersion(ConfigVersioning $versioning, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $version = $versioning->getVersion((int) $versionId); + + if ($version === null) { + $this->components->error("Version not found: {$versionId}"); + + return self::FAILURE; + } + + $this->components->info("Version #{$version->id}: {$version->label}"); + $this->components->twoColumnDetail('Created', $version->created_at->format('Y-m-d H:i:s')); + $this->components->twoColumnDetail('Author', $version->author ?? '-'); + $this->components->twoColumnDetail('Workspace ID', $version->workspace_id ?? 'system'); + + $values = $version->getValues(); + $this->newLine(); + $this->components->info('Values ('.count($values).' items):'); + + $rows = array_map(function ($v) { + $displayValue = match (true) { + is_array($v['value']) => '[array]', + is_null($v['value']) => 'null', + is_bool($v['value']) => $v['value'] ? 'true' : 'false', + is_string($v['value']) && strlen($v['value']) > 40 => substr($v['value'], 0, 37).'...', + default => (string) $v['value'], + }; + + return [ + $v['key'], + $displayValue, + $v['locked'] ?? false ? 'LOCKED' : '', + ]; + }, $values); + + $this->table(['Key', 'Value', 'Status'], $rows); + + return self::SUCCESS; + } + + /** + * Rollback to a version. + */ + protected function rollbackVersion(ConfigVersioning $versioning, ?object $workspace, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $version = $versioning->getVersion((int) $versionId); + + if ($version === null) { + $this->components->error("Version not found: {$versionId}"); + + return self::FAILURE; + } + + $scope = $workspace ? "workspace: {$workspace->slug}" : 'system'; + + if (! $this->option('force')) { + $this->components->warn("This will restore config to version #{$version->id}: {$version->label}"); + $this->components->warn("Scope: {$scope}"); + + if (! $this->confirm('Are you sure you want to rollback?')) { + $this->components->info('Rollback cancelled.'); + + return self::SUCCESS; + } + } + + $createBackup = ! $this->option('no-backup'); + $result = null; + + $this->components->task('Rolling back config', function () use ($versioning, $workspace, $versionId, $createBackup, &$result) { + $result = $versioning->rollback((int) $versionId, $workspace, $createBackup); + }); + + $this->newLine(); + $this->components->info("Rollback complete: {$result->getSummary()}"); + + if ($createBackup) { + $this->components->info('A backup version was created before rollback.'); + } + + return self::SUCCESS; + } + + /** + * Compare two versions. + */ + protected function compareVersions(ConfigVersioning $versioning, ?object $workspace, ?string $oldId, ?string $newId): int + { + if ($oldId === null || $newId === null) { + $this->components->error('Two version IDs required for comparison.'); + + return self::FAILURE; + } + + $diff = $versioning->compare($workspace, (int) $oldId, (int) $newId); + + $this->components->info("Comparing version #{$oldId} to #{$newId}:"); + $this->newLine(); + + if ($diff->isEmpty()) { + $this->components->info('No differences found.'); + + return self::SUCCESS; + } + + $this->displayDiff($diff); + + return self::SUCCESS; + } + + /** + * Compare version with current state. + */ + protected function diffWithCurrent(ConfigVersioning $versioning, ?object $workspace, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $diff = $versioning->compareWithCurrent($workspace, (int) $versionId); + + $this->components->info("Comparing version #{$versionId} to current state:"); + $this->newLine(); + + if ($diff->isEmpty()) { + $this->components->info('No differences found. Current state matches the version.'); + + return self::SUCCESS; + } + + $this->displayDiff($diff); + + return self::SUCCESS; + } + + /** + * Display a diff. + */ + protected function displayDiff(\Core\Config\VersionDiff $diff): void + { + $this->components->info("Summary: {$diff->getSummary()}"); + $this->newLine(); + + // Added + if (count($diff->getAdded()) > 0) { + $this->components->twoColumnDetail('Added', count($diff->getAdded()).' keys'); + foreach ($diff->getAdded() as $item) { + $this->line(" + {$item['key']}"); + } + $this->newLine(); + } + + // Removed + if (count($diff->getRemoved()) > 0) { + $this->components->twoColumnDetail('Removed', count($diff->getRemoved()).' keys'); + foreach ($diff->getRemoved() as $item) { + $this->line(" - {$item['key']}"); + } + $this->newLine(); + } + + // Changed + if (count($diff->getChanged()) > 0) { + $this->components->twoColumnDetail('Changed', count($diff->getChanged()).' keys'); + foreach ($diff->getChanged() as $item) { + $oldDisplay = $this->formatValue($item['old']); + $newDisplay = $this->formatValue($item['new']); + $this->line(" ~ {$item['key']}"); + $this->line(" old: {$oldDisplay}"); + $this->line(" new: {$newDisplay}"); + } + $this->newLine(); + } + + // Lock changes + if (count($diff->getLockChanged()) > 0) { + $this->components->twoColumnDetail('Lock Changed', count($diff->getLockChanged()).' keys'); + foreach ($diff->getLockChanged() as $item) { + $oldLock = $item['old'] ? 'LOCKED' : 'unlocked'; + $newLock = $item['new'] ? 'LOCKED' : 'unlocked'; + $this->line(" * {$item['key']}: {$oldLock} -> {$newLock}"); + } + } + } + + /** + * Format a value for display. + */ + protected function formatValue(mixed $value): string + { + return match (true) { + is_array($value) => '[array]', + is_null($value) => 'null', + is_bool($value) => $value ? 'true' : 'false', + is_string($value) && strlen($value) > 50 => '"'.substr($value, 0, 47).'..."', + default => (string) $value, + }; + } + + /** + * Delete a version. + */ + protected function deleteVersion(ConfigVersioning $versioning, ?string $versionId): int + { + if ($versionId === null) { + $this->components->error('Version ID required.'); + + return self::FAILURE; + } + + $version = $versioning->getVersion((int) $versionId); + + if ($version === null) { + $this->components->error("Version not found: {$versionId}"); + + return self::FAILURE; + } + + if (! $this->option('force')) { + $this->components->warn("This will permanently delete version #{$version->id}: {$version->label}"); + + if (! $this->confirm('Are you sure you want to delete this version?')) { + $this->components->info('Delete cancelled.'); + + return self::SUCCESS; + } + } + + $versioning->deleteVersion((int) $versionId); + $this->components->info("Version #{$versionId} deleted."); + + return self::SUCCESS; + } + + /** + * Handle invalid action. + */ + protected function invalidAction(string $action): int + { + $this->components->error("Invalid action: {$action}"); + $this->newLine(); + $this->components->info('Available actions:'); + $this->components->bulletList([ + 'list - List all versions', + 'create - Create a new version snapshot', + 'show - Show version details', + 'rollback - Restore config to a version', + 'compare - Compare two versions', + 'diff - Compare version with current state', + 'delete - Delete a version', + ]); + + return self::FAILURE; + } + + /** + * Get autocompletion suggestions. + * + * @return array + */ + public function complete(CompletionInput $input, array $suggestions): array + { + if ($input->mustSuggestArgumentValuesFor('action')) { + return ['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete']; + } + + if ($input->mustSuggestOptionValuesFor('workspace')) { + if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + return \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray(); + } + } + + return $suggestions; + } +} diff --git a/packages/core-php/src/Core/Config/Contracts/ConfigProvider.php b/packages/core-php/src/Core/Config/Contracts/ConfigProvider.php new file mode 100644 index 0000000..2e1418a --- /dev/null +++ b/packages/core-php/src/Core/Config/Contracts/ConfigProvider.php @@ -0,0 +1,107 @@ +registerProvider('bio.*', new BioConfigProvider()); + * ``` + * + * ## Example Implementation + * + * ```php + * class BioConfigProvider implements ConfigProvider + * { + * public function pattern(): string + * { + * return 'bio.*'; + * } + * + * public function resolve( + * string $keyCode, + * ?object $workspace, + * string|Channel|null $channel + * ): mixed { + * // Extract the specific key (e.g., "bio.theme" -> "theme") + * $subKey = substr($keyCode, 4); + * + * return match ($subKey) { + * 'theme' => $this->getTheme($workspace), + * 'layout' => $this->getLayout($workspace), + * default => null, + * }; + * } + * } + * ``` + * + * @package Core\Config\Contracts + * + * @see \Core\Config\ConfigResolver::registerProvider() + */ +interface ConfigProvider +{ + /** + * Get the key pattern this provider handles. + * + * Supports wildcards: + * - `*` matches any characters + * - `bio.*` matches "bio.theme", "bio.colors.primary", etc. + * + * @return string The key pattern (e.g., 'bio.*', 'theme.colors.*') + */ + public function pattern(): string; + + /** + * Resolve a config value for the given key. + * + * Called when a key matches this provider's pattern. Return null if the + * provider cannot supply a value for this specific key, allowing other + * providers or the database to supply the value. + * + * @param string $keyCode The full config key being resolved + * @param object|null $workspace Workspace model instance or null for system scope + * @param string|Channel|null $channel Channel code or object + * @return mixed The config value, or null if not provided + */ + public function resolve( + string $keyCode, + ?object $workspace, + string|Channel|null $channel + ): mixed; +} diff --git a/packages/core-php/src/Core/Config/Events/ConfigChanged.php b/packages/core-php/src/Core/Config/Events/ConfigChanged.php index dd90669..5039788 100644 --- a/packages/core-php/src/Core/Config/Events/ConfigChanged.php +++ b/packages/core-php/src/Core/Config/Events/ConfigChanged.php @@ -18,7 +18,59 @@ use Illuminate\Queue\SerializesModels; /** * Fired when a config value is set or updated. * - * Modules can listen to invalidate caches or trigger side effects. + * This event is dispatched after `ConfigService::set()` is called, + * providing both the new value and the previous value for comparison. + * + * ## Event Properties + * + * - `keyCode` - The config key that changed (e.g., 'cdn.bunny.api_key') + * - `value` - The new value + * - `previousValue` - The previous value (null if key was not set before) + * - `profile` - The ConfigProfile where the value was set + * - `channelId` - The channel ID (null if not channel-specific) + * + * ## Listening to Config Changes + * + * ```php + * use Core\Config\Events\ConfigChanged; + * + * class MyModuleListener + * { + * public function handle(ConfigChanged $event): void + * { + * if ($event->keyCode === 'cdn.bunny.api_key') { + * // API key changed - refresh CDN client + * $this->cdnService->refreshClient(); + * } + * + * // Check for prefix matches + * if (str_starts_with($event->keyCode, 'mymodule.')) { + * Cache::tags(['mymodule'])->flush(); + * } + * } + * } + * ``` + * + * ## In Module Boot.php + * + * ```php + * use Core\Config\Events\ConfigChanged; + * + * class Boot + * { + * public static array $listens = [ + * ConfigChanged::class => 'onConfigChanged', + * ]; + * + * public function onConfigChanged(ConfigChanged $event): void + * { + * // Handle config changes + * } + * } + * ``` + * + * @see ConfigInvalidated For cache invalidation events + * @see ConfigLocked For when config values are locked */ class ConfigChanged { diff --git a/packages/core-php/src/Core/Config/Events/ConfigInvalidated.php b/packages/core-php/src/Core/Config/Events/ConfigInvalidated.php index 4fb0f53..ebdd10d 100644 --- a/packages/core-php/src/Core/Config/Events/ConfigInvalidated.php +++ b/packages/core-php/src/Core/Config/Events/ConfigInvalidated.php @@ -17,7 +17,48 @@ use Illuminate\Queue\SerializesModels; /** * Fired when config cache is invalidated. * - * Modules can listen to refresh their own caches. + * This event is dispatched when the config cache is manually cleared, + * allowing modules to refresh their own caches that depend on config values. + * + * ## Invalidation Scope + * + * The event includes context about what was invalidated: + * - `keyCode` - Specific key that was invalidated (null = all keys) + * - `workspaceId` - Workspace scope (null = system scope) + * - `channelId` - Channel scope (null = all channels) + * + * ## Listening to Invalidation + * + * ```php + * use Core\Config\Events\ConfigInvalidated; + * + * class MyModuleListener + * { + * public function handle(ConfigInvalidated $event): void + * { + * // Check if this affects our module's config + * if ($event->affectsKey('mymodule.api_key')) { + * // Clear our module's cached API client + * Cache::forget('mymodule:api_client'); + * } + * + * // Or handle full invalidation + * if ($event->isFull()) { + * // Clear all module caches + * Cache::tags(['mymodule'])->flush(); + * } + * } + * } + * ``` + * + * ## Invalidation Sources + * + * This event is fired by: + * - `ConfigService::invalidateWorkspace()` - Clears workspace config + * - `ConfigService::invalidateKey()` - Clears a specific key + * + * @see ConfigChanged For changes to specific config values + * @see ConfigLocked For when config values are locked */ class ConfigInvalidated { diff --git a/packages/core-php/src/Core/Config/ImportResult.php b/packages/core-php/src/Core/Config/ImportResult.php new file mode 100644 index 0000000..06e76c2 --- /dev/null +++ b/packages/core-php/src/Core/Config/ImportResult.php @@ -0,0 +1,241 @@ + + */ + protected array $created = []; + + /** + * Items updated during import. + * + * @var array + */ + protected array $updated = []; + + /** + * Items skipped during import. + * + * @var array + */ + protected array $skipped = []; + + /** + * Errors encountered during import. + * + * @var array + */ + protected array $errors = []; + + /** + * Add a created item. + * + * @param string $code The item code/identifier + * @param string $type The item type (key, value) + */ + public function addCreated(string $code, string $type): void + { + $this->created[] = ['code' => $code, 'type' => $type]; + } + + /** + * Add an updated item. + * + * @param string $code The item code/identifier + * @param string $type The item type (key, value) + */ + public function addUpdated(string $code, string $type): void + { + $this->updated[] = ['code' => $code, 'type' => $type]; + } + + /** + * Add a skipped item. + * + * @param string $reason Reason for skipping + */ + public function addSkipped(string $reason): void + { + $this->skipped[] = $reason; + } + + /** + * Add an error. + * + * @param string $message Error message + */ + public function addError(string $message): void + { + $this->errors[] = $message; + } + + /** + * Get created items. + * + * @return array + */ + public function getCreated(): array + { + return $this->created; + } + + /** + * Get updated items. + * + * @return array + */ + public function getUpdated(): array + { + return $this->updated; + } + + /** + * Get skipped items. + * + * @return array + */ + public function getSkipped(): array + { + return $this->skipped; + } + + /** + * Get errors. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Check if import was successful (no errors). + */ + public function isSuccessful(): bool + { + return empty($this->errors); + } + + /** + * Check if any changes were made. + */ + public function hasChanges(): bool + { + return ! empty($this->created) || ! empty($this->updated); + } + + /** + * Check if there were any errors. + */ + public function hasErrors(): bool + { + return ! empty($this->errors); + } + + /** + * Get total count of created items. + */ + public function createdCount(): int + { + return count($this->created); + } + + /** + * Get total count of updated items. + */ + public function updatedCount(): int + { + return count($this->updated); + } + + /** + * Get total count of skipped items. + */ + public function skippedCount(): int + { + return count($this->skipped); + } + + /** + * Get total count of errors. + */ + public function errorCount(): int + { + return count($this->errors); + } + + /** + * Get summary string. + */ + public function getSummary(): string + { + $parts = []; + + if ($this->createdCount() > 0) { + $parts[] = "{$this->createdCount()} created"; + } + + if ($this->updatedCount() > 0) { + $parts[] = "{$this->updatedCount()} updated"; + } + + if ($this->skippedCount() > 0) { + $parts[] = "{$this->skippedCount()} skipped"; + } + + if ($this->errorCount() > 0) { + $parts[] = "{$this->errorCount()} errors"; + } + + if (empty($parts)) { + return 'No changes'; + } + + return implode(', ', $parts); + } + + /** + * Convert to array for JSON serialization. + * + * @return array + */ + public function toArray(): array + { + return [ + 'success' => $this->isSuccessful(), + 'summary' => $this->getSummary(), + 'created' => $this->created, + 'updated' => $this->updated, + 'skipped' => $this->skipped, + 'errors' => $this->errors, + 'counts' => [ + 'created' => $this->createdCount(), + 'updated' => $this->updatedCount(), + 'skipped' => $this->skippedCount(), + 'errors' => $this->errorCount(), + ], + ]; + } +} diff --git a/packages/core-php/src/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php new file mode 100644 index 0000000..91770b0 --- /dev/null +++ b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000002_add_soft_deletes_to_config_profiles.php @@ -0,0 +1,35 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('config_profiles', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/packages/core-php/src/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php new file mode 100644 index 0000000..878b5d1 --- /dev/null +++ b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000003_add_is_sensitive_to_config_keys.php @@ -0,0 +1,38 @@ +boolean('is_sensitive')->default(false)->after('default_value'); + $table->index('is_sensitive'); + }); + } + + public function down(): void + { + Schema::table('config_keys', function (Blueprint $table) { + $table->dropIndex(['is_sensitive']); + $table->dropColumn('is_sensitive'); + }); + } +}; diff --git a/packages/core-php/src/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php new file mode 100644 index 0000000..3b371cb --- /dev/null +++ b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000004_create_config_versions_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('profile_id') + ->constrained('config_profiles') + ->cascadeOnDelete(); + $table->unsignedBigInteger('workspace_id')->nullable()->index(); + $table->string('label'); + $table->longText('snapshot'); // JSON snapshot of all config values + $table->string('author')->nullable(); + $table->timestamp('created_at'); + + $table->index(['profile_id', 'created_at']); + $table->index(['workspace_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('config_versions'); + } +}; diff --git a/packages/core-php/src/Core/Config/Models/ConfigKey.php b/packages/core-php/src/Core/Config/Models/ConfigKey.php index 239c8bf..d5e1205 100644 --- a/packages/core-php/src/Core/Config/Models/ConfigKey.php +++ b/packages/core-php/src/Core/Config/Models/ConfigKey.php @@ -28,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $category * @property string|null $description * @property mixed $default_value + * @property bool $is_sensitive * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at */ @@ -42,13 +43,23 @@ class ConfigKey extends Model 'category', 'description', 'default_value', + 'is_sensitive', ]; protected $casts = [ 'type' => ConfigType::class, 'default_value' => 'json', + 'is_sensitive' => 'boolean', ]; + /** + * Check if this key contains sensitive data that should be encrypted. + */ + public function isSensitive(): bool + { + return $this->is_sensitive ?? false; + } + /** * Parent key (for hierarchical grouping). */ diff --git a/packages/core-php/src/Core/Config/Models/ConfigProfile.php b/packages/core-php/src/Core/Config/Models/ConfigProfile.php index 727e23b..9111a1a 100644 --- a/packages/core-php/src/Core/Config/Models/ConfigProfile.php +++ b/packages/core-php/src/Core/Config/Models/ConfigProfile.php @@ -14,6 +14,7 @@ use Core\Config\Enums\ScopeType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * Configuration profile (M2 layer). @@ -29,9 +30,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property int $priority * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at + * @property \Carbon\Carbon|null $deleted_at */ class ConfigProfile extends Model { + use SoftDeletes; + protected $table = 'config_profiles'; protected $fillable = [ diff --git a/packages/core-php/src/Core/Config/Models/ConfigValue.php b/packages/core-php/src/Core/Config/Models/ConfigValue.php index 053bb87..09bd7c2 100644 --- a/packages/core-php/src/Core/Config/Models/ConfigValue.php +++ b/packages/core-php/src/Core/Config/Models/ConfigValue.php @@ -14,6 +14,7 @@ use Core\Config\ConfigResolver; use Core\Config\Enums\ScopeType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Crypt; /** * Configuration value (junction table). @@ -46,10 +47,79 @@ class ConfigValue extends Model ]; protected $casts = [ - 'value' => 'json', 'locked' => 'boolean', ]; + /** + * Encrypted value marker prefix. + * + * Used to detect if a stored value is encrypted. + */ + protected const ENCRYPTED_PREFIX = 'encrypted:'; + + /** + * Get the value attribute with automatic decryption for sensitive keys. + */ + public function getValueAttribute(mixed $value): mixed + { + if ($value === null) { + return null; + } + + // Decode JSON first + $decoded = is_string($value) ? json_decode($value, true) : $value; + + // Check if this is an encrypted value + if (is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX)) { + try { + $encrypted = substr($decoded, strlen(self::ENCRYPTED_PREFIX)); + + return json_decode(Crypt::decryptString($encrypted), true); + } catch (\Illuminate\Contracts\Encryption\DecryptException) { + // Return null if decryption fails (key rotation, corruption, etc.) + return null; + } + } + + return $decoded; + } + + /** + * Set the value attribute with automatic encryption for sensitive keys. + */ + public function setValueAttribute(mixed $value): void + { + // Check if the key is sensitive (need to load it if not already) + $key = $this->relationLoaded('key') + ? $this->getRelation('key') + : ($this->key_id ? ConfigKey::find($this->key_id) : null); + + if ($key?->isSensitive() && $value !== null) { + // Encrypt the value + $jsonValue = json_encode($value); + $encrypted = Crypt::encryptString($jsonValue); + $this->attributes['value'] = json_encode(self::ENCRYPTED_PREFIX . $encrypted); + } else { + // Store as regular JSON + $this->attributes['value'] = json_encode($value); + } + } + + /** + * Check if the current stored value is encrypted. + */ + public function isEncrypted(): bool + { + $raw = $this->attributes['value'] ?? null; + if ($raw === null) { + return false; + } + + $decoded = json_decode($raw, true); + + return is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX); + } + /** * The profile this value belongs to. */ diff --git a/packages/core-php/src/Core/Config/Models/ConfigVersion.php b/packages/core-php/src/Core/Config/Models/ConfigVersion.php new file mode 100644 index 0000000..48087b8 --- /dev/null +++ b/packages/core-php/src/Core/Config/Models/ConfigVersion.php @@ -0,0 +1,184 @@ + 'datetime', + ]; + + /** + * The profile this version belongs to. + */ + public function profile(): BelongsTo + { + return $this->belongsTo(ConfigProfile::class, 'profile_id'); + } + + /** + * Workspace this version is for (null = system). + * + * Requires Core\Mod\Tenant module to be installed. + */ + public function workspace(): BelongsTo + { + if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) { + return $this->belongsTo(\Core\Mod\Tenant\Models\Workspace::class); + } + + // Return a null relationship when Tenant module is not installed + return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0'); + } + + /** + * Get the parsed snapshot data. + * + * @return array + */ + public function getSnapshotData(): array + { + return json_decode($this->snapshot, true) ?? []; + } + + /** + * Get the config values from the snapshot. + * + * @return array + */ + public function getValues(): array + { + $data = $this->getSnapshotData(); + + return $data['values'] ?? []; + } + + /** + * Get a specific value from the snapshot. + * + * @param string $key Config key code + * @return mixed|null The value or null if not found + */ + public function getValue(string $key): mixed + { + $values = $this->getValues(); + + foreach ($values as $value) { + if ($value['key'] === $key) { + return $value['value']; + } + } + + return null; + } + + /** + * Check if a key exists in the snapshot. + * + * @param string $key Config key code + */ + public function hasKey(string $key): bool + { + $values = $this->getValues(); + + foreach ($values as $value) { + if ($value['key'] === $key) { + return true; + } + } + + return false; + } + + /** + * Get versions for a scope. + * + * @param int|null $workspaceId Workspace ID or null for system + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function forScope(?int $workspaceId = null): \Illuminate\Database\Eloquent\Collection + { + return static::where('workspace_id', $workspaceId) + ->orderByDesc('created_at') + ->get(); + } + + /** + * Get the latest version for a scope. + * + * @param int|null $workspaceId Workspace ID or null for system + */ + public static function latest(?int $workspaceId = null): ?self + { + return static::where('workspace_id', $workspaceId) + ->orderByDesc('created_at') + ->first(); + } + + /** + * Get versions created by a specific author. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function byAuthor(string $author): \Illuminate\Database\Eloquent\Collection + { + return static::where('author', $author) + ->orderByDesc('created_at') + ->get(); + } + + /** + * Get versions created within a date range. + * + * @param \Carbon\Carbon $from Start date + * @param \Carbon\Carbon $to End date + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function inDateRange(\Carbon\Carbon $from, \Carbon\Carbon $to): \Illuminate\Database\Eloquent\Collection + { + return static::whereBetween('created_at', [$from, $to]) + ->orderByDesc('created_at') + ->get(); + } +} diff --git a/packages/core-php/src/Core/Config/VersionDiff.php b/packages/core-php/src/Core/Config/VersionDiff.php new file mode 100644 index 0000000..6b59fc1 --- /dev/null +++ b/packages/core-php/src/Core/Config/VersionDiff.php @@ -0,0 +1,220 @@ + + */ + protected array $added = []; + + /** + * Keys removed in the new version. + * + * @var array + */ + protected array $removed = []; + + /** + * Keys with changed values. + * + * @var array + */ + protected array $changed = []; + + /** + * Keys with changed lock status. + * + * @var array + */ + protected array $lockChanged = []; + + /** + * Add an added key. + * + * @param string $key The config key + * @param mixed $value The new value + */ + public function addAdded(string $key, mixed $value): void + { + $this->added[] = ['key' => $key, 'value' => $value]; + } + + /** + * Add a removed key. + * + * @param string $key The config key + * @param mixed $value The old value + */ + public function addRemoved(string $key, mixed $value): void + { + $this->removed[] = ['key' => $key, 'value' => $value]; + } + + /** + * Add a changed key. + * + * @param string $key The config key + * @param mixed $oldValue The old value + * @param mixed $newValue The new value + */ + public function addChanged(string $key, mixed $oldValue, mixed $newValue): void + { + $this->changed[] = ['key' => $key, 'old' => $oldValue, 'new' => $newValue]; + } + + /** + * Add a lock status change. + * + * @param string $key The config key + * @param bool $oldLocked Old lock status + * @param bool $newLocked New lock status + */ + public function addLockChanged(string $key, bool $oldLocked, bool $newLocked): void + { + $this->lockChanged[] = ['key' => $key, 'old' => $oldLocked, 'new' => $newLocked]; + } + + /** + * Get added keys. + * + * @return array + */ + public function getAdded(): array + { + return $this->added; + } + + /** + * Get removed keys. + * + * @return array + */ + public function getRemoved(): array + { + return $this->removed; + } + + /** + * Get changed keys. + * + * @return array + */ + public function getChanged(): array + { + return $this->changed; + } + + /** + * Get lock status changes. + * + * @return array + */ + public function getLockChanged(): array + { + return $this->lockChanged; + } + + /** + * Check if there are any differences. + */ + public function hasDifferences(): bool + { + return ! empty($this->added) + || ! empty($this->removed) + || ! empty($this->changed) + || ! empty($this->lockChanged); + } + + /** + * Check if there are no differences. + */ + public function isEmpty(): bool + { + return ! $this->hasDifferences(); + } + + /** + * Get total count of differences. + */ + public function count(): int + { + return count($this->added) + + count($this->removed) + + count($this->changed) + + count($this->lockChanged); + } + + /** + * Get summary string. + */ + public function getSummary(): string + { + if ($this->isEmpty()) { + return 'No differences'; + } + + $parts = []; + + if (count($this->added) > 0) { + $parts[] = count($this->added).' added'; + } + + if (count($this->removed) > 0) { + $parts[] = count($this->removed).' removed'; + } + + if (count($this->changed) > 0) { + $parts[] = count($this->changed).' changed'; + } + + if (count($this->lockChanged) > 0) { + $parts[] = count($this->lockChanged).' lock changes'; + } + + return implode(', ', $parts); + } + + /** + * Convert to array for JSON serialization. + * + * @return array + */ + public function toArray(): array + { + return [ + 'has_differences' => $this->hasDifferences(), + 'summary' => $this->getSummary(), + 'added' => $this->added, + 'removed' => $this->removed, + 'changed' => $this->changed, + 'lock_changed' => $this->lockChanged, + 'counts' => [ + 'added' => count($this->added), + 'removed' => count($this->removed), + 'changed' => count($this->changed), + 'lock_changed' => count($this->lockChanged), + 'total' => $this->count(), + ], + ]; + } +} diff --git a/packages/core-php/src/Core/Console/Boot.php b/packages/core-php/src/Core/Console/Boot.php index 7d0ef6c..78a4599 100644 --- a/packages/core-php/src/Core/Console/Boot.php +++ b/packages/core-php/src/Core/Console/Boot.php @@ -27,5 +27,6 @@ class Boot $event->command(Commands\MakeModCommand::class); $event->command(Commands\MakePlugCommand::class); $event->command(Commands\MakeWebsiteCommand::class); + $event->command(Commands\PruneEmailShieldStatsCommand::class); } } diff --git a/packages/core-php/src/Core/Console/Commands/InstallCommand.php b/packages/core-php/src/Core/Console/Commands/InstallCommand.php index aaad098..d11496e 100644 --- a/packages/core-php/src/Core/Console/Commands/InstallCommand.php +++ b/packages/core-php/src/Core/Console/Commands/InstallCommand.php @@ -18,6 +18,11 @@ use Illuminate\Support\Facades\File; * * Helps new users set up the framework with sensible defaults. * Run: php artisan core:install + * + * Options: + * --force Overwrite existing configuration + * --no-interaction Run without prompts using defaults + * --dry-run Show what would happen without executing */ class InstallCommand extends Command { @@ -26,13 +31,32 @@ class InstallCommand extends Command */ protected $signature = 'core:install {--force : Overwrite existing configuration} - {--no-interaction : Run without prompts using defaults}'; + {--no-interaction : Run without prompts using defaults} + {--dry-run : Show what would happen without executing}'; /** * The console command description. */ protected $description = 'Install and configure Core PHP Framework'; + /** + * Installation steps for progress tracking. + * + * @var array + */ + protected array $installationSteps = [ + 'environment' => 'Setting up environment file', + 'application' => 'Configuring application settings', + 'migrations' => 'Running database migrations', + 'app_key' => 'Generating application key', + 'storage_link' => 'Creating storage symlink', + ]; + + /** + * Whether this is a dry run. + */ + protected bool $isDryRun = false; + /** * Track completed installation steps for rollback. * @@ -50,37 +74,78 @@ class InstallCommand extends Command */ public function handle(): int { + $this->isDryRun = (bool) $this->option('dry-run'); + $this->info(''); $this->info(' '.__('core::core.installer.title')); $this->info(' '.str_repeat('=', strlen(__('core::core.installer.title')))); + + if ($this->isDryRun) { + $this->warn(' [DRY RUN] No changes will be made'); + } + $this->info(''); - // Preserve original state for rollback - $this->preserveOriginalState(); + // Preserve original state for rollback (not needed in dry-run) + if (! $this->isDryRun) { + $this->preserveOriginalState(); + } try { + // Show progress bar for all steps + $this->info(' Installation Progress:'); + $this->info(''); + + $steps = $this->getInstallationSteps(); + $progressBar = $this->output->createProgressBar(count($steps)); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%'); + $progressBar->setMessage('Starting...'); + $progressBar->start(); + // Step 1: Environment file + $progressBar->setMessage($this->installationSteps['environment']); if (! $this->setupEnvironment()) { + $progressBar->finish(); + $this->newLine(); return self::FAILURE; } + $progressBar->advance(); // Step 2: Application settings + $progressBar->setMessage($this->installationSteps['application']); + $progressBar->display(); + $this->newLine(); $this->configureApplication(); + $progressBar->advance(); // Step 3: Database - if ($this->option('no-interaction') || $this->confirm(__('core::core.installer.prompts.run_migrations'), true)) { + $progressBar->setMessage($this->installationSteps['migrations']); + $progressBar->display(); + if ($this->option('no-interaction') || $this->isDryRun || $this->confirm(__('core::core.installer.prompts.run_migrations'), true)) { $this->runMigrations(); } + $progressBar->advance(); // Step 4: Generate app key if needed + $progressBar->setMessage($this->installationSteps['app_key']); $this->generateAppKey(); + $progressBar->advance(); // Step 5: Create storage link + $progressBar->setMessage($this->installationSteps['storage_link']); $this->createStorageLink(); + $progressBar->advance(); + + $progressBar->setMessage('Complete!'); + $progressBar->finish(); + $this->newLine(2); // Done! - $this->info(''); - $this->info(' '.__('core::core.installer.complete')); + if ($this->isDryRun) { + $this->info(' [DRY RUN] Installation preview complete. No changes were made.'); + } else { + $this->info(' '.__('core::core.installer.complete')); + } $this->info(''); $this->info(' '.__('core::core.installer.next_steps').':'); $this->info(' 1. Run: valet link core'); @@ -89,16 +154,42 @@ class InstallCommand extends Command return self::SUCCESS; } catch (\Throwable $e) { + $this->newLine(); $this->error(''); $this->error(' Installation failed: '.$e->getMessage()); $this->error(''); - $this->rollback(); + if (! $this->isDryRun) { + $this->rollback(); + } return self::FAILURE; } } + /** + * Get the list of installation steps to execute. + * + * @return array + */ + protected function getInstallationSteps(): array + { + return array_keys($this->installationSteps); + } + + /** + * Log an action in dry-run mode or execute it. + */ + protected function dryRunOrExecute(string $description, callable $action): mixed + { + if ($this->isDryRun) { + $this->info(" [WOULD] {$description}"); + return null; + } + + return $action(); + } + /** * Preserve original state for potential rollback. */ @@ -176,8 +267,12 @@ class InstallCommand extends Command return false; } - File::copy($envExamplePath, $envPath); - $this->completedSteps['env_created'] = true; + if ($this->isDryRun) { + $this->info(' [WOULD] Copy .env.example to .env'); + } else { + File::copy($envExamplePath, $envPath); + $this->completedSteps['env_created'] = true; + } $this->info(' [✓] '.__('core::core.installer.env_created')); return true; @@ -194,6 +289,14 @@ class InstallCommand extends Command return; } + if ($this->isDryRun) { + $this->info(' [WOULD] Prompt for app name, domain, and database settings'); + $this->info(' [WOULD] Update .env with configured values'); + $this->info(' [✓] '.__('core::core.installer.default_config').' (dry-run)'); + + return; + } + // App name $appName = $this->ask(__('core::core.installer.prompts.app_name'), __('core::core.brand.name')); $this->updateEnv('APP_BRAND_NAME', $appName); @@ -276,6 +379,14 @@ class InstallCommand extends Command protected function runMigrations(): void { $this->info(''); + + if ($this->isDryRun) { + $this->info(' [WOULD] Run: php artisan migrate --force'); + $this->info(' [✓] '.__('core::core.installer.migrations_complete').' (dry-run)'); + + return; + } + $this->info(' Running migrations...'); $this->call('migrate', ['--force' => true]); @@ -291,8 +402,13 @@ class InstallCommand extends Command $key = config('app.key'); if (empty($key) || $key === 'base64:') { - $this->call('key:generate'); - $this->info(' [✓] '.__('core::core.installer.key_generated')); + if ($this->isDryRun) { + $this->info(' [WOULD] Run: php artisan key:generate'); + $this->info(' [✓] '.__('core::core.installer.key_generated').' (dry-run)'); + } else { + $this->call('key:generate'); + $this->info(' [✓] '.__('core::core.installer.key_generated')); + } } else { $this->info(' [✓] '.__('core::core.installer.key_exists')); } @@ -311,6 +427,13 @@ class InstallCommand extends Command return; } + if ($this->isDryRun) { + $this->info(' [WOULD] Run: php artisan storage:link'); + $this->info(' [✓] '.__('core::core.installer.storage_link_created').' (dry-run)'); + + return; + } + $this->call('storage:link'); $this->completedSteps['storage_link'] = true; $this->info(' [✓] '.__('core::core.installer.storage_link_created')); @@ -350,4 +473,21 @@ class InstallCommand extends Command File::put($envPath, $content); } + + /** + * Get shell completion suggestions for options. + * + * This command has no option values that need completion hints, + * but implements the method for consistency with other commands. + * + * @param \Symfony\Component\Console\Completion\CompletionInput $input + * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + */ + public function complete( + \Symfony\Component\Console\Completion\CompletionInput $input, + \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + ): void { + // No argument/option values need completion for this command + // All options are flags (--force, --no-interaction, --dry-run) + } } diff --git a/packages/core-php/src/Core/Console/Commands/MakeModCommand.php b/packages/core-php/src/Core/Console/Commands/MakeModCommand.php index 3f5c7fe..b654914 100644 --- a/packages/core-php/src/Core/Console/Commands/MakeModCommand.php +++ b/packages/core-php/src/Core/Console/Commands/MakeModCommand.php @@ -41,6 +41,13 @@ class MakeModCommand extends Command */ protected $description = 'Create a new module in the Mod namespace'; + /** + * Files created during generation for summary table. + * + * @var array + */ + protected array $createdFiles = []; + /** * Execute the console command. */ @@ -50,13 +57,18 @@ class MakeModCommand extends Command $modulePath = $this->getModulePath($name); if (File::isDirectory($modulePath) && ! $this->option('force')) { - $this->error("Module [{$name}] already exists!"); - $this->info("Use --force to overwrite."); + $this->newLine(); + $this->components->error("Module [{$name}] already exists!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing module.'); + $this->newLine(); return self::FAILURE; } - $this->info("Creating module: {$name}"); + $this->newLine(); + $this->components->info("Creating module: {$name}"); + $this->newLine(); // Create directory structure $this->createDirectoryStructure($modulePath); @@ -67,15 +79,26 @@ class MakeModCommand extends Command // 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(''); + // Show summary table of created files + $this->newLine(); + $this->components->twoColumnDetail('Created Files', 'Description'); + foreach ($this->createdFiles as $file) { + $this->components->twoColumnDetail( + "{$file['file']}", + "{$file['description']}" + ); + } + + $this->newLine(); + $this->components->info("Module [{$name}] created successfully!"); + $this->newLine(); + $this->components->twoColumnDetail('Location', "{$modulePath}"); + $this->newLine(); + + $this->components->info('Next steps:'); + $this->line(' 1. Add your module logic to the Boot.php event handlers'); + $this->line(' 2. Create Models, Views, and Controllers as needed'); + $this->newLine(); return self::SUCCESS; } @@ -120,7 +143,7 @@ class MakeModCommand extends Command File::ensureDirectoryExists($directory); } - $this->info(' [+] Created directory structure'); + $this->components->task('Creating directory structure', fn () => true); } /** @@ -174,7 +197,8 @@ class Boot PHP; File::put("{$modulePath}/Boot.php", $content); - $this->info(' [+] Created Boot.php'); + $this->createdFiles[] = ['file' => 'Boot.php', 'description' => 'Event-driven module loader']; + $this->components->task('Creating Boot.php', fn () => true); } /** @@ -385,7 +409,8 @@ Route::prefix('{$moduleName}')->group(function () { PHP; File::put("{$modulePath}/Routes/web.php", $content); - $this->info(' [+] Created Routes/web.php'); + $this->createdFiles[] = ['file' => 'Routes/web.php', 'description' => 'Public web routes']; + $this->components->task('Creating Routes/web.php', fn () => true); } /** @@ -418,7 +443,8 @@ Route::prefix('{$moduleName}')->name('{$moduleName}.admin.')->group(function () PHP; File::put("{$modulePath}/Routes/admin.php", $content); - $this->info(' [+] Created Routes/admin.php'); + $this->createdFiles[] = ['file' => 'Routes/admin.php', 'description' => 'Admin panel routes']; + $this->components->task('Creating Routes/admin.php', fn () => true); } /** @@ -451,7 +477,8 @@ Route::prefix('{$moduleName}')->name('api.{$moduleName}.')->group(function () { PHP; File::put("{$modulePath}/Routes/api.php", $content); - $this->info(' [+] Created Routes/api.php'); + $this->createdFiles[] = ['file' => 'Routes/api.php', 'description' => 'REST API routes']; + $this->components->task('Creating Routes/api.php', fn () => true); } /** @@ -472,6 +499,31 @@ PHP; BLADE; File::put("{$modulePath}/View/Blade/index.blade.php", $content); - $this->info(' [+] Created View/Blade/index.blade.php'); + $this->createdFiles[] = ['file' => 'View/Blade/index.blade.php', 'description' => 'Sample index view']; + $this->components->task('Creating View/Blade/index.blade.php', fn () => true); + } + + /** + * Get shell completion suggestions for arguments. + * + * @param \Symfony\Component\Console\Completion\CompletionInput $input + * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + */ + public function complete( + \Symfony\Component\Console\Completion\CompletionInput $input, + \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common module naming patterns + $suggestions->suggestValues([ + 'Auth', + 'Blog', + 'Content', + 'Dashboard', + 'Media', + 'Settings', + 'Users', + ]); + } } } diff --git a/packages/core-php/src/Core/Console/Commands/MakePlugCommand.php b/packages/core-php/src/Core/Console/Commands/MakePlugCommand.php index 3ad4d13..52ccd3b 100644 --- a/packages/core-php/src/Core/Console/Commands/MakePlugCommand.php +++ b/packages/core-php/src/Core/Console/Commands/MakePlugCommand.php @@ -47,6 +47,13 @@ class MakePlugCommand extends Command */ protected const CATEGORIES = ['Social', 'Web3', 'Content', 'Chat', 'Business']; + /** + * Operations created during generation for summary table. + * + * @var array + */ + protected array $createdOperations = []; + /** * Execute the console command. */ @@ -56,8 +63,11 @@ class MakePlugCommand extends Command $category = Str::studly($this->option('category')); if (! in_array($category, self::CATEGORIES)) { - $this->error("Invalid category [{$category}]."); - $this->info('Valid categories: '.implode(', ', self::CATEGORIES)); + $this->newLine(); + $this->components->error("Invalid category [{$category}]."); + $this->newLine(); + $this->components->bulletList(self::CATEGORIES); + $this->newLine(); return self::FAILURE; } @@ -65,32 +75,48 @@ class MakePlugCommand extends Command $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."); + $this->newLine(); + $this->components->error("Provider [{$name}] already exists in [{$category}]!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing provider.'); + $this->newLine(); return self::FAILURE; } - $this->info("Creating Plug provider: {$category}/{$name}"); + $this->newLine(); + $this->components->info("Creating Plug provider: {$category}/{$name}"); + $this->newLine(); // Create directory structure File::ensureDirectoryExists($providerPath); - $this->info(' [+] Created provider directory'); + $this->components->task('Creating provider directory', fn () => true); // 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(''); + // Show summary table of created operations + $this->newLine(); + $this->components->twoColumnDetail('Created Operations', 'Description'); + foreach ($this->createdOperations as $op) { + $this->components->twoColumnDetail( + "{$op['operation']}", + "{$op['description']}" + ); + } + + $this->newLine(); + $this->components->info("Plug provider [{$category}/{$name}] created successfully!"); + $this->newLine(); + $this->components->twoColumnDetail('Location', "{$providerPath}"); + $this->newLine(); + + $this->components->info('Usage example:'); + $this->line(" use Plug\\{$category}\\{$name}\\Auth;"); + $this->newLine(); + $this->line(' $auth = new Auth(\$clientId, \$clientSecret, \$redirectUrl);'); + $this->line(' $authUrl = \$auth->getAuthUrl();'); + $this->newLine(); return self::SUCCESS; } @@ -309,7 +335,8 @@ class Auth PHP; File::put("{$providerPath}/Auth.php", $content); - $this->info(' [+] Created Auth.php (OAuth authentication)'); + $this->createdOperations[] = ['operation' => 'Auth.php', 'description' => 'OAuth 2.0 authentication']; + $this->components->task('Creating Auth.php', fn () => true); } /** @@ -402,7 +429,8 @@ class Post PHP; File::put("{$providerPath}/Post.php", $content); - $this->info(' [+] Created Post.php (content creation)'); + $this->createdOperations[] = ['operation' => 'Post.php', 'description' => 'Content creation/publishing']; + $this->components->task('Creating Post.php', fn () => true); } /** @@ -476,7 +504,8 @@ class Delete PHP; File::put("{$providerPath}/Delete.php", $content); - $this->info(' [+] Created Delete.php (content deletion)'); + $this->createdOperations[] = ['operation' => 'Delete.php', 'description' => 'Content deletion']; + $this->components->task('Creating Delete.php', fn () => true); } /** @@ -566,6 +595,37 @@ class Media PHP; File::put("{$providerPath}/Media.php", $content); - $this->info(' [+] Created Media.php (media uploads)'); + $this->createdOperations[] = ['operation' => 'Media.php', 'description' => 'Media file uploads']; + $this->components->task('Creating Media.php', fn () => true); + } + + /** + * Get shell completion suggestions for arguments and options. + * + * @param \Symfony\Component\Console\Completion\CompletionInput $input + * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + */ + public function complete( + \Symfony\Component\Console\Completion\CompletionInput $input, + \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common social platform names + $suggestions->suggestValues([ + 'Twitter', + 'Instagram', + 'Facebook', + 'LinkedIn', + 'TikTok', + 'YouTube', + 'Mastodon', + 'Threads', + 'Bluesky', + ]); + } + + if ($input->mustSuggestOptionValuesFor('category')) { + $suggestions->suggestValues(self::CATEGORIES); + } } } diff --git a/packages/core-php/src/Core/Console/Commands/MakeWebsiteCommand.php b/packages/core-php/src/Core/Console/Commands/MakeWebsiteCommand.php index 5d7b040..e1bff2b 100644 --- a/packages/core-php/src/Core/Console/Commands/MakeWebsiteCommand.php +++ b/packages/core-php/src/Core/Console/Commands/MakeWebsiteCommand.php @@ -41,6 +41,13 @@ class MakeWebsiteCommand extends Command */ protected $description = 'Create a new domain-isolated website'; + /** + * Files created during generation for summary table. + * + * @var array + */ + protected array $createdFiles = []; + /** * Execute the console command. */ @@ -51,14 +58,19 @@ class MakeWebsiteCommand extends Command $websitePath = $this->getWebsitePath($name); if (File::isDirectory($websitePath) && ! $this->option('force')) { - $this->error("Website [{$name}] already exists!"); - $this->info("Use --force to overwrite."); + $this->newLine(); + $this->components->error("Website [{$name}] already exists!"); + $this->newLine(); + $this->components->warn('Use --force to overwrite the existing website.'); + $this->newLine(); return self::FAILURE; } - $this->info("Creating website: {$name}"); - $this->info("Domain: {$domain}"); + $this->newLine(); + $this->components->info("Creating website: {$name}"); + $this->components->twoColumnDetail('Domain', "{$domain}"); + $this->newLine(); // Create directory structure $this->createDirectoryStructure($websitePath); @@ -69,17 +81,28 @@ class MakeWebsiteCommand extends Command // 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(''); + // Show summary table of created files + $this->newLine(); + $this->components->twoColumnDetail('Created Files', 'Description'); + foreach ($this->createdFiles as $file) { + $this->components->twoColumnDetail( + "{$file['file']}", + "{$file['description']}" + ); + } + + $this->newLine(); + $this->components->info("Website [{$name}] created successfully!"); + $this->newLine(); + $this->components->twoColumnDetail('Location', "{$websitePath}"); + $this->newLine(); + + $this->components->info('Next steps:'); + $this->line(" 1. Configure your local dev server to serve {$domain}"); + $this->line(' (e.g., valet link '.Str::snake($name, '-').')'); + $this->line(" 2. Visit http://{$domain} to see your website"); + $this->line(' 3. Add routes, views, and controllers as needed'); + $this->newLine(); return self::SUCCESS; } @@ -113,7 +136,7 @@ class MakeWebsiteCommand extends Command File::ensureDirectoryExists($directory); } - $this->info(' [+] Created directory structure'); + $this->components->task('Creating directory structure', fn () => true); } /** @@ -227,7 +250,8 @@ class Boot extends ServiceProvider PHP; File::put("{$websitePath}/Boot.php", $content); - $this->info(' [+] Created Boot.php'); + $this->createdFiles[] = ['file' => 'Boot.php', 'description' => 'Domain-isolated website provider']; + $this->components->task('Creating Boot.php', fn () => true); } /** @@ -393,7 +417,8 @@ Route::get('/', function () { PHP; File::put("{$websitePath}/Routes/web.php", $content); - $this->info(' [+] Created Routes/web.php'); + $this->createdFiles[] = ['file' => 'Routes/web.php', 'description' => 'Public web routes']; + $this->components->task('Creating Routes/web.php', fn () => true); } /** @@ -426,7 +451,8 @@ Route::prefix('admin/{$websiteName}')->name('{$websiteName}.admin.')->group(func PHP; File::put("{$websitePath}/Routes/admin.php", $content); - $this->info(' [+] Created Routes/admin.php'); + $this->createdFiles[] = ['file' => 'Routes/admin.php', 'description' => 'Admin panel routes']; + $this->components->task('Creating Routes/admin.php', fn () => true); } /** @@ -459,7 +485,8 @@ Route::prefix('{$websiteName}')->name('api.{$websiteName}.')->group(function () PHP; File::put("{$websitePath}/Routes/api.php", $content); - $this->info(' [+] Created Routes/api.php'); + $this->createdFiles[] = ['file' => 'Routes/api.php', 'description' => 'REST API routes']; + $this->components->task('Creating Routes/api.php', fn () => true); } /** @@ -506,7 +533,8 @@ PHP; BLADE; File::put("{$websitePath}/View/Blade/layouts/app.blade.php", $content); - $this->info(' [+] Created View/Blade/layouts/app.blade.php'); + $this->createdFiles[] = ['file' => 'View/Blade/layouts/app.blade.php', 'description' => 'Base layout template']; + $this->components->task('Creating View/Blade/layouts/app.blade.php', fn () => true); } /** @@ -537,6 +565,41 @@ BLADE; BLADE; File::put("{$websitePath}/View/Blade/home.blade.php", $content); - $this->info(' [+] Created View/Blade/home.blade.php'); + $this->createdFiles[] = ['file' => 'View/Blade/home.blade.php', 'description' => 'Homepage view']; + $this->components->task('Creating View/Blade/home.blade.php', fn () => true); + } + + /** + * Get shell completion suggestions for arguments and options. + * + * @param \Symfony\Component\Console\Completion\CompletionInput $input + * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + */ + public function complete( + \Symfony\Component\Console\Completion\CompletionInput $input, + \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestArgumentValuesFor('name')) { + // Suggest common website naming patterns + $suggestions->suggestValues([ + 'MarketingSite', + 'Blog', + 'Documentation', + 'LandingPage', + 'Portal', + 'Dashboard', + 'Support', + ]); + } + + if ($input->mustSuggestOptionValuesFor('domain')) { + // Suggest common development domains + $suggestions->suggestValues([ + 'example.test', + 'app.test', + 'site.test', + 'dev.test', + ]); + } } } diff --git a/packages/core-php/src/Core/Console/Commands/PruneEmailShieldStatsCommand.php b/packages/core-php/src/Core/Console/Commands/PruneEmailShieldStatsCommand.php new file mode 100644 index 0000000..4db71d6 --- /dev/null +++ b/packages/core-php/src/Core/Console/Commands/PruneEmailShieldStatsCommand.php @@ -0,0 +1,147 @@ +command('email-shield:prune')->daily(); + */ +class PruneEmailShieldStatsCommand extends Command +{ + /** + * The name and signature of the console command. + */ + protected $signature = 'email-shield:prune + {--days= : Number of days to retain (default: from config or 90)} + {--dry-run : Show what would be deleted without actually deleting}'; + + /** + * The console command description. + */ + protected $description = 'Prune old Email Shield statistics records'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $days = $this->getRetentionDays(); + $dryRun = $this->option('dry-run'); + + $this->newLine(); + $this->components->info('Email Shield Stats Cleanup'); + $this->newLine(); + + // Get count of records that would be deleted + $cutoffDate = now()->subDays($days)->format('Y-m-d'); + $recordsToDelete = EmailShieldStat::query() + ->where('date', '<', $cutoffDate) + ->count(); + + // Show current state table + $this->components->twoColumnDetail('Configuration', ''); + $this->components->twoColumnDetail('Retention period', "{$days} days"); + $this->components->twoColumnDetail('Cutoff date', "{$cutoffDate}"); + $this->components->twoColumnDetail('Records to delete', $recordsToDelete > 0 + ? "{$recordsToDelete}" + : '0'); + $this->newLine(); + + if ($recordsToDelete === 0) { + $this->components->info('No records older than the retention period found.'); + $this->newLine(); + + return self::SUCCESS; + } + + if ($dryRun) { + $this->components->warn('Dry run mode - no records were deleted.'); + $this->newLine(); + + return self::SUCCESS; + } + + // Show progress for deletion + $this->components->task( + "Deleting {$recordsToDelete} old records", + function () use ($days) { + EmailShieldStat::pruneOldRecords($days); + return true; + } + ); + + $this->newLine(); + $this->components->info("Successfully deleted {$recordsToDelete} records older than {$days} days."); + $this->newLine(); + + // Show remaining stats + $remaining = EmailShieldStat::getRecordCount(); + $oldest = EmailShieldStat::getOldestRecordDate(); + + $this->components->twoColumnDetail('Current State', ''); + $this->components->twoColumnDetail('Remaining records', "{$remaining}"); + if ($oldest) { + $this->components->twoColumnDetail('Oldest record', "{$oldest->format('Y-m-d')}"); + } + $this->newLine(); + + return self::SUCCESS; + } + + /** + * Get the retention period in days from option, config, or default. + */ + protected function getRetentionDays(): int + { + // First check command option + $days = $this->option('days'); + if ($days !== null) { + return (int) $days; + } + + // Then check config + $configDays = config('core.email_shield.retention_days'); + if ($configDays !== null) { + return (int) $configDays; + } + + // Default to 90 days + return 90; + } + + /** + * Get shell completion suggestions for options. + * + * @param \Symfony\Component\Console\Completion\CompletionInput $input + * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + */ + public function complete( + \Symfony\Component\Console\Completion\CompletionInput $input, + \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions + ): void { + if ($input->mustSuggestOptionValuesFor('days')) { + // Suggest common retention periods + $suggestions->suggestValues(['7', '14', '30', '60', '90', '180', '365']); + } + } +} diff --git a/packages/core-php/src/Core/Crypt/LthnHash.php b/packages/core-php/src/Core/Crypt/LthnHash.php index ee5966e..ef4d265 100644 --- a/packages/core-php/src/Core/Crypt/LthnHash.php +++ b/packages/core-php/src/Core/Crypt/LthnHash.php @@ -11,10 +11,32 @@ declare(strict_types=1); namespace Core\Crypt; /** - * LTHN Protocol QuasiHash. + * LTHN Protocol QuasiHash - Deterministic Identifier Generator. * * A lightweight, deterministic identifier generator for workspace/domain scoping. - * Used to create vBucket IDs for CDN path isolation. + * Used to create vBucket IDs for CDN path isolation and tenant-scoped identifiers. + * + * ## Algorithm Overview + * + * The LthnHash algorithm uses a two-step process: + * + * 1. **Salt Generation**: The input string is reversed and passed through a + * character substitution map (key map), creating a deterministic "salt" + * 2. **Hashing**: The original input is concatenated with the salt and hashed + * using SHA-256 (or xxHash/CRC32 for `fastHash()`) + * + * This produces outputs with good distribution properties while maintaining + * determinism - the same input always produces the same output. + * + * ## Available Hash Algorithms + * + * | Method | Algorithm | Output Length | Use Case | + * |--------|-----------|---------------|----------| + * | `hash()` | SHA-256 | 64 hex chars (256 bits) | Default, high quality | + * | `shortHash()` | SHA-256 truncated | 16-32 hex chars | Space-constrained IDs | + * | `fastHash()` | xxHash or CRC32 | 8-16 hex chars | High-throughput scenarios | + * | `vBucketId()` | SHA-256 | 64 hex chars | CDN path isolation | + * | `toInt()` | SHA-256 -> int | 60 bits | Sharding/partitioning | * * ## Security Properties * @@ -40,6 +62,20 @@ namespace Core\Crypt; * | 32 | 128 | ~1 in 3.4e38 | Long-term storage | * | 64 | 256 | Negligible | Maximum security | * + * ## Performance Considerations + * + * For short inputs (< 64 bytes), the default SHA-256 implementation is suitable + * for most use cases. For extremely high-throughput scenarios with many short + * strings, consider using `fastHash()` which uses xxHash (when available) or + * a CRC32-based approach for better performance. + * + * Benchmark reference (typical values, YMMV): + * - SHA-256: ~300k hashes/sec for short strings + * - xxHash (via hash extension): ~2M hashes/sec for short strings + * - CRC32: ~1.5M hashes/sec for short strings + * + * Use `benchmark()` to measure actual performance on your system. + * * ## Key Rotation * * The class supports multiple key maps for rotation. When verifying, all registered @@ -50,12 +86,38 @@ namespace Core\Crypt; * 3. Verification tries new key first, falls back to old * 4. After migration period, remove old key map with `removeKeyMap()` * + * ## Usage Examples + * + * ```php + * // Generate a vBucket ID for CDN path isolation + * $vbucket = LthnHash::vBucketId('workspace.example.com'); + * // => "a7b3c9d2e1f4g5h6..." + * + * // Generate a short ID for internal use + * $shortId = LthnHash::shortHash('user-12345', LthnHash::MEDIUM_LENGTH); + * // => "a7b3c9d2e1f4g5h6i8j9k1l2" + * + * // High-throughput scenario + * $fastId = LthnHash::fastHash('cache-key-123'); + * // => "1a2b3c4d5e6f7g8h" + * + * // Sharding: get consistent partition number + * $partition = LthnHash::toInt('user@example.com', 16); + * // => 7 (always 7 for this input) + * + * // Verify a hash + * $isValid = LthnHash::verify('user-12345', $shortId); + * // => true + * ``` + * * ## NOT Suitable For * * - Password hashing (use `password_hash()` instead) * - Security tokens (use `random_bytes()` instead) * - Cryptographic signatures * - Any security-sensitive operations + * + * @package Core\Crypt */ class LthnHash { @@ -157,12 +219,17 @@ class LthnHash } /** - * Verify that a hash matches an input. + * Verify that a hash matches an input using constant-time comparison. * * 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. * + * SECURITY NOTE: This method uses hash_equals() for constant-time string + * comparison, which prevents timing attacks. Regular string comparison + * (== or ===) can leak information about the hash through timing differences. + * Always use this method for hash verification rather than direct comparison. + * * @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 @@ -344,4 +411,109 @@ class LthnHash return gmp_intval(gmp_mod(gmp_init($hex, 16), $max)); } + + /** + * Generate a fast hash for performance-critical operations. + * + * Uses xxHash when available (via hash extension), falling back to a + * CRC32-based approach. This is significantly faster than SHA-256 for + * short inputs but provides less collision resistance. + * + * Best for: + * - High-throughput scenarios (millions of hashes) + * - Cache keys and temporary identifiers + * - Hash table bucketing + * + * NOT suitable for: + * - Long-term storage identifiers + * - Security-sensitive operations + * - Cases requiring strong collision resistance + * + * @param string $input The input string to hash + * @param int $length Output length in hex characters (max 16 for xxh64, 8 for crc32) + * @return string Hex hash string + */ + public static function fastHash(string $input, int $length = 16): string + { + // Apply key map for consistency with standard hash + $keyId = self::$activeKey; + $reversed = strrev($input); + $salted = $input . self::applyKeyMap($reversed, $keyId); + + // Use xxHash if available (PHP 8.1+ with hash extension) + if (in_array('xxh64', hash_algos(), true)) { + $hash = hash('xxh64', $salted); + return substr($hash, 0, min($length, 16)); + } + + // Fallback: combine two CRC32 variants for 16 hex chars + $crc1 = hash('crc32b', $salted); + $crc2 = hash('crc32c', strrev($salted)); + $combined = $crc1 . $crc2; + + return substr($combined, 0, min($length, 16)); + } + + /** + * Run a simple benchmark comparing hash algorithms. + * + * Returns timing data for hash(), shortHash(), and fastHash() to help + * choose the appropriate method for your use case. + * + * @param int $iterations Number of hash operations to run + * @param string|null $testInput Input string to hash (default: random 32 chars) + * @return array{ + * hash: array{iterations: int, total_ms: float, per_hash_us: float}, + * shortHash: array{iterations: int, total_ms: float, per_hash_us: float}, + * fastHash: array{iterations: int, total_ms: float, per_hash_us: float}, + * fastHash_algorithm: string + * } + */ + public static function benchmark(int $iterations = 10000, ?string $testInput = null): array + { + $testInput ??= bin2hex(random_bytes(16)); // 32 char test string + + // Benchmark hash() + $start = hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + self::hash($testInput . $i); + } + $hashTime = (hrtime(true) - $start) / 1e6; // Convert to ms + + // Benchmark shortHash() + $start = hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + self::shortHash($testInput . $i); + } + $shortHashTime = (hrtime(true) - $start) / 1e6; + + // Benchmark fastHash() + $start = hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + self::fastHash($testInput . $i); + } + $fastHashTime = (hrtime(true) - $start) / 1e6; + + // Determine which algorithm fastHash is using + $fastHashAlgo = in_array('xxh64', hash_algos(), true) ? 'xxh64' : 'crc32b+crc32c'; + + return [ + 'hash' => [ + 'iterations' => $iterations, + 'total_ms' => round($hashTime, 2), + 'per_hash_us' => round(($hashTime * 1000) / $iterations, 3), + ], + 'shortHash' => [ + 'iterations' => $iterations, + 'total_ms' => round($shortHashTime, 2), + 'per_hash_us' => round(($shortHashTime * 1000) / $iterations, 3), + ], + 'fastHash' => [ + 'iterations' => $iterations, + 'total_ms' => round($fastHashTime, 2), + 'per_hash_us' => round(($fastHashTime * 1000) / $iterations, 3), + ], + 'fastHash_algorithm' => $fastHashAlgo, + ]; + } } diff --git a/packages/core-php/src/Core/Database/Seeders/Attributes/SeederAfter.php b/packages/core-php/src/Core/Database/Seeders/Attributes/SeederAfter.php new file mode 100644 index 0000000..b566fe8 --- /dev/null +++ b/packages/core-php/src/Core/Database/Seeders/Attributes/SeederAfter.php @@ -0,0 +1,60 @@ + + */ + public readonly array $seeders; + + /** + * Create a new dependency attribute. + * + * @param class-string ...$seeders Seeder classes that must run first + */ + public function __construct(string ...$seeders) + { + $this->seeders = $seeders; + } +} diff --git a/packages/core-php/src/Core/Database/Seeders/Attributes/SeederBefore.php b/packages/core-php/src/Core/Database/Seeders/Attributes/SeederBefore.php new file mode 100644 index 0000000..f26ebff --- /dev/null +++ b/packages/core-php/src/Core/Database/Seeders/Attributes/SeederBefore.php @@ -0,0 +1,61 @@ + + */ + public readonly array $seeders; + + /** + * Create a new dependency attribute. + * + * @param class-string ...$seeders Seeder classes that must run after this one + */ + public function __construct(string ...$seeders) + { + $this->seeders = $seeders; + } +} diff --git a/packages/core-php/src/Core/Database/Seeders/Attributes/SeederPriority.php b/packages/core-php/src/Core/Database/Seeders/Attributes/SeederPriority.php new file mode 100644 index 0000000..c5b3e0b --- /dev/null +++ b/packages/core-php/src/Core/Database/Seeders/Attributes/SeederPriority.php @@ -0,0 +1,61 @@ +getSeedersToRun(); + + if (empty($seeders)) { + $this->info('No seeders found to run.'); + + return; + } + + $this->info(sprintf('Running %d seeders...', count($seeders))); + $this->newLine(); + + foreach ($seeders as $seeder) { + $shortName = $this->getShortName($seeder); + $this->info("Running: {$shortName}"); + + $this->call($seeder); + } + + $this->newLine(); + $this->info('Database seeding completed successfully.'); + } + + /** + * Get the list of seeders to run. + * + * @return array Ordered list of seeder class names + */ + protected function getSeedersToRun(): array + { + $seeders = $this->discoverSeeders(); + + // Apply filters + $seeders = $this->applyExcludeFilter($seeders); + $seeders = $this->applyOnlyFilter($seeders); + + return $seeders; + } + + /** + * Discover all seeders. + * + * @return array Ordered list of seeder class names + */ + protected function discoverSeeders(): array + { + // Check if auto-discovery is enabled + if (! $this->shouldAutoDiscover()) { + return $this->getManualSeeders(); + } + + $discovery = $this->getDiscovery(); + + return $discovery->discover(); + } + + /** + * Get manually registered seeders. + * + * @return array + */ + protected function getManualSeeders(): array + { + $registry = $this->getRegistry(); + + return $registry->getOrdered(); + } + + /** + * Get the seeder discovery instance. + */ + protected function getDiscovery(): SeederDiscovery + { + if ($this->discovery === null) { + $this->discovery = new SeederDiscovery( + $this->getSeederPaths(), + $this->getExcludedSeeders() + ); + } + + return $this->discovery; + } + + /** + * Get the seeder registry instance. + */ + protected function getRegistry(): SeederRegistry + { + if ($this->registry === null) { + $this->registry = new SeederRegistry; + $this->registerSeeders($this->registry); + } + + return $this->registry; + } + + /** + * Register seeders manually when auto-discovery is disabled. + * + * Override this method in subclasses to add seeders. + * + * @param SeederRegistry $registry The registry to add seeders to + */ + protected function registerSeeders(SeederRegistry $registry): void + { + // Override in subclasses + } + + /** + * Get paths to scan for seeders. + * + * Override this method to customize seeder paths. + * + * @return array + */ + protected function getSeederPaths(): array + { + // Use config if available, otherwise use defaults + $config = config('core.seeders.paths'); + + if (is_array($config) && ! empty($config)) { + return $config; + } + + return [ + app_path('Core'), + app_path('Mod'), + app_path('Website'), + ]; + } + + /** + * Get seeders to exclude. + * + * Override this method to customize excluded seeders. + * + * @return array + */ + protected function getExcludedSeeders(): array + { + return config('core.seeders.exclude', []); + } + + /** + * Check if auto-discovery should be used. + */ + protected function shouldAutoDiscover(): bool + { + if (! $this->autoDiscover) { + return false; + } + + return config('core.seeders.auto_discover', true); + } + + /** + * Apply the --exclude filter. + * + * @param array $seeders List of seeder classes + * @return array Filtered list + */ + protected function applyExcludeFilter(array $seeders): array + { + $excludes = $this->getCommandOption('exclude'); + + if (empty($excludes)) { + return $seeders; + } + + $excludePatterns = is_array($excludes) ? $excludes : [$excludes]; + + return array_filter($seeders, function ($seeder) use ($excludePatterns) { + foreach ($excludePatterns as $pattern) { + if ($this->matchesPattern($seeder, $pattern)) { + return false; + } + } + + return true; + }); + } + + /** + * Apply the --only filter. + * + * @param array $seeders List of seeder classes + * @return array Filtered list + */ + protected function applyOnlyFilter(array $seeders): array + { + $only = $this->getCommandOption('only'); + + if (empty($only)) { + return $seeders; + } + + $onlyPatterns = is_array($only) ? $only : [$only]; + + return array_values(array_filter($seeders, function ($seeder) use ($onlyPatterns) { + foreach ($onlyPatterns as $pattern) { + if ($this->matchesPattern($seeder, $pattern)) { + return true; + } + } + + return false; + })); + } + + /** + * Check if a seeder matches a pattern. + * + * Patterns can be: + * - Full class name: Core\Mod\Tenant\Database\Seeders\FeatureSeeder + * - Short name: FeatureSeeder + * - Partial match: Feature (matches FeatureSeeder) + * + * @param string $seeder Full class name + * @param string $pattern Pattern to match + */ + protected function matchesPattern(string $seeder, string $pattern): bool + { + // Exact match + if ($seeder === $pattern) { + return true; + } + + // Short name match + $shortName = $this->getShortName($seeder); + if ($shortName === $pattern) { + return true; + } + + // Partial match (contains) + if (str_contains($shortName, $pattern) || str_contains($seeder, $pattern)) { + return true; + } + + return false; + } + + /** + * Get a command option value. + * + * @param string $name Option name + */ + protected function getCommandOption(string $name): mixed + { + if (! $this->command instanceof Command) { + return null; + } + + // Check if the option exists before getting it + if (! $this->command->hasOption($name)) { + return null; + } + + return $this->command->option($name); + } + + /** + * Get the short (class only) name of a seeder. + * + * @param string $class Fully qualified class name + * @return string Class name without namespace + */ + protected function getShortName(string $class): string + { + $parts = explode('\\', $class); + + return end($parts); + } + + /** + * Output an info message. + * + * @param string $message Message to output + */ + protected function info(string $message): void + { + if ($this->command instanceof Command) { + $this->command->info($message); + } + } + + /** + * Output a newline. + */ + protected function newLine(): void + { + if ($this->command instanceof Command) { + $this->command->newLine(); + } + } +} diff --git a/packages/core-php/src/Core/Database/Seeders/Exceptions/CircularDependencyException.php b/packages/core-php/src/Core/Database/Seeders/Exceptions/CircularDependencyException.php new file mode 100644 index 0000000..758bf3a --- /dev/null +++ b/packages/core-php/src/Core/Database/Seeders/Exceptions/CircularDependencyException.php @@ -0,0 +1,62 @@ + + */ + public readonly array $cycle; + + /** + * Create a new exception instance. + * + * @param array $cycle The seeders forming the dependency cycle + */ + public function __construct(array $cycle) + { + $this->cycle = $cycle; + + $cycleStr = implode(' -> ', $cycle); + + parent::__construct( + "Circular dependency detected in seeders: {$cycleStr}" + ); + } + + /** + * Create an exception from a dependency path. + * + * @param array $path The path of seeders leading to the cycle + * @param string $duplicate The seeder that was found again, completing the cycle + */ + public static function fromPath(array $path, string $duplicate): self + { + // Find where the cycle starts + $cycleStart = array_search($duplicate, $path, true); + $cycle = array_slice($path, $cycleStart); + $cycle[] = $duplicate; + + return new self($cycle); + } +} diff --git a/packages/core-php/src/Core/Database/Seeders/SeederDiscovery.php b/packages/core-php/src/Core/Database/Seeders/SeederDiscovery.php new file mode 100644 index 0000000..7ac116e --- /dev/null +++ b/packages/core-php/src/Core/Database/Seeders/SeederDiscovery.php @@ -0,0 +1,546 @@ +, before: array}> + */ + private array $seeders = []; + + /** + * Paths to scan for seeders. + * + * @var array + */ + private array $paths = []; + + /** + * Seeder classes to exclude. + * + * @var array + */ + private array $excluded = []; + + /** + * Whether discovery has been performed. + */ + private bool $discovered = false; + + /** + * Create a new SeederDiscovery instance. + * + * @param array $paths Directories to scan for modules + * @param array $excluded Seeder classes to exclude + */ + public function __construct(array $paths = [], array $excluded = []) + { + $this->paths = $paths; + $this->excluded = $excluded; + } + + /** + * Add paths to scan for seeders. + * + * @param array $paths Directories to add + * @return $this + */ + public function addPaths(array $paths): self + { + $this->paths = array_merge($this->paths, $paths); + $this->discovered = false; + + return $this; + } + + /** + * Set paths to scan for seeders. + * + * @param array $paths Directories to scan + * @return $this + */ + public function setPaths(array $paths): self + { + $this->paths = $paths; + $this->discovered = false; + + return $this; + } + + /** + * Add seeder classes to exclude. + * + * @param array $classes Seeder class names to exclude + * @return $this + */ + public function exclude(array $classes): self + { + $this->excluded = array_merge($this->excluded, $classes); + + return $this; + } + + /** + * Discover and return ordered seeder classes. + * + * @return array Ordered list of seeder class names + * + * @throws CircularDependencyException If a circular dependency is detected + */ + public function discover(): array + { + if (! $this->discovered) { + $this->scanPaths(); + $this->discovered = true; + } + + return $this->sort(); + } + + /** + * Get all discovered seeders with their metadata. + * + * @return array, before: array}> + */ + public function getSeeders(): array + { + if (! $this->discovered) { + $this->scanPaths(); + $this->discovered = true; + } + + return $this->seeders; + } + + /** + * Reset the discovery cache. + * + * @return $this + */ + public function reset(): self + { + $this->seeders = []; + $this->discovered = false; + + return $this; + } + + /** + * Scan configured paths for seeder classes. + */ + private function scanPaths(): void + { + $this->seeders = []; + + foreach ($this->paths as $path) { + $this->scanPath($path); + } + } + + /** + * Scan a single path for seeder classes. + * + * @param string $path Directory to scan + */ + private function scanPath(string $path): void + { + if (! is_dir($path)) { + return; + } + + // Look for Database/Seeders directories in immediate subdirectories + $pattern = "{$path}/*/Database/Seeders/*Seeder.php"; + $files = glob($pattern) ?: []; + + // Also check for seeders directly in the path (for Core modules) + $directPattern = "{$path}/Database/Seeders/*Seeder.php"; + $directFiles = glob($directPattern) ?: []; + $files = array_merge($files, $directFiles); + + foreach ($files as $file) { + $class = $this->classFromFile($file); + + if ($class && class_exists($class) && ! in_array($class, $this->excluded, true)) { + $this->seeders[$class] = $this->extractMetadata($class); + } + } + } + + /** + * Derive class name from file path. + * + * @param string $file Path to the seeder file + * @return string|null Fully qualified class name, or null if not determinable + */ + private function classFromFile(string $file): ?string + { + $contents = file_get_contents($file); + if ($contents === false) { + return null; + } + + // Extract namespace + if (preg_match('/namespace\s+([^;]+);/', $contents, $nsMatch)) { + $namespace = $nsMatch[1]; + } else { + return null; + } + + // Extract class name + if (preg_match('/class\s+(\w+)/', $contents, $classMatch)) { + $className = $classMatch[1]; + } else { + return null; + } + + return $namespace.'\\'.$className; + } + + /** + * Extract ordering metadata from a seeder class. + * + * @param string $class Seeder class name + * @return array{priority: int, after: array, before: array} + */ + private function extractMetadata(string $class): array + { + $reflection = new ReflectionClass($class); + + return [ + 'priority' => $this->extractPriority($reflection), + 'after' => $this->extractAfter($reflection), + 'before' => $this->extractBefore($reflection), + ]; + } + + /** + * Extract priority from a seeder class. + * + * Checks for SeederPriority attribute first, then falls back to + * public $priority property. + * + * @param ReflectionClass $reflection Reflection of the seeder class + * @return int Priority value + */ + private function extractPriority(ReflectionClass $reflection): int + { + // Check for attribute first + $attributes = $reflection->getAttributes(SeederPriority::class); + if (! empty($attributes)) { + return $attributes[0]->newInstance()->priority; + } + + // Fall back to property + if ($reflection->hasProperty('priority')) { + $prop = $reflection->getProperty('priority'); + if ($prop->isPublic() && ! $prop->isStatic()) { + $defaultProps = $reflection->getDefaultProperties(); + if (isset($defaultProps['priority']) && is_int($defaultProps['priority'])) { + return $defaultProps['priority']; + } + } + } + + return self::DEFAULT_PRIORITY; + } + + /** + * Extract 'after' dependencies from a seeder class. + * + * Checks for SeederAfter attributes first, then falls back to + * public $after property. + * + * @param ReflectionClass $reflection Reflection of the seeder class + * @return array Seeder classes that must run before this one + */ + private function extractAfter(ReflectionClass $reflection): array + { + $after = []; + + // Check for attributes + $attributes = $reflection->getAttributes(SeederAfter::class); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $after = array_merge($after, $instance->seeders); + } + + // If no attributes, check for property + if (empty($after) && $reflection->hasProperty('after')) { + $prop = $reflection->getProperty('after'); + if ($prop->isPublic() && ! $prop->isStatic()) { + $defaultProps = $reflection->getDefaultProperties(); + if (isset($defaultProps['after']) && is_array($defaultProps['after'])) { + $after = $defaultProps['after']; + } + } + } + + return $after; + } + + /** + * Extract 'before' dependencies from a seeder class. + * + * Checks for SeederBefore attributes first, then falls back to + * public $before property. + * + * @param ReflectionClass $reflection Reflection of the seeder class + * @return array Seeder classes that must run after this one + */ + private function extractBefore(ReflectionClass $reflection): array + { + $before = []; + + // Check for attributes + $attributes = $reflection->getAttributes(SeederBefore::class); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $before = array_merge($before, $instance->seeders); + } + + // If no attributes, check for property + if (empty($before) && $reflection->hasProperty('before')) { + $prop = $reflection->getProperty('before'); + if ($prop->isPublic() && ! $prop->isStatic()) { + $defaultProps = $reflection->getDefaultProperties(); + if (isset($defaultProps['before']) && is_array($defaultProps['before'])) { + $before = $defaultProps['before']; + } + } + } + + return $before; + } + + /** + * Topologically sort seeders based on dependencies and priority. + * + * Lower priority values run first (e.g., priority 10 runs before priority 50). + * + * @return array Ordered seeder class names + * + * @throws CircularDependencyException If a circular dependency is detected + */ + private function sort(): array + { + // Build adjacency list (seeder -> seeders that must run before it) + $dependencies = []; + foreach ($this->seeders as $seeder => $meta) { + $dependencies[$seeder] = $meta['after']; + + // Process 'before' declarations (reverse dependencies) + foreach ($meta['before'] as $dependent) { + if (isset($this->seeders[$dependent])) { + $dependencies[$dependent][] = $seeder; + } + } + } + + // Normalize dependencies to unique values + foreach ($dependencies as $seeder => $deps) { + $dependencies[$seeder] = array_unique($deps); + } + + // Kahn's algorithm for topological sort + $inDegree = []; + $graph = []; + + // Initialize + foreach ($dependencies as $seeder => $deps) { + if (! isset($inDegree[$seeder])) { + $inDegree[$seeder] = 0; + } + if (! isset($graph[$seeder])) { + $graph[$seeder] = []; + } + + foreach ($deps as $dep) { + // Only count dependencies that exist in our discovered seeders + if (isset($this->seeders[$dep])) { + $inDegree[$seeder]++; + $graph[$dep][] = $seeder; + } + } + } + + // Start with seeders that have no dependencies + $queue = []; + foreach ($inDegree as $seeder => $degree) { + if ($degree === 0) { + $queue[] = $seeder; + } + } + + // Sort queue by priority (lower priority first - lower numbers run first) + usort($queue, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']); + + $sorted = []; + $processed = 0; + + while (! empty($queue)) { + $seeder = array_shift($queue); + $sorted[] = $seeder; + $processed++; + + // Collect dependents that become ready + $ready = []; + foreach ($graph[$seeder] ?? [] as $dependent) { + $inDegree[$dependent]--; + if ($inDegree[$dependent] === 0) { + $ready[] = $dependent; + } + } + + // Sort newly ready seeders by priority and add to queue + usort($ready, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']); + $queue = array_merge($ready, $queue); + + // Re-sort the entire queue to maintain priority order + usort($queue, fn ($a, $b) => $this->seeders[$a]['priority'] <=> $this->seeders[$b]['priority']); + } + + // Check for cycles + if ($processed < count($this->seeders)) { + $this->detectCycle($dependencies); + } + + return $sorted; + } + + /** + * Detect and report a cycle in the dependency graph. + * + * @param array> $dependencies Adjacency list + * + * @throws CircularDependencyException + */ + private function detectCycle(array $dependencies): void + { + $visited = []; + $recStack = []; + $path = []; + + foreach (array_keys($this->seeders) as $seeder) { + if ($this->dfsDetectCycle($seeder, $dependencies, $visited, $recStack, $path)) { + return; // Exception already thrown + } + } + + // If we get here, there's a cycle but we couldn't find it + throw new CircularDependencyException(['Unknown cycle detected']); + } + + /** + * DFS helper for cycle detection. + * + * @param string $seeder Current seeder being visited + * @param array> $dependencies Adjacency list + * @param array $visited Fully processed nodes + * @param array $recStack Nodes in current recursion stack + * @param array $path Current path for error reporting + * + * @throws CircularDependencyException If a cycle is detected + */ + private function dfsDetectCycle( + string $seeder, + array $dependencies, + array &$visited, + array &$recStack, + array &$path + ): bool { + if (! isset($this->seeders[$seeder])) { + return false; + } + + if (isset($recStack[$seeder])) { + throw CircularDependencyException::fromPath($path, $seeder); + } + + if (isset($visited[$seeder])) { + return false; + } + + $visited[$seeder] = true; + $recStack[$seeder] = true; + $path[] = $seeder; + + foreach ($dependencies[$seeder] ?? [] as $dep) { + if ($this->dfsDetectCycle($dep, $dependencies, $visited, $recStack, $path)) { + return true; + } + } + + array_pop($path); + unset($recStack[$seeder]); + + return false; + } +} diff --git a/packages/core-php/src/Core/Database/Seeders/SeederRegistry.php b/packages/core-php/src/Core/Database/Seeders/SeederRegistry.php new file mode 100644 index 0000000..b7e9a29 --- /dev/null +++ b/packages/core-php/src/Core/Database/Seeders/SeederRegistry.php @@ -0,0 +1,189 @@ +register(FeatureSeeder::class, priority: 10) + * ->register(PackageSeeder::class, after: [FeatureSeeder::class]) + * ->register(WorkspaceSeeder::class, after: [PackageSeeder::class]); + * + * // Get ordered seeders + * $seeders = $registry->getOrdered(); + * ``` + * + * + * @see SeederDiscovery For auto-discovered seeders + */ +class SeederRegistry +{ + /** + * Registered seeder metadata. + * + * @var array, before: array}> + */ + private array $seeders = []; + + /** + * Register a seeder class. + * + * @param string $class Fully qualified seeder class name + * @param int $priority Priority (higher runs first, default 50) + * @param array $after Seeders that must run before this one + * @param array $before Seeders that must run after this one + * @return $this + */ + public function register( + string $class, + int $priority = SeederDiscovery::DEFAULT_PRIORITY, + array $after = [], + array $before = [] + ): self { + $this->seeders[$class] = [ + 'priority' => $priority, + 'after' => $after, + 'before' => $before, + ]; + + return $this; + } + + /** + * Register multiple seeders at once. + * + * @param array, before?: array}|int> $seeders + * Either [Class => priority] or [Class => ['priority' => n, 'after' => [], 'before' => []]] + * @return $this + */ + public function registerMany(array $seeders): self + { + foreach ($seeders as $class => $config) { + if (is_int($config)) { + $this->register($class, priority: $config); + } else { + $this->register( + $class, + priority: $config['priority'] ?? SeederDiscovery::DEFAULT_PRIORITY, + after: $config['after'] ?? [], + before: $config['before'] ?? [] + ); + } + } + + return $this; + } + + /** + * Remove a seeder from the registry. + * + * @param string $class Seeder class to remove + * @return $this + */ + public function remove(string $class): self + { + unset($this->seeders[$class]); + + return $this; + } + + /** + * Check if a seeder is registered. + * + * @param string $class Seeder class to check + */ + public function has(string $class): bool + { + return isset($this->seeders[$class]); + } + + /** + * Get all registered seeders. + * + * @return array, before: array}> + */ + public function all(): array + { + return $this->seeders; + } + + /** + * Get ordered seeder classes. + * + * @return array Ordered list of seeder class names + * + * @throws CircularDependencyException If a circular dependency is detected + */ + public function getOrdered(): array + { + // Use SeederDiscovery's sorting logic by creating a temporary instance + $discovery = new class extends SeederDiscovery + { + /** + * @param array, before: array}> $seeders + */ + public function setSeeders(array $seeders): void + { + $reflection = new \ReflectionClass(SeederDiscovery::class); + $prop = $reflection->getProperty('seeders'); + $prop->setValue($this, $seeders); + + $discovered = $reflection->getProperty('discovered'); + $discovered->setValue($this, true); + } + }; + + $discovery->setSeeders($this->seeders); + + return $discovery->discover(); + } + + /** + * Merge another registry into this one. + * + * @param SeederRegistry $registry Registry to merge + * @return $this + */ + public function merge(SeederRegistry $registry): self + { + foreach ($registry->all() as $class => $meta) { + if (! isset($this->seeders[$class])) { + $this->seeders[$class] = $meta; + } + } + + return $this; + } + + /** + * Clear all registered seeders. + * + * @return $this + */ + public function clear(): self + { + $this->seeders = []; + + return $this; + } +} diff --git a/packages/core-php/src/Core/Events/Concerns/HasEventVersion.php b/packages/core-php/src/Core/Events/Concerns/HasEventVersion.php new file mode 100644 index 0000000..3fc1072 --- /dev/null +++ b/packages/core-php/src/Core/Events/Concerns/HasEventVersion.php @@ -0,0 +1,97 @@ + 'onWebRoutes', + * ]; + * + * // Declare minimum event versions this module requires + * protected static array $eventVersions = [ + * WebRoutesRegistering::class => 1, + * ]; + * + * public function onWebRoutes(WebRoutesRegistering $event): void + * { + * // Handle event + * } + * } + * ``` + * + * ## Version Checking + * + * During bootstrap, the framework checks version compatibility: + * - If a handler requires a version higher than available, a warning is logged + * - If a handler uses a deprecated version, a deprecation notice is raised + * + * @package Core\Events\Concerns + */ +trait HasEventVersion +{ + /** + * Get the required event version for a given event class. + * + * Returns the version number from $eventVersions if defined, + * or 1 (the baseline version) if not specified. + * + * @param string $eventClass The event class name + * @return int The required version number + */ + public static function getRequiredEventVersion(string $eventClass): int + { + if (property_exists(static::class, 'eventVersions')) { + return static::$eventVersions[$eventClass] ?? 1; + } + + return 1; + } + + /** + * Check if this module is compatible with an event version. + * + * @param string $eventClass The event class name + * @param int $availableVersion The available event API version + * @return bool True if the module can handle this event version + */ + public static function isCompatibleWithEventVersion(string $eventClass, int $availableVersion): bool + { + $required = static::getRequiredEventVersion($eventClass); + + return $availableVersion >= $required; + } + + /** + * Get all declared event version requirements. + * + * @return array Map of event class to required version + */ + public static function getEventVersionRequirements(): array + { + if (property_exists(static::class, 'eventVersions')) { + return static::$eventVersions; + } + + return []; + } +} diff --git a/packages/core-php/src/Core/Events/LifecycleEvent.php b/packages/core-php/src/Core/Events/LifecycleEvent.php index cdb9769..8daa00b 100644 --- a/packages/core-php/src/Core/Events/LifecycleEvent.php +++ b/packages/core-php/src/Core/Events/LifecycleEvent.php @@ -17,6 +17,142 @@ namespace Core\Events; * listen to these events via static `$listens` arrays in their Boot class and * register their resources through the request methods provided here. * + * ## Event Flow Diagram + * + * The following diagram shows how lifecycle events flow through the system: + * + * ``` + * ┌─────────────────────────────────────────────────────────────────────────────┐ + * │ LIFECYCLE EVENT FLOW │ + * └─────────────────────────────────────────────────────────────────────────────┘ + * + * ┌──────────────────────┐ + * │ Application Start │ + * └──────────┬───────────┘ + * │ + * ▼ + * ┌──────────────────────┐ ┌─────────────────────────────────────────┐ + * │ ModuleScanner │────►│ Scans app/Core, app/Mod, app/Website │ + * │ (Discovery Phase) │ │ for Boot.php files with $listens │ + * └──────────┬───────────┘ └─────────────────────────────────────────┘ + * │ + * ▼ + * ┌──────────────────────┐ ┌─────────────────────────────────────────┐ + * │ ModuleRegistry │────►│ Registers LazyModuleListener for each │ + * │ (Registration) │ │ event-module pair with Laravel Events │ + * └──────────┬───────────┘ └─────────────────────────────────────────┘ + * │ + * ▼ + * ┌──────────────────────────────────────────────────────────────────────┐ + * │ REQUEST CONTEXT DETECTION │ + * └──────────────────────────────────────────────────────────────────────┘ + * │ + * ┌───────┴───────┬───────────┬───────────┬───────────┬───────────┐ + * │ │ │ │ │ │ + * ▼ ▼ ▼ ▼ ▼ ▼ + * ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ + * │ Web │ │ Admin │ │ API │ │ Client │ │ Console │ │ Queue │ + * │ Routes │ │ Panel │ │ Routes │ │ Routes │ │ Booting │ │ Worker │ + * └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ + * │ │ │ │ │ │ + * ▼ ▼ ▼ ▼ ▼ ▼ + * ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ + * │WebRoutes│ │AdminPanel││ApiRoutes│ │Client │ │Console │ │QueueWkr │ + * │Register-│ │Booting │ │Register-│ │Routes │ │Booting │ │Booting │ + * │ing │ │ │ │ing │ │Register-│ │ │ │ │ + * └────┬────┘ └────┬────┘ └────┬────┘ │ing │ └────┬────┘ └────┬────┘ + * │ │ │ └────┬────┘ │ │ + * └───────────────┴───────────┴───────────┴───────────┴───────────┘ + * │ + * ▼ + * ┌──────────────────────┐ + * │ FrameworkBooted │ + * │ (After all booted) │ + * └──────────────────────┘ + * ``` + * + * ## Event Order + * + * Events fire in a specific order based on request context: + * + * | Order | Event | Context | Middleware | + * |-------|-------|---------|------------| + * | 1 | `WebRoutesRegistering` | Web requests | 'web' | + * | 1 | `AdminPanelBooting` | Admin requests | 'admin' | + * | 1 | `ApiRoutesRegistering` | API requests | 'api' | + * | 1 | `ClientRoutesRegistering` | Client dashboard | 'client' | + * | 1 | `ConsoleBooting` | CLI commands | - | + * | 1 | `QueueWorkerBooting` | Queue workers | - | + * | 1 | `McpToolsRegistering` | MCP server | - | + * | 2 | `FrameworkBooted` | All contexts | - | + * + * Note: Events marked "1" are mutually exclusive based on context. + * + * ## Module Registration Flow + * + * ``` + * ┌─────────────────────────────────────────────────────────────────────────────┐ + * │ MODULE REGISTRATION FLOW │ + * └─────────────────────────────────────────────────────────────────────────────┘ + * + * Module Boot.php Framework + * ───────────────── ───────── + * │ + * │ public static array $listens = [ + * │ WebRoutesRegistering::class => 'onWebRoutes', + * │ AdminPanelBooting::class => ['onAdmin', 10], + * │ ]; + * │ + * │ │ + * │◄──────────────────────────────────┤ ModuleScanner reads $listens + * │ │ without instantiation + * │ │ + * │ ▼ + * │ ┌─────────────────────┐ + * │ │ ModuleRegistry │ + * │ │ sorts by priority │ + * │ │ (10 runs before 0) │ + * │ └──────────┬──────────┘ + * │ │ + * │ ▼ + * │ ┌─────────────────────┐ + * │ │ LazyModuleListener │ + * │ │ registered with │ + * │ │ Laravel Events │ + * │ └──────────┬──────────┘ + * │ │ + * │ │ Event fires + * │ ▼ + * │ ┌─────────────────────┐ + * │ │ LazyModuleListener │ + * │◄───────────────────────────┤ instantiates module │ + * │ Module instantiated │ via container │ + * │ only when event fires └──────────┬──────────┘ + * │ │ + * ▼ │ + * ┌─────────────┐ │ + * │ onWebRoutes │◄──────────────────────────────┘ + * │ ($event) │ Method called with event + * └──────┬──────┘ + * │ + * ▼ + * ┌──────────────────────────────────────────────────────┐ + * │ $event->routes(fn () => require __DIR__.'/web.php'); │ + * │ $event->views('mymod', __DIR__.'/Views'); │ + * │ $event->livewire('my-comp', MyComponent::class); │ + * └──────────────────────────────────────────────────────┘ + * │ + * │ Requests collected in event + * ▼ + * ┌─────────────────────────────────────────────────────────────┐ + * │ LifecycleEventProvider processes requests: │ + * │ - Registers view namespaces │ + * │ - Registers Livewire components │ + * │ - Wraps routes with appropriate middleware │ + * │ - Refreshes route lookups │ + * └─────────────────────────────────────────────────────────────┘ + * ``` + * * ## Request/Collect Pattern * * This class implements a "request/collect" pattern rather than direct mutation: @@ -42,6 +178,17 @@ namespace Core\Events; * | `policy()` | Register model policies | * | `navigation()` | Register navigation items | * + * ## Event Versioning + * + * Events support versioning for backwards compatibility. The version number + * indicates the API contract version: + * + * - Version 1: Original API (current) + * - Future versions may add methods but maintain backwards compatibility + * + * Check version with `$event->version()` in your handlers to support multiple + * event versions during transitions. + * * ## Usage Example * * ```php @@ -55,10 +202,47 @@ namespace Core\Events; * * @package Core\Events * + * @method void navigation(array $item) Request a navigation item be added + * @method void routes(callable $callback) Request routes be registered + * @method void views(string $namespace, string $path) Request a view namespace be registered + * @method void middleware(string $alias, string $class) Request a middleware alias be registered + * @method void livewire(string $alias, string $class) Request a Livewire component be registered + * @method void command(string $class) Request an Artisan command be registered + * @method void translations(string $namespace, string $path) Request translations be loaded + * @method void bladeComponentPath(string $path, ?string $namespace = null) Request a Blade component path + * @method void policy(string $model, string $policy) Request a policy be registered + * @method array navigationRequests() Get collected navigation requests + * @method array routeRequests() Get collected route requests + * @method array viewRequests() Get collected view requests + * @method array middlewareRequests() Get collected middleware requests + * @method array livewireRequests() Get collected Livewire requests + * @method array commandRequests() Get collected command requests + * @method array translationRequests() Get collected translation requests + * @method array bladeComponentRequests() Get collected Blade component requests + * @method array policyRequests() Get collected policy requests + * * @see LifecycleEventProvider For event processing */ abstract class LifecycleEvent { + /** + * Event API version. + * + * Increment this when making breaking changes to the event interface. + * Handlers can check this to maintain backwards compatibility. + * + * Version history: + * - 1: Initial release (Core PHP 1.0) + */ + public const VERSION = 1; + + /** + * Minimum supported handler version. + * + * Handlers declaring a version lower than this will receive a deprecation warning. + */ + public const MIN_SUPPORTED_VERSION = 1; + /** @var array> Collected navigation item requests */ protected array $navigationRequests = []; @@ -326,4 +510,53 @@ abstract class LifecycleEvent { return $this->policyRequests; } + + /** + * Get the event API version. + * + * Use this in your event handlers to check API compatibility: + * + * ```php + * public function onWebRoutes(WebRoutesRegistering $event): void + * { + * if ($event->version() >= 2) { + * // Use new v2 features + * } else { + * // Fallback to v1 behavior + * } + * } + * ``` + * + * @return int The event API version number + */ + public function version(): int + { + return static::VERSION; + } + + /** + * Check if this event supports a specific version. + * + * Returns true if the event's version is greater than or equal to the + * requested version. + * + * @param int $version The version to check against + * @return bool True if the event supports the specified version + */ + public function supportsVersion(int $version): bool + { + return static::VERSION >= $version; + } + + /** + * Get the event class name without namespace. + * + * Useful for logging and debugging. + * + * @return string The short class name (e.g., 'WebRoutesRegistering') + */ + public function eventName(): string + { + return class_basename(static::class); + } } diff --git a/packages/core-php/src/Core/Events/ListenerProfiler.php b/packages/core-php/src/Core/Events/ListenerProfiler.php new file mode 100644 index 0000000..ab294dc --- /dev/null +++ b/packages/core-php/src/Core/Events/ListenerProfiler.php @@ -0,0 +1,549 @@ +50ms as slow + * ``` + * + * ## Retrieving Metrics + * + * ```php + * $profiles = ListenerProfiler::getProfiles(); // All listener profiles + * $slow = ListenerProfiler::getSlowListeners(); // Listeners exceeding threshold + * $sorted = ListenerProfiler::getSlowest(10); // Top 10 slowest listeners + * $byEvent = ListenerProfiler::getProfilesForEvent(WebRoutesRegistering::class); + * $summary = ListenerProfiler::getSummary(); // Overall statistics + * ``` + * + * ## Profile Structure + * + * Each profile contains: + * - `event` - Event class name + * - `handler` - Handler class name + * - `method` - Handler method name + * - `duration_ms` - Total execution time (milliseconds) + * - `memory_peak_bytes` - Peak memory usage during execution + * - `memory_delta_bytes` - Memory change during execution + * - `call_count` - Number of invocations + * - `avg_duration_ms` - Average time per call + * - `is_slow` - Whether any call exceeded slow threshold + * - `calls` - Array of individual call metrics + * + * ## Integration with LazyModuleListener + * + * Enable automatic profiling integration: + * + * ```php + * ListenerProfiler::enable(); + * // Profiling is automatically integrated via LazyModuleListener + * ``` + * + * @package Core\Events + * + * @see EventAuditLog For simpler success/failure tracking + * @see LazyModuleListener For automatic profiling integration + */ +class ListenerProfiler +{ + private static bool $enabled = false; + + /** + * Threshold in milliseconds for flagging slow listeners. + */ + private static float $slowThreshold = 100.0; + + /** + * Collected profile data. + * + * @var array + * }> + */ + private static array $profiles = []; + + /** + * Active profiling contexts (for nested calls). + * + * @var array + */ + private static array $activeContexts = []; + + /** + * Enable listener profiling. + */ + public static function enable(): void + { + self::$enabled = true; + } + + /** + * Disable listener profiling. + */ + public static function disable(): void + { + self::$enabled = false; + } + + /** + * Check if profiling is enabled. + */ + public static function isEnabled(): bool + { + return self::$enabled; + } + + /** + * Set the threshold for flagging slow listeners. + * + * @param float $thresholdMs Threshold in milliseconds + */ + public static function setSlowThreshold(float $thresholdMs): void + { + self::$slowThreshold = $thresholdMs; + } + + /** + * Get the current slow listener threshold. + * + * @return float Threshold in milliseconds + */ + public static function getSlowThreshold(): float + { + return self::$slowThreshold; + } + + /** + * Start profiling a listener execution. + * + * Call this before invoking the listener. Returns a context key that must + * be passed to stop() to properly correlate the measurement. + * + * @param string $eventClass Event being handled + * @param string $handlerClass Handler class name + * @param string $method Handler method name + * @return string Context key for stop() + */ + public static function start(string $eventClass, string $handlerClass, string $method = '__invoke'): string + { + if (! self::$enabled) { + return ''; + } + + $contextKey = self::makeContextKey($eventClass, $handlerClass, $method); + + self::$activeContexts[$contextKey] = [ + 'start_time' => hrtime(true), + 'memory_before' => memory_get_usage(true), + ]; + + return $contextKey; + } + + /** + * Stop profiling and record the results. + * + * @param string $contextKey Key returned by start() + */ + public static function stop(string $contextKey): void + { + if (! self::$enabled || $contextKey === '' || ! isset(self::$activeContexts[$contextKey])) { + return; + } + + $context = self::$activeContexts[$contextKey]; + unset(self::$activeContexts[$contextKey]); + + $endTime = hrtime(true); + $memoryAfter = memory_get_usage(true); + $memoryPeak = memory_get_peak_usage(true); + + $durationNs = $endTime - $context['start_time']; + $durationMs = $durationNs / 1_000_000; + + // Parse context key to get event, handler, method + [$eventClass, $handlerClass, $method] = self::parseContextKey($contextKey); + + $profileKey = self::makeProfileKey($eventClass, $handlerClass); + + // Initialize profile if needed + if (! isset(self::$profiles[$profileKey])) { + self::$profiles[$profileKey] = [ + 'event' => $eventClass, + 'handler' => $handlerClass, + 'method' => $method, + 'duration_ms' => 0.0, + 'memory_peak_bytes' => 0, + 'memory_delta_bytes' => 0, + 'call_count' => 0, + 'avg_duration_ms' => 0.0, + 'is_slow' => false, + 'calls' => [], + ]; + } + + // Record this call + $callData = [ + 'duration_ms' => round($durationMs, 3), + 'memory_before' => $context['memory_before'], + 'memory_after' => $memoryAfter, + 'memory_peak' => $memoryPeak, + ]; + + self::$profiles[$profileKey]['calls'][] = $callData; + self::$profiles[$profileKey]['duration_ms'] += $durationMs; + self::$profiles[$profileKey]['call_count']++; + + // Update peak memory if this call used more + $memoryDelta = $memoryAfter - $context['memory_before']; + if ($memoryPeak > self::$profiles[$profileKey]['memory_peak_bytes']) { + self::$profiles[$profileKey]['memory_peak_bytes'] = $memoryPeak; + } + self::$profiles[$profileKey]['memory_delta_bytes'] += $memoryDelta; + + // Update average + self::$profiles[$profileKey]['avg_duration_ms'] = round( + self::$profiles[$profileKey]['duration_ms'] / self::$profiles[$profileKey]['call_count'], + 3 + ); + + // Check if slow + if ($durationMs >= self::$slowThreshold) { + self::$profiles[$profileKey]['is_slow'] = true; + } + } + + /** + * Profile a listener execution using a callback. + * + * Convenience method that handles start/stop automatically. + * + * ```php + * ListenerProfiler::profile( + * WebRoutesRegistering::class, + * MyModule::class, + * 'onWebRoutes', + * fn() => $handler->onWebRoutes($event) + * ); + * ``` + * + * @template T + * @param string $eventClass Event being handled + * @param string $handlerClass Handler class name + * @param string $method Handler method name + * @param callable(): T $callback The listener callback to profile + * @return T The callback's return value + */ + public static function profile(string $eventClass, string $handlerClass, string $method, callable $callback): mixed + { + $contextKey = self::start($eventClass, $handlerClass, $method); + + try { + return $callback(); + } finally { + self::stop($contextKey); + } + } + + /** + * Get all collected profiles. + * + * @return array + * }> + */ + public static function getProfiles(): array + { + return self::$profiles; + } + + /** + * Get profiles for a specific event. + * + * @param string $eventClass Event class name + * @return array + */ + public static function getProfilesForEvent(string $eventClass): array + { + return array_filter( + self::$profiles, + fn($profile) => $profile['event'] === $eventClass + ); + } + + /** + * Get profiles for a specific handler. + * + * @param string $handlerClass Handler class name + * @return array + */ + public static function getProfilesForHandler(string $handlerClass): array + { + return array_filter( + self::$profiles, + fn($profile) => $profile['handler'] === $handlerClass + ); + } + + /** + * Get listeners that exceeded the slow threshold. + * + * @return array + */ + public static function getSlowListeners(): array + { + return array_filter( + self::$profiles, + fn($profile) => $profile['is_slow'] + ); + } + + /** + * Get the N slowest listeners by total duration. + * + * @param int $limit Maximum number of results + * @return array + */ + public static function getSlowest(int $limit = 10): array + { + $profiles = self::$profiles; + uasort($profiles, fn($a, $b) => $b['duration_ms'] <=> $a['duration_ms']); + + return array_slice($profiles, 0, $limit, true); + } + + /** + * Get the N highest memory-consuming listeners. + * + * @param int $limit Maximum number of results + * @return array + */ + public static function getHighestMemory(int $limit = 10): array + { + $profiles = self::$profiles; + uasort($profiles, fn($a, $b) => $b['memory_delta_bytes'] <=> $a['memory_delta_bytes']); + + return array_slice($profiles, 0, $limit, true); + } + + /** + * Get summary statistics for all profiled listeners. + * + * @return array{ + * total_listeners: int, + * total_calls: int, + * total_duration_ms: float, + * avg_duration_ms: float, + * slow_listeners: int, + * total_memory_delta_bytes: int, + * by_event: array + * } + */ + public static function getSummary(): array + { + $totalListeners = count(self::$profiles); + $totalCalls = 0; + $totalDuration = 0.0; + $slowCount = 0; + $totalMemoryDelta = 0; + $byEvent = []; + + foreach (self::$profiles as $profile) { + $totalCalls += $profile['call_count']; + $totalDuration += $profile['duration_ms']; + $totalMemoryDelta += $profile['memory_delta_bytes']; + + if ($profile['is_slow']) { + $slowCount++; + } + + $event = $profile['event']; + if (! isset($byEvent[$event])) { + $byEvent[$event] = [ + 'listeners' => 0, + 'duration_ms' => 0.0, + 'calls' => 0, + ]; + } + $byEvent[$event]['listeners']++; + $byEvent[$event]['duration_ms'] += $profile['duration_ms']; + $byEvent[$event]['calls'] += $profile['call_count']; + } + + return [ + 'total_listeners' => $totalListeners, + 'total_calls' => $totalCalls, + 'total_duration_ms' => round($totalDuration, 3), + 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 3) : 0.0, + 'slow_listeners' => $slowCount, + 'total_memory_delta_bytes' => $totalMemoryDelta, + 'by_event' => $byEvent, + ]; + } + + /** + * Clear all collected profiles. + */ + public static function clear(): void + { + self::$profiles = []; + self::$activeContexts = []; + } + + /** + * Reset to initial state (disable and clear). + */ + public static function reset(): void + { + self::$enabled = false; + self::$slowThreshold = 100.0; + self::clear(); + } + + /** + * Export profiles to a format suitable for analysis tools. + * + * @return array{ + * timestamp: string, + * slow_threshold_ms: float, + * summary: array, + * profiles: array + * } + */ + public static function export(): array + { + return [ + 'timestamp' => date('c'), + 'slow_threshold_ms' => self::$slowThreshold, + 'summary' => self::getSummary(), + 'profiles' => self::$profiles, + ]; + } + + /** + * Create a unique context key for a listener execution. + */ + private static function makeContextKey(string $eventClass, string $handlerClass, string $method): string + { + $uniqueId = bin2hex(random_bytes(8)); + return "{$eventClass}|{$handlerClass}|{$method}|{$uniqueId}"; + } + + /** + * Parse a context key back into its components. + * + * @return array{0: string, 1: string, 2: string} [event, handler, method] + */ + private static function parseContextKey(string $contextKey): array + { + $parts = explode('|', $contextKey); + return [$parts[0] ?? '', $parts[1] ?? '', $parts[2] ?? '']; + } + + /** + * Create a profile key for aggregating calls to the same listener. + */ + private static function makeProfileKey(string $eventClass, string $handlerClass): string + { + return "{$eventClass}::{$handlerClass}"; + } +} diff --git a/packages/core-php/src/Core/Front/Admin/AdminMenuRegistry.php b/packages/core-php/src/Core/Front/Admin/AdminMenuRegistry.php index fa0987a..d7ebc1e 100644 --- a/packages/core-php/src/Core/Front/Admin/AdminMenuRegistry.php +++ b/packages/core-php/src/Core/Front/Admin/AdminMenuRegistry.php @@ -12,6 +12,7 @@ namespace Core\Front\Admin; use Core\Front\Admin\Contracts\AdminMenuProvider; use Core\Front\Admin\Contracts\DynamicMenuProvider; +use Core\Front\Admin\Validation\IconValidator; use Illuminate\Support\Facades\Cache; /** @@ -94,7 +95,17 @@ class AdminMenuRegistry */ protected ?object $entitlements = null; - public function __construct(?object $entitlements = null) + /** + * Icon validator instance. + */ + protected ?IconValidator $iconValidator = null; + + /** + * Whether icon validation is enabled. + */ + protected bool $validateIcons = true; + + public function __construct(?object $entitlements = null, ?IconValidator $iconValidator = null) { if ($entitlements === null && class_exists(\Core\Mod\Tenant\Services\EntitlementService::class)) { $this->entitlements = app(\Core\Mod\Tenant\Services\EntitlementService::class); @@ -102,8 +113,10 @@ class AdminMenuRegistry $this->entitlements = $entitlements; } + $this->iconValidator = $iconValidator ?? new IconValidator(); $this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL); $this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true); + $this->validateIcons = (bool) config('core.admin_menu.validate_icons', true); } /** @@ -539,6 +552,73 @@ class AdminMenuRegistry return $this->groups[$key] ?? []; } + /** + * Get the icon validator instance. + */ + public function getIconValidator(): IconValidator + { + return $this->iconValidator; + } + + /** + * Enable or disable icon validation. + */ + public function setIconValidation(bool $enabled): void + { + $this->validateIcons = $enabled; + } + + /** + * Validate an icon and return whether it's valid. + * + * @param string $icon The icon name to validate + * @return bool True if valid, false otherwise + */ + public function validateIcon(string $icon): bool + { + if (! $this->validateIcons || $this->iconValidator === null) { + return true; + } + + return $this->iconValidator->isValid($icon); + } + + /** + * Validate a menu item's icon. + * + * @param array $item The menu item array + * @return array Array of validation error messages (empty if valid) + */ + public function validateMenuItem(array $item): array + { + $errors = []; + + if (! $this->validateIcons || $this->iconValidator === null) { + return $errors; + } + + $icon = $item['icon'] ?? null; + if ($icon !== null && ! empty($icon)) { + $iconErrors = $this->iconValidator->validate($icon); + $errors = array_merge($errors, $iconErrors); + } + + // Validate children icons if present + if (! empty($item['children'])) { + foreach ($item['children'] as $index => $child) { + $childIcon = $child['icon'] ?? null; + if ($childIcon !== null && ! empty($childIcon)) { + $childErrors = $this->iconValidator->validate($childIcon); + foreach ($childErrors as $error) { + $errors[] = "Child item {$index}: {$error}"; + } + } + } + } + + return $errors; + } + /** * Get all service menu items indexed by service key. * diff --git a/packages/core-php/src/Core/Front/Admin/Blade/components/card-grid.blade.php b/packages/core-php/src/Core/Front/Admin/Blade/components/card-grid.blade.php index f4ddb56..6fb5a66 100644 --- a/packages/core-php/src/Core/Front/Admin/Blade/components/card-grid.blade.php +++ b/packages/core-php/src/Core/Front/Admin/Blade/components/card-grid.blade.php @@ -10,7 +10,7 @@ Each card in $cards array: - subtitle: subtitle text (optional) - status: { label: 'Online', color: 'green' } (optional) - stats: [{ label: 'CPU', value: '45%', progress: 45, progressColor: 'green' }] (optional) -- details: [{ label: 'Type', value: 'WordPress' }] (optional) +- details: [{ label: 'Type', value: 'CMS' }] (optional) - footer: [{ label: 'Visit', icon: 'arrow-up-right', href: 'url' }] (optional) - menu: [{ label: 'Settings', icon: 'cog', href: 'url' or click: 'method' }] (optional) --}} diff --git a/packages/core-php/src/Core/Front/Admin/Blade/components/sidemenu.blade.php b/packages/core-php/src/Core/Front/Admin/Blade/components/sidemenu.blade.php index 02dd96e..7ef497d 100644 --- a/packages/core-php/src/Core/Front/Admin/Blade/components/sidemenu.blade.php +++ b/packages/core-php/src/Core/Front/Admin/Blade/components/sidemenu.blade.php @@ -1,9 +1,89 @@