Passed
Push — master ( 6fcfa0...21293d )
by Thomas
13:58 queued 13s
created

SimpleJobsController   F

Complexity

Total Complexity 92

Size/Duplication

Total Lines 495
Duplicated Lines 0 %

Importance

Changes 6
Bugs 0 Features 1
Metric Value
eloc 233
dl 0
loc 495
rs 2
c 6
b 0
f 1
wmc 92

13 Methods

Rating   Name   Duplication   Size   Complexity  
B trigger_manual() 0 25 8
A getLogger() 0 3 1
F trigger() 0 93 22
A trigger_next_task() 0 19 6
A init() 0 10 2
A isTaskDue() 0 25 6
A output() 0 6 2
A keyAuth() 0 14 4
A viewlogs() 0 22 6
A getCurrentTask() 0 3 1
C basicAuth() 0 53 14
C runTask() 0 64 13
B index() 0 38 7

How to fix   Complexity   

Complex Class

Complex classes like SimpleJobsController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SimpleJobsController, and based on these observations, apply Extract Interface, too.

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 SilverStripe\Core\Convert;
11
use LeKoala\SimpleJobs\CronJob;
12
use SilverStripe\Core\ClassInfo;
13
use SilverStripe\Control\Director;
14
use SilverStripe\Core\Environment;
15
use SilverStripe\Control\Controller;
16
use SilverStripe\Security\Permission;
17
use SilverStripe\Control\HTTPResponse;
18
use SilverStripe\Core\Injector\Injector;
19
use SilverStripe\CronTask\CronTaskStatus;
20
use SilverStripe\ORM\FieldType\DBDatetime;
21
use SilverStripe\CronTask\Interfaces\CronTask;
22
use SilverStripe\Control\HTTPResponse_Exception;
23
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
24
25
/**
26
 * A controller that triggers the jobs from an http request
27
 *
28
 */
29
class SimpleJobsController extends Controller
30
{
31
    /**
32
     * @var string
33
     */
34
    private static $url_segment = 'simple-jobs';
35
36
    /**
37
     * @var array<string, string>
38
     */
39
    private static $url_handlers = [
0 ignored issues
show
introduced by
The private property $url_handlers is not used, and could be removed.
Loading history...
40
        'simple-jobs/$Action/$ID/$OtherID' => 'handleAction',
41
    ];
42
43
    /**
44
     * @var array<string>
45
     */
46
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
47
        'trigger',
48
        'trigger_manual',
49
        'trigger_next_task',
50
        'viewlogs',
51
    ];
52
53
    /**
54
     * @var bool
55
     */
56
    protected $basicAuthEnabled = false;
57
58
    /**
59
     * @var string
60
     */
61
    protected static $currentTask = null;
62
63
    /**
64
     * @return void
65
     */
66
    public function init()
67
    {
68
        // Avoid multiple auths
69
        if (self::config()->username) {
70
            $this->basicAuthEnabled = false;
71
        }
72
73
        parent::init();
74
75
        HTTPCacheControlMiddleware::singleton()->disableCache();
76
    }
77
78
    /**
79
     * @return string|HTTPResponse|void
80
     */
81
    public function index()
82
    {
83
        $this->basicAuth();
84
85
        if (!Director::isDev()) {
86
            return 'Listing tasks is only available in dev mode';
87
        }
88
89
        $tasks = CronJob::allTasks();
90
        if (empty($tasks)) {
91
            return "There are no implementators of CronTask to run";
92
        }
93
94
        $segment = self::$url_segment;
95
96
        foreach ($tasks as $task) {
97
            $taskName = $task;
98
99
            $job = CronJob::getByTaskClass($task);
100
            if ($job && $job->IsDisabled()) {
101
                $taskName .= ' - disabled';
102
            }
103
104
            $taskUrl = str_replace('\\', '-', $task);
105
106
            $link = "/$segment/trigger_manual/" . $taskUrl;
107
            $forceLink = "/$segment/trigger_manual/" . $taskUrl;
108
            $this->output('<a href="' . $link . '">' . $taskName . '</a>');
109
            $this->output('<a href="' . $forceLink . '?force=1">' . $taskName . ' (forced run)</a>');
110
        }
111
112
        $this->output('');
113
        $this->output('<a href="/' . $segment . '/trigger_next_task">Trigger next simple task</a>');
114
115
        if (self::config()->store_results) {
116
            $this->output('');
117
            $this->output('<a href="/' . $segment . '/viewlogs">View 10 most recent log entries</a>');
118
            $this->output('<a href="/' . $segment . '/viewlogs/100">View 100 most recent log entries</a>');
119
        }
120
    }
121
122
    /**
123
     * @return string|HTTPResponse|void
124
     */
