refactor: update references from 'biolink' to 'page' and improve seeder structure
This commit is contained in:
parent
b8531676e2
commit
cc6cf23ff0
436 changed files with 42518 additions and 59781 deletions
|
|
@ -1,296 +1,9 @@
|
||||||
# Core-PHP TODO
|
# Core-PHP TODO
|
||||||
|
|
||||||
## Implemented
|
## High Priority
|
||||||
|
|
||||||
### Actions Pattern ✓
|
- [ ] **CDN integration tests** - Add integration tests for CDN operations (BunnyCDN upload, signed URLs, etc.)
|
||||||
|
|
||||||
`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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Seeder Auto-Discovery
|
*See `changelog/2026/jan/` for completed features and code review findings.*
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
|
||||||
181
packages/core-php/changelog/2026/jan/code-review.md
Normal file
181
packages/core-php/changelog/2026/jan/code-review.md
Normal file
|
|
@ -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)*
|
||||||
163
packages/core-php/changelog/2026/jan/features.md
Normal file
163
packages/core-php/changelog/2026/jan/features.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -15,13 +15,19 @@
|
||||||
"laravel/pennant": "^1.0",
|
"laravel/pennant": "^1.0",
|
||||||
"livewire/livewire": "^3.0|^4.0"
|
"livewire/livewire": "^3.0|^4.0"
|
||||||
},
|
},
|
||||||
|
"suggest": {
|
||||||
|
"spatie/laravel-activitylog": "Required for activity logging features (^4.0)"
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Core\\": "src/Core/",
|
"Core\\": "src/Core/",
|
||||||
"Core\\Website\\": "src/Website/",
|
"Core\\Website\\": "src/Website/",
|
||||||
"Core\\Mod\\": "src/Mod/",
|
"Core\\Mod\\": "src/Mod/",
|
||||||
"Core\\Plug\\": "src/Plug/"
|
"Core\\Plug\\": "src/Plug/"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"src/Core/Media/Thumbnail/helpers.php"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
@ -35,7 +41,8 @@
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"providers": [
|
"providers": [
|
||||||
"Core\\LifecycleEventProvider",
|
"Core\\LifecycleEventProvider",
|
||||||
"Core\\Lang\\LangServiceProvider"
|
"Core\\Lang\\LangServiceProvider",
|
||||||
|
"Core\\Bouncer\\Gate\\Boot"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
| Admin Menu Configuration
|
||||||
|
|
@ -153,6 +174,73 @@ return [
|
||||||
|
|
||||||
// Whether to dispatch RedisFallbackActivated events for monitoring/alerting
|
// Whether to dispatch RedisFallbackActivated events for monitoring/alerting
|
||||||
'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true),
|
'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.
|
// Log level for missing translation key warnings.
|
||||||
// Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical'
|
// Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical'
|
||||||
'missing_key_log_level' => env('CORE_LANG_MISSING_KEY_LOG_LEVEL', 'debug'),
|
'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),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Feature">
|
<testsuite name="Feature">
|
||||||
<directory>tests/Feature</directory>
|
<directory>tests/Feature</directory>
|
||||||
|
<directory>src/Core/**/Tests/Feature</directory>
|
||||||
|
<directory>src/Mod/**/Tests/Feature</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
<testsuite name="Unit">
|
<testsuite name="Unit">
|
||||||
<directory>tests/Unit</directory>
|
<directory>tests/Unit</directory>
|
||||||
|
<directory>src/Core/**/Tests/Unit</directory>
|
||||||
|
<directory>src/Mod/**/Tests/Unit</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,21 @@ namespace Core\Actions;
|
||||||
*
|
*
|
||||||
* Convention:
|
* Convention:
|
||||||
* - One action per file
|
* - 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()
|
* - Single public method: handle() or __invoke()
|
||||||
* - Dependencies injected via constructor
|
* - Dependencies injected via constructor
|
||||||
* - Static run() helper for convenience
|
* - Static run() helper for convenience
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* // Via dependency injection
|
* // Via dependency injection
|
||||||
* public function __construct(private CreateBiolink $createBiolink) {}
|
* public function __construct(private CreatePage $createPage) {}
|
||||||
* $biolink = $this->createBiolink->handle($user, $data);
|
* $page = $this->createPage->handle($user, $data);
|
||||||
*
|
*
|
||||||
* // Via static helper
|
* // Via static helper
|
||||||
* $biolink = CreateBiolink::run($user, $data);
|
* $page = CreatePage::run($user, $data);
|
||||||
*
|
*
|
||||||
* // Via app container
|
* // Via app container
|
||||||
* $biolink = app(CreateBiolink::class)->handle($user, $data);
|
* $page = app(CreatePage::class)->handle($user, $data);
|
||||||
*
|
*
|
||||||
* Directory structure:
|
* Directory structure:
|
||||||
* app/Mod/{Module}/Actions/
|
* app/Mod/{Module}/Actions/
|
||||||
|
|
|
||||||
77
packages/core-php/src/Core/Activity/Boot.php
Normal file
77
packages/core-php/src/Core/Activity/Boot.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity;
|
||||||
|
|
||||||
|
use Core\Activity\Console\ActivityPruneCommand;
|
||||||
|
use Core\Activity\Services\ActivityLogService;
|
||||||
|
use Core\Activity\View\Modal\Admin\ActivityFeed;
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Events\ConsoleBooting;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity module boot class.
|
||||||
|
*
|
||||||
|
* Registers activity logging features with the Core PHP framework:
|
||||||
|
* - Console commands (activity:prune)
|
||||||
|
* - Livewire components (ActivityFeed)
|
||||||
|
* - Service bindings
|
||||||
|
*
|
||||||
|
* The module uses the spatie/laravel-activitylog package with
|
||||||
|
* workspace-aware enhancements.
|
||||||
|
*/
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
public static array $listens = [
|
||||||
|
ConsoleBooting::class => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
packages/core-php/src/Core/Activity/Concerns/LogsActivity.php
Normal file
238
packages/core-php/src/Core/Activity/Concerns/LogsActivity.php
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity\Concerns;
|
||||||
|
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity as SpatieLogsActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for models that should log activity changes.
|
||||||
|
*
|
||||||
|
* This trait wraps spatie/laravel-activitylog with sensible defaults for
|
||||||
|
* the Core PHP framework, including automatic workspace_id tagging.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* class Post extends Model {
|
||||||
|
* use LogsActivity;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Configuration via model properties:
|
||||||
|
* - $activityLogAttributes: array of attributes to log (default: all dirty)
|
||||||
|
* - $activityLogName: custom log name (default: from config)
|
||||||
|
* - $activityLogEvents: events to log (default: ['created', 'updated', 'deleted'])
|
||||||
|
* - $activityLogWorkspace: whether to include workspace_id (default: true)
|
||||||
|
* - $activityLogOnlyDirty: only log dirty attributes (default: true)
|
||||||
|
*
|
||||||
|
* @requires spatie/laravel-activitylog
|
||||||
|
*/
|
||||||
|
trait LogsActivity
|
||||||
|
{
|
||||||
|
use SpatieLogsActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the activity log options for this model.
|
||||||
|
*/
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
$options = LogOptions::defaults();
|
||||||
|
|
||||||
|
// Configure what to log
|
||||||
|
if ($this->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<string>|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<string>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity\Console;
|
||||||
|
|
||||||
|
use Core\Activity\Services\ActivityLogService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to prune old activity logs.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan activity:prune # Use retention from config
|
||||||
|
* php artisan activity:prune --days=30 # Keep last 30 days
|
||||||
|
* php artisan activity:prune --dry-run # Show what would be deleted
|
||||||
|
*/
|
||||||
|
class ActivityPruneCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'activity:prune
|
||||||
|
{--days= : Number of days to retain (default: from config)}
|
||||||
|
{--dry-run : Show count without deleting}';
|
||||||
|
|
||||||
|
protected $description = 'Delete activity logs older than the retention period';
|
||||||
|
|
||||||
|
public function handle(ActivityLogService $activityService): int
|
||||||
|
{
|
||||||
|
$days = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
packages/core-php/src/Core/Activity/Models/Activity.php
Normal file
203
packages/core-php/src/Core/Activity/Models/Activity.php
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity\Models;
|
||||||
|
|
||||||
|
use Core\Activity\Scopes\ActivityScopes;
|
||||||
|
use Spatie\Activitylog\Models\Activity as SpatieActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended Activity model with workspace-aware scopes.
|
||||||
|
*
|
||||||
|
* This model extends Spatie's Activity model to add workspace scoping
|
||||||
|
* and additional query scopes for the Core PHP framework.
|
||||||
|
*
|
||||||
|
* To use this model instead of Spatie's default, add to your
|
||||||
|
* config/activitylog.php:
|
||||||
|
*
|
||||||
|
* 'activity_model' => \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<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getOldValuesAttribute(): array
|
||||||
|
{
|
||||||
|
return $this->properties->get('old', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the new values from properties.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getNewValuesAttribute(): array
|
||||||
|
{
|
||||||
|
return $this->properties->get('attributes', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the changed attributes.
|
||||||
|
*
|
||||||
|
* @return array<string, array{old: mixed, new: mixed}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
262
packages/core-php/src/Core/Activity/Scopes/ActivityScopes.php
Normal file
262
packages/core-php/src/Core/Activity/Scopes/ActivityScopes.php
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity\Scopes;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query scopes for the Activity model.
|
||||||
|
*
|
||||||
|
* These scopes can be added to a custom Activity model that extends
|
||||||
|
* Spatie's Activity model, or used as standalone scope methods.
|
||||||
|
*
|
||||||
|
* Usage with custom Activity model:
|
||||||
|
* class Activity extends \Spatie\Activitylog\Models\Activity {
|
||||||
|
* use ActivityScopes;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Usage as standalone scopes:
|
||||||
|
* Activity::forWorkspace($workspaceId)->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<string> $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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity\Services;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for querying and managing activity logs.
|
||||||
|
*
|
||||||
|
* Provides a fluent interface for filtering activities by subject, causer,
|
||||||
|
* workspace, event type, and more.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* // Get activities for a specific model
|
||||||
|
* $activities = $service->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<string> $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
<div class="space-y-6" @if($pollInterval > 0) wire:poll.{{ $pollInterval }}s @endif>
|
||||||
|
<flux:heading size="xl">Activity Log</flux:heading>
|
||||||
|
|
||||||
|
{{-- Statistics Cards --}}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Total Activities</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold">{{ number_format($this->statistics['total']) }}</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Created</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-green-600">{{ number_format($this->statistics['by_event']['created'] ?? 0) }}</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Updated</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-blue-600">{{ number_format($this->statistics['by_event']['updated'] ?? 0) }}</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deleted</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-red-600">{{ number_format($this->statistics['by_event']['deleted'] ?? 0) }}</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<flux:select wire:model.live="causerId" placeholder="All Users" class="w-48">
|
||||||
|
@foreach ($this->causers as $id => $name)
|
||||||
|
<flux:select.option value="{{ $id }}">{{ $name }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="subjectType" placeholder="All Types" class="w-48">
|
||||||
|
@foreach ($this->subjectTypes as $type => $label)
|
||||||
|
<flux:select.option value="{{ $type }}">{{ $label }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="eventType" placeholder="All Events" class="w-40">
|
||||||
|
@foreach ($this->eventTypes as $type => $label)
|
||||||
|
<flux:select.option value="{{ $type }}">{{ $label }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="daysBack" class="w-40">
|
||||||
|
@foreach ($this->dateRanges as $days => $label)
|
||||||
|
<flux:select.option value="{{ $days }}">{{ $label }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:input
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="Search activities..."
|
||||||
|
icon="magnifying-glass"
|
||||||
|
class="flex-1 min-w-48"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if ($causerId || $subjectType || $eventType || $daysBack !== 30 || $search)
|
||||||
|
<flux:button variant="ghost" wire:click="resetFilters" icon="x-mark">
|
||||||
|
Clear Filters
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Activity List --}}
|
||||||
|
<flux:card>
|
||||||
|
@if ($this->activities->isEmpty())
|
||||||
|
<div class="py-12 text-center">
|
||||||
|
<flux:icon.clock class="mx-auto h-12 w-12 text-zinc-300 dark:text-zinc-600" />
|
||||||
|
<flux:heading size="sm" class="mt-4">No Activities Found</flux:heading>
|
||||||
|
<flux:text class="mt-2">
|
||||||
|
@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
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||||
|
@foreach ($this->activities as $activity)
|
||||||
|
@php
|
||||||
|
$formatted = $this->formatActivity($activity);
|
||||||
|
@endphp
|
||||||
|
<div
|
||||||
|
wire:key="activity-{{ $activity->id }}"
|
||||||
|
class="flex items-start gap-4 p-4 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 cursor-pointer transition-colors"
|
||||||
|
wire:click="showDetail({{ $activity->id }})"
|
||||||
|
>
|
||||||
|
{{-- Avatar --}}
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
@if ($formatted['actor'])
|
||||||
|
@if ($formatted['actor']['avatar'])
|
||||||
|
<img
|
||||||
|
src="{{ $formatted['actor']['avatar'] }}"
|
||||||
|
alt="{{ $formatted['actor']['name'] }}"
|
||||||
|
class="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-zinc-100 text-sm font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
{{ $formatted['actor']['initials'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<flux:icon.cog class="h-5 w-5 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Details --}}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-zinc-900 dark:text-white">
|
||||||
|
{{ $formatted['actor']['name'] ?? 'System' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ $formatted['description'] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($formatted['subject'])
|
||||||
|
<div class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ $formatted['subject']['type'] }}:
|
||||||
|
@if ($formatted['subject']['url'])
|
||||||
|
<a href="{{ $formatted['subject']['url'] }}" wire:navigate class="text-violet-500 hover:text-violet-600" wire:click.stop>
|
||||||
|
{{ $formatted['subject']['name'] }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
{{ $formatted['subject']['name'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($formatted['changes'])
|
||||||
|
<div class="mt-2 text-xs">
|
||||||
|
<div class="inline-flex flex-wrap items-center gap-1 rounded bg-zinc-100 px-2 py-1 font-mono text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||||
|
@php $changeCount = 0; @endphp
|
||||||
|
@foreach ($formatted['changes']['new'] as $key => $newValue)
|
||||||
|
@if (($formatted['changes']['old'][$key] ?? null) !== $newValue && $changeCount < 3)
|
||||||
|
@if ($changeCount > 0)
|
||||||
|
<span class="mx-2 text-zinc-400">|</span>
|
||||||
|
@endif
|
||||||
|
<span class="font-semibold">{{ $key }}:</span>
|
||||||
|
<span class="text-red-500 line-through truncate max-w-20">{{ is_array($formatted['changes']['old'][$key] ?? null) ? json_encode($formatted['changes']['old'][$key]) : ($formatted['changes']['old'][$key] ?? 'null') }}</span>
|
||||||
|
<span class="mx-1">→</span>
|
||||||
|
<span class="text-green-500 truncate max-w-20">{{ is_array($newValue) ? json_encode($newValue) : $newValue }}</span>
|
||||||
|
@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)
|
||||||
|
<span class="ml-2 text-zinc-400">+{{ count($formatted['changes']['new']) - 3 }} more</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-2 text-xs text-zinc-400">
|
||||||
|
{{ $formatted['relative_time'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Event Badge --}}
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium {{ $this->eventColor($formatted['event']) }}">
|
||||||
|
<flux:icon :name="$this->eventIcon($formatted['event'])" class="h-3 w-3" />
|
||||||
|
{{ ucfirst($formatted['event']) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Pagination --}}
|
||||||
|
@if ($this->activities->hasPages())
|
||||||
|
<div class="border-t border-zinc-200 dark:border-zinc-700 px-4 py-3">
|
||||||
|
{{ $this->activities->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Detail Modal --}}
|
||||||
|
<flux:modal wire:model="showDetailModal" class="max-w-2xl">
|
||||||
|
@if ($this->selectedActivity)
|
||||||
|
@php
|
||||||
|
$selected = $this->formatActivity($this->selectedActivity);
|
||||||
|
@endphp
|
||||||
|
<div class="space-y-6">
|
||||||
|
<flux:heading size="lg">Activity Details</flux:heading>
|
||||||
|
|
||||||
|
{{-- Activity Header --}}
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
@if ($selected['actor'])
|
||||||
|
@if ($selected['actor']['avatar'])
|
||||||
|
<img
|
||||||
|
src="{{ $selected['actor']['avatar'] }}"
|
||||||
|
alt="{{ $selected['actor']['name'] }}"
|
||||||
|
class="h-12 w-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 text-lg font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
{{ $selected['actor']['initials'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<flux:icon.cog class="h-6 w-6 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg font-medium text-zinc-900 dark:text-white">
|
||||||
|
{{ $selected['actor']['name'] ?? 'System' }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {{ $this->eventColor($selected['event']) }}">
|
||||||
|
{{ ucfirst($selected['event']) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ $selected['description'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-zinc-400">
|
||||||
|
{{ $selected['relative_time'] }} · {{ \Carbon\Carbon::parse($selected['timestamp'])->format('M j, Y \a\t g:i A') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Subject Info --}}
|
||||||
|
@if ($selected['subject'])
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Subject</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:badge color="zinc">{{ $selected['subject']['type'] }}</flux:badge>
|
||||||
|
@if ($selected['subject']['url'])
|
||||||
|
<a href="{{ $selected['subject']['url'] }}" wire:navigate class="text-violet-500 hover:text-violet-600 font-medium">
|
||||||
|
{{ $selected['subject']['name'] }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span class="font-medium">{{ $selected['subject']['name'] }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Changes Diff --}}
|
||||||
|
@if ($selected['changes'] && (count($selected['changes']['old']) > 0 || count($selected['changes']['new']) > 0))
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">Changes</div>
|
||||||
|
<div class="rounded-lg border border-zinc-200 dark:border-zinc-700 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-zinc-50 dark:bg-zinc-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left font-medium text-zinc-600 dark:text-zinc-300">Field</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium text-zinc-600 dark:text-zinc-300">Old Value</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium text-zinc-600 dark:text-zinc-300">New Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||||
|
@foreach ($selected['changes']['new'] as $key => $newValue)
|
||||||
|
@php
|
||||||
|
$oldValue = $selected['changes']['old'][$key] ?? null;
|
||||||
|
@endphp
|
||||||
|
@if ($oldValue !== $newValue)
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-mono text-zinc-900 dark:text-white">{{ $key }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-red-600 dark:text-red-400 break-all">
|
||||||
|
@if (is_array($oldValue))
|
||||||
|
<pre class="text-xs whitespace-pre-wrap">{{ json_encode($oldValue, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
@elseif ($oldValue === null)
|
||||||
|
<span class="text-zinc-400 italic">null</span>
|
||||||
|
@elseif (is_bool($oldValue))
|
||||||
|
{{ $oldValue ? 'true' : 'false' }}
|
||||||
|
@else
|
||||||
|
{{ $oldValue }}
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-green-600 dark:text-green-400 break-all">
|
||||||
|
@if (is_array($newValue))
|
||||||
|
<pre class="text-xs whitespace-pre-wrap">{{ json_encode($newValue, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
@elseif ($newValue === null)
|
||||||
|
<span class="text-zinc-400 italic">null</span>
|
||||||
|
@elseif (is_bool($newValue))
|
||||||
|
{{ $newValue ? 'true' : 'false' }}
|
||||||
|
@else
|
||||||
|
{{ $newValue }}
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Raw Properties --}}
|
||||||
|
<flux:accordion>
|
||||||
|
<flux:accordion.item>
|
||||||
|
<flux:accordion.heading>
|
||||||
|
<span class="text-sm text-zinc-500">Raw Properties</span>
|
||||||
|
</flux:accordion.heading>
|
||||||
|
<flux:accordion.content>
|
||||||
|
<pre class="text-xs font-mono bg-zinc-100 dark:bg-zinc-800 p-3 rounded overflow-auto max-h-48">{{ json_encode($this->selectedActivity->properties, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
</flux:accordion.content>
|
||||||
|
</flux:accordion.item>
|
||||||
|
</flux:accordion>
|
||||||
|
|
||||||
|
{{-- Actions --}}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<flux:button variant="ghost" wire:click="closeDetail">Close</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Activity\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Core\Activity\Services\ActivityLogService;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livewire component for displaying activity logs in the admin panel.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Paginated activity list
|
||||||
|
* - Filters: user, model type, event type, date range
|
||||||
|
* - Activity detail modal with full diff
|
||||||
|
* - Optional polling for real-time updates
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <livewire:core.activity-feed />
|
||||||
|
* <livewire:core.activity-feed :workspace-id="$workspace->id" />
|
||||||
|
* <livewire:core.activity-feed poll="10s" />
|
||||||
|
*/
|
||||||
|
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<string, string>
|
||||||
|
*/
|
||||||
|
#[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<string, string>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function eventTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'' => 'All Events',
|
||||||
|
'created' => 'Created',
|
||||||
|
'updated' => 'Updated',
|
||||||
|
'deleted' => 'Deleted',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available users (causers) for filtering.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
#[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<int, string>
|
||||||
|
*/
|
||||||
|
#[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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Bouncer;
|
namespace Core\Bouncer;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
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
|
* Uses a Bloom filter-style approach: cache the blocklist as a set
|
||||||
* for O(1) lookups, rebuild periodically from database.
|
* 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
|
class BlocklistService
|
||||||
{
|
{
|
||||||
protected const CACHE_KEY = 'bouncer:blocklist';
|
protected const CACHE_KEY = 'bouncer:blocklist';
|
||||||
protected const CACHE_TTL = 300; // 5 minutes
|
protected const CACHE_TTL = 300; // 5 minutes
|
||||||
|
protected const DEFAULT_PER_PAGE = 50;
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
public const STATUS_APPROVED = 'approved';
|
public const STATUS_APPROVED = 'approved';
|
||||||
|
|
@ -71,6 +141,9 @@ class BlocklistService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full blocklist (cached). Only returns approved entries.
|
* 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
|
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.
|
* Check if the blocked_ips table exists.
|
||||||
*/
|
*/
|
||||||
|
|
@ -141,18 +239,27 @@ class BlocklistService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get pending entries awaiting human review.
|
* 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()) {
|
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)
|
->where('status', self::STATUS_PENDING)
|
||||||
->orderBy('blocked_at', 'desc')
|
->orderBy('blocked_at', 'desc');
|
||||||
->get()
|
|
||||||
->toArray();
|
if ($perPage === null) {
|
||||||
|
return $query->get()->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,72 @@ use Illuminate\Support\ServiceProvider;
|
||||||
* Two responsibilities:
|
* Two responsibilities:
|
||||||
* 1. Block bad actors (honeypot critical hits) before wasting CPU
|
* 1. Block bad actors (honeypot critical hits) before wasting CPU
|
||||||
* 2. Handle SEO redirects before Laravel routing
|
* 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
|
class Boot extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
|
||||||
155
packages/core-php/src/Core/Bouncer/Gate/ActionGateMiddleware.php
Normal file
155
packages/core-php/src/Core/Bouncer/Gate/ActionGateMiddleware.php
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action Gate Middleware - enforces action whitelisting.
|
||||||
|
*
|
||||||
|
* Intercepts requests and checks if the target action is permitted.
|
||||||
|
*
|
||||||
|
* ## Integration
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* Request -> 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
370
packages/core-php/src/Core/Bouncer/Gate/ActionGateService.php
Normal file
370
packages/core-php/src/Core/Bouncer/Gate/ActionGateService.php
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate;
|
||||||
|
|
||||||
|
use Core\Bouncer\Gate\Attributes\Action;
|
||||||
|
use Core\Bouncer\Gate\Models\ActionPermission;
|
||||||
|
use Core\Bouncer\Gate\Models\ActionRequest;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action Gate Service - whitelist-based request authorization.
|
||||||
|
*
|
||||||
|
* Philosophy: "If it wasn't trained, it doesn't exist."
|
||||||
|
*
|
||||||
|
* Every controller action must be explicitly permitted. Unknown actions are
|
||||||
|
* blocked in production or prompt for approval in training mode.
|
||||||
|
*
|
||||||
|
* ## Integration Flow
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* Request -> 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<string, array{action: string, scope: string|null}>
|
||||||
|
*/
|
||||||
|
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('/(?<!^)[A-Z]/', '_$0', $part));
|
||||||
|
}, $parts);
|
||||||
|
|
||||||
|
// Filter out common namespace prefixes
|
||||||
|
$parts = array_filter($parts, fn ($p) => ! 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare an explicit action name for a controller method.
|
||||||
|
*
|
||||||
|
* This attribute allows explicit declaration of the action name that will
|
||||||
|
* be used for permission checking, rather than relying on auto-resolution
|
||||||
|
* from the controller and method names.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Bouncer\Gate\Attributes\Action;
|
||||||
|
*
|
||||||
|
* class ProductController
|
||||||
|
* {
|
||||||
|
* #[Action('product.create')]
|
||||||
|
* public function store(Request $request)
|
||||||
|
* {
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* #[Action('product.delete', scope: 'product')]
|
||||||
|
* public function destroy(Product $product)
|
||||||
|
* {
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Auto-Resolution
|
||||||
|
*
|
||||||
|
* If this attribute is not present, the action name is auto-resolved:
|
||||||
|
* - `ProductController@store` -> `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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
150
packages/core-php/src/Core/Bouncer/Gate/Boot.php
Normal file
150
packages/core-php/src/Core/Bouncer/Gate/Boot.php
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action Gate - whitelist-based request authorization.
|
||||||
|
*
|
||||||
|
* Philosophy: "If it wasn't trained, it doesn't exist."
|
||||||
|
*
|
||||||
|
* Every controller action must be explicitly permitted. Unknown actions are
|
||||||
|
* blocked in production or prompt for approval in training mode.
|
||||||
|
*
|
||||||
|
* ## Integration Flow
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* Request -> 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Action permission tables - whitelist-based request authorization.
|
||||||
|
*
|
||||||
|
* Philosophy: "If it wasn't trained, it doesn't exist."
|
||||||
|
* Every controller action must be explicitly permitted.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
// 1. Action Permissions (whitelist)
|
||||||
|
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'], '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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action permission record.
|
||||||
|
*
|
||||||
|
* Represents a whitelisted action that users with specific roles/guards
|
||||||
|
* are permitted to perform.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $action Action identifier (e.g., 'product.create')
|
||||||
|
* @property string|null $scope Resource scope (type or specific ID)
|
||||||
|
* @property string $guard Guard name ('web', 'api', 'admin')
|
||||||
|
* @property string|null $role Required role or null for any authenticated user
|
||||||
|
* @property bool $allowed Whether this action is permitted
|
||||||
|
* @property string $source How this was created ('trained', 'seeded', 'manual')
|
||||||
|
* @property string|null $trained_route The route used during training
|
||||||
|
* @property int|null $trained_by User ID who trained this action
|
||||||
|
* @property \Carbon\Carbon|null $trained_at When training occurred
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class ActionPermission extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'core_action_permissions';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'action',
|
||||||
|
'scope',
|
||||||
|
'guard',
|
||||||
|
'role',
|
||||||
|
'allowed',
|
||||||
|
'source',
|
||||||
|
'trained_route',
|
||||||
|
'trained_by',
|
||||||
|
'trained_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'allowed' => '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<int, self>
|
||||||
|
*/
|
||||||
|
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<int, self>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
179
packages/core-php/src/Core/Bouncer/Gate/Models/ActionRequest.php
Normal file
179
packages/core-php/src/Core/Bouncer/Gate/Models/ActionRequest.php
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action request audit log entry.
|
||||||
|
*
|
||||||
|
* Records all action permission checks for auditing and training purposes.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $method HTTP method (GET, POST, etc.)
|
||||||
|
* @property string $route Request path
|
||||||
|
* @property string $action Action identifier
|
||||||
|
* @property string|null $scope Resource scope
|
||||||
|
* @property string $guard Guard name
|
||||||
|
* @property string|null $role User's role at time of request
|
||||||
|
* @property int|null $user_id User ID if authenticated
|
||||||
|
* @property string|null $ip_address Client IP
|
||||||
|
* @property string $status Result: 'allowed', 'denied', 'pending'
|
||||||
|
* @property bool $was_trained Whether this request triggered training
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class ActionRequest extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'core_action_requests';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'method',
|
||||||
|
'route',
|
||||||
|
'action',
|
||||||
|
'scope',
|
||||||
|
'guard',
|
||||||
|
'role',
|
||||||
|
'user_id',
|
||||||
|
'ip_address',
|
||||||
|
'status',
|
||||||
|
'was_trained',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'was_trained' => '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<int, self>
|
||||||
|
*/
|
||||||
|
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<int, self>
|
||||||
|
*/
|
||||||
|
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<int, self>
|
||||||
|
*/
|
||||||
|
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<string, array{action: string, count: int, last_at: string}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
packages/core-php/src/Core/Bouncer/Gate/RouteActionMacro.php
Normal file
86
packages/core-php/src/Core/Bouncer/Gate/RouteActionMacro.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate;
|
||||||
|
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route macros for action gate integration.
|
||||||
|
*
|
||||||
|
* Provides fluent methods for setting action names on routes:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* Route::post('/products', [ProductController::class, 'store'])
|
||||||
|
* ->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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,388 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate\Tests\Feature;
|
||||||
|
|
||||||
|
use Core\Bouncer\Gate\ActionGateService;
|
||||||
|
use Core\Bouncer\Gate\Attributes\Action;
|
||||||
|
use Core\Bouncer\Gate\Models\ActionPermission;
|
||||||
|
use Core\Bouncer\Gate\Models\ActionRequest;
|
||||||
|
use Core\Bouncer\Gate\RouteActionMacro;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class ActionGateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Register route macros
|
||||||
|
RouteActionMacro::register();
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Gate\Tests\Unit;
|
||||||
|
|
||||||
|
use Core\Bouncer\Gate\ActionGateService;
|
||||||
|
use Core\Bouncer\Gate\Attributes\Action;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ActionGateServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
protected ActionGateService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,594 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Bouncer\Tests\Unit;
|
||||||
|
|
||||||
|
use Core\Bouncer\BlocklistService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class BlocklistServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected BlocklistService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Facade;
|
||||||
* @method static string cdn(string $path)
|
* @method static string cdn(string $path)
|
||||||
* @method static string origin(string $path)
|
* @method static string origin(string $path)
|
||||||
* @method static string private(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 apex(string $path)
|
||||||
* @method static string asset(string $path, ?string $context = null)
|
* @method static string asset(string $path, ?string $context = null)
|
||||||
* @method static array urls(string $path)
|
* @method static array urls(string $path)
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,34 @@ use Illuminate\Support\Str;
|
||||||
* Asset processing pipeline for the dual-bucket CDN architecture.
|
* Asset processing pipeline for the dual-bucket CDN architecture.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Store raw upload → private bucket (optional, for processing)
|
* 1. Store raw upload -> private bucket (optional, for processing)
|
||||||
* 2. Process (resize, optimize, etc.) → handled by caller
|
* 2. Process (resize, optimize, etc.) -> handled by caller
|
||||||
* 3. Store processed → public bucket
|
* 3. Store processed -> public bucket
|
||||||
* 4. Push to CDN storage zone
|
* 4. Push to CDN storage zone
|
||||||
*
|
*
|
||||||
* Categories define path prefixes:
|
* Categories define path prefixes:
|
||||||
* - media: General media uploads
|
* - media: General media uploads
|
||||||
* - social: SocialHost media
|
* - social: Social media assets
|
||||||
* - biolink: BioHost assets
|
* - page: Page builder assets
|
||||||
* - avatar: User/workspace avatars
|
* - avatar: User/workspace avatars
|
||||||
* - content: ContentMedia
|
* - content: ContentMedia
|
||||||
* - static: Static assets
|
* - 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
|
class AssetPipeline
|
||||||
{
|
{
|
||||||
|
|
@ -51,7 +66,7 @@ class AssetPipeline
|
||||||
* Process and store an uploaded file.
|
* Process and store an uploaded file.
|
||||||
*
|
*
|
||||||
* @param UploadedFile $file The 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 string|null $filename Custom filename (auto-generated if null)
|
||||||
* @param array $options Additional options (workspace_id, user_id, etc.)
|
* @param array $options Additional options (workspace_id, user_id, etc.)
|
||||||
* @return array{path: string, cdn_url: string, origin_url: string, size: int, mime: string}
|
* @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 $sourceBucket Source bucket ('public' or 'private')
|
||||||
* @param string $destBucket Destination bucket ('public' or 'private')
|
* @param string $destBucket Destination bucket ('public' or 'private')
|
||||||
* @param string|null $destPath Destination path (same as source if null)
|
* @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
|
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 $path File path
|
||||||
* @param string $bucket 'public' or 'private'
|
* @param string $bucket 'public' or 'private'
|
||||||
|
* @return bool True if deletion was successful
|
||||||
*/
|
*/
|
||||||
public function delete(string $path, string $bucket = 'public'): bool
|
public function delete(string $path, string $bucket = 'public'): bool
|
||||||
{
|
{
|
||||||
|
|
@ -210,6 +228,7 @@ class AssetPipeline
|
||||||
*
|
*
|
||||||
* @param array<string> $paths File paths
|
* @param array<string> $paths File paths
|
||||||
* @param string $bucket 'public' or 'private'
|
* @param string $bucket 'public' or 'private'
|
||||||
|
* @return array<string, bool> Map of path to deletion success status
|
||||||
*/
|
*/
|
||||||
public function deleteMany(array $paths, string $bucket = 'public'): array
|
public function deleteMany(array $paths, string $bucket = 'public'): array
|
||||||
{
|
{
|
||||||
|
|
@ -241,6 +260,7 @@ class AssetPipeline
|
||||||
* Get URLs for a path.
|
* Get URLs for a path.
|
||||||
*
|
*
|
||||||
* @param string $path File path
|
* @param string $path File path
|
||||||
|
* @return array{cdn: string, origin: string}
|
||||||
*/
|
*/
|
||||||
public function urls(string $path): array
|
public function urls(string $path): array
|
||||||
{
|
{
|
||||||
|
|
@ -249,6 +269,11 @@ class AssetPipeline
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build storage path from category and filename.
|
* Build storage path from category and filename.
|
||||||
|
*
|
||||||
|
* @param string $category Category key (media, social, etc.)
|
||||||
|
* @param string $filename Filename with extension
|
||||||
|
* @param array<string, mixed> $options Options including workspace_id, user_id
|
||||||
|
* @return string Full storage path
|
||||||
*/
|
*/
|
||||||
protected function buildPath(string $category, string $filename, array $options = []): string
|
protected function buildPath(string $category, string $filename, array $options = []): string
|
||||||
{
|
{
|
||||||
|
|
@ -277,6 +302,9 @@ class AssetPipeline
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique filename.
|
* Generate a unique filename.
|
||||||
|
*
|
||||||
|
* @param UploadedFile $file The uploaded file
|
||||||
|
* @return string Unique filename with original extension
|
||||||
*/
|
*/
|
||||||
protected function generateFilename(UploadedFile $file): string
|
protected function generateFilename(UploadedFile $file): string
|
||||||
{
|
{
|
||||||
|
|
@ -288,6 +316,11 @@ class AssetPipeline
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a CDN push job if auto-push is enabled.
|
* 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
|
protected function queueCdnPush(string $disk, string $path, string $zone): void
|
||||||
{
|
{
|
||||||
|
|
@ -318,6 +351,7 @@ class AssetPipeline
|
||||||
*
|
*
|
||||||
* @param string $path File path
|
* @param string $path File path
|
||||||
* @param string $bucket 'public' or 'private'
|
* @param string $bucket 'public' or 'private'
|
||||||
|
* @return bool True if file exists
|
||||||
*/
|
*/
|
||||||
public function exists(string $path, string $bucket = 'public'): bool
|
public function exists(string $path, string $bucket = 'public'): bool
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,22 @@ use Illuminate\Support\Facades\Log;
|
||||||
* - Cache purging (URL, tag, workspace, global)
|
* - Cache purging (URL, tag, workspace, global)
|
||||||
* - Statistics retrieval
|
* - Statistics retrieval
|
||||||
* - Pull zone management
|
* - 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
|
class BunnyCdnService
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +55,9 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize an error message to remove sensitive data like API keys.
|
* 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
|
protected function sanitizeErrorMessage(string $message): string
|
||||||
{
|
{
|
||||||
|
|
@ -59,6 +78,8 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the service is configured.
|
* Check if the service is configured.
|
||||||
|
*
|
||||||
|
* @return bool True if BunnyCDN API key and pull zone ID are configured
|
||||||
*/
|
*/
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -71,6 +92,9 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge a single URL from CDN cache.
|
* 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
|
public function purgeUrl(string $url): bool
|
||||||
{
|
{
|
||||||
|
|
@ -79,6 +103,9 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge multiple URLs from CDN cache.
|
* Purge multiple URLs from CDN cache.
|
||||||
|
*
|
||||||
|
* @param array<string> $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
|
public function purgeUrls(array $urls): bool
|
||||||
{
|
{
|
||||||
|
|
@ -117,6 +144,8 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge entire pull zone cache.
|
* Purge entire pull zone cache.
|
||||||
|
*
|
||||||
|
* @return bool True if purge was successful, false otherwise
|
||||||
*/
|
*/
|
||||||
public function purgeAll(): bool
|
public function purgeAll(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -139,6 +168,9 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge cache by tag.
|
* 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
|
public function purgeByTag(string $tag): bool
|
||||||
{
|
{
|
||||||
|
|
@ -168,6 +200,7 @@ class BunnyCdnService
|
||||||
* Purge all cached content for a workspace.
|
* Purge all cached content for a workspace.
|
||||||
*
|
*
|
||||||
* @param object $workspace Workspace model instance (requires uuid property)
|
* @param object $workspace Workspace model instance (requires uuid property)
|
||||||
|
* @return bool True if purge was successful, false otherwise
|
||||||
*/
|
*/
|
||||||
public function purgeWorkspace(object $workspace): bool
|
public function purgeWorkspace(object $workspace): bool
|
||||||
{
|
{
|
||||||
|
|
@ -180,6 +213,10 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get CDN statistics for pull zone.
|
* 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<string, mixed>|null Statistics array or null on failure
|
||||||
*/
|
*/
|
||||||
public function getStats(?string $dateFrom = null, ?string $dateTo = null): ?array
|
public function getStats(?string $dateFrom = null, ?string $dateTo = null): ?array
|
||||||
{
|
{
|
||||||
|
|
@ -217,6 +254,10 @@ class BunnyCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bandwidth usage for pull zone.
|
* 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
|
public function getBandwidth(?string $dateFrom = null, ?string $dateTo = null): ?array
|
||||||
{
|
{
|
||||||
|
|
@ -241,6 +282,10 @@ class BunnyCdnService
|
||||||
* List files in a storage zone via API.
|
* List files in a storage zone via API.
|
||||||
*
|
*
|
||||||
* Note: For direct storage operations, use BunnyStorageService instead.
|
* 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<int, array<string, mixed>>|null Array of file objects or null on failure
|
||||||
*/
|
*/
|
||||||
public function listStorageFiles(string $storageZoneName, string $path = '/'): ?array
|
public function listStorageFiles(string $storageZoneName, string $path = '/'): ?array
|
||||||
{
|
{
|
||||||
|
|
@ -274,6 +319,11 @@ class BunnyCdnService
|
||||||
* Upload a file to storage zone via API.
|
* Upload a file to storage zone via API.
|
||||||
*
|
*
|
||||||
* Note: For direct storage operations, use BunnyStorageService instead.
|
* 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
|
public function uploadFile(string $storageZoneName, string $path, string $contents): bool
|
||||||
{
|
{
|
||||||
|
|
@ -304,6 +354,10 @@ class BunnyCdnService
|
||||||
* Delete a file from storage zone via API.
|
* Delete a file from storage zone via API.
|
||||||
*
|
*
|
||||||
* Note: For direct storage operations, use BunnyStorageService instead.
|
* 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
|
public function deleteFile(string $storageZoneName, string $path): bool
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ namespace Core\Cdn\Services;
|
||||||
|
|
||||||
use Core\Config\ConfigService;
|
use Core\Config\ConfigService;
|
||||||
use Core\Crypt\LthnHash;
|
use Core\Crypt\LthnHash;
|
||||||
|
use Core\Service\Contracts\HealthCheckable;
|
||||||
|
use Core\Service\HealthCheckResult;
|
||||||
use Bunny\Storage\Client;
|
use Bunny\Storage\Client;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -24,8 +26,9 @@ use Illuminate\Support\Facades\Storage;
|
||||||
* - Private zone: DRM/gated content
|
* - Private zone: DRM/gated content
|
||||||
*
|
*
|
||||||
* Supports vBucket scoping for workspace-isolated CDN paths.
|
* Supports vBucket scoping for workspace-isolated CDN paths.
|
||||||
|
* Implements HealthCheckable for monitoring CDN connectivity.
|
||||||
*/
|
*/
|
||||||
class BunnyStorageService
|
class BunnyStorageService implements HealthCheckable
|
||||||
{
|
{
|
||||||
protected ?Client $publicClient = null;
|
protected ?Client $publicClient = null;
|
||||||
|
|
||||||
|
|
@ -46,6 +49,80 @@ class BunnyStorageService
|
||||||
*/
|
*/
|
||||||
protected const RETRY_BASE_DELAY_MS = 100;
|
protected const RETRY_BASE_DELAY_MS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common MIME type mappings by file extension.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
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(
|
public function __construct(
|
||||||
protected ConfigService $config,
|
protected ConfigService $config,
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -154,14 +231,19 @@ class BunnyStorageService
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $zone) {
|
$contentType = $this->detectContentType($localPath);
|
||||||
$client->upload($localPath, $remotePath);
|
|
||||||
|
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;
|
return true;
|
||||||
}, [
|
}, [
|
||||||
'local' => $localPath,
|
'local' => $localPath,
|
||||||
'remote' => $remotePath,
|
'remote' => $remotePath,
|
||||||
'zone' => $zone,
|
'zone' => $zone,
|
||||||
|
'content_type' => $contentType,
|
||||||
], 'Upload');
|
], 'Upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +255,76 @@ class BunnyStorageService
|
||||||
return (int) $this->config->get('cdn.bunny.max_file_size', self::DEFAULT_MAX_FILE_SIZE);
|
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.
|
* Execute an operation with exponential backoff retry.
|
||||||
*/
|
*/
|
||||||
|
|
@ -229,13 +381,18 @@ class BunnyStorageService
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->executeWithRetry(function () use ($client, $remotePath, $contents) {
|
$contentType = $this->detectContentType($remotePath, $contents);
|
||||||
$client->putContents($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;
|
return true;
|
||||||
}, [
|
}, [
|
||||||
'remote' => $remotePath,
|
'remote' => $remotePath,
|
||||||
'zone' => $zone,
|
'zone' => $zone,
|
||||||
|
'content_type' => $contentType,
|
||||||
], 'putContents');
|
], 'putContents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,4 +552,160 @@ class BunnyStorageService
|
||||||
|
|
||||||
return $this->list($scopedPath, $zone);
|
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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
348
packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php
Normal file
348
packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Cdn\Services;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Core\Crypt\LthnHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized URL building for CDN operations.
|
||||||
|
*
|
||||||
|
* Extracts URL building logic from StorageUrlResolver and other CDN services
|
||||||
|
* into a dedicated class for consistency and reusability.
|
||||||
|
*
|
||||||
|
* ## URL Types
|
||||||
|
*
|
||||||
|
* | Type | Description | Example |
|
||||||
|
* |------|-------------|---------|
|
||||||
|
* | CDN | Pull zone delivery URL | https://cdn.example.com/path |
|
||||||
|
* | Origin | Origin storage URL (Hetzner) | https://storage.example.com/path |
|
||||||
|
* | Private | Private bucket URL (gated) | https://private.example.com/path |
|
||||||
|
* | Apex | Main domain fallback | https://example.com/path |
|
||||||
|
* | Signed | Token-authenticated URL | https://cdn.example.com/path?token=xxx |
|
||||||
|
*
|
||||||
|
* ## vBucket Scoping
|
||||||
|
*
|
||||||
|
* Uses LTHN QuasiHash for workspace-isolated CDN paths:
|
||||||
|
* ```
|
||||||
|
* cdn.example.com/{vBucketId}/path/to/asset.js
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Methods
|
||||||
|
*
|
||||||
|
* | Method | Returns | Description |
|
||||||
|
* |--------|---------|-------------|
|
||||||
|
* | `cdn()` | `string` | Build CDN delivery URL |
|
||||||
|
* | `origin()` | `string` | Build origin storage URL |
|
||||||
|
* | `private()` | `string` | Build private bucket URL |
|
||||||
|
* | `apex()` | `string` | Build apex domain URL |
|
||||||
|
* | `signed()` | `string\|null` | Build signed URL for private content |
|
||||||
|
* | `vBucket()` | `string` | Build vBucket-scoped URL |
|
||||||
|
* | `vBucketId()` | `string` | Generate vBucket ID for a domain |
|
||||||
|
* | `vBucketPath()` | `string` | Build vBucket-scoped storage path |
|
||||||
|
* | `asset()` | `string` | Build context-aware asset URL |
|
||||||
|
* | `withVersion()` | `string` | Build URL with version query param |
|
||||||
|
*/
|
||||||
|
class CdnUrlBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build a CDN delivery URL for a path.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to CDN root
|
||||||
|
* @param string|null $baseUrl Optional base URL override (uses config if null)
|
||||||
|
* @return string Full CDN URL
|
||||||
|
*/
|
||||||
|
public function cdn(string $path, ?string $baseUrl = null): string
|
||||||
|
{
|
||||||
|
$baseUrl = $baseUrl ?? config('cdn.urls.cdn');
|
||||||
|
|
||||||
|
return $this->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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,13 +20,34 @@ use Flux\Flux;
|
||||||
* In production: Uses CDN URLs (cdn.host.uk.com/flux/flux.min.js)
|
* In production: Uses CDN URLs (cdn.host.uk.com/flux/flux.min.js)
|
||||||
*
|
*
|
||||||
* Requires Flux assets to be uploaded to CDN storage zone.
|
* 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<string, string>` | Get source-to-CDN path mapping |
|
||||||
|
*
|
||||||
|
* @see CdnUrlBuilder For the underlying URL building logic
|
||||||
*/
|
*/
|
||||||
class FluxCdnService
|
class FluxCdnService
|
||||||
{
|
{
|
||||||
|
protected CdnUrlBuilder $urlBuilder;
|
||||||
|
|
||||||
|
public function __construct(?CdnUrlBuilder $urlBuilder = null)
|
||||||
|
{
|
||||||
|
$this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Get the Flux scripts tag with CDN awareness.
|
* Get the Flux scripts tag with CDN awareness.
|
||||||
*
|
*
|
||||||
* @param array $options Options like ['nonce' => 'abc123']
|
* @param array<string, mixed> $options Options like ['nonce' => 'abc123']
|
||||||
|
* @return string HTML script tag
|
||||||
*/
|
*/
|
||||||
public function scripts(array $options = []): string
|
public function scripts(array $options = []): string
|
||||||
{
|
{
|
||||||
|
|
@ -47,6 +68,9 @@ class FluxCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Flux editor scripts tag with CDN awareness.
|
* 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
|
public function editorScripts(): string
|
||||||
{
|
{
|
||||||
|
|
@ -69,6 +93,9 @@ class FluxCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Flux editor styles tag with CDN awareness.
|
* 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
|
public function editorStyles(): string
|
||||||
{
|
{
|
||||||
|
|
@ -90,6 +117,9 @@ class FluxCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get version hash from Flux manifest.
|
* 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
|
protected function getVersionHash(string $key = '/flux.js'): string
|
||||||
{
|
{
|
||||||
|
|
@ -108,7 +138,10 @@ class FluxCdnService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we should use CDN for Flux assets.
|
* Check if we should use CDN for Flux assets.
|
||||||
|
*
|
||||||
* Respects CDN_FORCE_LOCAL for testing.
|
* Respects CDN_FORCE_LOCAL for testing.
|
||||||
|
*
|
||||||
|
* @return bool True if CDN should be used, false for local assets
|
||||||
*/
|
*/
|
||||||
public function shouldUseCdn(): bool
|
public function shouldUseCdn(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -120,18 +153,24 @@ class FluxCdnService
|
||||||
*
|
*
|
||||||
* Flux assets are shared across all workspaces, so they don't use
|
* Flux assets are shared across all workspaces, so they don't use
|
||||||
* workspace-specific vBucket prefixes.
|
* 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
|
protected function cdnUrl(string $path, ?string $version = null): string
|
||||||
{
|
{
|
||||||
$cdnUrl = config('cdn.urls.cdn');
|
$cdnUrl = config('cdn.urls.cdn');
|
||||||
|
|
||||||
if (empty($cdnUrl)) {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Cdn\Services;
|
namespace Core\Cdn\Services;
|
||||||
|
|
||||||
use Core\Crypt\LthnHash;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Request;
|
use Illuminate\Support\Facades\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -19,19 +19,67 @@ use Illuminate\Support\Facades\Storage;
|
||||||
* Context-aware URL resolver for CDN/storage architecture.
|
* Context-aware URL resolver for CDN/storage architecture.
|
||||||
*
|
*
|
||||||
* Provides intelligent URL resolution based on request context:
|
* Provides intelligent URL resolution based on request context:
|
||||||
* - Admin/internal requests → Origin URLs (Hetzner)
|
* - Admin/internal requests -> Origin URLs (Hetzner)
|
||||||
* - Public/embed requests → CDN URLs (BunnyCDN)
|
* - Public/embed requests -> CDN URLs (BunnyCDN)
|
||||||
* - API requests → Both URLs returned
|
* - API requests -> Both URLs returned
|
||||||
*
|
*
|
||||||
* Supports vBucket scoping for workspace-isolated CDN paths using LTHN QuasiHash.
|
* 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
|
class StorageUrlResolver
|
||||||
{
|
{
|
||||||
protected BunnyStorageService $bunnyStorage;
|
protected BunnyStorageService $bunnyStorage;
|
||||||
|
|
||||||
public function __construct(BunnyStorageService $bunnyStorage)
|
protected CdnUrlBuilder $urlBuilder;
|
||||||
|
|
||||||
|
public function __construct(BunnyStorageService $bunnyStorage, ?CdnUrlBuilder $urlBuilder = null)
|
||||||
{
|
{
|
||||||
$this->bunnyStorage = $bunnyStorage;
|
$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
|
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
|
public function vBucketCdn(string $domain, string $path): string
|
||||||
{
|
{
|
||||||
$vBucketId = $this->vBucketId($domain);
|
return $this->urlBuilder->vBucket($domain, $path);
|
||||||
|
|
||||||
return $this->cdn("{$vBucketId}/{$path}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -69,9 +115,9 @@ class StorageUrlResolver
|
||||||
*/
|
*/
|
||||||
public function vBucketOrigin(string $domain, string $path): string
|
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
|
public function vBucketPath(string $domain, string $path): string
|
||||||
{
|
{
|
||||||
$vBucketId = $this->vBucketId($domain);
|
return $this->urlBuilder->vBucketPath($domain, $path);
|
||||||
|
|
||||||
return "{$vBucketId}/".ltrim($path, '/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -96,14 +140,7 @@ class StorageUrlResolver
|
||||||
*/
|
*/
|
||||||
public function vBucketUrls(string $domain, string $path): array
|
public function vBucketUrls(string $domain, string $path): array
|
||||||
{
|
{
|
||||||
$vBucketId = $this->vBucketId($domain);
|
return $this->urlBuilder->vBucketUrls($domain, $path);
|
||||||
$scopedPath = "{$vBucketId}/{$path}";
|
|
||||||
|
|
||||||
return [
|
|
||||||
'cdn' => $this->cdn($scopedPath),
|
|
||||||
'origin' => $this->origin($scopedPath),
|
|
||||||
'vbucket' => $vBucketId,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -114,7 +151,7 @@ class StorageUrlResolver
|
||||||
*/
|
*/
|
||||||
public function cdn(string $path): string
|
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
|
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
|
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.
|
* Generates time-limited access URLs for gated/DRM content.
|
||||||
*
|
*
|
||||||
* @param string $path Path relative to storage root
|
* @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
|
* @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');
|
$pullZone = config('cdn.bunny.private.pull_zone');
|
||||||
$expires = time() + $expiry;
|
|
||||||
$path = '/'.ltrim($path, '/');
|
|
||||||
|
|
||||||
// BunnyCDN token authentication format (using HMAC for security)
|
// Support both full URL and just hostname in config
|
||||||
// See: https://docs.bunny.net/docs/cdn-token-authentication
|
if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) {
|
||||||
$hashableBase = $token.$path.$expires;
|
return rtrim($pullZone, '/');
|
||||||
$hash = base64_encode(hash_hmac('sha256', $hashableBase, $token, true));
|
}
|
||||||
|
|
||||||
// URL-safe base64
|
return "https://{$pullZone}";
|
||||||
$hash = str_replace(['+', '/'], ['-', '_'], $hash);
|
|
||||||
$hash = rtrim($hash, '=');
|
|
||||||
|
|
||||||
return "https://{$pullZone}{$path}?token={$hash}&expires={$expires}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -179,7 +216,7 @@ class StorageUrlResolver
|
||||||
*/
|
*/
|
||||||
public function apex(string $path): string
|
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();
|
$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
|
public function urls(string $path): array
|
||||||
{
|
{
|
||||||
return [
|
return $this->urlBuilder->urls($path);
|
||||||
'cdn' => $this->cdn($path),
|
|
||||||
'origin' => $this->origin($path),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -218,16 +252,13 @@ class StorageUrlResolver
|
||||||
*/
|
*/
|
||||||
public function allUrls(string $path): array
|
public function allUrls(string $path): array
|
||||||
{
|
{
|
||||||
return [
|
return $this->urlBuilder->allUrls($path);
|
||||||
'cdn' => $this->cdn($path),
|
|
||||||
'origin' => $this->origin($path),
|
|
||||||
'private' => $this->private($path),
|
|
||||||
'apex' => $this->apex($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'
|
* @return string 'admin' or 'public'
|
||||||
*/
|
*/
|
||||||
|
|
@ -253,6 +284,8 @@ class StorageUrlResolver
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current context is admin/internal.
|
* Check if the current context is admin/internal.
|
||||||
|
*
|
||||||
|
* @return bool True if in admin context
|
||||||
*/
|
*/
|
||||||
public function isAdminContext(): bool
|
public function isAdminContext(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -325,18 +358,16 @@ class StorageUrlResolver
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a URL from base URL and path.
|
* 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
|
protected function buildUrl(?string $baseUrl, string $path): string
|
||||||
{
|
{
|
||||||
if (empty($baseUrl)) {
|
return $this->urlBuilder->build($baseUrl, $path);
|
||||||
// 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}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -429,11 +460,11 @@ class StorageUrlResolver
|
||||||
/**
|
/**
|
||||||
* Get the path prefix for a content category.
|
* 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
|
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
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,36 @@ return [
|
||||||
*/
|
*/
|
||||||
'enabled' => env('CDN_ENABLED', false),
|
'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
|
| URL Configuration
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All URL building uses these config values for consistency.
|
||||||
|
| Never hardcode URLs in service methods.
|
||||||
|
|
|
||||||
*/
|
*/
|
||||||
'urls' => [
|
'urls' => [
|
||||||
// CDN delivery URL (when enabled)
|
// CDN delivery URL (when enabled)
|
||||||
'cdn' => env('CDN_URL'),
|
'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 domain fallback
|
||||||
'apex' => env('APP_URL', 'https://core.test'),
|
'apex' => env('APP_URL', 'https://core.test'),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ return [
|
||||||
* File path organisation within bucket.
|
* File path organisation within bucket.
|
||||||
*/
|
*/
|
||||||
'paths' => [
|
'paths' => [
|
||||||
'biolink' => 'biolinks',
|
'page' => 'pages',
|
||||||
'avatar' => 'avatars',
|
'avatar' => 'avatars',
|
||||||
'media' => 'media',
|
'media' => 'media',
|
||||||
'static' => 'static',
|
'static' => 'static',
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,42 @@ use Livewire\Livewire;
|
||||||
* $config = app(ConfigService::class);
|
* $config = app(ConfigService::class);
|
||||||
* $value = $config->get('cdn.bunny.api_key', $workspace);
|
* $value = $config->get('cdn.bunny.api_key', $workspace);
|
||||||
* if ($config->isConfigured('cdn.bunny', $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
|
class Boot extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -38,6 +74,19 @@ class Boot extends ServiceProvider
|
||||||
|
|
||||||
// Alias for convenience
|
// Alias for convenience
|
||||||
$this->app->alias(ConfigService::class, 'config.service');
|
$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([
|
$this->commands([
|
||||||
Console\ConfigPrimeCommand::class,
|
Console\ConfigPrimeCommand::class,
|
||||||
Console\ConfigListCommand::class,
|
Console\ConfigListCommand::class,
|
||||||
|
Console\ConfigExportCommand::class,
|
||||||
|
Console\ConfigImportCommand::class,
|
||||||
|
Console\ConfigVersionCommand::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
537
packages/core-php/src/Core/Config/ConfigExporter.php
Normal file
537
packages/core-php/src/Core/Config/ConfigExporter.php
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config;
|
||||||
|
|
||||||
|
use Core\Config\Enums\ConfigType;
|
||||||
|
use Core\Config\Enums\ScopeType;
|
||||||
|
use Core\Config\Models\ConfigKey;
|
||||||
|
use Core\Config\Models\ConfigProfile;
|
||||||
|
use Core\Config\Models\ConfigValue;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration import/export service.
|
||||||
|
*
|
||||||
|
* Provides functionality to export config to JSON/YAML and import back.
|
||||||
|
* Supports workspace-level and system-level config export/import.
|
||||||
|
*
|
||||||
|
* ## Export Formats
|
||||||
|
*
|
||||||
|
* - JSON: Standard JSON format with metadata
|
||||||
|
* - YAML: Human-readable YAML format for manual editing
|
||||||
|
*
|
||||||
|
* ## Export Structure
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "version": "1.0",
|
||||||
|
* "exported_at": "2025-01-26T10:00:00Z",
|
||||||
|
* "scope": {
|
||||||
|
* "type": "workspace",
|
||||||
|
* "id": 123
|
||||||
|
* },
|
||||||
|
* "keys": [
|
||||||
|
* {
|
||||||
|
* "code": "cdn.bunny.api_key",
|
||||||
|
* "type": "string",
|
||||||
|
* "category": "cdn",
|
||||||
|
* "description": "BunnyCDN API key",
|
||||||
|
* "is_sensitive": true
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* "values": [
|
||||||
|
* {
|
||||||
|
* "key": "cdn.bunny.api_key",
|
||||||
|
* "value": "***SENSITIVE***",
|
||||||
|
* "locked": false
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $exporter = app(ConfigExporter::class);
|
||||||
|
*
|
||||||
|
* // Export to JSON
|
||||||
|
* $json = $exporter->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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>> $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<array<string, mixed>> $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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Config;
|
namespace Core\Config;
|
||||||
|
|
||||||
|
use Core\Config\Contracts\ConfigProvider;
|
||||||
use Core\Config\Enums\ScopeType;
|
use Core\Config\Enums\ScopeType;
|
||||||
use Core\Config\Models\Channel;
|
use Core\Config\Models\Channel;
|
||||||
use Core\Config\Models\ConfigKey;
|
use Core\Config\Models\ConfigKey;
|
||||||
|
|
@ -50,7 +51,9 @@ class ConfigResolver
|
||||||
/**
|
/**
|
||||||
* Registered virtual providers.
|
* Registered virtual providers.
|
||||||
*
|
*
|
||||||
* @var array<string, callable>
|
* Supports both ConfigProvider instances and callable functions.
|
||||||
|
*
|
||||||
|
* @var array<string, ConfigProvider|callable>
|
||||||
*/
|
*/
|
||||||
protected array $providers = [];
|
protected array $providers = [];
|
||||||
|
|
||||||
|
|
@ -399,18 +402,26 @@ class ConfigResolver
|
||||||
* Register a virtual provider for a key pattern.
|
* Register a virtual provider for a key pattern.
|
||||||
*
|
*
|
||||||
* Providers supply values from module data without database storage.
|
* 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 string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance
|
||||||
* @param callable $provider fn(string $key, ?object $workspace, ?Channel $channel): mixed
|
* @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.
|
* Resolve value from virtual providers.
|
||||||
*
|
*
|
||||||
|
* Supports both ConfigProvider instances and legacy callables.
|
||||||
|
*
|
||||||
* @param object|null $workspace Workspace model instance or null for system scope
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
*/
|
*/
|
||||||
public function resolveFromProviders(
|
public function resolveFromProviders(
|
||||||
|
|
@ -420,7 +431,10 @@ class ConfigResolver
|
||||||
): mixed {
|
): mixed {
|
||||||
foreach ($this->providers as $pattern => $provider) {
|
foreach ($this->providers as $pattern => $provider) {
|
||||||
if ($this->matchesPattern($keyCode, $pattern)) {
|
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) {
|
if ($value !== null) {
|
||||||
return $value;
|
return $value;
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,83 @@ use Core\Config\Models\ConfigValue;
|
||||||
* Single hash: ConfigResolver::$values
|
* Single hash: ConfigResolver::$values
|
||||||
* Read path: hash lookup → lazy load scope → compute if needed
|
* Read path: hash lookup → lazy load scope → compute if needed
|
||||||
*
|
*
|
||||||
* Usage:
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
* $config = app(ConfigService::class);
|
* $config = app(ConfigService::class);
|
||||||
* $value = $config->get('cdn.bunny.api_key', $workspace);
|
* $value = $config->get('cdn.bunny.api_key', $workspace);
|
||||||
* $config->set('cdn.bunny.api_key', 'new-value', $profile);
|
* $config->set('cdn.bunny.api_key', 'new-value', $profile);
|
||||||
*
|
*
|
||||||
* // Module Boot.php - provide runtime value (no DB)
|
* // Module Boot.php - provide runtime value (no DB)
|
||||||
* $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
|
* $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
|
class ConfigService
|
||||||
{
|
{
|
||||||
|
|
@ -461,6 +531,20 @@ class ConfigService
|
||||||
*
|
*
|
||||||
* Populates both hash (process-scoped) and database (persistent).
|
* 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
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
*/
|
*/
|
||||||
public function prime(?object $workspace = null, string|Channel|null $channel = null): void
|
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.
|
* Clears both hash and database. Next read will lazy-prime.
|
||||||
* Fires ConfigInvalidated event.
|
* 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
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
*/
|
*/
|
||||||
public function invalidateWorkspace(?object $workspace = null): void
|
public function invalidateWorkspace(?object $workspace = null): void
|
||||||
|
|
|
||||||
355
packages/core-php/src/Core/Config/ConfigVersioning.php
Normal file
355
packages/core-php/src/Core/Config/ConfigVersioning.php
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config;
|
||||||
|
|
||||||
|
use Core\Config\Enums\ScopeType;
|
||||||
|
use Core\Config\Models\ConfigProfile;
|
||||||
|
use Core\Config\Models\ConfigVersion;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration versioning service.
|
||||||
|
*
|
||||||
|
* Provides ability to version config changes and rollback to previous versions.
|
||||||
|
* Each version captures a snapshot of all config values for a scope.
|
||||||
|
*
|
||||||
|
* ## Features
|
||||||
|
*
|
||||||
|
* - Create named snapshots of config state
|
||||||
|
* - Rollback to any previous version
|
||||||
|
* - Compare versions to see differences
|
||||||
|
* - Automatic versioning on significant changes
|
||||||
|
* - Retention policy for old versions
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $versioning = app(ConfigVersioning::class);
|
||||||
|
*
|
||||||
|
* // Create a snapshot before changes
|
||||||
|
* $version = $versioning->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<int, ConfigVersion>
|
||||||
|
*/
|
||||||
|
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<array{key: string, value: mixed, locked: bool}> $oldValues
|
||||||
|
* @param array<array{key: string, value: mixed, locked: bool}> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config\Console;
|
||||||
|
|
||||||
|
use Core\Config\ConfigExporter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export config to JSON or YAML file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan config:export config.json
|
||||||
|
* php artisan config:export config.yaml --workspace=myworkspace
|
||||||
|
* php artisan config:export backup.json --include-sensitive
|
||||||
|
*/
|
||||||
|
class ConfigExportCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'config:export
|
||||||
|
{file : Output file path (.json or .yaml/.yml)}
|
||||||
|
{--workspace= : Export config for specific workspace slug}
|
||||||
|
{--category= : Export only a specific category}
|
||||||
|
{--include-sensitive : Include sensitive values (WARNING: security risk)}
|
||||||
|
{--no-keys : Exclude key definitions, only export values}';
|
||||||
|
|
||||||
|
protected $description = 'Export config to JSON or YAML file';
|
||||||
|
|
||||||
|
public function handle(ConfigExporter $exporter): int
|
||||||
|
{
|
||||||
|
$file = $this->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<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config\Console;
|
||||||
|
|
||||||
|
use Core\Config\ConfigExporter;
|
||||||
|
use Core\Config\ConfigVersioning;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import config from JSON or YAML file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan config:import config.json
|
||||||
|
* php artisan config:import config.yaml --workspace=myworkspace
|
||||||
|
* php artisan config:import backup.json --dry-run
|
||||||
|
*/
|
||||||
|
class ConfigImportCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'config:import
|
||||||
|
{file : Input file path (.json or .yaml/.yml)}
|
||||||
|
{--workspace= : Import config for specific workspace slug}
|
||||||
|
{--dry-run : Preview changes without applying}
|
||||||
|
{--no-backup : Skip creating a version backup before import}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
protected $description = 'Import config from JSON or YAML file';
|
||||||
|
|
||||||
|
public function handle(ConfigExporter $exporter, ConfigVersioning $versioning): int
|
||||||
|
{
|
||||||
|
$file = $this->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('<fg=green>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('<fg=yellow>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('<fg=gray>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(["<fg=red>{$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<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config\Console;
|
||||||
|
|
||||||
|
use Core\Config\ConfigVersioning;
|
||||||
|
use Core\Config\Models\ConfigVersion;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage config versions.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan config:version list
|
||||||
|
* php artisan config:version create "Before deployment"
|
||||||
|
* php artisan config:version show 123
|
||||||
|
* php artisan config:version rollback 123
|
||||||
|
* php artisan config:version compare 122 123
|
||||||
|
* php artisan config:version diff 123
|
||||||
|
*/
|
||||||
|
class ConfigVersionCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'config:version
|
||||||
|
{action : Action to perform (list, create, show, rollback, compare, diff, delete)}
|
||||||
|
{arg1? : First argument (version ID or label)}
|
||||||
|
{arg2? : Second argument (version ID for compare)}
|
||||||
|
{--workspace= : Workspace slug for version operations}
|
||||||
|
{--limit=20 : Maximum versions to list}
|
||||||
|
{--no-backup : Skip backup when rolling back}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
protected $description = 'Manage config versions (snapshots for rollback)';
|
||||||
|
|
||||||
|
public function handle(ConfigVersioning $versioning): int
|
||||||
|
{
|
||||||
|
$action = $this->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 ?? '<fg=gray>-</>',
|
||||||
|
$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']) => '<fg=cyan>[array]</>',
|
||||||
|
is_null($v['value']) => '<fg=gray>null</>',
|
||||||
|
is_bool($v['value']) => $v['value'] ? '<fg=green>true</>' : '<fg=red>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 ? '<fg=yellow>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('<fg=green>Added</>', count($diff->getAdded()).' keys');
|
||||||
|
foreach ($diff->getAdded() as $item) {
|
||||||
|
$this->line(" <fg=green>+</> {$item['key']}");
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed
|
||||||
|
if (count($diff->getRemoved()) > 0) {
|
||||||
|
$this->components->twoColumnDetail('<fg=red>Removed</>', count($diff->getRemoved()).' keys');
|
||||||
|
foreach ($diff->getRemoved() as $item) {
|
||||||
|
$this->line(" <fg=red>-</> {$item['key']}");
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed
|
||||||
|
if (count($diff->getChanged()) > 0) {
|
||||||
|
$this->components->twoColumnDetail('<fg=yellow>Changed</>', count($diff->getChanged()).' keys');
|
||||||
|
foreach ($diff->getChanged() as $item) {
|
||||||
|
$oldDisplay = $this->formatValue($item['old']);
|
||||||
|
$newDisplay = $this->formatValue($item['new']);
|
||||||
|
$this->line(" <fg=yellow>~</> {$item['key']}");
|
||||||
|
$this->line(" <fg=gray>old:</> {$oldDisplay}");
|
||||||
|
$this->line(" <fg=gray>new:</> {$newDisplay}");
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock changes
|
||||||
|
if (count($diff->getLockChanged()) > 0) {
|
||||||
|
$this->components->twoColumnDetail('<fg=cyan>Lock Changed</>', count($diff->getLockChanged()).' keys');
|
||||||
|
foreach ($diff->getLockChanged() as $item) {
|
||||||
|
$oldLock = $item['old'] ? 'LOCKED' : 'unlocked';
|
||||||
|
$newLock = $item['new'] ? 'LOCKED' : 'unlocked';
|
||||||
|
$this->line(" <fg=cyan>*</> {$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<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
packages/core-php/src/Core/Config/Contracts/ConfigProvider.php
Normal file
107
packages/core-php/src/Core/Config/Contracts/ConfigProvider.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config\Contracts;
|
||||||
|
|
||||||
|
use Core\Config\Models\Channel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for virtual configuration providers.
|
||||||
|
*
|
||||||
|
* Configuration providers supply values for config keys without database storage.
|
||||||
|
* They enable modules to expose their runtime data through the config system,
|
||||||
|
* allowing for consistent access patterns across all configuration sources.
|
||||||
|
*
|
||||||
|
* ## When to Use
|
||||||
|
*
|
||||||
|
* Use ConfigProvider when you have module data that should be accessible via
|
||||||
|
* the config system but doesn't need to be stored in the database:
|
||||||
|
*
|
||||||
|
* - Module-specific settings computed at runtime
|
||||||
|
* - Aggregated data from multiple sources
|
||||||
|
* - Dynamic values that change per-request
|
||||||
|
*
|
||||||
|
* ## Pattern Matching
|
||||||
|
*
|
||||||
|
* Providers are matched against key patterns using wildcard syntax:
|
||||||
|
* - `bio.*` - Matches all keys starting with "bio."
|
||||||
|
* - `theme.colors.*` - Matches nested keys under "theme.colors"
|
||||||
|
* - `exact.key` - Matches only the exact key
|
||||||
|
*
|
||||||
|
* ## Registration
|
||||||
|
*
|
||||||
|
* Register providers via ConfigResolver:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $resolver->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;
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,59 @@ use Illuminate\Queue\SerializesModels;
|
||||||
/**
|
/**
|
||||||
* Fired when a config value is set or updated.
|
* 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
|
class ConfigChanged
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,48 @@ use Illuminate\Queue\SerializesModels;
|
||||||
/**
|
/**
|
||||||
* Fired when config cache is invalidated.
|
* 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
|
class ConfigInvalidated
|
||||||
{
|
{
|
||||||
|
|
|
||||||
241
packages/core-php/src/Core/Config/ImportResult.php
Normal file
241
packages/core-php/src/Core/Config/ImportResult.php
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config import result.
|
||||||
|
*
|
||||||
|
* Tracks the outcome of a config import operation including
|
||||||
|
* created, updated, skipped items, and any errors.
|
||||||
|
*
|
||||||
|
* @see ConfigExporter For import/export operations
|
||||||
|
*/
|
||||||
|
class ImportResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Items created during import.
|
||||||
|
*
|
||||||
|
* @var array<array{code: string, type: string}>
|
||||||
|
*/
|
||||||
|
protected array $created = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items updated during import.
|
||||||
|
*
|
||||||
|
* @var array<array{code: string, type: string}>
|
||||||
|
*/
|
||||||
|
protected array $updated = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items skipped during import.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected array $skipped = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors encountered during import.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
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<array{code: string, type: string}>
|
||||||
|
*/
|
||||||
|
public function getCreated(): array
|
||||||
|
{
|
||||||
|
return $this->created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get updated items.
|
||||||
|
*
|
||||||
|
* @return array<array{code: string, type: string}>
|
||||||
|
*/
|
||||||
|
public function getUpdated(): array
|
||||||
|
{
|
||||||
|
return $this->updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skipped items.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getSkipped(): array
|
||||||
|
{
|
||||||
|
return $this->skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get errors.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add soft deletes to config_profiles for audit trail.
|
||||||
|
*
|
||||||
|
* Enables tracking of deleted profiles for compliance and debugging.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('config_profiles', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('config_profiles', function (Blueprint $table) {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add is_sensitive flag to config_keys for encryption support.
|
||||||
|
*
|
||||||
|
* When is_sensitive is true, values for this key will be encrypted
|
||||||
|
* at rest using Laravel's encryption (APP_KEY).
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('config_keys', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Config versions table - stores point-in-time snapshots for rollback.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('config_versions', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -28,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
* @property string $category
|
* @property string $category
|
||||||
* @property string|null $description
|
* @property string|null $description
|
||||||
* @property mixed $default_value
|
* @property mixed $default_value
|
||||||
|
* @property bool $is_sensitive
|
||||||
* @property \Carbon\Carbon $created_at
|
* @property \Carbon\Carbon $created_at
|
||||||
* @property \Carbon\Carbon $updated_at
|
* @property \Carbon\Carbon $updated_at
|
||||||
*/
|
*/
|
||||||
|
|
@ -42,13 +43,23 @@ class ConfigKey extends Model
|
||||||
'category',
|
'category',
|
||||||
'description',
|
'description',
|
||||||
'default_value',
|
'default_value',
|
||||||
|
'is_sensitive',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'type' => ConfigType::class,
|
'type' => ConfigType::class,
|
||||||
'default_value' => 'json',
|
'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).
|
* Parent key (for hierarchical grouping).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use Core\Config\Enums\ScopeType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration profile (M2 layer).
|
* Configuration profile (M2 layer).
|
||||||
|
|
@ -29,9 +30,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
* @property int $priority
|
* @property int $priority
|
||||||
* @property \Carbon\Carbon $created_at
|
* @property \Carbon\Carbon $created_at
|
||||||
* @property \Carbon\Carbon $updated_at
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
* @property \Carbon\Carbon|null $deleted_at
|
||||||
*/
|
*/
|
||||||
class ConfigProfile extends Model
|
class ConfigProfile extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $table = 'config_profiles';
|
protected $table = 'config_profiles';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use Core\Config\ConfigResolver;
|
||||||
use Core\Config\Enums\ScopeType;
|
use Core\Config\Enums\ScopeType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration value (junction table).
|
* Configuration value (junction table).
|
||||||
|
|
@ -46,10 +47,79 @@ class ConfigValue extends Model
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'value' => 'json',
|
|
||||||
'locked' => 'boolean',
|
'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.
|
* The profile this value belongs to.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
184
packages/core-php/src/Core/Config/Models/ConfigVersion.php
Normal file
184
packages/core-php/src/Core/Config/Models/ConfigVersion.php
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration version (snapshot).
|
||||||
|
*
|
||||||
|
* Stores a point-in-time snapshot of all config values for a scope.
|
||||||
|
* Used for version history and rollback capability.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $profile_id
|
||||||
|
* @property int|null $workspace_id
|
||||||
|
* @property string $label
|
||||||
|
* @property string $snapshot
|
||||||
|
* @property string|null $author
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
*/
|
||||||
|
class ConfigVersion extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'config_versions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable updated_at timestamp since versions are immutable.
|
||||||
|
*/
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'profile_id',
|
||||||
|
'workspace_id',
|
||||||
|
'label',
|
||||||
|
'snapshot',
|
||||||
|
'author',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'created_at' => '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<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getSnapshotData(): array
|
||||||
|
{
|
||||||
|
return json_decode($this->snapshot, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the config values from the snapshot.
|
||||||
|
*
|
||||||
|
* @return array<array{key: string, value: mixed, locked: bool}>
|
||||||
|
*/
|
||||||
|
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<int, self>
|
||||||
|
*/
|
||||||
|
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<int, self>
|
||||||
|
*/
|
||||||
|
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<int, self>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
220
packages/core-php/src/Core/Config/VersionDiff.php
Normal file
220
packages/core-php/src/Core/Config/VersionDiff.php
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config version difference.
|
||||||
|
*
|
||||||
|
* Represents the difference between two config versions,
|
||||||
|
* tracking added, removed, and changed values.
|
||||||
|
*
|
||||||
|
* @see ConfigVersioning For version comparison
|
||||||
|
*/
|
||||||
|
class VersionDiff
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Keys added in the new version.
|
||||||
|
*
|
||||||
|
* @var array<array{key: string, value: mixed}>
|
||||||
|
*/
|
||||||
|
protected array $added = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys removed in the new version.
|
||||||
|
*
|
||||||
|
* @var array<array{key: string, value: mixed}>
|
||||||
|
*/
|
||||||
|
protected array $removed = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys with changed values.
|
||||||
|
*
|
||||||
|
* @var array<array{key: string, old: mixed, new: mixed}>
|
||||||
|
*/
|
||||||
|
protected array $changed = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys with changed lock status.
|
||||||
|
*
|
||||||
|
* @var array<array{key: string, old: bool, new: bool}>
|
||||||
|
*/
|
||||||
|
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<array{key: string, value: mixed}>
|
||||||
|
*/
|
||||||
|
public function getAdded(): array
|
||||||
|
{
|
||||||
|
return $this->added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get removed keys.
|
||||||
|
*
|
||||||
|
* @return array<array{key: string, value: mixed}>
|
||||||
|
*/
|
||||||
|
public function getRemoved(): array
|
||||||
|
{
|
||||||
|
return $this->removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get changed keys.
|
||||||
|
*
|
||||||
|
* @return array<array{key: string, old: mixed, new: mixed}>
|
||||||
|
*/
|
||||||
|
public function getChanged(): array
|
||||||
|
{
|
||||||
|
return $this->changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lock status changes.
|
||||||
|
*
|
||||||
|
* @return array<array{key: string, old: bool, new: bool}>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,5 +27,6 @@ class Boot
|
||||||
$event->command(Commands\MakeModCommand::class);
|
$event->command(Commands\MakeModCommand::class);
|
||||||
$event->command(Commands\MakePlugCommand::class);
|
$event->command(Commands\MakePlugCommand::class);
|
||||||
$event->command(Commands\MakeWebsiteCommand::class);
|
$event->command(Commands\MakeWebsiteCommand::class);
|
||||||
|
$event->command(Commands\PruneEmailShieldStatsCommand::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ use Illuminate\Support\Facades\File;
|
||||||
*
|
*
|
||||||
* Helps new users set up the framework with sensible defaults.
|
* Helps new users set up the framework with sensible defaults.
|
||||||
* Run: php artisan core:install
|
* 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
|
class InstallCommand extends Command
|
||||||
{
|
{
|
||||||
|
|
@ -26,13 +31,32 @@ class InstallCommand extends Command
|
||||||
*/
|
*/
|
||||||
protected $signature = 'core:install
|
protected $signature = 'core:install
|
||||||
{--force : Overwrite existing configuration}
|
{--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.
|
* The console command description.
|
||||||
*/
|
*/
|
||||||
protected $description = 'Install and configure Core PHP Framework';
|
protected $description = 'Install and configure Core PHP Framework';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installation steps for progress tracking.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
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.
|
* Track completed installation steps for rollback.
|
||||||
*
|
*
|
||||||
|
|
@ -50,37 +74,78 @@ class InstallCommand extends Command
|
||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
$this->isDryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
$this->info('');
|
$this->info('');
|
||||||
$this->info(' '.__('core::core.installer.title'));
|
$this->info(' '.__('core::core.installer.title'));
|
||||||
$this->info(' '.str_repeat('=', strlen(__('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('');
|
$this->info('');
|
||||||
|
|
||||||
// Preserve original state for rollback
|
// Preserve original state for rollback (not needed in dry-run)
|
||||||
|
if (! $this->isDryRun) {
|
||||||
$this->preserveOriginalState();
|
$this->preserveOriginalState();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Step 1: Environment file
|
||||||
|
$progressBar->setMessage($this->installationSteps['environment']);
|
||||||
if (! $this->setupEnvironment()) {
|
if (! $this->setupEnvironment()) {
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine();
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
// Step 2: Application settings
|
// Step 2: Application settings
|
||||||
|
$progressBar->setMessage($this->installationSteps['application']);
|
||||||
|
$progressBar->display();
|
||||||
|
$this->newLine();
|
||||||
$this->configureApplication();
|
$this->configureApplication();
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
// Step 3: Database
|
// 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();
|
$this->runMigrations();
|
||||||
}
|
}
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
// Step 4: Generate app key if needed
|
// Step 4: Generate app key if needed
|
||||||
|
$progressBar->setMessage($this->installationSteps['app_key']);
|
||||||
$this->generateAppKey();
|
$this->generateAppKey();
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
// Step 5: Create storage link
|
// Step 5: Create storage link
|
||||||
|
$progressBar->setMessage($this->installationSteps['storage_link']);
|
||||||
$this->createStorageLink();
|
$this->createStorageLink();
|
||||||
|
$progressBar->advance();
|
||||||
|
|
||||||
|
$progressBar->setMessage('Complete!');
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
// Done!
|
// Done!
|
||||||
$this->info('');
|
if ($this->isDryRun) {
|
||||||
|
$this->info(' [DRY RUN] Installation preview complete. No changes were made.');
|
||||||
|
} else {
|
||||||
$this->info(' '.__('core::core.installer.complete'));
|
$this->info(' '.__('core::core.installer.complete'));
|
||||||
|
}
|
||||||
$this->info('');
|
$this->info('');
|
||||||
$this->info(' '.__('core::core.installer.next_steps').':');
|
$this->info(' '.__('core::core.installer.next_steps').':');
|
||||||
$this->info(' 1. Run: valet link core');
|
$this->info(' 1. Run: valet link core');
|
||||||
|
|
@ -89,16 +154,42 @@ class InstallCommand extends Command
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
$this->newLine();
|
||||||
$this->error('');
|
$this->error('');
|
||||||
$this->error(' Installation failed: '.$e->getMessage());
|
$this->error(' Installation failed: '.$e->getMessage());
|
||||||
$this->error('');
|
$this->error('');
|
||||||
|
|
||||||
|
if (! $this->isDryRun) {
|
||||||
$this->rollback();
|
$this->rollback();
|
||||||
|
}
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of installation steps to execute.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
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.
|
* Preserve original state for potential rollback.
|
||||||
*/
|
*/
|
||||||
|
|
@ -176,8 +267,12 @@ class InstallCommand extends Command
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isDryRun) {
|
||||||
|
$this->info(' [WOULD] Copy .env.example to .env');
|
||||||
|
} else {
|
||||||
File::copy($envExamplePath, $envPath);
|
File::copy($envExamplePath, $envPath);
|
||||||
$this->completedSteps['env_created'] = true;
|
$this->completedSteps['env_created'] = true;
|
||||||
|
}
|
||||||
$this->info(' [✓] '.__('core::core.installer.env_created'));
|
$this->info(' [✓] '.__('core::core.installer.env_created'));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -194,6 +289,14 @@ class InstallCommand extends Command
|
||||||
return;
|
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
|
// App name
|
||||||
$appName = $this->ask(__('core::core.installer.prompts.app_name'), __('core::core.brand.name'));
|
$appName = $this->ask(__('core::core.installer.prompts.app_name'), __('core::core.brand.name'));
|
||||||
$this->updateEnv('APP_BRAND_NAME', $appName);
|
$this->updateEnv('APP_BRAND_NAME', $appName);
|
||||||
|
|
@ -276,6 +379,14 @@ class InstallCommand extends Command
|
||||||
protected function runMigrations(): void
|
protected function runMigrations(): void
|
||||||
{
|
{
|
||||||
$this->info('');
|
$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->info(' Running migrations...');
|
||||||
|
|
||||||
$this->call('migrate', ['--force' => true]);
|
$this->call('migrate', ['--force' => true]);
|
||||||
|
|
@ -291,8 +402,13 @@ class InstallCommand extends Command
|
||||||
$key = config('app.key');
|
$key = config('app.key');
|
||||||
|
|
||||||
if (empty($key) || $key === 'base64:') {
|
if (empty($key) || $key === 'base64:') {
|
||||||
|
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->call('key:generate');
|
||||||
$this->info(' [✓] '.__('core::core.installer.key_generated'));
|
$this->info(' [✓] '.__('core::core.installer.key_generated'));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->info(' [✓] '.__('core::core.installer.key_exists'));
|
$this->info(' [✓] '.__('core::core.installer.key_exists'));
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +427,13 @@ class InstallCommand extends Command
|
||||||
return;
|
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->call('storage:link');
|
||||||
$this->completedSteps['storage_link'] = true;
|
$this->completedSteps['storage_link'] = true;
|
||||||
$this->info(' [✓] '.__('core::core.installer.storage_link_created'));
|
$this->info(' [✓] '.__('core::core.installer.storage_link_created'));
|
||||||
|
|
@ -350,4 +473,21 @@ class InstallCommand extends Command
|
||||||
|
|
||||||
File::put($envPath, $content);
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ class MakeModCommand extends Command
|
||||||
*/
|
*/
|
||||||
protected $description = 'Create a new module in the Mod namespace';
|
protected $description = 'Create a new module in the Mod namespace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files created during generation for summary table.
|
||||||
|
*
|
||||||
|
* @var array<array{file: string, description: string}>
|
||||||
|
*/
|
||||||
|
protected array $createdFiles = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
|
|
@ -50,13 +57,18 @@ class MakeModCommand extends Command
|
||||||
$modulePath = $this->getModulePath($name);
|
$modulePath = $this->getModulePath($name);
|
||||||
|
|
||||||
if (File::isDirectory($modulePath) && ! $this->option('force')) {
|
if (File::isDirectory($modulePath) && ! $this->option('force')) {
|
||||||
$this->error("Module [{$name}] already exists!");
|
$this->newLine();
|
||||||
$this->info("Use --force to overwrite.");
|
$this->components->error("Module [{$name}] already exists!");
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->warn('Use --force to overwrite the existing module.');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Creating module: {$name}");
|
$this->newLine();
|
||||||
|
$this->components->info("Creating module: <comment>{$name}</comment>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
$this->createDirectoryStructure($modulePath);
|
$this->createDirectoryStructure($modulePath);
|
||||||
|
|
@ -67,15 +79,26 @@ class MakeModCommand extends Command
|
||||||
// Create optional route files based on flags
|
// Create optional route files based on flags
|
||||||
$this->createOptionalFiles($modulePath, $name);
|
$this->createOptionalFiles($modulePath, $name);
|
||||||
|
|
||||||
$this->info('');
|
// Show summary table of created files
|
||||||
$this->info("Module [{$name}] created successfully!");
|
$this->newLine();
|
||||||
$this->info('');
|
$this->components->twoColumnDetail('<fg=green;options=bold>Created Files</>', '<fg=gray>Description</>');
|
||||||
$this->info('Location: '.$modulePath);
|
foreach ($this->createdFiles as $file) {
|
||||||
$this->info('');
|
$this->components->twoColumnDetail(
|
||||||
$this->info('Next steps:');
|
"<fg=cyan>{$file['file']}</>",
|
||||||
$this->info(' 1. Add your module logic to the Boot.php event handlers');
|
"<fg=gray>{$file['description']}</>"
|
||||||
$this->info(' 2. Create Models, Views, and Controllers as needed');
|
);
|
||||||
$this->info('');
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->info("Module [{$name}] created successfully!");
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->twoColumnDetail('Location', "<fg=yellow>{$modulePath}</>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->components->info('Next steps:');
|
||||||
|
$this->line(' <fg=gray>1.</> Add your module logic to the Boot.php event handlers');
|
||||||
|
$this->line(' <fg=gray>2.</> Create Models, Views, and Controllers as needed');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +143,7 @@ class MakeModCommand extends Command
|
||||||
File::ensureDirectoryExists($directory);
|
File::ensureDirectoryExists($directory);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info(' [+] Created directory structure');
|
$this->components->task('Creating directory structure', fn () => true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -174,7 +197,8 @@ class Boot
|
||||||
PHP;
|
PHP;
|
||||||
|
|
||||||
File::put("{$modulePath}/Boot.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$modulePath}/Routes/web.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$modulePath}/Routes/admin.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$modulePath}/Routes/api.php", $content);
|
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;
|
BLADE;
|
||||||
|
|
||||||
File::put("{$modulePath}/View/Blade/index.blade.php", $content);
|
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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@ class MakePlugCommand extends Command
|
||||||
*/
|
*/
|
||||||
protected const CATEGORIES = ['Social', 'Web3', 'Content', 'Chat', 'Business'];
|
protected const CATEGORIES = ['Social', 'Web3', 'Content', 'Chat', 'Business'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations created during generation for summary table.
|
||||||
|
*
|
||||||
|
* @var array<array{operation: string, description: string}>
|
||||||
|
*/
|
||||||
|
protected array $createdOperations = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
|
|
@ -56,8 +63,11 @@ class MakePlugCommand extends Command
|
||||||
$category = Str::studly($this->option('category'));
|
$category = Str::studly($this->option('category'));
|
||||||
|
|
||||||
if (! in_array($category, self::CATEGORIES)) {
|
if (! in_array($category, self::CATEGORIES)) {
|
||||||
$this->error("Invalid category [{$category}].");
|
$this->newLine();
|
||||||
$this->info('Valid categories: '.implode(', ', self::CATEGORIES));
|
$this->components->error("Invalid category [{$category}].");
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->bulletList(self::CATEGORIES);
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
@ -65,32 +75,48 @@ class MakePlugCommand extends Command
|
||||||
$providerPath = $this->getProviderPath($category, $name);
|
$providerPath = $this->getProviderPath($category, $name);
|
||||||
|
|
||||||
if (File::isDirectory($providerPath) && ! $this->option('force')) {
|
if (File::isDirectory($providerPath) && ! $this->option('force')) {
|
||||||
$this->error("Provider [{$name}] already exists in [{$category}]!");
|
$this->newLine();
|
||||||
$this->info("Use --force to overwrite.");
|
$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;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Creating Plug provider: {$category}/{$name}");
|
$this->newLine();
|
||||||
|
$this->components->info("Creating Plug provider: <comment>{$category}/{$name}</comment>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
File::ensureDirectoryExists($providerPath);
|
File::ensureDirectoryExists($providerPath);
|
||||||
$this->info(' [+] Created provider directory');
|
$this->components->task('Creating provider directory', fn () => true);
|
||||||
|
|
||||||
// Create operations based on flags
|
// Create operations based on flags
|
||||||
$this->createOperations($providerPath, $category, $name);
|
$this->createOperations($providerPath, $category, $name);
|
||||||
|
|
||||||
$this->info('');
|
// Show summary table of created operations
|
||||||
$this->info("Plug provider [{$category}/{$name}] created successfully!");
|
$this->newLine();
|
||||||
$this->info('');
|
$this->components->twoColumnDetail('<fg=green;options=bold>Created Operations</>', '<fg=gray>Description</>');
|
||||||
$this->info('Location: '.$providerPath);
|
foreach ($this->createdOperations as $op) {
|
||||||
$this->info('');
|
$this->components->twoColumnDetail(
|
||||||
$this->info('Usage example:');
|
"<fg=cyan>{$op['operation']}</>",
|
||||||
$this->info(" use Plug\\{$category}\\{$name}\\Auth;");
|
"<fg=gray>{$op['description']}</>"
|
||||||
$this->info('');
|
);
|
||||||
$this->info(' $auth = new Auth(\$clientId, \$clientSecret, \$redirectUrl);');
|
}
|
||||||
$this->info(' $authUrl = $auth->getAuthUrl();');
|
|
||||||
$this->info('');
|
$this->newLine();
|
||||||
|
$this->components->info("Plug provider [{$category}/{$name}] created successfully!");
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->twoColumnDetail('Location', "<fg=yellow>{$providerPath}</>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->components->info('Usage example:');
|
||||||
|
$this->line(" <fg=magenta>use</> Plug\\{$category}\\{$name}\\Auth;");
|
||||||
|
$this->newLine();
|
||||||
|
$this->line(' <fg=gray>$auth</> = <fg=cyan>new</> Auth(<fg=gray>\$clientId</>, <fg=gray>\$clientSecret</>, <fg=gray>\$redirectUrl</>);');
|
||||||
|
$this->line(' <fg=gray>$authUrl</> = <fg=gray>\$auth</>-><fg=yellow>getAuthUrl</>();');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +335,8 @@ class Auth
|
||||||
PHP;
|
PHP;
|
||||||
|
|
||||||
File::put("{$providerPath}/Auth.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$providerPath}/Post.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$providerPath}/Delete.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$providerPath}/Media.php", $content);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ class MakeWebsiteCommand extends Command
|
||||||
*/
|
*/
|
||||||
protected $description = 'Create a new domain-isolated website';
|
protected $description = 'Create a new domain-isolated website';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files created during generation for summary table.
|
||||||
|
*
|
||||||
|
* @var array<array{file: string, description: string}>
|
||||||
|
*/
|
||||||
|
protected array $createdFiles = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
|
|
@ -51,14 +58,19 @@ class MakeWebsiteCommand extends Command
|
||||||
$websitePath = $this->getWebsitePath($name);
|
$websitePath = $this->getWebsitePath($name);
|
||||||
|
|
||||||
if (File::isDirectory($websitePath) && ! $this->option('force')) {
|
if (File::isDirectory($websitePath) && ! $this->option('force')) {
|
||||||
$this->error("Website [{$name}] already exists!");
|
$this->newLine();
|
||||||
$this->info("Use --force to overwrite.");
|
$this->components->error("Website [{$name}] already exists!");
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->warn('Use --force to overwrite the existing website.');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Creating website: {$name}");
|
$this->newLine();
|
||||||
$this->info("Domain: {$domain}");
|
$this->components->info("Creating website: <comment>{$name}</comment>");
|
||||||
|
$this->components->twoColumnDetail('Domain', "<fg=yellow>{$domain}</>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
$this->createDirectoryStructure($websitePath);
|
$this->createDirectoryStructure($websitePath);
|
||||||
|
|
@ -69,17 +81,28 @@ class MakeWebsiteCommand extends Command
|
||||||
// Create optional route files
|
// Create optional route files
|
||||||
$this->createOptionalFiles($websitePath, $name);
|
$this->createOptionalFiles($websitePath, $name);
|
||||||
|
|
||||||
$this->info('');
|
// Show summary table of created files
|
||||||
$this->info("Website [{$name}] created successfully!");
|
$this->newLine();
|
||||||
$this->info('');
|
$this->components->twoColumnDetail('<fg=green;options=bold>Created Files</>', '<fg=gray>Description</>');
|
||||||
$this->info('Location: '.$websitePath);
|
foreach ($this->createdFiles as $file) {
|
||||||
$this->info('');
|
$this->components->twoColumnDetail(
|
||||||
$this->info('Next steps:');
|
"<fg=cyan>{$file['file']}</>",
|
||||||
$this->info(" 1. Configure your local dev server to serve {$domain}");
|
"<fg=gray>{$file['description']}</>"
|
||||||
$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('');
|
$this->newLine();
|
||||||
|
$this->components->info("Website [{$name}] created successfully!");
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->twoColumnDetail('Location', "<fg=yellow>{$websitePath}</>");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->components->info('Next steps:');
|
||||||
|
$this->line(" <fg=gray>1.</> Configure your local dev server to serve <fg=yellow>{$domain}</>");
|
||||||
|
$this->line(' <fg=gray>(e.g.,</> valet link '.Str::snake($name, '-').'<fg=gray>)</>');
|
||||||
|
$this->line(" <fg=gray>2.</> Visit <fg=cyan>http://{$domain}</> to see your website");
|
||||||
|
$this->line(' <fg=gray>3.</> Add routes, views, and controllers as needed');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +136,7 @@ class MakeWebsiteCommand extends Command
|
||||||
File::ensureDirectoryExists($directory);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$websitePath}/Boot.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$websitePath}/Routes/web.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$websitePath}/Routes/admin.php", $content);
|
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;
|
PHP;
|
||||||
|
|
||||||
File::put("{$websitePath}/Routes/api.php", $content);
|
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;
|
BLADE;
|
||||||
|
|
||||||
File::put("{$websitePath}/View/Blade/layouts/app.blade.php", $content);
|
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;
|
BLADE;
|
||||||
|
|
||||||
File::put("{$websitePath}/View/Blade/home.blade.php", $content);
|
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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Console\Commands;
|
||||||
|
|
||||||
|
use Core\Mail\EmailShieldStat;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune old Email Shield statistics records.
|
||||||
|
*
|
||||||
|
* Removes records older than the specified retention period to prevent
|
||||||
|
* unbounded table growth. Should be scheduled to run daily.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan email-shield:prune
|
||||||
|
* php artisan email-shield:prune --days=30
|
||||||
|
*
|
||||||
|
* Scheduling (in app/Console/Kernel.php):
|
||||||
|
* $schedule->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('<fg=gray;options=bold>Configuration</>', '');
|
||||||
|
$this->components->twoColumnDetail('Retention period', "<fg=cyan>{$days} days</>");
|
||||||
|
$this->components->twoColumnDetail('Cutoff date', "<fg=cyan>{$cutoffDate}</>");
|
||||||
|
$this->components->twoColumnDetail('Records to delete', $recordsToDelete > 0
|
||||||
|
? "<fg=yellow>{$recordsToDelete}</>"
|
||||||
|
: '<fg=green>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('<fg=gray;options=bold>Current State</>', '');
|
||||||
|
$this->components->twoColumnDetail('Remaining records', "<fg=cyan>{$remaining}</>");
|
||||||
|
if ($oldest) {
|
||||||
|
$this->components->twoColumnDetail('Oldest record', "<fg=cyan>{$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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,32 @@ declare(strict_types=1);
|
||||||
namespace Core\Crypt;
|
namespace Core\Crypt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LTHN Protocol QuasiHash.
|
* LTHN Protocol QuasiHash - Deterministic Identifier Generator.
|
||||||
*
|
*
|
||||||
* A lightweight, deterministic identifier generator for workspace/domain scoping.
|
* 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
|
* ## Security Properties
|
||||||
*
|
*
|
||||||
|
|
@ -40,6 +62,20 @@ namespace Core\Crypt;
|
||||||
* | 32 | 128 | ~1 in 3.4e38 | Long-term storage |
|
* | 32 | 128 | ~1 in 3.4e38 | Long-term storage |
|
||||||
* | 64 | 256 | Negligible | Maximum security |
|
* | 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
|
* ## Key Rotation
|
||||||
*
|
*
|
||||||
* The class supports multiple key maps for rotation. When verifying, all registered
|
* 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
|
* 3. Verification tries new key first, falls back to old
|
||||||
* 4. After migration period, remove old key map with `removeKeyMap()`
|
* 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
|
* ## NOT Suitable For
|
||||||
*
|
*
|
||||||
* - Password hashing (use `password_hash()` instead)
|
* - Password hashing (use `password_hash()` instead)
|
||||||
* - Security tokens (use `random_bytes()` instead)
|
* - Security tokens (use `random_bytes()` instead)
|
||||||
* - Cryptographic signatures
|
* - Cryptographic signatures
|
||||||
* - Any security-sensitive operations
|
* - Any security-sensitive operations
|
||||||
|
*
|
||||||
|
* @package Core\Crypt
|
||||||
*/
|
*/
|
||||||
class LthnHash
|
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).
|
* Tries all registered key maps in order (active key first, then others).
|
||||||
* This supports key rotation: old hashes remain verifiable while new hashes
|
* This supports key rotation: old hashes remain verifiable while new hashes
|
||||||
* use the current active key.
|
* 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 $input The original input
|
||||||
* @param string $hash The hash to verify
|
* @param string $hash The hash to verify
|
||||||
* @return bool True if the hash matches with any registered key map
|
* @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));
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares that this seeder must run after the specified seeders.
|
||||||
|
*
|
||||||
|
* Use this attribute to define explicit dependencies between seeders.
|
||||||
|
* The seeder will not run until all specified dependencies have completed.
|
||||||
|
*
|
||||||
|
* ## Example
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Mod\Tenant\Database\Seeders\FeatureSeeder;
|
||||||
|
*
|
||||||
|
* #[SeederAfter(FeatureSeeder::class)]
|
||||||
|
* class PackageSeeder extends Seeder
|
||||||
|
* {
|
||||||
|
* public function run(): void { ... }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Multiple dependencies
|
||||||
|
* #[SeederAfter(FeatureSeeder::class, PackageSeeder::class)]
|
||||||
|
* class WorkspaceSeeder extends Seeder
|
||||||
|
* {
|
||||||
|
* public function run(): void { ... }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
|
||||||
|
class SeederAfter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The seeder classes that must run before this one.
|
||||||
|
*
|
||||||
|
* @var array<class-string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares that this seeder must run before the specified seeders.
|
||||||
|
*
|
||||||
|
* Use this attribute to ensure this seeder runs before its dependents.
|
||||||
|
* This is the inverse of SeederAfter - it declares that other seeders
|
||||||
|
* depend on this one.
|
||||||
|
*
|
||||||
|
* ## Example
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Mod\Tenant\Database\Seeders\PackageSeeder;
|
||||||
|
*
|
||||||
|
* #[SeederBefore(PackageSeeder::class)]
|
||||||
|
* class FeatureSeeder extends Seeder
|
||||||
|
* {
|
||||||
|
* public function run(): void { ... }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Multiple dependents
|
||||||
|
* #[SeederBefore(PackageSeeder::class, WorkspaceSeeder::class)]
|
||||||
|
* class FeatureSeeder extends Seeder
|
||||||
|
* {
|
||||||
|
* public function run(): void { ... }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
|
||||||
|
class SeederBefore
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The seeder classes that must run after this one.
|
||||||
|
*
|
||||||
|
* @var array<class-string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the priority of a seeder.
|
||||||
|
*
|
||||||
|
* Lower priority values run first. Default priority is 50.
|
||||||
|
*
|
||||||
|
* ## Example
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* #[SeederPriority(10)] // Runs early
|
||||||
|
* class FeatureSeeder extends Seeder
|
||||||
|
* {
|
||||||
|
* public function run(): void { ... }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* #[SeederPriority(90)] // Runs later
|
||||||
|
* class DemoSeeder extends Seeder
|
||||||
|
* {
|
||||||
|
* public function run(): void { ... }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Priority Guidelines
|
||||||
|
*
|
||||||
|
* - 0-20: Foundation seeders (features, configuration)
|
||||||
|
* - 20-40: Core data (packages, workspaces)
|
||||||
|
* - 40-60: Default priority (general seeders)
|
||||||
|
* - 60-80: Content seeders (pages, posts)
|
||||||
|
* - 80-100: Demo/test data seeders
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS)]
|
||||||
|
class SeederPriority
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Default priority for seeders without explicit priority.
|
||||||
|
*/
|
||||||
|
public const DEFAULT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new priority attribute.
|
||||||
|
*
|
||||||
|
* @param int $priority Priority value (higher runs first)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $priority
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base database seeder with auto-discovery support.
|
||||||
|
*
|
||||||
|
* This class automatically discovers and runs all module seeders in the
|
||||||
|
* correct order based on their priority and dependency declarations.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* Apps can extend this class directly:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* class DatabaseSeeder extends CoreDatabaseSeeder
|
||||||
|
* {
|
||||||
|
* // Optionally override paths or excluded seeders
|
||||||
|
* protected function getSeederPaths(): array
|
||||||
|
* {
|
||||||
|
* return [
|
||||||
|
* app_path('Core'),
|
||||||
|
* app_path('Mod'),
|
||||||
|
* base_path('packages/my-package/src'),
|
||||||
|
* ];
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or use the class directly:
|
||||||
|
*
|
||||||
|
* ```bash
|
||||||
|
* php artisan db:seed --class=Core\\Database\\Seeders\\CoreDatabaseSeeder
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Filtering
|
||||||
|
*
|
||||||
|
* Seeders can be filtered using Artisan command options:
|
||||||
|
*
|
||||||
|
* - `--exclude=SeederName` - Skip specific seeders
|
||||||
|
* - `--only=SeederName` - Run only specific seeders
|
||||||
|
*
|
||||||
|
* Multiple filters can be specified by repeating the option.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @see SeederDiscovery For the discovery mechanism
|
||||||
|
* @see SeederRegistry For manual seeder registration
|
||||||
|
*/
|
||||||
|
class CoreDatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The seeder discovery instance.
|
||||||
|
*/
|
||||||
|
protected ?SeederDiscovery $discovery = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seeder registry for manual registrations.
|
||||||
|
*/
|
||||||
|
protected ?SeederRegistry $registry = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to use auto-discovery.
|
||||||
|
*/
|
||||||
|
protected bool $autoDiscover = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$seeders = $this->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<string> 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<string> 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<string>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string> $seeders List of seeder classes
|
||||||
|
* @return array<string> 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<string> $seeders List of seeder classes
|
||||||
|
* @return array<string> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a circular dependency is detected in seeder ordering.
|
||||||
|
*
|
||||||
|
* This exception indicates that the seeder dependency graph contains a cycle,
|
||||||
|
* making it impossible to determine a valid execution order.
|
||||||
|
*/
|
||||||
|
class CircularDependencyException extends RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The seeders involved in the circular dependency.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
public readonly array $cycle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new exception instance.
|
||||||
|
*
|
||||||
|
* @param array<string> $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<string> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
546
packages/core-php/src/Core/Database/Seeders/SeederDiscovery.php
Normal file
546
packages/core-php/src/Core/Database/Seeders/SeederDiscovery.php
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders;
|
||||||
|
|
||||||
|
use Core\Database\Seeders\Attributes\SeederAfter;
|
||||||
|
use Core\Database\Seeders\Attributes\SeederBefore;
|
||||||
|
use Core\Database\Seeders\Attributes\SeederPriority;
|
||||||
|
use Core\Database\Seeders\Exceptions\CircularDependencyException;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers and orders seeders from module directories.
|
||||||
|
*
|
||||||
|
* The SeederDiscovery service scans configured paths for seeder classes,
|
||||||
|
* reads their priority and dependency declarations, and produces a
|
||||||
|
* topologically sorted list of seeders ready for execution.
|
||||||
|
*
|
||||||
|
* ## Discovery
|
||||||
|
*
|
||||||
|
* Seeders are discovered by scanning for `*Seeder.php` files in
|
||||||
|
* `Database/Seeders/` subdirectories of configured paths.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* Seeders can declare ordering preferences via:
|
||||||
|
*
|
||||||
|
* 1. **Priority** (property or attribute): Higher values run first
|
||||||
|
* ```php
|
||||||
|
* public int $priority = 10;
|
||||||
|
* // or
|
||||||
|
* #[SeederPriority(10)]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 2. **After** (property or attribute): Must run after specified seeders
|
||||||
|
* ```php
|
||||||
|
* public array $after = [FeatureSeeder::class];
|
||||||
|
* // or
|
||||||
|
* #[SeederAfter(FeatureSeeder::class)]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 3. **Before** (property or attribute): Must run before specified seeders
|
||||||
|
* ```php
|
||||||
|
* public array $before = [PackageSeeder::class];
|
||||||
|
* // or
|
||||||
|
* #[SeederBefore(PackageSeeder::class)]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Dependencies take precedence over priority. Within the same dependency
|
||||||
|
* level, seeders are sorted by priority (higher first).
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @see SeederPriority For priority configuration
|
||||||
|
* @see SeederAfter For dependency configuration
|
||||||
|
* @see SeederBefore For reverse dependency configuration
|
||||||
|
*/
|
||||||
|
class SeederDiscovery
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Default priority for seeders.
|
||||||
|
*/
|
||||||
|
public const DEFAULT_PRIORITY = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovered seeder metadata.
|
||||||
|
*
|
||||||
|
* @var array<string, array{priority: int, after: array<string>, before: array<string>}>
|
||||||
|
*/
|
||||||
|
private array $seeders = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths to scan for seeders.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
private array $paths = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeder classes to exclude.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
private array $excluded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether discovery has been performed.
|
||||||
|
*/
|
||||||
|
private bool $discovered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new SeederDiscovery instance.
|
||||||
|
*
|
||||||
|
* @param array<string> $paths Directories to scan for modules
|
||||||
|
* @param array<string> $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<string> $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<string> $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<string> $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<string> 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<string, array{priority: int, after: array<string>, before: array<string>}>
|
||||||
|
*/
|
||||||
|
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<string>, before: array<string>}
|
||||||
|
*/
|
||||||
|
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<string> 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<string> 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<string> 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<string, array<string>> $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<string, array<string>> $dependencies Adjacency list
|
||||||
|
* @param array<string, bool> $visited Fully processed nodes
|
||||||
|
* @param array<string, bool> $recStack Nodes in current recursion stack
|
||||||
|
* @param array<string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
189
packages/core-php/src/Core/Database/Seeders/SeederRegistry.php
Normal file
189
packages/core-php/src/Core/Database/Seeders/SeederRegistry.php
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Database\Seeders;
|
||||||
|
|
||||||
|
use Core\Database\Seeders\Exceptions\CircularDependencyException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual seeder registration for explicit control over seeder ordering.
|
||||||
|
*
|
||||||
|
* Use SeederRegistry when you want explicit control over which seeders run
|
||||||
|
* and in what order, rather than relying on auto-discovery.
|
||||||
|
*
|
||||||
|
* ## Example
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $registry = new SeederRegistry();
|
||||||
|
*
|
||||||
|
* $registry
|
||||||
|
* ->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<string, array{priority: int, after: array<string>, before: array<string>}>
|
||||||
|
*/
|
||||||
|
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<string> $after Seeders that must run before this one
|
||||||
|
* @param array<string> $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<string, array{priority?: int, after?: array<string>, before?: array<string>}|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<string, array{priority: int, after: array<string>, before: array<string>}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->seeders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ordered seeder classes.
|
||||||
|
*
|
||||||
|
* @return array<string> 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<string, array{priority: int, after: array<string>, before: array<string>}> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Events\Concerns;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for module Boot classes to declare event version compatibility.
|
||||||
|
*
|
||||||
|
* Use this trait in your Boot class to declare which event versions your
|
||||||
|
* handlers support. This enables graceful handling of event API changes.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* class Boot
|
||||||
|
* {
|
||||||
|
* use HasEventVersion;
|
||||||
|
*
|
||||||
|
* public static array $listens = [
|
||||||
|
* WebRoutesRegistering::class => '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<class-string, int> Map of event class to required version
|
||||||
|
*/
|
||||||
|
public static function getEventVersionRequirements(): array
|
||||||
|
{
|
||||||
|
if (property_exists(static::class, 'eventVersions')) {
|
||||||
|
return static::$eventVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,142 @@ namespace Core\Events;
|
||||||
* listen to these events via static `$listens` arrays in their Boot class and
|
* listen to these events via static `$listens` arrays in their Boot class and
|
||||||
* register their resources through the request methods provided here.
|
* 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
|
* ## Request/Collect Pattern
|
||||||
*
|
*
|
||||||
* This class implements a "request/collect" pattern rather than direct mutation:
|
* This class implements a "request/collect" pattern rather than direct mutation:
|
||||||
|
|
@ -42,6 +178,17 @@ namespace Core\Events;
|
||||||
* | `policy()` | Register model policies |
|
* | `policy()` | Register model policies |
|
||||||
* | `navigation()` | Register navigation items |
|
* | `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
|
* ## Usage Example
|
||||||
*
|
*
|
||||||
* ```php
|
* ```php
|
||||||
|
|
@ -55,10 +202,47 @@ namespace Core\Events;
|
||||||
*
|
*
|
||||||
* @package 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
|
* @see LifecycleEventProvider For event processing
|
||||||
*/
|
*/
|
||||||
abstract class LifecycleEvent
|
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<int, array<string, mixed>> Collected navigation item requests */
|
/** @var array<int, array<string, mixed>> Collected navigation item requests */
|
||||||
protected array $navigationRequests = [];
|
protected array $navigationRequests = [];
|
||||||
|
|
||||||
|
|
@ -326,4 +510,53 @@ abstract class LifecycleEvent
|
||||||
{
|
{
|
||||||
return $this->policyRequests;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
549
packages/core-php/src/Core/Events/ListenerProfiler.php
Normal file
549
packages/core-php/src/Core/Events/ListenerProfiler.php
Normal file
|
|
@ -0,0 +1,549 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profiles event listener execution time and memory usage.
|
||||||
|
*
|
||||||
|
* ListenerProfiler provides detailed performance metrics for event listeners,
|
||||||
|
* helping identify slow or memory-intensive handlers that may need optimization.
|
||||||
|
*
|
||||||
|
* ## Features
|
||||||
|
*
|
||||||
|
* - **Execution Time** - Tracks time spent in each listener (milliseconds)
|
||||||
|
* - **Memory Usage** - Measures peak memory delta during listener execution
|
||||||
|
* - **Call Counting** - Tracks how many times each listener is invoked
|
||||||
|
* - **Slow Listener Detection** - Configurable threshold for identifying slow listeners
|
||||||
|
*
|
||||||
|
* ## Enabling Profiling
|
||||||
|
*
|
||||||
|
* Profiling is disabled by default for performance. Enable when needed:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* ListenerProfiler::enable(); // Enable profiling
|
||||||
|
* ListenerProfiler::setSlowThreshold(50); // Flag listeners >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<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: array<int, array{duration_ms: float, memory_before: int, memory_after: int, memory_peak: int}>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private static array $profiles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active profiling contexts (for nested calls).
|
||||||
|
*
|
||||||
|
* @var array<string, array{start_time: float, memory_before: int}>
|
||||||
|
*/
|
||||||
|
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<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: array<int, array{duration_ms: float, memory_before: int, memory_after: int, memory_peak: int}>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function getProfiles(): array
|
||||||
|
{
|
||||||
|
return self::$profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profiles for a specific event.
|
||||||
|
*
|
||||||
|
* @param string $eventClass Event class name
|
||||||
|
* @return array<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: 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<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: 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<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: 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<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: 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<string, array{
|
||||||
|
* event: string,
|
||||||
|
* handler: string,
|
||||||
|
* method: string,
|
||||||
|
* duration_ms: float,
|
||||||
|
* memory_peak_bytes: int,
|
||||||
|
* memory_delta_bytes: int,
|
||||||
|
* call_count: int,
|
||||||
|
* avg_duration_ms: float,
|
||||||
|
* is_slow: bool,
|
||||||
|
* calls: 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<string, array{listeners: int, duration_ms: float, calls: int}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ namespace Core\Front\Admin;
|
||||||
|
|
||||||
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
use Core\Front\Admin\Contracts\DynamicMenuProvider;
|
use Core\Front\Admin\Contracts\DynamicMenuProvider;
|
||||||
|
use Core\Front\Admin\Validation\IconValidator;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,7 +95,17 @@ class AdminMenuRegistry
|
||||||
*/
|
*/
|
||||||
protected ?object $entitlements = null;
|
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)) {
|
if ($entitlements === null && class_exists(\Core\Mod\Tenant\Services\EntitlementService::class)) {
|
||||||
$this->entitlements = app(\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->entitlements = $entitlements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->iconValidator = $iconValidator ?? new IconValidator();
|
||||||
$this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL);
|
$this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL);
|
||||||
$this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true);
|
$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] ?? [];
|
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<string> 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.
|
* Get all service menu items indexed by service key.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Each card in $cards array:
|
||||||
- subtitle: subtitle text (optional)
|
- subtitle: subtitle text (optional)
|
||||||
- status: { label: 'Online', color: 'green' } (optional)
|
- status: { label: 'Online', color: 'green' } (optional)
|
||||||
- stats: [{ label: 'CPU', value: '45%', progress: 45, progressColor: '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)
|
- footer: [{ label: 'Visit', icon: 'arrow-up-right', href: 'url' }] (optional)
|
||||||
- menu: [{ label: 'Settings', icon: 'cog', href: 'url' or click: 'method' }] (optional)
|
- menu: [{ label: 'Settings', icon: 'cog', href: 'url' or click: 'method' }] (optional)
|
||||||
--}}
|
--}}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,89 @@
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
@foreach($items as $item)
|
@foreach($items as $item)
|
||||||
@if(!empty($item['divider']))
|
@if(!empty($item['divider']))
|
||||||
{{-- Divider --}}
|
{{-- Divider (with optional label) --}}
|
||||||
<li class="py-2">
|
<li class="py-2">
|
||||||
|
@if(!empty($item['label']))
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<hr class="flex-1 border-gray-200 dark:border-gray-700" />
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ $item['label'] }}</span>
|
||||||
|
<hr class="flex-1 border-gray-200 dark:border-gray-700" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
<hr class="border-gray-200 dark:border-gray-700" />
|
<hr class="border-gray-200 dark:border-gray-700" />
|
||||||
|
@endif
|
||||||
|
</li>
|
||||||
|
@elseif(!empty($item['separator']))
|
||||||
|
{{-- Simple separator --}}
|
||||||
|
<li class="py-1">
|
||||||
|
<hr class="border-gray-100 dark:border-gray-800" />
|
||||||
|
</li>
|
||||||
|
@elseif(!empty($item['collapsible']))
|
||||||
|
{{-- Collapsible group --}}
|
||||||
|
@php
|
||||||
|
$collapsibleId = 'menu-group-' . ($item['stateKey'] ?? \Illuminate\Support\Str::slug($item['label']));
|
||||||
|
$isOpen = $item['open'] ?? true;
|
||||||
|
$groupColor = match($item['color'] ?? 'gray') {
|
||||||
|
'violet' => 'text-violet-500',
|
||||||
|
'blue' => 'text-blue-500',
|
||||||
|
'green' => 'text-green-500',
|
||||||
|
'red' => 'text-red-500',
|
||||||
|
'amber' => 'text-amber-500',
|
||||||
|
'emerald' => 'text-emerald-500',
|
||||||
|
'cyan' => 'text-cyan-500',
|
||||||
|
'pink' => 'text-pink-500',
|
||||||
|
default => 'text-gray-500',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<li x-data="{ open: {{ $isOpen ? 'true' : 'false' }} }" class="group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="open = !open"
|
||||||
|
class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold uppercase tracking-wider {{ $groupColor }} hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
@if(!empty($item['icon']))
|
||||||
|
<core:icon :name="$item['icon']" class="size-4 shrink-0" />
|
||||||
|
@endif
|
||||||
|
{{ $item['label'] }}
|
||||||
|
</span>
|
||||||
|
<core:icon name="chevron-down" class="size-4 shrink-0 transition-transform duration-200" x-bind:class="{ 'rotate-180': open }" />
|
||||||
|
</button>
|
||||||
|
<ul x-show="open" x-collapse class="mt-1 ml-2 pl-2 border-l border-gray-200 dark:border-gray-700 space-y-1">
|
||||||
|
@foreach($item['children'] ?? [] as $child)
|
||||||
|
@if(!empty($child['separator']))
|
||||||
|
<li class="py-1"><hr class="border-gray-100 dark:border-gray-800" /></li>
|
||||||
|
@elseif(!empty($child['section']))
|
||||||
|
@php
|
||||||
|
$childColor = match($child['color'] ?? 'gray') {
|
||||||
|
'violet' => 'text-violet-500',
|
||||||
|
'blue' => 'text-blue-500',
|
||||||
|
'green' => 'text-green-500',
|
||||||
|
'red' => 'text-red-500',
|
||||||
|
'amber' => 'text-amber-500',
|
||||||
|
'emerald' => 'text-emerald-500',
|
||||||
|
'cyan' => 'text-cyan-500',
|
||||||
|
'pink' => 'text-pink-500',
|
||||||
|
default => 'text-gray-400 dark:text-gray-500',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<li class="pt-2 pb-1 first:pt-0 flex items-center gap-2">
|
||||||
|
@if(!empty($child['icon']))
|
||||||
|
<core:icon :name="$child['icon']" class="size-3 shrink-0 {{ $childColor }}" />
|
||||||
|
@endif
|
||||||
|
<span class="text-[10px] font-semibold uppercase tracking-wider {{ $childColor }}">{{ $child['section'] }}</span>
|
||||||
|
</li>
|
||||||
|
@else
|
||||||
|
<admin:nav-link
|
||||||
|
:href="$child['href'] ?? '#'"
|
||||||
|
:active="$child['active'] ?? false"
|
||||||
|
:badge="$child['badge'] ?? null"
|
||||||
|
:icon="$child['icon'] ?? null"
|
||||||
|
:color="$child['color'] ?? null"
|
||||||
|
>{{ $child['label'] }}</admin:nav-link>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@elseif(!empty($item['children']))
|
@elseif(!empty($item['children']))
|
||||||
{{-- Dropdown menu with children --}}
|
{{-- Dropdown menu with children --}}
|
||||||
|
|
@ -15,7 +95,12 @@
|
||||||
:color="$item['color'] ?? 'gray'"
|
:color="$item['color'] ?? 'gray'"
|
||||||
>
|
>
|
||||||
@foreach($item['children'] as $child)
|
@foreach($item['children'] as $child)
|
||||||
@if(!empty($child['section']))
|
@if(!empty($child['separator']))
|
||||||
|
{{-- Separator within dropdown --}}
|
||||||
|
<li class="py-1">
|
||||||
|
<hr class="border-gray-100 dark:border-gray-800" />
|
||||||
|
</li>
|
||||||
|
@elseif(!empty($child['section']))
|
||||||
{{-- Section header within dropdown --}}
|
{{-- Section header within dropdown --}}
|
||||||
@php
|
@php
|
||||||
$sectionIconClass = match($child['color'] ?? 'gray') {
|
$sectionIconClass = match($child['color'] ?? 'gray') {
|
||||||
|
|
@ -37,6 +122,11 @@
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider {{ $sectionIconClass }}">
|
<span class="text-xs font-semibold uppercase tracking-wider {{ $sectionIconClass }}">
|
||||||
{{ $child['section'] }}
|
{{ $child['section'] }}
|
||||||
</span>
|
</span>
|
||||||
|
@if(!empty($child['badge']))
|
||||||
|
<span class="ml-auto text-xs px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ is_array($child['badge']) ? ($child['badge']['text'] ?? '') : $child['badge'] }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
</li>
|
</li>
|
||||||
@else
|
@else
|
||||||
<admin:nav-link
|
<admin:nav-link
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,61 @@ namespace Core\Front\Admin\Contracts;
|
||||||
*
|
*
|
||||||
* Each item returned by `adminMenuItems()` specifies:
|
* Each item returned by `adminMenuItems()` specifies:
|
||||||
*
|
*
|
||||||
* - **group** - Where in the menu hierarchy (`dashboard`, `webhost`, `services`, `settings`, `admin`)
|
* - **group** - Where in the menu hierarchy (`dashboard`, `workspaces`, `services`, `settings`, `admin`)
|
||||||
* - **priority** - Order within group (lower = earlier)
|
* - **priority** - Order within group (lower = earlier, see Priority Constants below)
|
||||||
* - **entitlement** - Optional feature code for workspace-level access
|
* - **entitlement** - Optional feature code for workspace-level access
|
||||||
* - **permissions** - Optional array of required user permissions
|
* - **permissions** - Optional array of required user permissions
|
||||||
* - **admin** - Whether item requires Hades/admin user
|
* - **admin** - Whether item requires Hades/admin user
|
||||||
* - **item** - Closure returning the actual menu item data (lazy-evaluated)
|
* - **item** - Closure returning the actual menu item data (lazy-evaluated)
|
||||||
*
|
*
|
||||||
|
* ## Menu Item Grouping
|
||||||
|
*
|
||||||
|
* Items with children can use grouping elements for better organization:
|
||||||
|
*
|
||||||
|
* - **separator** - Simple visual divider (`['separator' => true]`)
|
||||||
|
* - **divider** - Divider with optional label (`['divider' => true, 'label' => 'More']`)
|
||||||
|
* - **section** - Section header (`['section' => 'Products', 'icon' => 'cube']`)
|
||||||
|
* - **collapsible** - Collapsible sub-group with state persistence
|
||||||
|
*
|
||||||
|
* Use `MenuItemGroup` helper for cleaner syntax:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Front\Admin\Support\MenuItemGroup;
|
||||||
|
*
|
||||||
|
* 'children' => [
|
||||||
|
* MenuItemGroup::header('Products', 'cube'),
|
||||||
|
* ['label' => 'All Products', 'href' => '/products'],
|
||||||
|
* MenuItemGroup::separator(),
|
||||||
|
* MenuItemGroup::header('Orders', 'receipt'),
|
||||||
|
* ['label' => 'All Orders', 'href' => '/orders'],
|
||||||
|
* ],
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Priority Constants (Ordering Specification)
|
||||||
|
*
|
||||||
|
* Use these priority ranges to ensure consistent menu ordering across modules:
|
||||||
|
*
|
||||||
|
* | Range | Constant | Description |
|
||||||
|
* |----------|-----------------------|---------------------------------------|
|
||||||
|
* | 0-9 | PRIORITY_FIRST | Reserved for system items |
|
||||||
|
* | 10-19 | PRIORITY_HIGH | Primary navigation items |
|
||||||
|
* | 20-39 | PRIORITY_ABOVE_NORMAL | Important but not primary items |
|
||||||
|
* | 40-60 | PRIORITY_NORMAL | Standard items (default: 50) |
|
||||||
|
* | 61-79 | PRIORITY_BELOW_NORMAL | Less important items |
|
||||||
|
* | 80-89 | PRIORITY_LOW | Rarely used items |
|
||||||
|
* | 90-99 | PRIORITY_LAST | Items that should appear at the end |
|
||||||
|
*
|
||||||
|
* Within the same priority, items are ordered by registration order.
|
||||||
|
*
|
||||||
|
* ## Icon Validation
|
||||||
|
*
|
||||||
|
* Icons should be valid FontAwesome icon names. The `IconValidator` class
|
||||||
|
* validates icons against known FontAwesome icons. Supported formats:
|
||||||
|
*
|
||||||
|
* - Shorthand: `home`, `user`, `gear`
|
||||||
|
* - Full class: `fas fa-home`, `fa-solid fa-user`
|
||||||
|
* - Brand icons: `fab fa-github`, `fa-brands fa-twitter`
|
||||||
|
*
|
||||||
* ## Lazy Evaluation
|
* ## Lazy Evaluation
|
||||||
*
|
*
|
||||||
* The `item` closure is only called when the menu is rendered, after permission
|
* The `item` closure is only called when the menu is rendered, after permission
|
||||||
|
|
@ -43,31 +91,75 @@ namespace Core\Front\Admin\Contracts;
|
||||||
* @package Core\Front\Admin\Contracts
|
* @package Core\Front\Admin\Contracts
|
||||||
*
|
*
|
||||||
* @see DynamicMenuProvider For uncached, real-time menu items
|
* @see DynamicMenuProvider For uncached, real-time menu items
|
||||||
|
* @see \Core\Front\Admin\Validation\IconValidator For icon validation
|
||||||
*/
|
*/
|
||||||
interface AdminMenuProvider
|
interface AdminMenuProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Priority: Reserved for system items (0-9).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_FIRST = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: Primary navigation items (10-19).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_HIGH = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: Important but not primary items (20-39).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_ABOVE_NORMAL = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: Standard items, default (40-60).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_NORMAL = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: Less important items (61-79).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_BELOW_NORMAL = 70;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: Rarely used items (80-89).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_LOW = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: Items that should appear at the end (90-99).
|
||||||
|
*/
|
||||||
|
public const PRIORITY_LAST = 90;
|
||||||
/**
|
/**
|
||||||
* Return admin menu items for this module.
|
* Return admin menu items for this module.
|
||||||
*
|
*
|
||||||
* Each item should specify:
|
* Each item should specify:
|
||||||
* - group: string (dashboard|webhost|services|settings|admin)
|
* - group: string (dashboard|workspaces|services|settings|admin)
|
||||||
* - priority: int (lower = earlier in group)
|
* - priority: int (use PRIORITY_* constants for consistent ordering)
|
||||||
* - entitlement: string|null (feature code for access check)
|
* - entitlement: string|null (feature code for access check)
|
||||||
* - permissions: array|null (required user permissions)
|
* - permissions: array|null (required user permissions)
|
||||||
* - admin: bool (requires Hades/admin user)
|
* - admin: bool (requires Hades/admin user)
|
||||||
* - item: Closure (lazy-evaluated menu item data)
|
* - item: Closure (lazy-evaluated menu item data)
|
||||||
*
|
*
|
||||||
|
* The item closure should return an array with:
|
||||||
|
* - label: string (display text)
|
||||||
|
* - icon: string (FontAwesome icon name, validated by IconValidator)
|
||||||
|
* - href: string (link URL)
|
||||||
|
* - active: bool (whether item is currently active)
|
||||||
|
* - color: string|null (optional color theme)
|
||||||
|
* - badge: string|array|null (optional badge text or config)
|
||||||
|
* - children: array|null (optional sub-menu items)
|
||||||
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* ```php
|
* ```php
|
||||||
* return [
|
* return [
|
||||||
* [
|
* [
|
||||||
* 'group' => 'services',
|
* 'group' => 'services',
|
||||||
* 'priority' => 20,
|
* 'priority' => self::PRIORITY_NORMAL, // 50
|
||||||
* 'entitlement' => 'core.srv.bio',
|
* 'entitlement' => 'core.srv.bio',
|
||||||
* 'permissions' => ['bio.view', 'bio.manage'],
|
* 'permissions' => ['bio.view', 'bio.manage'],
|
||||||
* 'item' => fn() => [
|
* 'item' => fn() => [
|
||||||
* 'label' => 'BioHost',
|
* 'label' => 'BioHost',
|
||||||
* 'icon' => 'link',
|
* 'icon' => 'link', // Validated against FontAwesome icons
|
||||||
* 'href' => route('hub.bio.index'),
|
* 'href' => route('hub.bio.index'),
|
||||||
* 'active' => request()->routeIs('hub.bio.*'),
|
* 'active' => request()->routeIs('hub.bio.*'),
|
||||||
* 'children' => [...],
|
* 'children' => [...],
|
||||||
|
|
@ -84,6 +176,8 @@ interface AdminMenuProvider
|
||||||
* admin?: bool,
|
* admin?: bool,
|
||||||
* item: \Closure
|
* item: \Closure
|
||||||
* }>
|
* }>
|
||||||
|
*
|
||||||
|
* @see IconValidator For valid icon names
|
||||||
*/
|
*/
|
||||||
public function adminMenuItems(): array;
|
public function adminMenuItems(): array;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,853 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Front\Admin\Support;
|
||||||
|
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for constructing admin menu items.
|
||||||
|
*
|
||||||
|
* Provides a chainable API for building menu item configurations
|
||||||
|
* without manually constructing nested arrays.
|
||||||
|
*
|
||||||
|
* ## Basic Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Front\Admin\Support\MenuItemBuilder;
|
||||||
|
*
|
||||||
|
* $item = MenuItemBuilder::make('Products')
|
||||||
|
* ->icon('cube')
|
||||||
|
* ->href('/admin/products')
|
||||||
|
* ->inGroup('services')
|
||||||
|
* ->withPriority(AdminMenuProvider::PRIORITY_NORMAL)
|
||||||
|
* ->build();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## With Children
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $item = MenuItemBuilder::make('Commerce')
|
||||||
|
* ->icon('shopping-cart')
|
||||||
|
* ->href('/admin/commerce')
|
||||||
|
* ->inGroup('services')
|
||||||
|
* ->entitlement('core.srv.commerce')
|
||||||
|
* ->children([
|
||||||
|
* MenuItemBuilder::child('Products', '/products')->icon('cube'),
|
||||||
|
* MenuItemBuilder::child('Orders', '/orders')->icon('receipt'),
|
||||||
|
* ])
|
||||||
|
* ->build();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## With Permissions
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $item = MenuItemBuilder::make('Settings')
|
||||||
|
* ->icon('gear')
|
||||||
|
* ->href('/admin/settings')
|
||||||
|
* ->requireAdmin()
|
||||||
|
* ->permissions(['settings.view', 'settings.edit'])
|
||||||
|
* ->build();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package Core\Front\Admin\Support
|
||||||
|
*
|
||||||
|
* @see AdminMenuProvider For menu provider interface
|
||||||
|
* @see MenuItemGroup For grouping utilities
|
||||||
|
*/
|
||||||
|
class MenuItemBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The menu item label.
|
||||||
|
*/
|
||||||
|
protected string $label;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu item icon (FontAwesome name).
|
||||||
|
*/
|
||||||
|
protected ?string $icon = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu item URL/href.
|
||||||
|
*/
|
||||||
|
protected ?string $href = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route name for href generation.
|
||||||
|
*/
|
||||||
|
protected ?string $route = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route parameters for href generation.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected array $routeParams = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu group (dashboard, workspaces, services, settings, admin).
|
||||||
|
*/
|
||||||
|
protected string $group = 'services';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority within the group.
|
||||||
|
*/
|
||||||
|
protected int $priority = AdminMenuProvider::PRIORITY_NORMAL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entitlement code for access control.
|
||||||
|
*/
|
||||||
|
protected ?string $entitlement = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required permissions array.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected array $permissions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether admin access is required.
|
||||||
|
*/
|
||||||
|
protected bool $admin = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color theme for the item.
|
||||||
|
*/
|
||||||
|
protected ?string $color = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge text or configuration.
|
||||||
|
*
|
||||||
|
* @var string|array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected string|array|null $badge = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Child menu items.
|
||||||
|
*
|
||||||
|
* @var array<int, MenuItemBuilder|array>
|
||||||
|
*/
|
||||||
|
protected array $children = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closure to determine active state.
|
||||||
|
*/
|
||||||
|
protected ?\Closure $activeCallback = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the item is currently active.
|
||||||
|
*/
|
||||||
|
protected ?bool $active = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service key for service-specific lookups.
|
||||||
|
*/
|
||||||
|
protected ?string $service = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional custom attributes.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected array $attributes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new menu item builder.
|
||||||
|
*
|
||||||
|
* @param string $label The menu item display text
|
||||||
|
*/
|
||||||
|
public function __construct(string $label)
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new menu item builder (static factory).
|
||||||
|
*
|
||||||
|
* @param string $label The menu item display text
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public static function make(string $label): static
|
||||||
|
{
|
||||||
|
return new static($label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a child menu item builder.
|
||||||
|
*
|
||||||
|
* Convenience factory for creating sub-menu items with a relative href.
|
||||||
|
*
|
||||||
|
* @param string $label The child item label
|
||||||
|
* @param string $href The child item URL
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public static function child(string $label, string $href): static
|
||||||
|
{
|
||||||
|
return (new static($label))->href($href);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the icon name (FontAwesome).
|
||||||
|
*
|
||||||
|
* @param string $icon Icon name (e.g., 'home', 'gear', 'fa-solid fa-user')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function icon(string $icon): static
|
||||||
|
{
|
||||||
|
$this->icon = $icon;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the URL/href for the menu item.
|
||||||
|
*
|
||||||
|
* @param string $href The URL path
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function href(string $href): static
|
||||||
|
{
|
||||||
|
$this->href = $href;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the route name for href generation.
|
||||||
|
*
|
||||||
|
* The href will be generated using Laravel's route() helper at build time.
|
||||||
|
*
|
||||||
|
* @param string $route The route name
|
||||||
|
* @param array<string, mixed> $params Optional route parameters
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function route(string $route, array $params = []): static
|
||||||
|
{
|
||||||
|
$this->route = $route;
|
||||||
|
$this->routeParams = $params;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the menu group.
|
||||||
|
*
|
||||||
|
* @param string $group Group key (dashboard, workspaces, services, settings, admin)
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function inGroup(string $group): static
|
||||||
|
{
|
||||||
|
$this->group = $group;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place in the dashboard group.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function inDashboard(): static
|
||||||
|
{
|
||||||
|
return $this->inGroup('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place in the workspaces group.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function inWorkspaces(): static
|
||||||
|
{
|
||||||
|
return $this->inGroup('workspaces');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place in the services group (default).
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function inServices(): static
|
||||||
|
{
|
||||||
|
return $this->inGroup('services');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place in the settings group.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function inSettings(): static
|
||||||
|
{
|
||||||
|
return $this->inGroup('settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place in the admin group.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function inAdmin(): static
|
||||||
|
{
|
||||||
|
return $this->inGroup('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the priority within the group.
|
||||||
|
*
|
||||||
|
* @param int $priority Use AdminMenuProvider::PRIORITY_* constants
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function withPriority(int $priority): static
|
||||||
|
{
|
||||||
|
$this->priority = $priority;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for withPriority().
|
||||||
|
*
|
||||||
|
* @param int $priority Priority value
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function priority(int $priority): static
|
||||||
|
{
|
||||||
|
return $this->withPriority($priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to highest priority (first in group).
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function first(): static
|
||||||
|
{
|
||||||
|
return $this->withPriority(AdminMenuProvider::PRIORITY_FIRST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to high priority.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function high(): static
|
||||||
|
{
|
||||||
|
return $this->withPriority(AdminMenuProvider::PRIORITY_HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to low priority.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function low(): static
|
||||||
|
{
|
||||||
|
return $this->withPriority(AdminMenuProvider::PRIORITY_LOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to lowest priority (last in group).
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function last(): static
|
||||||
|
{
|
||||||
|
return $this->withPriority(AdminMenuProvider::PRIORITY_LAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the entitlement code for workspace-level access control.
|
||||||
|
*
|
||||||
|
* @param string $entitlement The feature code (e.g., 'core.srv.commerce')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function entitlement(string $entitlement): static
|
||||||
|
{
|
||||||
|
$this->entitlement = $entitlement;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for entitlement().
|
||||||
|
*
|
||||||
|
* @param string $entitlement The feature code
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function requiresEntitlement(string $entitlement): static
|
||||||
|
{
|
||||||
|
return $this->entitlement($entitlement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set required permissions.
|
||||||
|
*
|
||||||
|
* @param array<string> $permissions Array of permission keys
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function permissions(array $permissions): static
|
||||||
|
{
|
||||||
|
$this->permissions = $permissions;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single required permission.
|
||||||
|
*
|
||||||
|
* @param string $permission The permission key
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function permission(string $permission): static
|
||||||
|
{
|
||||||
|
$this->permissions[] = $permission;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for permissions().
|
||||||
|
*
|
||||||
|
* @param array<string> $permissions Array of permission keys
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function requiresPermissions(array $permissions): static
|
||||||
|
{
|
||||||
|
return $this->permissions($permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require admin access (Hades user).
|
||||||
|
*
|
||||||
|
* @param bool $required Whether admin is required
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function requireAdmin(bool $required = true): static
|
||||||
|
{
|
||||||
|
$this->admin = $required;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for requireAdmin().
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function adminOnly(): static
|
||||||
|
{
|
||||||
|
return $this->requireAdmin(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the color theme.
|
||||||
|
*
|
||||||
|
* @param string $color Color name (e.g., 'blue', 'green', 'amber')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function color(string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a text badge.
|
||||||
|
*
|
||||||
|
* @param string $text Badge text
|
||||||
|
* @param string|null $color Optional badge color
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function badge(string $text, ?string $color = null): static
|
||||||
|
{
|
||||||
|
if ($color !== null) {
|
||||||
|
$this->badge = ['text' => $text, 'color' => $color];
|
||||||
|
} else {
|
||||||
|
$this->badge = $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a numeric badge with a count.
|
||||||
|
*
|
||||||
|
* @param int $count The count to display
|
||||||
|
* @param string|null $color Optional badge color
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function badgeCount(int $count, ?string $color = null): static
|
||||||
|
{
|
||||||
|
return $this->badge((string) $count, $color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a configurable badge.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $config Badge configuration
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function badgeConfig(array $config): static
|
||||||
|
{
|
||||||
|
$this->badge = $config;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set child menu items.
|
||||||
|
*
|
||||||
|
* @param array<int, MenuItemBuilder|array> $children Child items or builders
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function children(array $children): static
|
||||||
|
{
|
||||||
|
$this->children = $children;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a child menu item.
|
||||||
|
*
|
||||||
|
* @param MenuItemBuilder|array $child Child item or builder
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function addChild(MenuItemBuilder|array $child): static
|
||||||
|
{
|
||||||
|
$this->children[] = $child;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a separator to children.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function separator(): static
|
||||||
|
{
|
||||||
|
$this->children[] = MenuItemGroup::separator();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a section header to children.
|
||||||
|
*
|
||||||
|
* @param string $label Section label
|
||||||
|
* @param string|null $icon Optional icon
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function section(string $label, ?string $icon = null): static
|
||||||
|
{
|
||||||
|
$this->children[] = MenuItemGroup::header($label, $icon);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a divider to children.
|
||||||
|
*
|
||||||
|
* @param string|null $label Optional divider label
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function divider(?string $label = null): static
|
||||||
|
{
|
||||||
|
$this->children[] = MenuItemGroup::divider($label);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether the item is active.
|
||||||
|
*
|
||||||
|
* @param bool $active Active state
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function active(bool $active = true): static
|
||||||
|
{
|
||||||
|
$this->active = $active;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a callback to determine active state.
|
||||||
|
*
|
||||||
|
* The callback is evaluated at build time in the item closure.
|
||||||
|
*
|
||||||
|
* @param \Closure $callback Callback returning bool
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function activeWhen(\Closure $callback): static
|
||||||
|
{
|
||||||
|
$this->activeCallback = $callback;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set active when the current route matches a pattern.
|
||||||
|
*
|
||||||
|
* @param string $pattern Route pattern (e.g., 'hub.commerce.*')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function activeOnRoute(string $pattern): static
|
||||||
|
{
|
||||||
|
return $this->activeWhen(fn () => request()->routeIs($pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the service key for service-specific lookups.
|
||||||
|
*
|
||||||
|
* @param string $key Service key (e.g., 'commerce', 'bio')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function service(string $key): static
|
||||||
|
{
|
||||||
|
$this->service = $key;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom attribute.
|
||||||
|
*
|
||||||
|
* @param string $key Attribute key
|
||||||
|
* @param mixed $value Attribute value
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function with(string $key, mixed $value): static
|
||||||
|
{
|
||||||
|
$this->attributes[$key] = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set multiple custom attributes.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes Attributes array
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function withAttributes(array $attributes): static
|
||||||
|
{
|
||||||
|
$this->attributes = array_merge($this->attributes, $attributes);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the menu item registration array.
|
||||||
|
*
|
||||||
|
* Returns the structure expected by AdminMenuProvider::adminMenuItems().
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* group: string,
|
||||||
|
* priority: int,
|
||||||
|
* entitlement?: string|null,
|
||||||
|
* permissions?: array<string>,
|
||||||
|
* admin?: bool,
|
||||||
|
* service?: string,
|
||||||
|
* item: \Closure
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(): array
|
||||||
|
{
|
||||||
|
$registration = [
|
||||||
|
'group' => $this->group,
|
||||||
|
'priority' => $this->priority,
|
||||||
|
'item' => $this->buildItemClosure(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->entitlement !== null) {
|
||||||
|
$registration['entitlement'] = $this->entitlement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($this->permissions)) {
|
||||||
|
$registration['permissions'] = $this->permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->admin) {
|
||||||
|
$registration['admin'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->service !== null) {
|
||||||
|
$registration['service'] = $this->service;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the lazy-evaluated item closure.
|
||||||
|
*
|
||||||
|
* @return \Closure
|
||||||
|
*/
|
||||||
|
protected function buildItemClosure(): \Closure
|
||||||
|
{
|
||||||
|
return function () {
|
||||||
|
$item = [
|
||||||
|
'label' => $this->label,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve href
|
||||||
|
if ($this->route !== null) {
|
||||||
|
$item['href'] = route($this->route, $this->routeParams);
|
||||||
|
} elseif ($this->href !== null) {
|
||||||
|
$item['href'] = $this->href;
|
||||||
|
} else {
|
||||||
|
$item['href'] = '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional icon
|
||||||
|
if ($this->icon !== null) {
|
||||||
|
$item['icon'] = $this->icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve active state
|
||||||
|
if ($this->activeCallback !== null) {
|
||||||
|
$item['active'] = ($this->activeCallback)();
|
||||||
|
} elseif ($this->active !== null) {
|
||||||
|
$item['active'] = $this->active;
|
||||||
|
} else {
|
||||||
|
$item['active'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional color
|
||||||
|
if ($this->color !== null) {
|
||||||
|
$item['color'] = $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional badge
|
||||||
|
if ($this->badge !== null) {
|
||||||
|
$item['badge'] = $this->badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build children
|
||||||
|
if (! empty($this->children)) {
|
||||||
|
$item['children'] = $this->buildChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom attributes
|
||||||
|
foreach ($this->attributes as $key => $value) {
|
||||||
|
if (! isset($item[$key])) {
|
||||||
|
$item[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the children array.
|
||||||
|
*
|
||||||
|
* @return array<int, array>
|
||||||
|
*/
|
||||||
|
protected function buildChildren(): array
|
||||||
|
{
|
||||||
|
$built = [];
|
||||||
|
|
||||||
|
foreach ($this->children as $child) {
|
||||||
|
if ($child instanceof MenuItemBuilder) {
|
||||||
|
// Build the child item directly (not the registration)
|
||||||
|
$built[] = $child->buildChildItem();
|
||||||
|
} else {
|
||||||
|
// Already an array (separator, header, etc.)
|
||||||
|
$built[] = $child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $built;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a child item array (without registration wrapper).
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function buildChildItem(): array
|
||||||
|
{
|
||||||
|
$item = [
|
||||||
|
'label' => $this->label,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->route !== null) {
|
||||||
|
$item['href'] = route($this->route, $this->routeParams);
|
||||||
|
} elseif ($this->href !== null) {
|
||||||
|
$item['href'] = $this->href;
|
||||||
|
} else {
|
||||||
|
$item['href'] = '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->icon !== null) {
|
||||||
|
$item['icon'] = $this->icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->activeCallback !== null) {
|
||||||
|
$item['active'] = ($this->activeCallback)();
|
||||||
|
} elseif ($this->active !== null) {
|
||||||
|
$item['active'] = $this->active;
|
||||||
|
} else {
|
||||||
|
$item['active'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->color !== null) {
|
||||||
|
$item['color'] = $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->badge !== null) {
|
||||||
|
$item['badge'] = $this->badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->attributes as $key => $value) {
|
||||||
|
if (! isset($item[$key])) {
|
||||||
|
$item[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the group.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getGroup(): string
|
||||||
|
{
|
||||||
|
return $this->group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the priority.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return $this->priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
257
packages/core-php/src/Core/Front/Admin/Support/MenuItemGroup.php
Normal file
257
packages/core-php/src/Core/Front/Admin/Support/MenuItemGroup.php
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Front\Admin\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a group of related menu items with optional separator and header.
|
||||||
|
*
|
||||||
|
* Menu item groups allow organizing menu items within a navigation section
|
||||||
|
* by adding visual separators and section headers. This improves menu
|
||||||
|
* organization for modules with many related items.
|
||||||
|
*
|
||||||
|
* ## Features
|
||||||
|
*
|
||||||
|
* - Section headers with optional icons
|
||||||
|
* - Visual separators between groups
|
||||||
|
* - Collapsible group support
|
||||||
|
* - Badge support for group headers
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* Groups are defined in the menu item structure using special keys:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* return [
|
||||||
|
* [
|
||||||
|
* 'group' => 'services',
|
||||||
|
* 'priority' => 50,
|
||||||
|
* 'item' => fn() => [
|
||||||
|
* 'label' => 'Commerce',
|
||||||
|
* 'icon' => 'shopping-cart',
|
||||||
|
* 'href' => route('hub.commerce.index'),
|
||||||
|
* 'active' => request()->routeIs('hub.commerce.*'),
|
||||||
|
* // Define sub-groups within children
|
||||||
|
* 'children' => [
|
||||||
|
* // Group header (section)
|
||||||
|
* MenuItemGroup::header('Products', 'cube'),
|
||||||
|
* ['label' => 'All Products', 'href' => '/products'],
|
||||||
|
* ['label' => 'Categories', 'href' => '/categories'],
|
||||||
|
* // Separator
|
||||||
|
* MenuItemGroup::separator(),
|
||||||
|
* // Another group
|
||||||
|
* MenuItemGroup::header('Orders', 'receipt'),
|
||||||
|
* ['label' => 'All Orders', 'href' => '/orders'],
|
||||||
|
* ['label' => 'Pending', 'href' => '/orders/pending'],
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ];
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @package Core\Front\Admin\Support
|
||||||
|
*/
|
||||||
|
class MenuItemGroup
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Type constant for separator items.
|
||||||
|
*/
|
||||||
|
public const TYPE_SEPARATOR = 'separator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type constant for section header items.
|
||||||
|
*/
|
||||||
|
public const TYPE_HEADER = 'header';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type constant for collapsible group items.
|
||||||
|
*/
|
||||||
|
public const TYPE_COLLAPSIBLE = 'collapsible';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a separator element.
|
||||||
|
*
|
||||||
|
* Separators are visual dividers between groups of menu items.
|
||||||
|
* They render as a horizontal line in the menu.
|
||||||
|
*
|
||||||
|
* @return array{separator: true}
|
||||||
|
*/
|
||||||
|
public static function separator(): array
|
||||||
|
{
|
||||||
|
return ['separator' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a section header element.
|
||||||
|
*
|
||||||
|
* Section headers provide a label for a group of related menu items.
|
||||||
|
* They appear as styled text (usually uppercase) with an optional icon.
|
||||||
|
*
|
||||||
|
* @param string $label The header text
|
||||||
|
* @param string|null $icon Optional FontAwesome icon name
|
||||||
|
* @param string|null $color Optional color theme (e.g., 'blue', 'green')
|
||||||
|
* @param string|array|null $badge Optional badge text or config
|
||||||
|
* @return array{section: string, icon?: string, color?: string, badge?: string|array}
|
||||||
|
*/
|
||||||
|
public static function header(
|
||||||
|
string $label,
|
||||||
|
?string $icon = null,
|
||||||
|
?string $color = null,
|
||||||
|
string|array|null $badge = null
|
||||||
|
): array {
|
||||||
|
$item = ['section' => $label];
|
||||||
|
|
||||||
|
if ($icon !== null) {
|
||||||
|
$item['icon'] = $icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($color !== null) {
|
||||||
|
$item['color'] = $color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($badge !== null) {
|
||||||
|
$item['badge'] = $badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a collapsible group.
|
||||||
|
*
|
||||||
|
* Collapsible groups can be expanded/collapsed by clicking the header.
|
||||||
|
* They maintain state using localStorage when configured.
|
||||||
|
*
|
||||||
|
* @param string $label The group header text
|
||||||
|
* @param array<int, array> $children Child menu items
|
||||||
|
* @param string|null $icon Optional FontAwesome icon name
|
||||||
|
* @param string|null $color Optional color theme
|
||||||
|
* @param bool $defaultOpen Whether the group is open by default
|
||||||
|
* @param string|null $stateKey Optional localStorage key for persisting state
|
||||||
|
* @return array{collapsible: true, label: string, children: array, icon?: string, color?: string, open?: bool, stateKey?: string}
|
||||||
|
*/
|
||||||
|
public static function collapsible(
|
||||||
|
string $label,
|
||||||
|
array $children,
|
||||||
|
?string $icon = null,
|
||||||
|
?string $color = null,
|
||||||
|
bool $defaultOpen = true,
|
||||||
|
?string $stateKey = null
|
||||||
|
): array {
|
||||||
|
$item = [
|
||||||
|
'collapsible' => true,
|
||||||
|
'label' => $label,
|
||||||
|
'children' => $children,
|
||||||
|
'open' => $defaultOpen,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($icon !== null) {
|
||||||
|
$item['icon'] = $icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($color !== null) {
|
||||||
|
$item['color'] = $color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stateKey !== null) {
|
||||||
|
$item['stateKey'] = $stateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a divider with an optional label.
|
||||||
|
*
|
||||||
|
* Dividers are similar to separators but can include centered text.
|
||||||
|
*
|
||||||
|
* @param string|null $label Optional centered label
|
||||||
|
* @return array{divider: true, label?: string}
|
||||||
|
*/
|
||||||
|
public static function divider(?string $label = null): array
|
||||||
|
{
|
||||||
|
$item = ['divider' => true];
|
||||||
|
|
||||||
|
if ($label !== null) {
|
||||||
|
$item['label'] = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is a separator.
|
||||||
|
*
|
||||||
|
* @param array $item The menu item to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isSeparator(array $item): bool
|
||||||
|
{
|
||||||
|
return ! empty($item['separator']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is a section header.
|
||||||
|
*
|
||||||
|
* @param array $item The menu item to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isHeader(array $item): bool
|
||||||
|
{
|
||||||
|
return ! empty($item['section']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is a collapsible group.
|
||||||
|
*
|
||||||
|
* @param array $item The menu item to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isCollapsible(array $item): bool
|
||||||
|
{
|
||||||
|
return ! empty($item['collapsible']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is a divider.
|
||||||
|
*
|
||||||
|
* @param array $item The menu item to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isDivider(array $item): bool
|
||||||
|
{
|
||||||
|
return ! empty($item['divider']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is a structural element (separator, header, divider, or collapsible).
|
||||||
|
*
|
||||||
|
* @param array $item The menu item to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isStructural(array $item): bool
|
||||||
|
{
|
||||||
|
return self::isSeparator($item)
|
||||||
|
|| self::isHeader($item)
|
||||||
|
|| self::isDivider($item)
|
||||||
|
|| self::isCollapsible($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item is a regular menu link.
|
||||||
|
*
|
||||||
|
* @param array $item The menu item to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isLink(array $item): bool
|
||||||
|
{
|
||||||
|
return ! self::isStructural($item) && isset($item['label']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,465 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Front\Admin\Validation;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates menu item icons against known FontAwesome icon sets.
|
||||||
|
*
|
||||||
|
* This validator ensures that icons used in admin menu items are valid
|
||||||
|
* FontAwesome icons. It supports both shorthand (e.g., 'home') and full
|
||||||
|
* format (e.g., 'fas fa-home', 'fa-solid fa-home').
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $validator = new IconValidator();
|
||||||
|
*
|
||||||
|
* // Validate a single icon
|
||||||
|
* if ($validator->isValid('home')) {
|
||||||
|
* // Icon is valid
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Get validation errors
|
||||||
|
* $errors = $validator->validate(['home', 'invalid-icon']);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Icon Formats
|
||||||
|
*
|
||||||
|
* The validator accepts multiple formats:
|
||||||
|
* - Shorthand: `home`, `user`, `cog`
|
||||||
|
* - Full class: `fas fa-home`, `fa-solid fa-home`
|
||||||
|
* - Brand icons: `fab fa-github`, `fa-brands fa-twitter`
|
||||||
|
*
|
||||||
|
* ## Extending
|
||||||
|
*
|
||||||
|
* Add custom icons via the `addCustomIcon()` method or register
|
||||||
|
* icon packs with `registerIconPack()`.
|
||||||
|
*/
|
||||||
|
class IconValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Core FontAwesome solid icons (common subset).
|
||||||
|
*
|
||||||
|
* This is not exhaustive - FontAwesome has 2000+ icons.
|
||||||
|
* Configure additional icons via config or custom icon packs.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected const SOLID_ICONS = [
|
||||||
|
// Navigation & UI
|
||||||
|
'home', 'house', 'bars', 'times', 'close', 'xmark', 'check', 'plus', 'minus',
|
||||||
|
'arrow-left', 'arrow-right', 'arrow-up', 'arrow-down', 'chevron-left',
|
||||||
|
'chevron-right', 'chevron-up', 'chevron-down', 'angle-left', 'angle-right',
|
||||||
|
'angle-up', 'angle-down', 'caret-left', 'caret-right', 'caret-up', 'caret-down',
|
||||||
|
'ellipsis', 'ellipsis-vertical', 'grip', 'grip-vertical',
|
||||||
|
|
||||||
|
// Common Objects
|
||||||
|
'user', 'users', 'user-plus', 'user-minus', 'user-gear', 'user-shield',
|
||||||
|
'user-check', 'user-xmark', 'user-group', 'people-group',
|
||||||
|
'gear', 'gears', 'cog', 'cogs', 'sliders', 'wrench', 'screwdriver',
|
||||||
|
'file', 'file-lines', 'file-pdf', 'file-image', 'file-code', 'file-export',
|
||||||
|
'file-import', 'folder', 'folder-open', 'folder-plus', 'folder-minus',
|
||||||
|
'envelope', 'envelope-open', 'paper-plane', 'inbox', 'mailbox',
|
||||||
|
'phone', 'mobile', 'tablet', 'laptop', 'desktop', 'computer',
|
||||||
|
'calendar', 'calendar-days', 'calendar-check', 'calendar-plus',
|
||||||
|
'clock', 'stopwatch', 'hourglass', 'timer',
|
||||||
|
'bell', 'bell-slash', 'bell-concierge',
|
||||||
|
'bookmark', 'bookmarks', 'flag', 'tag', 'tags',
|
||||||
|
'star', 'star-half', 'heart', 'thumbs-up', 'thumbs-down',
|
||||||
|
'comment', 'comments', 'message', 'quote-left', 'quote-right',
|
||||||
|
'image', 'images', 'camera', 'video', 'film', 'photo-film',
|
||||||
|
'music', 'headphones', 'microphone', 'volume-high', 'volume-low', 'volume-off',
|
||||||
|
'play', 'pause', 'stop', 'forward', 'backward', 'circle-play',
|
||||||
|
'link', 'link-slash', 'chain', 'chain-broken', 'unlink',
|
||||||
|
'key', 'lock', 'lock-open', 'unlock', 'shield', 'shield-halved',
|
||||||
|
'eye', 'eye-slash', 'glasses',
|
||||||
|
'magnifying-glass', 'search', 'filter', 'sort', 'sort-up', 'sort-down',
|
||||||
|
|
||||||
|
// E-commerce & Business
|
||||||
|
'cart-shopping', 'basket-shopping', 'bag-shopping', 'store', 'shop',
|
||||||
|
'credit-card', 'money-bill', 'money-bill-wave', 'coins', 'wallet',
|
||||||
|
'receipt', 'barcode', 'qrcode', 'box', 'boxes', 'package',
|
||||||
|
'truck', 'shipping-fast', 'dolly', 'warehouse',
|
||||||
|
'chart-line', 'chart-bar', 'chart-pie', 'chart-area', 'chart-simple',
|
||||||
|
'arrow-trend-up', 'arrow-trend-down',
|
||||||
|
'briefcase', 'suitcase', 'building', 'buildings', 'city', 'industry',
|
||||||
|
'handshake', 'handshake-angle', 'hands-holding',
|
||||||
|
|
||||||
|
// Communication & Social
|
||||||
|
'at', 'hashtag', 'share', 'share-nodes', 'share-from-square',
|
||||||
|
'globe', 'earth-americas', 'earth-europe', 'earth-asia',
|
||||||
|
'rss', 'wifi', 'signal', 'broadcast-tower',
|
||||||
|
'bullhorn', 'megaphone', 'newspaper',
|
||||||
|
|
||||||
|
// Content & Media
|
||||||
|
'pen', 'pencil', 'pen-to-square', 'edit', 'eraser', 'highlighter',
|
||||||
|
'palette', 'paintbrush', 'brush', 'spray-can',
|
||||||
|
'align-left', 'align-center', 'align-right', 'align-justify',
|
||||||
|
'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript',
|
||||||
|
'list', 'list-ul', 'list-ol', 'list-check', 'table', 'table-cells',
|
||||||
|
'code', 'terminal', 'code-branch', 'code-merge', 'code-pull-request',
|
||||||
|
'cube', 'cubes', 'puzzle-piece', 'shapes',
|
||||||
|
|
||||||
|
// Actions & States
|
||||||
|
'plus', 'minus', 'times', 'check', 'xmark',
|
||||||
|
'circle', 'circle-check', 'circle-xmark', 'circle-info', 'circle-question',
|
||||||
|
'circle-exclamation', 'circle-notch', 'circle-dot', 'circle-half-stroke',
|
||||||
|
'square', 'square-check', 'square-xmark', 'square-plus', 'square-minus',
|
||||||
|
'triangle-exclamation', 'exclamation', 'question', 'info',
|
||||||
|
'rotate', 'rotate-right', 'rotate-left', 'sync', 'refresh', 'redo', 'undo',
|
||||||
|
'arrows-rotate', 'arrows-spin', 'spinner', 'circle-notch',
|
||||||
|
'download', 'upload', 'cloud-download', 'cloud-upload',
|
||||||
|
'save', 'floppy-disk', 'copy', 'paste', 'clipboard', 'trash', 'trash-can',
|
||||||
|
'print', 'share', 'export', 'external-link', 'expand', 'compress',
|
||||||
|
|
||||||
|
// Security & Privacy
|
||||||
|
'shield', 'shield-halved', 'shield-check', 'user-shield',
|
||||||
|
'fingerprint', 'id-card', 'id-badge', 'passport',
|
||||||
|
'mask', 'ban', 'block', 'circle-stop',
|
||||||
|
|
||||||
|
// Development & Tech
|
||||||
|
'server', 'database', 'hdd', 'memory', 'microchip',
|
||||||
|
'plug', 'power-off', 'bolt', 'battery-full', 'battery-half', 'battery-empty',
|
||||||
|
'robot', 'brain', 'lightbulb', 'wand-magic', 'wand-magic-sparkles',
|
||||||
|
'bug', 'bug-slash', 'vial', 'flask', 'microscope',
|
||||||
|
|
||||||
|
// Locations & Maps
|
||||||
|
'location-dot', 'location-pin', 'map', 'map-pin', 'map-marker',
|
||||||
|
'compass', 'directions', 'route', 'road',
|
||||||
|
|
||||||
|
// Nature & Weather
|
||||||
|
'sun', 'moon', 'cloud', 'cloud-sun', 'cloud-moon', 'cloud-rain',
|
||||||
|
'snowflake', 'wind', 'temperature-high', 'temperature-low',
|
||||||
|
'tree', 'leaf', 'seedling', 'flower', 'mountain',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
'layer-group', 'layers', 'sitemap', 'network-wired',
|
||||||
|
'grip-lines', 'grip-lines-vertical', 'border-all',
|
||||||
|
'award', 'trophy', 'medal', 'crown', 'gem',
|
||||||
|
'gift', 'cake-candles', 'champagne-glasses',
|
||||||
|
'graduation-cap', 'book', 'book-open', 'bookmark',
|
||||||
|
'hospital', 'stethoscope', 'heart-pulse', 'pills', 'syringe',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand icons (FontAwesome brands).
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected const BRAND_ICONS = [
|
||||||
|
// Social Media
|
||||||
|
'facebook', 'facebook-f', 'twitter', 'x-twitter', 'instagram', 'linkedin',
|
||||||
|
'linkedin-in', 'youtube', 'tiktok', 'snapchat', 'pinterest', 'reddit',
|
||||||
|
'tumblr', 'whatsapp', 'telegram', 'discord', 'slack', 'twitch',
|
||||||
|
|
||||||
|
// Development
|
||||||
|
'github', 'gitlab', 'bitbucket', 'git', 'git-alt', 'docker',
|
||||||
|
'npm', 'node', 'node-js', 'php', 'python', 'java', 'js', 'js-square',
|
||||||
|
'html5', 'css3', 'css3-alt', 'sass', 'less', 'bootstrap',
|
||||||
|
'react', 'vuejs', 'angular', 'laravel', 'symfony',
|
||||||
|
'aws', 'digital-ocean', 'google-cloud', 'microsoft', 'azure',
|
||||||
|
|
||||||
|
// Companies
|
||||||
|
'apple', 'google', 'amazon', 'microsoft', 'meta', 'stripe',
|
||||||
|
'paypal', 'cc-visa', 'cc-mastercard', 'cc-amex', 'cc-stripe',
|
||||||
|
'shopify', 'wordpress', 'drupal', 'joomla', 'magento',
|
||||||
|
|
||||||
|
// Services
|
||||||
|
'dropbox', 'google-drive', 'trello', 'jira', 'confluence',
|
||||||
|
'figma', 'sketch', 'invision', 'adobe', 'behance', 'dribbble',
|
||||||
|
'vimeo', 'spotify', 'soundcloud', 'deezer', 'lastfm',
|
||||||
|
'mailchimp', 'hubspot', 'salesforce', 'zendesk',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
'android', 'apple', 'windows', 'linux', 'ubuntu', 'fedora', 'chrome',
|
||||||
|
'firefox', 'safari', 'edge', 'opera', 'internet-explorer',
|
||||||
|
'bluetooth', 'usb', 'wifi',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom registered icons.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected array $customIcons = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon packs (name => icons array).
|
||||||
|
*
|
||||||
|
* @var array<string, array<string>>
|
||||||
|
*/
|
||||||
|
protected array $iconPacks = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to log validation warnings.
|
||||||
|
*/
|
||||||
|
protected bool $logWarnings = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether strict validation is enabled.
|
||||||
|
*/
|
||||||
|
protected bool $strictMode = false;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->strictMode = (bool) config('core.admin_menu.strict_icon_validation', false);
|
||||||
|
$this->logWarnings = (bool) config('core.admin_menu.log_icon_warnings', true);
|
||||||
|
|
||||||
|
// Load custom icons from config
|
||||||
|
$customIcons = config('core.admin_menu.custom_icons', []);
|
||||||
|
if (is_array($customIcons)) {
|
||||||
|
$this->customIcons = $customIcons;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an icon is valid.
|
||||||
|
*/
|
||||||
|
public function isValid(string $icon): bool
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeIcon($icon);
|
||||||
|
|
||||||
|
return $this->isKnownIcon($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an icon and return errors if any.
|
||||||
|
*
|
||||||
|
* @return array<string> Array of error messages
|
||||||
|
*/
|
||||||
|
public function validate(string $icon): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (empty($icon)) {
|
||||||
|
$errors[] = 'Icon name cannot be empty';
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeIcon($icon);
|
||||||
|
|
||||||
|
if (! $this->isKnownIcon($normalized)) {
|
||||||
|
$errors[] = sprintf(
|
||||||
|
"Unknown icon '%s'. Ensure it's a valid FontAwesome icon or add it to custom icons.",
|
||||||
|
$icon
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->logWarnings) {
|
||||||
|
Log::warning("Unknown admin menu icon: {$icon}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate multiple icons at once.
|
||||||
|
*
|
||||||
|
* @param array<string> $icons
|
||||||
|
* @return array<string, array<string>> Icon => errors mapping
|
||||||
|
*/
|
||||||
|
public function validateMany(array $icons): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($icons as $icon) {
|
||||||
|
$errors = $this->validate($icon);
|
||||||
|
if (! empty($errors)) {
|
||||||
|
$results[$icon] = $errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an icon name to its base form.
|
||||||
|
*
|
||||||
|
* Handles various formats:
|
||||||
|
* - 'home' => 'home'
|
||||||
|
* - 'fa-home' => 'home'
|
||||||
|
* - 'fas fa-home' => 'home'
|
||||||
|
* - 'fa-solid fa-home' => 'home'
|
||||||
|
* - 'fab fa-github' => 'github' (brand)
|
||||||
|
*/
|
||||||
|
public function normalizeIcon(string $icon): string
|
||||||
|
{
|
||||||
|
$icon = trim($icon);
|
||||||
|
|
||||||
|
// Remove FontAwesome class prefixes
|
||||||
|
$icon = preg_replace('/^(fas?|far|fab|fa-solid|fa-regular|fa-brands)\s+/', '', $icon);
|
||||||
|
|
||||||
|
// Remove 'fa-' prefix
|
||||||
|
$icon = preg_replace('/^fa-/', '', $icon ?? $icon);
|
||||||
|
|
||||||
|
return strtolower($icon ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the normalized icon name is in any known icon set.
|
||||||
|
*/
|
||||||
|
protected function isKnownIcon(string $normalizedIcon): bool
|
||||||
|
{
|
||||||
|
// Check core solid icons
|
||||||
|
if (in_array($normalizedIcon, self::SOLID_ICONS, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check brand icons
|
||||||
|
if (in_array($normalizedIcon, self::BRAND_ICONS, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom icons
|
||||||
|
if (in_array($normalizedIcon, $this->customIcons, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check registered icon packs
|
||||||
|
foreach ($this->iconPacks as $icons) {
|
||||||
|
if (in_array($normalizedIcon, $icons, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not strict mode, allow any icon (for extensibility)
|
||||||
|
if (! $this->strictMode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a custom icon to the validator.
|
||||||
|
*/
|
||||||
|
public function addCustomIcon(string $icon): self
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeIcon($icon);
|
||||||
|
if (! in_array($normalized, $this->customIcons, true)) {
|
||||||
|
$this->customIcons[] = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple custom icons.
|
||||||
|
*
|
||||||
|
* @param array<string> $icons
|
||||||
|
*/
|
||||||
|
public function addCustomIcons(array $icons): self
|
||||||
|
{
|
||||||
|
foreach ($icons as $icon) {
|
||||||
|
$this->addCustomIcon($icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a named icon pack.
|
||||||
|
*
|
||||||
|
* @param array<string> $icons
|
||||||
|
*/
|
||||||
|
public function registerIconPack(string $name, array $icons): self
|
||||||
|
{
|
||||||
|
$this->iconPacks[$name] = array_map(
|
||||||
|
fn ($icon) => $this->normalizeIcon($icon),
|
||||||
|
$icons
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set strict mode.
|
||||||
|
*
|
||||||
|
* In strict mode, only known icons are valid.
|
||||||
|
* In non-strict mode (default), unknown icons generate warnings but are allowed.
|
||||||
|
*/
|
||||||
|
public function setStrictMode(bool $strict): self
|
||||||
|
{
|
||||||
|
$this->strictMode = $strict;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable warning logging.
|
||||||
|
*/
|
||||||
|
public function setLogWarnings(bool $log): self
|
||||||
|
{
|
||||||
|
$this->logWarnings = $log;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known solid icons.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getSolidIcons(): array
|
||||||
|
{
|
||||||
|
return self::SOLID_ICONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known brand icons.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getBrandIcons(): array
|
||||||
|
{
|
||||||
|
return self::BRAND_ICONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered custom icons.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getCustomIcons(): array
|
||||||
|
{
|
||||||
|
return $this->customIcons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon suggestions for a potentially misspelled icon.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getSuggestions(string $icon, int $maxSuggestions = 5): array
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeIcon($icon);
|
||||||
|
$allIcons = array_merge(
|
||||||
|
self::SOLID_ICONS,
|
||||||
|
self::BRAND_ICONS,
|
||||||
|
$this->customIcons
|
||||||
|
);
|
||||||
|
|
||||||
|
$suggestions = [];
|
||||||
|
foreach ($allIcons as $knownIcon) {
|
||||||
|
$distance = levenshtein($normalized, $knownIcon);
|
||||||
|
if ($distance <= 3) { // Allow up to 3 character differences
|
||||||
|
$suggestions[$knownIcon] = $distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
asort($suggestions);
|
||||||
|
|
||||||
|
return array_slice(array_keys($suggestions), 0, $maxSuggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
$termsUrl = config('core.urls.terms', '/terms');
|
$termsUrl = config('core.urls.terms', '/terms');
|
||||||
@endphp
|
@endphp
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="dark scroll-smooth overscroll-none">
|
<html lang="en" class="scroll-smooth overscroll-none"
|
||||||
|
x-data="{ darkMode: localStorage.getItem('dark-mode') === 'true' || (localStorage.getItem('dark-mode') === null && window.matchMedia('(prefers-color-scheme: dark)').matches) }"
|
||||||
|
x-init="$watch('darkMode', val => localStorage.setItem('dark-mode', val))"
|
||||||
|
:class="{ 'dark': darkMode }">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
@ -35,7 +38,21 @@
|
||||||
<!-- Tailwind / Vite -->
|
<!-- Tailwind / Vite -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
{{-- Prevent flash of wrong theme --}}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var darkMode = localStorage.getItem('dark-mode');
|
||||||
|
if (darkMode === 'true' || (darkMode === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Prevent flash of unstyled content */
|
||||||
|
html { background-color: #0f172a; }
|
||||||
|
html:not(.dark) { background-color: #f8fafc; }
|
||||||
|
|
||||||
/* Swing animation for decorative shapes */
|
/* Swing animation for decorative shapes */
|
||||||
@keyframes swing {
|
@keyframes swing {
|
||||||
0%, 100% { transform: rotate(-3deg); }
|
0%, 100% { transform: rotate(-3deg); }
|
||||||
|
|
@ -47,23 +64,23 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased bg-slate-900 text-slate-100 tracking-tight min-h-screen flex flex-col overflow-x-hidden overscroll-none">
|
<body class="font-sans antialiased bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-100 tracking-tight min-h-screen flex flex-col overflow-x-hidden overscroll-none">
|
||||||
|
|
||||||
<!-- Decorative background shapes -->
|
<!-- Decorative background shapes -->
|
||||||
<div class="fixed inset-0 -z-10 pointer-events-none overflow-hidden" aria-hidden="true">
|
<div class="fixed inset-0 -z-10 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="absolute w-[960px] h-24 top-12 left-1/2 -translate-x-1/2 animate-[swing_8s_ease-in-out_infinite] blur-3xl">
|
<div class="absolute w-[960px] h-24 top-12 left-1/2 -translate-x-1/2 animate-[swing_8s_ease-in-out_infinite] blur-3xl">
|
||||||
<div class="absolute inset-0 rounded-full bg-gradient-to-b from-transparent via-violet-600/30 to-transparent -rotate-[42deg]"></div>
|
<div class="absolute inset-0 rounded-full bg-gradient-to-b from-transparent via-violet-400/20 dark:via-violet-600/30 to-transparent -rotate-[42deg]"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute w-[960px] h-24 -top-12 left-1/4 animate-[swing_15s_-1s_ease-in-out_infinite] blur-3xl">
|
<div class="absolute w-[960px] h-24 -top-12 left-1/4 animate-[swing_15s_-1s_ease-in-out_infinite] blur-3xl">
|
||||||
<div class="absolute inset-0 rounded-full bg-gradient-to-b from-transparent via-purple-400/20 to-transparent -rotate-[42deg]"></div>
|
<div class="absolute inset-0 rounded-full bg-gradient-to-b from-transparent via-purple-300/15 dark:via-purple-400/20 to-transparent -rotate-[42deg]"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute w-[960px] h-64 bottom-24 right-1/4 animate-[swing_10s_ease-in-out_infinite] blur-3xl">
|
<div class="absolute w-[960px] h-64 bottom-24 right-1/4 animate-[swing_10s_ease-in-out_infinite] blur-3xl">
|
||||||
<div class="absolute inset-0 rounded-full bg-gradient-to-b from-transparent via-violet-600/10 to-transparent -rotate-[42deg]"></div>
|
<div class="absolute inset-0 rounded-full bg-gradient-to-b from-transparent via-violet-400/10 dark:via-violet-600/10 to-transparent -rotate-[42deg]"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Site header -->
|
<!-- Site header -->
|
||||||
<header class="sticky top-0 z-30 bg-slate-900/90 backdrop-blur-xl">
|
<header class="sticky top-0 z-30 bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl">
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between h-16">
|
<div class="flex items-center justify-between h-16">
|
||||||
|
|
||||||
|
|
@ -76,7 +93,7 @@
|
||||||
@else
|
@else
|
||||||
<img src="{{ $appIcon }}" alt="{{ $appName }}" class="w-10 h-10">
|
<img src="{{ $appIcon }}" alt="{{ $appName }}" class="w-10 h-10">
|
||||||
@endif
|
@endif
|
||||||
<span class="font-bold text-lg text-slate-200 group-hover:text-white transition">
|
<span class="font-bold text-lg text-slate-700 dark:text-slate-200 group-hover:text-slate-900 dark:group-hover:text-white transition">
|
||||||
{{ $workspace?->name ?? $appName }}
|
{{ $workspace?->name ?? $appName }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -84,14 +101,24 @@
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="hidden sm:flex items-center gap-6">
|
<nav class="hidden sm:flex items-center gap-6">
|
||||||
@if($workspace)
|
@if($workspace)
|
||||||
<a href="/" class="text-sm text-slate-400 hover:text-white transition">Home</a>
|
<a href="/" class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition">Home</a>
|
||||||
<a href="/blog" class="text-sm text-slate-400 hover:text-white transition">Blog</a>
|
<a href="/blog" class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition">Blog</a>
|
||||||
@endif
|
@endif
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="{{ $appUrl }}" target="_blank" class="text-sm text-slate-400 hover:text-white transition flex items-center gap-1">
|
<!-- Theme toggle -->
|
||||||
|
<button
|
||||||
|
@click="darkMode = !darkMode"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
||||||
|
:aria-label="darkMode ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-sun-bright" x-show="darkMode" x-cloak></i>
|
||||||
|
<i class="fa-solid fa-moon-stars" x-show="!darkMode"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="{{ $appUrl }}" target="_blank" class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition flex items-center gap-1">
|
||||||
<i class="fa-solid fa-arrow-up-right-from-square text-xs"></i>
|
<i class="fa-solid fa-arrow-up-right-from-square text-xs"></i>
|
||||||
<span class="hidden sm:inline">Powered by {{ $appName }}</span>
|
<span class="hidden sm:inline">Powered by {{ $appName }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -107,8 +134,8 @@
|
||||||
<main class="flex-1 relative">
|
<main class="flex-1 relative">
|
||||||
<!-- Radial gradient glow at top of content -->
|
<!-- Radial gradient glow at top of content -->
|
||||||
<div class="absolute flex items-center justify-center top-0 -translate-y-1/2 left-1/2 -translate-x-1/2 pointer-events-none -z-10 w-[800px] aspect-square" aria-hidden="true">
|
<div class="absolute flex items-center justify-center top-0 -translate-y-1/2 left-1/2 -translate-x-1/2 pointer-events-none -z-10 w-[800px] aspect-square" aria-hidden="true">
|
||||||
<div class="absolute inset-0 translate-z-0 bg-violet-500 rounded-full blur-[120px] opacity-20"></div>
|
<div class="absolute inset-0 translate-z-0 bg-violet-500 rounded-full blur-[120px] opacity-10 dark:opacity-20"></div>
|
||||||
<div class="absolute w-64 h-64 translate-z-0 bg-purple-400 rounded-full blur-[80px] opacity-40"></div>
|
<div class="absolute w-64 h-64 translate-z-0 bg-purple-400 rounded-full blur-[80px] opacity-20 dark:opacity-40"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|
@ -127,9 +154,9 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-6 text-sm text-slate-500">
|
<div class="flex items-center gap-6 text-sm text-slate-500">
|
||||||
<a href="{{ $privacyUrl }}" class="hover:text-slate-300 transition">Privacy</a>
|
<a href="{{ $privacyUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition">Privacy</a>
|
||||||
<a href="{{ $termsUrl }}" class="hover:text-slate-300 transition">Terms</a>
|
<a href="{{ $termsUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition">Terms</a>
|
||||||
<a href="{{ $appUrl }}" class="hover:text-slate-300 transition flex items-center gap-1">
|
<a href="{{ $appUrl }}" class="hover:text-slate-700 dark:hover:text-slate-300 transition flex items-center gap-1">
|
||||||
<i class="fa-solid fa-bolt text-violet-400 text-xs"></i>
|
<i class="fa-solid fa-bolt text-violet-400 text-xs"></i>
|
||||||
Powered by {{ $appName }}
|
Powered by {{ $appName }}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Wraps flux:button with built-in authorization checking.
|
||||||
Usage:
|
Usage:
|
||||||
<x-forms.button>Save</x-forms.button>
|
<x-forms.button>Save</x-forms.button>
|
||||||
<x-forms.button variant="primary">Save</x-forms.button>
|
<x-forms.button variant="primary">Save</x-forms.button>
|
||||||
<x-forms.button canGate="update" :canResource="$biolink">Save</x-forms.button>
|
<x-forms.button canGate="update" :canResource="$page">Save</x-forms.button>
|
||||||
--}}
|
--}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Wraps flux:checkbox with built-in authorization checking.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
<x-forms.checkbox id="notify" wire:model="notify" label="Send notifications" />
|
<x-forms.checkbox id="notify" wire:model="notify" label="Send notifications" />
|
||||||
<x-forms.checkbox id="notify" wire:model="notify" label="Send notifications" canGate="update" :canResource="$biolink" />
|
<x-forms.checkbox id="notify" wire:model="notify" label="Send notifications" canGate="update" :canResource="$page" />
|
||||||
--}}
|
--}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Wraps flux:input with built-in authorization checking.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
<x-forms.input id="name" wire:model="name" label="Name" />
|
<x-forms.input id="name" wire:model="name" label="Name" />
|
||||||
<x-forms.input id="name" wire:model="name" label="Name" canGate="update" :canResource="$biolink" />
|
<x-forms.input id="name" wire:model="name" label="Name" canGate="update" :canResource="$page" />
|
||||||
--}}
|
--}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Usage:
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
</x-forms.select>
|
</x-forms.select>
|
||||||
|
|
||||||
<x-forms.select id="theme" wire:model="theme" label="Theme" canGate="update" :canResource="$biolink">
|
<x-forms.select id="theme" wire:model="theme" label="Theme" canGate="update" :canResource="$page">
|
||||||
<flux:select.option value="light">Light</flux:select.option>
|
<flux:select.option value="light">Light</flux:select.option>
|
||||||
<flux:select.option value="dark">Dark</flux:select.option>
|
<flux:select.option value="dark">Dark</flux:select.option>
|
||||||
</x-forms.select>
|
</x-forms.select>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Wraps flux:textarea with built-in authorization checking.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
<x-forms.textarea id="bio" wire:model="bio" label="Bio" rows="4" />
|
<x-forms.textarea id="bio" wire:model="bio" label="Bio" rows="4" />
|
||||||
<x-forms.textarea id="bio" wire:model="bio" label="Bio" canGate="update" :canResource="$biolink" />
|
<x-forms.textarea id="bio" wire:model="bio" label="Bio" canGate="update" :canResource="$page" />
|
||||||
--}}
|
--}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Wraps flux:switch with built-in authorization checking.
|
||||||
Usage:
|
Usage:
|
||||||
<x-forms.toggle id="is_public" wire:model="is_public" label="Public" />
|
<x-forms.toggle id="is_public" wire:model="is_public" label="Public" />
|
||||||
<x-forms.toggle id="is_public" wire:model="is_public" label="Public" instantSave />
|
<x-forms.toggle id="is_public" wire:model="is_public" label="Public" instantSave />
|
||||||
<x-forms.toggle id="is_public" wire:model="is_public" label="Public" canGate="update" :canResource="$biolink" />
|
<x-forms.toggle id="is_public" wire:model="is_public" label="Public" canGate="update" :canResource="$page" />
|
||||||
--}}
|
--}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
|
|
|
||||||
246
packages/core-php/src/Core/Front/HLCRF.md
Normal file
246
packages/core-php/src/Core/Front/HLCRF.md
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
# HLCRF Compositor
|
||||||
|
|
||||||
|
**H**ierarchical **L**ayer **C**ompositing **R**ender **F**rame
|
||||||
|
|
||||||
|
A data-driven layout system where each composite contains up to five regions - Header, Left, Content, Right, Footer. Composites nest infinitely: any region can contain another composite.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
|
||||||
|
// Simple page layout
|
||||||
|
$page = Layout::make('HCF')
|
||||||
|
->h('<nav>Navigation</nav>')
|
||||||
|
->c('<article>Main content</article>')
|
||||||
|
->f('<footer>Footer</footer>');
|
||||||
|
|
||||||
|
echo $page;
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Five Regions
|
||||||
|
|
||||||
|
| Letter | Region | HTML Element | Purpose |
|
||||||
|
|--------|---------|--------------|---------|
|
||||||
|
| **H** | Header | `<header>` | Top navigation, branding |
|
||||||
|
| **L** | Left | `<aside>` | Left sidebar |
|
||||||
|
| **C** | Content | `<main>` | Primary content |
|
||||||
|
| **R** | Right | `<aside>` | Right sidebar |
|
||||||
|
| **F** | Footer | `<footer>` | Site footer |
|
||||||
|
|
||||||
|
## Variant Strings
|
||||||
|
|
||||||
|
The variant string defines which regions are active. What's missing defines the layout type.
|
||||||
|
|
||||||
|
| Variant | Description | Use Case |
|
||||||
|
|---------|-------------|----------|
|
||||||
|
| `C` | Content only | Widgets, embeds |
|
||||||
|
| `HCF` | Header, Content, Footer | Standard page |
|
||||||
|
| `HLCF` | + Left sidebar | Admin panel |
|
||||||
|
| `HLCRF` | All regions | Full dashboard |
|
||||||
|
|
||||||
|
```
|
||||||
|
HCF layout:
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ H │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ C │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ F │
|
||||||
|
└─────────────────────────┘
|
||||||
|
|
||||||
|
HLCRF layout:
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ H │
|
||||||
|
├───────┬─────────┬───────┤
|
||||||
|
│ L │ C │ R │
|
||||||
|
├───────┴─────────┴───────┤
|
||||||
|
│ F │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nested Layouts
|
||||||
|
|
||||||
|
Any region can contain another layout. The path system tracks hierarchy:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$sidebar = Layout::make('HCF')
|
||||||
|
->h('<h3>Widget</h3>')
|
||||||
|
->c('<ul>...</ul>')
|
||||||
|
->f('<a href="#">More</a>');
|
||||||
|
|
||||||
|
$page = Layout::make('HLCF')
|
||||||
|
->h(view('header'))
|
||||||
|
->l($sidebar) // Nested layout
|
||||||
|
->c(view('content'))
|
||||||
|
->f(view('footer'));
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidebar's regions receive paths: `L-H`, `L-C`, `L-F`.
|
||||||
|
|
||||||
|
## Inline Nesting Syntax
|
||||||
|
|
||||||
|
Declare nested structures in a single string using brackets:
|
||||||
|
|
||||||
|
```
|
||||||
|
H[LC]CF = Header contains a Left-Content layout, plus root Content and Footer
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ H ┌───────────┬───────────────┐ │
|
||||||
|
│ │ H-L │ H-C │ │
|
||||||
|
│ └───────────┴───────────────┘ │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ C │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ F │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Path-Based IDs
|
||||||
|
|
||||||
|
Every element has a unique, deterministic address:
|
||||||
|
|
||||||
|
```
|
||||||
|
L-H-0
|
||||||
|
│ │ └─ Block index (first block)
|
||||||
|
│ └─── Region in nested layout (Header)
|
||||||
|
└───── Region in root layout (Left)
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `H-0` - First block in root Header
|
||||||
|
- `L-C-2` - Third block in Content of layout nested in Left
|
||||||
|
- `C-F-C-0` - First block in Content of layout nested in Footer of layout nested in Content
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Factory
|
||||||
|
|
||||||
|
```php
|
||||||
|
Layout::make(string $variant = 'HCF', string $path = ''): static
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slot Methods
|
||||||
|
|
||||||
|
```php
|
||||||
|
->h(mixed ...$items) // Header
|
||||||
|
->l(mixed ...$items) // Left
|
||||||
|
->c(mixed ...$items) // Content
|
||||||
|
->r(mixed ...$items) // Right
|
||||||
|
->f(mixed ...$items) // Footer
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts: strings, `Htmlable`, `Renderable`, `View`, nested `Layout`, callables.
|
||||||
|
|
||||||
|
### Attributes
|
||||||
|
|
||||||
|
```php
|
||||||
|
->attributes(['id' => 'main'])
|
||||||
|
->class('my-layout')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
```php
|
||||||
|
->render(): string
|
||||||
|
->toHtml(): string
|
||||||
|
(string) $layout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generated HTML
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="hlcrf-layout" data-layout="root">
|
||||||
|
<header class="hlcrf-header" data-slot="H">
|
||||||
|
<div data-block="H-0">...</div>
|
||||||
|
</header>
|
||||||
|
<div class="hlcrf-body flex flex-1">
|
||||||
|
<aside class="hlcrf-left shrink-0" data-slot="L">...</aside>
|
||||||
|
<main class="hlcrf-content flex-1" data-slot="C">...</main>
|
||||||
|
<aside class="hlcrf-right shrink-0" data-slot="R">...</aside>
|
||||||
|
</div>
|
||||||
|
<footer class="hlcrf-footer" data-slot="F">...</footer>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
Base styles for the grid structure:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.hlcrf-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hlcrf-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hlcrf-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hlcrf-left,
|
||||||
|
.hlcrf-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: collapse sidebars on tablet */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.hlcrf-left,
|
||||||
|
.hlcrf-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### Livewire
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('HLCF')
|
||||||
|
->h(livewire('nav'))
|
||||||
|
->l(livewire('sidebar'))
|
||||||
|
->c(livewire('content'))
|
||||||
|
->f(livewire('footer'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blade
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{!! Core\Front\Components\Layout::make('HCF')
|
||||||
|
->h(view('partials.header'))
|
||||||
|
->c($slot)
|
||||||
|
->f(view('partials.footer'))
|
||||||
|
!!}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Configuration
|
||||||
|
|
||||||
|
Store layout config in a JSON column:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"layout_type": {
|
||||||
|
"desktop": "HLCRF",
|
||||||
|
"tablet": "HCF",
|
||||||
|
"phone": "CF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why HLCRF?
|
||||||
|
|
||||||
|
1. **Data-driven** - Layout is data, not templates
|
||||||
|
2. **Composable** - Infinite nesting with automatic path tracking
|
||||||
|
3. **Portable** - A string describes the entire structure
|
||||||
|
4. **Semantic** - Maps to HTML5 landmark elements
|
||||||
|
5. **Simple** - Five regions, predictable behaviour
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Location: `Core\Front\Components\Layout`*
|
||||||
|
|
@ -10,7 +10,10 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Headers;
|
namespace Core\Headers;
|
||||||
|
|
||||||
|
use Core\Headers\Livewire\HeaderConfigurationManager;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers Module Service Provider.
|
* Headers Module Service Provider.
|
||||||
|
|
@ -19,6 +22,116 @@ use Illuminate\Support\ServiceProvider;
|
||||||
* - Device detection (User-Agent parsing)
|
* - Device detection (User-Agent parsing)
|
||||||
* - GeoIP lookups (from headers or database)
|
* - GeoIP lookups (from headers or database)
|
||||||
* - Configurable security headers (CSP, Permissions-Policy, etc.)
|
* - Configurable security headers (CSP, Permissions-Policy, etc.)
|
||||||
|
* - CSP nonce generation for inline scripts/styles
|
||||||
|
* - Header configuration UI via Livewire component
|
||||||
|
*
|
||||||
|
* ## Content Security Policy (CSP) Configuration
|
||||||
|
*
|
||||||
|
* Configure CSP via `config/headers.php` (published) or environment variables.
|
||||||
|
*
|
||||||
|
* ### Quick Reference
|
||||||
|
*
|
||||||
|
* | Option | Environment Variable | Default | Description |
|
||||||
|
* |--------|---------------------|---------|-------------|
|
||||||
|
* | `csp.enabled` | `SECURITY_CSP_ENABLED` | `true` | Enable/disable CSP entirely |
|
||||||
|
* | `csp.report_only` | `SECURITY_CSP_REPORT_ONLY` | `false` | Log violations without blocking |
|
||||||
|
* | `csp.report_uri` | `SECURITY_CSP_REPORT_URI` | `null` | URL for violation reports |
|
||||||
|
* | `csp.nonce_enabled` | `SECURITY_CSP_NONCE_ENABLED` | `true` | Enable nonce-based CSP |
|
||||||
|
* | `csp.nonce_length` | `SECURITY_CSP_NONCE_LENGTH` | `16` | Nonce length in bytes (128 bits) |
|
||||||
|
*
|
||||||
|
* ### CSP Directives
|
||||||
|
*
|
||||||
|
* Default directives are configured in `config/headers.php` under `csp.directives`:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* 'directives' => [
|
||||||
|
* 'default-src' => ["'self'"],
|
||||||
|
* 'script-src' => ["'self'"],
|
||||||
|
* 'style-src' => ["'self'", 'https://fonts.bunny.net'],
|
||||||
|
* 'img-src' => ["'self'", 'data:', 'https:', 'blob:'],
|
||||||
|
* 'font-src' => ["'self'", 'https://fonts.bunny.net'],
|
||||||
|
* 'connect-src' => ["'self'"],
|
||||||
|
* 'frame-src' => ["'self'", 'https://www.youtube.com'],
|
||||||
|
* 'frame-ancestors' => ["'self'"],
|
||||||
|
* 'base-uri' => ["'self'"],
|
||||||
|
* 'form-action' => ["'self'"],
|
||||||
|
* 'object-src' => ["'none'"],
|
||||||
|
* ],
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Environment-Specific Overrides
|
||||||
|
*
|
||||||
|
* Different environments can have different CSP rules:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* 'environment' => [
|
||||||
|
* 'local' => [
|
||||||
|
* 'script-src' => ["'unsafe-inline'", "'unsafe-eval'"],
|
||||||
|
* 'style-src' => ["'unsafe-inline'"],
|
||||||
|
* ],
|
||||||
|
* 'production' => [
|
||||||
|
* // Production should be strict - nonces replace unsafe-inline
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Nonce-Based CSP
|
||||||
|
*
|
||||||
|
* Nonces provide secure inline script/style support without `'unsafe-inline'`.
|
||||||
|
*
|
||||||
|
* #### In Blade Templates
|
||||||
|
*
|
||||||
|
* ```blade
|
||||||
|
* {{-- Using the helper function --}}
|
||||||
|
* <script nonce="{{ csp_nonce() }}">
|
||||||
|
* console.log('Allowed by nonce');
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* {{-- Using the Blade directive --}}
|
||||||
|
* <script @cspnonce>
|
||||||
|
* console.log('Also allowed');
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* {{-- Just the nonce value --}}
|
||||||
|
* <script nonce="@cspnoncevalue">
|
||||||
|
* console.log('Works too');
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Nonce Skip Environments
|
||||||
|
*
|
||||||
|
* In local/development environments, nonces are skipped by default to allow
|
||||||
|
* hot reload and dev tools. Configure via `csp.nonce_skip_environments`:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* 'nonce_skip_environments' => ['local', 'development'],
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### External Service Sources
|
||||||
|
*
|
||||||
|
* Enable third-party services via environment variables:
|
||||||
|
*
|
||||||
|
* | Service | Environment Variable | Sources Added |
|
||||||
|
* |---------|---------------------|---------------|
|
||||||
|
* | jsDelivr | `SECURITY_CSP_JSDELIVR` | cdn.jsdelivr.net |
|
||||||
|
* | unpkg | `SECURITY_CSP_UNPKG` | unpkg.com |
|
||||||
|
* | Google Analytics | `SECURITY_CSP_GOOGLE_ANALYTICS` | googletagmanager.com, google-analytics.com |
|
||||||
|
* | Facebook | `SECURITY_CSP_FACEBOOK` | connect.facebook.net, facebook.com |
|
||||||
|
*
|
||||||
|
* ### Other Security Headers
|
||||||
|
*
|
||||||
|
* | Header | Option | Default |
|
||||||
|
* |--------|--------|---------|
|
||||||
|
* | Strict-Transport-Security | `hsts.enabled` | `true` (production only) |
|
||||||
|
* | X-Frame-Options | `x_frame_options` | `SAMEORIGIN` |
|
||||||
|
* | X-Content-Type-Options | `x_content_type_options` | `nosniff` |
|
||||||
|
* | X-XSS-Protection | `x_xss_protection` | `1; mode=block` |
|
||||||
|
* | Referrer-Policy | `referrer_policy` | `strict-origin-when-cross-origin` |
|
||||||
|
* | Permissions-Policy | `permissions.enabled` | `true` |
|
||||||
|
*
|
||||||
|
* @see SecurityHeaders For the middleware implementation
|
||||||
|
* @see CspNonceService For nonce generation
|
||||||
|
* @see config/headers.php For full configuration reference
|
||||||
*/
|
*/
|
||||||
class Boot extends ServiceProvider
|
class Boot extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -31,6 +144,9 @@ class Boot extends ServiceProvider
|
||||||
|
|
||||||
$this->app->singleton(DetectDevice::class);
|
$this->app->singleton(DetectDevice::class);
|
||||||
$this->app->singleton(DetectLocation::class);
|
$this->app->singleton(DetectLocation::class);
|
||||||
|
|
||||||
|
// Register CSP nonce service as singleton (one nonce per request)
|
||||||
|
$this->app->singleton(CspNonceService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,6 +154,50 @@ class Boot extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
$this->loadViewsFrom(__DIR__.'/Views', 'core');
|
||||||
|
|
||||||
|
$this->registerBladeDirectives();
|
||||||
|
$this->registerHelperFunctions();
|
||||||
|
$this->registerLivewireComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Blade directives for CSP nonces.
|
||||||
|
*/
|
||||||
|
protected function registerBladeDirectives(): void
|
||||||
|
{
|
||||||
|
// @cspnonce - Outputs the nonce attribute
|
||||||
|
// Usage: <script @cspnonce>...</script>
|
||||||
|
Blade::directive('cspnonce', function () {
|
||||||
|
return '<?php echo app(\Core\Headers\CspNonceService::class)->getNonceAttribute(); ?>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// @cspnoncevalue - Outputs just the nonce value (for use in nonce="...")
|
||||||
|
// Usage: <script nonce="@cspnoncevalue">...</script>
|
||||||
|
Blade::directive('cspnoncevalue', function () {
|
||||||
|
return '<?php echo app(\Core\Headers\CspNonceService::class)->getNonce(); ?>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register global helper functions.
|
||||||
|
*/
|
||||||
|
protected function registerHelperFunctions(): void
|
||||||
|
{
|
||||||
|
// Register the csp_nonce() helper function
|
||||||
|
if (! function_exists('csp_nonce')) {
|
||||||
|
require __DIR__.'/helpers.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Livewire components.
|
||||||
|
*/
|
||||||
|
protected function registerLivewireComponents(): void
|
||||||
|
{
|
||||||
|
// Only register if Livewire is available
|
||||||
|
if (class_exists(Livewire::class)) {
|
||||||
|
Livewire::component('header-configuration-manager', HeaderConfigurationManager::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
packages/core-php/src/Core/Headers/CspNonceService.php
Normal file
161
packages/core-php/src/Core/Headers/CspNonceService.php
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Headers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for generating and managing CSP nonces.
|
||||||
|
*
|
||||||
|
* CSP nonces provide a secure way to allow inline scripts and styles
|
||||||
|
* without using 'unsafe-inline'. Each request generates a unique nonce
|
||||||
|
* that must be included in both the CSP header and the inline element.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* In Blade templates:
|
||||||
|
* ```blade
|
||||||
|
* <script nonce="{{ csp_nonce() }}">
|
||||||
|
* // Your inline JavaScript
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* <style nonce="{{ csp_nonce() }}">
|
||||||
|
* /* Your inline CSS */
|
||||||
|
* </style>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or using the directive:
|
||||||
|
* ```blade
|
||||||
|
* <script @cspnonce>
|
||||||
|
* // Your inline JavaScript
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Security
|
||||||
|
*
|
||||||
|
* - Nonces are generated once per request and cached
|
||||||
|
* - Uses cryptographically secure random bytes
|
||||||
|
* - Base64-encoded for safe use in HTML attributes
|
||||||
|
* - Nonces are 128 bits (16 bytes) by default
|
||||||
|
*/
|
||||||
|
class CspNonceService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The generated nonce for this request.
|
||||||
|
*/
|
||||||
|
protected ?string $nonce = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether nonce-based CSP is enabled.
|
||||||
|
*/
|
||||||
|
protected bool $enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nonce length in bytes (before base64 encoding).
|
||||||
|
*/
|
||||||
|
protected int $nonceLength = 16;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->enabled = (bool) config('headers.csp.nonce_enabled', true);
|
||||||
|
$this->nonceLength = (int) config('headers.csp.nonce_length', 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CSP nonce for the current request.
|
||||||
|
*
|
||||||
|
* Generates a new nonce if one hasn't been created yet.
|
||||||
|
*/
|
||||||
|
public function getNonce(): string
|
||||||
|
{
|
||||||
|
if ($this->nonce === null) {
|
||||||
|
$this->nonce = $this->generateNonce();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure nonce.
|
||||||
|
*/
|
||||||
|
protected function generateNonce(): string
|
||||||
|
{
|
||||||
|
return base64_encode(random_bytes($this->nonceLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the nonce formatted for a CSP directive.
|
||||||
|
*
|
||||||
|
* Returns the nonce in the format: 'nonce-{base64-value}'
|
||||||
|
*/
|
||||||
|
public function getCspNonceDirective(): string
|
||||||
|
{
|
||||||
|
return "'nonce-{$this->getNonce()}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the nonce as an HTML attribute.
|
||||||
|
*
|
||||||
|
* Returns: nonce="{base64-value}"
|
||||||
|
*/
|
||||||
|
public function getNonceAttribute(): string
|
||||||
|
{
|
||||||
|
return 'nonce="' . $this->getNonce() . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if nonce-based CSP is enabled.
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable nonce-based CSP.
|
||||||
|
*/
|
||||||
|
public function enable(): self
|
||||||
|
{
|
||||||
|
$this->enabled = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable nonce-based CSP.
|
||||||
|
*/
|
||||||
|
public function disable(): self
|
||||||
|
{
|
||||||
|
$this->enabled = false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the nonce (for testing or special cases).
|
||||||
|
*
|
||||||
|
* This should rarely be needed in production.
|
||||||
|
*/
|
||||||
|
public function reset(): self
|
||||||
|
{
|
||||||
|
$this->nonce = null;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a specific nonce (for testing purposes only).
|
||||||
|
*/
|
||||||
|
public function setNonce(string $nonce): self
|
||||||
|
{
|
||||||
|
$this->nonce = $nonce;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Headers\Livewire;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livewire component for managing security header configuration.
|
||||||
|
*
|
||||||
|
* Provides a UI for configuring:
|
||||||
|
* - Content Security Policy (CSP) directives
|
||||||
|
* - Strict Transport Security (HSTS) settings
|
||||||
|
* - Permissions Policy features
|
||||||
|
* - Other security headers
|
||||||
|
*
|
||||||
|
* Settings can be saved to the database (via ConfigService) or exported
|
||||||
|
* for inclusion in environment files.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* In a Blade view:
|
||||||
|
* ```blade
|
||||||
|
* <livewire:header-configuration-manager />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or with initial settings:
|
||||||
|
* ```blade
|
||||||
|
* <livewire:header-configuration-manager :workspace="$workspace" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class HeaderConfigurationManager extends Component
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Whether security headers are globally enabled.
|
||||||
|
*/
|
||||||
|
public bool $headersEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HSTS configuration.
|
||||||
|
*/
|
||||||
|
public bool $hstsEnabled = true;
|
||||||
|
public int $hstsMaxAge = 31536000;
|
||||||
|
public bool $hstsIncludeSubdomains = true;
|
||||||
|
public bool $hstsPreload = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSP configuration.
|
||||||
|
*/
|
||||||
|
public bool $cspEnabled = true;
|
||||||
|
public bool $cspReportOnly = false;
|
||||||
|
public ?string $cspReportUri = null;
|
||||||
|
public bool $cspNonceEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSP Directives.
|
||||||
|
*/
|
||||||
|
public array $cspDirectives = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External service toggles.
|
||||||
|
*/
|
||||||
|
public bool $jsdelivrEnabled = false;
|
||||||
|
public bool $unpkgEnabled = false;
|
||||||
|
public bool $googleAnalyticsEnabled = false;
|
||||||
|
public bool $facebookEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions Policy features.
|
||||||
|
*/
|
||||||
|
public array $permissionsFeatures = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other security headers.
|
||||||
|
*/
|
||||||
|
public string $xFrameOptions = 'SAMEORIGIN';
|
||||||
|
public string $referrerPolicy = 'strict-origin-when-cross-origin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state.
|
||||||
|
*/
|
||||||
|
public string $activeTab = 'csp';
|
||||||
|
public bool $showAdvanced = false;
|
||||||
|
public ?string $saveMessage = null;
|
||||||
|
public ?string $errorMessage = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available CSP directive options.
|
||||||
|
*/
|
||||||
|
protected array $availableDirectives = [
|
||||||
|
'default-src',
|
||||||
|
'script-src',
|
||||||
|
'style-src',
|
||||||
|
'img-src',
|
||||||
|
'font-src',
|
||||||
|
'connect-src',
|
||||||
|
'frame-src',
|
||||||
|
'frame-ancestors',
|
||||||
|
'base-uri',
|
||||||
|
'form-action',
|
||||||
|
'object-src',
|
||||||
|
'media-src',
|
||||||
|
'worker-src',
|
||||||
|
'manifest-src',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the component with current configuration.
|
||||||
|
*/
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from current settings.
|
||||||
|
*/
|
||||||
|
public function loadConfiguration(): void
|
||||||
|
{
|
||||||
|
// Global
|
||||||
|
$this->headersEnabled = (bool) config('headers.enabled', true);
|
||||||
|
|
||||||
|
// HSTS
|
||||||
|
$this->hstsEnabled = (bool) config('headers.hsts.enabled', true);
|
||||||
|
$this->hstsMaxAge = (int) config('headers.hsts.max_age', 31536000);
|
||||||
|
$this->hstsIncludeSubdomains = (bool) config('headers.hsts.include_subdomains', true);
|
||||||
|
$this->hstsPreload = (bool) config('headers.hsts.preload', true);
|
||||||
|
|
||||||
|
// CSP
|
||||||
|
$this->cspEnabled = (bool) config('headers.csp.enabled', true);
|
||||||
|
$this->cspReportOnly = (bool) config('headers.csp.report_only', false);
|
||||||
|
$this->cspReportUri = config('headers.csp.report_uri');
|
||||||
|
$this->cspNonceEnabled = (bool) config('headers.csp.nonce_enabled', true);
|
||||||
|
|
||||||
|
// CSP Directives
|
||||||
|
$this->cspDirectives = $this->formatDirectivesForUI(
|
||||||
|
config('headers.csp.directives', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// External services
|
||||||
|
$this->jsdelivrEnabled = (bool) config('headers.csp.external.jsdelivr.enabled', false);
|
||||||
|
$this->unpkgEnabled = (bool) config('headers.csp.external.unpkg.enabled', false);
|
||||||
|
$this->googleAnalyticsEnabled = (bool) config('headers.csp.external.google_analytics.enabled', false);
|
||||||
|
$this->facebookEnabled = (bool) config('headers.csp.external.facebook.enabled', false);
|
||||||
|
|
||||||
|
// Permissions Policy
|
||||||
|
$this->permissionsFeatures = $this->formatPermissionsForUI(
|
||||||
|
config('headers.permissions.features', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Other headers
|
||||||
|
$this->xFrameOptions = config('headers.x_frame_options', 'SAMEORIGIN');
|
||||||
|
$this->referrerPolicy = config('headers.referrer_policy', 'strict-origin-when-cross-origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format CSP directives for UI display.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string>> $directives
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function formatDirectivesForUI(array $directives): array
|
||||||
|
{
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($directives as $directive => $sources) {
|
||||||
|
$formatted[$directive] = implode(' ', $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format permissions policy for UI display.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string>> $features
|
||||||
|
* @return array<string, array{enabled: bool, allowlist: string}>
|
||||||
|
*/
|
||||||
|
protected function formatPermissionsForUI(array $features): array
|
||||||
|
{
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($features as $feature => $allowlist) {
|
||||||
|
$formatted[$feature] = [
|
||||||
|
'enabled' => ! empty($allowlist),
|
||||||
|
'allowlist' => implode(' ', $allowlist),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a CSP directive value.
|
||||||
|
*/
|
||||||
|
public function updateDirective(string $directive, string $value): void
|
||||||
|
{
|
||||||
|
$this->cspDirectives[$directive] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new CSP directive.
|
||||||
|
*/
|
||||||
|
public function addDirective(string $directive): void
|
||||||
|
{
|
||||||
|
if (! isset($this->cspDirectives[$directive])) {
|
||||||
|
$this->cspDirectives[$directive] = "'self'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a CSP directive.
|
||||||
|
*/
|
||||||
|
public function removeDirective(string $directive): void
|
||||||
|
{
|
||||||
|
unset($this->cspDirectives[$directive]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a permissions policy feature.
|
||||||
|
*/
|
||||||
|
public function togglePermission(string $feature): void
|
||||||
|
{
|
||||||
|
if (isset($this->permissionsFeatures[$feature])) {
|
||||||
|
$current = $this->permissionsFeatures[$feature]['enabled'] ?? false;
|
||||||
|
$this->permissionsFeatures[$feature]['enabled'] = ! $current;
|
||||||
|
|
||||||
|
if (! $current) {
|
||||||
|
// Enabling - default to 'self'
|
||||||
|
$this->permissionsFeatures[$feature]['allowlist'] = 'self';
|
||||||
|
} else {
|
||||||
|
// Disabling - clear allowlist
|
||||||
|
$this->permissionsFeatures[$feature]['allowlist'] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active configuration tab.
|
||||||
|
*/
|
||||||
|
public function setTab(string $tab): void
|
||||||
|
{
|
||||||
|
$this->activeTab = $tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle advanced options visibility.
|
||||||
|
*/
|
||||||
|
public function toggleAdvanced(): void
|
||||||
|
{
|
||||||
|
$this->showAdvanced = ! $this->showAdvanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate environment file content for current configuration.
|
||||||
|
*/
|
||||||
|
public function generateEnvConfig(): string
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
'# Security Headers Configuration',
|
||||||
|
'# Generated by HeaderConfigurationManager',
|
||||||
|
'',
|
||||||
|
'SECURITY_HEADERS_ENABLED=' . ($this->headersEnabled ? 'true' : 'false'),
|
||||||
|
'',
|
||||||
|
'# HSTS',
|
||||||
|
'SECURITY_HSTS_ENABLED=' . ($this->hstsEnabled ? 'true' : 'false'),
|
||||||
|
'SECURITY_HSTS_MAX_AGE=' . $this->hstsMaxAge,
|
||||||
|
'SECURITY_HSTS_INCLUDE_SUBDOMAINS=' . ($this->hstsIncludeSubdomains ? 'true' : 'false'),
|
||||||
|
'SECURITY_HSTS_PRELOAD=' . ($this->hstsPreload ? 'true' : 'false'),
|
||||||
|
'',
|
||||||
|
'# CSP',
|
||||||
|
'SECURITY_CSP_ENABLED=' . ($this->cspEnabled ? 'true' : 'false'),
|
||||||
|
'SECURITY_CSP_REPORT_ONLY=' . ($this->cspReportOnly ? 'true' : 'false'),
|
||||||
|
'SECURITY_CSP_NONCE_ENABLED=' . ($this->cspNonceEnabled ? 'true' : 'false'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->cspReportUri) {
|
||||||
|
$lines[] = 'SECURITY_CSP_REPORT_URI=' . $this->cspReportUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array_merge($lines, [
|
||||||
|
'',
|
||||||
|
'# External Services',
|
||||||
|
'SECURITY_CSP_JSDELIVR=' . ($this->jsdelivrEnabled ? 'true' : 'false'),
|
||||||
|
'SECURITY_CSP_UNPKG=' . ($this->unpkgEnabled ? 'true' : 'false'),
|
||||||
|
'SECURITY_CSP_GOOGLE_ANALYTICS=' . ($this->googleAnalyticsEnabled ? 'true' : 'false'),
|
||||||
|
'SECURITY_CSP_FACEBOOK=' . ($this->facebookEnabled ? 'true' : 'false'),
|
||||||
|
'',
|
||||||
|
'# Other Headers',
|
||||||
|
'SECURITY_X_FRAME_OPTIONS=' . $this->xFrameOptions,
|
||||||
|
'SECURITY_REFERRER_POLICY=' . $this->referrerPolicy,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSP directives as array for config.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
protected function getDirectivesAsArray(): array
|
||||||
|
{
|
||||||
|
$directives = [];
|
||||||
|
foreach ($this->cspDirectives as $directive => $value) {
|
||||||
|
$sources = array_filter(array_map('trim', explode(' ', $value)));
|
||||||
|
if (! empty($sources)) {
|
||||||
|
$directives[$directive] = $sources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permissions policy as array for config.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
protected function getPermissionsAsArray(): array
|
||||||
|
{
|
||||||
|
$features = [];
|
||||||
|
foreach ($this->permissionsFeatures as $feature => $config) {
|
||||||
|
if ($config['enabled'] ?? false) {
|
||||||
|
$allowlist = array_filter(array_map('trim', explode(' ', $config['allowlist'] ?? '')));
|
||||||
|
$features[$feature] = $allowlist;
|
||||||
|
} else {
|
||||||
|
$features[$feature] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available CSP directives that can be added.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getAvailableDirectives(): array
|
||||||
|
{
|
||||||
|
return array_diff($this->availableDirectives, array_keys($this->cspDirectives));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all permission feature names.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getPermissionFeatures(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->permissionsFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview the CSP header that would be generated.
|
||||||
|
*/
|
||||||
|
public function previewCspHeader(): string
|
||||||
|
{
|
||||||
|
$directives = $this->getDirectivesAsArray();
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach ($directives as $directive => $sources) {
|
||||||
|
$parts[] = $directive . ' ' . implode(' ', $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset to default configuration.
|
||||||
|
*/
|
||||||
|
public function resetToDefaults(): void
|
||||||
|
{
|
||||||
|
// Clear runtime config and reload defaults
|
||||||
|
Config::set('headers', null);
|
||||||
|
$this->loadConfiguration();
|
||||||
|
|
||||||
|
$this->saveMessage = 'Configuration reset to defaults.';
|
||||||
|
$this->dispatch('configuration-reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save configuration (dispatches event for parent to handle).
|
||||||
|
*/
|
||||||
|
public function saveConfiguration(): void
|
||||||
|
{
|
||||||
|
$config = [
|
||||||
|
'enabled' => $this->headersEnabled,
|
||||||
|
'hsts' => [
|
||||||
|
'enabled' => $this->hstsEnabled,
|
||||||
|
'max_age' => $this->hstsMaxAge,
|
||||||
|
'include_subdomains' => $this->hstsIncludeSubdomains,
|
||||||
|
'preload' => $this->hstsPreload,
|
||||||
|
],
|
||||||
|
'csp' => [
|
||||||
|
'enabled' => $this->cspEnabled,
|
||||||
|
'report_only' => $this->cspReportOnly,
|
||||||
|
'report_uri' => $this->cspReportUri,
|
||||||
|
'nonce_enabled' => $this->cspNonceEnabled,
|
||||||
|
'directives' => $this->getDirectivesAsArray(),
|
||||||
|
'external' => [
|
||||||
|
'jsdelivr' => ['enabled' => $this->jsdelivrEnabled],
|
||||||
|
'unpkg' => ['enabled' => $this->unpkgEnabled],
|
||||||
|
'google_analytics' => ['enabled' => $this->googleAnalyticsEnabled],
|
||||||
|
'facebook' => ['enabled' => $this->facebookEnabled],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'permissions' => [
|
||||||
|
'features' => $this->getPermissionsAsArray(),
|
||||||
|
],
|
||||||
|
'x_frame_options' => $this->xFrameOptions,
|
||||||
|
'referrer_policy' => $this->referrerPolicy,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dispatch event for parent component or controller to handle persistence
|
||||||
|
$this->dispatch('header-configuration-saved', config: $config);
|
||||||
|
|
||||||
|
$this->saveMessage = 'Configuration saved successfully.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear notification messages.
|
||||||
|
*/
|
||||||
|
public function clearMessages(): void
|
||||||
|
{
|
||||||
|
$this->saveMessage = null;
|
||||||
|
$this->errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the component.
|
||||||
|
*/
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('core::headers.livewire.header-configuration-manager');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ namespace Core\Headers;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,9 +26,61 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
* - X-XSS-Protection - enable browser XSS filtering
|
* - X-XSS-Protection - enable browser XSS filtering
|
||||||
* - Referrer-Policy - control referrer information
|
* - Referrer-Policy - control referrer information
|
||||||
* - Permissions-Policy - control browser features
|
* - Permissions-Policy - control browser features
|
||||||
|
*
|
||||||
|
* Supports nonce-based CSP for inline scripts and styles via CspNonceService.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* Register in your HTTP kernel or route middleware:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* // app/Http/Kernel.php
|
||||||
|
* protected $middleware = [
|
||||||
|
* // ...
|
||||||
|
* \Core\Headers\SecurityHeaders::class,
|
||||||
|
* ];
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## CSP Directive Resolution
|
||||||
|
*
|
||||||
|
* CSP directives are built in this order:
|
||||||
|
* 1. Base directives from `config('headers.csp.directives')`
|
||||||
|
* 2. Environment-specific overrides from `config('headers.csp.environment')`
|
||||||
|
* 3. Nonces added to script-src/style-src (if enabled)
|
||||||
|
* 4. CDN sources from `config('core.cdn.subdomain')`
|
||||||
|
* 5. External service sources (jsDelivr, Google Analytics, etc.)
|
||||||
|
* 6. Development WebSocket sources (localhost:8080)
|
||||||
|
* 7. Report URI (if configured)
|
||||||
|
*
|
||||||
|
* ## Report-Only Mode
|
||||||
|
*
|
||||||
|
* Enable `SECURITY_CSP_REPORT_ONLY=true` to log violations without blocking.
|
||||||
|
* This uses the `Content-Security-Policy-Report-Only` header instead.
|
||||||
|
*
|
||||||
|
* ## HSTS Behaviour
|
||||||
|
*
|
||||||
|
* HSTS is only added in production environments to avoid issues with
|
||||||
|
* local development over HTTP. Configure via:
|
||||||
|
*
|
||||||
|
* - `SECURITY_HSTS_ENABLED` - Enable/disable HSTS
|
||||||
|
* - `SECURITY_HSTS_MAX_AGE` - Max age in seconds (default: 1 year)
|
||||||
|
* - `SECURITY_HSTS_INCLUDE_SUBDOMAINS` - Include subdomains
|
||||||
|
* - `SECURITY_HSTS_PRELOAD` - Enable preload flag for browser preload lists
|
||||||
|
*
|
||||||
|
* @see CspNonceService For nonce generation
|
||||||
|
* @see Boot For configuration documentation
|
||||||
*/
|
*/
|
||||||
class SecurityHeaders
|
class SecurityHeaders
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The CSP nonce service.
|
||||||
|
*/
|
||||||
|
protected ?CspNonceService $nonceService = null;
|
||||||
|
public function __construct(?CspNonceService $nonceService = null)
|
||||||
|
{
|
||||||
|
$this->nonceService = $nonceService ?? App::make(CspNonceService::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*/
|
*/
|
||||||
|
|
@ -47,6 +100,14 @@ class SecurityHeaders
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CSP nonce service.
|
||||||
|
*/
|
||||||
|
public function getNonceService(): CspNonceService
|
||||||
|
{
|
||||||
|
return $this->nonceService;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Strict-Transport-Security header.
|
* Add Strict-Transport-Security header.
|
||||||
*/
|
*/
|
||||||
|
|
@ -110,6 +171,9 @@ class SecurityHeaders
|
||||||
// Apply environment-specific overrides
|
// Apply environment-specific overrides
|
||||||
$directives = $this->applyEnvironmentOverrides($directives, $config);
|
$directives = $this->applyEnvironmentOverrides($directives, $config);
|
||||||
|
|
||||||
|
// Add nonces for script-src and style-src if enabled
|
||||||
|
$directives = $this->addNonceDirectives($directives, $config);
|
||||||
|
|
||||||
// Add CDN subdomain sources
|
// Add CDN subdomain sources
|
||||||
$directives = $this->addCdnSources($directives, $config);
|
$directives = $this->addCdnSources($directives, $config);
|
||||||
|
|
||||||
|
|
@ -127,6 +191,51 @@ class SecurityHeaders
|
||||||
return $directives;
|
return $directives;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add nonce directives for script-src and style-src.
|
||||||
|
*
|
||||||
|
* When nonce-based CSP is enabled, nonces are added to script-src and
|
||||||
|
* style-src directives, allowing inline scripts/styles that include
|
||||||
|
* the matching nonce attribute.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
protected function addNonceDirectives(array $directives, array $config): array
|
||||||
|
{
|
||||||
|
$nonceEnabled = $config['nonce_enabled'] ?? true;
|
||||||
|
|
||||||
|
// Skip if nonces are disabled
|
||||||
|
if (! $nonceEnabled || ! $this->nonceService?->isEnabled()) {
|
||||||
|
return $directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't add nonces in local/development environments with unsafe-inline
|
||||||
|
// as it would be redundant and could cause issues
|
||||||
|
$environment = app()->environment();
|
||||||
|
$skipNonceEnvs = $config['nonce_skip_environments'] ?? ['local', 'development'];
|
||||||
|
|
||||||
|
if (in_array($environment, $skipNonceEnvs, true)) {
|
||||||
|
return $directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonce = $this->nonceService->getCspNonceDirective();
|
||||||
|
$nonceDirectives = $config['nonce_directives'] ?? ['script-src', 'style-src'];
|
||||||
|
|
||||||
|
foreach ($nonceDirectives as $directive) {
|
||||||
|
if (isset($directives[$directive])) {
|
||||||
|
// Remove unsafe-inline if present and add nonce
|
||||||
|
// Nonces are more secure than unsafe-inline
|
||||||
|
$directives[$directive] = array_filter(
|
||||||
|
$directives[$directive],
|
||||||
|
fn ($value) => $value !== "'unsafe-inline'"
|
||||||
|
);
|
||||||
|
$directives[$directive][] = $nonce;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $directives;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default CSP directives.
|
* Get default CSP directives.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
476
packages/core-php/src/Core/Headers/Testing/HeaderAssertions.php
Normal file
476
packages/core-php/src/Core/Headers/Testing/HeaderAssertions.php
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Headers\Testing;
|
||||||
|
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
|
use PHPUnit\Framework\Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testing utilities for HTTP security headers.
|
||||||
|
*
|
||||||
|
* Provides assertion methods and helpers for testing security headers
|
||||||
|
* in your application's HTTP responses.
|
||||||
|
*
|
||||||
|
* ## Usage in Test Classes
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Headers\Testing\HeaderAssertions;
|
||||||
|
*
|
||||||
|
* class MySecurityTest extends TestCase
|
||||||
|
* {
|
||||||
|
* use HeaderAssertions;
|
||||||
|
*
|
||||||
|
* public function test_security_headers_are_present(): void
|
||||||
|
* {
|
||||||
|
* $response = $this->get('/');
|
||||||
|
*
|
||||||
|
* $this->assertHasSecurityHeaders($response);
|
||||||
|
* $this->assertHasHstsHeader($response);
|
||||||
|
* $this->assertHasCspHeader($response);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Available Assertions
|
||||||
|
*
|
||||||
|
* | Method | Description |
|
||||||
|
* |--------|-------------|
|
||||||
|
* | `assertHasSecurityHeaders()` | Assert all standard security headers present |
|
||||||
|
* | `assertHasHstsHeader()` | Assert HSTS header present with valid config |
|
||||||
|
* | `assertHasCspHeader()` | Assert CSP header present |
|
||||||
|
* | `assertCspContainsDirective()` | Assert CSP contains a specific directive |
|
||||||
|
* | `assertCspContainsSource()` | Assert CSP directive contains a source |
|
||||||
|
* | `assertCspDoesNotContainSource()` | Assert CSP directive does not contain source |
|
||||||
|
* | `assertHasPermissionsPolicy()` | Assert Permissions-Policy header present |
|
||||||
|
* | `assertPermissionsPolicyFeature()` | Assert specific feature in Permissions-Policy |
|
||||||
|
* | `assertHasXFrameOptions()` | Assert X-Frame-Options header present |
|
||||||
|
* | `assertHasXContentTypeOptions()` | Assert X-Content-Type-Options header present |
|
||||||
|
* | `assertHasReferrerPolicy()` | Assert Referrer-Policy header present |
|
||||||
|
* | `assertHasCspNonce()` | Assert CSP contains a nonce directive |
|
||||||
|
* | `assertNoCspUnsafeInline()` | Assert CSP does not use unsafe-inline |
|
||||||
|
*/
|
||||||
|
trait HeaderAssertions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Assert that all standard security headers are present.
|
||||||
|
*
|
||||||
|
* Checks for:
|
||||||
|
* - X-Content-Type-Options: nosniff
|
||||||
|
* - X-Frame-Options
|
||||||
|
* - X-XSS-Protection
|
||||||
|
* - Referrer-Policy
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasSecurityHeaders(TestResponse $response): self
|
||||||
|
{
|
||||||
|
$response->assertHeader('X-Content-Type-Options');
|
||||||
|
$response->assertHeader('X-Frame-Options');
|
||||||
|
$response->assertHeader('X-XSS-Protection');
|
||||||
|
$response->assertHeader('Referrer-Policy');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that HSTS header is present and properly configured.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param int|null $minMaxAge Minimum max-age value (optional)
|
||||||
|
* @param bool|null $includeSubdomains Whether includeSubDomains should be present (optional)
|
||||||
|
* @param bool|null $preload Whether preload should be present (optional)
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasHstsHeader(
|
||||||
|
TestResponse $response,
|
||||||
|
?int $minMaxAge = null,
|
||||||
|
?bool $includeSubdomains = null,
|
||||||
|
?bool $preload = null
|
||||||
|
): self {
|
||||||
|
$response->assertHeader('Strict-Transport-Security');
|
||||||
|
|
||||||
|
$hsts = $response->headers->get('Strict-Transport-Security');
|
||||||
|
Assert::assertNotNull($hsts, 'HSTS header should not be null');
|
||||||
|
|
||||||
|
// Check max-age
|
||||||
|
if ($minMaxAge !== null) {
|
||||||
|
preg_match('/max-age=(\d+)/', $hsts, $matches);
|
||||||
|
Assert::assertNotEmpty($matches, 'HSTS should contain max-age directive');
|
||||||
|
Assert::assertGreaterThanOrEqual($minMaxAge, (int) $matches[1], "HSTS max-age should be at least {$minMaxAge}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check includeSubDomains
|
||||||
|
if ($includeSubdomains === true) {
|
||||||
|
Assert::assertStringContainsString('includeSubDomains', $hsts, 'HSTS should include subdomains');
|
||||||
|
} elseif ($includeSubdomains === false) {
|
||||||
|
Assert::assertStringNotContainsString('includeSubDomains', $hsts, 'HSTS should not include subdomains');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check preload
|
||||||
|
if ($preload === true) {
|
||||||
|
Assert::assertStringContainsString('preload', $hsts, 'HSTS should have preload flag');
|
||||||
|
} elseif ($preload === false) {
|
||||||
|
Assert::assertStringNotContainsString('preload', $hsts, 'HSTS should not have preload flag');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that CSP header is present.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param bool $reportOnly Whether to check for report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasCspHeader(TestResponse $response, bool $reportOnly = false): self
|
||||||
|
{
|
||||||
|
$headerName = $reportOnly
|
||||||
|
? 'Content-Security-Policy-Report-Only'
|
||||||
|
: 'Content-Security-Policy';
|
||||||
|
|
||||||
|
$response->assertHeader($headerName);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that CSP contains a specific directive.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $directive The CSP directive to check (e.g., 'default-src', 'script-src')
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertCspContainsDirective(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive,
|
||||||
|
bool $reportOnly = false
|
||||||
|
): self {
|
||||||
|
$csp = $this->getCspHeader($response, $reportOnly);
|
||||||
|
Assert::assertStringContainsString($directive, $csp, "CSP should contain '{$directive}' directive");
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a CSP directive contains a specific source.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $directive The CSP directive (e.g., 'script-src')
|
||||||
|
* @param string $source The source to check for (e.g., "'self'", 'https://example.com')
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertCspContainsSource(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive,
|
||||||
|
string $source,
|
||||||
|
bool $reportOnly = false
|
||||||
|
): self {
|
||||||
|
$directives = $this->parseCspDirectives($response, $reportOnly);
|
||||||
|
|
||||||
|
Assert::assertArrayHasKey($directive, $directives, "CSP should contain '{$directive}' directive");
|
||||||
|
Assert::assertContains(
|
||||||
|
$source,
|
||||||
|
$directives[$directive],
|
||||||
|
"CSP directive '{$directive}' should contain source '{$source}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a CSP directive does not contain a specific source.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $directive The CSP directive (e.g., 'script-src')
|
||||||
|
* @param string $source The source that should not be present
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertCspDoesNotContainSource(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive,
|
||||||
|
string $source,
|
||||||
|
bool $reportOnly = false
|
||||||
|
): self {
|
||||||
|
$directives = $this->parseCspDirectives($response, $reportOnly);
|
||||||
|
|
||||||
|
if (isset($directives[$directive])) {
|
||||||
|
Assert::assertNotContains(
|
||||||
|
$source,
|
||||||
|
$directives[$directive],
|
||||||
|
"CSP directive '{$directive}' should not contain source '{$source}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that CSP contains a nonce directive.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $directive The directive to check for nonce (default: 'script-src')
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasCspNonce(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive = 'script-src',
|
||||||
|
bool $reportOnly = false
|
||||||
|
): self {
|
||||||
|
$csp = $this->getCspHeader($response, $reportOnly);
|
||||||
|
$pattern = "/{$directive}[^;]*'nonce-[A-Za-z0-9+\/=]+'/";
|
||||||
|
|
||||||
|
Assert::assertMatchesRegularExpression(
|
||||||
|
$pattern,
|
||||||
|
$csp,
|
||||||
|
"CSP '{$directive}' should contain a nonce directive"
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that CSP does not use 'unsafe-inline' in specified directive.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $directive The directive to check (default: 'script-src')
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertNoCspUnsafeInline(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive = 'script-src',
|
||||||
|
bool $reportOnly = false
|
||||||
|
): self {
|
||||||
|
return $this->assertCspDoesNotContainSource($response, $directive, "'unsafe-inline'", $reportOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that CSP does not use 'unsafe-eval' in specified directive.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $directive The directive to check (default: 'script-src')
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertNoCspUnsafeEval(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive = 'script-src',
|
||||||
|
bool $reportOnly = false
|
||||||
|
): self {
|
||||||
|
return $this->assertCspDoesNotContainSource($response, $directive, "'unsafe-eval'", $reportOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that Permissions-Policy header is present.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasPermissionsPolicy(TestResponse $response): self
|
||||||
|
{
|
||||||
|
$response->assertHeader('Permissions-Policy');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that Permissions-Policy contains a specific feature setting.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $feature The feature name (e.g., 'geolocation', 'camera')
|
||||||
|
* @param array<string> $allowList Expected allow list (empty array for '()')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertPermissionsPolicyFeature(
|
||||||
|
TestResponse $response,
|
||||||
|
string $feature,
|
||||||
|
array $allowList = []
|
||||||
|
): self {
|
||||||
|
$policy = $response->headers->get('Permissions-Policy');
|
||||||
|
Assert::assertNotNull($policy, 'Permissions-Policy header should be present');
|
||||||
|
|
||||||
|
if (empty($allowList)) {
|
||||||
|
// Feature should be disabled: feature=()
|
||||||
|
Assert::assertMatchesRegularExpression(
|
||||||
|
"/{$feature}=\(\)/",
|
||||||
|
$policy,
|
||||||
|
"Permissions-Policy should disable '{$feature}'"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Feature should have specific origins
|
||||||
|
Assert::assertStringContainsString(
|
||||||
|
"{$feature}=",
|
||||||
|
$policy,
|
||||||
|
"Permissions-Policy should contain '{$feature}' feature"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that X-Frame-Options header is present with expected value.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string|null $expected Expected value ('DENY', 'SAMEORIGIN', etc.)
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasXFrameOptions(TestResponse $response, ?string $expected = null): self
|
||||||
|
{
|
||||||
|
$response->assertHeader('X-Frame-Options');
|
||||||
|
|
||||||
|
if ($expected !== null) {
|
||||||
|
$actual = $response->headers->get('X-Frame-Options');
|
||||||
|
Assert::assertSame($expected, $actual, "X-Frame-Options should be '{$expected}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that X-Content-Type-Options header is present with 'nosniff'.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasXContentTypeOptions(TestResponse $response): self
|
||||||
|
{
|
||||||
|
$response->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that Referrer-Policy header is present with expected value.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string|null $expected Expected value (e.g., 'strict-origin-when-cross-origin')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasReferrerPolicy(TestResponse $response, ?string $expected = null): self
|
||||||
|
{
|
||||||
|
$response->assertHeader('Referrer-Policy');
|
||||||
|
|
||||||
|
if ($expected !== null) {
|
||||||
|
$actual = $response->headers->get('Referrer-Policy');
|
||||||
|
Assert::assertSame($expected, $actual, "Referrer-Policy should be '{$expected}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that X-XSS-Protection header is present.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string|null $expected Expected value (e.g., '1; mode=block')
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHasXssProtection(TestResponse $response, ?string $expected = null): self
|
||||||
|
{
|
||||||
|
$response->assertHeader('X-XSS-Protection');
|
||||||
|
|
||||||
|
if ($expected !== null) {
|
||||||
|
$actual = $response->headers->get('X-XSS-Protection');
|
||||||
|
Assert::assertSame($expected, $actual, "X-XSS-Protection should be '{$expected}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a header is NOT present.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response to check
|
||||||
|
* @param string $headerName The header name to check
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertHeaderMissing(TestResponse $response, string $headerName): self
|
||||||
|
{
|
||||||
|
$response->assertHeaderMissing($headerName);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CSP header value from response.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response
|
||||||
|
* @param bool $reportOnly Whether to get report-only header
|
||||||
|
* @return string The CSP header value
|
||||||
|
*/
|
||||||
|
protected function getCspHeader(TestResponse $response, bool $reportOnly = false): string
|
||||||
|
{
|
||||||
|
$headerName = $reportOnly
|
||||||
|
? 'Content-Security-Policy-Report-Only'
|
||||||
|
: 'Content-Security-Policy';
|
||||||
|
|
||||||
|
$csp = $response->headers->get($headerName);
|
||||||
|
Assert::assertNotNull($csp, "{$headerName} header should be present");
|
||||||
|
|
||||||
|
return $csp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSP header into directives array.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response
|
||||||
|
* @param bool $reportOnly Whether to parse report-only header
|
||||||
|
* @return array<string, array<string>> Map of directive to sources
|
||||||
|
*/
|
||||||
|
protected function parseCspDirectives(TestResponse $response, bool $reportOnly = false): array
|
||||||
|
{
|
||||||
|
$csp = $this->getCspHeader($response, $reportOnly);
|
||||||
|
$directives = [];
|
||||||
|
|
||||||
|
foreach (explode(';', $csp) as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if (empty($part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = preg_split('/\s+/', $part);
|
||||||
|
$directiveName = array_shift($tokens);
|
||||||
|
$directives[$directiveName] = $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the nonce value from a CSP header.
|
||||||
|
*
|
||||||
|
* @param TestResponse $response The HTTP response
|
||||||
|
* @param string $directive The directive to extract nonce from
|
||||||
|
* @param bool $reportOnly Whether to check report-only header
|
||||||
|
* @return string|null The nonce value or null if not found
|
||||||
|
*/
|
||||||
|
public function extractCspNonce(
|
||||||
|
TestResponse $response,
|
||||||
|
string $directive = 'script-src',
|
||||||
|
bool $reportOnly = false
|
||||||
|
): ?string {
|
||||||
|
$csp = $this->getCspHeader($response, $reportOnly);
|
||||||
|
|
||||||
|
// Match nonce in the specified directive
|
||||||
|
if (preg_match("/{$directive}[^;]*'nonce-([A-Za-z0-9+\/=]+)'/", $csp, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,583 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Headers\Testing;
|
||||||
|
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone helper for testing security headers.
|
||||||
|
*
|
||||||
|
* Provides static methods for validating security headers in HTTP responses.
|
||||||
|
* Can be used without traits for more flexible testing scenarios.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Headers\Testing\SecurityHeaderTester;
|
||||||
|
*
|
||||||
|
* // Validate all security headers at once
|
||||||
|
* $issues = SecurityHeaderTester::validate($response);
|
||||||
|
* $this->assertEmpty($issues, 'Security headers should be valid');
|
||||||
|
*
|
||||||
|
* // Generate a security report
|
||||||
|
* $report = SecurityHeaderTester::report($response);
|
||||||
|
*
|
||||||
|
* // Check specific headers
|
||||||
|
* $this->assertTrue(SecurityHeaderTester::hasValidHsts($response));
|
||||||
|
* $this->assertTrue(SecurityHeaderTester::hasValidCsp($response));
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Validation Rules
|
||||||
|
*
|
||||||
|
* The validator checks against security best practices:
|
||||||
|
* - HSTS: max-age >= 31536000 (1 year), includeSubDomains, preload
|
||||||
|
* - CSP: No 'unsafe-inline' or 'unsafe-eval' in script-src/style-src
|
||||||
|
* - X-Frame-Options: Should be DENY or SAMEORIGIN
|
||||||
|
* - X-Content-Type-Options: Should be nosniff
|
||||||
|
* - Referrer-Policy: Should be strict-origin-when-cross-origin or stricter
|
||||||
|
*/
|
||||||
|
class SecurityHeaderTester
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Recommended minimum HSTS max-age (1 year).
|
||||||
|
*/
|
||||||
|
public const RECOMMENDED_HSTS_MAX_AGE = 31536000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid X-Frame-Options values.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
public const VALID_X_FRAME_OPTIONS = ['DENY', 'SAMEORIGIN'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict referrer policies (recommended).
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
public const STRICT_REFERRER_POLICIES = [
|
||||||
|
'no-referrer',
|
||||||
|
'no-referrer-when-downgrade',
|
||||||
|
'same-origin',
|
||||||
|
'strict-origin',
|
||||||
|
'strict-origin-when-cross-origin',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all security headers and return any issues found.
|
||||||
|
*
|
||||||
|
* @param TestResponse|Response $response The HTTP response to validate
|
||||||
|
* @param array<string, mixed> $options Validation options
|
||||||
|
* @return array<string, string> Map of header name to issue description
|
||||||
|
*/
|
||||||
|
public static function validate(TestResponse|Response $response, array $options = []): array
|
||||||
|
{
|
||||||
|
$issues = [];
|
||||||
|
$headers = self::getHeaders($response);
|
||||||
|
|
||||||
|
// Check required headers
|
||||||
|
$requiredHeaders = $options['required'] ?? [
|
||||||
|
'X-Content-Type-Options',
|
||||||
|
'X-Frame-Options',
|
||||||
|
'Referrer-Policy',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($requiredHeaders as $header) {
|
||||||
|
if (! isset($headers[strtolower($header)])) {
|
||||||
|
$issues[$header] = 'Header is missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate specific headers
|
||||||
|
if ($issue = self::validateXContentTypeOptions($headers)) {
|
||||||
|
$issues['X-Content-Type-Options'] = $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issue = self::validateXFrameOptions($headers)) {
|
||||||
|
$issues['X-Frame-Options'] = $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issue = self::validateReferrerPolicy($headers)) {
|
||||||
|
$issues['Referrer-Policy'] = $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($options['check_hsts'] ?? true) && isset($headers['strict-transport-security'])) {
|
||||||
|
if ($issue = self::validateHsts($headers)) {
|
||||||
|
$issues['Strict-Transport-Security'] = $issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($options['check_csp'] ?? true) && (isset($headers['content-security-policy']) || isset($headers['content-security-policy-report-only']))) {
|
||||||
|
$cspIssues = self::validateCsp($headers, $options);
|
||||||
|
foreach ($cspIssues as $directive => $issue) {
|
||||||
|
$issues["CSP:{$directive}"] = $issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($options['check_permissions'] ?? true) && isset($headers['permissions-policy'])) {
|
||||||
|
if ($issue = self::validatePermissionsPolicy($headers)) {
|
||||||
|
$issues['Permissions-Policy'] = $issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a comprehensive security header report.
|
||||||
|
*
|
||||||
|
* @param TestResponse|Response $response The HTTP response to analyze
|
||||||
|
* @return array<string, mixed> Detailed report of security header status
|
||||||
|
*/
|
||||||
|
public static function report(TestResponse|Response $response): array
|
||||||
|
{
|
||||||
|
$headers = self::getHeaders($response);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hsts' => self::analyzeHsts($headers),
|
||||||
|
'csp' => self::analyzeCsp($headers),
|
||||||
|
'permissions_policy' => self::analyzePermissionsPolicy($headers),
|
||||||
|
'x_frame_options' => self::analyzeXFrameOptions($headers),
|
||||||
|
'x_content_type_options' => self::analyzeXContentTypeOptions($headers),
|
||||||
|
'referrer_policy' => self::analyzeReferrerPolicy($headers),
|
||||||
|
'x_xss_protection' => self::analyzeXssProtection($headers),
|
||||||
|
'issues' => self::validate($response),
|
||||||
|
'score' => self::calculateScore($response),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a security score (0-100) based on headers.
|
||||||
|
*
|
||||||
|
* @param TestResponse|Response $response The HTTP response to score
|
||||||
|
* @return int Security score from 0 (no security) to 100 (excellent)
|
||||||
|
*/
|
||||||
|
public static function calculateScore(TestResponse|Response $response): int
|
||||||
|
{
|
||||||
|
$headers = self::getHeaders($response);
|
||||||
|
$score = 0;
|
||||||
|
|
||||||
|
// HSTS (20 points)
|
||||||
|
if (isset($headers['strict-transport-security'])) {
|
||||||
|
$score += 10;
|
||||||
|
$hsts = $headers['strict-transport-security'];
|
||||||
|
if (str_contains($hsts, 'includeSubDomains')) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
if (str_contains($hsts, 'preload')) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSP (30 points)
|
||||||
|
$cspHeader = $headers['content-security-policy'] ?? $headers['content-security-policy-report-only'] ?? null;
|
||||||
|
if ($cspHeader) {
|
||||||
|
$score += 15;
|
||||||
|
if (! str_contains($cspHeader, "'unsafe-inline'")) {
|
||||||
|
$score += 10;
|
||||||
|
}
|
||||||
|
if (! str_contains($cspHeader, "'unsafe-eval'")) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions-Policy (10 points)
|
||||||
|
if (isset($headers['permissions-policy'])) {
|
||||||
|
$score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Frame-Options (15 points)
|
||||||
|
if (isset($headers['x-frame-options'])) {
|
||||||
|
$score += 10;
|
||||||
|
if (in_array(strtoupper($headers['x-frame-options']), self::VALID_X_FRAME_OPTIONS, true)) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Content-Type-Options (10 points)
|
||||||
|
if (isset($headers['x-content-type-options']) && strtolower($headers['x-content-type-options']) === 'nosniff') {
|
||||||
|
$score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referrer-Policy (10 points)
|
||||||
|
if (isset($headers['referrer-policy'])) {
|
||||||
|
$score += 5;
|
||||||
|
if (in_array(strtolower($headers['referrer-policy']), self::STRICT_REFERRER_POLICIES, true)) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-XSS-Protection (5 points - legacy but still good to have)
|
||||||
|
if (isset($headers['x-xss-protection'])) {
|
||||||
|
$score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(100, $score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if HSTS header is valid.
|
||||||
|
*
|
||||||
|
* @param TestResponse|Response $response The HTTP response to check
|
||||||
|
* @return bool True if HSTS is properly configured
|
||||||
|
*/
|
||||||
|
public static function hasValidHsts(TestResponse|Response $response): bool
|
||||||
|
{
|
||||||
|
$headers = self::getHeaders($response);
|
||||||
|
|
||||||
|
return self::validateHsts($headers) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if CSP header is valid (no unsafe directives).
|
||||||
|
*
|
||||||
|
* @param TestResponse|Response $response The HTTP response to check
|
||||||
|
* @param array<string, mixed> $options Validation options
|
||||||
|
* @return bool True if CSP is properly configured
|
||||||
|
*/
|
||||||
|
public static function hasValidCsp(TestResponse|Response $response, array $options = []): bool
|
||||||
|
{
|
||||||
|
$headers = self::getHeaders($response);
|
||||||
|
$issues = self::validateCsp($headers, $options);
|
||||||
|
|
||||||
|
return empty($issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSP header into directives.
|
||||||
|
*
|
||||||
|
* @param TestResponse|Response $response The HTTP response
|
||||||
|
* @return array<string, array<string>> Map of directive to sources
|
||||||
|
*/
|
||||||
|
public static function parseCsp(TestResponse|Response $response): array
|
||||||
|
{
|
||||||
|
$headers = self::getHeaders($response);
|
||||||
|
$csp = $headers['content-security-policy'] ?? $headers['content-security-policy-report-only'] ?? '';
|
||||||
|
|
||||||
|
return self::parseCspString($csp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal validation methods
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get headers from response as lowercase key array.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected static function getHeaders(TestResponse|Response $response): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
|
||||||
|
if ($response instanceof TestResponse) {
|
||||||
|
$headerBag = $response->headers;
|
||||||
|
} else {
|
||||||
|
$headerBag = $response->headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($headerBag->all() as $name => $values) {
|
||||||
|
$headers[strtolower($name)] = is_array($values) ? ($values[0] ?? '') : $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate X-Content-Type-Options header.
|
||||||
|
*/
|
||||||
|
protected static function validateXContentTypeOptions(array $headers): ?string
|
||||||
|
{
|
||||||
|
$value = $headers['x-content-type-options'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return null; // Handled by required check
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtolower($value) !== 'nosniff') {
|
||||||
|
return "Should be 'nosniff', got '{$value}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate X-Frame-Options header.
|
||||||
|
*/
|
||||||
|
protected static function validateXFrameOptions(array $headers): ?string
|
||||||
|
{
|
||||||
|
$value = $headers['x-frame-options'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return null; // Handled by required check
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array(strtoupper($value), self::VALID_X_FRAME_OPTIONS, true)) {
|
||||||
|
return "Should be DENY or SAMEORIGIN, got '{$value}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Referrer-Policy header.
|
||||||
|
*/
|
||||||
|
protected static function validateReferrerPolicy(array $headers): ?string
|
||||||
|
{
|
||||||
|
$value = $headers['referrer-policy'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return null; // Handled by required check
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtolower($value) === 'unsafe-url') {
|
||||||
|
return "'unsafe-url' exposes full URL to third parties";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Strict-Transport-Security header.
|
||||||
|
*/
|
||||||
|
protected static function validateHsts(array $headers): ?string
|
||||||
|
{
|
||||||
|
$value = $headers['strict-transport-security'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return 'HSTS header is missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/max-age=(\d+)/', $value, $matches)) {
|
||||||
|
return 'HSTS should contain max-age directive';
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxAge = (int) $matches[1];
|
||||||
|
if ($maxAge < self::RECOMMENDED_HSTS_MAX_AGE) {
|
||||||
|
return "max-age should be at least " . self::RECOMMENDED_HSTS_MAX_AGE . " (1 year), got {$maxAge}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Content-Security-Policy header.
|
||||||
|
*
|
||||||
|
* @return array<string, string> Map of directive to issue
|
||||||
|
*/
|
||||||
|
protected static function validateCsp(array $headers, array $options = []): array
|
||||||
|
{
|
||||||
|
$csp = $headers['content-security-policy'] ?? $headers['content-security-policy-report-only'] ?? null;
|
||||||
|
|
||||||
|
if ($csp === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues = [];
|
||||||
|
$allowUnsafeInline = $options['allow_unsafe_inline'] ?? false;
|
||||||
|
$allowUnsafeEval = $options['allow_unsafe_eval'] ?? false;
|
||||||
|
|
||||||
|
$directives = self::parseCspString($csp);
|
||||||
|
|
||||||
|
// Check for unsafe-inline in script-src
|
||||||
|
if (! $allowUnsafeInline && isset($directives['script-src'])) {
|
||||||
|
if (in_array("'unsafe-inline'", $directives['script-src'], true)) {
|
||||||
|
$issues['script-src'] = "'unsafe-inline' allows XSS attacks";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unsafe-eval in script-src
|
||||||
|
if (! $allowUnsafeEval && isset($directives['script-src'])) {
|
||||||
|
if (in_array("'unsafe-eval'", $directives['script-src'], true)) {
|
||||||
|
$issues['script-src'] = ($issues['script-src'] ?? '') . " 'unsafe-eval' allows code injection";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Permissions-Policy header.
|
||||||
|
*/
|
||||||
|
protected static function validatePermissionsPolicy(array $headers): ?string
|
||||||
|
{
|
||||||
|
$value = $headers['permissions-policy'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return 'Permissions-Policy header is missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic syntax check
|
||||||
|
if (empty(trim($value))) {
|
||||||
|
return 'Permissions-Policy header is empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSP string into directives array.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string>>
|
||||||
|
*/
|
||||||
|
protected static function parseCspString(string $csp): array
|
||||||
|
{
|
||||||
|
$directives = [];
|
||||||
|
|
||||||
|
foreach (explode(';', $csp) as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if (empty($part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = preg_split('/\s+/', $part);
|
||||||
|
$directiveName = array_shift($tokens);
|
||||||
|
$directives[$directiveName] = $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Analysis methods for report generation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze HSTS header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzeHsts(array $headers): array
|
||||||
|
{
|
||||||
|
$value = $headers['strict-transport-security'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return ['present' => false, 'value' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
preg_match('/max-age=(\d+)/', $value, $matches);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => true,
|
||||||
|
'value' => $value,
|
||||||
|
'max_age' => isset($matches[1]) ? (int) $matches[1] : null,
|
||||||
|
'include_subdomains' => str_contains($value, 'includeSubDomains'),
|
||||||
|
'preload' => str_contains($value, 'preload'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze CSP header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzeCsp(array $headers): array
|
||||||
|
{
|
||||||
|
$csp = $headers['content-security-policy'] ?? null;
|
||||||
|
$reportOnly = $headers['content-security-policy-report-only'] ?? null;
|
||||||
|
|
||||||
|
if ($csp === null && $reportOnly === null) {
|
||||||
|
return ['present' => false, 'value' => null, 'report_only' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $csp ?? $reportOnly;
|
||||||
|
$directives = self::parseCspString($value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => true,
|
||||||
|
'report_only' => $csp === null,
|
||||||
|
'value' => $value,
|
||||||
|
'directives' => $directives,
|
||||||
|
'has_nonce' => (bool) preg_match("/'nonce-/", $value),
|
||||||
|
'has_unsafe_inline' => str_contains($value, "'unsafe-inline'"),
|
||||||
|
'has_unsafe_eval' => str_contains($value, "'unsafe-eval'"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze Permissions-Policy header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzePermissionsPolicy(array $headers): array
|
||||||
|
{
|
||||||
|
$value = $headers['permissions-policy'] ?? null;
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return ['present' => false, 'value' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse features
|
||||||
|
$features = [];
|
||||||
|
preg_match_all('/(\w+(?:-\w+)*)=\(([^)]*)\)/', $value, $matches, PREG_SET_ORDER);
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$features[$match[1]] = trim($match[2]) === '' ? [] : preg_split('/\s+/', trim($match[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => true,
|
||||||
|
'value' => $value,
|
||||||
|
'features' => $features,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze X-Frame-Options header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzeXFrameOptions(array $headers): array
|
||||||
|
{
|
||||||
|
$value = $headers['x-frame-options'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => $value !== null,
|
||||||
|
'value' => $value,
|
||||||
|
'valid' => $value !== null && in_array(strtoupper($value), self::VALID_X_FRAME_OPTIONS, true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze X-Content-Type-Options header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzeXContentTypeOptions(array $headers): array
|
||||||
|
{
|
||||||
|
$value = $headers['x-content-type-options'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => $value !== null,
|
||||||
|
'value' => $value,
|
||||||
|
'valid' => $value !== null && strtolower($value) === 'nosniff',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze Referrer-Policy header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzeReferrerPolicy(array $headers): array
|
||||||
|
{
|
||||||
|
$value = $headers['referrer-policy'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => $value !== null,
|
||||||
|
'value' => $value,
|
||||||
|
'strict' => $value !== null && in_array(strtolower($value), self::STRICT_REFERRER_POLICIES, true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze X-XSS-Protection header for report.
|
||||||
|
*/
|
||||||
|
protected static function analyzeXssProtection(array $headers): array
|
||||||
|
{
|
||||||
|
$value = $headers['x-xss-protection'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'present' => $value !== null,
|
||||||
|
'value' => $value,
|
||||||
|
'enabled' => $value !== null && str_starts_with($value, '1'),
|
||||||
|
'mode_block' => $value !== null && str_contains($value, 'mode=block'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Notification Messages --}}
|
||||||
|
@if ($saveMessage)
|
||||||
|
<div class="rounded-md bg-green-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-green-800">{{ $saveMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<button wire:click="clearMessages" type="button" class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100">
|
||||||
|
<span class="sr-only">Dismiss</span>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($errorMessage)
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-red-800">{{ $errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Header Section --}}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Security Headers Configuration</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure HTTP security headers for your application.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<label for="headers-enabled" class="text-sm font-medium text-gray-700 dark:text-gray-300">Enable Headers</label>
|
||||||
|
<button
|
||||||
|
wire:click="$toggle('headersEnabled')"
|
||||||
|
type="button"
|
||||||
|
class="{{ $headersEnabled ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700' }} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
|
||||||
|
role="switch"
|
||||||
|
aria-checked="{{ $headersEnabled ? 'true' : 'false' }}"
|
||||||
|
>
|
||||||
|
<span class="{{ $headersEnabled ? 'translate-x-5' : 'translate-x-0' }} pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tab Navigation --}}
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||||
|
@foreach (['csp' => 'Content Security Policy', 'hsts' => 'HSTS', 'permissions' => 'Permissions Policy', 'other' => 'Other Headers'] as $tab => $label)
|
||||||
|
<button
|
||||||
|
wire:click="setTab('{{ $tab }}')"
|
||||||
|
type="button"
|
||||||
|
class="{{ $activeTab === $tab ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }} whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ $label }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CSP Tab --}}
|
||||||
|
@if ($activeTab === 'csp')
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- CSP Enable/Disable --}}
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Content Security Policy</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Control which resources can be loaded.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="$toggle('cspEnabled')"
|
||||||
|
type="button"
|
||||||
|
class="{{ $cspEnabled ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700' }} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out"
|
||||||
|
role="switch"
|
||||||
|
>
|
||||||
|
<span class="{{ $cspEnabled ? 'translate-x-5' : 'translate-x-0' }} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($cspEnabled)
|
||||||
|
{{-- CSP Options --}}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="cspReportOnly" type="checkbox" id="csp-report-only" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="csp-report-only" class="text-sm font-medium text-gray-700 dark:text-gray-300">Report-Only Mode</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="cspNonceEnabled" type="checkbox" id="csp-nonce-enabled" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="csp-nonce-enabled" class="text-sm font-medium text-gray-700 dark:text-gray-300">Enable Nonce-based CSP</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="csp-report-uri" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Report URI</label>
|
||||||
|
<input wire:model="cspReportUri" type="url" id="csp-report-uri" placeholder="https://example.com/csp-report" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CSP Directives --}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">CSP Directives</h4>
|
||||||
|
@if (count($this->getAvailableDirectives()) > 0)
|
||||||
|
<div class="relative">
|
||||||
|
<select wire:change="addDirective($event.target.value); $event.target.value=''" class="block rounded-md border-gray-300 py-1.5 pl-3 pr-10 text-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="">Add directive...</option>
|
||||||
|
@foreach ($this->getAvailableDirectives() as $directive)
|
||||||
|
<option value="{{ $directive }}">{{ $directive }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($cspDirectives as $directive => $value)
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">{{ $directive }}</label>
|
||||||
|
<input
|
||||||
|
wire:model.blur="cspDirectives.{{ $directive }}"
|
||||||
|
type="text"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
placeholder="'self' https://example.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button wire:click="removeDirective('{{ $directive }}')" type="button" class="mt-6 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:hover:bg-gray-700">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- External Services --}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">External Services</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="jsdelivrEnabled" type="checkbox" id="jsdelivr-enabled" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="jsdelivr-enabled" class="text-sm text-gray-700 dark:text-gray-300">jsDelivr CDN</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="unpkgEnabled" type="checkbox" id="unpkg-enabled" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="unpkg-enabled" class="text-sm text-gray-700 dark:text-gray-300">unpkg CDN</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="googleAnalyticsEnabled" type="checkbox" id="ga-enabled" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="ga-enabled" class="text-sm text-gray-700 dark:text-gray-300">Google Analytics</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="facebookEnabled" type="checkbox" id="fb-enabled" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="fb-enabled" class="text-sm text-gray-700 dark:text-gray-300">Facebook SDK</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CSP Preview --}}
|
||||||
|
<div class="rounded-lg bg-gray-900 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium text-gray-400">CSP Header Preview</span>
|
||||||
|
</div>
|
||||||
|
<pre class="mt-2 overflow-x-auto text-xs text-green-400"><code>{{ $this->previewCspHeader() }}</code></pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- HSTS Tab --}}
|
||||||
|
@if ($activeTab === 'hsts')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">HTTP Strict Transport Security</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Enforce HTTPS connections.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
wire:click="$toggle('hstsEnabled')"
|
||||||
|
type="button"
|
||||||
|
class="{{ $hstsEnabled ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700' }} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out"
|
||||||
|
role="switch"
|
||||||
|
>
|
||||||
|
<span class="{{ $hstsEnabled ? 'translate-x-5' : 'translate-x-0' }} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($hstsEnabled)
|
||||||
|
<div>
|
||||||
|
<label for="hsts-max-age" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Max Age (seconds)</label>
|
||||||
|
<input wire:model="hstsMaxAge" type="number" id="hsts-max-age" min="0" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm">
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Recommended: 31536000 (1 year)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="hstsIncludeSubdomains" type="checkbox" id="hsts-subdomains" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="hsts-subdomains" class="text-sm font-medium text-gray-700 dark:text-gray-300">Include Subdomains</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input wire:model="hstsPreload" type="checkbox" id="hsts-preload" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
|
<label for="hsts-preload" class="text-sm font-medium text-gray-700 dark:text-gray-300">Preload</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||||
|
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Note:</strong> HSTS headers are only sent in production environments to prevent development issues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Permissions Policy Tab --}}
|
||||||
|
@if ($activeTab === 'permissions')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Permissions Policy</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Control browser features and APIs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@foreach ($permissionsFeatures as $feature => $config)
|
||||||
|
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $feature }}</label>
|
||||||
|
<button
|
||||||
|
wire:click="togglePermission('{{ $feature }}')"
|
||||||
|
type="button"
|
||||||
|
class="{{ $config['enabled'] ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700' }} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out"
|
||||||
|
role="switch"
|
||||||
|
>
|
||||||
|
<span class="{{ $config['enabled'] ? 'translate-x-4' : 'translate-x-0' }} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if ($config['enabled'])
|
||||||
|
<input
|
||||||
|
wire:model.blur="permissionsFeatures.{{ $feature }}.allowlist"
|
||||||
|
type="text"
|
||||||
|
class="mt-2 block w-full rounded-md border-gray-300 text-xs shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="self https://example.com"
|
||||||
|
>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Other Headers Tab --}}
|
||||||
|
@if ($activeTab === 'other')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="x-frame-options" class="block text-sm font-medium text-gray-700 dark:text-gray-300">X-Frame-Options</label>
|
||||||
|
<select wire:model="xFrameOptions" id="x-frame-options" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm">
|
||||||
|
<option value="DENY">DENY</option>
|
||||||
|
<option value="SAMEORIGIN">SAMEORIGIN</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Prevents clickjacking attacks by controlling iframe embedding.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="referrer-policy" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Referrer-Policy</label>
|
||||||
|
<select wire:model="referrerPolicy" id="referrer-policy" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm">
|
||||||
|
<option value="no-referrer">no-referrer</option>
|
||||||
|
<option value="no-referrer-when-downgrade">no-referrer-when-downgrade</option>
|
||||||
|
<option value="origin">origin</option>
|
||||||
|
<option value="origin-when-cross-origin">origin-when-cross-origin</option>
|
||||||
|
<option value="same-origin">same-origin</option>
|
||||||
|
<option value="strict-origin">strict-origin</option>
|
||||||
|
<option value="strict-origin-when-cross-origin">strict-origin-when-cross-origin</option>
|
||||||
|
<option value="unsafe-url">unsafe-url</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Controls how much referrer information is sent with requests.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Fixed Headers</h4>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">These headers are always included for security:</p>
|
||||||
|
<ul class="list-inside list-disc text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<li>X-Content-Type-Options: nosniff</li>
|
||||||
|
<li>X-XSS-Protection: 1; mode=block</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Action Buttons --}}
|
||||||
|
<div class="flex items-center justify-between border-t border-gray-200 pt-6 dark:border-gray-700">
|
||||||
|
<button wire:click="resetToDefaults" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-700">
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button
|
||||||
|
wire:click="$dispatch('show-env-config')"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-700"
|
||||||
|
x-data
|
||||||
|
x-on:click="$dispatch('open-modal', 'env-config')"
|
||||||
|
>
|
||||||
|
Export .env
|
||||||
|
</button>
|
||||||
|
<button wire:click="saveConfiguration" type="button" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -63,6 +63,34 @@ return [
|
||||||
// Report URI for CSP violation reports
|
// Report URI for CSP violation reports
|
||||||
'report_uri' => env('SECURITY_CSP_REPORT_URI'),
|
'report_uri' => env('SECURITY_CSP_REPORT_URI'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
| Nonce-based CSP
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When enabled, a unique nonce is generated per request and added to
|
||||||
|
| script-src and style-src directives. Inline scripts/styles must
|
||||||
|
| include the nonce attribute to be allowed.
|
||||||
|
|
|
||||||
|
| Usage in Blade:
|
||||||
|
| <script nonce="{{ csp_nonce() }}">...</script>
|
||||||
|
| <script @cspnonce>...</script>
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Enable nonce-based CSP (recommended for production)
|
||||||
|
'nonce_enabled' => env('SECURITY_CSP_NONCE_ENABLED', true),
|
||||||
|
|
||||||
|
// Nonce length in bytes (16 = 128 bits, recommended minimum)
|
||||||
|
'nonce_length' => env('SECURITY_CSP_NONCE_LENGTH', 16),
|
||||||
|
|
||||||
|
// Directives to add nonces to
|
||||||
|
'nonce_directives' => ['script-src', 'style-src'],
|
||||||
|
|
||||||
|
// Environments where nonces are skipped (unsafe-inline is used instead)
|
||||||
|
// This avoids issues with hot reload and dev tools
|
||||||
|
'nonce_skip_environments' => ['local', 'development'],
|
||||||
|
|
||||||
// CSP Directives
|
// CSP Directives
|
||||||
'directives' => [
|
'directives' => [
|
||||||
'default-src' => ["'self'"],
|
'default-src' => ["'self'"],
|
||||||
|
|
|
||||||
49
packages/core-php/src/Core/Headers/helpers.php
Normal file
49
packages/core-php/src/Core/Headers/helpers.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Headers\CspNonceService;
|
||||||
|
|
||||||
|
if (! function_exists('csp_nonce')) {
|
||||||
|
/**
|
||||||
|
* Get the CSP nonce for the current request.
|
||||||
|
*
|
||||||
|
* Usage in Blade templates:
|
||||||
|
* ```blade
|
||||||
|
* <script nonce="{{ csp_nonce() }}">
|
||||||
|
* // Your inline JavaScript
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @return string The base64-encoded nonce value
|
||||||
|
*/
|
||||||
|
function csp_nonce(): string
|
||||||
|
{
|
||||||
|
return app(CspNonceService::class)->getNonce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('csp_nonce_attribute')) {
|
||||||
|
/**
|
||||||
|
* Get the CSP nonce as an HTML attribute.
|
||||||
|
*
|
||||||
|
* Usage in Blade templates:
|
||||||
|
* ```blade
|
||||||
|
* <script {!! csp_nonce_attribute() !!}>
|
||||||
|
* // Your inline JavaScript
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @return string The nonce attribute (e.g., 'nonce="abc123..."')
|
||||||
|
*/
|
||||||
|
function csp_nonce_attribute(): string
|
||||||
|
{
|
||||||
|
return app(CspNonceService::class)->getNonceAttribute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -165,7 +165,7 @@ TEXT;
|
||||||
|
|
||||||
// Check if it's already valid PEM format
|
// Check if it's already valid PEM format
|
||||||
if (str_starts_with($keyData, '-----BEGIN')) {
|
if (str_starts_with($keyData, '-----BEGIN')) {
|
||||||
// Handle escaped newlines from Docker/Coolify
|
// Handle escaped newlines from Docker environments
|
||||||
$keyData = str_replace(['\\n', '\n', '\\\\n'], "\n", $keyData);
|
$keyData = str_replace(['\\n', '\n', '\\\\n'], "\n", $keyData);
|
||||||
|
|
||||||
return $keyData;
|
return $keyData;
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,72 @@ use Psr\Log\LoggerInterface;
|
||||||
* - Configurable filter rules per field via schema
|
* - Configurable filter rules per field via schema
|
||||||
* - Unicode NFC normalization for consistent string handling
|
* - Unicode NFC normalization for consistent string handling
|
||||||
* - Optional audit logging when content is modified
|
* - Optional audit logging when content is modified
|
||||||
|
* - Rich text support with safe HTML tags whitelist
|
||||||
|
* - Configurable maximum input length enforcement
|
||||||
|
* - Transformation hooks for custom processing at different stages
|
||||||
|
*
|
||||||
|
* ## Transformation Hooks
|
||||||
|
*
|
||||||
|
* Register callbacks to transform values at specific stages of the
|
||||||
|
* sanitization pipeline:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $sanitiser = (new Sanitiser())
|
||||||
|
* ->beforeFilter(function (string $value, string $field): string {
|
||||||
|
* // Transform before any filtering
|
||||||
|
* return trim($value);
|
||||||
|
* })
|
||||||
|
* ->afterFilter(function (string $value, string $field): string {
|
||||||
|
* // Transform after all filtering is complete
|
||||||
|
* return $value;
|
||||||
|
* })
|
||||||
|
* ->transformField('username', function (string $value): string {
|
||||||
|
* // Field-specific transformation
|
||||||
|
* return strtolower($value);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Hook execution order:
|
||||||
|
* 1. Before hooks (global, then field-specific)
|
||||||
|
* 2. Standard filtering pipeline (normalize, strip, HTML, preset, filters, length)
|
||||||
|
* 3. After hooks (global, then field-specific)
|
||||||
*/
|
*/
|
||||||
class Sanitiser
|
class Sanitiser
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Default safe HTML tags for rich text fields.
|
||||||
|
* These tags are considered safe for user-generated content.
|
||||||
|
*/
|
||||||
|
public const SAFE_HTML_TAGS = '<p><br><strong><em><a><ul><ol><li><h1><h2><h3><h4><h5><h6><blockquote><code><pre>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal HTML tags for basic formatting.
|
||||||
|
*/
|
||||||
|
public const BASIC_HTML_TAGS = '<p><br><strong><em>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default maximum input length (0 = unlimited).
|
||||||
|
*/
|
||||||
|
public const DEFAULT_MAX_LENGTH = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common filter rule presets for quick configuration.
|
||||||
|
* Use with withPreset() or field-level 'preset' schema option.
|
||||||
|
*/
|
||||||
|
public const PRESET_EMAIL = 'email';
|
||||||
|
public const PRESET_URL = 'url';
|
||||||
|
public const PRESET_PHONE = 'phone';
|
||||||
|
public const PRESET_ALPHA = 'alpha';
|
||||||
|
public const PRESET_ALPHANUMERIC = 'alphanumeric';
|
||||||
|
public const PRESET_NUMERIC = 'numeric';
|
||||||
|
public const PRESET_SLUG = 'slug';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for per-field filter rules.
|
* Schema for per-field filter rules.
|
||||||
*
|
*
|
||||||
* Format: ['field_name' => ['filters' => [...], 'options' => [...]]]
|
* Format: ['field_name' => ['filters' => [...], 'options' => [...]]]
|
||||||
*
|
*
|
||||||
* @var array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool}>
|
* @var array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool, allow_html?: string|bool, max_length?: int}>
|
||||||
*/
|
*/
|
||||||
protected array $schema = [];
|
protected array $schema = [];
|
||||||
|
|
||||||
|
|
@ -52,24 +109,97 @@ class Sanitiser
|
||||||
*/
|
*/
|
||||||
protected bool $normalizeUnicode = true;
|
protected bool $normalizeUnicode = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global maximum input length (0 = unlimited).
|
||||||
|
*/
|
||||||
|
protected int $maxLength = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global allowed HTML tags (empty string = strip all HTML).
|
||||||
|
*/
|
||||||
|
protected string $allowedHtmlTags = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global before-filter transformation hooks.
|
||||||
|
*
|
||||||
|
* @var array<callable(string, string): string>
|
||||||
|
*/
|
||||||
|
protected array $beforeHooks = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global after-filter transformation hooks.
|
||||||
|
*
|
||||||
|
* @var array<callable(string, string): string>
|
||||||
|
*/
|
||||||
|
protected array $afterHooks = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field-specific transformation hooks (keyed by field name).
|
||||||
|
*
|
||||||
|
* @var array<string, array{before?: array<callable(string): string>, after?: array<callable(string): string>}>
|
||||||
|
*/
|
||||||
|
protected array $fieldHooks = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset definitions for common input types.
|
||||||
|
*
|
||||||
|
* @var array<string, array{pattern?: string, filter?: int, transform?: callable(string): string}>
|
||||||
|
*/
|
||||||
|
protected static array $presets = [
|
||||||
|
self::PRESET_EMAIL => [
|
||||||
|
'filter' => FILTER_SANITIZE_EMAIL,
|
||||||
|
'transform' => 'strtolower',
|
||||||
|
],
|
||||||
|
self::PRESET_URL => [
|
||||||
|
'filter' => FILTER_SANITIZE_URL,
|
||||||
|
],
|
||||||
|
self::PRESET_PHONE => [
|
||||||
|
// Keep only digits, plus, hyphens, parentheses, and spaces
|
||||||
|
'pattern' => '/[^\d\+\-\(\)\s]/',
|
||||||
|
],
|
||||||
|
self::PRESET_ALPHA => [
|
||||||
|
// Keep only letters (including Unicode)
|
||||||
|
'pattern' => '/[^\p{L}]/u',
|
||||||
|
],
|
||||||
|
self::PRESET_ALPHANUMERIC => [
|
||||||
|
// Keep only letters and numbers (including Unicode)
|
||||||
|
'pattern' => '/[^\p{L}\p{N}]/u',
|
||||||
|
],
|
||||||
|
self::PRESET_NUMERIC => [
|
||||||
|
// Keep only digits, decimal point, and minus sign
|
||||||
|
'pattern' => '/[^\d\.\-]/',
|
||||||
|
],
|
||||||
|
self::PRESET_SLUG => [
|
||||||
|
// Convert to URL-safe slug format
|
||||||
|
'pattern' => '/[^a-z0-9\-]/',
|
||||||
|
'transform' => 'strtolower',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new Sanitiser instance.
|
* Create a new Sanitiser instance.
|
||||||
*
|
*
|
||||||
* @param array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool}> $schema Per-field filter rules
|
* @param array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool, allow_html?: string|bool, max_length?: int}> $schema Per-field filter rules
|
||||||
* @param LoggerInterface|null $logger Optional PSR-3 logger for audit logging
|
* @param LoggerInterface|null $logger Optional PSR-3 logger for audit logging
|
||||||
* @param bool $auditEnabled Whether to enable audit logging (requires logger)
|
* @param bool $auditEnabled Whether to enable audit logging (requires logger)
|
||||||
* @param bool $normalizeUnicode Whether to normalize Unicode to NFC form
|
* @param bool $normalizeUnicode Whether to normalize Unicode to NFC form
|
||||||
|
* @param int $maxLength Global maximum input length (0 = unlimited)
|
||||||
|
* @param string $allowedHtmlTags Global allowed HTML tags (empty = strip all)
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
array $schema = [],
|
array $schema = [],
|
||||||
?LoggerInterface $logger = null,
|
?LoggerInterface $logger = null,
|
||||||
bool $auditEnabled = false,
|
bool $auditEnabled = false,
|
||||||
bool $normalizeUnicode = true
|
bool $normalizeUnicode = true,
|
||||||
|
int $maxLength = 0,
|
||||||
|
string $allowedHtmlTags = ''
|
||||||
) {
|
) {
|
||||||
$this->schema = $schema;
|
$this->schema = $schema;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
$this->auditEnabled = $auditEnabled && $logger !== null;
|
$this->auditEnabled = $auditEnabled && $logger !== null;
|
||||||
$this->normalizeUnicode = $normalizeUnicode;
|
$this->normalizeUnicode = $normalizeUnicode;
|
||||||
|
$this->maxLength = $maxLength;
|
||||||
|
$this->allowedHtmlTags = $allowedHtmlTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -82,10 +212,12 @@ class Sanitiser
|
||||||
* 'options' => [FILTER_FLAG_STRIP_HIGH, ...], // Additional flags
|
* 'options' => [FILTER_FLAG_STRIP_HIGH, ...], // Additional flags
|
||||||
* 'skip_control_strip' => false, // Skip control character stripping
|
* 'skip_control_strip' => false, // Skip control character stripping
|
||||||
* 'skip_normalize' => false, // Skip Unicode normalization
|
* 'skip_normalize' => false, // Skip Unicode normalization
|
||||||
|
* 'allow_html' => '<p><br><strong>', // Allowed HTML tags for this field
|
||||||
|
* 'max_length' => 1000, // Max length for this field (overrides global)
|
||||||
* ],
|
* ],
|
||||||
* ]
|
* ]
|
||||||
*
|
*
|
||||||
* @param array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool}> $schema
|
* @param array<string, array{filters?: int[], options?: int[], skip_control_strip?: bool, skip_normalize?: bool, allow_html?: string|bool, max_length?: int}> $schema
|
||||||
* @return static
|
* @return static
|
||||||
*/
|
*/
|
||||||
public function withSchema(array $schema): static
|
public function withSchema(array $schema): static
|
||||||
|
|
@ -126,6 +258,392 @@ class Sanitiser
|
||||||
return $clone;
|
return $clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set allowed HTML tags globally for all fields.
|
||||||
|
*
|
||||||
|
* Pass a string of allowed tags (e.g., '<p><br><strong>') or
|
||||||
|
* use one of the predefined constants (SAFE_HTML_TAGS, BASIC_HTML_TAGS).
|
||||||
|
*
|
||||||
|
* @param string $allowedTags Allowed HTML tags in strip_tags format
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function allowHtml(string $allowedTags = self::SAFE_HTML_TAGS): static
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
$clone->allowedHtmlTags = $allowedTags;
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable rich text mode with safe HTML tags.
|
||||||
|
*
|
||||||
|
* Allows common formatting tags: p, br, strong, em, a, ul, ol, li,
|
||||||
|
* headings, blockquote, code, and pre.
|
||||||
|
*
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function richText(): static
|
||||||
|
{
|
||||||
|
return $this->allowHtml(self::SAFE_HTML_TAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable basic HTML mode with minimal formatting tags.
|
||||||
|
*
|
||||||
|
* Allows only: p, br, strong, em.
|
||||||
|
*
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function basicHtml(): static
|
||||||
|
{
|
||||||
|
return $this->allowHtml(self::BASIC_HTML_TAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set global maximum input length.
|
||||||
|
*
|
||||||
|
* Inputs exceeding this length will be truncated.
|
||||||
|
* Set to 0 for unlimited length.
|
||||||
|
*
|
||||||
|
* @param int $maxLength Maximum length in characters (0 = unlimited)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function maxLength(int $maxLength): static
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
$clone->maxLength = max(0, $maxLength);
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an email preset to sanitise email addresses.
|
||||||
|
*
|
||||||
|
* Sanitises using FILTER_SANITIZE_EMAIL and lowercases the result.
|
||||||
|
* Use for fields that should contain valid email addresses.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function email(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_EMAIL, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a URL preset to sanitise URLs.
|
||||||
|
*
|
||||||
|
* Sanitises using FILTER_SANITIZE_URL.
|
||||||
|
* Use for fields that should contain valid URLs.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function url(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_URL, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a phone preset to sanitise phone numbers.
|
||||||
|
*
|
||||||
|
* Keeps only digits, plus signs, hyphens, parentheses, and spaces.
|
||||||
|
* Use for fields that should contain phone numbers.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function phone(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_PHONE, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an alpha preset to allow only letters.
|
||||||
|
*
|
||||||
|
* Keeps only alphabetic characters (including Unicode letters).
|
||||||
|
* Use for fields like names that should only contain letters.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function alpha(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_ALPHA, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an alphanumeric preset to allow only letters and numbers.
|
||||||
|
*
|
||||||
|
* Keeps only alphanumeric characters (including Unicode).
|
||||||
|
* Use for usernames, codes, or similar fields.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function alphanumeric(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_ALPHANUMERIC, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a numeric preset to allow only numbers.
|
||||||
|
*
|
||||||
|
* Keeps only digits, decimal points, and minus signs.
|
||||||
|
* Use for numeric input fields.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function numeric(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_NUMERIC, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a slug preset to create URL-safe slugs.
|
||||||
|
*
|
||||||
|
* Lowercases and keeps only lowercase letters, numbers, and hyphens.
|
||||||
|
* Use for URL slugs, identifiers, or similar fields.
|
||||||
|
*
|
||||||
|
* @param string ...$fields Field names to apply the preset to (empty = all fields)
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function slug(string ...$fields): static
|
||||||
|
{
|
||||||
|
return $this->applyPresetToFields(self::PRESET_SLUG, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom preset.
|
||||||
|
*
|
||||||
|
* @param string $name Preset name
|
||||||
|
* @param array{pattern?: string, filter?: int, transform?: callable(string): string} $definition
|
||||||
|
*/
|
||||||
|
public static function registerPreset(string $name, array $definition): void
|
||||||
|
{
|
||||||
|
self::$presets[$name] = $definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered presets.
|
||||||
|
*
|
||||||
|
* @return array<string, array{pattern?: string, filter?: int, transform?: callable(string): string}>
|
||||||
|
*/
|
||||||
|
public static function getPresets(): array
|
||||||
|
{
|
||||||
|
return self::$presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a preset to specified fields or globally.
|
||||||
|
*
|
||||||
|
* @param string $presetName
|
||||||
|
* @param array<string> $fields
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
protected function applyPresetToFields(string $presetName, array $fields): static
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
|
||||||
|
if (empty($fields)) {
|
||||||
|
// Apply globally by setting a default preset
|
||||||
|
$clone->schema['*'] = array_merge(
|
||||||
|
$clone->schema['*'] ?? [],
|
||||||
|
['preset' => $presetName]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Apply to specific fields
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$clone->schema[$field] = array_merge(
|
||||||
|
$clone->schema[$field] ?? [],
|
||||||
|
['preset' => $presetName]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TRANSFORMATION HOOKS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a global before-filter transformation hook.
|
||||||
|
*
|
||||||
|
* The callback receives the value and field name, and should return
|
||||||
|
* the transformed value. Multiple hooks are executed in order.
|
||||||
|
*
|
||||||
|
* @param callable(string, string): string $callback
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function beforeFilter(callable $callback): static
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
$clone->beforeHooks[] = $callback;
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a global after-filter transformation hook.
|
||||||
|
*
|
||||||
|
* The callback receives the value and field name, and should return
|
||||||
|
* the transformed value. Multiple hooks are executed in order.
|
||||||
|
*
|
||||||
|
* @param callable(string, string): string $callback
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function afterFilter(callable $callback): static
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
$clone->afterHooks[] = $callback;
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a field-specific transformation hook.
|
||||||
|
*
|
||||||
|
* The callback receives only the value (field name is known).
|
||||||
|
* Use `$stage` to control when the hook runs:
|
||||||
|
* - 'before': Run before standard filtering
|
||||||
|
* - 'after': Run after standard filtering (default)
|
||||||
|
*
|
||||||
|
* @param string $field Field name to transform
|
||||||
|
* @param callable(string): string $callback
|
||||||
|
* @param string $stage When to run: 'before' or 'after'
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function transformField(string $field, callable $callback, string $stage = 'after'): static
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
|
||||||
|
if (!isset($clone->fieldHooks[$field])) {
|
||||||
|
$clone->fieldHooks[$field] = ['before' => [], 'after' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stage = in_array($stage, ['before', 'after'], true) ? $stage : 'after';
|
||||||
|
$clone->fieldHooks[$field][$stage][] = $callback;
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a before-filter hook for specific fields.
|
||||||
|
*
|
||||||
|
* @param callable(string): string $callback
|
||||||
|
* @param string ...$fields Field names to apply the hook to
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function beforeFilterFields(callable $callback, string ...$fields): static
|
||||||
|
{
|
||||||
|
$clone = $this;
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$clone = $clone->transformField($field, $callback, 'before');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an after-filter hook for specific fields.
|
||||||
|
*
|
||||||
|
* @param callable(string): string $callback
|
||||||
|
* @param string ...$fields Field names to apply the hook to
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function afterFilterFields(callable $callback, string ...$fields): static
|
||||||
|
{
|
||||||
|
$clone = $this;
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$clone = $clone->transformField($field, $callback, 'after');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all before hooks to a value.
|
||||||
|
*
|
||||||
|
* @param string $value The value to transform
|
||||||
|
* @param string $fieldName The field name
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function applyBeforeHooks(string $value, string $fieldName): string
|
||||||
|
{
|
||||||
|
// Apply global before hooks
|
||||||
|
foreach ($this->beforeHooks as $hook) {
|
||||||
|
$value = $hook($value, $fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply field-specific before hooks
|
||||||
|
if (isset($this->fieldHooks[$fieldName]['before'])) {
|
||||||
|
foreach ($this->fieldHooks[$fieldName]['before'] as $hook) {
|
||||||
|
$value = $hook($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all after hooks to a value.
|
||||||
|
*
|
||||||
|
* @param string $value The value to transform
|
||||||
|
* @param string $fieldName The field name
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function applyAfterHooks(string $value, string $fieldName): string
|
||||||
|
{
|
||||||
|
// Apply global after hooks
|
||||||
|
foreach ($this->afterHooks as $hook) {
|
||||||
|
$value = $hook($value, $fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply field-specific after hooks
|
||||||
|
if (isset($this->fieldHooks[$fieldName]['after'])) {
|
||||||
|
foreach ($this->fieldHooks[$fieldName]['after'] as $hook) {
|
||||||
|
$value = $hook($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any transformation hooks are registered.
|
||||||
|
*/
|
||||||
|
public function hasTransformationHooks(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->beforeHooks)
|
||||||
|
|| !empty($this->afterHooks)
|
||||||
|
|| !empty($this->fieldHooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of registered hooks.
|
||||||
|
*
|
||||||
|
* @return array{before: int, after: int, field: int}
|
||||||
|
*/
|
||||||
|
public function getHookCounts(): array
|
||||||
|
{
|
||||||
|
$fieldCount = 0;
|
||||||
|
foreach ($this->fieldHooks as $hooks) {
|
||||||
|
$fieldCount += count($hooks['before'] ?? []) + count($hooks['after'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'before' => count($this->beforeHooks),
|
||||||
|
'after' => count($this->afterHooks),
|
||||||
|
'field' => $fieldCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip dangerous control characters from all values.
|
* Strip dangerous control characters from all values.
|
||||||
*
|
*
|
||||||
|
|
@ -176,6 +694,17 @@ class Sanitiser
|
||||||
/**
|
/**
|
||||||
* Apply filters to a string value.
|
* Apply filters to a string value.
|
||||||
*
|
*
|
||||||
|
* The filtering pipeline executes in this order:
|
||||||
|
* 1. Before hooks (global, then field-specific)
|
||||||
|
* 2. Unicode NFC normalization
|
||||||
|
* 3. Control character stripping
|
||||||
|
* 4. HTML filtering
|
||||||
|
* 5. Preset application
|
||||||
|
* 6. Additional schema filters
|
||||||
|
* 7. Max length enforcement
|
||||||
|
* 8. After hooks (global, then field-specific)
|
||||||
|
* 9. Audit logging (if enabled and value changed)
|
||||||
|
*
|
||||||
* @param string $value
|
* @param string $value
|
||||||
* @param string $path Full path for logging
|
* @param string $path Full path for logging
|
||||||
* @param string $fieldName Top-level field name for schema lookup
|
* @param string $fieldName Top-level field name for schema lookup
|
||||||
|
|
@ -185,9 +714,16 @@ class Sanitiser
|
||||||
{
|
{
|
||||||
$original = $value;
|
$original = $value;
|
||||||
$fieldSchema = $this->schema[$fieldName] ?? [];
|
$fieldSchema = $this->schema[$fieldName] ?? [];
|
||||||
|
$globalSchema = $this->schema['*'] ?? [];
|
||||||
|
|
||||||
|
// Merge global schema with field-specific schema (field takes precedence)
|
||||||
|
$effectiveSchema = array_merge($globalSchema, $fieldSchema);
|
||||||
|
|
||||||
|
// Step 0: Apply before hooks
|
||||||
|
$value = $this->applyBeforeHooks($value, $fieldName);
|
||||||
|
|
||||||
// Step 1: Unicode NFC normalization (unless skipped)
|
// Step 1: Unicode NFC normalization (unless skipped)
|
||||||
$skipNormalize = $fieldSchema['skip_normalize'] ?? false;
|
$skipNormalize = $effectiveSchema['skip_normalize'] ?? false;
|
||||||
if ($this->normalizeUnicode && !$skipNormalize && $this->isNormalizerAvailable()) {
|
if ($this->normalizeUnicode && !$skipNormalize && $this->isNormalizerAvailable()) {
|
||||||
$normalized = Normalizer::normalize($value, Normalizer::FORM_C);
|
$normalized = Normalizer::normalize($value, Normalizer::FORM_C);
|
||||||
if ($normalized !== false) {
|
if ($normalized !== false) {
|
||||||
|
|
@ -196,14 +732,20 @@ class Sanitiser
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Strip control characters (unless skipped)
|
// Step 2: Strip control characters (unless skipped)
|
||||||
$skipControlStrip = $fieldSchema['skip_control_strip'] ?? false;
|
$skipControlStrip = $effectiveSchema['skip_control_strip'] ?? false;
|
||||||
if (!$skipControlStrip) {
|
if (!$skipControlStrip) {
|
||||||
$value = filter_var($value, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW) ?? '';
|
$value = filter_var($value, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Apply additional schema-defined filters
|
// Step 3: Handle HTML tags (strip or allow based on configuration)
|
||||||
$additionalFilters = $fieldSchema['filters'] ?? [];
|
$value = $this->filterHtml($value, $effectiveSchema);
|
||||||
$additionalOptions = $fieldSchema['options'] ?? [];
|
|
||||||
|
// Step 4: Apply preset if specified
|
||||||
|
$value = $this->applyPreset($value, $effectiveSchema);
|
||||||
|
|
||||||
|
// Step 5: Apply additional schema-defined filters
|
||||||
|
$additionalFilters = $effectiveSchema['filters'] ?? [];
|
||||||
|
$additionalOptions = $effectiveSchema['options'] ?? [];
|
||||||
|
|
||||||
foreach ($additionalFilters as $filter) {
|
foreach ($additionalFilters as $filter) {
|
||||||
$options = 0;
|
$options = 0;
|
||||||
|
|
@ -219,7 +761,13 @@ class Sanitiser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Audit logging if content was modified
|
// Step 6: Enforce max length
|
||||||
|
$value = $this->enforceMaxLength($value, $effectiveSchema);
|
||||||
|
|
||||||
|
// Step 7: Apply after hooks
|
||||||
|
$value = $this->applyAfterHooks($value, $fieldName);
|
||||||
|
|
||||||
|
// Step 8: Audit logging if content was modified
|
||||||
if ($this->auditEnabled && $this->logger !== null && $value !== $original) {
|
if ($this->auditEnabled && $this->logger !== null && $value !== $original) {
|
||||||
$this->logSanitisation($path, $original, $value);
|
$this->logSanitisation($path, $original, $value);
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +775,113 @@ class Sanitiser
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a preset to a value.
|
||||||
|
*
|
||||||
|
* @param string $value The value to transform
|
||||||
|
* @param array $schema The effective schema containing preset configuration
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function applyPreset(string $value, array $schema): string
|
||||||
|
{
|
||||||
|
$presetName = $schema['preset'] ?? null;
|
||||||
|
|
||||||
|
if ($presetName === null || !isset(self::$presets[$presetName])) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preset = self::$presets[$presetName];
|
||||||
|
|
||||||
|
// Apply regex pattern if defined
|
||||||
|
if (isset($preset['pattern'])) {
|
||||||
|
$value = preg_replace($preset['pattern'], '', $value) ?? $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter if defined
|
||||||
|
if (isset($preset['filter'])) {
|
||||||
|
$filtered = filter_var($value, $preset['filter']);
|
||||||
|
if ($filtered !== false) {
|
||||||
|
$value = $filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply transform function if defined
|
||||||
|
if (isset($preset['transform'])) {
|
||||||
|
$transform = $preset['transform'];
|
||||||
|
if (is_callable($transform)) {
|
||||||
|
$value = $transform($value);
|
||||||
|
} elseif (is_string($transform) && function_exists($transform)) {
|
||||||
|
$value = $transform($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter HTML from value based on configuration.
|
||||||
|
*
|
||||||
|
* @param string $value The value to filter
|
||||||
|
* @param array $effectiveSchema Effective schema (merged global + field)
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function filterHtml(string $value, array $effectiveSchema): string
|
||||||
|
{
|
||||||
|
// Check field-specific HTML allowance first
|
||||||
|
$allowHtml = $effectiveSchema['allow_html'] ?? null;
|
||||||
|
|
||||||
|
if ($allowHtml !== null) {
|
||||||
|
if ($allowHtml === true) {
|
||||||
|
// Allow default safe HTML tags
|
||||||
|
return strip_tags($value, self::SAFE_HTML_TAGS);
|
||||||
|
} elseif ($allowHtml === false) {
|
||||||
|
// Strip all HTML
|
||||||
|
return strip_tags($value);
|
||||||
|
} elseif (is_string($allowHtml) && $allowHtml !== '') {
|
||||||
|
// Use custom allowed tags
|
||||||
|
return strip_tags($value, $allowHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to global setting
|
||||||
|
if ($this->allowedHtmlTags !== '') {
|
||||||
|
return strip_tags($value, $this->allowedHtmlTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No HTML filtering by default (preserves BC)
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce maximum length on value.
|
||||||
|
*
|
||||||
|
* @param string $value The value to truncate
|
||||||
|
* @param array $effectiveSchema Effective schema (merged global + field)
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function enforceMaxLength(string $value, array $effectiveSchema): string
|
||||||
|
{
|
||||||
|
// Check field-specific max length first
|
||||||
|
$maxLength = $effectiveSchema['max_length'] ?? null;
|
||||||
|
|
||||||
|
if ($maxLength === null) {
|
||||||
|
// Fall back to global setting
|
||||||
|
$maxLength = $this->maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 means unlimited
|
||||||
|
if ($maxLength <= 0) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if needed (using mb_substr for Unicode safety)
|
||||||
|
if (mb_strlen($value) > $maxLength) {
|
||||||
|
return mb_substr($value, 0, $maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log when content is modified during sanitisation.
|
* Log when content is modified during sanitisation.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue