Issues (47)

src/SimpleJobsController.php (1 issue)

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 = [
38
        'simple-jobs/$Action/$ID/$OtherID' => 'handleAction',
39
    ];
40
41
    /**
42
     * @var array<string>
43
     */
44
    private static $allowed_actions = [
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();
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;
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