125
    public function viewlogs()
126
    {
127
        if (!Director::isDev() && !Permission::check('ADMIN')) {
128
            return "View logs is only available in dev mode or for admins";
129
        }
130
131
        $limit = (int) $this->getRequest()->param('ID');
132
        if (!$limit) {
133
            $limit = 10;
134
        }
135
136
        $results = CronTaskResult::get()->limit($limit);
137
138
        if (!$results->count()) {
139
            $this->output("No results to display");
140
        } else {
141
            $this->output("Displaying last $limit results");
142
        }
143
144
        foreach ($results as $result) {
145
            $this->output($result->Status());
146
            $this->output($result->PrettyResult());
147
        }
148
    }
149
150
    /**
151
     * This is a dedicated endpoint to manually run a specific job for admin
152
     *
153
     * @return string|void
154
     */
155
    public function trigger_manual()
156
    {
157
        if (!Permission::check('ADMIN')) {
158
            return 'You must be logged as an admin';
159
        }
160
161
        $class = $this->getRequest()->param('ID');
162
        if (!$class) {
163
            return 'You must specify a class';
164
        }
165
        $class = str_replace('-', '\\', $class);
166
        if (!class_exists($class)) {
167
            return 'Invalid class name';
168
        }
169
170
        $forceRun = $this->getRequest()->getVar('force') ? true : false;
171
172
        $cronJob = CronJob::getByTaskClass($class);
173
        if ($cronJob && $cronJob->IsDisabled() && !$forceRun) {
174
            return 'Task is disabled, use forced run';
175
        }
176
177
        /** @var \SilverStripe\CronTask\Interfaces\CronTask $task */
178
        $task = new $class();
179
        $this->runTask($task, $forceRun);
180
    }
181
182
    /**
183
     * This is a dedicated endpoint to force run the next task
184
     *
185
     * @return string|void
186
     */
187
    public function trigger_next_task()
188
    {
189
        if (!Director::isDev() && !Permission::check('ADMIN')) {
190
            return 'You must be logged as an admin or in dev mode';
191
        }
192
193
        $simpleTask = SimpleTask::getNextTaskToRun();
194
        if ($simpleTask) {
195
            $failed = $simpleTask->process();
196
            $message = 'ok';
197
            if ($failed) {
198
                $message = $simpleTask->ErrorMessage ? $simpleTask->ErrorMessage : 'not ok';
199
            }
200
            return $message;
201
        }
202
203
        $c = SimpleTask::get()->filter('Processed', 0)->count();
204
        $t = date('Y-m-d H:i:s');
205
        return 'No task (' . $c . ' future tasks, current time is ' . $t . ')';
206
    }
207
208
    /**
209
     * This is the endpoint that must be called by your monitoring system
210
     * You can create two endpoints:
211
     * - one with /trigger/cron for jobs
212
     * - one with /trigger/task for tasks
213
     *
214
     * If unspecified, it will run all jobs and the next task
215
     *
216
     * @return void|string
217
     */
218
    public function trigger()
