Issues (47)

src/SimpleJobsController.php (5 issues)

1
<?php
2
3
namespace LeKoala\SimpleJobs;
4
5
use DateTime;
6
use Exception;
7
use Cron\CronExpression;
8
use SilverStripe\ORM\DB;
9
use Psr\Log\LoggerInterface;
10
use LeKoala\SimpleJobs\CronJob;
11
use SilverStripe\Control\Director;
12
use SilverStripe\Core\Environment;
13
use SilverStripe\Control\Controller;
14
use SilverStripe\Security\Permission;
15
use SilverStripe\Control\HTTPResponse;
16
use SilverStripe\Core\Injector\Injector;
17
use SilverStripe\CronTask\CronTaskStatus;
18
use SilverStripe\ORM\FieldType\DBDatetime;
19
use SilverStripe\CronTask\Interfaces\CronTask;
20
use SilverStripe\Control\HTTPResponse_Exception;
21
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
22
23
/**
24
 * A controller that triggers the jobs from an http request
25
 *
26
 */
27
class SimpleJobsController extends Controller
28
{
29
    /**
30
     * @var string
31
     */
32
    private static $url_segment = 'simple-jobs';
33
34
    /**
35
     * @var array<string, string>
36
     */
37
    private static $url_handlers = [
0 ignored issues
show
The private property $url_handlers is not used, and could be removed.
Loading history...
38
        'simple-jobs/$Action/$ID/$OtherID' => 'handleAction',
39
    ];
40
41
    /**
42
     * @var array<string>
43
     */
44
    private static $allowed_actions = [
0 ignored issues
show
The private property $allowed_actions is not used, and could be removed.
Loading history...
45
        'trigger',
46
        'trigger_manual',
47
        'trigger_next_task',
48
        'viewlogs',
49
    ];
50
51
    /**
52
     * @var bool
53
     */
54
    protected $basicAuthEnabled = false;
55
56
    /**
57
     * @var string
58
     */
59
    protected static $currentTask = null;
60
61
    /**
62
     * @return void
63
     */
64
    public function init()
65
    {
66
        // Avoid multiple auths
67
        if (self::config()->username) {
68
            $this->basicAuthEnabled = false;
69
        }
70
71
        parent::init();
72
73
        HTTPCacheControlMiddleware::singleton()->disableCache();
74
    }
75
76
    /**
77
     * @return string|HTTPResponse|void
78
     */
79
    public function index()
80
    {
81
        $this->basicAuth();
82
83
        if (!Director::isDev()) {
84
            return 'Listing tasks is only available in dev mode';
85
        }
86
87
        $tasks = CronJob::allTasks();
88
        if (empty($tasks)) {
89
            return "There are no implementators of CronTask to run";
90
        }
91
92
        $segment = self::$url_segment;
93
94
        foreach ($tasks as $task) {
95
            $taskName = $task;
96
97
            $job = CronJob::getByTaskClass($task);
98
            if ($job && $job->IsDisabled()) {
99
                $taskName .= ' - disabled';
100
            }
101
102
            $taskUrl = str_replace('\\', '-', $task);
103
104
            $link = "/$segment/trigger_manual/" . $taskUrl;
105
            $forceLink = "/$segment/trigger_manual/" . $taskUrl;
106
            $this->output('<a href="' . $link . '">' . $taskName . '</a>');
107
            $this->output('<a href="' . $forceLink . '?force=1">' . $taskName . ' (forced run)</a>');
108
        }
109
110
        $this->output('');
111
        $this->output('<a href="/' . $segment . '/trigger_next_task">Trigger next simple task</a>');
112
113
        if (self::config()->store_results) {
114
            $this->output('');
115
            $this->output('<a href="/' . $segment . '/viewlogs">View 10 most recent log entries</a>');
116
            $this->output('<a href="/' . $segment . '/viewlogs/100">View 100 most recent log entries</a>');
117
        }
118
    }
119
120
    /**
121
     * @return string|HTTPResponse|void
122
     */
123
    public function viewlogs()
124
    {
125
        if (!Director::isDev() && !Permission::check('ADMIN')) {
126
            return "View logs is only available in dev mode or for admins";
127
        }
128
129
        $limit = (int) $this->getRequest()->param('ID');
130
        if (!$limit) {
131
            $limit = 10;
132
        }
133
134
        $results = CronTaskResult::get()->limit($limit);
135
136
        if (!$results->count()) {
137
            $this->output("No results to display");
138
        } else {
139
            $this->output("Displaying last $limit results");
140
        }
141
142
        foreach ($results as $result) {
143
            $this->output($result->Status());
144
            $this->output($result->PrettyResult());
145
        }
146
    }
147
148
    /**
149
     * This is a dedicated endpoint to manually run a specific job for admin
150
     *
151
     * @return string|void
152
     */
153
    public function trigger_manual()
154
    {
155
        if (!Permission::check('ADMIN')) {
156
            return 'You must be logged as an admin';
157
        }
158
159
        $class = $this->getRequest()->param('ID');
160
        if (!$class) {
161
            return 'You must specify a class';
162
        }
163
        $class = str_replace('-', '\\', $class);
164
        if (!class_exists($class)) {
165
            return 'Invalid class name';
166
        }
167
168
        $forceRun = $this->getRequest()->getVar('force') ? true : false;
169
170
        $cronJob = CronJob::getByTaskClass($class);
171
        if ($cronJob && $cronJob->IsDisabled() && !$forceRun) {
172
            return 'Task is disabled, use forced run';
173
        }
174
175
        /** @var \SilverStripe\CronTask\Interfaces\CronTask $task */
176
        $task = new $class();
177
        $this->runTask($task, $forceRun);
178
    }
179
180
    /**
181
     * This is a dedicated endpoint to force run the next task
182
     *
183
     * @return string|void
184
     */
185
    public function trigger_next_task()
186
    {
187
        if (!Director::isDev() && !Permission::check('ADMIN')) {
188
            return 'You must be logged as an admin or in dev mode';
189
        }
190
191
        $simpleTask = SimpleTask::getNextTaskToRun();
192
        if ($simpleTask) {
193
            $failed = $simpleTask->process();
194
            $message = 'ok';
195
            if ($failed) {
196
                $message = $simpleTask->ErrorMessage ? $simpleTask->ErrorMessage : 'not ok';
197
            }
198
            return $message;
199
        }
200
201
        $c = SimpleTask::get()->filter('Processed', 0)->count();
202
        $t = date('Y-m-d H:i:s');
203
        return 'No task (' . $c . ' future tasks, current time is ' . $t . ')';
204
    }
205
206
    /**
207
     * This is the endpoint that must be called by your monitoring system
208
     * You can create two endpoints:
209
     * - one with /trigger/cron for jobs
210
     * - one with /trigger/task for tasks
211
     *
212
     * If unspecified, it will run all jobs and the next task
213
     *
214
     * @return void
215
     */
216
    public function trigger()
217
    {
218
        // Never set a limit longer than the frequency at which this endpoint is called
219
        Environment::increaseTimeLimitTo(self::config()->time_limit);
220
221
        $this->keyAuth();
222
        $this->basicAuth();
223
224
        // We can set a type (cron|task). If empty, we run both cron and task
225
        $type = $this->getRequest()->param("ID");
226
        if ($type && !in_array($type, ['cron', 'task'])) {
227
            throw new Exception("Only 'cron' and 'task' are valid parameters");
228
        }
229
230
        // Create the lock file
231
        $lockFile = Director::baseFolder() . "/.simple-jobs-lock";
232
        if ($type) {
233
            $lockFile .= "-" . $type;
234
        }
235
        $now = date('Y-m-d H:i:s');
236
        if (is_file($lockFile)) {
237
            $lock_file_warn_early = self::config()->lock_file_warn_early;
238
            $t = file_get_contents($lockFile);
239
            $ip = $this->getRequest()->getIP();
240
241
            // there is an uncleared lockfile ?
242
            if ($lock_file_warn_early) {
243
                $this->getLogger()->error("Uncleared lock file created at $t ($type) - $ip");
244
            }
245
246
            // prevent running tasks < 5 min
247
            $nowt = strtotime($now);
248
            if ($t && $nowt) {
249
                $nowMinusFive = strtotime("-5 minutes", $nowt);
250
                if (strtotime($t) > $nowMinusFive) {
251
                    $this->output("Prevent running concurrent queues");
252
                    return;
253
                }
254
            }
255
256
            if (!$lock_file_warn_early) {
257
                $this->getLogger()->error("Uncleared lock file created at $t ($type) - $ip");
258
            }
259
260
            // clear anyway
261
            unlink($lockFile);
262
        }
263
        file_put_contents($lockFile, $now);
264
265
        $tasks = CronJob::allTasks();
266
        if (empty($tasks)) {
267
            $this->output("There are no implementators of CronTask to run");
268
            return;
269
        }
270
271
        // Do we have a cron job to run ?
272
        if (!$type || $type == "cron") {
273
            foreach ($tasks as $subclass) {
274
                $cronJob = CronJob::getByTaskClass($subclass);
275
                if ($cronJob && $cronJob->IsDisabled()) {
276
                    $this->output("Task $subclass is disabled");
277
                    continue;
278
                }
279
                /** @var \SilverStripe\CronTask\Interfaces\CronTask $task */
280
                $task = new $subclass();
281
                $this->runTask($task);
282
            }
283
284
            if (empty($tasks)) {
285
                $this->output("No jobs");
286
            }
287
288
            // Avoid the table to be full of stuff
289
            if (self::config()->auto_clean) {
290
                if (self::config()->store_results) {
291
                    self::clearResultsTable();
292
                }
293
            }
294
        }
295
296
        // Do we have a simple task to run ?
297
        if (!$type || $type == "task") {
298
            $simpleTask = SimpleTask::getNextTaskToRun();
299
            if ($simpleTask) {
300
                $simpleTask->process();
301
                $this->output("Processed task {$simpleTask->ID}");
302
            } else {
303
                $this->output("No task");
304
            }
305
306
            // Avoid the table to be full of stuff
307
            if (self::config()->auto_clean) {
308
                self::clearTasksTable();
309
            }
310
        }
311
312
        // Clear lock file (check if it hasn't be cleared in the meantime)
313
        if (is_file($lockFile)) {
314
            unlink($lockFile);
315
        }
316
    }
317
318
    /**
319
     * @return void
320
     */
321
    public static function clearTasksTable()
322
    {
323
        if (!self::config()->auto_clean_threshold) {
324
            return;
325
        }
326
        $time = date('Y-m-d', strtotime(self::config()->auto_clean_threshold));
327
        $sql = "DELETE FROM \"SimpleTask\" WHERE \"Created\" < '$time'";
328
        DB::query($sql);
329
    }
330
331
    /**
332
     * @return void
333
     */
334
    public static function clearResultsTable()
335
    {
336
        if (!self::config()->auto_clean_threshold) {
337
            return;
338
        }
339
        $time = date('Y-m-d', strtotime(self::config()->auto_clean_threshold));
340
        $sql = "DELETE FROM \"CronTaskResult\" WHERE \"Created\" < '$time'";
341
        DB::query($sql);
342
    }
343
344
    /**
345
     * Determine if a task should be run
346
     *
347
     * @param CronTask $task
348
     * @param \Cron\CronExpression $cron
349
     * @return bool
350
     */
351
    protected function isTaskDue(CronTask $task, \Cron\CronExpression $cron)
352
    {
353
        // Get last run status
354
        /** @var CronTaskStatus|null $status */
355
        $status = CronTaskStatus::get_status(get_class($task));
356
357
        // If the cron is due immediately, then run it
358
        $now = new DateTime(DBDatetime::now()->getValue());
359
        if ($cron->isDue($now)) {
360
            if (empty($status) || empty($status->LastRun)) {
361
                return true;
362
            }
363
            // In case this process is invoked twice in one minute, supress subsequent executions
364
            $lastRun = new DateTime($status->LastRun);
365
            return $lastRun->format('Y-m-d H:i') != $now->format('Y-m-d H:i');
366
        }
367
368
        // If this is the first time this task is ever checked, no way to detect postponed execution
369
        if (empty($status) || empty($status->LastChecked)) {
370
            return false;
371
        }
372
373
        // Determine if we have passed the last expected run time
374
        $nextExpectedDate = $cron->getNextRunDate($status->LastChecked);
375
        return $nextExpectedDate <= $now;
376
    }
377
378
    /**
379
     * Checks and runs a single CronTask
380
     *
381
     * @param CronTask $task
382
     * @param boolean $forceRun
383
     * @return void
384
     */
385
    protected function runTask(CronTask $task, $forceRun = false)
386
    {
387
        $cron = new CronExpression($task->getSchedule());
388
        $isDue = $this->isTaskDue($task, $cron);
389
        $willRun = $isDue || $forceRun;
390
        // Update status of this task prior to execution in case of interruption
391
        CronTaskStatus::update_status(get_class($task), $willRun);
392
        if ($isDue || $forceRun) {
393
            $msg = ' will start now';
394
            if (!$isDue && $forceRun) {
395
                $msg .= " (forced run)";
396
            }
397
            $this->output(get_class($task) . $msg);
398
399
            $startDate = date('Y-m-d H:i:s');
400
401
            // Handle exceptions for tasks
402
            $error = null;
403
            try {
404
                self::$currentTask = get_class($task);
405
                // We override docblock return type because we allow a result
406
                /** @var mixed $result */
407
                $result = $task->process();
0 ignored issues
show
Are you sure the assignment to $result is correct as $task->process() targeting SilverStripe\CronTask\In...ces\CronTask::process() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
408
                $this->output(CronTaskResult::PrettifyResult($result));
409
            } catch (Exception $ex) {
410
                $result = false;
411
                $error = $ex->getMessage();
412
                $this->output(CronTaskResult::PrettifyResult($result));
413
            }
414
415
            $endDate = date('Y-m-d H:i:s');
416
            $timeToExecute = strtotime($endDate) - strtotime($startDate);
417
418
            // Store result if we return something
419
            if (self::config()->store_results && $result !== null) {
420
                $cronResult = new CronTaskResult;
421
                if ($result === false) {
422
                    $cronResult->Failed = true;
423
                    $cronResult->Result = $error;
424
                } else {
425
                    if (is_object($result)) {
426
                        $result = print_r($result, true);
427
                    } elseif (is_array($result)) {
428
                        $json = json_encode($result);
429
                        if ($json) {
430
                            $result = $json;
431
                        } else {
432
                            $result = json_last_error_msg();
433
                        }
434
                    }
435
                    $cronResult->Result = $result;
0 ignored issues
show
Documentation Bug introduced by
It seems like $result can also be of type true. However, the property $Result is declared as type null|string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
436
                }
437
                $cronResult->TaskClass = get_class($task);
438
                $cronResult->ForcedRun = $forceRun;
439
                $cronResult->StartDate = $startDate;
440
                $cronResult->EndDate = $endDate;
441
                $cronResult->TimeToExecute = $timeToExecute;
442
                $cronResult->write();
443
            }
444
        } else {
445
            $this->output(get_class($task) . ' will run at ' . $cron->getNextRunDate()->format('Y-m-d H:i:s') . '.');
446
        }
447
448
        self::$currentTask = null;
449
    }
450
451
    /**
452
     * @return ?string
453
     */
454
    public static function getCurrentTask()
455
    {
456
        return self::$currentTask;
457
    }
458
459
    /**
460
     * @param string|null $message
461
     * @param boolean $escape
462
     * @return void
463
     */
464
    protected function output($message, $escape = false)
465
    {
466
        if ($escape) {
467
            $message = htmlspecialchars($message ?? '', ENT_QUOTES, 'UTF-8');
468
        }
469
        echo $message . '<br />' . PHP_EOL;
470
    }
471
472
    protected function keyAuth()
473
    {
474
        $envKey = Environment::getEnv('SIMPLE_JOBS_KEY');
475
        if (!$envKey) {
476
            return true;
477
        }
478
        $key = $this->getRequest()->getVar('key');
479
        if (!$key) {
480
            $key = $this->getRequest()->getHeader('X-KEY');
481
        }
482
        if ($key != $envKey) {
483
            die("Invalid key");
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
484
        }
485
        return true;
486
    }
487
488
    /**
489
     * Enable BasicAuth in a similar fashion as BasicAuth class
490
     *
491
     * @return boolean
492
     * @throws HTTPResponse_Exception
493
     */
494
    protected function basicAuth()
495
    {
496
        if (Director::is_cli()) {
497
            return true;
498
        }
499
500
        $username = self::config()->username;
501
        $password = self::config()->password;
502
        if (!$username || !$password) {
503
            return true;
504
        }
505
506
        $authHeader = null;
507
        if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
508
            $authHeader = $_SERVER['HTTP_AUTHORIZATION'];
509
        } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
510
            $authHeader = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
511
        }
512
513
        $matches = array();
514
515
        $hasBasicHeaders =  $authHeader && preg_match('/Basic\s+(.*)$/i', $authHeader, $matches);
516
        if ($hasBasicHeaders) {
517
            list($name, $password) = explode(':', base64_decode($matches[1]));
518
            $_SERVER['PHP_AUTH_USER'] = strip_tags($name);
519
            $_SERVER['PHP_AUTH_PW'] = strip_tags($password);
520
        }
521
522
        $authSuccess = false;
523
        if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
524
            if ($_SERVER['PHP_AUTH_USER'] == $username && $_SERVER['PHP_AUTH_PW'] == $password) {
525
                $authSuccess = true;
526
            }
527
        }
528
529
        if (!$authSuccess) {
530
            $realm = "Enter your credentials";
531
            $response = new HTTPResponse(null, 401);
532
            $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\"");
533
534
            if (isset($_SERVER['PHP_AUTH_USER'])) {
535
                $response->setBody(_t('BasicAuth.ERRORNOTREC', "That username / password isn't recognised"));
536
            } else {
537
                $response->setBody(_t('BasicAuth.ENTERINFO', "Please enter a username and password."));
538
            }
539
540
            // Exception is caught by RequestHandler->handleRequest() and will halt further execution
541
            $e = new HTTPResponse_Exception(null, 401);
542
            $e->setResponse($response);
543
            throw $e;
544
        }
545
546
        return $authSuccess;
547
    }
548
549
    /**
550
     * @return LoggerInterface
551
     */
552
    public static function getLogger()
553
    {
554
        return Injector::inst()->get(LoggerInterface::class)->withName('SimpleJobsController');
555
    }
556
}
557