Passed
Push — master ( 4f95ca...71f2c0 )
by Thomas
13:45
created

SimpleJobsController::keyAuth()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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