feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.
Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details
Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
5cf876aa21
commit
41a90cbff8
737 changed files with 85861 additions and 1476 deletions
21
app/Boot.php
21
app/Boot.php
|
|
@ -6,20 +6,15 @@ namespace App;
|
||||||
|
|
||||||
use Core\Boot as CoreBoot;
|
use Core\Boot as CoreBoot;
|
||||||
|
|
||||||
/**
|
|
||||||
* Lethean.io — TLD website and blockchain services.
|
|
||||||
*
|
|
||||||
* Website modules handle domain resolution and web routes.
|
|
||||||
* Mod modules provide business logic and API endpoints.
|
|
||||||
*/
|
|
||||||
class Boot extends CoreBoot
|
class Boot extends CoreBoot
|
||||||
{
|
{
|
||||||
public static array $providers = [
|
public static array $providers = [
|
||||||
\Core\Storage\CacheResilienceProvider::class,
|
\Core\Storage\CacheResilienceProvider::class,
|
||||||
\Core\LifecycleEventProvider::class,
|
\Core\LifecycleEventProvider::class,
|
||||||
\Core\Front\FrontServiceProvider::class,
|
\Core\Front\Web\Boot::class,
|
||||||
|
\Core\Front\Client\Boot::class,
|
||||||
// Websites — domain-scoped route registration
|
\Core\Front\Cli\Boot::class,
|
||||||
|
\Core\Front\Components\Boot::class,
|
||||||
\Website\Lethean\Boot::class,
|
\Website\Lethean\Boot::class,
|
||||||
\Website\Explorer\Boot::class,
|
\Website\Explorer\Boot::class,
|
||||||
\Website\Names\Boot::class,
|
\Website\Names\Boot::class,
|
||||||
|
|
@ -27,9 +22,6 @@ class Boot extends CoreBoot
|
||||||
\Website\Pool\Boot::class,
|
\Website\Pool\Boot::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Mod boot classes — loaded via lifecycle events.
|
|
||||||
*/
|
|
||||||
public static array $modules = [
|
public static array $modules = [
|
||||||
\Mod\Chain\Boot::class,
|
\Mod\Chain\Boot::class,
|
||||||
\Mod\Explorer\Boot::class,
|
\Mod\Explorer\Boot::class,
|
||||||
|
|
@ -37,4 +29,9 @@ class Boot extends CoreBoot
|
||||||
\Mod\Pool\Boot::class,
|
\Mod\Pool\Boot::class,
|
||||||
\Mod\Names\Boot::class,
|
\Mod\Names\Boot::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected static function basePath(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
app/Core/Actions/Action.php
Normal file
52
app/Core/Actions/Action.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Actions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Action trait for single-purpose business logic classes.
|
||||||
|
*
|
||||||
|
* Actions are small, focused classes that do one thing well.
|
||||||
|
* They extract complex logic from controllers and Livewire components.
|
||||||
|
*
|
||||||
|
* Convention:
|
||||||
|
* - One action per file
|
||||||
|
* - Named after what it does: CreatePage, PublishPost, SendInvoice
|
||||||
|
* - Single public method: handle() or __invoke()
|
||||||
|
* - Dependencies injected via constructor
|
||||||
|
* - Static run() helper for convenience
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* // Via dependency injection
|
||||||
|
* public function __construct(private CreatePage $createPage) {}
|
||||||
|
* $page = $this->createPage->handle($user, $data);
|
||||||
|
*
|
||||||
|
* // Via static helper
|
||||||
|
* $page = CreatePage::run($user, $data);
|
||||||
|
*
|
||||||
|
* // Via app container
|
||||||
|
* $page = app(CreatePage::class)->handle($user, $data);
|
||||||
|
*
|
||||||
|
* Directory structure:
|
||||||
|
* app/Mod/{Module}/Actions/
|
||||||
|
* ├── CreateThing.php
|
||||||
|
* ├── UpdateThing.php
|
||||||
|
* ├── DeleteThing.php
|
||||||
|
* └── Thing/
|
||||||
|
* ├── PublishThing.php
|
||||||
|
* └── ArchiveThing.php
|
||||||
|
*/
|
||||||
|
trait Action
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the action via the container.
|
||||||
|
*
|
||||||
|
* Resolves the action from the container (with dependencies)
|
||||||
|
* and calls handle() with the provided arguments.
|
||||||
|
*/
|
||||||
|
public static function run(mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
return app(static::class)->handle(...$args);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Core/Actions/Actionable.php
Normal file
19
app/Core/Actions/Actionable.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Actions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for actions that want explicit contracts.
|
||||||
|
*
|
||||||
|
* Optional - most actions just use the Action trait.
|
||||||
|
* Use this when you need to type-hint against an action interface.
|
||||||
|
*/
|
||||||
|
interface Actionable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Execute the action.
|
||||||
|
*/
|
||||||
|
public function handle(mixed ...$args): mixed;
|
||||||
|
}
|
||||||
57
app/Core/Actions/CLAUDE.md
Normal file
57
app/Core/Actions/CLAUDE.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Actions
|
||||||
|
|
||||||
|
Single-purpose business logic pattern with scheduling support.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Provides the `Action` trait for extracting business logic from controllers/components into focused, testable classes. Each action does one thing via a `handle()` method and gets a static `run()` shortcut that resolves dependencies from the container.
|
||||||
|
|
||||||
|
Also provides attribute-driven scheduling: annotate an Action with `#[Scheduled]` and it gets wired into Laravel's scheduler automatically.
|
||||||
|
|
||||||
|
## Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Action` (trait) | Adds `static run(...$args)` that resolves via container and calls `handle()` |
|
||||||
|
| `Actionable` (interface) | Optional contract for type-hinting actions |
|
||||||
|
| `Scheduled` (attribute) | Marks an Action for scheduled execution with frequency string |
|
||||||
|
| `ScheduledAction` (model) | Eloquent model persisted to `scheduled_actions` table |
|
||||||
|
| `ScheduledActionScanner` | Discovers `#[Scheduled]` attributes by scanning directories |
|
||||||
|
| `ScheduleServiceProvider` | Reads enabled scheduled actions from DB and registers with Laravel scheduler |
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Use the Action pattern
|
||||||
|
class CreateOrder {
|
||||||
|
use Action;
|
||||||
|
public function handle(User $user, array $data): Order { ... }
|
||||||
|
}
|
||||||
|
CreateOrder::run($user, $data); // resolves from container
|
||||||
|
|
||||||
|
// Schedule an action
|
||||||
|
#[Scheduled(frequency: 'dailyAt:09:00', timezone: 'Europe/London')]
|
||||||
|
class PublishDigest {
|
||||||
|
use Action;
|
||||||
|
public function handle(): void { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frequency String Format
|
||||||
|
|
||||||
|
`method:arg1,arg2` maps directly to Laravel Schedule methods:
|
||||||
|
- `everyMinute` / `hourly` / `daily` / `weekly` / `monthly`
|
||||||
|
- `dailyAt:09:00` / `weeklyOn:1,09:00` / `cron:* * * * *`
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
- Scanner skips `Tests/` directories and `*Test.php` files
|
||||||
|
- ScheduleServiceProvider validates namespace (`App\`, `Core\`, `Mod\`) and frequency method against allowlists before executing
|
||||||
|
- Actions are placed in `app/Mod/{Module}/Actions/`
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- One action per file, named after what it does: `CreatePage`, `SendInvoice`
|
||||||
|
- Dependencies injected via constructor
|
||||||
|
- `handle()` is the single public method
|
||||||
|
- Scheduling state is DB-driven (enable/disable without code changes)
|
||||||
143
app/Core/Actions/ScheduleServiceProvider.php
Normal file
143
app/Core/Actions/ScheduleServiceProvider.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?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\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads scheduled_actions table and wires enabled actions into Laravel's scheduler.
|
||||||
|
*
|
||||||
|
* This provider runs in console context only. It queries the database for enabled
|
||||||
|
* scheduled actions and registers them with the Laravel Schedule facade.
|
||||||
|
*
|
||||||
|
* The scheduled_actions table is populated by the `schedule:sync` command,
|
||||||
|
* which discovers #[Scheduled] attributes on Action classes.
|
||||||
|
*/
|
||||||
|
class ScheduleServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Allowed namespace prefixes — prevents autoloading of classes from unexpected namespaces.
|
||||||
|
*/
|
||||||
|
private const ALLOWED_NAMESPACES = ['App\\', 'Core\\', 'Mod\\'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed frequency methods — prevents arbitrary method dispatch from DB strings.
|
||||||
|
*/
|
||||||
|
private const ALLOWED_FREQUENCIES = [
|
||||||
|
'everyMinute', 'everyTwoMinutes', 'everyThreeMinutes', 'everyFourMinutes',
|
||||||
|
'everyFiveMinutes', 'everyTenMinutes', 'everyFifteenMinutes', 'everyThirtyMinutes',
|
||||||
|
'hourly', 'hourlyAt', 'everyOddHour', 'everyTwoHours', 'everyThreeHours',
|
||||||
|
'everyFourHours', 'everySixHours',
|
||||||
|
'daily', 'dailyAt', 'twiceDaily', 'twiceDailyAt',
|
||||||
|
'weekly', 'weeklyOn',
|
||||||
|
'monthly', 'monthlyOn', 'twiceMonthly', 'lastDayOfMonth',
|
||||||
|
'quarterly', 'quarterlyOn',
|
||||||
|
'yearly', 'yearlyOn',
|
||||||
|
'cron',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if (! $this->app->runningInConsole()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against table not existing (pre-migration)
|
||||||
|
try {
|
||||||
|
if (! Schema::hasTable('scheduled_actions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// DB unreachable — skip gracefully so scheduler doesn't crash
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->app->booted(function () {
|
||||||
|
$this->registerScheduledActions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerScheduledActions(): void
|
||||||
|
{
|
||||||
|
$schedule = $this->app->make(Schedule::class);
|
||||||
|
$actions = ScheduledAction::enabled()->get();
|
||||||
|
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
try {
|
||||||
|
$class = $action->action_class;
|
||||||
|
|
||||||
|
// Validate namespace prefix against allowlist
|
||||||
|
$hasAllowedNamespace = false;
|
||||||
|
|
||||||
|
foreach (self::ALLOWED_NAMESPACES as $prefix) {
|
||||||
|
if (str_starts_with($class, $prefix)) {
|
||||||
|
$hasAllowedNamespace = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasAllowedNamespace) {
|
||||||
|
logger()->warning("Scheduled action {$class} has disallowed namespace — skipping");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists($class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the class uses the Action trait
|
||||||
|
if (! in_array(Action::class, class_uses_recursive($class), true)) {
|
||||||
|
logger()->warning("Scheduled action {$class} does not use the Action trait — skipping");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate frequency method against allowlist
|
||||||
|
$method = $action->frequencyMethod();
|
||||||
|
|
||||||
|
if (! in_array($method, self::ALLOWED_FREQUENCIES, true)) {
|
||||||
|
logger()->warning("Scheduled action {$class} has invalid frequency method: {$method}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = $schedule->call(function () use ($class, $action) {
|
||||||
|
$class::run();
|
||||||
|
$action->markRun();
|
||||||
|
})->name($class);
|
||||||
|
|
||||||
|
// Apply frequency
|
||||||
|
$args = $action->frequencyArgs();
|
||||||
|
$event->{$method}(...$args);
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
if ($action->without_overlapping) {
|
||||||
|
$event->withoutOverlapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action->run_in_background) {
|
||||||
|
$event->runInBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action->timezone) {
|
||||||
|
$event->timezone($action->timezone);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning("Failed to register scheduled action: {$action->action_class} — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Core/Actions/Scheduled.php
Normal file
46
app/Core/Actions/Scheduled.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?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\Actions;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an Action class for scheduled execution.
|
||||||
|
*
|
||||||
|
* The frequency string maps to Laravel Schedule methods:
|
||||||
|
* - 'everyMinute' → ->everyMinute()
|
||||||
|
* - 'dailyAt:09:00' → ->dailyAt('09:00')
|
||||||
|
* - 'weeklyOn:1,09:00' → ->weeklyOn(1, '09:00')
|
||||||
|
* - 'hourly' → ->hourly()
|
||||||
|
* - 'monthlyOn:1,00:00' → ->monthlyOn(1, '00:00')
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* #[Scheduled(frequency: 'dailyAt:09:00', timezone: 'Europe/London')]
|
||||||
|
* class PublishDigest
|
||||||
|
* {
|
||||||
|
* use Action;
|
||||||
|
* public function handle(): void { ... }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Discovered by ScheduledActionScanner, persisted to scheduled_actions table
|
||||||
|
* via `php artisan schedule:sync`, and executed by ScheduleServiceProvider.
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS)]
|
||||||
|
class Scheduled
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $frequency,
|
||||||
|
public ?string $timezone = null,
|
||||||
|
public bool $withoutOverlapping = true,
|
||||||
|
public bool $runInBackground = true,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
107
app/Core/Actions/ScheduledAction.php
Normal file
107
app/Core/Actions/ScheduledAction.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\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a scheduled action persisted in the database.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $action_class
|
||||||
|
* @property string $frequency
|
||||||
|
* @property string|null $timezone
|
||||||
|
* @property bool $without_overlapping
|
||||||
|
* @property bool $run_in_background
|
||||||
|
* @property bool $is_enabled
|
||||||
|
* @property Carbon|null $last_run_at
|
||||||
|
* @property Carbon|null $next_run_at
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class ScheduledAction extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'action_class',
|
||||||
|
'frequency',
|
||||||
|
'timezone',
|
||||||
|
'without_overlapping',
|
||||||
|
'run_in_background',
|
||||||
|
'is_enabled',
|
||||||
|
'last_run_at',
|
||||||
|
'next_run_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'without_overlapping' => 'boolean',
|
||||||
|
'run_in_background' => 'boolean',
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
|
'last_run_at' => 'datetime',
|
||||||
|
'next_run_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to only enabled actions.
|
||||||
|
*/
|
||||||
|
public function scopeEnabled(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the frequency string and return the method name.
|
||||||
|
*
|
||||||
|
* 'dailyAt:09:00' → 'dailyAt'
|
||||||
|
* 'everyMinute' → 'everyMinute'
|
||||||
|
*/
|
||||||
|
public function frequencyMethod(): string
|
||||||
|
{
|
||||||
|
return explode(':', $this->frequency, 2)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the frequency string and return the arguments.
|
||||||
|
*
|
||||||
|
* 'dailyAt:09:00' → ['09:00']
|
||||||
|
* 'weeklyOn:1,09:00' → [1, '09:00']
|
||||||
|
* 'everyMinute' → []
|
||||||
|
*
|
||||||
|
* Numeric strings are cast to integers so methods like weeklyOn()
|
||||||
|
* receive the correct types.
|
||||||
|
*/
|
||||||
|
public function frequencyArgs(): array
|
||||||
|
{
|
||||||
|
$parts = explode(':', $this->frequency, 2);
|
||||||
|
|
||||||
|
if (! isset($parts[1])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (string $arg) => ctype_digit($arg) ? (int) $arg : $arg,
|
||||||
|
explode(',', $parts[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that this action has just run.
|
||||||
|
*/
|
||||||
|
public function markRun(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_run_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Core/Actions/ScheduledActionScanner.php
Normal file
181
app/Core/Actions/ScheduledActionScanner.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?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\Actions;
|
||||||
|
|
||||||
|
use Core\ModuleScanner;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans directories for Action classes with the #[Scheduled] attribute.
|
||||||
|
*
|
||||||
|
* Unlike ModuleScanner (which scans Boot.php files), this scanner finds
|
||||||
|
* any PHP class with the #[Scheduled] attribute in the given directories.
|
||||||
|
*
|
||||||
|
* It uses PHP's native reflection to read attributes — no file parsing.
|
||||||
|
*
|
||||||
|
* @see Scheduled The attribute this scanner discovers
|
||||||
|
* @see ModuleScanner Similar pattern for Boot.php discovery
|
||||||
|
*/
|
||||||
|
class ScheduledActionScanner
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Scan directories for classes with #[Scheduled] attribute.
|
||||||
|
*
|
||||||
|
* @param array<string> $paths Directories to scan recursively
|
||||||
|
* @return array<class-string, Scheduled> Map of class name to attribute instance
|
||||||
|
*/
|
||||||
|
public function scan(array $paths): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (! is_dir($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->getExtension() !== 'php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip test directories — test files extend base classes
|
||||||
|
// that aren't available without dev dependencies.
|
||||||
|
// Convention: module test dirs use capital "Tests/" (e.g. app/Mod/Lem/Tests/).
|
||||||
|
if (preg_match('#[/\\\\]Tests[/\\\\]#', $file->getPathname())
|
||||||
|
|| str_ends_with($file->getBasename(), 'Test.php')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = file_get_contents($file->getPathname());
|
||||||
|
|
||||||
|
if ($contents === false || ! str_contains($contents, '#[Scheduled')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = $this->classFromFile($file->getPathname(), $contents);
|
||||||
|
|
||||||
|
if ($class === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (! class_exists($class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Class may reference unavailable dependencies (e.g. dev-only)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attribute = $this->extractScheduled($class);
|
||||||
|
|
||||||
|
if ($attribute !== null) {
|
||||||
|
$results[$class] = $attribute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the #[Scheduled] attribute from a class.
|
||||||
|
*/
|
||||||
|
private function extractScheduled(string $class): ?Scheduled
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionClass($class);
|
||||||
|
$attrs = $ref->getAttributes(Scheduled::class);
|
||||||
|
|
||||||
|
if (empty($attrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attrs[0]->newInstance();
|
||||||
|
} catch (\ReflectionException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive fully qualified class name from a PHP file.
|
||||||
|
*
|
||||||
|
* Reads the file's namespace declaration and class name.
|
||||||
|
*/
|
||||||
|
private function classFromFile(string $file, ?string $contents = null): ?string
|
||||||
|
{
|
||||||
|
$contents ??= file_get_contents($file);
|
||||||
|
|
||||||
|
if ($contents === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = token_get_all($contents);
|
||||||
|
$namespace = null;
|
||||||
|
$class = null;
|
||||||
|
|
||||||
|
$count = count($tokens);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$token = $tokens[$i];
|
||||||
|
|
||||||
|
if (! is_array($token)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token[0] === T_NAMESPACE) {
|
||||||
|
$namespaceParts = [];
|
||||||
|
|
||||||
|
for ($j = $i + 1; $j < $count; $j++) {
|
||||||
|
$t = $tokens[$j];
|
||||||
|
|
||||||
|
if (is_array($t) && in_array($t[0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) {
|
||||||
|
$namespaceParts[] = $t[1];
|
||||||
|
} elseif ($t === ';' || $t === '{') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$namespace = implode('', $namespaceParts) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token[0] === T_CLASS) {
|
||||||
|
for ($j = $i + 1; $j < $count; $j++) {
|
||||||
|
$t = $tokens[$j];
|
||||||
|
|
||||||
|
if (is_array($t) && $t[0] === T_WHITESPACE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_array($t) && $t[0] === T_STRING) {
|
||||||
|
$class = $t[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($class === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $namespace !== null ? "{$namespace}\\{$class}" : $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Core/Activity/Boot.php
Normal file
79
app/Core/Activity/Boot.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?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 (only if Livewire is available)
|
||||||
|
if (app()->bound('livewire')) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Core/Activity/CLAUDE.md
Normal file
48
app/Core/Activity/CLAUDE.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Activity
|
||||||
|
|
||||||
|
Workspace-aware activity logging built on `spatie/laravel-activitylog`.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Wraps Spatie's activity log with automatic `workspace_id` tagging, a fluent query service, a Livewire feed component for the admin panel, and a prune command for retention management.
|
||||||
|
|
||||||
|
## Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Boot` | Registers console commands, Livewire component, and service binding via lifecycle events |
|
||||||
|
| `Activity` (model) | Extends Spatie's model with `ActivityScopes` trait. Adds `workspace_id`, `old_values`, `new_values`, `changes`, `causer_name`, `subject_name` accessors |
|
||||||
|
| `ActivityLogService` | Fluent query builder: `logFor($model)`, `logBy($user)`, `forWorkspace($ws)`, `ofType('updated')`, `search('term')`, `paginate()`, `statistics()`, `timeline()`, `prune()` |
|
||||||
|
| `LogsActivity` (trait) | Drop-in trait for models. Auto-logs dirty attributes, auto-tags `workspace_id` from model or request context, generates human descriptions |
|
||||||
|
| `ActivityScopes` (trait) | 20+ Eloquent scopes: `forWorkspace`, `forSubject`, `byCauser`, `ofType`, `betweenDates`, `today`, `lastDays`, `search`, `withChanges`, `withExistingSubject` |
|
||||||
|
| `ActivityPruneCommand` | `php artisan activity:prune [--days=N] [--dry-run]` |
|
||||||
|
| `ActivityFeed` (Livewire) | `<livewire:core.activity-feed :workspace-id="$id" />` with filters, search, pagination, detail modal |
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Make a model log activity
|
||||||
|
class Post extends Model {
|
||||||
|
use LogsActivity;
|
||||||
|
protected array $activityLogAttributes = ['title', 'status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query activities
|
||||||
|
$service = app(ActivityLogService::class);
|
||||||
|
$service->logFor($post)->lastDays(7)->paginate();
|
||||||
|
$service->forWorkspace($workspace)->ofType('deleted')->recent(10);
|
||||||
|
$service->statistics($workspace); // => [total, by_event, by_subject, by_user]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
- Listens to `ConsoleBooting` and `AdminPanelBooting` lifecycle events
|
||||||
|
- `LogsActivity` trait auto-detects workspace from model's `workspace_id` attribute, request `workspace_model` attribute, or authenticated user's `defaultHostWorkspace()`
|
||||||
|
- Config: `core.activity.enabled`, `core.activity.retention_days` (default 90), `core.activity.log_name`
|
||||||
|
- Override activity model in `config/activitylog.php`: `'activity_model' => Activity::class`
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- `LogsActivity::withoutActivityLogging(fn() => ...)` to suppress logging during bulk operations
|
||||||
|
- Models can implement `customizeActivity($activity, $event)` for custom property injection
|
||||||
|
- Config properties on model: `$activityLogAttributes`, `$activityLogName`, `$activityLogEvents`, `$activityLogWorkspace`, `$activityLogOnlyDirty`
|
||||||
17
app/Core/Activity/Concerns/CLAUDE.md
Normal file
17
app/Core/Activity/Concerns/CLAUDE.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Activity/Concerns/ — Activity Logging Trait
|
||||||
|
|
||||||
|
## Traits
|
||||||
|
|
||||||
|
| Trait | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `LogsActivity` | Drop-in trait for models that should log changes. Wraps `spatie/laravel-activitylog` with sensible defaults: auto workspace_id tagging, dirty-only logging, empty log suppression. |
|
||||||
|
|
||||||
|
## Configuration via Model Properties
|
||||||
|
|
||||||
|
- `$activityLogAttributes` — array of attributes to log (default: all dirty)
|
||||||
|
- `$activityLogName` — custom log name
|
||||||
|
- `$activityLogEvents` — events to log (default: created, updated, deleted)
|
||||||
|
- `$activityLogWorkspace` — include workspace_id (default: true)
|
||||||
|
- `$activityLogOnlyDirty` — only log changed attributes (default: true)
|
||||||
|
|
||||||
|
Static helpers: `activityLoggingEnabled()`, `withoutActivityLogging(callable)`.
|
||||||
229
app/Core/Activity/Concerns/LogsActivity.php
Normal file
229
app/Core/Activity/Concerns/LogsActivity.php
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
<?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\Contracts\Activity;
|
||||||
|
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(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
|
||||||
|
{
|
||||||
|
return activity()->withoutLogs($callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Core/Activity/Console/ActivityPruneCommand.php
Normal file
66
app/Core/Activity/Console/ActivityPruneCommand.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?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;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Core/Activity/Console/CLAUDE.md
Normal file
9
app/Core/Activity/Console/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Activity/Console/ — Activity Log Commands
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Signature | Purpose |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| `ActivityPruneCommand` | `activity:prune` | Prunes old activity logs. Options: `--days=N` (retention period), `--dry-run` (show count without deleting). Uses retention from config when days not specified. |
|
||||||
|
|
||||||
|
Part of the Activity subsystem's maintenance tooling. Should be scheduled in the application's console kernel for regular cleanup.
|
||||||
204
app/Core/Activity/Models/Activity.php
Normal file
204
app/Core/Activity/Models/Activity.php
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
<?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 Illuminate\Support\Collection;
|
||||||
|
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 Collection<string, array{old: mixed, new: mixed}>
|
||||||
|
*/
|
||||||
|
public function getChangesAttribute(): Collection
|
||||||
|
{
|
||||||
|
$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 collect($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 ($changes->isEmpty()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Core/Activity/Models/CLAUDE.md
Normal file
14
app/Core/Activity/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Activity/Models/ — Activity Log Model
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
| Model | Extends | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `Activity` | `Spatie\Activitylog\Models\Activity` | Extended activity model with workspace-aware scopes via the `ActivityScopes` trait. Adds query scopes for filtering by workspace, subject, causer, event type, date range, and search. |
|
||||||
|
|
||||||
|
Configure as the activity model in `config/activitylog.php`:
|
||||||
|
```php
|
||||||
|
'activity_model' => \Core\Activity\Models\Activity::class,
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `spatie/laravel-activitylog`.
|
||||||
262
app/Core/Activity/Scopes/ActivityScopes.php
Normal file
262
app/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');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Core/Activity/Scopes/CLAUDE.md
Normal file
9
app/Core/Activity/Scopes/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Activity/Scopes/ — Activity Query Scopes
|
||||||
|
|
||||||
|
## Traits
|
||||||
|
|
||||||
|
| Trait | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `ActivityScopes` | Comprehensive query scopes for activity log filtering. Includes: `forWorkspace`, `forSubject`, `forSubjectType`, `byCauser`, `byCauserId`, `ofType`, `createdEvents`, `updatedEvents`, `deletedEvents`, `betweenDates`, `today`, `lastDays`, `lastHours`, `search`, `inLog`, `withChanges`, `withExistingSubject`, `withDeletedSubject`, `newest`, `oldest`. |
|
||||||
|
|
||||||
|
Used by `Core\Activity\Models\Activity`. Workspace scoping checks both `properties->workspace_id` and subject model's `workspace_id`. Requires `spatie/laravel-activitylog`.
|
||||||
448
app/Core/Activity/Services/ActivityLogService.php
Normal file
448
app/Core/Activity/Services/ActivityLogService.php
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Core/Activity/Services/CLAUDE.md
Normal file
11
app/Core/Activity/Services/CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Activity/Services/ — Activity Log Service
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `ActivityLogService` | Fluent interface for querying and managing activity logs. Methods: `logFor($model)`, `logBy($user)`, `forWorkspace($workspace)`, `recent()`, `search($term)`. Chainable query builder with workspace awareness. |
|
||||||
|
|
||||||
|
Provides the business logic layer over Spatie's activity log. Used by the `ActivityFeed` Livewire component and available for injection throughout the application.
|
||||||
|
|
||||||
|
Requires `spatie/laravel-activitylog`.
|
||||||
9
app/Core/Activity/View/Blade/admin/CLAUDE.md
Normal file
9
app/Core/Activity/View/Blade/admin/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Activity/View/Blade/admin/ — Activity Feed Blade Template
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `activity-feed.blade.php` | Admin panel activity log display — paginated list with filters (user, model type, event type, date range), activity detail modal with full diff view, optional polling for real-time updates. |
|
||||||
|
|
||||||
|
Rendered by the `ActivityFeed` Livewire component via the `core.activity::admin.*` view namespace.
|
||||||
322
app/Core/Activity/View/Blade/admin/activity-feed.blade.php
Normal file
322
app/Core/Activity/View/Blade/admin/activity-feed.blade.php
Normal file
|
|
@ -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>
|
||||||
370
app/Core/Activity/View/Modal/Admin/ActivityFeed.php
Normal file
370
app/Core/Activity/View/Modal/Admin/ActivityFeed.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\Activity\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Core\Activity\Services\ActivityLogService;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
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(): View
|
||||||
|
{
|
||||||
|
return view('core.activity::admin.activity-feed');
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Core/Activity/View/Modal/Admin/CLAUDE.md
Normal file
11
app/Core/Activity/View/Modal/Admin/CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Activity/View/Modal/Admin/ — Activity Feed Livewire Component
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `ActivityFeed` | Livewire component for displaying activity logs in the admin panel. Paginated list with URL-bound filters (causer, subject type, event type, date range, search). Supports workspace scoping and optional polling for real-time updates. |
|
||||||
|
|
||||||
|
Usage: `<livewire:core.activity-feed />` or `<livewire:core.activity-feed :workspace-id="$workspace->id" poll="10s" />`
|
||||||
|
|
||||||
|
Requires `spatie/laravel-activitylog`.
|
||||||
94
app/Core/Boot.php
Normal file
94
app/Core/Boot.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application bootstrap - configures Laravel with Core framework patterns.
|
||||||
|
*
|
||||||
|
* Consuming apps use Core\Init::handle() as their entry point.
|
||||||
|
* This class configures providers, middleware, and exception handling.
|
||||||
|
*
|
||||||
|
* Provider loading order matters:
|
||||||
|
* 1. LifecycleEventProvider - wires lazy module listeners
|
||||||
|
* 2. Mod\Boot - domain-scoped event listeners (before frontages)
|
||||||
|
* 3. Front\Boot - fires lifecycle events that modules respond to
|
||||||
|
*/
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Service providers loaded by the framework.
|
||||||
|
*
|
||||||
|
* Consuming apps can extend this by creating their own Boot class
|
||||||
|
* that merges additional providers.
|
||||||
|
*/
|
||||||
|
public static array $providers = [
|
||||||
|
// Lifecycle events - must load first to wire lazy listeners
|
||||||
|
LifecycleEventProvider::class,
|
||||||
|
|
||||||
|
// Websites - domain-scoped, must wire before frontages fire events
|
||||||
|
Website\Boot::class,
|
||||||
|
|
||||||
|
// Core frontages - fire lifecycle events
|
||||||
|
Front\Boot::class,
|
||||||
|
|
||||||
|
// Base modules (from core-php package)
|
||||||
|
Mod\Boot::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and configure the application.
|
||||||
|
*/
|
||||||
|
public static function app(): Application
|
||||||
|
{
|
||||||
|
return Application::configure(basePath: static::basePath())
|
||||||
|
->withProviders(static::$providers)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
// Session middleware priority
|
||||||
|
$middleware->priority([
|
||||||
|
StartSession::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$middleware->redirectGuestsTo('/login');
|
||||||
|
$middleware->redirectUsersTo('/hub');
|
||||||
|
|
||||||
|
// Front module configures middleware groups (web, admin, api, mcp)
|
||||||
|
Front\Boot::middleware($middleware);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
// Clean exception handling for open-source
|
||||||
|
// Apps can add Sentry, custom error pages, etc.
|
||||||
|
})->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application base path.
|
||||||
|
*
|
||||||
|
* Works whether Core is in vendor/ or packages/ (monorepo).
|
||||||
|
*/
|
||||||
|
protected static function basePath(): string
|
||||||
|
{
|
||||||
|
// Check for monorepo structure (packages/core-php/src/Core/Boot.php)
|
||||||
|
// The monorepo root has app/ directory while the package root doesn't
|
||||||
|
$monorepoBase = dirname(__DIR__, 4);
|
||||||
|
if (file_exists($monorepoBase.'/composer.json') && is_dir($monorepoBase.'/app')) {
|
||||||
|
return $monorepoBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard vendor structure (vendor/*/core/src/Core/Boot.php)
|
||||||
|
return dirname(__DIR__, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
347
app/Core/Bouncer/BlocklistService.php
Normal file
347
app/Core/Bouncer/BlocklistService.php
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages IP blocklist with Redis caching.
|
||||||
|
*
|
||||||
|
* Blocklist is populated from:
|
||||||
|
* - Honeypot critical hits (/admin probing) - requires human review
|
||||||
|
* - Manual entries - immediately active
|
||||||
|
*
|
||||||
|
* Uses a Bloom filter-style approach: cache the blocklist as a set
|
||||||
|
* for O(1) lookups, rebuild periodically from database.
|
||||||
|
*
|
||||||
|
* ## Blocking Statuses
|
||||||
|
*
|
||||||
|
* | Status | Description |
|
||||||
|
* |--------|-------------|
|
||||||
|
* | `pending` | From honeypot, awaiting human review |
|
||||||
|
* | `approved` | Active block (manual or reviewed) |
|
||||||
|
* | `rejected` | Reviewed and rejected (not blocked) |
|
||||||
|
*
|
||||||
|
* ## Honeypot Integration
|
||||||
|
*
|
||||||
|
* When `auto_block_critical` is enabled (default), IPs hitting critical
|
||||||
|
* honeypot paths are immediately blocked. Otherwise, they're added with
|
||||||
|
* 'pending' status for human review.
|
||||||
|
*
|
||||||
|
* ### Syncing from Honeypot
|
||||||
|
*
|
||||||
|
* Call `syncFromHoneypot()` from a scheduled job to create pending entries
|
||||||
|
* for critical hits from the last 24 hours:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* // In app/Console/Kernel.php
|
||||||
|
* $schedule->call(function () {
|
||||||
|
* app(BlocklistService::class)->syncFromHoneypot();
|
||||||
|
* })->hourly();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Reviewing Pending Blocks
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $blocklist = app(BlocklistService::class);
|
||||||
|
*
|
||||||
|
* // Get all pending entries (paginated for large blocklists)
|
||||||
|
* $pending = $blocklist->getPending(perPage: 50);
|
||||||
|
*
|
||||||
|
* // Approve a block
|
||||||
|
* $blocklist->approve('192.168.1.100');
|
||||||
|
*
|
||||||
|
* // Reject a block (IP will not be blocked)
|
||||||
|
* $blocklist->reject('192.168.1.100');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Cache Behaviour
|
||||||
|
*
|
||||||
|
* - Blocklist is cached for 5 minutes (CACHE_TTL constant)
|
||||||
|
* - Only 'approved' entries with valid expiry are included in cache
|
||||||
|
* - Cache is automatically cleared on block/unblock/approve operations
|
||||||
|
* - Use `clearCache()` to force cache refresh
|
||||||
|
*
|
||||||
|
* ## Manual Blocking
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $blocklist = app(BlocklistService::class);
|
||||||
|
*
|
||||||
|
* // Block an IP immediately (approved status)
|
||||||
|
* $blocklist->block('192.168.1.100', 'spam', BlocklistService::STATUS_APPROVED);
|
||||||
|
*
|
||||||
|
* // Unblock an IP
|
||||||
|
* $blocklist->unblock('192.168.1.100');
|
||||||
|
*
|
||||||
|
* // Check if IP is blocked
|
||||||
|
* if ($blocklist->isBlocked('192.168.1.100')) {
|
||||||
|
* // IP is actively blocked
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see Boot For honeypot configuration options
|
||||||
|
* @see BouncerMiddleware For the blocking middleware
|
||||||
|
*/
|
||||||
|
class BlocklistService
|
||||||
|
{
|
||||||
|
protected const CACHE_KEY = 'bouncer:blocklist';
|
||||||
|
|
||||||
|
protected const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
protected const DEFAULT_PER_PAGE = 50;
|
||||||
|
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_APPROVED = 'approved';
|
||||||
|
|
||||||
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP is blocked.
|
||||||
|
*/
|
||||||
|
public function isBlocked(string $ip): bool
|
||||||
|
{
|
||||||
|
$blocklist = $this->getBlocklist();
|
||||||
|
|
||||||
|
return isset($blocklist[$ip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add IP to blocklist (immediately approved for manual blocks).
|
||||||
|
*/
|
||||||
|
public function block(string $ip, string $reason = 'manual', string $status = self::STATUS_APPROVED): void
|
||||||
|
{
|
||||||
|
DB::table('blocked_ips')->updateOrInsert(
|
||||||
|
['ip_address' => $ip],
|
||||||
|
[
|
||||||
|
'reason' => $reason,
|
||||||
|
'status' => $status,
|
||||||
|
'blocked_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove IP from blocklist.
|
||||||
|
*/
|
||||||
|
public function unblock(string $ip): void
|
||||||
|
{
|
||||||
|
DB::table('blocked_ips')->where('ip_address', $ip)->delete();
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array {
|
||||||
|
if (! $this->tableExists()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('blocked_ips')
|
||||||
|
->where('status', self::STATUS_APPROVED)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->pluck('reason', 'ip_address')
|
||||||
|
->toArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
protected function tableExists(): bool
|
||||||
|
{
|
||||||
|
return Cache::remember('bouncer:blocked_ips_table_exists', 3600, function (): bool {
|
||||||
|
return DB::getSchemaBuilder()->hasTable('blocked_ips');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync blocklist from honeypot critical hits.
|
||||||
|
*
|
||||||
|
* Creates entries in 'pending' status for human review.
|
||||||
|
* Call this from a scheduled job or after honeypot hits.
|
||||||
|
*/
|
||||||
|
public function syncFromHoneypot(): int
|
||||||
|
{
|
||||||
|
if (! DB::getSchemaBuilder()->hasTable('honeypot_hits')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$criticalIps = DB::table('honeypot_hits')
|
||||||
|
->where('severity', 'critical')
|
||||||
|
->where('created_at', '>=', now()->subDay())
|
||||||
|
->distinct()
|
||||||
|
->pluck('ip_address');
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($criticalIps as $ip) {
|
||||||
|
$exists = DB::table('blocked_ips')
|
||||||
|
->where('ip_address', $ip)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
DB::table('blocked_ips')->insert([
|
||||||
|
'ip_address' => $ip,
|
||||||
|
'reason' => 'honeypot_critical',
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'blocked_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(?int $perPage = null): array|LengthAwarePaginator
|
||||||
|
{
|
||||||
|
if (! $this->tableExists()) {
|
||||||
|
return $perPage === null
|
||||||
|
? []
|
||||||
|
: new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = DB::table('blocked_ips')
|
||||||
|
->where('status', self::STATUS_PENDING)
|
||||||
|
->orderBy('blocked_at', 'desc');
|
||||||
|
|
||||||
|
if ($perPage === null) {
|
||||||
|
return $query->get()->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a pending block entry.
|
||||||
|
*/
|
||||||
|
public function approve(string $ip): bool
|
||||||
|
{
|
||||||
|
$updated = DB::table('blocked_ips')
|
||||||
|
->where('ip_address', $ip)
|
||||||
|
->where('status', self::STATUS_PENDING)
|
||||||
|
->update(['status' => self::STATUS_APPROVED]);
|
||||||
|
|
||||||
|
if ($updated > 0) {
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a pending block entry.
|
||||||
|
*/
|
||||||
|
public function reject(string $ip): bool
|
||||||
|
{
|
||||||
|
$updated = DB::table('blocked_ips')
|
||||||
|
->where('ip_address', $ip)
|
||||||
|
->where('status', self::STATUS_PENDING)
|
||||||
|
->update(['status' => self::STATUS_REJECTED]);
|
||||||
|
|
||||||
|
return $updated > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache.
|
||||||
|
*/
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget(self::CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stats for dashboard.
|
||||||
|
*/
|
||||||
|
public function getStats(): array
|
||||||
|
{
|
||||||
|
if (! $this->tableExists()) {
|
||||||
|
return [
|
||||||
|
'total_blocked' => 0,
|
||||||
|
'active_blocked' => 0,
|
||||||
|
'pending_review' => 0,
|
||||||
|
'by_reason' => [],
|
||||||
|
'by_status' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_blocked' => DB::table('blocked_ips')->count(),
|
||||||
|
'active_blocked' => DB::table('blocked_ips')
|
||||||
|
->where('status', self::STATUS_APPROVED)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->count(),
|
||||||
|
'pending_review' => DB::table('blocked_ips')
|
||||||
|
->where('status', self::STATUS_PENDING)
|
||||||
|
->count(),
|
||||||
|
'by_reason' => DB::table('blocked_ips')
|
||||||
|
->selectRaw('reason, COUNT(*) as count')
|
||||||
|
->groupBy('reason')
|
||||||
|
->pluck('count', 'reason')
|
||||||
|
->toArray(),
|
||||||
|
'by_status' => DB::table('blocked_ips')
|
||||||
|
->selectRaw('status, COUNT(*) as count')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('count', 'status')
|
||||||
|
->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Core/Bouncer/Boot.php
Normal file
101
app/Core/Bouncer/Boot.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core Bouncer - Early-exit middleware for security and SEO.
|
||||||
|
*
|
||||||
|
* Two responsibilities:
|
||||||
|
* 1. Block bad actors (honeypot critical hits) before wasting CPU
|
||||||
|
* 2. Handle SEO redirects before Laravel routing
|
||||||
|
*
|
||||||
|
* ## Honeypot Configuration
|
||||||
|
*
|
||||||
|
* The honeypot system traps bots that ignore robots.txt by monitoring
|
||||||
|
* paths listed as disallowed. Configure via `config/core.php` under
|
||||||
|
* the `bouncer.honeypot` key:
|
||||||
|
*
|
||||||
|
* ### Configuration Options
|
||||||
|
*
|
||||||
|
* | Option | Environment Variable | Default | Description |
|
||||||
|
* |--------|---------------------|---------|-------------|
|
||||||
|
* | `auto_block_critical` | `CORE_BOUNCER_HONEYPOT_AUTO_BLOCK` | `true` | Auto-block IPs hitting critical paths like /admin or /.env |
|
||||||
|
* | `rate_limit_max` | `CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX` | `10` | Max honeypot log entries per IP within the time window |
|
||||||
|
* | `rate_limit_window` | `CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW` | `60` | Rate limit window in seconds (default: 1 minute) |
|
||||||
|
* | `severity_levels.critical` | `CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL` | `'critical'` | Label for critical severity hits |
|
||||||
|
* | `severity_levels.warning` | `CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING` | `'warning'` | Label for warning severity hits |
|
||||||
|
* | `critical_paths` | N/A | See below | Paths that trigger critical severity |
|
||||||
|
*
|
||||||
|
* ### Default Critical Paths
|
||||||
|
*
|
||||||
|
* These paths indicate malicious probing and trigger 'critical' severity:
|
||||||
|
* - `admin` - Admin panel probing
|
||||||
|
* - `wp-admin` - WordPress admin probing
|
||||||
|
* - `wp-login.php` - WordPress login probing
|
||||||
|
* - `administrator` - Joomla admin probing
|
||||||
|
* - `phpmyadmin` - Database admin probing
|
||||||
|
* - `.env` - Environment file probing
|
||||||
|
* - `.git` - Git repository probing
|
||||||
|
*
|
||||||
|
* ### Customizing Critical Paths
|
||||||
|
*
|
||||||
|
* Override in your `config/core.php`:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* 'bouncer' => [
|
||||||
|
* 'honeypot' => [
|
||||||
|
* 'critical_paths' => [
|
||||||
|
* 'admin',
|
||||||
|
* 'wp-admin',
|
||||||
|
* '.env',
|
||||||
|
* '.git',
|
||||||
|
* 'backup', // Add custom paths
|
||||||
|
* 'config.php',
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Blocking Workflow
|
||||||
|
*
|
||||||
|
* 1. Bot hits a honeypot path (e.g., /admin)
|
||||||
|
* 2. Path is checked against `critical_paths` (prefix matching)
|
||||||
|
* 3. If critical and `auto_block_critical` is true, IP is blocked immediately
|
||||||
|
* 4. Otherwise, entry is added to `honeypot_hits` with 'pending' status
|
||||||
|
* 5. Admin reviews pending entries via `BlocklistService::getPending()`
|
||||||
|
* 6. Admin approves or rejects via `approve($ip)` or `reject($ip)`
|
||||||
|
*
|
||||||
|
* ### Rate Limiting
|
||||||
|
*
|
||||||
|
* To prevent DoS via log flooding, honeypot logging is rate-limited:
|
||||||
|
* - Default: 10 entries per IP per minute
|
||||||
|
* - Exceeded entries are silently dropped
|
||||||
|
* - Rate limit uses Laravel's RateLimiter facade
|
||||||
|
*
|
||||||
|
* @see BlocklistService For IP blocking functionality
|
||||||
|
* @see BouncerMiddleware For the early-exit middleware
|
||||||
|
*/
|
||||||
|
class Boot extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->singleton(BlocklistService::class);
|
||||||
|
$this->app->singleton(RedirectService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadMigrationsFrom(__DIR__.'/Migrations');
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Core/Bouncer/BouncerMiddleware.php
Normal file
91
app/Core/Bouncer/BouncerMiddleware.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Early-exit middleware for blocking bad actors and handling redirects.
|
||||||
|
*
|
||||||
|
* Runs FIRST before any other middleware to:
|
||||||
|
* 1. Set trusted proxies (so we get real client IP)
|
||||||
|
* 2. Block bad actors (honeypot critical hits)
|
||||||
|
* 3. Handle SEO redirects (301/302)
|
||||||
|
*
|
||||||
|
* This replaces Laravel's TrustProxies middleware - all early-exit logic in one place.
|
||||||
|
*/
|
||||||
|
class BouncerMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected BlocklistService $blocklist,
|
||||||
|
protected RedirectService $redirects,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Trust proxies first - need real client IP for everything else
|
||||||
|
$this->setTrustedProxies($request);
|
||||||
|
|
||||||
|
$ip = $request->ip();
|
||||||
|
$path = $request->path();
|
||||||
|
|
||||||
|
// Check blocklist - fastest rejection
|
||||||
|
if ($this->blocklist->isBlocked($ip)) {
|
||||||
|
return $this->blockedResponse($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check SEO redirects
|
||||||
|
if ($redirect = $this->redirects->match($path)) {
|
||||||
|
return redirect($redirect['to'], $redirect['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure trusted proxies for correct client IP detection.
|
||||||
|
*
|
||||||
|
* TRUSTED_PROXIES env var: comma-separated IPs or '*' for all.
|
||||||
|
* Production: set to load balancer IPs (e.g., hermes.lb.host.uk.com)
|
||||||
|
* Development: defaults to '*' (trust all)
|
||||||
|
*/
|
||||||
|
protected function setTrustedProxies(Request $request): void
|
||||||
|
{
|
||||||
|
$trustedProxies = env('TRUSTED_PROXIES', '*');
|
||||||
|
|
||||||
|
$proxies = $trustedProxies === '*'
|
||||||
|
? $request->server->get('REMOTE_ADDR') // Trust the immediate proxy
|
||||||
|
: explode(',', $trustedProxies);
|
||||||
|
|
||||||
|
$request->setTrustedProxies(
|
||||||
|
is_array($proxies) ? $proxies : [$proxies],
|
||||||
|
Request::HEADER_X_FORWARDED_FOR |
|
||||||
|
Request::HEADER_X_FORWARDED_HOST |
|
||||||
|
Request::HEADER_X_FORWARDED_PORT |
|
||||||
|
Request::HEADER_X_FORWARDED_PROTO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for blocked IPs - minimal processing.
|
||||||
|
*/
|
||||||
|
protected function blockedResponse(string $ip): Response
|
||||||
|
{
|
||||||
|
return response('🫖', 418, [
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
'X-Blocked' => 'true',
|
||||||
|
'X-Powered-By' => 'Earl Grey',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Core/Bouncer/CLAUDE.md
Normal file
61
app/Core/Bouncer/CLAUDE.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Bouncer
|
||||||
|
|
||||||
|
Early-exit security middleware + whitelist-based action authorisation gate.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Two subsystems in one:
|
||||||
|
|
||||||
|
1. **Bouncer** (top-level): IP blocklist + SEO redirects, runs before all other middleware
|
||||||
|
2. **Gate** (subdirectory): Whitelist-based controller action authorisation with training mode
|
||||||
|
|
||||||
|
## Bouncer (IP Blocking + Redirects)
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Boot` | ServiceProvider registering `BlocklistService`, `RedirectService`, and migrations |
|
||||||
|
| `BouncerMiddleware` | Early-exit middleware: sets trusted proxies, checks blocklist (O(1) via cached set), handles SEO redirects, then passes through |
|
||||||
|
| `BlocklistService` | IP blocking with Redis-cached lookup. Statuses: `pending` (honeypot, needs review), `approved` (active block), `rejected` (reviewed, not blocked). Methods: `isBlocked()`, `block()`, `unblock()`, `syncFromHoneypot()`, `approve()`, `reject()`, `getPending()`, `getStats()` |
|
||||||
|
| `RedirectService` | Cached SEO redirects from `seo_redirects` table. Supports exact match and wildcard (`path/*`). Methods: `match()`, `add()`, `remove()` |
|
||||||
|
|
||||||
|
### Hidden Ideas
|
||||||
|
|
||||||
|
- Blocked IPs get `418 I'm a teapot` with `X-Powered-By: Earl Grey`
|
||||||
|
- Honeypot monitors paths from `robots.txt` disallow list; critical paths (`/admin`, `/.env`, `/wp-admin`) trigger auto-block
|
||||||
|
- Rate-limited honeypot logging prevents DoS via log flooding
|
||||||
|
- `TRUSTED_PROXIES` env var: comma-separated IPs or `*` (trust all)
|
||||||
|
|
||||||
|
## Gate (Action Whitelist)
|
||||||
|
|
||||||
|
Philosophy: **"If it wasn't trained, it doesn't exist."**
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Gate\Boot` | ServiceProvider registering middleware, migrations, route macros, and training routes |
|
||||||
|
| `ActionGateService` | Resolves action name from route (3-level priority), checks against `ActionPermission` table, logs to `ActionRequest`. Methods: `check()`, `allow()`, `deny()`, `resolveAction()` |
|
||||||
|
| `ActionGateMiddleware` | Enforces gate: allowed = pass, denied = 403, training = approval prompt (JSON for API, redirect for web) |
|
||||||
|
| `Action` (attribute) | `#[Action('product.create', scope: 'product')]` on controller methods |
|
||||||
|
| `ActionPermission` (model) | Whitelist record: action + guard + role + scope. Methods: `isAllowed()`, `train()`, `revoke()`, `allowedFor()` |
|
||||||
|
| `ActionRequest` (model) | Audit log of all permission checks. Methods: `log()`, `pending()`, `deniedActionsSummary()`, `prune()` |
|
||||||
|
| `RouteActionMacro` | Adds `->action('name')`, `->bypassGate()`, `->requiresTraining()` to Route |
|
||||||
|
|
||||||
|
### Action Resolution Priority
|
||||||
|
|
||||||
|
1. Route action: `Route::post(...)->action('product.create')`
|
||||||
|
2. Controller attribute: `#[Action('product.create')]`
|
||||||
|
3. Auto-resolved: `ProductController@store` becomes `product.store`
|
||||||
|
|
||||||
|
### Training Mode
|
||||||
|
|
||||||
|
When `core.bouncer.training_mode = true`, unknown actions prompt for approval instead of blocking. Training routes at `/_bouncer/approve` and `/_bouncer/pending`.
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
- BouncerMiddleware runs FIRST in the stack (replaces Laravel TrustProxies)
|
||||||
|
- ActionGateMiddleware appends to `web`, `admin`, `api`, `client` groups
|
||||||
|
- Config: `core.bouncer.enabled`, `core.bouncer.training_mode`, `core.bouncer.guarded_middleware`
|
||||||
|
- DB tables: `blocked_ips`, `seo_redirects`, `honeypot_hits`, `core_action_permissions`, `core_action_requests`
|
||||||
7
app/Core/Bouncer/Database/Seeders/CLAUDE.md
Normal file
7
app/Core/Bouncer/Database/Seeders/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Bouncer/Database/Seeders/ — Bouncer Seeders
|
||||||
|
|
||||||
|
## Seeders
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `WebsiteRedirectSeeder.php` | Seeds 301 redirects for renamed website URLs. Uses the `RedirectService` to register old-to-new path mappings (e.g., `/services/biohost` -> `/services/bio`). Added during URL simplification (2026-01-16). |
|
||||||
54
app/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php
Normal file
54
app/Core/Bouncer/Database/Seeders/WebsiteRedirectSeeder.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?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\Database\Seeders;
|
||||||
|
|
||||||
|
use Core\Bouncer\RedirectService;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed 301 redirects for renamed website URLs.
|
||||||
|
*
|
||||||
|
* URL simplification: removed "host" suffix from service pages.
|
||||||
|
* Added 2026-01-16.
|
||||||
|
*/
|
||||||
|
class WebsiteRedirectSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* URL redirects: old path => new path.
|
||||||
|
*/
|
||||||
|
protected array $redirects = [
|
||||||
|
// Service pages
|
||||||
|
'/services/biohost' => '/services/bio',
|
||||||
|
'/services/socialhost' => '/services/social',
|
||||||
|
'/services/trusthost' => '/services/trust',
|
||||||
|
'/services/mailhost' => '/services/mail',
|
||||||
|
'/services/analyticshost' => '/services/analytics',
|
||||||
|
'/services/notifyhost' => '/services/notify',
|
||||||
|
|
||||||
|
// MCP documentation
|
||||||
|
'/developers/mcp/biohost' => '/developers/mcp/bio',
|
||||||
|
'/developers/mcp/socialhost' => '/developers/mcp/social',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected RedirectService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
foreach ($this->redirects as $from => $to) {
|
||||||
|
$this->service->add($from, $to, 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command?->info('Seeded '.count($this->redirects).' website redirects.');
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/Core/Bouncer/Gate/ActionGateMiddleware.php
Normal file
155
app/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
app/Core/Bouncer/Gate/ActionGateService.php
Normal file
370
app/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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Core/Bouncer/Gate/Attributes/Action.php
Normal file
63
app/Core/Bouncer/Gate/Attributes/Action.php
Normal file
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
14
app/Core/Bouncer/Gate/Attributes/CLAUDE.md
Normal file
14
app/Core/Bouncer/Gate/Attributes/CLAUDE.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Bouncer/Gate/Attributes/ — Action Gate PHP Attributes
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
| Attribute | Target | Purpose |
|
||||||
|
|-----------|--------|---------|
|
||||||
|
| `#[Action(name, scope?)]` | Method, Class | Declares an explicit action name for permission checking, overriding auto-resolution from controller/method names. Optional `scope` for resource-specific permissions. |
|
||||||
|
|
||||||
|
Without this attribute, action names are auto-resolved: `ProductController@store` becomes `product.store`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Action('product.create')]
|
||||||
|
public function store(Request $request) { ... }
|
||||||
|
```
|
||||||
150
app/Core/Bouncer/Gate/Boot.php
Normal file
150
app/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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Core/Bouncer/Gate/CLAUDE.md
Normal file
18
app/Core/Bouncer/Gate/CLAUDE.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Bouncer/Gate/ — Action Gate Authorisation
|
||||||
|
|
||||||
|
Whitelist-based request authorisation system. Philosophy: "If it wasn't trained, it doesn't exist."
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Boot.php` | ServiceProvider — registers middleware, configures action gate. |
|
||||||
|
| `ActionGateMiddleware.php` | Intercepts requests, checks if the target action is permitted. Production mode blocks unknown actions (403). Training mode prompts for approval. |
|
||||||
|
| `ActionGateService.php` | Core service — resolves action names from routes/controllers, checks `ActionPermission` records. Supports `#[Action]` attribute, auto-resolution from controller names, and training mode. |
|
||||||
|
| `RouteActionMacro.php` | Adds `->action('name')` and `->bypassGate()` macros to Laravel routes for fluent action naming. |
|
||||||
|
|
||||||
|
## Integration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Request -> ActionGateMiddleware -> ActionGateService::check() -> ActionPermission (allowed/denied) -> Controller
|
||||||
|
```
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
7
app/Core/Bouncer/Gate/Migrations/CLAUDE.md
Normal file
7
app/Core/Bouncer/Gate/Migrations/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Bouncer/Gate/Migrations/ — Action Gate Schema
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `0001_01_01_000002_create_action_permission_tables.php` | Creates `core_action_permissions` (whitelisted actions) and `core_action_requests` (audit log) tables. |
|
||||||
207
app/Core/Bouncer/Gate/Models/ActionPermission.php
Normal file
207
app/Core/Bouncer/Gate/Models/ActionPermission.php
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
<?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 Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
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|null $trained_at When training occurred
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property 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 Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function forGuard(string $guard): Collection
|
||||||
|
{
|
||||||
|
return static::where('guard', $guard)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all allowed actions for a guard/role combination.
|
||||||
|
*
|
||||||
|
* @return Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function allowedFor(string $guard, ?string $role = null): 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Core/Bouncer/Gate/Models/ActionRequest.php
Normal file
181
app/Core/Bouncer/Gate/Models/ActionRequest.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?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 Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
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 $created_at
|
||||||
|
* @property 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 Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function pending(): Collection
|
||||||
|
{
|
||||||
|
return static::where('status', self::STATUS_PENDING)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get denied requests for an action.
|
||||||
|
*
|
||||||
|
* @return Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function deniedFor(string $action): Collection
|
||||||
|
{
|
||||||
|
return static::where('action', $action)
|
||||||
|
->where('status', self::STATUS_DENIED)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get requests by user.
|
||||||
|
*
|
||||||
|
* @return Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function forUser(int $userId): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Core/Bouncer/Gate/Models/CLAUDE.md
Normal file
8
app/Core/Bouncer/Gate/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Bouncer/Gate/Models/ — Action Gate Models
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
| Model | Table | Purpose |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `ActionPermission` | `core_action_permissions` | Whitelisted action record. Stores action identifier, scope, guard, role, allowed flag, and training metadata (who trained it, when, from which route). Source: `trained`, `seeded`, or `manual`. |
|
||||||
|
| `ActionRequest` | `core_action_requests` | Audit log entry for all action permission checks. Records HTTP method, route, action, guard, user, IP, status (allowed/denied/pending), and whether training was triggered. |
|
||||||
86
app/Core/Bouncer/Gate/RouteActionMacro.php
Normal file
86
app/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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
389
app/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php
Normal file
389
app/Core/Bouncer/Gate/Tests/Feature/ActionGateTest.php
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
<?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\Boot;
|
||||||
|
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 [
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md
Normal file
7
app/Core/Bouncer/Gate/Tests/Feature/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Bouncer/Gate/Tests/Feature/ — Action Gate Feature Tests
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `ActionGateTest.php` | Integration tests for the full action gate flow — middleware interception, permission enforcement, training mode responses, route macro behaviour. |
|
||||||
235
app/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php
Normal file
235
app/Core/Bouncer/Gate/Tests/Unit/ActionGateServiceTest.php
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md
Normal file
7
app/Core/Bouncer/Gate/Tests/Unit/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Bouncer/Gate/Tests/Unit/ — Action Gate Unit Tests
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `ActionGateServiceTest.php` | Unit tests for the `ActionGateService`. Tests action name resolution from routes and controllers, permission checking, training mode behaviour, and `#[Action]` attribute support. |
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Core bouncer tables - IP blocking, rate limiting, redirects.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
// 1. Blocked IPs
|
||||||
|
Schema::create('blocked_ips', function (Blueprint $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('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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Rate Limit Buckets
|
||||||
|
Schema::create('rate_limit_buckets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('key');
|
||||||
|
$table->string('bucket_type', 32);
|
||||||
|
$table->unsignedInteger('tokens')->default(0);
|
||||||
|
$table->unsignedInteger('max_tokens');
|
||||||
|
$table->timestamp('last_refill_at');
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['key', 'bucket_type']);
|
||||||
|
$table->index('expires_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
Schema::dropIfExists('rate_limit_buckets');
|
||||||
|
Schema::dropIfExists('blocked_ips');
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
};
|
||||||
9
app/Core/Bouncer/Migrations/CLAUDE.md
Normal file
9
app/Core/Bouncer/Migrations/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Bouncer/Migrations/ — Bouncer Schema Migrations
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `0001_01_01_000001_create_bouncer_tables.php` | Creates core bouncer tables for IP/domain blocklisting, redirect rules, and rate limiting configuration. |
|
||||||
|
|
||||||
|
Uses early timestamps to run before application migrations.
|
||||||
132
app/Core/Bouncer/RedirectService.php
Normal file
132
app/Core/Bouncer/RedirectService.php
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEO redirect manager with caching.
|
||||||
|
*
|
||||||
|
* Handles 301/302 redirects early in the request lifecycle
|
||||||
|
* before Laravel does heavy processing.
|
||||||
|
*/
|
||||||
|
class RedirectService
|
||||||
|
{
|
||||||
|
protected const CACHE_KEY = 'bouncer:redirects';
|
||||||
|
|
||||||
|
protected const CACHE_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a path against redirects.
|
||||||
|
*
|
||||||
|
* @return array{to: string, status: int}|null
|
||||||
|
*/
|
||||||
|
public function match(string $path): ?array
|
||||||
|
{
|
||||||
|
$redirects = $this->getRedirects();
|
||||||
|
$path = '/'.ltrim($path, '/');
|
||||||
|
|
||||||
|
// Exact match first
|
||||||
|
if (isset($redirects[$path])) {
|
||||||
|
return $redirects[$path];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard matches (path/*)
|
||||||
|
foreach ($redirects as $from => $redirect) {
|
||||||
|
if (str_ends_with($from, '*')) {
|
||||||
|
$prefix = rtrim($from, '*');
|
||||||
|
if (str_starts_with($path, $prefix)) {
|
||||||
|
// Replace the matched portion
|
||||||
|
$suffix = substr($path, strlen($prefix));
|
||||||
|
$to = str_ends_with($redirect['to'], '*')
|
||||||
|
? rtrim($redirect['to'], '*').$suffix
|
||||||
|
: $redirect['to'];
|
||||||
|
|
||||||
|
return ['to' => $to, 'status' => $redirect['status']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all redirects (cached).
|
||||||
|
*
|
||||||
|
* @return array<string, array{to: string, status: int}>
|
||||||
|
*/
|
||||||
|
public function getRedirects(): array
|
||||||
|
{
|
||||||
|
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
|
||||||
|
if (! $this->tableExists()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('seo_redirects')
|
||||||
|
->where('active', true)
|
||||||
|
->get()
|
||||||
|
->keyBy('from_path')
|
||||||
|
->map(fn ($row) => [
|
||||||
|
'to' => $row->to_path,
|
||||||
|
'status' => $row->status_code,
|
||||||
|
])
|
||||||
|
->toArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a redirect.
|
||||||
|
*/
|
||||||
|
public function add(string $from, string $to, int $status = 301): void
|
||||||
|
{
|
||||||
|
DB::table('seo_redirects')->updateOrInsert(
|
||||||
|
['from_path' => $from],
|
||||||
|
[
|
||||||
|
'to_path' => $to,
|
||||||
|
'status_code' => $status,
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a redirect.
|
||||||
|
*/
|
||||||
|
public function remove(string $from): void
|
||||||
|
{
|
||||||
|
DB::table('seo_redirects')->where('from_path', $from)->delete();
|
||||||
|
$this->clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache.
|
||||||
|
*/
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget(self::CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if redirects table exists.
|
||||||
|
*/
|
||||||
|
protected function tableExists(): bool
|
||||||
|
{
|
||||||
|
return Cache::remember('bouncer:redirects_table_exists', 3600, function () {
|
||||||
|
return DB::getSchemaBuilder()->hasTable('seo_redirects');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
594
app/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php
Normal file
594
app/Core/Bouncer/Tests/Unit/BlocklistServiceTest.php
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Core/Bouncer/Tests/Unit/CLAUDE.md
Normal file
7
app/Core/Bouncer/Tests/Unit/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Bouncer/Tests/Unit/ — Bouncer Unit Tests
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `BlocklistServiceTest.php` | Unit tests for the IP/domain blocklist service. Tests blocking, allowing, and checking IPs and domains against the blocklist. |
|
||||||
83
app/Core/CLAUDE.md
Normal file
83
app/Core/CLAUDE.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Core Orchestration
|
||||||
|
|
||||||
|
Root-level files in `src/Core/` that wire the entire framework together. These are the bootstrap, module discovery, lazy loading, and pro-feature detection systems.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Init.php` | True entry point. `Core\Init::handle()` replaces Laravel's `bootstrap/app.php`. Runs WAF input filtering via `Input::capture()`, then delegates to `Boot::app()`. Prefers `App\Boot` if it exists. |
|
||||||
|
| `Boot.php` | Configures Laravel `Application` with providers, middleware, and exceptions. Provider load order is critical: `LifecycleEventProvider` -> `Website\Boot` -> `Front\Boot` -> `Mod\Boot`. |
|
||||||
|
| `LifecycleEventProvider.php` | The orchestrator. Registers `ModuleScanner` and `ModuleRegistry` as singletons, scans configured paths, wires lazy listeners. Static `fire*()` methods are called by frontage modules to dispatch lifecycle events and process collected requests (views, livewire, routes, middleware). |
|
||||||
|
| `ModuleScanner.php` | Discovers `Boot.php` files in subdirectories of given paths. Reads static `$listens` arrays via reflection without instantiating modules. Maps paths to namespaces (`/Core` -> `Core\`, `/Mod` -> `Mod\`, `/Website` -> `Website\`, `/Plug` -> `Plug\`). |
|
||||||
|
| `ModuleRegistry.php` | Coordinates scanner output into Laravel's event system. Sorts listeners by priority (highest first), creates `LazyModuleListener` instances, supports late-registration via `addPaths()`. |
|
||||||
|
| `LazyModuleListener.php` | The lazy-loading wrapper. Instantiates module on first event fire (cached thereafter). ServiceProviders use `resolveProvider()`, plain classes use `make()`. Records audit logs and profiling data. |
|
||||||
|
| `Pro.php` | Detects Flux Pro and FontAwesome Pro installations. Auto-enables pro features, falls back gracefully to free equivalents. Throws helpful dev-mode exceptions. |
|
||||||
|
| `config.php` | Framework configuration: branding, domains, CDN, organisation, social links, contact, FontAwesome, pro fallback behaviour, icon defaults, debug settings, seeder auto-discovery. |
|
||||||
|
|
||||||
|
## Bootstrap Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
public/index.php
|
||||||
|
-> Core\Init::handle()
|
||||||
|
-> Input::capture() # WAF layer sanitises $_GET/$_POST
|
||||||
|
-> Boot::app() # Build Laravel Application
|
||||||
|
-> LifecycleEventProvider # register(): scan + wire lazy listeners
|
||||||
|
-> Website\Boot # register(): domain resolution
|
||||||
|
-> Front\Boot # boot(): fires lifecycle events
|
||||||
|
-> Mod\Boot # aggregates feature modules
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Declaration Pattern
|
||||||
|
|
||||||
|
Modules declare interest in events via static `$listens`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
public static array $listens = [
|
||||||
|
WebRoutesRegistering::class => 'onWebRoutes',
|
||||||
|
AdminPanelBooting::class => ['onAdmin', 10], // priority 10
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Modules are never instantiated until their event fires.
|
||||||
|
|
||||||
|
## Lifecycle Events (fire* methods)
|
||||||
|
|
||||||
|
| Method | Event | Middleware | Processes |
|
||||||
|
|--------|-------|-----------|-----------|
|
||||||
|
| `fireWebRoutes()` | `WebRoutesRegistering` | `web` | views, livewire, routes |
|
||||||
|
| `fireAdminBooting()` | `AdminPanelBooting` | `admin` | views, translations, livewire, routes |
|
||||||
|
| `fireClientRoutes()` | `ClientRoutesRegistering` | `client` | views, livewire, routes |
|
||||||
|
| `fireApiRoutes()` | `ApiRoutesRegistering` | `api` | routes |
|
||||||
|
| `fireMcpRoutes()` | `McpRoutesRegistering` | `mcp` | routes |
|
||||||
|
| `fireMcpTools()` | `McpToolsRegistering` | -- | returns handler class names |
|
||||||
|
| `fireConsoleBooting()` | `ConsoleBooting` | -- | artisan commands |
|
||||||
|
| `fireQueueWorkerBooting()` | `QueueWorkerBooting` | -- | queue-specific init |
|
||||||
|
|
||||||
|
All route-registering fire methods call `refreshRoutes()` afterward to deduplicate names and refresh lookups.
|
||||||
|
|
||||||
|
## Default Scan Paths
|
||||||
|
|
||||||
|
- `app_path('Core')` -- application-level core modules
|
||||||
|
- `app_path('Mod')` -- feature modules
|
||||||
|
- `app_path('Website')` -- domain-scoped website modules
|
||||||
|
- `src/Core` -- framework's own modules
|
||||||
|
- `src/Mod` -- framework's own feature modules
|
||||||
|
|
||||||
|
Configurable via `config('core.module_paths')`.
|
||||||
|
|
||||||
|
## Priority System
|
||||||
|
|
||||||
|
- Default: `0`
|
||||||
|
- Higher values run first: `['onAdmin', 100]` runs before `['onAdmin', 0]`
|
||||||
|
- Negative values run last: `['onCleanup', -10]`
|
||||||
|
|
||||||
|
## Key Integration Points
|
||||||
|
|
||||||
|
- `Init::boot()` returns `App\Boot` if it exists, allowing apps to customise providers
|
||||||
|
- `Boot::basePath()` auto-detects monorepo vs vendor structure
|
||||||
|
- `LifecycleEventProvider` processes middleware aliases, view namespaces, and Livewire components collected during event dispatch
|
||||||
|
- Route deduplication prevents `route:cache` failures when the same route file serves multiple domains
|
||||||
153
app/Core/Cdn/Boot.php
Normal file
153
app/Core/Cdn/Boot.php
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use App\Facades\Cdn;
|
||||||
|
use App\Http\Middleware\RewriteOffloadedUrls;
|
||||||
|
use App\Jobs\PushAssetToCdn;
|
||||||
|
use App\Traits\HasCdnUrls;
|
||||||
|
use Core\Cdn\Console\CdnPurge;
|
||||||
|
use Core\Cdn\Console\OffloadMigrateCommand;
|
||||||
|
use Core\Cdn\Console\PushAssetsToCdn;
|
||||||
|
use Core\Cdn\Console\PushFluxToCdn;
|
||||||
|
use Core\Cdn\Services\AssetPipeline;
|
||||||
|
use Core\Cdn\Services\BunnyCdnService;
|
||||||
|
use Core\Cdn\Services\BunnyStorageService;
|
||||||
|
use Core\Cdn\Services\FluxCdnService;
|
||||||
|
use Core\Cdn\Services\StorageOffload;
|
||||||
|
use Core\Cdn\Services\StorageUrlResolver;
|
||||||
|
use Core\Crypt\LthnHash;
|
||||||
|
use Core\Plug\Cdn\CdnManager;
|
||||||
|
use Core\Plug\Storage\StorageManager;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDN Module Service Provider.
|
||||||
|
*
|
||||||
|
* Provides unified CDN and storage functionality:
|
||||||
|
* - BunnyCDN pull zone operations (purging, stats)
|
||||||
|
* - BunnyCDN storage zone operations (file upload/download)
|
||||||
|
* - Context-aware URL resolution
|
||||||
|
* - Asset processing pipeline
|
||||||
|
* - vBucket workspace isolation using LTHN QuasiHash
|
||||||
|
*/
|
||||||
|
class Boot extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// Register configuration
|
||||||
|
$this->mergeConfigFrom(__DIR__.'/config.php', 'cdn');
|
||||||
|
$this->mergeConfigFrom(__DIR__.'/offload.php', 'offload');
|
||||||
|
|
||||||
|
// Register Plug managers as singletons (when available)
|
||||||
|
if (class_exists(CdnManager::class)) {
|
||||||
|
$this->app->singleton(CdnManager::class);
|
||||||
|
}
|
||||||
|
if (class_exists(StorageManager::class)) {
|
||||||
|
$this->app->singleton(StorageManager::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register legacy services as singletons (for backward compatibility)
|
||||||
|
$this->app->singleton(BunnyCdnService::class);
|
||||||
|
$this->app->singleton(BunnyStorageService::class);
|
||||||
|
$this->app->singleton(StorageUrlResolver::class);
|
||||||
|
$this->app->singleton(FluxCdnService::class);
|
||||||
|
$this->app->singleton(AssetPipeline::class);
|
||||||
|
$this->app->singleton(StorageOffload::class);
|
||||||
|
|
||||||
|
// Register backward compatibility aliases
|
||||||
|
$this->registerBackwardCompatAliases();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Register console commands
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->commands([
|
||||||
|
CdnPurge::class,
|
||||||
|
PushAssetsToCdn::class,
|
||||||
|
PushFluxToCdn::class,
|
||||||
|
OffloadMigrateCommand::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register backward compatibility class aliases.
|
||||||
|
*
|
||||||
|
* These allow existing code using old namespaces to continue working
|
||||||
|
* while we migrate to the new Core structure.
|
||||||
|
*/
|
||||||
|
protected function registerBackwardCompatAliases(): void
|
||||||
|
{
|
||||||
|
// Services
|
||||||
|
if (! class_exists(\App\Services\BunnyCdnService::class)) {
|
||||||
|
class_alias(BunnyCdnService::class, \App\Services\BunnyCdnService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\App\Services\Storage\BunnyStorageService::class)) {
|
||||||
|
class_alias(BunnyStorageService::class, \App\Services\Storage\BunnyStorageService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\App\Services\Storage\StorageUrlResolver::class)) {
|
||||||
|
class_alias(StorageUrlResolver::class, \App\Services\Storage\StorageUrlResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\App\Services\Storage\AssetPipeline::class)) {
|
||||||
|
class_alias(AssetPipeline::class, \App\Services\Storage\AssetPipeline::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\App\Services\Storage\StorageOffload::class)) {
|
||||||
|
class_alias(StorageOffload::class, \App\Services\Storage\StorageOffload::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\App\Services\Cdn\FluxCdnService::class)) {
|
||||||
|
class_alias(FluxCdnService::class, \App\Services\Cdn\FluxCdnService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crypt
|
||||||
|
if (! class_exists(\App\Services\Crypt\LthnHash::class)) {
|
||||||
|
class_alias(LthnHash::class, \App\Services\Crypt\LthnHash::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models
|
||||||
|
if (! class_exists(\App\Models\StorageOffload::class)) {
|
||||||
|
class_alias(Models\StorageOffload::class, \App\Models\StorageOffload::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facades
|
||||||
|
if (! class_exists(Cdn::class)) {
|
||||||
|
class_alias(Facades\Cdn::class, Cdn::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traits
|
||||||
|
if (! trait_exists(HasCdnUrls::class)) {
|
||||||
|
class_alias(Traits\HasCdnUrls::class, HasCdnUrls::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
if (! class_exists(RewriteOffloadedUrls::class)) {
|
||||||
|
class_alias(Middleware\RewriteOffloadedUrls::class, RewriteOffloadedUrls::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
if (! class_exists(PushAssetToCdn::class)) {
|
||||||
|
class_alias(Jobs\PushAssetToCdn::class, PushAssetToCdn::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Core/Cdn/CLAUDE.md
Normal file
57
app/Core/Cdn/CLAUDE.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Cdn
|
||||||
|
|
||||||
|
BunnyCDN integration with vBucket workspace isolation and storage offloading.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Unified CDN and object storage layer providing:
|
||||||
|
- BunnyCDN pull zone operations (purge, stats)
|
||||||
|
- BunnyCDN storage zone operations (upload, download, list, delete)
|
||||||
|
- Context-aware URL building (CDN, origin, private, signed)
|
||||||
|
- vBucket-scoped paths using `LthnHash` for tenant isolation
|
||||||
|
- Asset pipeline for processing and offloading
|
||||||
|
- Flux Pro CDN delivery
|
||||||
|
- Storage offload migration from local to CDN
|
||||||
|
|
||||||
|
## Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Boot` | ServiceProvider registering all services as singletons + backward-compat aliases to `App\` namespaces |
|
||||||
|
| `BunnyCdnService` | Pull zone API: `purgeUrl()`, `purgeUrls()`, `purgeAll()`, `purgeByTag()`, `purgeWorkspace()`, `getStats()`, `getBandwidth()`, `listStorageFiles()`, `uploadFile()`, `deleteFile()`. Sanitises error messages to redact API keys |
|
||||||
|
| `BunnyStorageService` | Direct storage zone operations (separate from pull zone API) |
|
||||||
|
| `CdnUrlBuilder` | URL construction: `cdn()`, `origin()`, `private()`, `apex()`, `signed()`, `vBucket()`, `vBucketId()`, `vBucketPath()`, `asset()`, `withVersion()`, `urls()`, `allUrls()` |
|
||||||
|
| `StorageUrlResolver` | Context-aware URL resolution |
|
||||||
|
| `FluxCdnService` | Flux Pro component CDN delivery |
|
||||||
|
| `AssetPipeline` | Asset processing pipeline |
|
||||||
|
| `StorageOffload` (service) | Migrates files from local storage to CDN |
|
||||||
|
| `StorageOffload` (model) | Tracks offloaded files in DB |
|
||||||
|
| `Cdn` (facade) | `Cdn::purge(...)` etc. |
|
||||||
|
| `HasCdnUrls` (trait) | Adds CDN URL methods to Eloquent models |
|
||||||
|
|
||||||
|
## Console Commands
|
||||||
|
|
||||||
|
- `cdn:purge` -- Purge CDN cache
|
||||||
|
- `cdn:push-assets` -- Push assets to CDN storage
|
||||||
|
- `cdn:push-flux` -- Push Flux Pro assets to CDN
|
||||||
|
- `cdn:offload-migrate` -- Migrate local files to CDN storage
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
- `RewriteOffloadedUrls` -- Rewrites storage URLs in responses to CDN URLs
|
||||||
|
- `LocalCdnMiddleware` -- Serves CDN assets locally in development
|
||||||
|
|
||||||
|
## vBucket Pattern
|
||||||
|
|
||||||
|
Workspace-isolated CDN paths using `LthnHash::vBucketId()`:
|
||||||
|
```
|
||||||
|
cdn.example.com/{vBucketId}/path/to/asset.js
|
||||||
|
```
|
||||||
|
The vBucketId is a deterministic SHA-256 hash of the domain name, ensuring each workspace's assets are namespaced.
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
- Reads credentials from `ConfigService` (DB-backed config), not just `.env`
|
||||||
|
- Signed URLs use HMAC-SHA256 with BunnyCDN token authentication
|
||||||
|
- Config files: `config.php` (CDN settings), `offload.php` (storage offload settings)
|
||||||
|
- Backward-compat aliases registered for all `App\Services\*` and `App\Models\*` namespaces
|
||||||
10
app/Core/Cdn/Console/CLAUDE.md
Normal file
10
app/Core/Cdn/Console/CLAUDE.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Cdn/Console/ — CDN Artisan Commands
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Signature | Purpose |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| `CdnPurge` | `cdn:purge` | Purge CDN cache — by URL, tag, workspace, or global. |
|
||||||
|
| `OffloadMigrateCommand` | `cdn:offload-migrate` | Migrate local files to remote storage, creating offload records. |
|
||||||
|
| `PushAssetsToCdn` | `cdn:push` | Push local assets to CDN storage zone. |
|
||||||
|
| `PushFluxToCdn` | `cdn:push-flux` | Push Flux UI framework assets to CDN. |
|
||||||
324
app/Core/Cdn/Console/CdnPurge.php
Normal file
324
app/Core/Cdn/Console/CdnPurge.php
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
<?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\Console;
|
||||||
|
|
||||||
|
use Core\Plug\Cdn\Bunny\Purge;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CdnPurge extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'cdn:purge
|
||||||
|
{workspace? : Workspace slug or "all" to purge all workspaces}
|
||||||
|
{--url=* : Specific URL(s) to purge}
|
||||||
|
{--tag= : Purge by cache tag}
|
||||||
|
{--everything : Purge entire CDN cache (use with caution)}
|
||||||
|
{--dry-run : Show what would be purged without making changes}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Purge content from CDN edge cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purger instance (Core\Plug\Cdn\Bunny\Purge when available).
|
||||||
|
*/
|
||||||
|
protected ?object $purger = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
if (class_exists(Purge::class)) {
|
||||||
|
$this->purger = new Purge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if ($this->purger === null) {
|
||||||
|
$this->error('CDN Purge requires Core\Plug\Cdn\Bunny\Purge class. Plug module not installed.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceArg = $this->argument('workspace');
|
||||||
|
$urls = $this->option('url');
|
||||||
|
$tag = $this->option('tag');
|
||||||
|
$everything = $this->option('everything');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('Dry run mode - no changes will be made');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mutually exclusive options
|
||||||
|
$optionCount = ($everything ? 1 : 0) + (! empty($urls) ? 1 : 0) + (! empty($tag) ? 1 : 0) + (! empty($workspaceArg) ? 1 : 0);
|
||||||
|
if ($optionCount > 1 && $everything) {
|
||||||
|
$this->error('Cannot use --everything with other options');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge everything
|
||||||
|
if ($everything) {
|
||||||
|
return $this->purgeEverything($dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge specific URLs
|
||||||
|
if (! empty($urls)) {
|
||||||
|
return $this->purgeUrls($urls, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge by tag
|
||||||
|
if (! empty($tag)) {
|
||||||
|
return $this->purgeByTag($tag, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge by workspace
|
||||||
|
if (empty($workspaceArg)) {
|
||||||
|
$workspaceOptions = ['all', 'Select specific URLs'];
|
||||||
|
if (class_exists(Workspace::class)) {
|
||||||
|
$workspaceOptions = array_merge($workspaceOptions, Workspace::pluck('slug')->toArray());
|
||||||
|
}
|
||||||
|
$workspaceArg = $this->choice(
|
||||||
|
'What would you like to purge?',
|
||||||
|
$workspaceOptions,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($workspaceArg === 'Select specific URLs') {
|
||||||
|
$urlInput = $this->ask('Enter URL(s) to purge (comma-separated)');
|
||||||
|
$urls = array_map('trim', explode(',', $urlInput));
|
||||||
|
|
||||||
|
return $this->purgeUrls($urls, $dryRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspaceArg === 'all') {
|
||||||
|
return $this->purgeAllWorkspaces($dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->purgeWorkspace($workspaceArg, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purgeEverything(bool $dryRun): int
|
||||||
|
{
|
||||||
|
if (! $dryRun && ! $this->confirm('Are you sure you want to purge the ENTIRE CDN cache? This will affect all content.', false)) {
|
||||||
|
$this->info('Aborted');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn('Purging entire CDN cache...');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('Would purge: entire CDN cache');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->purger->all();
|
||||||
|
|
||||||
|
if ($result->isOk()) {
|
||||||
|
$this->info('CDN cache purged successfully');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Failed to purge CDN cache: '.$result->message());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Purge failed: {$e->getMessage()}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purgeUrls(array $urls, bool $dryRun): int
|
||||||
|
{
|
||||||
|
$this->info('Purging '.count($urls).' URL(s)...');
|
||||||
|
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
$this->line(" - {$url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->purger->urls($urls);
|
||||||
|
|
||||||
|
if ($result->isOk()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('URLs purged successfully');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Failed to purge URLs: '.$result->message());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Purge failed: {$e->getMessage()}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purgeByTag(string $tag, bool $dryRun): int
|
||||||
|
{
|
||||||
|
$this->info("Purging cache tag: {$tag}");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info("Would purge: all content with tag '{$tag}'");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->purger->tag($tag);
|
||||||
|
|
||||||
|
if ($result->isOk()) {
|
||||||
|
$this->info('Cache tag purged successfully');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Failed to purge cache tag: '.$result->message());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Purge failed: {$e->getMessage()}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purgeAllWorkspaces(bool $dryRun): int
|
||||||
|
{
|
||||||
|
if (! class_exists(Workspace::class)) {
|
||||||
|
$this->error('Workspace purge requires Tenant module to be installed.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaces = Workspace::all();
|
||||||
|
|
||||||
|
if ($workspaces->isEmpty()) {
|
||||||
|
$this->error('No workspaces found');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Purging {$workspaces->count()} workspaces...");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$success = true;
|
||||||
|
|
||||||
|
foreach ($workspaces as $workspace) {
|
||||||
|
$this->line("Workspace: <info>{$workspace->slug}</info>");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" Would purge: workspace-{$workspace->uuid}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->purger->workspace($workspace->uuid);
|
||||||
|
|
||||||
|
if ($result->isOk()) {
|
||||||
|
$this->line(' <fg=green>Purged</>');
|
||||||
|
} else {
|
||||||
|
$this->line(' <fg=red>Failed: '.$result->message().'</>');
|
||||||
|
$success = false;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->line(" <fg=red>Error: {$e->getMessage()}</>");
|
||||||
|
$success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$this->info('All workspaces purged successfully');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn('Some workspaces failed to purge');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purgeWorkspace(string $slug, bool $dryRun): int
|
||||||
|
{
|
||||||
|
if (! class_exists(Workspace::class)) {
|
||||||
|
$this->error('Workspace purge requires Tenant module to be installed.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if (! $workspace) {
|
||||||
|
$this->error("Workspace not found: {$slug}");
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Available workspaces:');
|
||||||
|
Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}"));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Purging workspace: {$workspace->slug}");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line("Would purge: workspace-{$workspace->uuid}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->purger->workspace($workspace->uuid);
|
||||||
|
|
||||||
|
if ($result->isOk()) {
|
||||||
|
$this->info('Workspace cache purged successfully');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Failed to purge workspace cache: '.$result->message());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Purge failed: {$e->getMessage()}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
app/Core/Cdn/Console/OffloadMigrateCommand.php
Normal file
214
app/Core/Cdn/Console/OffloadMigrateCommand.php
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
<?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\Console;
|
||||||
|
|
||||||
|
use Core\Cdn\Services\StorageOffload;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class OffloadMigrateCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*/
|
||||||
|
protected $signature = 'offload:migrate
|
||||||
|
{path? : Directory to scan for files}
|
||||||
|
{--category=media : Category for offloaded files}
|
||||||
|
{--dry-run : Show what would be offloaded without actually doing it}
|
||||||
|
{--force : Skip confirmation prompt}
|
||||||
|
{--only-missing : Only offload files not already offloaded}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*/
|
||||||
|
protected $description = 'Migrate local files to remote storage';
|
||||||
|
|
||||||
|
protected StorageOffload $offloadService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(StorageOffload $offloadService): int
|
||||||
|
{
|
||||||
|
$this->offloadService = $offloadService;
|
||||||
|
|
||||||
|
if (! $this->offloadService->isEnabled()) {
|
||||||
|
$this->error('Storage offload is not enabled in configuration.');
|
||||||
|
$this->info('Set STORAGE_OFFLOAD_ENABLED=true in your .env file.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine path to scan
|
||||||
|
$path = $this->argument('path') ?? storage_path('app/public');
|
||||||
|
$category = $this->option('category');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$onlyMissing = $this->option('only-missing');
|
||||||
|
|
||||||
|
if (! is_dir($path)) {
|
||||||
|
$this->error("Directory not found: {$path}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Scanning directory: {$path}");
|
||||||
|
$this->info("Category: {$category}");
|
||||||
|
$this->info("Disk: {$this->offloadService->getDiskName()}");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No files will be offloaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
// Scan for files
|
||||||
|
$files = $this->scanDirectory($path);
|
||||||
|
|
||||||
|
if (empty($files)) {
|
||||||
|
$this->info('No eligible files found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Found '.count($files).' file(s) to process.');
|
||||||
|
|
||||||
|
// Filter already offloaded files if requested
|
||||||
|
if ($onlyMissing) {
|
||||||
|
$files = array_filter($files, function ($file) {
|
||||||
|
return ! $this->offloadService->isOffloaded($file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (empty($files)) {
|
||||||
|
$this->info('All files are already offloaded.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Found '.count($files).' file(s) not yet offloaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
$totalSize = array_sum(array_map('filesize', $files));
|
||||||
|
$this->info('Total size: '.$this->formatBytes($totalSize));
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
// Confirmation
|
||||||
|
if (! $dryRun && ! $this->option('force')) {
|
||||||
|
if (! $this->confirm('Proceed with offloading?')) {
|
||||||
|
$this->info('Cancelled.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
$processed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
$this->withProgressBar($files, function ($file) use ($category, $dryRun, &$processed, &$failed, &$skipped) {
|
||||||
|
// Check if already offloaded
|
||||||
|
if ($this->offloadService->isOffloaded($file)) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to offload
|
||||||
|
$result = $this->offloadService->upload($file, null, $category);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$processed++;
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->info('Migration complete!');
|
||||||
|
$this->table(
|
||||||
|
['Status', 'Count'],
|
||||||
|
[
|
||||||
|
['Processed', $processed],
|
||||||
|
['Failed', $failed],
|
||||||
|
['Skipped', $skipped],
|
||||||
|
['Total', count($files)],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
$this->warn('Some files failed to offload. Check logs for details.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan directory recursively for eligible files.
|
||||||
|
*/
|
||||||
|
protected function scanDirectory(string $path): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
$allowedExtensions = config('offload.allowed_extensions', []);
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $file->getPathname();
|
||||||
|
$extension = strtolower($file->getExtension());
|
||||||
|
|
||||||
|
// Skip if not in allowed extensions list (if configured)
|
||||||
|
if (! empty($allowedExtensions) && ! in_array($extension, $allowedExtensions)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if exceeds max file size
|
||||||
|
$maxSize = config('offload.max_file_size', 100 * 1024 * 1024);
|
||||||
|
if ($file->getSize() > $maxSize) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable format.
|
||||||
|
*/
|
||||||
|
protected function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
|
||||||
|
$power = min($power, count($units) - 1);
|
||||||
|
|
||||||
|
return round($bytes / (1024 ** $power), 2).' '.$units[$power];
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/Core/Cdn/Console/PushAssetsToCdn.php
Normal file
198
app/Core/Cdn/Console/PushAssetsToCdn.php
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<?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\Console;
|
||||||
|
|
||||||
|
use Core\Cdn\Services\FluxCdnService;
|
||||||
|
use Core\Cdn\Services\StorageUrlResolver;
|
||||||
|
use Core\Plug\Storage\Bunny\VBucket;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class PushAssetsToCdn extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cdn:push-assets
|
||||||
|
{--domain=host.uk.com : Workspace domain for vBucket scoping}
|
||||||
|
{--flux : Push Flux UI assets only}
|
||||||
|
{--fontawesome : Push Font Awesome assets only}
|
||||||
|
{--js : Push JavaScript assets only}
|
||||||
|
{--all : Push all static assets (default)}
|
||||||
|
{--dry-run : Show what would be uploaded without uploading}';
|
||||||
|
|
||||||
|
protected $description = 'Push static assets to CDN storage zone for edge delivery';
|
||||||
|
|
||||||
|
protected StorageUrlResolver $cdn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VBucket instance (Core\Plug\Storage\Bunny\VBucket when available).
|
||||||
|
*/
|
||||||
|
protected ?object $vbucket = null;
|
||||||
|
|
||||||
|
protected bool $dryRun = false;
|
||||||
|
|
||||||
|
protected int $uploadCount = 0;
|
||||||
|
|
||||||
|
protected int $failCount = 0;
|
||||||
|
|
||||||
|
public function handle(FluxCdnService $flux, StorageUrlResolver $cdn): int
|
||||||
|
{
|
||||||
|
if (! class_exists(VBucket::class)) {
|
||||||
|
$this->error('Push assets to CDN requires Core\Plug\Storage\Bunny\VBucket class. Plug module not installed.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cdn = $cdn;
|
||||||
|
$this->dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
// Create vBucket for workspace isolation
|
||||||
|
$domain = $this->option('domain');
|
||||||
|
$this->vbucket = VBucket::public($domain);
|
||||||
|
|
||||||
|
$pushFlux = $this->option('flux');
|
||||||
|
$pushFontawesome = $this->option('fontawesome');
|
||||||
|
$pushJs = $this->option('js');
|
||||||
|
$pushAll = $this->option('all') || (! $pushFlux && ! $pushFontawesome && ! $pushJs);
|
||||||
|
|
||||||
|
$this->info("Pushing assets to CDN storage zone for {$domain}...");
|
||||||
|
$this->line("vBucket: {$this->vbucket->id()}");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($pushAll || $pushFlux) {
|
||||||
|
$this->pushFluxAssets($flux);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pushAll || $pushFontawesome) {
|
||||||
|
$this->pushFontAwesomeAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pushAll || $pushJs) {
|
||||||
|
$this->pushJsAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($this->dryRun) {
|
||||||
|
$this->info("Dry run complete. Would upload {$this->uploadCount} files.");
|
||||||
|
} else {
|
||||||
|
$this->info("Upload complete. {$this->uploadCount} files uploaded, {$this->failCount} failed.");
|
||||||
|
$this->line('CDN URL: '.config('cdn.urls.cdn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->failCount > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function pushFluxAssets(FluxCdnService $flux): void
|
||||||
|
{
|
||||||
|
$this->components->info('Flux UI assets');
|
||||||
|
|
||||||
|
$assets = $flux->getCdnAssetPaths();
|
||||||
|
|
||||||
|
foreach ($assets as $sourcePath => $cdnPath) {
|
||||||
|
$this->uploadFile($sourcePath, $cdnPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function pushFontAwesomeAssets(): void
|
||||||
|
{
|
||||||
|
$this->components->info('Font Awesome assets');
|
||||||
|
|
||||||
|
$basePath = public_path('vendor/fontawesome');
|
||||||
|
|
||||||
|
if (! File::isDirectory($basePath)) {
|
||||||
|
$this->warn(' Font Awesome directory not found at public/vendor/fontawesome');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push CSS files
|
||||||
|
$cssPath = "{$basePath}/css";
|
||||||
|
if (File::isDirectory($cssPath)) {
|
||||||
|
foreach (File::files($cssPath) as $file) {
|
||||||
|
$cdnPath = 'vendor/fontawesome/css/'.$file->getFilename();
|
||||||
|
$this->uploadFile($file->getPathname(), $cdnPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push webfonts
|
||||||
|
$webfontsPath = "{$basePath}/webfonts";
|
||||||
|
if (File::isDirectory($webfontsPath)) {
|
||||||
|
foreach (File::files($webfontsPath) as $file) {
|
||||||
|
$cdnPath = 'vendor/fontawesome/webfonts/'.$file->getFilename();
|
||||||
|
$this->uploadFile($file->getPathname(), $cdnPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function pushJsAssets(): void
|
||||||
|
{
|
||||||
|
$this->components->info('JavaScript assets');
|
||||||
|
|
||||||
|
$jsPath = public_path('js');
|
||||||
|
|
||||||
|
if (! File::isDirectory($jsPath)) {
|
||||||
|
$this->warn(' JavaScript directory not found at public/js');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (File::files($jsPath) as $file) {
|
||||||
|
if ($file->getExtension() === 'js') {
|
||||||
|
$cdnPath = 'js/'.$file->getFilename();
|
||||||
|
$this->uploadFile($file->getPathname(), $cdnPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function uploadFile(string $sourcePath, string $cdnPath): void
|
||||||
|
{
|
||||||
|
if (! file_exists($sourcePath)) {
|
||||||
|
$this->warn(" ✗ Source not found: {$sourcePath}");
|
||||||
|
$this->failCount++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = $this->formatBytes(filesize($sourcePath));
|
||||||
|
|
||||||
|
if ($this->dryRun) {
|
||||||
|
$this->line(" [DRY-RUN] {$cdnPath} ({$size})");
|
||||||
|
$this->uploadCount++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push directly to CDN storage zone via vBucket (workspace-isolated)
|
||||||
|
$contents = file_get_contents($sourcePath);
|
||||||
|
$result = $this->vbucket->putContents($cdnPath, $contents);
|
||||||
|
|
||||||
|
if ($result->isOk()) {
|
||||||
|
$this->line(" ✓ {$cdnPath} ({$size})");
|
||||||
|
$this->uploadCount++;
|
||||||
|
} else {
|
||||||
|
$this->error(" ✗ Failed: {$cdnPath}");
|
||||||
|
$this->failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return round($bytes / 1048576, 2).' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes >= 1024) {
|
||||||
|
return round($bytes / 1024, 2).' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes.' bytes';
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Core/Cdn/Console/PushFluxToCdn.php
Normal file
86
app/Core/Cdn/Console/PushFluxToCdn.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\Cdn\Console;
|
||||||
|
|
||||||
|
use Core\Cdn\Services\FluxCdnService;
|
||||||
|
use Core\Cdn\Services\StorageUrlResolver;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PushFluxToCdn extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cdn:push-flux {--dry-run : Show what would be uploaded without uploading}';
|
||||||
|
|
||||||
|
protected $description = 'Push Flux UI assets to CDN storage zone';
|
||||||
|
|
||||||
|
public function handle(FluxCdnService $flux, StorageUrlResolver $cdn): int
|
||||||
|
{
|
||||||
|
$this->info('Pushing Flux assets to CDN...');
|
||||||
|
|
||||||
|
$assets = $flux->getCdnAssetPaths();
|
||||||
|
|
||||||
|
if (empty($assets)) {
|
||||||
|
$this->warn('No Flux assets found to push.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
foreach ($assets as $sourcePath => $cdnPath) {
|
||||||
|
if (! file_exists($sourcePath)) {
|
||||||
|
$this->warn("Source file not found: {$sourcePath}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = $this->formatBytes(filesize($sourcePath));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" [DRY-RUN] Would upload: {$cdnPath} ({$size})");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Uploading: {$cdnPath} ({$size})");
|
||||||
|
|
||||||
|
$contents = file_get_contents($sourcePath);
|
||||||
|
$success = $cdn->storePublic($cdnPath, $contents, pushToCdn: true);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$this->info(' ✓ Uploaded to CDN');
|
||||||
|
} else {
|
||||||
|
$this->error(' ✗ Failed to upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Flux assets pushed to CDN successfully.');
|
||||||
|
$this->line('CDN URL: '.config('cdn.urls.cdn').'/flux/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return round($bytes / 1048576, 2).' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes >= 1024) {
|
||||||
|
return round($bytes / 1024, 2).' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes.' bytes';
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Core/Cdn/Facades/CLAUDE.md
Normal file
9
app/Core/Cdn/Facades/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Cdn/Facades/ — CDN Facade
|
||||||
|
|
||||||
|
## Facades
|
||||||
|
|
||||||
|
| Facade | Resolves To | Purpose |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `Cdn` | `StorageUrlResolver` | Static proxy for CDN operations — `cdn()`, `origin()`, `private()`, `signedUrl()`, `asset()`, `pushToCdn()`, `deleteFromCdn()`, `purge()`, `storePublic()`, `storePrivate()`, `vBucketCdn()`, and more. |
|
||||||
|
|
||||||
|
Usage: `Cdn::cdn('images/logo.png')` returns the CDN URL for the asset.
|
||||||
53
app/Core/Cdn/Facades/Cdn.php
Normal file
53
app/Core/Cdn/Facades/Cdn.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?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\Facades;
|
||||||
|
|
||||||
|
use Core\Cdn\Services\StorageUrlResolver;
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static string cdn(string $path)
|
||||||
|
* @method static string origin(string $path)
|
||||||
|
* @method static string private(string $path)
|
||||||
|
* @method static string|null signedUrl(string $path, int|\Carbon\Carbon|null $expiry = null)
|
||||||
|
* @method static string apex(string $path)
|
||||||
|
* @method static string asset(string $path, ?string $context = null)
|
||||||
|
* @method static array urls(string $path)
|
||||||
|
* @method static array allUrls(string $path)
|
||||||
|
* @method static string detectContext()
|
||||||
|
* @method static bool isAdminContext()
|
||||||
|
* @method static bool pushToCdn(string $disk, string $path, string $zone = 'public')
|
||||||
|
* @method static bool deleteFromCdn(string $path, string $zone = 'public')
|
||||||
|
* @method static bool purge(string $path)
|
||||||
|
* @method static string cachedAsset(string $path, ?string $context = null)
|
||||||
|
* @method static \Illuminate\Contracts\Filesystem\Filesystem publicDisk()
|
||||||
|
* @method static \Illuminate\Contracts\Filesystem\Filesystem privateDisk()
|
||||||
|
* @method static bool storePublic(string $path, mixed $contents, bool $pushToCdn = true)
|
||||||
|
* @method static bool storePrivate(string $path, mixed $contents, bool $pushToCdn = true)
|
||||||
|
* @method static bool deleteAsset(string $path, string $bucket = 'public')
|
||||||
|
* @method static string pathPrefix(string $category)
|
||||||
|
* @method static string categoryPath(string $category, string $path)
|
||||||
|
* @method static string vBucketId(string $domain)
|
||||||
|
* @method static string vBucketCdn(string $domain, string $path)
|
||||||
|
* @method static string vBucketOrigin(string $domain, string $path)
|
||||||
|
* @method static string vBucketPath(string $domain, string $path)
|
||||||
|
* @method static array vBucketUrls(string $domain, string $path)
|
||||||
|
*
|
||||||
|
* @see StorageUrlResolver
|
||||||
|
*/
|
||||||
|
class Cdn extends Facade
|
||||||
|
{
|
||||||
|
protected static function getFacadeAccessor(): string
|
||||||
|
{
|
||||||
|
return StorageUrlResolver::class;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Core/Cdn/Jobs/CLAUDE.md
Normal file
7
app/Core/Cdn/Jobs/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Cdn/Jobs/ — CDN Background Jobs
|
||||||
|
|
||||||
|
## Jobs
|
||||||
|
|
||||||
|
| Job | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `PushAssetToCdn` | Queued job to push a local asset file to the CDN (BunnyCDN). Handles upload, verification, and StorageOffload record creation. |
|
||||||
156
app/Core/Cdn/Jobs/PushAssetToCdn.php
Normal file
156
app/Core/Cdn/Jobs/PushAssetToCdn.php
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?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\Jobs;
|
||||||
|
|
||||||
|
use Core\Plug\Storage\StorageManager;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job to push an asset from Hetzner S3 to CDN storage zone.
|
||||||
|
*
|
||||||
|
* This enables async replication from origin storage to CDN edge.
|
||||||
|
*/
|
||||||
|
class PushAssetToCdn implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds to wait before retrying the job.
|
||||||
|
*/
|
||||||
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*
|
||||||
|
* @param string $disk Laravel filesystem disk name (e.g., 'hetzner-public')
|
||||||
|
* @param string $path Path within the disk
|
||||||
|
* @param string $zone Target CDN zone ('public' or 'private')
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $disk,
|
||||||
|
public string $path,
|
||||||
|
public string $zone = 'public',
|
||||||
|
) {
|
||||||
|
$this->onQueue(config('cdn.pipeline.queue', 'cdn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*
|
||||||
|
* @param object|null $storage StorageManager instance when Plug module available
|
||||||
|
*/
|
||||||
|
public function handle(?object $storage = null): void
|
||||||
|
{
|
||||||
|
if (! class_exists(StorageManager::class)) {
|
||||||
|
Log::warning('PushAssetToCdn: StorageManager not available, Plug module not installed');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve from container if not injected
|
||||||
|
if ($storage === null) {
|
||||||
|
$storage = app(StorageManager::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! config('cdn.bunny.push_enabled', false)) {
|
||||||
|
Log::debug('PushAssetToCdn: Push disabled, skipping', [
|
||||||
|
'disk' => $this->disk,
|
||||||
|
'path' => $this->path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploader = $storage->zone($this->zone)->upload();
|
||||||
|
|
||||||
|
if (! $uploader->isConfigured()) {
|
||||||
|
Log::warning('PushAssetToCdn: CDN storage not configured', [
|
||||||
|
'zone' => $this->zone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get contents from origin disk
|
||||||
|
$sourceDisk = Storage::disk($this->disk);
|
||||||
|
if (! $sourceDisk->exists($this->path)) {
|
||||||
|
Log::warning('PushAssetToCdn: Source file not found on disk', [
|
||||||
|
'disk' => $this->disk,
|
||||||
|
'path' => $this->path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = $sourceDisk->get($this->path);
|
||||||
|
$result = $uploader->contents($this->path, $contents);
|
||||||
|
|
||||||
|
if ($result->hasError()) {
|
||||||
|
Log::error('PushAssetToCdn: Failed to push asset', [
|
||||||
|
'disk' => $this->disk,
|
||||||
|
'path' => $this->path,
|
||||||
|
'zone' => $this->zone,
|
||||||
|
'error' => $result->message(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->fail(new \Exception("Failed to push {$this->path} to CDN zone {$this->zone}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('PushAssetToCdn: Asset pushed successfully', [
|
||||||
|
'disk' => $this->disk,
|
||||||
|
'path' => $this->path,
|
||||||
|
'zone' => $this->zone,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags that should be assigned to the job.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function tags(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cdn',
|
||||||
|
'push',
|
||||||
|
"zone:{$this->zone}",
|
||||||
|
"path:{$this->path}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the job should be unique.
|
||||||
|
*/
|
||||||
|
public function uniqueId(): string
|
||||||
|
{
|
||||||
|
return "{$this->zone}:{$this->path}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique ID of the job.
|
||||||
|
*/
|
||||||
|
public function uniqueFor(): int
|
||||||
|
{
|
||||||
|
return 300; // 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Core/Cdn/Middleware/CLAUDE.md
Normal file
8
app/Core/Cdn/Middleware/CLAUDE.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Cdn/Middleware/ — CDN HTTP Middleware
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `LocalCdnMiddleware` | Adds aggressive caching headers and compression for requests on the `cdn.*` subdomain. Provides CDN-like behaviour without external services. |
|
||||||
|
| `RewriteOffloadedUrls` | Processes JSON responses and replaces local storage paths with remote equivalents when files have been offloaded to external storage. |
|
||||||
143
app/Core/Cdn/Middleware/LocalCdnMiddleware.php
Normal file
143
app/Core/Cdn/Middleware/LocalCdnMiddleware.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?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\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local CDN Middleware.
|
||||||
|
*
|
||||||
|
* When requests hit the cdn.* subdomain (e.g., cdn.core.test), this middleware
|
||||||
|
* adds aggressive caching headers and enables compression. This provides
|
||||||
|
* CDN-like behaviour without external services.
|
||||||
|
*
|
||||||
|
* With Valet wildcard:
|
||||||
|
* core.test -> normal app
|
||||||
|
* cdn.core.test -> same app, but with CDN headers
|
||||||
|
*/
|
||||||
|
class LocalCdnMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Check if this is a CDN subdomain request
|
||||||
|
if (! $this->isCdnSubdomain($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Add CDN headers to the response
|
||||||
|
$this->addCdnHeaders($response, $request);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request is to the CDN subdomain.
|
||||||
|
*/
|
||||||
|
protected function isCdnSubdomain(Request $request): bool
|
||||||
|
{
|
||||||
|
$host = $request->getHost();
|
||||||
|
$cdnSubdomain = config('core.cdn.subdomain', 'cdn');
|
||||||
|
$baseDomain = config('core.domain.base', 'core.test');
|
||||||
|
|
||||||
|
// Check for cdn.{domain} pattern
|
||||||
|
return str_starts_with($host, "{$cdnSubdomain}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add CDN-appropriate headers to the response.
|
||||||
|
*/
|
||||||
|
protected function addCdnHeaders(Response $response, Request $request): void
|
||||||
|
{
|
||||||
|
// Skip if response isn't successful
|
||||||
|
if (! $response->isSuccessful()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache settings from config
|
||||||
|
$maxAge = config('core.cdn.cache_max_age', 31536000); // 1 year
|
||||||
|
$immutable = config('core.cdn.cache_immutable', true);
|
||||||
|
|
||||||
|
// Build Cache-Control header
|
||||||
|
$cacheControl = "public, max-age={$maxAge}";
|
||||||
|
if ($immutable) {
|
||||||
|
$cacheControl .= ', immutable';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->headers->set('Cache-Control', $cacheControl);
|
||||||
|
|
||||||
|
// Add ETag if possible
|
||||||
|
if ($response instanceof BinaryFileResponse) {
|
||||||
|
$file = $response->getFile();
|
||||||
|
if ($file && $file->isFile()) {
|
||||||
|
$etag = md5($file->getMTime().$file->getSize());
|
||||||
|
$response->headers->set('ETag', "\"{$etag}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vary on Accept-Encoding for compressed responses
|
||||||
|
$response->headers->set('Vary', 'Accept-Encoding');
|
||||||
|
|
||||||
|
// Add timing header for debugging
|
||||||
|
$response->headers->set('X-CDN-Cache', 'local');
|
||||||
|
|
||||||
|
// Set Content-Type headers for common static files
|
||||||
|
$this->setContentTypeHeaders($response, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set appropriate Content-Type for static assets.
|
||||||
|
*/
|
||||||
|
protected function setContentTypeHeaders(Response $response, Request $request): void
|
||||||
|
{
|
||||||
|
$path = $request->getPathInfo();
|
||||||
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
$mimeTypes = [
|
||||||
|
'js' => 'application/javascript; charset=utf-8',
|
||||||
|
'mjs' => 'application/javascript; charset=utf-8',
|
||||||
|
'css' => 'text/css; charset=utf-8',
|
||||||
|
'json' => 'application/json; charset=utf-8',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
'woff' => 'font/woff',
|
||||||
|
'woff2' => 'font/woff2',
|
||||||
|
'ttf' => 'font/ttf',
|
||||||
|
'eot' => 'application/vnd.ms-fontobject',
|
||||||
|
'ico' => 'image/x-icon',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'avif' => 'image/avif',
|
||||||
|
'mp4' => 'video/mp4',
|
||||||
|
'webm' => 'video/webm',
|
||||||
|
'mp3' => 'audio/mpeg',
|
||||||
|
'ogg' => 'audio/ogg',
|
||||||
|
'xml' => 'application/xml; charset=utf-8',
|
||||||
|
'txt' => 'text/plain; charset=utf-8',
|
||||||
|
'map' => 'application/json; charset=utf-8',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($mimeTypes[$extension])) {
|
||||||
|
$response->headers->set('Content-Type', $mimeTypes[$extension]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/Core/Cdn/Middleware/RewriteOffloadedUrls.php
Normal file
168
app/Core/Cdn/Middleware/RewriteOffloadedUrls.php
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<?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\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Core\Cdn\Services\StorageOffload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to rewrite local storage URLs to offloaded remote URLs.
|
||||||
|
*
|
||||||
|
* Processes JSON responses and replaces local storage paths with
|
||||||
|
* their remote equivalents if the file has been offloaded.
|
||||||
|
*/
|
||||||
|
class RewriteOffloadedUrls
|
||||||
|
{
|
||||||
|
protected StorageOffload $offloadService;
|
||||||
|
|
||||||
|
public function __construct(StorageOffload $offloadService)
|
||||||
|
{
|
||||||
|
$this->offloadService = $offloadService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* Rewrites URLs in JSON responses to point to offloaded storage.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Only process JSON responses
|
||||||
|
if (! $this->shouldProcess($response)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response content
|
||||||
|
$content = $response->getContent();
|
||||||
|
if (empty($content)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode JSON
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
if ($data === null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite URLs in the data
|
||||||
|
$rewritten = $this->rewriteUrls($data);
|
||||||
|
|
||||||
|
// Update response
|
||||||
|
$response->setContent(json_encode($rewritten));
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if response should be processed.
|
||||||
|
*/
|
||||||
|
protected function shouldProcess(Response $response): bool
|
||||||
|
{
|
||||||
|
// Only process successful responses
|
||||||
|
if (! $response->isSuccessful()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
$contentType = $response->headers->get('Content-Type', '');
|
||||||
|
|
||||||
|
return str_contains($contentType, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively rewrite URLs in data structure.
|
||||||
|
*/
|
||||||
|
protected function rewriteUrls(mixed $data): mixed
|
||||||
|
{
|
||||||
|
if (is_array($data)) {
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$data[$key] = $this->rewriteUrls($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($data)) {
|
||||||
|
return $this->rewriteUrl($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite a single URL if it matches a local storage path.
|
||||||
|
*/
|
||||||
|
protected function rewriteUrl(string $value): string
|
||||||
|
{
|
||||||
|
// Only process strings that look like URLs or paths
|
||||||
|
if (! $this->looksLikeStoragePath($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract local path from URL
|
||||||
|
$localPath = $this->extractLocalPath($value);
|
||||||
|
if (! $localPath) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this path has been offloaded
|
||||||
|
$offloadedUrl = $this->offloadService->url($localPath);
|
||||||
|
if ($offloadedUrl) {
|
||||||
|
return $offloadedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string looks like a storage path.
|
||||||
|
*/
|
||||||
|
protected function looksLikeStoragePath(string $value): bool
|
||||||
|
{
|
||||||
|
// Check for /storage/ in the path
|
||||||
|
if (str_contains($value, '/storage/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for storage_path pattern
|
||||||
|
if (preg_match('#/app/(public|private)/#', $value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract local file path from URL.
|
||||||
|
*/
|
||||||
|
protected function extractLocalPath(string $url): ?string
|
||||||
|
{
|
||||||
|
// Handle /storage/ URLs (symlinked public storage)
|
||||||
|
if (str_contains($url, '/storage/')) {
|
||||||
|
$parts = explode('/storage/', $url, 2);
|
||||||
|
if (count($parts) === 2) {
|
||||||
|
return storage_path('app/public/'.$parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle absolute paths
|
||||||
|
if (str_starts_with($url, storage_path())) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Core/Cdn/Models/CLAUDE.md
Normal file
7
app/Core/Cdn/Models/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Cdn/Models/ — CDN Storage Models
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
| Model | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `StorageOffload` | Tracks files offloaded to remote storage. Records local path, remote path, disk, SHA-256 hash, file size, MIME type, category, metadata, and offload timestamp. Used by the URL rewriting middleware. |
|
||||||
162
app/Core/Cdn/Models/StorageOffload.php
Normal file
162
app/Core/Cdn/Models/StorageOffload.php
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<?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\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks files that have been offloaded to remote storage.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $local_path Original local file path
|
||||||
|
* @property string $remote_path Path in remote storage
|
||||||
|
* @property string $disk Laravel disk name
|
||||||
|
* @property string|null $hash SHA-256 hash of file contents
|
||||||
|
* @property int|null $file_size File size in bytes
|
||||||
|
* @property string|null $mime_type MIME type
|
||||||
|
* @property string|null $category Category for path prefixing
|
||||||
|
* @property array|null $metadata Additional metadata
|
||||||
|
* @property Carbon|null $offloaded_at When file was offloaded
|
||||||
|
* @property Carbon|null $created_at
|
||||||
|
* @property Carbon|null $updated_at
|
||||||
|
*/
|
||||||
|
class StorageOffload extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'storage_offloads';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'local_path',
|
||||||
|
'remote_path',
|
||||||
|
'disk',
|
||||||
|
'hash',
|
||||||
|
'file_size',
|
||||||
|
'mime_type',
|
||||||
|
'category',
|
||||||
|
'metadata',
|
||||||
|
'offloaded_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'file_size' => 'integer',
|
||||||
|
'offloaded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the category.
|
||||||
|
*/
|
||||||
|
public function getCategory(): ?string
|
||||||
|
{
|
||||||
|
return $this->category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata value by key.
|
||||||
|
*/
|
||||||
|
public function getMetadata(?string $key = null): mixed
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->metadata[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original filename from metadata.
|
||||||
|
*/
|
||||||
|
public function getOriginalName(): ?string
|
||||||
|
{
|
||||||
|
return $this->getMetadata('original_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this offload is for a specific category.
|
||||||
|
*/
|
||||||
|
public function isCategory(string $category): bool
|
||||||
|
{
|
||||||
|
return $this->getCategory() === $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the file is an image.
|
||||||
|
*/
|
||||||
|
public function isImage(): bool
|
||||||
|
{
|
||||||
|
return $this->mime_type && str_starts_with($this->mime_type, 'image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the file is a video.
|
||||||
|
*/
|
||||||
|
public function isVideo(): bool
|
||||||
|
{
|
||||||
|
return $this->mime_type && str_starts_with($this->mime_type, 'video/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the file is audio.
|
||||||
|
*/
|
||||||
|
public function isAudio(): bool
|
||||||
|
{
|
||||||
|
return $this->mime_type && str_starts_with($this->mime_type, 'audio/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to filter by category.
|
||||||
|
*/
|
||||||
|
public function scopeCategory($query, string $category)
|
||||||
|
{
|
||||||
|
return $query->where('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for scopeCategory - filter by category.
|
||||||
|
*/
|
||||||
|
public function scopeInCategory($query, string $category)
|
||||||
|
{
|
||||||
|
return $this->scopeCategory($query, $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to filter by disk.
|
||||||
|
*/
|
||||||
|
public function scopeDisk($query, string $disk)
|
||||||
|
{
|
||||||
|
return $query->where('disk', $disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for scopeDisk - filter by disk.
|
||||||
|
*/
|
||||||
|
public function scopeForDisk($query, string $disk)
|
||||||
|
{
|
||||||
|
return $this->scopeDisk($query, $disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable file size.
|
||||||
|
*/
|
||||||
|
public function getFileSizeHumanAttribute(): string
|
||||||
|
{
|
||||||
|
$bytes = $this->file_size ?? 0;
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
|
||||||
|
$power = min($power, count($units) - 1);
|
||||||
|
|
||||||
|
return round($bytes / (1024 ** $power), 2).' '.$units[$power];
|
||||||
|
}
|
||||||
|
}
|
||||||
396
app/Core/Cdn/Services/AssetPipeline.php
Normal file
396
app/Core/Cdn/Services/AssetPipeline.php
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<?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 Core\Cdn\Jobs\PushAssetToCdn;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset processing pipeline for the dual-bucket CDN architecture.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Store raw upload -> private bucket (optional, for processing)
|
||||||
|
* 2. Process (resize, optimize, etc.) -> handled by caller
|
||||||
|
* 3. Store processed -> public bucket
|
||||||
|
* 4. Push to CDN storage zone
|
||||||
|
*
|
||||||
|
* Categories define path prefixes:
|
||||||
|
* - media: General media uploads
|
||||||
|
* - social: Social media assets
|
||||||
|
* - page: Page builder assets
|
||||||
|
* - avatar: User/workspace avatars
|
||||||
|
* - content: ContentMedia
|
||||||
|
* - static: Static assets
|
||||||
|
* - 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
|
||||||
|
{
|
||||||
|
protected StorageUrlResolver $urlResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage manager instance (Core\Plug\Storage\StorageManager when available).
|
||||||
|
*/
|
||||||
|
protected ?object $storage = null;
|
||||||
|
|
||||||
|
public function __construct(StorageUrlResolver $urlResolver, ?object $storage = null)
|
||||||
|
{
|
||||||
|
$this->urlResolver = $urlResolver;
|
||||||
|
$this->storage = $storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process and store an uploaded file.
|
||||||
|
*
|
||||||
|
* @param UploadedFile $file The uploaded file
|
||||||
|
* @param string $category Category key (media, social, page, etc.)
|
||||||
|
* @param string|null $filename Custom filename (auto-generated if null)
|
||||||
|
* @param array $options Additional options (workspace_id, user_id, etc.)
|
||||||
|
* @return array{path: string, cdn_url: string, origin_url: string, size: int, mime: string}
|
||||||
|
*/
|
||||||
|
public function store(UploadedFile $file, string $category, ?string $filename = null, array $options = []): array
|
||||||
|
{
|
||||||
|
$filename = $filename ?? $this->generateFilename($file);
|
||||||
|
$path = $this->buildPath($category, $filename, $options);
|
||||||
|
|
||||||
|
// Store to public bucket
|
||||||
|
$stored = $this->urlResolver->publicDisk()->putFileAs(
|
||||||
|
dirname($path),
|
||||||
|
$file,
|
||||||
|
basename($path)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $stored) {
|
||||||
|
throw new \RuntimeException("Failed to store file at: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue CDN push if enabled
|
||||||
|
$this->queueCdnPush('hetzner-public', $path, 'public');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'cdn_url' => $this->urlResolver->cdn($path),
|
||||||
|
'origin_url' => $this->urlResolver->origin($path),
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
'mime' => $file->getMimeType(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store raw content (string or stream).
|
||||||
|
*
|
||||||
|
* @param string|resource $contents File contents
|
||||||
|
* @param string $category Category key
|
||||||
|
* @param string $filename Filename with extension
|
||||||
|
* @param array $options Additional options
|
||||||
|
* @return array{path: string, cdn_url: string, origin_url: string}
|
||||||
|
*/
|
||||||
|
public function storeContents($contents, string $category, string $filename, array $options = []): array
|
||||||
|
{
|
||||||
|
$path = $this->buildPath($category, $filename, $options);
|
||||||
|
|
||||||
|
$stored = $this->urlResolver->publicDisk()->put($path, $contents);
|
||||||
|
|
||||||
|
if (! $stored) {
|
||||||
|
throw new \RuntimeException("Failed to store content at: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->queueCdnPush('hetzner-public', $path, 'public');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'cdn_url' => $this->urlResolver->cdn($path),
|
||||||
|
'origin_url' => $this->urlResolver->origin($path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store to private bucket (for DRM/gated content).
|
||||||
|
*
|
||||||
|
* @param UploadedFile|string|resource $content File or contents
|
||||||
|
* @param string $category Category key
|
||||||
|
* @param string|null $filename Filename (required for non-UploadedFile)
|
||||||
|
* @param array $options Additional options
|
||||||
|
* @return array{path: string, private_url: string}
|
||||||
|
*/
|
||||||
|
public function storePrivate($content, string $category, ?string $filename = null, array $options = []): array
|
||||||
|
{
|
||||||
|
if ($content instanceof UploadedFile) {
|
||||||
|
$filename = $filename ?? $this->generateFilename($content);
|
||||||
|
$path = $this->buildPath($category, $filename, $options);
|
||||||
|
|
||||||
|
$stored = $this->urlResolver->privateDisk()->putFileAs(
|
||||||
|
dirname($path),
|
||||||
|
$content,
|
||||||
|
basename($path)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (! $filename) {
|
||||||
|
throw new \InvalidArgumentException('Filename required for non-UploadedFile content');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->buildPath($category, $filename, $options);
|
||||||
|
$stored = $this->urlResolver->privateDisk()->put($path, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $stored) {
|
||||||
|
throw new \RuntimeException("Failed to store private content at: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->queueCdnPush('hetzner-private', $path, 'private');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'private_url' => $this->urlResolver->private($path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy an existing file from one bucket to another.
|
||||||
|
*
|
||||||
|
* @param string $sourcePath Source path
|
||||||
|
* @param string $sourceBucket Source bucket ('public' or 'private')
|
||||||
|
* @param string $destBucket Destination bucket ('public' or 'private')
|
||||||
|
* @param string|null $destPath Destination path (same as source if null)
|
||||||
|
* @return array{path: string, bucket: string}
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException If source file not found or copy fails
|
||||||
|
*/
|
||||||
|
public function copy(string $sourcePath, string $sourceBucket, string $destBucket, ?string $destPath = null): array
|
||||||
|
{
|
||||||
|
$sourceDisk = $sourceBucket === 'private'
|
||||||
|
? $this->urlResolver->privateDisk()
|
||||||
|
: $this->urlResolver->publicDisk();
|
||||||
|
|
||||||
|
$destDisk = $destBucket === 'private'
|
||||||
|
? $this->urlResolver->privateDisk()
|
||||||
|
: $this->urlResolver->publicDisk();
|
||||||
|
|
||||||
|
$destPath = $destPath ?? $sourcePath;
|
||||||
|
|
||||||
|
$contents = $sourceDisk->get($sourcePath);
|
||||||
|
|
||||||
|
if ($contents === null) {
|
||||||
|
throw new \RuntimeException("Source file not found: {$sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = $destDisk->put($destPath, $contents);
|
||||||
|
|
||||||
|
if (! $stored) {
|
||||||
|
throw new \RuntimeException("Failed to copy to: {$destPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$hetznerDisk = $destBucket === 'private' ? 'hetzner-private' : 'hetzner-public';
|
||||||
|
$this->queueCdnPush($hetznerDisk, $destPath, $destBucket);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $destPath,
|
||||||
|
'bucket' => $destBucket,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an asset from storage and CDN.
|
||||||
|
*
|
||||||
|
* @param string $path File path
|
||||||
|
* @param string $bucket 'public' or 'private'
|
||||||
|
* @return bool True if deletion was successful
|
||||||
|
*/
|
||||||
|
public function delete(string $path, string $bucket = 'public'): bool
|
||||||
|
{
|
||||||
|
return $this->urlResolver->deleteAsset($path, $bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple assets.
|
||||||
|
*
|
||||||
|
* @param array<string> $paths File paths
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
$disk = $bucket === 'private'
|
||||||
|
? $this->urlResolver->privateDisk()
|
||||||
|
: $this->urlResolver->publicDisk();
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$results[$path] = $disk->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk delete from CDN storage (requires StorageManager from Plug module)
|
||||||
|
if ($this->storage !== null) {
|
||||||
|
$this->storage->zone($bucket)->delete()->paths($paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge from CDN cache if enabled
|
||||||
|
if (config('cdn.pipeline.auto_purge', true)) {
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$this->urlResolver->purge($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get URLs for a path.
|
||||||
|
*
|
||||||
|
* @param string $path File path
|
||||||
|
* @return array{cdn: string, origin: string}
|
||||||
|
*/
|
||||||
|
public function urls(string $path): array
|
||||||
|
{
|
||||||
|
return $this->urlResolver->urls($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$prefix = $this->urlResolver->pathPrefix($category);
|
||||||
|
$parts = [$prefix];
|
||||||
|
|
||||||
|
// Add workspace scope if provided
|
||||||
|
if (isset($options['workspace_id'])) {
|
||||||
|
$parts[] = 'ws_'.$options['workspace_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user scope if provided
|
||||||
|
if (isset($options['user_id'])) {
|
||||||
|
$parts[] = 'u_'.$options['user_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add date partitioning for media files
|
||||||
|
if (in_array($category, ['media', 'social', 'content'])) {
|
||||||
|
$parts[] = date('Y/m');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = $filename;
|
||||||
|
|
||||||
|
return implode('/', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique filename.
|
||||||
|
*
|
||||||
|
* @param UploadedFile $file The uploaded file
|
||||||
|
* @return string Unique filename with original extension
|
||||||
|
*/
|
||||||
|
protected function generateFilename(UploadedFile $file): string
|
||||||
|
{
|
||||||
|
$extension = $file->getClientOriginalExtension();
|
||||||
|
$hash = Str::random(16);
|
||||||
|
|
||||||
|
return "{$hash}.{$extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
*/
|
||||||
|
protected function queueCdnPush(string $disk, string $path, string $zone): void
|
||||||
|
{
|
||||||
|
if (! config('cdn.pipeline.auto_push', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! config('cdn.bunny.push_enabled', false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = config('cdn.pipeline.queue');
|
||||||
|
|
||||||
|
if ($queue) {
|
||||||
|
PushAssetToCdn::dispatch($disk, $path, $zone);
|
||||||
|
} elseif ($this->storage !== null) {
|
||||||
|
// Synchronous push if no queue configured (requires StorageManager from Plug module)
|
||||||
|
$diskInstance = Storage::disk($disk);
|
||||||
|
if ($diskInstance->exists($path)) {
|
||||||
|
$contents = $diskInstance->get($path);
|
||||||
|
$this->storage->zone($zone)->upload()->contents($path, $contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists in storage.
|
||||||
|
*
|
||||||
|
* @param string $path File path
|
||||||
|
* @param string $bucket 'public' or 'private'
|
||||||
|
* @return bool True if file exists
|
||||||
|
*/
|
||||||
|
public function exists(string $path, string $bucket = 'public'): bool
|
||||||
|
{
|
||||||
|
$disk = $bucket === 'private'
|
||||||
|
? $this->urlResolver->privateDisk()
|
||||||
|
: $this->urlResolver->publicDisk();
|
||||||
|
|
||||||
|
return $disk->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file size in bytes.
|
||||||
|
*
|
||||||
|
* @param string $path File path
|
||||||
|
* @param string $bucket 'public' or 'private'
|
||||||
|
*/
|
||||||
|
public function size(string $path, string $bucket = 'public'): ?int
|
||||||
|
{
|
||||||
|
$disk = $bucket === 'private'
|
||||||
|
? $this->urlResolver->privateDisk()
|
||||||
|
: $this->urlResolver->publicDisk();
|
||||||
|
|
||||||
|
return $disk->exists($path) ? $disk->size($path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file MIME type.
|
||||||
|
*
|
||||||
|
* @param string $path File path
|
||||||
|
* @param string $bucket 'public' or 'private'
|
||||||
|
*/
|
||||||
|
public function mimeType(string $path, string $bucket = 'public'): ?string
|
||||||
|
{
|
||||||
|
$disk = $bucket === 'private'
|
||||||
|
? $this->urlResolver->privateDisk()
|
||||||
|
: $this->urlResolver->publicDisk();
|
||||||
|
|
||||||
|
return $disk->exists($path) ? $disk->mimeType($path) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
386
app/Core/Cdn/Services/BunnyCdnService.php
Normal file
386
app/Core/Cdn/Services/BunnyCdnService.php
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
<?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 Core\Config\ConfigService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BunnyCDN Pull Zone API service.
|
||||||
|
*
|
||||||
|
* Handles CDN operations via BunnyCDN API:
|
||||||
|
* - Cache purging (URL, tag, workspace, global)
|
||||||
|
* - Statistics retrieval
|
||||||
|
* - Pull zone management
|
||||||
|
*
|
||||||
|
* ## Methods
|
||||||
|
*
|
||||||
|
* | Method | Returns | Description |
|
||||||
|
* |--------|---------|-------------|
|
||||||
|
* | `isConfigured()` | `bool` | Check if BunnyCDN is configured |
|
||||||
|
* | `purgeUrl()` | `bool` | Purge a single URL from cache |
|
||||||
|
* | `purgeUrls()` | `bool` | Purge multiple URLs from cache |
|
||||||
|
* | `purgeAll()` | `bool` | Purge entire pull zone cache |
|
||||||
|
* | `purgeByTag()` | `bool` | Purge cache by tag |
|
||||||
|
* | `purgeWorkspace()` | `bool` | Purge all cached content for a workspace |
|
||||||
|
* | `getStats()` | `array\|null` | Get CDN statistics for pull zone |
|
||||||
|
* | `getBandwidth()` | `array\|null` | Get bandwidth usage for pull zone |
|
||||||
|
* | `listStorageFiles()` | `array\|null` | List files in storage zone |
|
||||||
|
* | `uploadFile()` | `bool` | Upload a file to storage zone |
|
||||||
|
* | `deleteFile()` | `bool` | Delete a file from storage zone |
|
||||||
|
*/
|
||||||
|
class BunnyCdnService
|
||||||
|
{
|
||||||
|
protected string $apiKey;
|
||||||
|
|
||||||
|
protected string $pullZoneId;
|
||||||
|
|
||||||
|
protected string $baseUrl = 'https://api.bunny.net';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ConfigService $config,
|
||||||
|
) {
|
||||||
|
$this->apiKey = $this->config->get('cdn.bunny.api_key') ?? '';
|
||||||
|
$this->pullZoneId = $this->config->get('cdn.bunny.pull_zone_id') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$sensitiveKeys = array_filter([
|
||||||
|
$this->apiKey,
|
||||||
|
$this->config->get('cdn.bunny.storage.public.api_key'),
|
||||||
|
$this->config->get('cdn.bunny.storage.private.api_key'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($sensitiveKeys as $key) {
|
||||||
|
if ($key !== '' && str_contains($message, $key)) {
|
||||||
|
$message = str_replace($key, '[REDACTED]', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the service is configured.
|
||||||
|
*
|
||||||
|
* @return bool True if BunnyCDN API key and pull zone ID are configured
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->config->isConfigured('cdn.bunny');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Cache Purging
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
return $this->purgeUrls([$url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
Log::warning('BunnyCDN: Cannot purge - not configured');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $this->apiKey,
|
||||||
|
])->post("{$this->baseUrl}/purge", [
|
||||||
|
'url' => $url,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('BunnyCDN: Purge failed', [
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: Purge exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge entire pull zone cache.
|
||||||
|
*
|
||||||
|
* @return bool True if purge was successful, false otherwise
|
||||||
|
*/
|
||||||
|
public function purgeAll(): bool
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $this->apiKey,
|
||||||
|
])->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache");
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: PurgeAll exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $this->apiKey,
|
||||||
|
])->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache", [
|
||||||
|
'CacheTag' => $tag,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: PurgeByTag exception', [
|
||||||
|
'tag' => $tag,
|
||||||
|
'error' => $this->sanitizeErrorMessage($e->getMessage()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge all cached content for a workspace.
|
||||||
|
*
|
||||||
|
* @param object $workspace Workspace model instance (requires uuid property)
|
||||||
|
* @return bool True if purge was successful, false otherwise
|
||||||
|
*/
|
||||||
|
public function purgeWorkspace(object $workspace): bool
|
||||||
|
{
|
||||||
|
return $this->purgeByTag("workspace-{$workspace->uuid}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Statistics
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$params = [
|
||||||
|
'pullZone' => $this->pullZoneId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dateFrom) {
|
||||||
|
$params['dateFrom'] = $dateFrom;
|
||||||
|
}
|
||||||
|
if ($dateTo) {
|
||||||
|
$params['dateTo'] = $dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $this->apiKey,
|
||||||
|
])->get("{$this->baseUrl}/statistics", $params);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: GetStats exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$stats = $this->getStats($dateFrom, $dateTo);
|
||||||
|
|
||||||
|
if (! $stats) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_bandwidth' => $stats['TotalBandwidthUsed'] ?? 0,
|
||||||
|
'cached_bandwidth' => $stats['CacheHitRate'] ?? 0,
|
||||||
|
'origin_bandwidth' => $stats['TotalOriginTraffic'] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Storage Zone Operations (via API)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a storage zone via API.
|
||||||
|
*
|
||||||
|
* Note: For direct storage operations, use BunnyStorageService instead.
|
||||||
|
*
|
||||||
|
* @param string $storageZoneName Name of the storage zone
|
||||||
|
* @param string $path Path within the storage zone (default: root)
|
||||||
|
* @return array<int, array<string, mixed>>|null Array of file objects or null on failure
|
||||||
|
*/
|
||||||
|
public function listStorageFiles(string $storageZoneName, string $path = '/'): ?array
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key');
|
||||||
|
$region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com');
|
||||||
|
|
||||||
|
$url = "https://{$region}/{$storageZoneName}/{$path}";
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $storageApiKey,
|
||||||
|
])->get($url);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: ListStorageFiles exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to storage zone via API.
|
||||||
|
*
|
||||||
|
* Note: For direct storage operations, use BunnyStorageService instead.
|
||||||
|
*
|
||||||
|
* @param string $storageZoneName Name of the storage zone
|
||||||
|
* @param string $path Target path within the storage zone
|
||||||
|
* @param string $contents File contents to upload
|
||||||
|
* @return bool True if upload was successful, false otherwise
|
||||||
|
*/
|
||||||
|
public function uploadFile(string $storageZoneName, string $path, string $contents): bool
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key');
|
||||||
|
$region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com');
|
||||||
|
|
||||||
|
$url = "https://{$region}/{$storageZoneName}/{$path}";
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $storageApiKey,
|
||||||
|
'Content-Type' => 'application/octet-stream',
|
||||||
|
])->withBody($contents, 'application/octet-stream')->put($url);
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: UploadFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from storage zone via API.
|
||||||
|
*
|
||||||
|
* Note: For direct storage operations, use BunnyStorageService instead.
|
||||||
|
*
|
||||||
|
* @param string $storageZoneName Name of the storage zone
|
||||||
|
* @param string $path Path of the file to delete
|
||||||
|
* @return bool True if deletion was successful, false otherwise
|
||||||
|
*/
|
||||||
|
public function deleteFile(string $storageZoneName, string $path): bool
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$storageApiKey = $this->config->get('cdn.bunny.storage.public.api_key');
|
||||||
|
$region = $this->config->get('cdn.bunny.storage.public.hostname', 'storage.bunnycdn.com');
|
||||||
|
|
||||||
|
$url = "https://{$region}/{$storageZoneName}/{$path}";
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'AccessKey' => $storageApiKey,
|
||||||
|
])->delete($url);
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyCDN: DeleteFile exception', ['error' => $this->sanitizeErrorMessage($e->getMessage())]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
712
app/Core/Cdn/Services/BunnyStorageService.php
Normal file
712
app/Core/Cdn/Services/BunnyStorageService.php
Normal file
|
|
@ -0,0 +1,712 @@
|
||||||
|
<?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 Bunny\Storage\Client;
|
||||||
|
use Core\Config\ConfigService;
|
||||||
|
use Core\Crypt\LthnHash;
|
||||||
|
use Core\Service\Contracts\HealthCheckable;
|
||||||
|
use Core\Service\HealthCheckResult;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BunnyCDN Storage Zone service for direct file operations.
|
||||||
|
*
|
||||||
|
* Manages file uploads/downloads to BunnyCDN storage zones:
|
||||||
|
* - Public zone: General assets, media
|
||||||
|
* - Private zone: DRM/gated content
|
||||||
|
*
|
||||||
|
* Supports vBucket scoping for workspace-isolated CDN paths.
|
||||||
|
* Implements HealthCheckable for monitoring CDN connectivity.
|
||||||
|
*/
|
||||||
|
class BunnyStorageService implements HealthCheckable
|
||||||
|
{
|
||||||
|
protected ?Client $publicClient = null;
|
||||||
|
|
||||||
|
protected ?Client $privateClient = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default maximum file size in bytes (100MB).
|
||||||
|
*/
|
||||||
|
protected const DEFAULT_MAX_FILE_SIZE = 104857600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum retry attempts for failed uploads.
|
||||||
|
*/
|
||||||
|
protected const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base delay in milliseconds for exponential backoff.
|
||||||
|
*/
|
||||||
|
protected const RETRY_BASE_DELAY_MS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
protected ConfigService $config,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public storage zone client.
|
||||||
|
*/
|
||||||
|
public function publicClient(): ?Client
|
||||||
|
{
|
||||||
|
if ($this->publicClient === null && $this->isConfigured('public')) {
|
||||||
|
$this->publicClient = new Client(
|
||||||
|
$this->config->get('cdn.bunny.storage.public.api_key'),
|
||||||
|
$this->config->get('cdn.bunny.storage.public.name'),
|
||||||
|
$this->config->get('cdn.bunny.storage.public.region', Client::STORAGE_ZONE_FS_EU)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->publicClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the private storage zone client.
|
||||||
|
*/
|
||||||
|
public function privateClient(): ?Client
|
||||||
|
{
|
||||||
|
if ($this->privateClient === null && $this->isConfigured('private')) {
|
||||||
|
$this->privateClient = new Client(
|
||||||
|
$this->config->get('cdn.bunny.storage.private.api_key'),
|
||||||
|
$this->config->get('cdn.bunny.storage.private.name'),
|
||||||
|
$this->config->get('cdn.bunny.storage.private.region', Client::STORAGE_ZONE_FS_EU)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->privateClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a storage zone is configured.
|
||||||
|
*/
|
||||||
|
public function isConfigured(string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
return $this->config->isConfigured("cdn.bunny.storage.{$zone}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if CDN push is enabled.
|
||||||
|
*/
|
||||||
|
public function isPushEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->config->get('cdn.bunny.push_enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a storage zone path.
|
||||||
|
*/
|
||||||
|
public function list(string $path, string $zone = 'public'): array
|
||||||
|
{
|
||||||
|
$client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
|
||||||
|
|
||||||
|
if (! $client) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $client->listFiles($path);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyStorage: Failed to list files', [
|
||||||
|
'path' => $path,
|
||||||
|
'zone' => $zone,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to storage zone.
|
||||||
|
*/
|
||||||
|
public function upload(string $localPath, string $remotePath, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
|
||||||
|
|
||||||
|
if (! $client) {
|
||||||
|
Log::warning('BunnyStorage: Client not configured', ['zone' => $zone]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! file_exists($localPath)) {
|
||||||
|
Log::error('BunnyStorage: Local file not found', ['local' => $localPath]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileSize = filesize($localPath);
|
||||||
|
$maxSize = $this->getMaxFileSize();
|
||||||
|
|
||||||
|
if ($fileSize === false || $fileSize > $maxSize) {
|
||||||
|
Log::error('BunnyStorage: File size exceeds limit', [
|
||||||
|
'local' => $localPath,
|
||||||
|
'size' => $fileSize,
|
||||||
|
'max_size' => $maxSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = $this->detectContentType($localPath);
|
||||||
|
|
||||||
|
return $this->executeWithRetry(function () use ($client, $localPath, $remotePath, $contentType) {
|
||||||
|
// The Bunny SDK upload method accepts optional headers parameter
|
||||||
|
// Pass content-type for proper CDN handling
|
||||||
|
$client->upload($localPath, $remotePath, ['Content-Type' => $contentType]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
'local' => $localPath,
|
||||||
|
'remote' => $remotePath,
|
||||||
|
'zone' => $zone,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
], 'Upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maximum allowed file size in bytes.
|
||||||
|
*/
|
||||||
|
protected function getMaxFileSize(): int
|
||||||
|
{
|
||||||
|
return (int) $this->config->get('cdn.bunny.max_file_size', self::DEFAULT_MAX_FILE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
protected function executeWithRetry(callable $operation, array $context, string $operationName): bool
|
||||||
|
{
|
||||||
|
$lastException = null;
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= self::MAX_RETRY_ATTEMPTS; $attempt++) {
|
||||||
|
try {
|
||||||
|
return $operation();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$lastException = $e;
|
||||||
|
|
||||||
|
if ($attempt < self::MAX_RETRY_ATTEMPTS) {
|
||||||
|
$delayMs = self::RETRY_BASE_DELAY_MS * (2 ** ($attempt - 1));
|
||||||
|
usleep($delayMs * 1000);
|
||||||
|
|
||||||
|
Log::warning("BunnyStorage: {$operationName} attempt {$attempt} failed, retrying", array_merge($context, [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'next_delay_ms' => $delayMs * 2,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error("BunnyStorage: {$operationName} failed after ".self::MAX_RETRY_ATTEMPTS.' attempts', array_merge($context, [
|
||||||
|
'error' => $lastException?->getMessage() ?? 'Unknown error',
|
||||||
|
]));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file contents directly.
|
||||||
|
*/
|
||||||
|
public function putContents(string $remotePath, string $contents, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
|
||||||
|
|
||||||
|
if (! $client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentSize = strlen($contents);
|
||||||
|
$maxSize = $this->getMaxFileSize();
|
||||||
|
|
||||||
|
if ($contentSize > $maxSize) {
|
||||||
|
Log::error('BunnyStorage: Content size exceeds limit', [
|
||||||
|
'remote' => $remotePath,
|
||||||
|
'size' => $contentSize,
|
||||||
|
'max_size' => $maxSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = $this->detectContentType($remotePath, $contents);
|
||||||
|
|
||||||
|
return $this->executeWithRetry(function () use ($client, $remotePath, $contents, $contentType) {
|
||||||
|
// The Bunny SDK putContents method accepts optional headers parameter
|
||||||
|
// Pass content-type for proper CDN handling
|
||||||
|
$client->putContents($remotePath, $contents, ['Content-Type' => $contentType]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
'remote' => $remotePath,
|
||||||
|
'zone' => $zone,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
], 'putContents');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file contents.
|
||||||
|
*/
|
||||||
|
public function getContents(string $remotePath, string $zone = 'public'): ?string
|
||||||
|
{
|
||||||
|
$client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
|
||||||
|
|
||||||
|
if (! $client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $client->getContents($remotePath);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyStorage: getContents failed', [
|
||||||
|
'remote' => $remotePath,
|
||||||
|
'zone' => $zone,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from storage zone.
|
||||||
|
*/
|
||||||
|
public function delete(string $remotePath, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$client = $zone === 'private' ? $this->privateClient() : $this->publicClient();
|
||||||
|
|
||||||
|
if (! $client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client->delete($remotePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('BunnyStorage: Delete failed', [
|
||||||
|
'remote' => $remotePath,
|
||||||
|
'zone' => $zone,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple files.
|
||||||
|
*/
|
||||||
|
public function deleteMultiple(array $paths, string $zone = 'public'): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$results[$path] = $this->delete($path, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a file from a Laravel disk to CDN storage zone.
|
||||||
|
*/
|
||||||
|
public function copyFromDisk(string $disk, string $path, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$diskInstance = Storage::disk($disk);
|
||||||
|
|
||||||
|
if (! $diskInstance->exists($path)) {
|
||||||
|
Log::warning('BunnyStorage: Source file not found on disk', [
|
||||||
|
'disk' => $disk,
|
||||||
|
'path' => $path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = $diskInstance->get($path);
|
||||||
|
|
||||||
|
return $this->putContents($path, $contents, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// vBucket operations for workspace-isolated CDN paths
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a vBucket ID for a domain/workspace.
|
||||||
|
*/
|
||||||
|
public function vBucketId(string $domain): string
|
||||||
|
{
|
||||||
|
return LthnHash::vBucketId($domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a vBucket-scoped path.
|
||||||
|
*/
|
||||||
|
public function vBucketPath(string $domain, string $path): string
|
||||||
|
{
|
||||||
|
$vBucketId = $this->vBucketId($domain);
|
||||||
|
|
||||||
|
return $vBucketId.'/'.ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload content with vBucket scoping.
|
||||||
|
*/
|
||||||
|
public function vBucketPutContents(string $domain, string $path, string $contents, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$scopedPath = $this->vBucketPath($domain, $path);
|
||||||
|
|
||||||
|
return $this->putContents($scopedPath, $contents, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file with vBucket scoping.
|
||||||
|
*/
|
||||||
|
public function vBucketUpload(string $domain, string $localPath, string $remotePath, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$scopedPath = $this->vBucketPath($domain, $remotePath);
|
||||||
|
|
||||||
|
return $this->upload($localPath, $scopedPath, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file contents with vBucket scoping.
|
||||||
|
*/
|
||||||
|
public function vBucketGetContents(string $domain, string $path, string $zone = 'public'): ?string
|
||||||
|
{
|
||||||
|
$scopedPath = $this->vBucketPath($domain, $path);
|
||||||
|
|
||||||
|
return $this->getContents($scopedPath, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete file with vBucket scoping.
|
||||||
|
*/
|
||||||
|
public function vBucketDelete(string $domain, string $path, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
$scopedPath = $this->vBucketPath($domain, $path);
|
||||||
|
|
||||||
|
return $this->delete($scopedPath, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files within a vBucket.
|
||||||
|
*/
|
||||||
|
public function vBucketList(string $domain, string $path = '', string $zone = 'public'): array
|
||||||
|
{
|
||||||
|
$scopedPath = $this->vBucketPath($domain, $path);
|
||||||
|
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Core/Cdn/Services/CLAUDE.md
Normal file
13
app/Core/Cdn/Services/CLAUDE.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Cdn/Services/ — CDN Service Layer
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `BunnyCdnService` | BunnyCDN pull zone API — cache purging (URL, tag, workspace, global), statistics retrieval, pull zone management. Uses config from `ConfigService`. |
|
||||||
|
| `BunnyStorageService` | BunnyCDN storage zone API — file upload, download, delete, list. Supports public and private storage zones. |
|
||||||
|
| `StorageOffload` (service) | Manages file offloading to remote storage — upload, track, verify. Creates `StorageOffload` model records. |
|
||||||
|
| `StorageUrlResolver` | URL builder for all asset contexts — CDN, origin, private, signed, apex. Supports virtual buckets (vBucket) per domain. Backs the `Cdn` facade. |
|
||||||
|
| `CdnUrlBuilder` | Low-level URL construction for CDN paths with cache-busting and domain resolution. |
|
||||||
|
| `AssetPipeline` | Orchestrates asset processing — push to CDN, cache headers, versioning. |
|
||||||
|
| `FluxCdnService` | Pushes Flux UI assets to CDN for faster component loading. |
|
||||||
348
app/Core/Cdn/Services/CdnUrlBuilder.php
Normal file
348
app/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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
206
app/Core/Cdn/Services/FluxCdnService.php
Normal file
206
app/Core/Cdn/Services/FluxCdnService.php
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<?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 Core\Helpers\Cdn;
|
||||||
|
use Flux\AssetManager;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDN-aware Flux asset service.
|
||||||
|
*
|
||||||
|
* In development: Uses standard Laravel routes (/flux/flux.js)
|
||||||
|
* In production: Uses CDN URLs (cdn.host.uk.com/flux/flux.min.js)
|
||||||
|
*
|
||||||
|
* Requires Flux assets to be uploaded to CDN storage zone.
|
||||||
|
*
|
||||||
|
* URL building is delegated to CdnUrlBuilder for consistency across services.
|
||||||
|
*
|
||||||
|
* ## Methods
|
||||||
|
*
|
||||||
|
* | Method | Returns | Description |
|
||||||
|
* |--------|---------|-------------|
|
||||||
|
* | `scripts()` | `string` | Get Flux scripts tag with CDN awareness |
|
||||||
|
* | `editorScripts()` | `string` | Get Flux editor scripts (Pro only) |
|
||||||
|
* | `editorStyles()` | `string` | Get Flux editor styles (Pro only) |
|
||||||
|
* | `shouldUseCdn()` | `bool` | Check if CDN should be used |
|
||||||
|
* | `getCdnAssetPaths()` | `array<string, string>` | Get source-to-CDN path mapping |
|
||||||
|
*
|
||||||
|
* @see CdnUrlBuilder For the underlying URL building logic
|
||||||
|
*/
|
||||||
|
class FluxCdnService
|
||||||
|
{
|
||||||
|
protected CdnUrlBuilder $urlBuilder;
|
||||||
|
|
||||||
|
public function __construct(?CdnUrlBuilder $urlBuilder = null)
|
||||||
|
{
|
||||||
|
$this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Flux scripts tag with CDN awareness.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $options Options like ['nonce' => 'abc123']
|
||||||
|
* @return string HTML script tag
|
||||||
|
*/
|
||||||
|
public function scripts(array $options = []): string
|
||||||
|
{
|
||||||
|
$nonce = isset($options['nonce']) ? ' nonce="'.$options['nonce'].'"' : '';
|
||||||
|
|
||||||
|
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
|
||||||
|
if (! $this->shouldUseCdn()) {
|
||||||
|
return app('flux')->scripts($options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, use CDN URL (no vBucket - shared platform asset)
|
||||||
|
$versionHash = $this->getVersionHash();
|
||||||
|
$filename = config('app.debug') ? 'flux.js' : 'flux.min.js';
|
||||||
|
$url = $this->cdnUrl("flux/{$filename}", $versionHash);
|
||||||
|
|
||||||
|
return '<script src="'.$url.'" data-navigate-once'.$nonce.'></script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
if (! Flux::pro()) {
|
||||||
|
throw new \Exception('Flux Pro is required to use the Flux editor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
|
||||||
|
if (! $this->shouldUseCdn()) {
|
||||||
|
return AssetManager::editorScripts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, use CDN URL (no vBucket - shared platform asset)
|
||||||
|
$versionHash = $this->getVersionHash('/editor.js');
|
||||||
|
$filename = config('app.debug') ? 'editor.js' : 'editor.min.js';
|
||||||
|
$url = $this->cdnUrl("flux/{$filename}", $versionHash);
|
||||||
|
|
||||||
|
return '<script src="'.$url.'" defer></script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
if (! Flux::pro()) {
|
||||||
|
throw new \Exception('Flux Pro is required to use the Flux editor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CDN when enabled (respects CDN_FORCE_LOCAL for testing)
|
||||||
|
if (! $this->shouldUseCdn()) {
|
||||||
|
return AssetManager::editorStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, use CDN URL (no vBucket - shared platform asset)
|
||||||
|
$versionHash = $this->getVersionHash('/editor.css');
|
||||||
|
$url = $this->cdnUrl('flux/editor.css', $versionHash);
|
||||||
|
|
||||||
|
return '<link rel="stylesheet" href="'.$url.'">';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$manifestPath = Flux::pro()
|
||||||
|
? base_path('vendor/admin/flux-pro/dist/manifest.json')
|
||||||
|
: base_path('vendor/admin/flux/dist/manifest.json');
|
||||||
|
|
||||||
|
if (! file_exists($manifestPath)) {
|
||||||
|
return substr(md5(config('app.version', '1.0')), 0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
|
||||||
|
return $manifest[$key] ?? substr(md5(config('app.version', '1.0')), 0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we should use CDN for Flux assets.
|
||||||
|
*
|
||||||
|
* Respects CDN_FORCE_LOCAL for testing.
|
||||||
|
*
|
||||||
|
* @return bool True if CDN should be used, false for local assets
|
||||||
|
*/
|
||||||
|
public function shouldUseCdn(): bool
|
||||||
|
{
|
||||||
|
return Cdn::isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CDN URL for shared platform assets (no vBucket scoping).
|
||||||
|
*
|
||||||
|
* Flux assets are shared across all workspaces, so they don't use
|
||||||
|
* workspace-specific vBucket prefixes.
|
||||||
|
*
|
||||||
|
* @param string $path Asset path relative to CDN root
|
||||||
|
* @param string|null $version Optional version hash for cache busting
|
||||||
|
* @return string Full CDN URL with optional version query parameter
|
||||||
|
*/
|
||||||
|
protected function cdnUrl(string $path, ?string $version = null): string
|
||||||
|
{
|
||||||
|
$cdnUrl = config('cdn.urls.cdn');
|
||||||
|
|
||||||
|
if (empty($cdnUrl)) {
|
||||||
|
$baseUrl = asset($path);
|
||||||
|
|
||||||
|
return $this->urlBuilder->withVersion($baseUrl, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->urlBuilder->cdn($path);
|
||||||
|
|
||||||
|
return $this->urlBuilder->withVersion($url, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of Flux files that should be uploaded to CDN.
|
||||||
|
*
|
||||||
|
* @return array<string, string> Map of source path => CDN path
|
||||||
|
*/
|
||||||
|
public function getCdnAssetPaths(): array
|
||||||
|
{
|
||||||
|
$basePath = Flux::pro()
|
||||||
|
? base_path('vendor/admin/flux-pro/dist')
|
||||||
|
: base_path('vendor/admin/flux/dist');
|
||||||
|
|
||||||
|
$files = [
|
||||||
|
"{$basePath}/flux.js" => 'flux/flux.js',
|
||||||
|
"{$basePath}/flux.min.js" => 'flux/flux.min.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add editor files for Pro
|
||||||
|
if (Flux::pro()) {
|
||||||
|
$files["{$basePath}/editor.js"] = 'flux/editor.js';
|
||||||
|
$files["{$basePath}/editor.min.js"] = 'flux/editor.min.js';
|
||||||
|
$files["{$basePath}/editor.css"] = 'flux/editor.css';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
}
|
||||||
410
app/Core/Cdn/Services/StorageOffload.php
Normal file
410
app/Core/Cdn/Services/StorageOffload.php
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
<?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 Core\Cdn\Models\StorageOffload as OffloadModel;
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage offload service for migrating local files to remote storage.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Tracking which files have been offloaded
|
||||||
|
* - Uploading local files to remote storage
|
||||||
|
* - URL resolution for offloaded files
|
||||||
|
* - Cleanup of local files after offload
|
||||||
|
*/
|
||||||
|
class StorageOffload
|
||||||
|
{
|
||||||
|
protected string $disk;
|
||||||
|
|
||||||
|
protected bool $enabled;
|
||||||
|
|
||||||
|
protected bool $keepLocal;
|
||||||
|
|
||||||
|
protected ?string $cdnUrl;
|
||||||
|
|
||||||
|
protected ?int $maxFileSize;
|
||||||
|
|
||||||
|
protected ?array $allowedExtensions;
|
||||||
|
|
||||||
|
protected bool $cacheEnabled;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->disk = config('offload.disk') ?? 'hetzner-public';
|
||||||
|
$this->enabled = config('offload.enabled') ?? false;
|
||||||
|
$this->keepLocal = config('offload.keep_local') ?? true;
|
||||||
|
$this->cdnUrl = config('offload.cdn_url');
|
||||||
|
$this->maxFileSize = config('offload.max_file_size');
|
||||||
|
$this->allowedExtensions = config('offload.allowed_extensions');
|
||||||
|
$this->cacheEnabled = config('offload.cache.enabled') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if storage offload is enabled.
|
||||||
|
*/
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the configured disk name.
|
||||||
|
*/
|
||||||
|
public function getDiskName(): string
|
||||||
|
{
|
||||||
|
return $this->disk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the disk instance.
|
||||||
|
*/
|
||||||
|
public function getDisk(): Filesystem
|
||||||
|
{
|
||||||
|
return Storage::disk($this->disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a local file has been offloaded.
|
||||||
|
*/
|
||||||
|
public function isOffloaded(string $localPath): bool
|
||||||
|
{
|
||||||
|
return OffloadModel::where('local_path', $localPath)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the offload record for a file.
|
||||||
|
*/
|
||||||
|
public function getRecord(string $localPath): ?OffloadModel
|
||||||
|
{
|
||||||
|
return OffloadModel::where('local_path', $localPath)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the remote URL for an offloaded file.
|
||||||
|
*/
|
||||||
|
public function url(string $localPath): ?string
|
||||||
|
{
|
||||||
|
// Check cache first
|
||||||
|
if ($this->cacheEnabled) {
|
||||||
|
$cached = Cache::get("offload_url:{$localPath}");
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached ?: null; // Empty string means no record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = OffloadModel::where('local_path', $localPath)->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
if ($this->cacheEnabled) {
|
||||||
|
Cache::put("offload_url:{$localPath}", '', 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CDN URL if configured, otherwise fall back to disk URL
|
||||||
|
if ($this->cdnUrl) {
|
||||||
|
$url = rtrim($this->cdnUrl, '/').'/'.ltrim($record->remote_path, '/');
|
||||||
|
} else {
|
||||||
|
$url = Storage::disk($this->disk)->url($record->remote_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->cacheEnabled) {
|
||||||
|
Cache::put("offload_url:{$localPath}", $url, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local file to remote storage.
|
||||||
|
*
|
||||||
|
* @param string $localPath Absolute path to local file
|
||||||
|
* @param string|null $remotePath Custom remote path (auto-generated if null)
|
||||||
|
* @param string $category Category for path prefixing
|
||||||
|
* @param array $metadata Additional metadata to store
|
||||||
|
* @return OffloadModel|null The offload record on success
|
||||||
|
*/
|
||||||
|
public function upload(string $localPath, ?string $remotePath = null, string $category = 'media', array $metadata = []): ?OffloadModel
|
||||||
|
{
|
||||||
|
if (! $this->enabled) {
|
||||||
|
Log::debug('StorageOffload: Offload disabled');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! File::exists($localPath)) {
|
||||||
|
Log::warning('StorageOffload: Local file not found', ['path' => $localPath]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileSize = File::size($localPath);
|
||||||
|
|
||||||
|
// Check max file size
|
||||||
|
if ($this->maxFileSize !== null && $fileSize > $this->maxFileSize) {
|
||||||
|
Log::debug('StorageOffload: File exceeds max size', [
|
||||||
|
'path' => $localPath,
|
||||||
|
'size' => $fileSize,
|
||||||
|
'max' => $this->maxFileSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allowed extensions
|
||||||
|
$extension = strtolower(pathinfo($localPath, PATHINFO_EXTENSION));
|
||||||
|
if ($this->allowedExtensions !== null && ! in_array($extension, $this->allowedExtensions)) {
|
||||||
|
Log::debug('StorageOffload: Extension not allowed', [
|
||||||
|
'path' => $localPath,
|
||||||
|
'extension' => $extension,
|
||||||
|
'allowed' => $this->allowedExtensions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already offloaded
|
||||||
|
if ($this->isOffloaded($localPath)) {
|
||||||
|
Log::debug('StorageOffload: File already offloaded', ['path' => $localPath]);
|
||||||
|
|
||||||
|
return OffloadModel::where('local_path', $localPath)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate remote path if not provided
|
||||||
|
$remotePath = $remotePath ?? $this->generateRemotePath($localPath, $category);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read file contents
|
||||||
|
$contents = File::get($localPath);
|
||||||
|
$hash = hash('sha256', $contents);
|
||||||
|
$mimeType = File::mimeType($localPath);
|
||||||
|
|
||||||
|
// Upload to remote storage
|
||||||
|
$disk = Storage::disk($this->disk);
|
||||||
|
$uploaded = $disk->put($remotePath, $contents);
|
||||||
|
|
||||||
|
if (! $uploaded) {
|
||||||
|
Log::error('StorageOffload: Upload failed', [
|
||||||
|
'local' => $localPath,
|
||||||
|
'remote' => $remotePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge original_name into metadata only if not already set
|
||||||
|
if (! isset($metadata['original_name'])) {
|
||||||
|
$metadata['original_name'] = basename($localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tracking record
|
||||||
|
$record = OffloadModel::create([
|
||||||
|
'local_path' => $localPath,
|
||||||
|
'remote_path' => $remotePath,
|
||||||
|
'disk' => $this->disk,
|
||||||
|
'hash' => $hash,
|
||||||
|
'file_size' => $fileSize,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'category' => $category,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'offloaded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete local file if not keeping
|
||||||
|
if (! $this->keepLocal) {
|
||||||
|
File::delete($localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('StorageOffload: File offloaded successfully', [
|
||||||
|
'local' => $localPath,
|
||||||
|
'remote' => $remotePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('StorageOffload: Exception during upload', [
|
||||||
|
'path' => $localPath,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch upload multiple files.
|
||||||
|
*
|
||||||
|
* @param array<string> $localPaths List of local paths
|
||||||
|
* @param string $category Category for path prefixing
|
||||||
|
* @return array{uploaded: int, failed: int, skipped: int}
|
||||||
|
*/
|
||||||
|
public function uploadBatch(array $localPaths, string $category = 'media'): array
|
||||||
|
{
|
||||||
|
$results = [
|
||||||
|
'uploaded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($localPaths as $path) {
|
||||||
|
if ($this->isOffloaded($path)) {
|
||||||
|
$results['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->upload($path, null, $category);
|
||||||
|
|
||||||
|
if ($record) {
|
||||||
|
$results['uploaded']++;
|
||||||
|
} else {
|
||||||
|
$results['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a remote path for a local file.
|
||||||
|
*/
|
||||||
|
protected function generateRemotePath(string $localPath, string $category): string
|
||||||
|
{
|
||||||
|
$extension = pathinfo($localPath, PATHINFO_EXTENSION);
|
||||||
|
$hash = Str::random(16);
|
||||||
|
|
||||||
|
// Date-based partitioning
|
||||||
|
$datePath = date('Y/m');
|
||||||
|
|
||||||
|
// Add 's' suffix to category for plural paths
|
||||||
|
$categoryPath = $category;
|
||||||
|
if (! str_ends_with($categoryPath, 's')) {
|
||||||
|
$categoryPath .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$categoryPath}/{$datePath}/{$hash}.{$extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all offloaded files for a category.
|
||||||
|
*
|
||||||
|
* @return Collection<OffloadModel>
|
||||||
|
*/
|
||||||
|
public function getByCategory(string $category)
|
||||||
|
{
|
||||||
|
return OffloadModel::where('category', $category)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an offloaded file from remote storage.
|
||||||
|
*/
|
||||||
|
public function delete(string $localPath): bool
|
||||||
|
{
|
||||||
|
$record = OffloadModel::where('local_path', $localPath)->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete from remote storage
|
||||||
|
Storage::disk($this->disk)->delete($record->remote_path);
|
||||||
|
|
||||||
|
// Delete tracking record
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
if ($this->cacheEnabled) {
|
||||||
|
Cache::forget("offload_url:{$localPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('StorageOffload: Delete failed', [
|
||||||
|
'path' => $localPath,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify file integrity by comparing hash.
|
||||||
|
*/
|
||||||
|
public function verifyIntegrity(string $localPath): bool
|
||||||
|
{
|
||||||
|
$record = OffloadModel::where('local_path', $localPath)->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$remoteContents = Storage::disk($this->disk)->get($record->remote_path);
|
||||||
|
$remoteHash = hash('sha256', $remoteContents);
|
||||||
|
|
||||||
|
return hash_equals($record->hash, $remoteHash);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('StorageOffload: Integrity check failed', [
|
||||||
|
'path' => $localPath,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage statistics.
|
||||||
|
*/
|
||||||
|
public function getStats(): array
|
||||||
|
{
|
||||||
|
$totalFiles = OffloadModel::count();
|
||||||
|
$totalSize = OffloadModel::sum('file_size');
|
||||||
|
|
||||||
|
$byCategory = OffloadModel::selectRaw('category, COUNT(*) as count, SUM(file_size) as total_size')
|
||||||
|
->groupBy('category')
|
||||||
|
->get()
|
||||||
|
->keyBy('category')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_files' => $totalFiles,
|
||||||
|
'total_size' => $totalSize,
|
||||||
|
'total_size_human' => $this->formatBytes($totalSize),
|
||||||
|
'by_category' => $byCategory,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable string.
|
||||||
|
*/
|
||||||
|
protected function formatBytes(int|string|null $bytes): string
|
||||||
|
{
|
||||||
|
$bytes = (int) ($bytes ?? 0);
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
|
||||||
|
$power = min($power, count($units) - 1);
|
||||||
|
|
||||||
|
return round($bytes / (1024 ** $power), 2).' '.$units[$power];
|
||||||
|
}
|
||||||
|
}
|
||||||
500
app/Core/Cdn/Services/StorageUrlResolver.php
Normal file
500
app/Core/Cdn/Services/StorageUrlResolver.php
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
<?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\Cdn\Jobs\PushAssetToCdn;
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-aware URL resolver for CDN/storage architecture.
|
||||||
|
*
|
||||||
|
* Provides intelligent URL resolution based on request context:
|
||||||
|
* - Admin/internal requests -> Origin URLs (Hetzner)
|
||||||
|
* - Public/embed requests -> CDN URLs (BunnyCDN)
|
||||||
|
* - API requests -> Both URLs returned
|
||||||
|
*
|
||||||
|
* Supports vBucket scoping for workspace-isolated CDN paths using LTHN QuasiHash.
|
||||||
|
*
|
||||||
|
* URL building is delegated to CdnUrlBuilder for consistency across services.
|
||||||
|
*
|
||||||
|
* ## Methods
|
||||||
|
*
|
||||||
|
* | Method | Returns | Description |
|
||||||
|
* |--------|---------|-------------|
|
||||||
|
* | `vBucketId()` | `string` | Generate vBucket ID for a domain |
|
||||||
|
* | `vBucketCdn()` | `string` | Get CDN URL with vBucket scoping |
|
||||||
|
* | `vBucketOrigin()` | `string` | Get origin URL with vBucket scoping |
|
||||||
|
* | `vBucketPath()` | `string` | Build vBucket-scoped storage path |
|
||||||
|
* | `vBucketUrls()` | `array` | Get both URLs with vBucket scoping |
|
||||||
|
* | `cdn()` | `string` | Get CDN delivery URL for a path |
|
||||||
|
* | `origin()` | `string` | Get origin URL (Hetzner) for a path |
|
||||||
|
* | `private()` | `string` | Get private storage URL for a path |
|
||||||
|
* | `signedUrl()` | `string\|null` | Get signed URL for private content |
|
||||||
|
* | `apex()` | `string` | Get apex domain URL for a path |
|
||||||
|
* | `asset()` | `string` | Get context-aware URL for a path |
|
||||||
|
* | `urls()` | `array` | Get both CDN and origin URLs |
|
||||||
|
* | `allUrls()` | `array` | Get all URLs (cdn, origin, private, apex) |
|
||||||
|
* | `detectContext()` | `string` | Detect current request context |
|
||||||
|
* | `isAdminContext()` | `bool` | Check if current context is admin |
|
||||||
|
* | `pushToCdn()` | `bool` | Push a file to CDN storage zone |
|
||||||
|
* | `deleteFromCdn()` | `bool` | Delete a file from CDN storage zone |
|
||||||
|
* | `purge()` | `bool` | Purge a path from CDN cache |
|
||||||
|
* | `cachedAsset()` | `string` | Get cached CDN URL with intelligent caching |
|
||||||
|
* | `publicDisk()` | `Filesystem` | Get the public storage disk |
|
||||||
|
* | `privateDisk()` | `Filesystem` | Get the private storage disk |
|
||||||
|
* | `storePublic()` | `bool` | Store file to public bucket |
|
||||||
|
* | `storePrivate()` | `bool` | Store file to private bucket |
|
||||||
|
* | `deleteAsset()` | `bool` | Delete file from storage and CDN |
|
||||||
|
* | `pathPrefix()` | `string` | Get path prefix for a category |
|
||||||
|
* | `categoryPath()` | `string` | Build full path with category prefix |
|
||||||
|
*
|
||||||
|
* @see CdnUrlBuilder For the underlying URL building logic
|
||||||
|
*/
|
||||||
|
class StorageUrlResolver
|
||||||
|
{
|
||||||
|
protected BunnyStorageService $bunnyStorage;
|
||||||
|
|
||||||
|
protected CdnUrlBuilder $urlBuilder;
|
||||||
|
|
||||||
|
public function __construct(BunnyStorageService $bunnyStorage, ?CdnUrlBuilder $urlBuilder = null)
|
||||||
|
{
|
||||||
|
$this->bunnyStorage = $bunnyStorage;
|
||||||
|
$this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL builder instance.
|
||||||
|
*/
|
||||||
|
public function getUrlBuilder(): CdnUrlBuilder
|
||||||
|
{
|
||||||
|
return $this->urlBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a vBucket ID for a domain/workspace.
|
||||||
|
*
|
||||||
|
* Uses LTHN QuasiHash protocol for deterministic, scoped identifiers.
|
||||||
|
* Format: cdn.host.uk.com/{vBucketId}/path/to/asset.js
|
||||||
|
*
|
||||||
|
* @param string $domain The domain name (e.g., "host.uk.com")
|
||||||
|
* @return string 16-character vBucket identifier
|
||||||
|
*/
|
||||||
|
public function vBucketId(string $domain): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->vBucketId($domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CDN URL with vBucket scoping for workspace isolation.
|
||||||
|
*
|
||||||
|
* @param string $domain The workspace domain for scoping
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
*/
|
||||||
|
public function vBucketCdn(string $domain, string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->vBucket($domain, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get origin URL with vBucket scoping for workspace isolation.
|
||||||
|
*
|
||||||
|
* @param string $domain The workspace domain for scoping
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
*/
|
||||||
|
public function vBucketOrigin(string $domain, string $path): string
|
||||||
|
{
|
||||||
|
$scopedPath = $this->urlBuilder->vBucketPath($domain, $path);
|
||||||
|
|
||||||
|
return $this->urlBuilder->origin($scopedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a vBucket-scoped storage path.
|
||||||
|
*
|
||||||
|
* @param string $domain The workspace domain for scoping
|
||||||
|
* @param string $path Path relative to vBucket root
|
||||||
|
*/
|
||||||
|
public function vBucketPath(string $domain, string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->vBucketPath($domain, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get both URLs with vBucket scoping 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
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->vBucketUrls($domain, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CDN delivery URL for a path.
|
||||||
|
* Always returns the BunnyCDN pull zone URL.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
*/
|
||||||
|
public function cdn(string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->cdn($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the origin URL for a path (Hetzner public bucket).
|
||||||
|
* Direct access to origin storage, bypassing CDN.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
*/
|
||||||
|
public function origin(string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->origin($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the private storage URL for a path.
|
||||||
|
* For DRM/gated content - not publicly accessible.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
*/
|
||||||
|
public function private(string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->private($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed URL for private CDN content with token authentication.
|
||||||
|
* Generates time-limited access URLs for gated/DRM content.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
* @param int|Carbon|null $expiry Expiry time in seconds, or a Carbon instance for absolute expiry.
|
||||||
|
* Defaults to config('cdn.signed_url_expiry', 3600) when null.
|
||||||
|
* @return string|null Signed URL or null if token not configured
|
||||||
|
*/
|
||||||
|
public function signedUrl(string $path, int|Carbon|null $expiry = null): ?string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->signed($path, $expiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the base URL for signed private URLs.
|
||||||
|
* Uses config for the private pull zone URL.
|
||||||
|
*
|
||||||
|
* @deprecated Use CdnUrlBuilder::signed() instead
|
||||||
|
*/
|
||||||
|
protected function buildSignedUrlBase(): string
|
||||||
|
{
|
||||||
|
$pullZone = config('cdn.bunny.private.pull_zone');
|
||||||
|
|
||||||
|
// Support both full URL and just hostname in config
|
||||||
|
if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) {
|
||||||
|
return rtrim($pullZone, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://{$pullZone}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the apex domain URL for a path.
|
||||||
|
* Fallback for assets served through main domain.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to web root
|
||||||
|
*/
|
||||||
|
public function apex(string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->apex($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware URL for a path.
|
||||||
|
* Automatically determines whether to return CDN or origin URL.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
* @param string|null $context Force context ('admin', 'public', or null for auto)
|
||||||
|
*/
|
||||||
|
public function asset(string $path, ?string $context = null): string
|
||||||
|
{
|
||||||
|
$context = $context ?? $this->detectContext();
|
||||||
|
|
||||||
|
return $this->urlBuilder->asset($path, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get 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 $this->urlBuilder->urls($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all URLs for a path (including private and apex).
|
||||||
|
*
|
||||||
|
* @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 $this->urlBuilder->allUrls($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
*/
|
||||||
|
public function detectContext(): string
|
||||||
|
{
|
||||||
|
// Check for admin headers
|
||||||
|
foreach (config('cdn.context.admin_headers', []) as $header) {
|
||||||
|
if (Request::hasHeader($header)) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin route prefixes
|
||||||
|
$path = Request::path();
|
||||||
|
foreach (config('cdn.context.admin_prefixes', []) as $prefix) {
|
||||||
|
if (str_starts_with($path, $prefix)) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config('cdn.context.default', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current context is admin/internal.
|
||||||
|
*
|
||||||
|
* @return bool True if in admin context
|
||||||
|
*/
|
||||||
|
public function isAdminContext(): bool
|
||||||
|
{
|
||||||
|
return $this->detectContext() === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a file to the CDN storage zone.
|
||||||
|
*
|
||||||
|
* @param string $disk Laravel disk name ('hetzner-public' or 'hetzner-private')
|
||||||
|
* @param string $path Path within the disk
|
||||||
|
* @param string $zone Target zone ('public' or 'private')
|
||||||
|
*/
|
||||||
|
public function pushToCdn(string $disk, string $path, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
if (! $this->bunnyStorage->isPushEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->bunnyStorage->copyFromDisk($disk, $path, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from the CDN storage zone.
|
||||||
|
*
|
||||||
|
* @param string $path Path within the storage zone
|
||||||
|
* @param string $zone Target zone ('public' or 'private')
|
||||||
|
*/
|
||||||
|
public function deleteFromCdn(string $path, string $zone = 'public'): bool
|
||||||
|
{
|
||||||
|
return $this->bunnyStorage->delete($path, $zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge a path from the CDN cache.
|
||||||
|
* Uses the existing BunnyCdnService for pull zone API.
|
||||||
|
*
|
||||||
|
* @param string $path Path to purge
|
||||||
|
*/
|
||||||
|
public function purge(string $path): bool
|
||||||
|
{
|
||||||
|
// Use existing BunnyCdnService for pull zone operations
|
||||||
|
$bunnyCdnService = app(BunnyCdnService::class);
|
||||||
|
|
||||||
|
if (! $bunnyCdnService->isConfigured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bunnyCdnService->purgeUrl($this->cdn($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached CDN URL with intelligent caching.
|
||||||
|
*
|
||||||
|
* @param string $path Path relative to storage root
|
||||||
|
* @param string|null $context Force context ('admin', 'public', or null for auto)
|
||||||
|
*/
|
||||||
|
public function cachedAsset(string $path, ?string $context = null): string
|
||||||
|
{
|
||||||
|
if (! config('cdn.cache.enabled', true)) {
|
||||||
|
return $this->asset($path, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $context ?? $this->detectContext();
|
||||||
|
$cacheKey = config('cdn.cache.prefix', 'cdn_url').':'.$context.':'.md5($path);
|
||||||
|
$ttl = config('cdn.cache.ttl', 3600);
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, $ttl, fn () => $this->asset($path, $context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->build($baseUrl, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public storage disk.
|
||||||
|
*
|
||||||
|
* @return Filesystem
|
||||||
|
*/
|
||||||
|
public function publicDisk()
|
||||||
|
{
|
||||||
|
return Storage::disk(config('cdn.disks.public', 'hetzner-public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the private storage disk.
|
||||||
|
*
|
||||||
|
* @return Filesystem
|
||||||
|
*/
|
||||||
|
public function privateDisk()
|
||||||
|
{
|
||||||
|
return Storage::disk(config('cdn.disks.private', 'hetzner-private'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store file to public bucket and optionally push to CDN.
|
||||||
|
*
|
||||||
|
* @param string $path Target path
|
||||||
|
* @param string|resource $contents File contents or stream
|
||||||
|
* @param bool $pushToCdn Whether to also push to BunnyCDN storage zone
|
||||||
|
*/
|
||||||
|
public function storePublic(string $path, $contents, bool $pushToCdn = true): bool
|
||||||
|
{
|
||||||
|
$stored = $this->publicDisk()->put($path, $contents);
|
||||||
|
|
||||||
|
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
|
||||||
|
// Queue the push if configured, otherwise push synchronously
|
||||||
|
if ($queue = config('cdn.pipeline.queue')) {
|
||||||
|
dispatch(new PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue);
|
||||||
|
} else {
|
||||||
|
$this->pushToCdn('hetzner-public', $path, 'public');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store file to private bucket and optionally push to CDN.
|
||||||
|
*
|
||||||
|
* @param string $path Target path
|
||||||
|
* @param string|resource $contents File contents or stream
|
||||||
|
* @param bool $pushToCdn Whether to also push to BunnyCDN storage zone
|
||||||
|
*/
|
||||||
|
public function storePrivate(string $path, $contents, bool $pushToCdn = true): bool
|
||||||
|
{
|
||||||
|
$stored = $this->privateDisk()->put($path, $contents);
|
||||||
|
|
||||||
|
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
|
||||||
|
if ($queue = config('cdn.pipeline.queue')) {
|
||||||
|
dispatch(new PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue);
|
||||||
|
} else {
|
||||||
|
$this->pushToCdn('hetzner-private', $path, 'private');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete file from storage and CDN.
|
||||||
|
*
|
||||||
|
* @param string $path File path
|
||||||
|
* @param string $bucket 'public' or 'private'
|
||||||
|
*/
|
||||||
|
public function deleteAsset(string $path, string $bucket = 'public'): bool
|
||||||
|
{
|
||||||
|
$disk = $bucket === 'private' ? $this->privateDisk() : $this->publicDisk();
|
||||||
|
$deleted = $disk->delete($path);
|
||||||
|
|
||||||
|
if ($deleted) {
|
||||||
|
$this->deleteFromCdn($path, $bucket);
|
||||||
|
|
||||||
|
if (config('cdn.pipeline.auto_purge', true)) {
|
||||||
|
$this->purge($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path prefix for a content category.
|
||||||
|
*
|
||||||
|
* @param string $category Category key from config (media, social, page, etc.)
|
||||||
|
*/
|
||||||
|
public function pathPrefix(string $category): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->pathPrefix($category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full path with category prefix.
|
||||||
|
*
|
||||||
|
* @param string $category Category key
|
||||||
|
* @param string $path Relative path within category
|
||||||
|
*/
|
||||||
|
public function categoryPath(string $category, string $path): string
|
||||||
|
{
|
||||||
|
return $this->urlBuilder->categoryPath($category, $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Core/Cdn/Traits/CLAUDE.md
Normal file
7
app/Core/Cdn/Traits/CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Cdn/Traits/ — CDN Model Traits
|
||||||
|
|
||||||
|
## Traits
|
||||||
|
|
||||||
|
| Trait | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `HasCdnUrls` | For models with asset paths needing CDN URL resolution. Requires `$cdnPathAttribute` (attribute with storage path) and optional `$cdnBucket` (`public` or `private`). Provides `cdnUrl()` accessor. |
|
||||||
186
app/Core/Cdn/Traits/HasCdnUrls.php
Normal file
186
app/Core/Cdn/Traits/HasCdnUrls.php
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<?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\Traits;
|
||||||
|
|
||||||
|
use Core\Cdn\Facades\Cdn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for models that have asset paths needing CDN URL resolution.
|
||||||
|
*
|
||||||
|
* Models using this trait should define a $cdnPathAttribute property
|
||||||
|
* specifying which attribute contains the storage path.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* protected string $cdnPathAttribute = 'path';
|
||||||
|
* protected string $cdnBucket = 'public'; // 'public' or 'private'
|
||||||
|
*/
|
||||||
|
trait HasCdnUrls
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the CDN delivery URL for this model's asset.
|
||||||
|
*/
|
||||||
|
public function getCdnUrl(): ?string
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
return $path ? Cdn::cdn($path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the origin storage URL (Hetzner) for this model's asset.
|
||||||
|
*/
|
||||||
|
public function getOriginUrl(): ?string
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
return $path ? Cdn::origin($path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware URL for this model's asset.
|
||||||
|
* Returns CDN URL for public context, origin URL for admin context.
|
||||||
|
*
|
||||||
|
* @param string|null $context Force context ('admin', 'public', or null for auto)
|
||||||
|
*/
|
||||||
|
public function getAssetUrl(?string $context = null): ?string
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
return $path ? Cdn::asset($path, $context) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get both CDN and origin URLs for API responses.
|
||||||
|
*
|
||||||
|
* @return array{cdn: string|null, origin: string|null}
|
||||||
|
*/
|
||||||
|
public function getAssetUrls(): array
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
if (! $path) {
|
||||||
|
return ['cdn' => null, 'origin' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cdn::urls($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all URLs for this model's asset (including private and apex).
|
||||||
|
*
|
||||||
|
* @return array{cdn: string|null, origin: string|null, private: string|null, apex: string|null}
|
||||||
|
*/
|
||||||
|
public function getAllAssetUrls(): array
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
if (! $path) {
|
||||||
|
return ['cdn' => null, 'origin' => null, 'private' => null, 'apex' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cdn::allUrls($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage path for this model's asset.
|
||||||
|
*/
|
||||||
|
public function getCdnPath(): ?string
|
||||||
|
{
|
||||||
|
$attribute = $this->getCdnPathAttribute();
|
||||||
|
|
||||||
|
return $this->{$attribute} ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attribute name containing the storage path.
|
||||||
|
* Override this method or set $cdnPathAttribute property.
|
||||||
|
*/
|
||||||
|
protected function getCdnPathAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->cdnPathAttribute ?? 'path';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bucket type for this model's assets.
|
||||||
|
* Override this method or set $cdnBucket property.
|
||||||
|
*
|
||||||
|
* @return string 'public' or 'private'
|
||||||
|
*/
|
||||||
|
public function getCdnBucket(): string
|
||||||
|
{
|
||||||
|
return $this->cdnBucket ?? 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push this model's asset to the CDN storage zone.
|
||||||
|
*/
|
||||||
|
public function pushToCdn(): bool
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
if (! $path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucket = $this->getCdnBucket();
|
||||||
|
$disk = $bucket === 'private' ? 'hetzner-private' : 'hetzner-public';
|
||||||
|
|
||||||
|
return Cdn::pushToCdn($disk, $path, $bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete this model's asset from the CDN storage zone.
|
||||||
|
*/
|
||||||
|
public function deleteFromCdn(): bool
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
if (! $path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cdn::deleteFromCdn($path, $this->getCdnBucket());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge this model's asset from the CDN cache.
|
||||||
|
*/
|
||||||
|
public function purgeFromCdn(): bool
|
||||||
|
{
|
||||||
|
$path = $this->getCdnPath();
|
||||||
|
|
||||||
|
if (! $path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cdn::purge($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to append CDN URLs to the model when converting to array/JSON.
|
||||||
|
* Add this to $appends property: 'cdn_url', 'origin_url', 'asset_urls'
|
||||||
|
*/
|
||||||
|
public function getCdnUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->getCdnUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOriginUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->getOriginUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAssetUrlsAttribute(): array
|
||||||
|
{
|
||||||
|
return $this->getAssetUrls();
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/Core/Cdn/config.php
Normal file
182
app/Core/Cdn/config.php
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDN / Storage Configuration.
|
||||||
|
*
|
||||||
|
* By default, assets are served locally with aggressive cache headers.
|
||||||
|
* For production, you can enable external CDN (BunnyCDN, CloudFlare, etc.)
|
||||||
|
*
|
||||||
|
* Local Development (Valet):
|
||||||
|
* Assets served from /public with proper cache headers
|
||||||
|
* Optional: cdn.{app}.test subdomain for CDN simulation
|
||||||
|
*
|
||||||
|
* Production:
|
||||||
|
* Enable CDN and configure your provider below
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| CDN Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When disabled, assets are served locally from /public with cache headers.
|
||||||
|
| Enable for production with an external CDN.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'enabled' => env('CDN_ENABLED', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Signed URL Expiry
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Default expiry time (in seconds) for signed URLs when not specified
|
||||||
|
| per-request. Signed URLs provide time-limited access to private content.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'signed_url_expiry' => env('CDN_SIGNED_URL_EXPIRY', 3600),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| URL Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All URL building uses these config values for consistency.
|
||||||
|
| Never hardcode URLs in service methods.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'urls' => [
|
||||||
|
// CDN delivery URL (when enabled)
|
||||||
|
'cdn' => env('CDN_URL'),
|
||||||
|
|
||||||
|
// Public origin URL (direct storage access, bypassing CDN)
|
||||||
|
'public' => env('CDN_PUBLIC_URL'),
|
||||||
|
|
||||||
|
// Private CDN URL (for signed/gated content)
|
||||||
|
'private' => env('CDN_PRIVATE_URL'),
|
||||||
|
|
||||||
|
// Apex domain fallback
|
||||||
|
'apex' => env('APP_URL', 'https://core.test'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disk Mapping
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'disks' => [
|
||||||
|
'private' => env('CDN_PRIVATE_DISK', 'local'),
|
||||||
|
'public' => env('CDN_PUBLIC_DISK', 'public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| BunnyCDN Configuration (Optional)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Only needed if using BunnyCDN as your CDN provider.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'bunny' => [
|
||||||
|
// Public storage zone (compiled assets)
|
||||||
|
'public' => [
|
||||||
|
'zone' => env('BUNNYCDN_PUBLIC_STORAGE_ZONE'),
|
||||||
|
'region' => env('BUNNYCDN_PUBLIC_STORAGE_REGION', 'de'),
|
||||||
|
'api_key' => env('BUNNYCDN_PUBLIC_STORAGE_API_KEY'),
|
||||||
|
'read_only_key' => env('BUNNYCDN_PUBLIC_STORAGE_READ_KEY'),
|
||||||
|
'pull_zone' => env('BUNNYCDN_PUBLIC_PULL_ZONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Private storage zone (DRM, gated content)
|
||||||
|
'private' => [
|
||||||
|
'zone' => env('BUNNYCDN_PRIVATE_STORAGE_ZONE'),
|
||||||
|
'region' => env('BUNNYCDN_PRIVATE_STORAGE_REGION', 'de'),
|
||||||
|
'api_key' => env('BUNNYCDN_PRIVATE_STORAGE_API_KEY'),
|
||||||
|
'read_only_key' => env('BUNNYCDN_PRIVATE_STORAGE_READ_KEY'),
|
||||||
|
'pull_zone' => env('BUNNYCDN_PRIVATE_PULL_ZONE'),
|
||||||
|
'token' => env('BUNNYCDN_PRIVATE_PULL_ZONE_TOKEN'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Account-level API (for cache purging)
|
||||||
|
'pull_zone_id' => env('BUNNYCDN_PULL_ZONE_ID'),
|
||||||
|
'api_key' => env('BUNNYCDN_API_KEY'),
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
'push_enabled' => env('CDN_PUSH_ENABLED', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Context Detection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define which routes/contexts should use origin vs CDN URLs.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'context' => [
|
||||||
|
// Route prefixes that should use origin URLs (admin/internal)
|
||||||
|
'admin_prefixes' => ['admin', 'hub', 'api/v1/admin', 'dashboard'],
|
||||||
|
|
||||||
|
// Headers that indicate internal/admin request
|
||||||
|
'admin_headers' => ['X-Admin-Request', 'X-Internal-Request'],
|
||||||
|
|
||||||
|
// Default context when not determinable
|
||||||
|
'default' => 'public',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Asset Processing Pipeline
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'pipeline' => [
|
||||||
|
// Auto-push to CDN after processing (only when CDN enabled)
|
||||||
|
'auto_push' => env('CDN_AUTO_PUSH', false),
|
||||||
|
|
||||||
|
// Auto-purge CDN on asset update
|
||||||
|
'auto_purge' => env('CDN_AUTO_PURGE', false),
|
||||||
|
|
||||||
|
// Queue for async operations
|
||||||
|
'queue' => env('CDN_QUEUE', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Path Prefixes by Category
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'paths' => [
|
||||||
|
'media' => 'media',
|
||||||
|
'avatar' => 'avatars',
|
||||||
|
'content' => 'content',
|
||||||
|
'static' => 'static',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Local Cache Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When CDN is disabled, these settings control local asset caching.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'cache' => [
|
||||||
|
'enabled' => env('CDN_CACHE_ENABLED', true),
|
||||||
|
'ttl' => env('CDN_CACHE_TTL', 3600),
|
||||||
|
'prefix' => 'cdn_url',
|
||||||
|
|
||||||
|
// Cache headers for static assets (when serving locally)
|
||||||
|
'headers' => [
|
||||||
|
'max_age' => env('CDN_CACHE_MAX_AGE', 31536000), // 1 year
|
||||||
|
'immutable' => env('CDN_CACHE_IMMUTABLE', true),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
115
app/Core/Cdn/offload.php
Normal file
115
app/Core/Cdn/offload.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core PHP Framework
|
||||||
|
*
|
||||||
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||||
|
* See LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Storage Offload Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configuration for offloading uploads to S3-compatible storage.
|
||||||
|
| Supports AWS S3, Hetzner Object Storage, and any S3-compatible endpoint.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable storage offload globally.
|
||||||
|
*/
|
||||||
|
'enabled' => env('STORAGE_OFFLOAD_ENABLED', false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default disk for offloading.
|
||||||
|
* Must be defined in config/filesystems.php
|
||||||
|
*
|
||||||
|
* Options: 'hetzner', 's3', or any custom S3-compatible disk
|
||||||
|
*/
|
||||||
|
'disk' => env('STORAGE_OFFLOAD_DISK', 'hetzner'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL for serving offloaded assets.
|
||||||
|
* Can be a CDN URL (e.g., BunnyCDN pull zone).
|
||||||
|
*
|
||||||
|
* If null, uses the disk's configured URL.
|
||||||
|
*/
|
||||||
|
'cdn_url' => env('STORAGE_OFFLOAD_CDN_URL'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hetzner Object Storage Configuration.
|
||||||
|
*/
|
||||||
|
'hetzner' => [
|
||||||
|
'endpoint' => env('HETZNER_ENDPOINT', 'https://fsn1.your-objectstorage.com'),
|
||||||
|
'region' => env('HETZNER_REGION', 'fsn1'),
|
||||||
|
'bucket' => env('HETZNER_BUCKET'),
|
||||||
|
'access_key' => env('HETZNER_ACCESS_KEY'),
|
||||||
|
'secret_key' => env('HETZNER_SECRET_KEY'),
|
||||||
|
'visibility' => 'public',
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File path organisation within bucket.
|
||||||
|
*/
|
||||||
|
'paths' => [
|
||||||
|
'page' => 'pages',
|
||||||
|
'avatar' => 'avatars',
|
||||||
|
'media' => 'media',
|
||||||
|
'static' => 'static',
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File types eligible for offloading.
|
||||||
|
*/
|
||||||
|
'allowed_extensions' => [
|
||||||
|
// Images
|
||||||
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico',
|
||||||
|
// Documents
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||||
|
// Media
|
||||||
|
'mp4', 'webm', 'mp3', 'wav', 'ogg',
|
||||||
|
// Archives
|
||||||
|
'zip', 'tar', 'gz',
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum file size for offload (bytes).
|
||||||
|
* Files larger than this will remain local.
|
||||||
|
*/
|
||||||
|
'max_file_size' => env('STORAGE_OFFLOAD_MAX_SIZE', 100 * 1024 * 1024), // 100MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically offload on upload.
|
||||||
|
* If false, manual migration via artisan command required.
|
||||||
|
*/
|
||||||
|
'auto_offload' => env('STORAGE_OFFLOAD_AUTO', true),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep local copy after offloading.
|
||||||
|
* Useful for gradual migration or backup purposes.
|
||||||
|
*/
|
||||||
|
'keep_local' => env('STORAGE_OFFLOAD_KEEP_LOCAL', false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue configuration for async offloading.
|
||||||
|
*/
|
||||||
|
'queue' => [
|
||||||
|
'enabled' => env('STORAGE_OFFLOAD_QUEUE', true),
|
||||||
|
'connection' => env('STORAGE_OFFLOAD_QUEUE_CONNECTION', 'redis'),
|
||||||
|
'name' => env('STORAGE_OFFLOAD_QUEUE_NAME', 'storage-offload'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache configuration for URL lookups.
|
||||||
|
*/
|
||||||
|
'cache' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'ttl' => 3600, // 1 hour
|
||||||
|
'prefix' => 'storage_offload',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
136
app/Core/Config/Boot.php
Normal file
136
app/Core/Config/Boot.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?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 Illuminate\Support\ServiceProvider;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hierarchical Configuration Module Service Provider.
|
||||||
|
*
|
||||||
|
* Provides workspace-aware config with inheritance and FINAL locks.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $config = app(ConfigService::class);
|
||||||
|
* $value = $config->get('cdn.bunny.api_key', $workspace);
|
||||||
|
* if ($config->isConfigured('cdn.bunny', $workspace)) { ... }
|
||||||
|
*
|
||||||
|
* ## Import/Export
|
||||||
|
*
|
||||||
|
* Export config to JSON or YAML for backup, migration, or sharing:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $exporter = app(ConfigExporter::class);
|
||||||
|
* $json = $exporter->exportJson($workspace);
|
||||||
|
* $result = $exporter->importJson($json, $workspace);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* CLI commands:
|
||||||
|
* - `config:export config.json` - Export to file
|
||||||
|
* - `config:import config.json` - Import from file
|
||||||
|
*
|
||||||
|
* ## Versioning & Rollback
|
||||||
|
*
|
||||||
|
* Create snapshots and rollback to previous states:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $versioning = app(ConfigVersioning::class);
|
||||||
|
* $version = $versioning->createVersion($workspace, 'Before migration');
|
||||||
|
* $versioning->rollback($version->id, $workspace);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* CLI commands:
|
||||||
|
* - `config:version list` - List all versions
|
||||||
|
* - `config:version create "Label"` - Create snapshot
|
||||||
|
* - `config:version rollback 123` - Rollback to version
|
||||||
|
* - `config:version compare 122 123` - Compare versions
|
||||||
|
*
|
||||||
|
* ## Configuration
|
||||||
|
*
|
||||||
|
* | Key | Type | Default | Description |
|
||||||
|
* |-----|------|---------|-------------|
|
||||||
|
* | `core.config.max_versions` | int | 50 | Max versions per scope |
|
||||||
|
*/
|
||||||
|
class Boot extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->singleton(ConfigResolver::class);
|
||||||
|
|
||||||
|
$this->app->singleton(ConfigService::class, function ($app) {
|
||||||
|
return new ConfigService($app->make(ConfigResolver::class));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias for convenience
|
||||||
|
$this->app->alias(ConfigService::class, 'config.service');
|
||||||
|
|
||||||
|
// Register exporter service
|
||||||
|
$this->app->singleton(ConfigExporter::class, function ($app) {
|
||||||
|
return new ConfigExporter($app->make(ConfigService::class));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register versioning service
|
||||||
|
$this->app->singleton(ConfigVersioning::class, function ($app) {
|
||||||
|
return new ConfigVersioning(
|
||||||
|
$app->make(ConfigService::class),
|
||||||
|
$app->make(ConfigExporter::class)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Load migrations
|
||||||
|
$this->loadMigrationsFrom(__DIR__.'/Migrations');
|
||||||
|
|
||||||
|
// Load views
|
||||||
|
$this->loadViewsFrom(__DIR__.'/View/Blade', 'core.config');
|
||||||
|
|
||||||
|
// Load routes
|
||||||
|
$this->loadRoutesFrom(__DIR__.'/Routes/admin.php');
|
||||||
|
|
||||||
|
// Register Livewire components
|
||||||
|
Livewire::component('app.core.config.view.modal.admin.workspace-config', View\Modal\Admin\WorkspaceConfig::class);
|
||||||
|
Livewire::component('app.core.config.view.modal.admin.config-panel', View\Modal\Admin\ConfigPanel::class);
|
||||||
|
|
||||||
|
// Register console commands
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->commands([
|
||||||
|
Console\ConfigPrimeCommand::class,
|
||||||
|
Console\ConfigListCommand::class,
|
||||||
|
Console\ConfigExportCommand::class,
|
||||||
|
Console\ConfigImportCommand::class,
|
||||||
|
Console\ConfigVersionCommand::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot key registry after app is ready (deferred to avoid DB during boot)
|
||||||
|
// Config resolver now uses lazy loading - no boot-time initialization needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if database is unavailable (migration context).
|
||||||
|
*/
|
||||||
|
protected function isDbUnavailable(): bool
|
||||||
|
{
|
||||||
|
// Check if we're running migrate or db commands
|
||||||
|
$command = $_SERVER['argv'][1] ?? '';
|
||||||
|
|
||||||
|
return in_array($command, ['migrate', 'migrate:fresh', 'migrate:reset', 'db:seed', 'db:wipe']);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Core/Config/CLAUDE.md
Normal file
70
app/Core/Config/CLAUDE.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Config
|
||||||
|
|
||||||
|
Database-backed configuration with scoping, versioning, profiles, and admin UI.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Replaces/supplements Laravel's file-based config with a DB-backed system supporting:
|
||||||
|
- Hierarchical scope resolution (global -> workspace -> user)
|
||||||
|
- Configuration profiles (sets of values that can be switched)
|
||||||
|
- Version history with diffs
|
||||||
|
- Sensitive value encryption
|
||||||
|
- Import/export (JSON/YAML)
|
||||||
|
- Livewire admin panels
|
||||||
|
- Event-driven invalidation
|
||||||
|
|
||||||
|
## Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Boot` | Listens to `AdminPanelBooting` and `ConsoleBooting` for registration |
|
||||||
|
| `ConfigService` | Primary API: `get()`, `set()`, `isConfigured()`, plus scope-aware resolution |
|
||||||
|
| `ConfigResolver` | Resolves values through scope hierarchy: user -> workspace -> global -> default |
|
||||||
|
| `ConfigResult` | DTO wrapping resolved value with metadata (source scope, profile, etc.) |
|
||||||
|
| `ConfigVersioning` | Tracks changes with diffs between versions |
|
||||||
|
| `VersionDiff` | Computes and formats diffs between config versions |
|
||||||
|
| `ConfigExporter` | Export/import config as JSON/YAML |
|
||||||
|
| `ImportResult` | DTO for import operation results |
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
| Model | Table | Purpose |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `ConfigKey` | `config_keys` | Key definitions with type, default, validation rules, `is_sensitive` flag |
|
||||||
|
| `ConfigValue` | `config_values` | Actual values scoped by type (global/workspace/user) |
|
||||||
|
| `ConfigProfile` | `config_profiles` | Named sets of config values (soft-deletable) |
|
||||||
|
| `ConfigVersion` | `config_versions` | Version history snapshots |
|
||||||
|
| `ConfigResolved` | -- | Value object for resolved config |
|
||||||
|
| `Channel` | -- | Notification channel config |
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
- `ConfigType` -- Value types (string, int, bool, json, etc.)
|
||||||
|
- `ScopeType` -- Resolution scopes (global, workspace, user)
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- `ConfigChanged` -- Fired when any config value changes
|
||||||
|
- `ConfigInvalidated` -- Fired when cache needs clearing
|
||||||
|
- `ConfigLocked` -- Fired when a config key is locked
|
||||||
|
|
||||||
|
## Console Commands
|
||||||
|
|
||||||
|
- `config:prime` -- Pre-populate config cache
|
||||||
|
- `config:list` -- List all config keys and values
|
||||||
|
- `config:version` -- Show version history
|
||||||
|
- `config:import` -- Import config from file
|
||||||
|
- `config:export` -- Export config to file
|
||||||
|
|
||||||
|
## Admin UI
|
||||||
|
|
||||||
|
- `ConfigPanel` (Livewire) -- General config editing panel
|
||||||
|
- `WorkspaceConfig` (Livewire) -- Workspace-specific config panel
|
||||||
|
- Routes registered under admin prefix
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
- `ConfigService` is used by other subsystems (e.g., `BunnyCdnService` reads CDN credentials via `$this->config->get('cdn.bunny.api_key')`)
|
||||||
|
- Sensitive keys (`is_sensitive = true`) are encrypted at rest
|
||||||
|
- Seeder: `ConfigKeySeeder` populates default keys
|
||||||
|
- 4 migrations covering base tables, soft deletes, versions, and sensitive flag
|
||||||
171
app/Core/Config/Config.php
Normal file
171
app/Core/Config/Config.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?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 Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for configuration form providers.
|
||||||
|
*
|
||||||
|
* Provides a standardised interface for managing configuration settings
|
||||||
|
* with validation, caching, and database persistence.
|
||||||
|
*
|
||||||
|
* Note: This class requires Core\Mod\Social module to be installed for
|
||||||
|
* database persistence functionality. Implements ConfigContract when available.
|
||||||
|
*/
|
||||||
|
abstract class Config
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new config instance.
|
||||||
|
*/
|
||||||
|
public function __construct(public readonly ?Request $request = null) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save configuration data from request or provided array.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Override data (optional)
|
||||||
|
*/
|
||||||
|
public function save(array $data = []): void
|
||||||
|
{
|
||||||
|
foreach ($this->form() as $name => $_) {
|
||||||
|
$payload = Arr::get($data, $name, $this->request?->input($name));
|
||||||
|
|
||||||
|
$this->persistData($name, $payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist data to database and cache.
|
||||||
|
*
|
||||||
|
* @param string $name Configuration field name
|
||||||
|
* @param mixed $payload Value to store
|
||||||
|
*/
|
||||||
|
public function persistData(string $name, mixed $payload): void
|
||||||
|
{
|
||||||
|
$this->insert($name, $payload);
|
||||||
|
$this->putCache($name, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update configuration in database.
|
||||||
|
*
|
||||||
|
* Requires Core\Mod\Social module to be installed.
|
||||||
|
*
|
||||||
|
* @param string $name Configuration field name
|
||||||
|
* @param mixed $payload Value to store
|
||||||
|
*/
|
||||||
|
public function insert(string $name, mixed $payload): void
|
||||||
|
{
|
||||||
|
if (! class_exists(\Core\Mod\Social\Models\Config::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
\Core\Mod\Social\Models\Config::updateOrCreate(
|
||||||
|
['name' => $name, 'group' => $this->group()],
|
||||||
|
['payload' => $payload]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a configuration value.
|
||||||
|
*
|
||||||
|
* Checks cache first, then database, finally falls back to default from form().
|
||||||
|
* Requires Core\Mod\Social module for database lookup.
|
||||||
|
*
|
||||||
|
* @param string $name Configuration field name
|
||||||
|
*/
|
||||||
|
public function get(string $name): mixed
|
||||||
|
{
|
||||||
|
return $this->getCache($name, function () use ($name) {
|
||||||
|
$default = Arr::get($this->form(), $name);
|
||||||
|
|
||||||
|
if (! class_exists(\Core\Mod\Social\Models\Config::class)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = \Core\Mod\Social\Models\Config::get(
|
||||||
|
property: "{$this->group()}.{$name}",
|
||||||
|
default: $default
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->putCache($name, $payload);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all configuration values for this group.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return Arr::map($this->form(), function ($_, $name) {
|
||||||
|
return $this->get($name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in cache.
|
||||||
|
*
|
||||||
|
* @param string $name Configuration field name
|
||||||
|
* @param mixed $default Value to cache
|
||||||
|
*/
|
||||||
|
public function putCache(string $name, mixed $default = null): void
|
||||||
|
{
|
||||||
|
Cache::put($this->resolveCacheKey($name), $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a value from cache.
|
||||||
|
*
|
||||||
|
* @param string $name Configuration field name
|
||||||
|
* @param mixed $default Default value or closure to execute if not cached
|
||||||
|
*/
|
||||||
|
public function getCache(string $name, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return Cache::get($this->resolveCacheKey($name), $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove cache entries for this configuration group.
|
||||||
|
*
|
||||||
|
* @param string|null $name Specific field name, or null to clear all
|
||||||
|
*/
|
||||||
|
public function forgetCache(?string $name = null): void
|
||||||
|
{
|
||||||
|
if (! $name) {
|
||||||
|
foreach (array_keys($this->form()) as $fieldName) {
|
||||||
|
$this->forgetCache($fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::forget($this->resolveCacheKey($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build cache key for a configuration field.
|
||||||
|
*
|
||||||
|
* @param string $key Configuration field name
|
||||||
|
*/
|
||||||
|
private function resolveCacheKey(string $key): string
|
||||||
|
{
|
||||||
|
$prefix = config('social.cache_prefix', 'social');
|
||||||
|
|
||||||
|
return "{$prefix}.configs.{$this->group()}.{$key}";
|
||||||
|
}
|
||||||
|
}
|
||||||
536
app/Core/Config/ConfigExporter.php
Normal file
536
app/Core/Config/ConfigExporter.php
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
<?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\Models\ConfigKey;
|
||||||
|
use Core\Config\Models\ConfigProfile;
|
||||||
|
use Core\Config\Models\ConfigValue;
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
639
app/Core/Config/ConfigResolver.php
Normal file
639
app/Core/Config/ConfigResolver.php
Normal file
|
|
@ -0,0 +1,639 @@
|
||||||
|
<?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\Contracts\ConfigProvider;
|
||||||
|
use Core\Config\Enums\ScopeType;
|
||||||
|
use Core\Config\Models\Channel;
|
||||||
|
use Core\Config\Models\ConfigKey;
|
||||||
|
use Core\Config\Models\ConfigProfile;
|
||||||
|
use Core\Config\Models\ConfigValue;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration resolution engine.
|
||||||
|
*
|
||||||
|
* Single static hash for all config values:
|
||||||
|
* - Runtime values from modules
|
||||||
|
* - Resolved values from database
|
||||||
|
* - All in one place, zero-DB reads after warmup
|
||||||
|
*
|
||||||
|
* Read path: $values[$key] ?? compute and store
|
||||||
|
*
|
||||||
|
* Resolution dimensions (when computing):
|
||||||
|
* - Scope: workspace → org → system (most specific wins)
|
||||||
|
* - Channel: specific → parent → null (most specific wins)
|
||||||
|
*
|
||||||
|
* Respects FINAL/locked declarations from parent scopes.
|
||||||
|
*/
|
||||||
|
class ConfigResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The hash. Key → value. That's it.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected static array $values = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the hash has been loaded.
|
||||||
|
*/
|
||||||
|
protected static bool $loaded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered virtual providers.
|
||||||
|
*
|
||||||
|
* Supports both ConfigProvider instances and callable functions.
|
||||||
|
*
|
||||||
|
* @var array<string, ConfigProvider|callable>
|
||||||
|
*/
|
||||||
|
protected array $providers = [];
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// THE HASH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from the hash.
|
||||||
|
*/
|
||||||
|
public static function get(string $key): mixed
|
||||||
|
{
|
||||||
|
return static::$values[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the hash.
|
||||||
|
*/
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
static::$values[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value exists in the hash.
|
||||||
|
*/
|
||||||
|
public static function has(string $key): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($key, static::$values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear keys matching a pattern (bi-directional).
|
||||||
|
*/
|
||||||
|
public static function clear(string $pattern): void
|
||||||
|
{
|
||||||
|
static::$values = array_filter(
|
||||||
|
static::$values,
|
||||||
|
fn ($k) => ! str_contains($k, $pattern),
|
||||||
|
ARRAY_FILTER_USE_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear entire hash.
|
||||||
|
*/
|
||||||
|
public static function clearAll(): void
|
||||||
|
{
|
||||||
|
static::$values = [];
|
||||||
|
static::$loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all values (for debugging).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return static::$values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if hash has been loaded.
|
||||||
|
*/
|
||||||
|
public static function isLoaded(): bool
|
||||||
|
{
|
||||||
|
return static::$loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark hash as loaded.
|
||||||
|
*/
|
||||||
|
public static function markLoaded(): void
|
||||||
|
{
|
||||||
|
static::$loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// RESOLUTION ENGINE (only runs during lazy prime, not normal reads)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single key for a workspace and optional channel.
|
||||||
|
*
|
||||||
|
* NOTE: This is the expensive path - only called when lazy-priming.
|
||||||
|
* Normal reads hit the hash directly via ConfigService.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @param string|Channel|null $channel Channel code or object
|
||||||
|
*/
|
||||||
|
public function resolve(
|
||||||
|
string $keyCode,
|
||||||
|
?object $workspace = null,
|
||||||
|
string|Channel|null $channel = null,
|
||||||
|
): ConfigResult {
|
||||||
|
// Get key definition (DB query - only during resolve, not normal reads)
|
||||||
|
$key = ConfigKey::byCode($keyCode);
|
||||||
|
|
||||||
|
if ($key === null) {
|
||||||
|
// Try JSON sub-key extraction
|
||||||
|
return $this->resolveJsonSubKey($keyCode, $workspace, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build chains
|
||||||
|
$profileChain = $this->buildProfileChain($workspace);
|
||||||
|
$channelChain = $this->buildChannelChain($channel, $workspace);
|
||||||
|
|
||||||
|
// Batch load all values for this key
|
||||||
|
$values = $this->batchLoadValues(
|
||||||
|
$key->id,
|
||||||
|
$profileChain->pluck('id')->all(),
|
||||||
|
$channelChain->pluck('id')->all()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build resolution matrix (profile × channel combinations)
|
||||||
|
$matrix = $this->buildResolutionMatrix($profileChain, $channelChain);
|
||||||
|
|
||||||
|
// First pass: check for FINAL locks (from least specific scope)
|
||||||
|
$lockedResult = $this->findFinalLock($matrix, $values, $keyCode, $key);
|
||||||
|
if ($lockedResult !== null) {
|
||||||
|
return $lockedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: find most specific value
|
||||||
|
foreach ($matrix as $combo) {
|
||||||
|
$value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
|
||||||
|
|
||||||
|
if ($value !== null) {
|
||||||
|
return ConfigResult::found(
|
||||||
|
key: $keyCode,
|
||||||
|
value: $value->value,
|
||||||
|
type: $key->type,
|
||||||
|
locked: false,
|
||||||
|
resolvedFrom: $combo['scope_type'],
|
||||||
|
profileId: $combo['profile_id'],
|
||||||
|
channelId: $combo['channel_id'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check virtual providers
|
||||||
|
$virtualValue = $this->resolveFromProviders($keyCode, $workspace, $channel);
|
||||||
|
if ($virtualValue !== null) {
|
||||||
|
return ConfigResult::virtual(
|
||||||
|
key: $keyCode,
|
||||||
|
value: $virtualValue,
|
||||||
|
type: $key->type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No value found - return default
|
||||||
|
return ConfigResult::notFound($keyCode, $key->getTypedDefault(), $key->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum recursion depth for JSON sub-key resolution.
|
||||||
|
*/
|
||||||
|
protected const MAX_SUBKEY_DEPTH = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current recursion depth for sub-key resolution.
|
||||||
|
*/
|
||||||
|
protected int $subKeyDepth = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
protected function resolveJsonSubKey(
|
||||||
|
string $keyCode,
|
||||||
|
?object $workspace,
|
||||||
|
string|Channel|null $channel,
|
||||||
|
): ConfigResult {
|
||||||
|
// Guard against stack overflow from deep nesting
|
||||||
|
if ($this->subKeyDepth >= self::MAX_SUBKEY_DEPTH) {
|
||||||
|
return ConfigResult::unconfigured($keyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->subKeyDepth++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$parts = explode('.', $keyCode);
|
||||||
|
|
||||||
|
// Try progressively shorter parent keys
|
||||||
|
for ($i = count($parts) - 1; $i > 0; $i--) {
|
||||||
|
$parentKey = implode('.', array_slice($parts, 0, $i));
|
||||||
|
$subPath = implode('.', array_slice($parts, $i));
|
||||||
|
|
||||||
|
$parentResult = $this->resolve($parentKey, $workspace, $channel);
|
||||||
|
|
||||||
|
if ($parentResult->found && is_array($parentResult->value)) {
|
||||||
|
$subValue = data_get($parentResult->value, $subPath);
|
||||||
|
|
||||||
|
if ($subValue !== null) {
|
||||||
|
return ConfigResult::found(
|
||||||
|
key: $keyCode,
|
||||||
|
value: $subValue,
|
||||||
|
type: $parentResult->type, // Inherit parent type
|
||||||
|
locked: $parentResult->locked,
|
||||||
|
resolvedFrom: $parentResult->resolvedFrom,
|
||||||
|
profileId: $parentResult->profileId,
|
||||||
|
channelId: $parentResult->channelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigResult::unconfigured($keyCode);
|
||||||
|
} finally {
|
||||||
|
$this->subKeyDepth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the channel inheritance chain.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @return Collection<int, Channel|null>
|
||||||
|
*/
|
||||||
|
public function buildChannelChain(
|
||||||
|
string|Channel|null $channel,
|
||||||
|
?object $workspace = null,
|
||||||
|
): Collection {
|
||||||
|
$chain = new Collection;
|
||||||
|
|
||||||
|
if ($channel === null) {
|
||||||
|
// No channel specified - just null (applies to all)
|
||||||
|
$chain->push(null);
|
||||||
|
|
||||||
|
return $chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve channel code to model
|
||||||
|
if (is_string($channel)) {
|
||||||
|
$channel = Channel::byCode($channel, $workspace?->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channel !== null) {
|
||||||
|
// Add channel inheritance chain
|
||||||
|
$chain = $chain->merge($channel->inheritanceChain());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include null (all-channels fallback)
|
||||||
|
$chain->push(null);
|
||||||
|
|
||||||
|
return $chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch load all values for a key across profiles and channels.
|
||||||
|
*
|
||||||
|
* @param array<int> $profileIds
|
||||||
|
* @param array<int|null> $channelIds
|
||||||
|
* @return Collection<int, ConfigValue>
|
||||||
|
*/
|
||||||
|
protected function batchLoadValues(int $keyId, array $profileIds, array $channelIds): Collection
|
||||||
|
{
|
||||||
|
// Separate null from actual channel IDs for query
|
||||||
|
$actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
|
||||||
|
|
||||||
|
return ConfigValue::where('key_id', $keyId)
|
||||||
|
->whereIn('profile_id', $profileIds)
|
||||||
|
->where(function ($query) use ($actualChannelIds) {
|
||||||
|
$query->whereNull('channel_id');
|
||||||
|
if (! empty($actualChannelIds)) {
|
||||||
|
$query->orWhereIn('channel_id', $actualChannelIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build resolution matrix (profile × channel combinations).
|
||||||
|
*
|
||||||
|
* Order: most specific first (workspace + specific channel)
|
||||||
|
* to least specific (system + null channel).
|
||||||
|
*
|
||||||
|
* @return array<array{profile_id: int, channel_id: int|null, scope_type: ScopeType}>
|
||||||
|
*/
|
||||||
|
protected function buildResolutionMatrix(Collection $profileChain, Collection $channelChain): array
|
||||||
|
{
|
||||||
|
$matrix = [];
|
||||||
|
|
||||||
|
foreach ($profileChain as $profile) {
|
||||||
|
foreach ($channelChain as $channel) {
|
||||||
|
$matrix[] = [
|
||||||
|
'profile_id' => $profile->id,
|
||||||
|
'channel_id' => $channel?->id,
|
||||||
|
'scope_type' => $profile->scope_type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a FINAL lock in the resolution matrix.
|
||||||
|
*
|
||||||
|
* Checks from least specific (system) to find any lock that
|
||||||
|
* would prevent more specific values from being used.
|
||||||
|
*/
|
||||||
|
protected function findFinalLock(
|
||||||
|
array $matrix,
|
||||||
|
Collection $values,
|
||||||
|
string $keyCode,
|
||||||
|
ConfigKey $key,
|
||||||
|
): ?ConfigResult {
|
||||||
|
// Reverse to check from least specific (system)
|
||||||
|
$reversed = array_reverse($matrix);
|
||||||
|
|
||||||
|
foreach ($reversed as $combo) {
|
||||||
|
$value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
|
||||||
|
|
||||||
|
if ($value !== null && $value->isLocked()) {
|
||||||
|
return ConfigResult::found(
|
||||||
|
key: $keyCode,
|
||||||
|
value: $value->value,
|
||||||
|
type: $key->type,
|
||||||
|
locked: true,
|
||||||
|
resolvedFrom: $combo['scope_type'],
|
||||||
|
profileId: $combo['profile_id'],
|
||||||
|
channelId: $combo['channel_id'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a value in the batch-loaded collection.
|
||||||
|
*/
|
||||||
|
protected function findValueInBatch(Collection $values, int $profileId, ?int $channelId): ?ConfigValue
|
||||||
|
{
|
||||||
|
return $values->first(function (ConfigValue $value) use ($profileId, $channelId) {
|
||||||
|
return $value->profile_id === $profileId
|
||||||
|
&& $value->channel_id === $channelId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a virtual provider for a key pattern.
|
||||||
|
*
|
||||||
|
* Providers supply values from module data without database storage.
|
||||||
|
* Accepts either a ConfigProvider instance or a callable.
|
||||||
|
*
|
||||||
|
* @param string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance
|
||||||
|
* @param ConfigProvider|callable|null $provider ConfigProvider instance or fn(string $key, ?object $workspace, ?Channel $channel): mixed
|
||||||
|
*/
|
||||||
|
public function registerProvider(string|ConfigProvider $patternOrProvider, ConfigProvider|callable|null $provider = null): void
|
||||||
|
{
|
||||||
|
// Support both new interface-based and legacy callable patterns
|
||||||
|
if ($patternOrProvider instanceof ConfigProvider) {
|
||||||
|
$this->providers[$patternOrProvider->pattern()] = $patternOrProvider;
|
||||||
|
} elseif ($provider !== null) {
|
||||||
|
$this->providers[$patternOrProvider] = $provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve value from virtual providers.
|
||||||
|
*
|
||||||
|
* Supports both ConfigProvider instances and legacy callables.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
public function resolveFromProviders(
|
||||||
|
string $keyCode,
|
||||||
|
?object $workspace,
|
||||||
|
string|Channel|null $channel,
|
||||||
|
): mixed {
|
||||||
|
foreach ($this->providers as $pattern => $provider) {
|
||||||
|
if ($this->matchesPattern($keyCode, $pattern)) {
|
||||||
|
// Support both ConfigProvider interface and legacy callable
|
||||||
|
$value = $provider instanceof ConfigProvider
|
||||||
|
? $provider->resolve($keyCode, $workspace, $channel)
|
||||||
|
: $provider($keyCode, $workspace, $channel);
|
||||||
|
|
||||||
|
if ($value !== null) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key matches a provider pattern.
|
||||||
|
*/
|
||||||
|
protected function matchesPattern(string $key, string $pattern): bool
|
||||||
|
{
|
||||||
|
if ($pattern === $key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert pattern to regex (e.g., "bio.*" → "^bio\..*$")
|
||||||
|
$regex = '/^'.str_replace(['.', '*'], ['\.', '.*'], $pattern).'$/';
|
||||||
|
|
||||||
|
return (bool) preg_match($regex, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve all keys for a workspace.
|
||||||
|
*
|
||||||
|
* NOTE: Only called during prime, not normal reads.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @return array<string, ConfigResult>
|
||||||
|
*/
|
||||||
|
public function resolveAll(?object $workspace = null, string|Channel|null $channel = null): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// Query all keys from DB (only during prime)
|
||||||
|
foreach (ConfigKey::all() as $key) {
|
||||||
|
$results[$key->code] = $this->resolve($key->code, $workspace, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve all keys in a category.
|
||||||
|
*
|
||||||
|
* NOTE: Only called during prime, not normal reads.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @return array<string, ConfigResult>
|
||||||
|
*/
|
||||||
|
public function resolveCategory(
|
||||||
|
string $category,
|
||||||
|
?object $workspace = null,
|
||||||
|
string|Channel|null $channel = null,
|
||||||
|
): array {
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// Query keys by category from DB (only during prime)
|
||||||
|
foreach (ConfigKey::where('category', $category)->get() as $key) {
|
||||||
|
$results[$key->code] = $this->resolve($key->code, $workspace, $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the profile chain for resolution.
|
||||||
|
*
|
||||||
|
* Returns profiles ordered from most specific (workspace) to least (system).
|
||||||
|
* Chain: workspace → org → system
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @return Collection<int, ConfigProfile>
|
||||||
|
*/
|
||||||
|
public function buildProfileChain(?object $workspace = null): Collection
|
||||||
|
{
|
||||||
|
$chain = new Collection;
|
||||||
|
|
||||||
|
// Workspace profiles (most specific)
|
||||||
|
if ($workspace !== null) {
|
||||||
|
$workspaceProfiles = ConfigProfile::forScope(ScopeType::WORKSPACE, $workspace->id);
|
||||||
|
$chain = $chain->merge($workspaceProfiles);
|
||||||
|
|
||||||
|
// Org layer - workspace belongs to organisation
|
||||||
|
$orgId = $this->resolveOrgId($workspace);
|
||||||
|
if ($orgId !== null) {
|
||||||
|
$orgProfiles = ConfigProfile::forScope(ScopeType::ORG, $orgId);
|
||||||
|
$chain = $chain->merge($orgProfiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System profiles (least specific)
|
||||||
|
$systemProfiles = ConfigProfile::forScope(ScopeType::SYSTEM, null);
|
||||||
|
$chain = $chain->merge($systemProfiles);
|
||||||
|
|
||||||
|
// Add parent profile inheritance
|
||||||
|
$chain = $this->expandParentProfiles($chain);
|
||||||
|
|
||||||
|
return $chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve organisation ID from workspace.
|
||||||
|
*
|
||||||
|
* Stub for now - will connect to Tenant module when org model exists.
|
||||||
|
* Organisation = multi-workspace grouping (agency accounts, teams).
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null
|
||||||
|
*/
|
||||||
|
protected function resolveOrgId(?object $workspace): ?int
|
||||||
|
{
|
||||||
|
if ($workspace === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace::organisation_id when model has org support
|
||||||
|
// For now, return null (no org layer)
|
||||||
|
return $workspace->organisation_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand chain to include parent profiles.
|
||||||
|
*
|
||||||
|
* @param Collection<int, ConfigProfile> $chain
|
||||||
|
* @return Collection<int, ConfigProfile>
|
||||||
|
*/
|
||||||
|
protected function expandParentProfiles(Collection $chain): Collection
|
||||||
|
{
|
||||||
|
$expanded = new Collection;
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($chain as $profile) {
|
||||||
|
$this->addProfileWithParents($profile, $expanded, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a profile and its parents to the chain.
|
||||||
|
*
|
||||||
|
* @param Collection<int, ConfigProfile> $chain
|
||||||
|
* @param array<int, bool> $seen
|
||||||
|
*/
|
||||||
|
protected function addProfileWithParents(ConfigProfile $profile, Collection $chain, array &$seen): void
|
||||||
|
{
|
||||||
|
if (isset($seen[$profile->id])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$profile->id] = true;
|
||||||
|
$chain->push($profile);
|
||||||
|
|
||||||
|
// Follow parent chain
|
||||||
|
if ($profile->parent_profile_id !== null) {
|
||||||
|
$parent = $profile->parent;
|
||||||
|
|
||||||
|
if ($parent !== null) {
|
||||||
|
$this->addProfileWithParents($parent, $chain, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key prefix is configured.
|
||||||
|
*
|
||||||
|
* Optimised to use EXISTS query instead of resolving each key.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
public function isPrefixConfigured(
|
||||||
|
string $prefix,
|
||||||
|
?object $workspace = null,
|
||||||
|
string|Channel|null $channel = null,
|
||||||
|
): bool {
|
||||||
|
// Get profile IDs for this workspace
|
||||||
|
$profileChain = $this->buildProfileChain($workspace);
|
||||||
|
$profileIds = $profileChain->pluck('id')->all();
|
||||||
|
|
||||||
|
// Get channel IDs
|
||||||
|
$channelChain = $this->buildChannelChain($channel, $workspace);
|
||||||
|
$channelIds = $channelChain->map(fn ($c) => $c?->id)->all();
|
||||||
|
$actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
|
||||||
|
|
||||||
|
// Single EXISTS query
|
||||||
|
return ConfigValue::whereIn('profile_id', $profileIds)
|
||||||
|
->where(function ($query) use ($actualChannelIds) {
|
||||||
|
$query->whereNull('channel_id');
|
||||||
|
if (! empty($actualChannelIds)) {
|
||||||
|
$query->orWhereIn('channel_id', $actualChannelIds);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->whereHas('key', function ($query) use ($prefix) {
|
||||||
|
$query->where('code', 'LIKE', "{$prefix}.%");
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/Core/Config/ConfigResult.php
Normal file
211
app/Core/Config/ConfigResult.php
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable result of config resolution.
|
||||||
|
*
|
||||||
|
* Contains the resolved value plus metadata about where it came from.
|
||||||
|
* This is what gets cached for fast last-mile access.
|
||||||
|
*/
|
||||||
|
final readonly class ConfigResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $key,
|
||||||
|
public mixed $value,
|
||||||
|
public ConfigType $type,
|
||||||
|
public bool $found,
|
||||||
|
public bool $locked,
|
||||||
|
public bool $virtual = false,
|
||||||
|
public ?ScopeType $resolvedFrom = null,
|
||||||
|
public ?int $profileId = null,
|
||||||
|
public ?int $channelId = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a found result.
|
||||||
|
*/
|
||||||
|
public static function found(
|
||||||
|
string $key,
|
||||||
|
mixed $value,
|
||||||
|
ConfigType $type,
|
||||||
|
bool $locked,
|
||||||
|
?ScopeType $resolvedFrom = null,
|
||||||
|
?int $profileId = null,
|
||||||
|
?int $channelId = null,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
key: $key,
|
||||||
|
value: $type->cast($value),
|
||||||
|
type: $type,
|
||||||
|
found: true,
|
||||||
|
locked: $locked,
|
||||||
|
virtual: false,
|
||||||
|
resolvedFrom: $resolvedFrom,
|
||||||
|
profileId: $profileId,
|
||||||
|
channelId: $channelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a result from a virtual provider.
|
||||||
|
*/
|
||||||
|
public static function virtual(
|
||||||
|
string $key,
|
||||||
|
mixed $value,
|
||||||
|
ConfigType $type,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
key: $key,
|
||||||
|
value: $type->cast($value),
|
||||||
|
type: $type,
|
||||||
|
found: true,
|
||||||
|
locked: false,
|
||||||
|
virtual: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a not-found result with default value.
|
||||||
|
*/
|
||||||
|
public static function notFound(string $key, mixed $defaultValue, ConfigType $type): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
key: $key,
|
||||||
|
value: $type->cast($defaultValue),
|
||||||
|
type: $type,
|
||||||
|
found: false,
|
||||||
|
locked: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a result for unconfigured key.
|
||||||
|
*/
|
||||||
|
public static function unconfigured(string $key): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
key: $key,
|
||||||
|
value: null,
|
||||||
|
type: ConfigType::STRING,
|
||||||
|
found: false,
|
||||||
|
locked: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the key was found (has a value).
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->found && $this->value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the value is locked (FINAL).
|
||||||
|
*/
|
||||||
|
public function isLocked(): bool
|
||||||
|
{
|
||||||
|
return $this->locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the value came from a virtual provider.
|
||||||
|
*/
|
||||||
|
public function isVirtual(): bool
|
||||||
|
{
|
||||||
|
return $this->virtual;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value, with optional fallback.
|
||||||
|
*/
|
||||||
|
public function get(mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->value ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value as string.
|
||||||
|
*/
|
||||||
|
public function string(string $default = ''): string
|
||||||
|
{
|
||||||
|
return (string) ($this->value ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value as integer.
|
||||||
|
*/
|
||||||
|
public function int(int $default = 0): int
|
||||||
|
{
|
||||||
|
return (int) ($this->value ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value as boolean.
|
||||||
|
*/
|
||||||
|
public function bool(bool $default = false): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->value ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value as array.
|
||||||
|
*/
|
||||||
|
public function array(array $default = []): array
|
||||||
|
{
|
||||||
|
if ($this->value === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($this->value) ? $this->value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to array for caching.
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => $this->key,
|
||||||
|
'value' => $this->value,
|
||||||
|
'type' => $this->type->value,
|
||||||
|
'found' => $this->found,
|
||||||
|
'locked' => $this->locked,
|
||||||
|
'virtual' => $this->virtual,
|
||||||
|
'resolved_from' => $this->resolvedFrom?->value,
|
||||||
|
'profile_id' => $this->profileId,
|
||||||
|
'channel_id' => $this->channelId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct from cached array.
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
key: $data['key'],
|
||||||
|
value: $data['value'],
|
||||||
|
type: ConfigType::from($data['type']),
|
||||||
|
found: $data['found'],
|
||||||
|
locked: $data['locked'],
|
||||||
|
virtual: $data['virtual'] ?? false,
|
||||||
|
resolvedFrom: ($data['resolved_from'] ?? null) ? ScopeType::from($data['resolved_from']) : null,
|
||||||
|
profileId: $data['profile_id'] ?? null,
|
||||||
|
channelId: $data['channel_id'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
822
app/Core/Config/ConfigService.php
Normal file
822
app/Core/Config/ConfigService.php
Normal file
|
|
@ -0,0 +1,822 @@
|
||||||
|
<?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\Events\ConfigChanged;
|
||||||
|
use Core\Config\Events\ConfigInvalidated;
|
||||||
|
use Core\Config\Events\ConfigLocked;
|
||||||
|
use Core\Config\Models\Channel;
|
||||||
|
use Core\Config\Models\ConfigKey;
|
||||||
|
use Core\Config\Models\ConfigProfile;
|
||||||
|
use Core\Config\Models\ConfigResolved;
|
||||||
|
use Core\Config\Models\ConfigValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration service - main API.
|
||||||
|
*
|
||||||
|
* Single hash: ConfigResolver::$values
|
||||||
|
* Read path: hash lookup → lazy load scope → compute if needed
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $config = app(ConfigService::class);
|
||||||
|
* $value = $config->get('cdn.bunny.api_key', $workspace);
|
||||||
|
* $config->set('cdn.bunny.api_key', 'new-value', $profile);
|
||||||
|
*
|
||||||
|
* // Module Boot.php - provide runtime value (no DB)
|
||||||
|
* $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Cache Invalidation Strategy
|
||||||
|
*
|
||||||
|
* The Config module uses a two-tier caching system:
|
||||||
|
*
|
||||||
|
* ### Tier 1: In-Memory Hash (Process-Scoped)
|
||||||
|
* - `ConfigResolver::$values` - Static array holding all config values
|
||||||
|
* - Cleared on process termination (dies with the request)
|
||||||
|
* - Cleared explicitly via `ConfigResolver::clearAll()` or `ConfigResolver::clear($key)`
|
||||||
|
*
|
||||||
|
* ### Tier 2: Database Resolved Table (Persistent)
|
||||||
|
* - `config_resolved` table - Materialised config resolution
|
||||||
|
* - Survives across requests, shared between all processes
|
||||||
|
* - Cleared via `ConfigResolved::clearScope()`, `clearWorkspace()`, or `clearKey()`
|
||||||
|
*
|
||||||
|
* ### Invalidation Triggers
|
||||||
|
*
|
||||||
|
* 1. **On Config Change (`set()`):**
|
||||||
|
* - Clears the specific key from both hash and database
|
||||||
|
* - Re-primes the key for the affected scope
|
||||||
|
* - Dispatches `ConfigChanged` event for module hooks
|
||||||
|
*
|
||||||
|
* 2. **On Lock/Unlock:**
|
||||||
|
* - Re-primes the key (lock affects all child scopes)
|
||||||
|
* - Dispatches `ConfigLocked` event
|
||||||
|
*
|
||||||
|
* 3. **Manual Invalidation:**
|
||||||
|
* - `invalidateWorkspace($workspace)` - Clears all config for a workspace
|
||||||
|
* - `invalidateKey($key)` - Clears a key across all scopes
|
||||||
|
* - Both dispatch `ConfigInvalidated` event
|
||||||
|
*
|
||||||
|
* 4. **Full Re-prime:**
|
||||||
|
* - `prime($workspace)` - Clears and recomputes all config for a scope
|
||||||
|
* - `primeAll()` - Primes system config + all workspaces (scheduled job)
|
||||||
|
*
|
||||||
|
* ### Lazy Loading
|
||||||
|
*
|
||||||
|
* When a key is not found in the hash:
|
||||||
|
* 1. If scope not loaded, `loadScope()` loads all resolved values for the scope
|
||||||
|
* 2. If still not found, `resolve()` computes and stores the value
|
||||||
|
* 3. Result is stored in both hash (for current request) and database (persistent)
|
||||||
|
*
|
||||||
|
* ### Events for Module Integration
|
||||||
|
*
|
||||||
|
* Modules can listen to cache events to refresh their own caches:
|
||||||
|
* - `ConfigChanged` - Fired when a config value is set/updated
|
||||||
|
* - `ConfigLocked` - Fired when a config value is locked
|
||||||
|
* - `ConfigInvalidated` - Fired when cache is manually invalidated
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* // In your module's Boot.php
|
||||||
|
* public static array $listens = [
|
||||||
|
* ConfigChanged::class => 'onConfigChanged',
|
||||||
|
* ];
|
||||||
|
*
|
||||||
|
* public function onConfigChanged(ConfigChanged $event): void
|
||||||
|
* {
|
||||||
|
* if ($event->keyCode === 'mymodule.api_key') {
|
||||||
|
* $this->refreshApiClient();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see ConfigResolver For the caching hash implementation
|
||||||
|
* @see ConfigResolved For the database cache model
|
||||||
|
* @see ConfigChanged Event fired on config changes
|
||||||
|
* @see ConfigInvalidated Event fired on cache invalidation
|
||||||
|
*/
|
||||||
|
class ConfigService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Current workspace context (Workspace model instance or null for system scope).
|
||||||
|
*/
|
||||||
|
protected ?object $workspace = null;
|
||||||
|
|
||||||
|
protected ?Channel $channel = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ConfigResolver $resolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current context (called by middleware).
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
public function setContext(?object $workspace, ?Channel $channel = null): void
|
||||||
|
{
|
||||||
|
$this->workspace = $workspace;
|
||||||
|
$this->channel = $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current workspace context.
|
||||||
|
*
|
||||||
|
* @return object|null Workspace model instance or null
|
||||||
|
*/
|
||||||
|
public function getWorkspace(): ?object
|
||||||
|
{
|
||||||
|
return $this->workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a config value.
|
||||||
|
*
|
||||||
|
* Context (workspace/channel) is set by middleware via setContext().
|
||||||
|
* This is just key/value - simple.
|
||||||
|
*/
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$result = $this->resolve($key, $this->workspace, $this->channel);
|
||||||
|
|
||||||
|
return $result->get($default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get config for a specific workspace (admin use only).
|
||||||
|
*
|
||||||
|
* Use this when you need another workspace's settings - requires explicit intent.
|
||||||
|
*
|
||||||
|
* @param object $workspace Workspace model instance
|
||||||
|
*/
|
||||||
|
public function getForWorkspace(string $key, object $workspace, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$result = $this->resolve($key, $workspace, null);
|
||||||
|
|
||||||
|
return $result->get($default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a resolved ConfigResult.
|
||||||
|
*
|
||||||
|
* Read path:
|
||||||
|
* 1. Hash lookup (O(1))
|
||||||
|
* 2. Lazy load scope if not loaded (1 query)
|
||||||
|
* 3. Hash lookup again
|
||||||
|
* 4. Compute via resolver if still not found (lazy prime)
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @param string|Channel|null $channel Channel code or object
|
||||||
|
*/
|
||||||
|
public function resolve(
|
||||||
|
string $key,
|
||||||
|
?object $workspace = null,
|
||||||
|
string|Channel|null $channel = null,
|
||||||
|
): ConfigResult {
|
||||||
|
$workspaceId = $workspace?->id;
|
||||||
|
$channelId = $this->resolveChannelId($channel, $workspace);
|
||||||
|
|
||||||
|
// 1. Check hash (O(1))
|
||||||
|
if (ConfigResolver::has($key)) {
|
||||||
|
// Get full result from ConfigResolved (indexed lookup with metadata)
|
||||||
|
$resolved = ConfigResolved::lookup($key, $workspaceId, $channelId);
|
||||||
|
|
||||||
|
if ($resolved !== null) {
|
||||||
|
return $resolved->toResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: value in hash but not in DB (runtime provided)
|
||||||
|
return ConfigResult::found(
|
||||||
|
key: $key,
|
||||||
|
value: ConfigResolver::get($key),
|
||||||
|
type: ConfigType::STRING,
|
||||||
|
locked: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scope not loaded - lazy load entire scope
|
||||||
|
if (! ConfigResolver::isLoaded()) {
|
||||||
|
$this->loadScope($workspaceId, $channelId);
|
||||||
|
|
||||||
|
// Check hash again
|
||||||
|
if (ConfigResolver::has($key)) {
|
||||||
|
$resolved = ConfigResolved::lookup($key, $workspaceId, $channelId);
|
||||||
|
|
||||||
|
if ($resolved !== null) {
|
||||||
|
return $resolved->toResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigResult::found(
|
||||||
|
key: $key,
|
||||||
|
value: ConfigResolver::get($key),
|
||||||
|
type: ConfigType::STRING,
|
||||||
|
locked: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try JSON sub-key extraction
|
||||||
|
$subKeyResult = $this->resolveJsonSubKey($key, $workspace, $channel);
|
||||||
|
if ($subKeyResult->found) {
|
||||||
|
return $subKeyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check virtual providers
|
||||||
|
$virtualValue = $this->resolver->resolveFromProviders($key, $workspace, $channel);
|
||||||
|
if ($virtualValue !== null) {
|
||||||
|
$keyModel = ConfigKey::byCode($key);
|
||||||
|
$type = $keyModel?->type ?? ConfigType::STRING;
|
||||||
|
|
||||||
|
// Store in hash for next read
|
||||||
|
ConfigResolver::set($key, $virtualValue);
|
||||||
|
|
||||||
|
return ConfigResult::virtual(
|
||||||
|
key: $key,
|
||||||
|
value: $virtualValue,
|
||||||
|
type: $type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Lazy prime: compute via resolver
|
||||||
|
$result = $this->resolver->resolve($key, $workspace, $channel);
|
||||||
|
|
||||||
|
// Store in hash
|
||||||
|
ConfigResolver::set($key, $result->value);
|
||||||
|
|
||||||
|
// Store in DB for future requests
|
||||||
|
if ($result->isConfigured()) {
|
||||||
|
ConfigResolved::store(
|
||||||
|
keyCode: $key,
|
||||||
|
value: $result->value,
|
||||||
|
type: $result->type,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
channelId: $channelId,
|
||||||
|
locked: $result->locked,
|
||||||
|
sourceProfileId: $result->profileId,
|
||||||
|
sourceChannelId: $result->channelId,
|
||||||
|
virtual: $result->virtual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a scope into the hash from database.
|
||||||
|
*/
|
||||||
|
protected function loadScope(?int $workspaceId, ?int $channelId): void
|
||||||
|
{
|
||||||
|
$resolved = ConfigResolved::forScope($workspaceId, $channelId);
|
||||||
|
|
||||||
|
foreach ($resolved as $row) {
|
||||||
|
ConfigResolver::set($row->key_code, $row->getTypedValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigResolver::markLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
protected function resolveJsonSubKey(
|
||||||
|
string $keyCode,
|
||||||
|
?object $workspace,
|
||||||
|
string|Channel|null $channel,
|
||||||
|
): ConfigResult {
|
||||||
|
$parts = explode('.', $keyCode);
|
||||||
|
|
||||||
|
// Try progressively shorter parent keys
|
||||||
|
for ($i = count($parts) - 1; $i > 0; $i--) {
|
||||||
|
$parentKey = implode('.', array_slice($parts, 0, $i));
|
||||||
|
$subPath = implode('.', array_slice($parts, $i));
|
||||||
|
|
||||||
|
$workspaceId = $workspace?->id;
|
||||||
|
$channelId = $this->resolveChannelId($channel, $workspace);
|
||||||
|
|
||||||
|
$resolved = ConfigResolved::lookup($parentKey, $workspaceId, $channelId);
|
||||||
|
|
||||||
|
if ($resolved !== null && is_array($resolved->value)) {
|
||||||
|
$subValue = data_get($resolved->value, $subPath);
|
||||||
|
|
||||||
|
if ($subValue !== null) {
|
||||||
|
$result = $resolved->toResult();
|
||||||
|
|
||||||
|
return new ConfigResult(
|
||||||
|
key: $keyCode,
|
||||||
|
value: $subValue,
|
||||||
|
type: $result->type,
|
||||||
|
found: true,
|
||||||
|
locked: $result->locked,
|
||||||
|
virtual: $result->virtual,
|
||||||
|
resolvedFrom: $result->resolvedFrom,
|
||||||
|
profileId: $result->profileId,
|
||||||
|
channelId: $result->channelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigResult::unconfigured($keyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key (or prefix) is configured.
|
||||||
|
*
|
||||||
|
* Uses current context set by middleware.
|
||||||
|
*/
|
||||||
|
public function isConfigured(string $keyOrPrefix): bool
|
||||||
|
{
|
||||||
|
$workspaceId = $this->workspace?->id;
|
||||||
|
$channelId = $this->channel?->id;
|
||||||
|
|
||||||
|
// Check if it's a direct key
|
||||||
|
$resolved = ConfigResolved::lookup($keyOrPrefix, $workspaceId, $channelId);
|
||||||
|
if ($resolved !== null && $resolved->value !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check as prefix - single EXISTS query
|
||||||
|
// Escape LIKE wildcards to prevent unintended pattern matching
|
||||||
|
$escapedPrefix = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $keyOrPrefix);
|
||||||
|
|
||||||
|
return ConfigResolved::where('workspace_id', $workspaceId)
|
||||||
|
->where('channel_id', $channelId)
|
||||||
|
->where('key_code', 'LIKE', "{$escapedPrefix}.%")
|
||||||
|
->whereNotNull('value')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a config value.
|
||||||
|
*
|
||||||
|
* Updates config_values (source of truth), then re-primes affected scope.
|
||||||
|
* Fires ConfigChanged event for invalidation hooks.
|
||||||
|
*
|
||||||
|
* @param string|Channel|null $channel Channel code or object
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If key is unknown or value type is invalid
|
||||||
|
*/
|
||||||
|
public function set(
|
||||||
|
string $keyCode,
|
||||||
|
mixed $value,
|
||||||
|
ConfigProfile $profile,
|
||||||
|
bool $locked = false,
|
||||||
|
string|Channel|null $channel = null,
|
||||||
|
): void {
|
||||||
|
// Get key from DB (only during set, not reads)
|
||||||
|
$key = ConfigKey::byCode($keyCode);
|
||||||
|
|
||||||
|
if ($key === null) {
|
||||||
|
throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate value type against schema
|
||||||
|
$this->validateValueType($value, $key->type, $keyCode);
|
||||||
|
|
||||||
|
$channelId = $this->resolveChannelId($channel, null);
|
||||||
|
|
||||||
|
// Capture previous value for event
|
||||||
|
$previousValue = ConfigValue::findValue($profile->id, $key->id, $channelId)?->value;
|
||||||
|
|
||||||
|
// Update source of truth
|
||||||
|
ConfigValue::setValue($profile->id, $key->id, $value, $locked, null, $channelId);
|
||||||
|
|
||||||
|
// Re-prime affected scope
|
||||||
|
$workspaceId = match ($profile->scope_type) {
|
||||||
|
Enums\ScopeType::WORKSPACE => $profile->scope_id,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->primeKey($keyCode, $workspaceId, $channelId);
|
||||||
|
|
||||||
|
// Fire event for module hooks
|
||||||
|
ConfigChanged::dispatch($keyCode, $value, $previousValue, $profile, $channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock a config value (FINAL - child cannot override).
|
||||||
|
* Fires ConfigLocked event.
|
||||||
|
*/
|
||||||
|
public function lock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void
|
||||||
|
{
|
||||||
|
// Get key from DB (only during lock, not reads)
|
||||||
|
$key = ConfigKey::byCode($keyCode);
|
||||||
|
|
||||||
|
if ($key === null) {
|
||||||
|
throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = $this->resolveChannelId($channel, null);
|
||||||
|
$value = ConfigValue::findValue($profile->id, $key->id, $channelId);
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
throw new \InvalidArgumentException("No value set for {$keyCode} in profile {$profile->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$value->update(['locked' => true]);
|
||||||
|
|
||||||
|
// Re-prime - lock affects all child scopes
|
||||||
|
$this->primeKey($keyCode);
|
||||||
|
|
||||||
|
// Fire event for module hooks
|
||||||
|
ConfigLocked::dispatch($keyCode, $profile, $channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock a config value.
|
||||||
|
*/
|
||||||
|
public function unlock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void
|
||||||
|
{
|
||||||
|
// Get key from DB (only during unlock, not reads)
|
||||||
|
$key = ConfigKey::byCode($keyCode);
|
||||||
|
|
||||||
|
if ($key === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = $this->resolveChannelId($channel, null);
|
||||||
|
$value = ConfigValue::findValue($profile->id, $key->id, $channelId);
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value->update(['locked' => false]);
|
||||||
|
|
||||||
|
// Re-prime
|
||||||
|
$this->primeKey($keyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a virtual provider.
|
||||||
|
*
|
||||||
|
* Virtual providers supply config values from module data
|
||||||
|
* without database storage.
|
||||||
|
*
|
||||||
|
* @param string $pattern Key pattern (supports * wildcard)
|
||||||
|
* @param callable $provider fn(string $key, ?Workspace, ?Channel): mixed
|
||||||
|
*/
|
||||||
|
public function virtual(string $pattern, callable $provider): void
|
||||||
|
{
|
||||||
|
$this->resolver->registerProvider($pattern, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all config values for a workspace.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function all(?object $workspace = null, string|Channel|null $channel = null): array
|
||||||
|
{
|
||||||
|
$workspaceId = $workspace?->id;
|
||||||
|
$channelId = $this->resolveChannelId($channel, $workspace);
|
||||||
|
|
||||||
|
$resolved = ConfigResolved::forScope($workspaceId, $channelId);
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
foreach ($resolved as $row) {
|
||||||
|
$values[$row->key_code] = $row->getTypedValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all config values for a category.
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function category(
|
||||||
|
string $category,
|
||||||
|
?object $workspace = null,
|
||||||
|
string|Channel|null $channel = null,
|
||||||
|
): array {
|
||||||
|
$workspaceId = $workspace?->id;
|
||||||
|
$channelId = $this->resolveChannelId($channel, $workspace);
|
||||||
|
|
||||||
|
// Escape LIKE wildcards to prevent unintended pattern matching
|
||||||
|
$escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category);
|
||||||
|
|
||||||
|
$resolved = ConfigResolved::where('workspace_id', $workspaceId)
|
||||||
|
->where('channel_id', $channelId)
|
||||||
|
->where('key_code', 'LIKE', "{$escapedCategory}.%")
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
foreach ($resolved as $row) {
|
||||||
|
$values[$row->key_code] = $row->getTypedValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime the resolved table for a workspace.
|
||||||
|
*
|
||||||
|
* This is THE computation - runs full resolution and stores results.
|
||||||
|
* Call after workspace creation, config changes, or on schedule.
|
||||||
|
*
|
||||||
|
* Populates both hash (process-scoped) and database (persistent).
|
||||||
|
*
|
||||||
|
* ## When to Call Prime
|
||||||
|
*
|
||||||
|
* - After creating a new workspace
|
||||||
|
* - After bulk config changes (migrations, imports)
|
||||||
|
* - From a scheduled job (`config:prime` command)
|
||||||
|
* - After significant profile hierarchy changes
|
||||||
|
*
|
||||||
|
* ## What Prime Does
|
||||||
|
*
|
||||||
|
* 1. Clears existing resolved values (hash + DB) for the scope
|
||||||
|
* 2. Runs full resolution for all config keys
|
||||||
|
* 3. Stores results in both hash and database
|
||||||
|
* 4. Marks hash as "loaded" to prevent re-loading
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
public function prime(?object $workspace = null, string|Channel|null $channel = null): void
|
||||||
|
{
|
||||||
|
$workspaceId = $workspace?->id;
|
||||||
|
$channelId = $this->resolveChannelId($channel, $workspace);
|
||||||
|
|
||||||
|
// Clear existing resolved values (hash + DB)
|
||||||
|
ConfigResolver::clearAll();
|
||||||
|
ConfigResolved::clearScope($workspaceId, $channelId);
|
||||||
|
|
||||||
|
// Run full resolution
|
||||||
|
$results = $this->resolver->resolveAll($workspace, $channel);
|
||||||
|
|
||||||
|
// Store all resolved values (hash + DB)
|
||||||
|
foreach ($results as $code => $result) {
|
||||||
|
// Store in hash (process-scoped)
|
||||||
|
ConfigResolver::set($code, $result->value);
|
||||||
|
|
||||||
|
// Store in database (persistent)
|
||||||
|
ConfigResolved::store(
|
||||||
|
keyCode: $code,
|
||||||
|
value: $result->value,
|
||||||
|
type: $result->type,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
channelId: $channelId,
|
||||||
|
locked: $result->locked,
|
||||||
|
sourceProfileId: $result->profileId,
|
||||||
|
sourceChannelId: $result->channelId,
|
||||||
|
virtual: $result->virtual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark hash as loaded
|
||||||
|
ConfigResolver::markLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime a single key across all affected scopes.
|
||||||
|
*
|
||||||
|
* Clears and re-computes a specific key in both hash and database.
|
||||||
|
*/
|
||||||
|
public function primeKey(string $keyCode, ?int $workspaceId = null, ?int $channelId = null): void
|
||||||
|
{
|
||||||
|
// Clear from hash (pattern match)
|
||||||
|
ConfigResolver::clear($keyCode);
|
||||||
|
|
||||||
|
// Clear from database
|
||||||
|
ConfigResolved::where('key_code', $keyCode)
|
||||||
|
->when($workspaceId !== null, fn ($q) => $q->where('workspace_id', $workspaceId))
|
||||||
|
->when($channelId !== null, fn ($q) => $q->where('channel_id', $channelId))
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
// Re-compute this key for the affected scope
|
||||||
|
$workspace = null;
|
||||||
|
if ($workspaceId !== null && class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||||
|
$workspace = \Core\Tenant\Models\Workspace::find($workspaceId);
|
||||||
|
}
|
||||||
|
$channel = $channelId ? Channel::find($channelId) : null;
|
||||||
|
|
||||||
|
$result = $this->resolver->resolve($keyCode, $workspace, $channel);
|
||||||
|
|
||||||
|
// Store in hash (process-scoped)
|
||||||
|
ConfigResolver::set($keyCode, $result->value);
|
||||||
|
|
||||||
|
// Store in database (persistent)
|
||||||
|
ConfigResolved::store(
|
||||||
|
keyCode: $keyCode,
|
||||||
|
value: $result->value,
|
||||||
|
type: $result->type,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
channelId: $channelId,
|
||||||
|
locked: $result->locked,
|
||||||
|
sourceProfileId: $result->profileId,
|
||||||
|
sourceChannelId: $result->channelId,
|
||||||
|
virtual: $result->virtual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime cache for all workspaces.
|
||||||
|
*
|
||||||
|
* Run this from a scheduled command or queue job.
|
||||||
|
* Requires Core\Tenant module to prime workspace-level config.
|
||||||
|
*/
|
||||||
|
public function primeAll(): void
|
||||||
|
{
|
||||||
|
// Prime system config
|
||||||
|
$this->prime(null);
|
||||||
|
|
||||||
|
// Prime each workspace (requires Tenant module)
|
||||||
|
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
|
||||||
|
\Core\Tenant\Models\Workspace::chunk(100, function ($workspaces) {
|
||||||
|
foreach ($workspaces as $workspace) {
|
||||||
|
$this->prime($workspace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate (clear) resolved config for a workspace.
|
||||||
|
*
|
||||||
|
* Clears both hash and database. Next read will lazy-prime.
|
||||||
|
* Fires ConfigInvalidated event.
|
||||||
|
*
|
||||||
|
* ## Cache Invalidation Behaviour
|
||||||
|
*
|
||||||
|
* This method performs a "soft" invalidation:
|
||||||
|
* - Clears the in-memory hash (immediate effect)
|
||||||
|
* - Clears the database resolved table (persistent effect)
|
||||||
|
* - Does NOT re-compute values immediately
|
||||||
|
* - Values are lazy-loaded on next read (lazy-prime)
|
||||||
|
*
|
||||||
|
* Use `prime()` instead if you need immediate re-computation.
|
||||||
|
*
|
||||||
|
* ## Listening for Invalidation
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* use Core\Config\Events\ConfigInvalidated;
|
||||||
|
*
|
||||||
|
* public function handle(ConfigInvalidated $event): void
|
||||||
|
* {
|
||||||
|
* if ($event->isFull()) {
|
||||||
|
* // Full invalidation - clear all module caches
|
||||||
|
* } elseif ($event->affectsKey('mymodule.setting')) {
|
||||||
|
* // Specific key was invalidated
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param object|null $workspace Workspace model instance or null for system scope
|
||||||
|
*/
|
||||||
|
public function invalidateWorkspace(?object $workspace = null): void
|
||||||
|
{
|
||||||
|
$workspaceId = $workspace?->id;
|
||||||
|
|
||||||
|
// Clear hash (process-scoped)
|
||||||
|
ConfigResolver::clearAll();
|
||||||
|
|
||||||
|
// Clear database (persistent)
|
||||||
|
ConfigResolved::clearWorkspace($workspaceId);
|
||||||
|
|
||||||
|
ConfigInvalidated::dispatch(null, $workspaceId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate (clear) resolved config for a key.
|
||||||
|
*
|
||||||
|
* Clears both hash and database. Next read will lazy-prime.
|
||||||
|
* Fires ConfigInvalidated event.
|
||||||
|
*/
|
||||||
|
public function invalidateKey(string $key): void
|
||||||
|
{
|
||||||
|
// Clear hash (process-scoped)
|
||||||
|
ConfigResolver::clear($key);
|
||||||
|
|
||||||
|
// Clear database (persistent)
|
||||||
|
ConfigResolved::clearKey($key);
|
||||||
|
|
||||||
|
ConfigInvalidated::dispatch($key, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve channel to ID.
|
||||||
|
*/
|
||||||
|
protected function resolveChannelId(string|Channel|null $channel, ?Workspace $workspace): ?int
|
||||||
|
{
|
||||||
|
if ($channel === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channel instanceof Channel) {
|
||||||
|
return $channel->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelModel = Channel::byCode($channel, $workspace?->id);
|
||||||
|
|
||||||
|
return $channelModel?->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a config key exists (for dynamic registration).
|
||||||
|
*/
|
||||||
|
public function ensureKey(
|
||||||
|
string $code,
|
||||||
|
ConfigType $type,
|
||||||
|
string $category,
|
||||||
|
?string $description = null,
|
||||||
|
mixed $default = null,
|
||||||
|
): ConfigKey {
|
||||||
|
return ConfigKey::firstOrCreate(
|
||||||
|
['code' => $code],
|
||||||
|
[
|
||||||
|
'type' => $type,
|
||||||
|
'category' => $category,
|
||||||
|
'description' => $description,
|
||||||
|
'default_value' => $default,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a config key if it doesn't exist.
|
||||||
|
*
|
||||||
|
* Convenience method for Boot.php files.
|
||||||
|
* Note: This persists to database. For runtime-only values, use provide().
|
||||||
|
*/
|
||||||
|
public function register(
|
||||||
|
string $code,
|
||||||
|
string $type,
|
||||||
|
string $category,
|
||||||
|
?string $description = null,
|
||||||
|
mixed $default = null,
|
||||||
|
): void {
|
||||||
|
$this->ensureKey($code, ConfigType::from($type), $category, $description, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a runtime value.
|
||||||
|
*
|
||||||
|
* Modules call this to share settings with other code in the process.
|
||||||
|
* Process-scoped, not persisted to database. Dies with the request.
|
||||||
|
*
|
||||||
|
* Usage in Boot.php:
|
||||||
|
* $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
|
||||||
|
* $config->provide('mymodule.timeout', 30, 'int');
|
||||||
|
*
|
||||||
|
* @param string $code Key code (e.g., 'mymodule.api_key')
|
||||||
|
* @param mixed $value The value
|
||||||
|
* @param string|ConfigType $type Value type for casting (currently unused, value stored as-is)
|
||||||
|
*/
|
||||||
|
public function provide(string $code, mixed $value, string|ConfigType $type = 'string'): void
|
||||||
|
{
|
||||||
|
// Runtime values just go in the hash (system scope)
|
||||||
|
ConfigResolver::set($code, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a runtime value has been provided.
|
||||||
|
*/
|
||||||
|
public function hasProvided(string $code): bool
|
||||||
|
{
|
||||||
|
return ConfigResolver::has($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a value matches the expected config type.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If value type is invalid
|
||||||
|
*/
|
||||||
|
protected function validateValueType(mixed $value, ConfigType $type, string $keyCode): void
|
||||||
|
{
|
||||||
|
// Null is allowed for any type (represents unset)
|
||||||
|
if ($value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$valid = match ($type) {
|
||||||
|
ConfigType::STRING => is_string($value) || is_numeric($value),
|
||||||
|
ConfigType::BOOL => is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false'], true),
|
||||||
|
ConfigType::INT => is_int($value) || (is_string($value) && ctype_digit(ltrim($value, '-'))),
|
||||||
|
ConfigType::FLOAT => is_float($value) || is_int($value) || is_numeric($value),
|
||||||
|
ConfigType::ARRAY, ConfigType::JSON => is_array($value),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $valid) {
|
||||||
|
$actualType = get_debug_type($value);
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"Invalid value type for config key '{$keyCode}': expected {$type->value}, got {$actualType}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
355
app/Core/Config/ConfigVersioning.php
Normal file
355
app/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\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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Core/Config/Console/CLAUDE.md
Normal file
13
app/Core/Config/Console/CLAUDE.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Config/Console/ — Config Artisan Commands
|
||||||
|
|
||||||
|
Artisan commands for managing the hierarchical configuration system.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Signature | Purpose |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| `ConfigListCommand` | `config:list` | List config keys with resolved values. Filters by workspace, category, or configured-only. |
|
||||||
|
| `ConfigPrimeCommand` | `config:prime` | Materialise resolved config into the fast-read table. Primes system and/or specific workspace. |
|
||||||
|
| `ConfigExportCommand` | `config:export` | Export config to JSON or YAML file. Supports workspace scope and sensitive value inclusion. |
|
||||||
|
| `ConfigImportCommand` | `config:import` | Import config from JSON or YAML file. Supports dry-run mode and automatic version snapshots. |
|
||||||
|
| `ConfigVersionCommand` | `config:version` | Manage config versions — list, create snapshots, show, rollback, and compare versions. |
|
||||||
114
app/Core/Config/Console/ConfigExportCommand.php
Normal file
114
app/Core/Config/Console/ConfigExportCommand.php
Normal file
|
|
@ -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 Core\Config\Models\ConfigKey;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(Workspace::class)) {
|
||||||
|
$this->components->error('Tenant module not installed. Cannot export workspace config.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = 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.
|
||||||
|
*/
|
||||||
|
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||||
|
{
|
||||||
|
if ($input->mustSuggestOptionValuesFor('workspace')) {
|
||||||
|
if (class_exists(Workspace::class)) {
|
||||||
|
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->mustSuggestOptionValuesFor('category')) {
|
||||||
|
$suggestions->suggestValues(ConfigKey::distinct()->pluck('category')->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Core/Config/Console/ConfigImportCommand.php
Normal file
187
app/Core/Config/Console/ConfigImportCommand.php
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
<?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 Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(Workspace::class)) {
|
||||||
|
$this->components->error('Tenant module not installed. Cannot import workspace config.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = 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.
|
||||||
|
*/
|
||||||
|
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||||
|
{
|
||||||
|
if ($input->mustSuggestOptionValuesFor('workspace')) {
|
||||||
|
if (class_exists(Workspace::class)) {
|
||||||
|
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Core/Config/Console/ConfigListCommand.php
Normal file
107
app/Core/Config/Console/ConfigListCommand.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\Console;
|
||||||
|
|
||||||
|
use Core\Config\ConfigService;
|
||||||
|
use Core\Config\Models\ConfigKey;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ConfigListCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'config:list
|
||||||
|
{--workspace= : Show config for specific workspace slug}
|
||||||
|
{--category= : Filter by category}
|
||||||
|
{--configured : Only show configured keys}';
|
||||||
|
|
||||||
|
protected $description = 'List config keys and their resolved values';
|
||||||
|
|
||||||
|
public function handle(ConfigService $config): int
|
||||||
|
{
|
||||||
|
$workspaceSlug = $this->option('workspace');
|
||||||
|
$category = $this->option('category');
|
||||||
|
$configuredOnly = $this->option('configured');
|
||||||
|
|
||||||
|
$workspace = null;
|
||||||
|
|
||||||
|
if ($workspaceSlug) {
|
||||||
|
if (! class_exists(Workspace::class)) {
|
||||||
|
$this->error('Tenant module not installed. Cannot filter by workspace.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||||
|
|
||||||
|
if (! $workspace) {
|
||||||
|
$this->error("Workspace not found: {$workspaceSlug}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Config for workspace: {$workspace->slug}");
|
||||||
|
} else {
|
||||||
|
$this->info('System config:');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$query = ConfigKey::query();
|
||||||
|
|
||||||
|
if ($category) {
|
||||||
|
$query->where('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = $query->orderBy('category')->orderBy('code')->get();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$result = $config->resolve($key->code, $workspace);
|
||||||
|
|
||||||
|
if ($configuredOnly && ! $result->isConfigured()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $result->get();
|
||||||
|
$displayValue = match (true) {
|
||||||
|
is_null($value) => '<fg=gray>null</>',
|
||||||
|
is_bool($value) => $value ? '<fg=green>true</>' : '<fg=red>false</>',
|
||||||
|
is_array($value) => '<fg=cyan>[array]</>',
|
||||||
|
is_string($value) && strlen($value) > 40 => substr($value, 0, 37).'...',
|
||||||
|
default => (string) $value,
|
||||||
|
};
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
$key->code,
|
||||||
|
$key->category,
|
||||||
|
$key->type->value,
|
||||||
|
$displayValue,
|
||||||
|
$result->isLocked() ? '<fg=yellow>LOCKED</>' : '',
|
||||||
|
$result->resolvedFrom?->value ?? '<fg=gray>default</>',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
$this->warn('No config keys found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Key', 'Category', 'Type', 'Value', 'Status', 'Source'],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Core/Config/Console/ConfigPrimeCommand.php
Normal file
80
app/Core/Config/Console/ConfigPrimeCommand.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?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\ConfigService;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ConfigPrimeCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'config:prime
|
||||||
|
{workspace? : Workspace slug to prime (omit for all)}
|
||||||
|
{--system : Prime system config only}';
|
||||||
|
|
||||||
|
protected $description = 'Prime the config cache (compute resolution, store in cache)';
|
||||||
|
|
||||||
|
public function handle(ConfigService $config): int
|
||||||
|
{
|
||||||
|
$workspaceSlug = $this->argument('workspace');
|
||||||
|
$systemOnly = $this->option('system');
|
||||||
|
|
||||||
|
if ($systemOnly) {
|
||||||
|
$this->info('Priming system config cache...');
|
||||||
|
$config->prime(null);
|
||||||
|
$this->info('System config cached.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspaceSlug) {
|
||||||
|
if (! class_exists(Workspace::class)) {
|
||||||
|
$this->error('Tenant module not installed. Cannot prime workspace config.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||||
|
|
||||||
|
if (! $workspace) {
|
||||||
|
$this->error("Workspace not found: {$workspaceSlug}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Priming config cache for workspace: {$workspace->slug}");
|
||||||
|
$config->prime($workspace);
|
||||||
|
$this->info('Workspace config cached.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Priming config cache for all workspaces...');
|
||||||
|
|
||||||
|
if (! class_exists(Workspace::class)) {
|
||||||
|
$this->warn('Tenant module not installed. Only priming system config.');
|
||||||
|
$config->prime(null);
|
||||||
|
$this->info('System config cached.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->withProgressBar(Workspace::all(), function ($workspace) use ($config) {
|
||||||
|
$config->prime($workspace);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('All config caches primed.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
420
app/Core/Config/Console/ConfigVersionCommand.php
Normal file
420
app/Core/Config/Console/ConfigVersionCommand.php
Normal file
|
|
@ -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 Core\Config\VersionDiff;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionInput;
|
||||||
|
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(Workspace::class)) {
|
||||||
|
$this->components->error('Tenant module not installed. Cannot manage workspace versions.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = 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(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.
|
||||||
|
*/
|
||||||
|
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
|
||||||
|
{
|
||||||
|
if ($input->mustSuggestArgumentValuesFor('action')) {
|
||||||
|
$suggestions->suggestValues(['list', 'create', 'show', 'rollback', 'compare', 'diff', 'delete']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->mustSuggestOptionValuesFor('workspace')) {
|
||||||
|
if (class_exists(Workspace::class)) {
|
||||||
|
$suggestions->suggestValues(Workspace::pluck('slug')->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Core/Config/Contracts/CLAUDE.md
Normal file
9
app/Core/Config/Contracts/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Config/Contracts/ — Config System Interfaces
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
| Interface | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `ConfigProvider` | Virtual configuration provider. Supplies config values at runtime without database storage. Matched against key patterns (e.g., `bio.*`). Registered via `ConfigResolver::registerProvider()`. |
|
||||||
|
|
||||||
|
Providers implement `pattern()` (wildcard key matching) and `resolve()` (returns the value for a given key, workspace, and channel context). Returns `null` to fall through to database resolution.
|
||||||
108
app/Core/Config/Contracts/ConfigProvider.php
Normal file
108
app/Core/Config/Contracts/ConfigProvider.php
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?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\ConfigResolver;
|
||||||
|
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,
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @see 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;
|
||||||
|
}
|
||||||
9
app/Core/Config/Database/Seeders/CLAUDE.md
Normal file
9
app/Core/Config/Database/Seeders/CLAUDE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Config/Database/Seeders/ — Config Key Seeders
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `ConfigKeySeeder.php` | Seeds known configuration keys into the `config_keys` table. Defines CDN (Bunny), storage (Hetzner S3), social, analytics, and bio settings with their types, categories, and defaults. Uses `firstOrCreate` for idempotency. |
|
||||||
|
|
||||||
|
Part of the Config subsystem's M1 layer (key definitions). These keys are then assigned values via `ConfigValue` at system or workspace scope.
|
||||||
87
app/Core/Config/Database/Seeders/ConfigKeySeeder.php
Normal file
87
app/Core/Config/Database/Seeders/ConfigKeySeeder.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?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\Database\Seeders;
|
||||||
|
|
||||||
|
use Core\Config\Enums\ConfigType;
|
||||||
|
use Core\Config\Models\ConfigKey;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed known configuration keys.
|
||||||
|
*
|
||||||
|
* Only actual settings - no parent/group markers.
|
||||||
|
* Hierarchy is implicit in the key names.
|
||||||
|
*/
|
||||||
|
class ConfigKeySeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$keys = [
|
||||||
|
// CDN - Bunny
|
||||||
|
['cdn.bunny.api_key', ConfigType::STRING, 'cdn', 'Bunny API key'],
|
||||||
|
['cdn.bunny.pull_zone_id', ConfigType::STRING, 'cdn', 'Bunny pull zone ID'],
|
||||||
|
['cdn.bunny.pull_zone_url', ConfigType::STRING, 'cdn', 'Bunny pull zone URL'],
|
||||||
|
['cdn.bunny.push_enabled', ConfigType::BOOL, 'cdn', 'Enable pushing assets to CDN', false],
|
||||||
|
|
||||||
|
// CDN - Bunny Storage Public
|
||||||
|
['cdn.bunny.storage.public.name', ConfigType::STRING, 'cdn', 'Public storage zone name'],
|
||||||
|
['cdn.bunny.storage.public.api_key', ConfigType::STRING, 'cdn', 'Public storage API key'],
|
||||||
|
['cdn.bunny.storage.public.hostname', ConfigType::STRING, 'cdn', 'Public storage hostname', 'storage.bunnycdn.com'],
|
||||||
|
['cdn.bunny.storage.public.region', ConfigType::STRING, 'cdn', 'Public storage region', 'de'],
|
||||||
|
|
||||||
|
// CDN - Bunny Storage Private
|
||||||
|
['cdn.bunny.storage.private.name', ConfigType::STRING, 'cdn', 'Private storage zone name'],
|
||||||
|
['cdn.bunny.storage.private.api_key', ConfigType::STRING, 'cdn', 'Private storage API key'],
|
||||||
|
['cdn.bunny.storage.private.hostname', ConfigType::STRING, 'cdn', 'Private storage hostname', 'storage.bunnycdn.com'],
|
||||||
|
['cdn.bunny.storage.private.region', ConfigType::STRING, 'cdn', 'Private storage region', 'de'],
|
||||||
|
|
||||||
|
// Storage - Hetzner S3
|
||||||
|
['storage.hetzner.key', ConfigType::STRING, 'storage', 'Hetzner S3 access key'],
|
||||||
|
['storage.hetzner.secret', ConfigType::STRING, 'storage', 'Hetzner S3 secret key'],
|
||||||
|
['storage.hetzner.region', ConfigType::STRING, 'storage', 'Hetzner S3 region', 'eu-central'],
|
||||||
|
['storage.hetzner.bucket', ConfigType::STRING, 'storage', 'Hetzner S3 bucket name'],
|
||||||
|
['storage.hetzner.endpoint', ConfigType::STRING, 'storage', 'Hetzner S3 endpoint'],
|
||||||
|
|
||||||
|
// Social
|
||||||
|
['social.default_timezone', ConfigType::STRING, 'social', 'Default timezone for scheduling', 'Europe/London'],
|
||||||
|
['social.max_accounts', ConfigType::INT, 'social', 'Maximum connected accounts', 5],
|
||||||
|
['social.max_scheduled_posts', ConfigType::INT, 'social', 'Maximum scheduled posts', 100],
|
||||||
|
|
||||||
|
// Social - AI
|
||||||
|
['social.ai.enabled', ConfigType::BOOL, 'social', 'Enable AI features', true],
|
||||||
|
['social.ai.provider', ConfigType::STRING, 'social', 'AI provider (claude, openai, gemini)', 'claude'],
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
['analytics.retention_days', ConfigType::INT, 'analytics', 'Data retention in days', 365],
|
||||||
|
['analytics.sample_rate', ConfigType::FLOAT, 'analytics', 'Sampling rate (0.0-1.0)', 1.0],
|
||||||
|
['analytics.heatmaps_enabled', ConfigType::BOOL, 'analytics', 'Enable heatmap tracking', true],
|
||||||
|
['analytics.session_replay_enabled', ConfigType::BOOL, 'analytics', 'Enable session replay', true],
|
||||||
|
|
||||||
|
// Bio
|
||||||
|
['bio.max_pages', ConfigType::INT, 'bio', 'Maximum bio pages per workspace', 5],
|
||||||
|
['bio.custom_domains_enabled', ConfigType::BOOL, 'bio', 'Enable custom domains', true],
|
||||||
|
['bio.default_theme', ConfigType::STRING, 'bio', 'Default theme slug', 'minimal'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
ConfigKey::firstOrCreate(
|
||||||
|
['code' => $key[0]],
|
||||||
|
[
|
||||||
|
'type' => $key[1],
|
||||||
|
'category' => $key[2],
|
||||||
|
'description' => $key[3] ?? null,
|
||||||
|
'default_value' => $key[4] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Core/Config/Enums/CLAUDE.md
Normal file
12
app/Core/Config/Enums/CLAUDE.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Config/Enums/ — Config Type System
|
||||||
|
|
||||||
|
Backed enums for the configuration system's type safety and scope hierarchy.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
| Enum | Values | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `ConfigType` | STRING, BOOL, INT, FLOAT, ARRAY, JSON | Determines how config values are cast and validated. Has `cast()` and `default()` methods. |
|
||||||
|
| `ScopeType` | SYSTEM, ORG, WORKSPACE | Defines the inheritance hierarchy. Resolution order: workspace (priority 20) > org (10) > system (0). |
|
||||||
|
|
||||||
|
`ScopeType::resolutionOrder()` returns scopes from most specific to least specific for cascade resolution.
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue