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; |
||
0 ignored issues
–
show
|
|||
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"); |
||
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 |
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 theid
property of an instance of theAccount
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.