219
    {
220
        // Never set a limit longer than the frequency at which this endpoint is called
221
        Environment::increaseTimeLimitTo(self::config()->time_limit);
222
223
        $this->keyAuth();
224
        $this->basicAuth();
225
226
        // We can set a type (cron|task). If empty, we run both cron and task
227
        $type = $this->getRequest()->param("ID");
228
        if ($type && !in_array($type, ['cron', 'task'])) {
229
            throw new Exception("Only 'cron' and 'task' are valid parameters");
230
        }
231
232
        // Create the lock file
233
        $lockFile = Director::baseFolder() . "/.simple-jobs-lock";
234
        if ($type) {
235
            $lockFile .= "-" . $type;
236
        }
237
        $now = date('Y-m-d H:i:s');
238
        if (is_file($lockFile)) {
239
            $lock_file_warn_early = self::config()->lock_file_warn_early;
240
            $t = file_get_contents($lockFile);
241
            $ip = $this->getRequest()->getIP();
242
243
            // there is an uncleared lockfile ?
244
            if ($lock_file_warn_early) {
245
                $this->getLogger()->error("Uncleared lock file created at $t ($type) - $ip");
246
            }
247
248
            // prevent running tasks < 5 min
249
            $nowt = strtotime($now);
250
            if ($t && $nowt) {
251
                $nowMinusFive = strtotime("-5 minutes", $nowt);
252
                if (strtotime($t) > $nowMinusFive) {
253
                    die("Prevent running concurrent queues");
0 ignored issues
show
Best Practice introduced by
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...
254
                }
255
            }
256
257
            if (!$lock_file_warn_early) {
258
                $this->getLogger()->error("Uncleared lock file created at $t ($type) - $ip");
259
            }
260
261
            // clear anyway
262
            unlink($lockFile);
263
        }
264
        file_put_contents($lockFile, $now);
265
266
        $tasks = CronJob::allTasks();
267
        if (empty($tasks)) {
268
            return "There are no implementators of CronTask to run";
269
        }
270
        if (!$type || $type == "cron") {
271
            foreach ($tasks as $subclass) {
272
                $cronJob = CronJob::getByTaskClass($subclass);
273
                if ($cronJob && $cronJob->IsDisabled()) {
274
                    $this->output("Task $subclass is disabled");
275
                    continue;
276
                }
277
                /** @var \SilverStripe\CronTask\Interfaces\CronTask $task */
278
                $task = new $subclass();
279
                $this->runTask($task);
280
            }
281
282
            // Avoid the table to be full of stuff
283
            if (self::config()->auto_clean) {
284
                $time = date('Y-m-d', strtotime(self::config()->auto_clean_threshold));
285
                if (self::config()->store_results) {
286
                    $sql = "DELETE FROM \"CronTaskResult\" WHERE \"Created\" < '$time'";
287
                    DB::query($sql);
288
                }
289
            }
290
        }
291
292
        // Do we have a simple task to run ?
293
        if (!$type || $type == "task") {
294
            $simpleTask = SimpleTask::getNextTaskToRun();
295
            if ($simpleTask) {
296
                $simpleTask->process();
297
                $this->output("Processed task {$simpleTask->ID}");
298
            } else {
299
                $this->output("No task");
300
            }
301
            // Avoid the table to be full of stuff
302
            if (self::config()->auto_clean) {
303
                $time = date('Y-m-d', strtotime(self::config()->auto_clean_threshold));
304
                $sql = "DELETE FROM \"SimpleTask\" WHERE \"Created\" < '$time'";
305
                DB::query($sql);
306
            }
307
        }
308
309
        // Clear lock file
310
        unlink($lockFile);
311
    }
312
313
    /**
314
     * Determine if a task should be run
315
     *
316
     * @param CronTask $task
317
     * @param \Cron\CronExpression $cron
318
     * @return bool
319
     */
320
    protected function isTaskDue(CronTask $task, \Cron\CronExpression $cron)
321
    {
322
        // Get last run status
323
        /** @var CronTaskStatus|null $status */
324
        $status = CronTaskStatus::get_status(get_class($task));
325
326
        // If the cron is due immediately, then run it
327
        $now = new DateTime(DBDatetime::now()->getValue());
328
        if ($cron->isDue($now)) {
329
            if (empty($status) || empty($status->LastRun)) {
330
                return true;
331
            }
332
            // In case this process is invoked twice in one minute, supress subsequent executions
333
            $lastRun = new DateTime($status->LastRun);
334
            return $lastRun->format('Y-m-d H:i') != $now->format('Y-m-d H:i');
335
        }
336
337
        // If this is the first time this task is ever checked, no way to detect postponed execution
338
        if (empty($status) || empty($status->LastChecked)) {
339
            return false;
340
        }
341
342
        // Determine if we have passed the last expected run time
343
        $nextExpectedDate = $cron->getNextRunDate($status->LastChecked);
344
        return $nextExpectedDate <= $now;
345
    }
346
347
    /**
348
     * Checks and runs a single CronTask
349
     *
350
     * @param CronTask $task
351
     * @param boolean $forceRun
352
     * @return void
353
     */
354
    protected function runTask(CronTask $task, $forceRun = false)
