Passed
Push — master ( 47f2b8...f595ce )
by Thomas
02:22
created

SimpleJobsController::index()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 40
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

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