2026-03-12 12:42:59 +00:00
|
|
|
<?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
|
|
|
|
|
{
|
2026-03-12 13:56:14 +00:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-12 12:42:59 +00:00
|
|
|
public function boot(): void
|
|
|
|
|
{
|
|
|
|
|
if (! $this->app->runningInConsole()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard against table not existing (pre-migration)
|
2026-03-12 13:56:14 +00:00
|
|
|
try {
|
|
|
|
|
if (! Schema::hasTable('scheduled_actions')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
// DB unreachable — skip gracefully so scheduler doesn't crash
|
2026-03-12 12:42:59 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->app->booted(function () {
|
2026-03-12 13:56:14 +00:00
|
|
|
$this->registerScheduledActions();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-12 12:42:59 +00:00
|
|
|
|
2026-03-12 13:56:14 +00:00
|
|
|
private function registerScheduledActions(): void
|
|
|
|
|
{
|
|
|
|
|
$schedule = $this->app->make(Schedule::class);
|
|
|
|
|
$actions = ScheduledAction::enabled()->get();
|
2026-03-12 12:42:59 +00:00
|
|
|
|
2026-03-12 13:56:14 +00:00
|
|
|
foreach ($actions as $action) {
|
|
|
|
|
try {
|
2026-03-12 12:42:59 +00:00
|
|
|
$class = $action->action_class;
|
|
|
|
|
|
2026-03-12 13:56:14 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 12:42:59 +00:00
|
|
|
if (! class_exists($class)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 13:56:14 +00:00
|
|
|
// Verify the class uses the Action trait
|
|
|
|
|
if (! in_array(\Core\Actions\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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 12:42:59 +00:00
|
|
|
$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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 13:56:14 +00:00
|
|
|
if ($action->run_in_background) {
|
|
|
|
|
$event->runInBackground();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 12:42:59 +00:00
|
|
|
if ($action->timezone) {
|
|
|
|
|
$event->timezone($action->timezone);
|
|
|
|
|
}
|
2026-03-12 13:56:14 +00:00
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
logger()->warning("Failed to register scheduled action: {$action->action_class} — {$e->getMessage()}");
|
2026-03-12 12:42:59 +00:00
|
|
|
}
|
2026-03-12 13:56:14 +00:00
|
|
|
}
|
2026-03-12 12:42:59 +00:00
|
|
|
}
|
|
|
|
|
}
|