355
    {
356
        $cron = new CronExpression($task->getSchedule());
357
        $isDue = $this->isTaskDue($task, $cron);
358
        $willRun = $isDue || $forceRun;
359
        // Update status of this task prior to execution in case of interruption
360
        CronTaskStatus::update_status(get_class($task), $willRun);
361
        if ($isDue || $forceRun) {
362
            $msg = ' will start now';
363
            if (!$isDue && $forceRun) {
364
                $msg .= " (forced run)";
365
            }
366
            $this->output(get_class($task) . $msg);
367
368
            $startDate = date('Y-m-d H:i:s');
369
370
            // Handle exceptions for tasks
371
            $error = null;
372
            try {
373
                self::$currentTask = get_class($task);
374
                // We override docblock return type because we allow a result
375
                /** @var mixed $result */
376
                $result = $task->process();
0 ignored issues
show
Bug introduced by
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...
377
                $this->output(CronTaskResult::PrettifyResult($result));
378
            } catch (Exception $ex) {
379
                $result = false;
380
                $error = $ex->getMessage();
381
                $this->output(CronTaskResult::PrettifyResult($result));
382
            }
383
384
            $endDate = date('Y-m-d H:i:s');
385
            $timeToExecute = strtotime($endDate) - strtotime($startDate);
386
387
            // Store result if we return something
388
            if (self::config()->store_results && $result !== null) {
389
                $cronResult = new CronTaskResult;
390
                if ($result === false) {
391
                    $cronResult->Failed = true;
392
                    $cronResult->Result = $error;
393
                } else {
394
                    if (is_object($result)) {
395
                        $result = print_r($result, true);
396
                    } elseif (is_array($result)) {
397
                        $json = json_encode($result);
398
                        if ($json) {
399
                            $result = $json;
400
                        } else {
401
                            $result = json_last_error_msg();
402
                        }
403
                    }
404
                    $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...
405
                }
406
                $cronResult->TaskClass = get_class($task);
407
                $cronResult->ForcedRun = $forceRun;
408
                $cronResult->StartDate = $startDate;
409
                $cronResult->EndDate = $endDate;
410
                $cronResult->TimeToExecute = $timeToExecute;
411
                $cronResult->write();
412
            }
413
        } else {
414
            $this->output(get_class($task) . ' will run at ' . $cron->getNextRunDate()->format('Y-m-d H:i:s') . '.');
415
        }
416
417
        self::$currentTask = null;
418
    }
419
420
    /**
421
     * @return ?string
422
     */
423
    public static function getCurrentTask()
424
    {
425
        return self::$currentTask;
426
    }
427
428
    /**
429
     * @param string|null $message
430
     * @param boolean $escape
431
     * @return void
432
     */
433
    protected function output($message, $escape = false)
434
    {
435
        if ($escape) {
436
            $message = htmlspecialchars($message ?? '', ENT_QUOTES, 'UTF-8');
437
        }
438
        echo $message . '<br />' . PHP_EOL;
439
    }
440
441
    protected function keyAuth()
442
    {
443
        $envKey = Environment::getEnv('SIMPLE_JOBS_KEY');
444
        if (!$envKey) {
445
            return true;
446
        }
447
        $key = $this->getRequest()->getVar('key');
448
        if (!$key) {
449
            $key = $this->getRequest()->getHeader('X-KEY');
450
        }
451
        if ($key != $envKey) {
452
            die("Invalid key");
0 ignored issues
show
Best Practice introduced by
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...
453
        }
454
        return true;
455
    }
456
457
    /**
458
     * Enable BasicAuth in a similar fashion as BasicAuth class
459
     *
460
     * @return boolean
461
     * @throws HTTPResponse_Exception
462
     */
463
    protected function basicAuth()
464
    {
465
        if (Director::is_cli()) {
466
            return true;
467
        }
468
469
        $username = self::config()->username;
470
        $password = self::config()->password;
471
        if (!$username || !$password) {
472
            return true;
473
        }
474
475
        $authHeader = null;
476
        if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
477
            $authHeader = $_SERVER['HTTP_AUTHORIZATION'];
478
        } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
479
            $authHeader = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
480
        }
481
482
        $matches = array();
483
484
        $hasBasicHeaders =  $authHeader && preg_match('/Basic\s+(.*)$/i', $authHeader, $matches);
485
        if ($hasBasicHeaders) {
486
            list($name, $password) = explode(':', base64_decode($matches[1]));
487
            $_SERVER['PHP_AUTH_USER'] = strip_tags($name);
488
            $_SERVER['PHP_AUTH_PW'] = strip_tags($password);
489
        }
490
491
        $authSuccess = false;
492
        if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
493
            if ($_SERVER['PHP_AUTH_USER'] == $username && $_SERVER['PHP_AUTH_PW'] == $password) {
494
                $authSuccess = true;
495
            }
496
        }
497
498
        if (!$authSuccess) {
499
            $realm = "Enter your credentials";
500
            $response = new HTTPResponse(null, 401);
501
            $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\"");
502
503
            if (isset($_SERVER['PHP_AUTH_USER'])) {
504
                $response->setBody(_t('BasicAuth.ERRORNOTREC', "That username / password isn't recognised"));
505
            } else {
506
                $response->setBody(_t('BasicAuth.ENTERINFO', "Please enter a username and password."));
507
            }
508
509
            // Exception is caught by RequestHandler->handleRequest() and will halt further execution
510
            $e = new HTTPResponse_Exception(null, 401);
511
            $e->setResponse($response);
512
            throw $e;
513
        }
514
515
        return $authSuccess;
516
    }
517
518
    /**
519
     * @return LoggerInterface
520
     */
521
    public static function getLogger()
522
    {
523
        return Injector::inst()->get(LoggerInterface::class)->withName('SimpleJobsController');
524
    }
525
}
526