diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2b7c50 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Kritbit + +Kritbit is a simplistic alternative to continuous integration tools like Jenkins. I've personally found Jenkins to be too cumbersome for a one man show. It's a great tool if you have the time to sit down and configure it and have it used by many people - but it was too complex for my needs. + +Kritbit was created to fill a gap of needing the ability to track and run jobs. + +Kritbit has 3 purposes: + +1. To run a job locally and collect stats about the run +2. To run a job remotely and collect stats about the run +3. To allow an external service to phone home with stats about a job that ran + +#1 is suited towards running compile and test jobs +#2 is to be crossplatform and thus will have a service that can be installed on systems. This will also have the ability to "hot run" a command remotely. +#3 is to have integration into current task scheduling systems + +Kritbit is designed to be simple and flexible. It makes no assumptions about your security and only provides minimal security procedures. I am not a crypto expert - but I make tools that works. So while I cannot guarantee that big brother won't be able to decrypt messages from external services - it should be good enough for most implementations. So please, if you find that the crypto security is less than perfect I accept patches of any size, creed, or color. \ No newline at end of file diff --git a/scripts/rijndael.py b/scripts/rijndael.py new file mode 100644 index 0000000..e1b721b --- /dev/null +++ b/scripts/rijndael.py @@ -0,0 +1,392 @@ +""" +A pure python (slow) implementation of rijndael with a decent interface + +To include - + +from rijndael import rijndael + +To do a key setup - + +r = rijndael(key, block_size = 16) + +key must be a string of length 16, 24, or 32 +blocksize must be 16, 24, or 32. Default is 16 + +To use - + +ciphertext = r.encrypt(plaintext) +plaintext = r.decrypt(ciphertext) + +If any strings are of the wrong length a ValueError is thrown +""" + +# ported from the Java reference code by Bram Cohen, bram@gawth.com, April 2001 +# this code is public domain, unless someone makes +# an intellectual property claim against the reference +# code, in which case it can be made public domain by +# deleting all the comments and renaming all the variables +# Source: https://raw.githubusercontent.com/andris9/squirrelpay/master/banklink/tlslite/utils/rijndael.py + +import copy +import string + + + +#----------------------- +#TREV - ADDED BECAUSE THERE'S WARNINGS ABOUT INT OVERFLOW BEHAVIOR CHANGING IN +#2.4..... +import os +if os.name != "java": + import exceptions + if hasattr(exceptions, "FutureWarning"): + import warnings + warnings.filterwarnings("ignore", category=FutureWarning, append=1) +#----------------------- + + + +shifts = [[[0, 0], [1, 3], [2, 2], [3, 1]], + [[0, 0], [1, 5], [2, 4], [3, 3]], + [[0, 0], [1, 7], [3, 5], [4, 4]]] + +# [keysize][block_size] +num_rounds = {16: {16: 10, 24: 12, 32: 14}, 24: {16: 12, 24: 12, 32: 14}, 32: {16: 14, 24: 14, 32: 14}} + +A = [[1, 1, 1, 1, 1, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 1, 1, 1, 1, 1, 0], + [0, 0, 0, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 1, 1, 1, 1], + [1, 1, 0, 0, 0, 1, 1, 1], + [1, 1, 1, 0, 0, 0, 1, 1], + [1, 1, 1, 1, 0, 0, 0, 1]] + +# produce log and alog tables, needed for multiplying in the +# field GF(2^m) (generator = 3) +alog = [1] +for i in xrange(255): + j = (alog[-1] << 1) ^ alog[-1] + if j & 0x100 != 0: + j ^= 0x11B + alog.append(j) + +log = [0] * 256 +for i in xrange(1, 255): + log[alog[i]] = i + +# multiply two elements of GF(2^m) +def mul(a, b): + if a == 0 or b == 0: + return 0 + return alog[(log[a & 0xFF] + log[b & 0xFF]) % 255] + +# substitution box based on F^{-1}(x) +box = [[0] * 8 for i in xrange(256)] +box[1][7] = 1 +for i in xrange(2, 256): + j = alog[255 - log[i]] + for t in xrange(8): + box[i][t] = (j >> (7 - t)) & 0x01 + +B = [0, 1, 1, 0, 0, 0, 1, 1] + +# affine transform: box[i] <- B + A*box[i] +cox = [[0] * 8 for i in xrange(256)] +for i in xrange(256): + for t in xrange(8): + cox[i][t] = B[t] + for j in xrange(8): + cox[i][t] ^= A[t][j] * box[i][j] + +# S-boxes and inverse S-boxes +S = [0] * 256 +Si = [0] * 256 +for i in xrange(256): + S[i] = cox[i][0] << 7 + for t in xrange(1, 8): + S[i] ^= cox[i][t] << (7-t) + Si[S[i] & 0xFF] = i + +# T-boxes +G = [[2, 1, 1, 3], + [3, 2, 1, 1], + [1, 3, 2, 1], + [1, 1, 3, 2]] + +AA = [[0] * 8 for i in xrange(4)] + +for i in xrange(4): + for j in xrange(4): + AA[i][j] = G[i][j] + AA[i][i+4] = 1 + +for i in xrange(4): + pivot = AA[i][i] + if pivot == 0: + t = i + 1 + while AA[t][i] == 0 and t < 4: + t += 1 + assert t != 4, 'G matrix must be invertible' + for j in xrange(8): + AA[i][j], AA[t][j] = AA[t][j], AA[i][j] + pivot = AA[i][i] + for j in xrange(8): + if AA[i][j] != 0: + AA[i][j] = alog[(255 + log[AA[i][j] & 0xFF] - log[pivot & 0xFF]) % 255] + for t in xrange(4): + if i != t: + for j in xrange(i+1, 8): + AA[t][j] ^= mul(AA[i][j], AA[t][i]) + AA[t][i] = 0 + +iG = [[0] * 4 for i in xrange(4)] + +for i in xrange(4): + for j in xrange(4): + iG[i][j] = AA[i][j + 4] + +def mul4(a, bs): + if a == 0: + return 0 + r = 0 + for b in bs: + r <<= 8 + if b != 0: + r = r | mul(a, b) + return r + +T1 = [] +T2 = [] +T3 = [] +T4 = [] +T5 = [] +T6 = [] +T7 = [] +T8 = [] +U1 = [] +U2 = [] +U3 = [] +U4 = [] + +for t in xrange(256): + s = S[t] + T1.append(mul4(s, G[0])) + T2.append(mul4(s, G[1])) + T3.append(mul4(s, G[2])) + T4.append(mul4(s, G[3])) + + s = Si[t] + T5.append(mul4(s, iG[0])) + T6.append(mul4(s, iG[1])) + T7.append(mul4(s, iG[2])) + T8.append(mul4(s, iG[3])) + + U1.append(mul4(t, iG[0])) + U2.append(mul4(t, iG[1])) + U3.append(mul4(t, iG[2])) + U4.append(mul4(t, iG[3])) + +# round constants +rcon = [1] +r = 1 +for t in xrange(1, 30): + r = mul(2, r) + rcon.append(r) + +del A +del AA +del pivot +del B +del G +del box +del log +del alog +del i +del j +del r +del s +del t +del mul +del mul4 +del cox +del iG + +class rijndael: + def __init__(self, key, block_size = 16): + if block_size != 16 and block_size != 24 and block_size != 32: + raise ValueError('Invalid block size: ' + str(block_size)) + if len(key) != 16 and len(key) != 24 and len(key) != 32: + raise ValueError('Invalid key size: ' + str(len(key))) + self.block_size = block_size + + ROUNDS = num_rounds[len(key)][block_size] + BC = block_size / 4 + # encryption round keys + Ke = [[0] * BC for i in xrange(ROUNDS + 1)] + # decryption round keys + Kd = [[0] * BC for i in xrange(ROUNDS + 1)] + ROUND_KEY_COUNT = (ROUNDS + 1) * BC + KC = len(key) / 4 + + # copy user material bytes into temporary ints + tk = [] + for i in xrange(0, KC): + tk.append((ord(key[i * 4]) << 24) | (ord(key[i * 4 + 1]) << 16) | + (ord(key[i * 4 + 2]) << 8) | ord(key[i * 4 + 3])) + + # copy values into round key arrays + t = 0 + j = 0 + while j < KC and t < ROUND_KEY_COUNT: + Ke[t / BC][t % BC] = tk[j] + Kd[ROUNDS - (t / BC)][t % BC] = tk[j] + j += 1 + t += 1 + tt = 0 + rconpointer = 0 + while t < ROUND_KEY_COUNT: + # extrapolate using phi (the round key evolution function) + tt = tk[KC - 1] + tk[0] ^= (S[(tt >> 16) & 0xFF] & 0xFF) << 24 ^ \ + (S[(tt >> 8) & 0xFF] & 0xFF) << 16 ^ \ + (S[ tt & 0xFF] & 0xFF) << 8 ^ \ + (S[(tt >> 24) & 0xFF] & 0xFF) ^ \ + (rcon[rconpointer] & 0xFF) << 24 + rconpointer += 1 + if KC != 8: + for i in xrange(1, KC): + tk[i] ^= tk[i-1] + else: + for i in xrange(1, KC / 2): + tk[i] ^= tk[i-1] + tt = tk[KC / 2 - 1] + tk[KC / 2] ^= (S[ tt & 0xFF] & 0xFF) ^ \ + (S[(tt >> 8) & 0xFF] & 0xFF) << 8 ^ \ + (S[(tt >> 16) & 0xFF] & 0xFF) << 16 ^ \ + (S[(tt >> 24) & 0xFF] & 0xFF) << 24 + for i in xrange(KC / 2 + 1, KC): + tk[i] ^= tk[i-1] + # copy values into round key arrays + j = 0 + while j < KC and t < ROUND_KEY_COUNT: + Ke[t / BC][t % BC] = tk[j] + Kd[ROUNDS - (t / BC)][t % BC] = tk[j] + j += 1 + t += 1 + # inverse MixColumn where needed + for r in xrange(1, ROUNDS): + for j in xrange(BC): + tt = Kd[r][j] + Kd[r][j] = U1[(tt >> 24) & 0xFF] ^ \ + U2[(tt >> 16) & 0xFF] ^ \ + U3[(tt >> 8) & 0xFF] ^ \ + U4[ tt & 0xFF] + self.Ke = Ke + self.Kd = Kd + + def encrypt(self, plaintext): + if len(plaintext) != self.block_size: + raise ValueError('wrong block length, expected ' + str(self.block_size) + ' got ' + str(len(plaintext))) + Ke = self.Ke + + BC = self.block_size / 4 + ROUNDS = len(Ke) - 1 + if BC == 4: + SC = 0 + elif BC == 6: + SC = 1 + else: + SC = 2 + s1 = shifts[SC][1][0] + s2 = shifts[SC][2][0] + s3 = shifts[SC][3][0] + a = [0] * BC + # temporary work array + t = [] + # plaintext to ints + key + for i in xrange(BC): + t.append((ord(plaintext[i * 4 ]) << 24 | + ord(plaintext[i * 4 + 1]) << 16 | + ord(plaintext[i * 4 + 2]) << 8 | + ord(plaintext[i * 4 + 3]) ) ^ Ke[0][i]) + # apply round transforms + for r in xrange(1, ROUNDS): + for i in xrange(BC): + a[i] = (T1[(t[ i ] >> 24) & 0xFF] ^ + T2[(t[(i + s1) % BC] >> 16) & 0xFF] ^ + T3[(t[(i + s2) % BC] >> 8) & 0xFF] ^ + T4[ t[(i + s3) % BC] & 0xFF] ) ^ Ke[r][i] + t = copy.copy(a) + # last round is special + result = [] + for i in xrange(BC): + tt = Ke[ROUNDS][i] + result.append((S[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF) + result.append((S[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF) + result.append((S[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF) + result.append((S[ t[(i + s3) % BC] & 0xFF] ^ tt ) & 0xFF) + return string.join(map(chr, result), '') + + def decrypt(self, ciphertext): + if len(ciphertext) != self.block_size: + raise ValueError('wrong block length, expected ' + str(self.block_size) + ' got ' + str(len(plaintext))) + Kd = self.Kd + + BC = self.block_size / 4 + ROUNDS = len(Kd) - 1 + if BC == 4: + SC = 0 + elif BC == 6: + SC = 1 + else: + SC = 2 + s1 = shifts[SC][1][1] + s2 = shifts[SC][2][1] + s3 = shifts[SC][3][1] + a = [0] * BC + # temporary work array + t = [0] * BC + # ciphertext to ints + key + for i in xrange(BC): + t[i] = (ord(ciphertext[i * 4 ]) << 24 | + ord(ciphertext[i * 4 + 1]) << 16 | + ord(ciphertext[i * 4 + 2]) << 8 | + ord(ciphertext[i * 4 + 3]) ) ^ Kd[0][i] + # apply round transforms + for r in xrange(1, ROUNDS): + for i in xrange(BC): + a[i] = (T5[(t[ i ] >> 24) & 0xFF] ^ + T6[(t[(i + s1) % BC] >> 16) & 0xFF] ^ + T7[(t[(i + s2) % BC] >> 8) & 0xFF] ^ + T8[ t[(i + s3) % BC] & 0xFF] ) ^ Kd[r][i] + t = copy.copy(a) + # last round is special + result = [] + for i in xrange(BC): + tt = Kd[ROUNDS][i] + result.append((Si[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF) + result.append((Si[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF) + result.append((Si[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF) + result.append((Si[ t[(i + s3) % BC] & 0xFF] ^ tt ) & 0xFF) + return string.join(map(chr, result), '') + +def encrypt(key, block): + return rijndael(key, len(block)).encrypt(block) + +def decrypt(key, block): + return rijndael(key, len(block)).decrypt(block) + +def test(): + def t(kl, bl): + b = 'b' * bl + r = rijndael('a' * kl, bl) + assert r.decrypt(r.encrypt(b)) == b + t(16, 16) + t(16, 24) + t(16, 32) + t(24, 16) + t(24, 24) + t(24, 32) + t(32, 16) + t(32, 24) + t(32, 32) diff --git a/scripts/runcommand.py b/scripts/runcommand.py new file mode 100644 index 0000000..72f1e3a --- /dev/null +++ b/scripts/runcommand.py @@ -0,0 +1,105 @@ +#!/bin/python + +import shlex +from subprocess import Popen, PIPE +import sys +import rijndael +import base64 +import string +import random +import json +import time +import hashlib +import pycurl +from urllib import urlencode + +try: + from cStringIO import StringIO +except ImportError: + try: + from StringIO import StringIO + except ImportError: + from io import StringIO + +SHARED_KEY = "" +HASH = "" + +# source http://stackoverflow.com/a/8232171/195722 +KEY_SIZE = 16 +BLOCK_SIZE = 32 +# JamPAo52/smpiObKa8p/MY5WAeDww0cOg9KiG6gMAYQ= + +def curl_post(url, postvals, header = []): + buffer = StringIO() + cobj = pycurl.Curl() + cobj.setopt(pycurl.URL, url) + cobj.setopt(pycurl.POST, 1) + cobj.setopt(pycurl.WRITEDATA, buffer) + postdata = urlencode(postvals) + cobj.setopt(pycurl.POSTFIELDS, postdata) + cobj.setopt(pycurl.HTTPHEADER, header) + cobj.perform() + cobj.close() + return buffer + +def get_exitcode_stdout_stderr(cmd): + """ + Execute the external command and get its exitcode, stdout and stderr. + """ + args = shlex.split(cmd) + + proc = Popen(args, stdout=PIPE, stderr=PIPE, shell=True) + out, err = proc.communicate() + exitcode = proc.returncode + # + return exitcode, out, err + +def encrypt(key, plaintext): + padded_key = key.ljust(KEY_SIZE, '\0') + padded_text = plaintext + (BLOCK_SIZE - len(plaintext) % BLOCK_SIZE) * '\0' + + # could also be one of + #if len(plaintext) % BLOCK_SIZE != 0: + # padded_text = plaintext.ljust((len(plaintext) / BLOCK_SIZE) + 1 * BLOCKSIZE), '\0') + # -OR- + #padded_text = plaintext.ljust((len(plaintext) + (BLOCK_SIZE - len(plaintext) % BLOCK_SIZE)), '\0') + + r = rijndael.rijndael(padded_key, BLOCK_SIZE) + + ciphertext = '' + for start in range(0, len(padded_text), BLOCK_SIZE): + ciphertext += r.encrypt(padded_text[start:start+BLOCK_SIZE]) + + encoded = base64.b64encode(ciphertext) + + return encoded + + +def decrypt(key, encoded): + padded_key = key.ljust(KEY_SIZE, '\0') + + ciphertext = base64.b64decode(encoded) + + r = rijndael.rijndael(padded_key, BLOCK_SIZE) + + padded_text = '' + for start in range(0, len(ciphertext), BLOCK_SIZE): + padded_text += r.decrypt(ciphertext[start:start+BLOCK_SIZE]) + + plaintext = padded_text.split('\x00', 1)[0] + + return plaintext + + +start_time = time.time() +exitcode, out, err = get_exitcode_stdout_stderr(sys.argv[1]) +total = time.time() - start_time +nonce = ''.join(random.SystemRandom().choice(string.hexdigits + string.digits) for _ in range(10)) +message = {} +message["nonce"] = nonce +message["message"] = json.dumps({"output":out, "time_taken": total, "result": exitcode}) +message["signature"] = hashlib.sha256(message["message"] + nonce + HASH).hexdigest() +print encrypt(SHARED_KEY, "test") +message["message"] = encrypt(SHARED_KEY, message["message"]) +print json.dumps(message) +print curl_post("http://192.168.128.36:8080/service/upload/5/", {"data": json.dumps(message)}).getvalue() \ No newline at end of file diff --git a/web/application/controllers/base.php b/web/application/controllers/base.php index 96c7a9b..eceae89 100644 --- a/web/application/controllers/base.php +++ b/web/application/controllers/base.php @@ -9,6 +9,7 @@ abstract class base extends \system\engine\HF_Controller { protected $session = null; protected $sessionData = null; protected $loginRequired = true; + protected $sessionRequired = true; protected function isLoggedIn() { if (!$this->sessionData && !isset($this->sessionData->userId)) { header("Location: /login"); @@ -108,8 +109,10 @@ abstract class base extends \system\engine\HF_Controller { \vendor\DB\DB::$c = $this->pdo; } - $this->setupSession(); - $this->setupUser(); + if ($this->sessionRequired) { + $this->setupSession(); + $this->setupUser(); + } if ($this->loginRequired && !$this->user) { $this->login(); } diff --git a/web/application/controllers/history.php b/web/application/controllers/history.php index ac115ce..2ebfff5 100644 --- a/web/application/controllers/history.php +++ b/web/application/controllers/history.php @@ -18,6 +18,22 @@ class history extends base return true; } + public function runscript($jobId) { + $jobObject = \application\models\Jobs::getByField("id", $jobId); + if ($this->checkAccess($jobObject[0])) { + header("Content-Type: text/plain"); + echo $jobObject[0]->runScript; + } + } + + public function failscript($jobId) { + $jobObject = \application\models\Jobs::getByField("id", $jobId); + if ($this->checkAccess($jobObject[0])) { + header("Content-Type: text/plain"); + echo $jobObject[0]->failScript; + } + } + public function view($id) { $idArr = explode("-", $id); try { diff --git a/web/application/controllers/job.php b/web/application/controllers/job.php index 2387302..c460d11 100644 --- a/web/application/controllers/job.php +++ b/web/application/controllers/job.php @@ -1,5 +1,7 @@ user->id, "%" . $_GET["q"] . "%"]); + echo $this->loadRender("search.html", ["search" => $_GET["q"], "histories" => $histories]); + } + } + } \ No newline at end of file diff --git a/web/application/controllers/service.php b/web/application/controllers/service.php index a25fea7..97c3097 100644 --- a/web/application/controllers/service.php +++ b/web/application/controllers/service.php @@ -3,28 +3,37 @@ use \vendor\DB\DB; class service extends base { + + protected $loginRequired = false; + protected $sessionRequired = false; + /** * This service will expect a JSON POST data of: * ["data"] => {"nonce": "randomString", "message": "cipherText", "signature": "abcdef"} * Signature will be a sha256 of the message pre-encrypt with nonce appended to the end * ie - * {JSON} + "|" nonce + "|" + sharedhash + * {JSON} + nonce + sharedhash * Note: sharedhash should NOT be the sharedkey that is used to encrypt the message * * * Unencrypted cipherText will look like - * {"output": "stdout of run", "run_date": "2015-01-01", "time_taken": 10, "result": 0} + * {"output": "stdout of run", "time_taken": 10, "result": 0} * Just like in most modern programs - a result of anything but 0 indicates an error * * @param $jobId */ public function upload($jobId) { - if ($jobId && is_int($jobId)) { - /** @var \application\models\Jobs $job */ - $job = \application\models\Jobs::getByField("id", $jobId)[0]; - //decrypt message - $data = json_decode($_POST["data"]); - $rawMessage = aes_decrypt($job->sharedkey,$data["message"]); + if ($jobId && is_numeric($jobId)) { + try { + /** @var \application\models\Jobs $job */ + $job = \application\models\Jobs::getByField("id", $jobId)[0]; + //decrypt message + $data = json_decode($_POST["data"], true); + $rawMessage = aes_decrypt($job->sharedkey, $data["message"]); + } catch (\Exception $e) { + echo $e; + exit(1); + } // if decryption was successful - // check signature @@ -41,55 +50,60 @@ class service extends base { if (in_array($_SERVER["REMOTE_ADDR"], $this->config["ACCEPTED_IPS"])) { // not very secure - but worst case they fire off the run early if (!file_exists("/tmp/kritbot")) { touch("/tmp/kritbot"); - /** @var \application\models\Jobs[] $jobs */ - $jobs = DB::fetchObject("SELECT * FROM jobs", "\\application\\models\\Jobs"); - foreach($jobs as $job) { - if ($job->runType == 1) { - $cron = Cron\CronExpression::factory($job->cron); - if ($cron->isDue() || $job->force_run == 1) { - $output = []; - $returnVar = 0; + try { + /** @var \application\models\Jobs[] $jobs */ + $jobs = DB::fetchObject("SELECT * FROM jobs", "\\application\\models\\Jobs"); + foreach ($jobs as $job) { + if ($job->runType == 1) { + $cron = Cron\CronExpression::factory($job->cron); + if ($cron->isDue() || $job->force_run == 1) { + $output = []; + $returnVar = 0; - $start = microtime(true); - // grumble grumble something something windows - if (stripos(php_uname("s"), "Win") !== false) { - file_put_contents("/tmp/kritscript.bat", $job->runScript); - exec("c:\\windows\\system32\\cmd.exe /c c:/tmp/kritscript.bat", $output, $returnVar); - } else { - file_put_contents("/tmp/kritscript", $job->runScript); - exec("/tmp/kritscript", $output, $returnVar); - chmod("/tmp/kritscript", 0777); - } - $end = microtime(true); - $delta = $end - $start; - $scriptOutput = implode("\n", $output); - if ($returnVar != 0) { + $start = microtime(true); + // grumble grumble something something windows if (stripos(php_uname("s"), "Win") !== false) { - file_put_contents("/tmp/kritscript.bat", $job->failScript); - exec("c:\\windows\\system32\\cmd.exe /c c:/tmp/kirtscript.bat"); + file_put_contents("/tmp/kritscript.bat", $job->runScript); + exec("c:\\windows\\system32\\cmd.exe /c c:/tmp/kritscript.bat", $output, $returnVar); } else { - file_put_contents("/tmp/kritscript", $job->failScript); + file_put_contents("/tmp/kritscript", $job->runScript); exec("/tmp/kritscript", $output, $returnVar); chmod("/tmp/kritscript", 0777); } + $end = microtime(true); + $delta = $end - $start; + $scriptOutput = implode("\n", $output); + if ($returnVar != 0) { + if (stripos(php_uname("s"), "Win") !== false) { + file_put_contents("/tmp/kritscript.bat", $job->failScript); + exec("c:\\windows\\system32\\cmd.exe /c c:/tmp/kirtscript.bat"); + } else { + file_put_contents("/tmp/kritscript", $job->failScript); + exec("/tmp/kritscript", $output, $returnVar); + chmod("/tmp/kritscript", 0777); + } + } + $historyObj = new \application\models\Histories(); + $historyObj->output = $scriptOutput; + $historyObj->result = $returnVar; + $historyObj->time_taken = $delta; + $historyObj->jobs_id = $job->id; + $now = date("Y-m-d H:i:s"); + $historyObj->run_date = $now; + $historyObj->save(); + $job->force_run = 0; + $job->last_run = $now; + $job->last_result = $returnVar; + $job->save(); } - $historyObj = new \application\models\Histories(); - $historyObj->output = $scriptOutput; - $historyObj->result = $returnVar; - $historyObj->time_taken = $delta; - $historyObj->jobs_id = $job->id; - $now = date("Y-m-d H:i:s"); - $historyObj->run_date = $now; - $historyObj->save(); - $job->force_run = 0; - $job->last_run = $now; - $job->last_result = $returnVar; - $job->save(); } } + unlink("/tmp/kritbot"); + } catch (\Exception $e) { + unlink("/tmp/kritbot"); } - unlink("/tmp/kritbot"); } + } } } \ No newline at end of file diff --git a/web/application/views/add.html b/web/application/views/add.html index 05d91af..b98263d 100644 --- a/web/application/views/add.html +++ b/web/application/views/add.html @@ -85,7 +85,8 @@
@@ -110,6 +111,13 @@ $("#selectedType").text($(this).text()); //$('#datebox').val($(this).text()); }); + $("#private").on("change", function () { + if ($(this).is(":checked")) { + $("#view_private").val(1); + } else { + $("#view_private").val(0); + } + }); }); diff --git a/web/application/views/base.html b/web/application/views/base.html index 06bf58b..bfcbf40 100644 --- a/web/application/views/base.html +++ b/web/application/views/base.html @@ -18,7 +18,9 @@ {{title}} diff --git a/web/application/views/history.html b/web/application/views/history.html index e7c0e0b..ad135c4 100644 --- a/web/application/views/history.html +++ b/web/application/views/history.html @@ -10,6 +10,24 @@
{{job.comments|nl2br|safe}}
+ +
+ + + + + + + + + + + + + + +
Run ScriptFail Script
View ScriptView Script
+
diff --git a/web/application/views/menu.html b/web/application/views/menu.html index b252c45..4c14d9f 100644 --- a/web/application/views/menu.html +++ b/web/application/views/menu.html @@ -20,9 +20,9 @@ - +
- +
diff --git a/web/application/views/search.html b/web/application/views/search.html new file mode 100644 index 0000000..ccf56a0 --- /dev/null +++ b/web/application/views/search.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block content %} + + + +
+ + + + + + + + + + + {% if !histories %} + + + + {% endif %} + {% for history in histories %} + + + + + + + + {% endfor %} + +
Job NameOutputRun DateTime TakenResult
No results
{{history.jobName}}View{{history.run_date}}{{history.time_taken}}{{history.result}}
+ +{% endblock %} \ No newline at end of file diff --git a/web/system/vendor/Cron/AbstractField.php b/web/system/vendor/Cron/AbstractField.php new file mode 100644 index 0000000..c0616a2 --- /dev/null +++ b/web/system/vendor/Cron/AbstractField.php @@ -0,0 +1,104 @@ +isIncrementsOfRanges($value)) { + return $this->isInIncrementsOfRanges($dateValue, $value); + } elseif ($this->isRange($value)) { + return $this->isInRange($dateValue, $value); + } + + return $value == '*' || $dateValue == $value; + } + + /** + * Check if a value is a range + * + * @param string $value Value to test + * + * @return bool + */ + public function isRange($value) + { + return strpos($value, '-') !== false; + } + + /** + * Check if a value is an increments of ranges + * + * @param string $value Value to test + * + * @return bool + */ + public function isIncrementsOfRanges($value) + { + return strpos($value, '/') !== false; + } + + /** + * Test if a value is within a range + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInRange($dateValue, $value) + { + $parts = array_map('trim', explode('-', $value, 2)); + + return $dateValue >= $parts[0] && $dateValue <= $parts[1]; + } + + /** + * Test if a value is within an increments of ranges (offset[-to]/step size) + * + * @param string $dateValue Set date value + * @param string $value Value to test + * + * @return bool + */ + public function isInIncrementsOfRanges($dateValue, $value) + { + $parts = array_map('trim', explode('/', $value, 2)); + $stepSize = isset($parts[1]) ? $parts[1] : 0; + if (($parts[0] == '*' || $parts[0] === '0') && 0 !== $stepSize) { + return (int) $dateValue % $stepSize == 0; + } + + $range = explode('-', $parts[0], 2); + $offset = $range[0]; + $to = isset($range[1]) ? $range[1] : $dateValue; + // Ensure that the date value is within the range + if ($dateValue < $offset || $dateValue > $to) { + return false; + } + + if ($dateValue > $offset && 0 === $stepSize) { + return false; + } + + for ($i = $offset; $i <= $to; $i+= $stepSize) { + if ($i == $dateValue) { + return true; + } + } + + return false; + } +} diff --git a/web/system/vendor/Cron/CronExpression.php b/web/system/vendor/Cron/CronExpression.php new file mode 100644 index 0000000..b59662a --- /dev/null +++ b/web/system/vendor/Cron/CronExpression.php @@ -0,0 +1,355 @@ + '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@hourly' => '0 * * * *' + ); + + if (isset($mappings[$expression])) { + $expression = $mappings[$expression]; + } + + return new static($expression, $fieldFactory ?: new FieldFactory()); + } + + /** + * Validate a CronExpression. + * + * @param string $expression The CRON expression to validate. + * + * @return bool True if a valid CRON expression was passed. False if not. + * @see Cron\CronExpression::factory + */ + public static function isValidExpression($expression) + { + try { + self::factory($expression); + } catch (\InvalidArgumentException $e) { + return false; + } + + return true; + } + + /** + * Parse a CRON expression + * + * @param string $expression CRON expression (e.g. '8 * * * *') + * @param FieldFactory $fieldFactory Factory to create cron fields + */ + public function __construct($expression, FieldFactory $fieldFactory) + { + $this->fieldFactory = $fieldFactory; + $this->setExpression($expression); + } + + /** + * Set or change the CRON expression + * + * @param string $value CRON expression (e.g. 8 * * * *) + * + * @return CronExpression + * @throws \InvalidArgumentException if not a valid CRON expression + */ + public function setExpression($value) + { + $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); + if (count($this->cronParts) < 5) { + throw new \InvalidArgumentException( + $value . ' is not a valid CRON expression' + ); + } + + foreach ($this->cronParts as $position => $part) { + $this->setPart($position, $part); + } + + return $this; + } + + /** + * Set part of the CRON expression + * + * @param int $position The position of the CRON expression to set + * @param string $value The value to set + * + * @return CronExpression + * @throws \InvalidArgumentException if the value is not valid for the part + */ + public function setPart($position, $value) + { + if (!$this->fieldFactory->getField($position)->validate($value)) { + throw new \InvalidArgumentException( + 'Invalid CRON field value ' . $value . ' at position ' . $position + ); + } + + $this->cronParts[$position] = $value; + + return $this; + } + + /** + * Get a next run date relative to the current date or a specific date + * + * @param string|\DateTime $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning a + * matching next run date. 0, the default, will return the current + * date and time if the next run date falls on the current date and + * time. Setting this value to 1 will skip the first match and go to + * the second match. Setting this value to 2 will skip the first 2 + * matches and so on. + * @param bool $allowCurrentDate Set to TRUE to return the current date if + * it matches the cron expression. + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + */ + public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate); + } + + /** + * Get a previous run date relative to the current date or a specific date + * + * @param string|\DateTime $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + * @see Cron\CronExpression::getNextRunDate + */ + public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) + { + return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate); + } + + /** + * Get multiple run dates starting at the current date or a specific date + * + * @param int $total Set the total number of dates to calculate + * @param string|\DateTime $currentTime Relative calculation date + * @param bool $invert Set to TRUE to retrieve previous dates + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * + * @return array Returns an array of run dates + */ + public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false) + { + $matches = array(); + for ($i = 0; $i < max(0, $total); $i++) { + try { + $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate); + } catch (\RuntimeException $e) { + break; + } + } + + return $matches; + } + + /** + * Get all or part of the CRON expression + * + * @param string $part Specify the part to retrieve or NULL to get the full + * cron schedule string. + * + * @return string|null Returns the CRON expression, a part of the + * CRON expression, or NULL if the part was specified but not found + */ + public function getExpression($part = null) + { + if (null === $part) { + return implode(' ', $this->cronParts); + } elseif (array_key_exists($part, $this->cronParts)) { + return $this->cronParts[$part]; + } + + return null; + } + + /** + * Helper method to output the full expression. + * + * @return string Full CRON expression + */ + public function __toString() + { + return $this->getExpression(); + } + + /** + * Determine if the cron is due to run based on the current date or a + * specific date. This method assumes that the current number of + * seconds are irrelevant, and should be called once per minute. + * + * @param string|\DateTime $currentTime Relative calculation date + * + * @return bool Returns TRUE if the cron is due to run or FALSE if not + */ + public function isDue($currentTime = 'now') + { + if ('now' === $currentTime) { + $currentDate = date('Y-m-d H:i'); + $currentTime = strtotime($currentDate); + } elseif ($currentTime instanceof \DateTime) { + $currentDate = clone $currentTime; + // Ensure time in 'current' timezone is used + $currentDate->setTimezone(new \DateTimeZone(date_default_timezone_get())); + $currentDate = $currentDate->format('Y-m-d H:i'); + $currentTime = strtotime($currentDate); + } else { + $currentTime = new \DateTime($currentTime); + $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0); + $currentDate = $currentTime->format('Y-m-d H:i'); + $currentTime = $currentTime->getTimeStamp(); + } + + try { + return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime; + } catch (\Exception $e) { + return false; + } + } + + /** + * Get the next or previous run date of the expression relative to a date + * + * @param string|\DateTime $currentTime Relative calculation date + * @param int $nth Number of matches to skip before returning + * @param bool $invert Set to TRUE to go backwards in time + * @param bool $allowCurrentDate Set to TRUE to return the + * current date if it matches the cron expression + * + * @return \DateTime + * @throws \RuntimeException on too many iterations + */ + protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false) + { + if ($currentTime instanceof \DateTime) { + $currentDate = clone $currentTime; + } else { + $currentDate = new \DateTime($currentTime ?: 'now'); + $currentDate->setTimezone(new \DateTimeZone(date_default_timezone_get())); + } + + $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); + $nextRun = clone $currentDate; + $nth = (int) $nth; + + // We don't have to satisfy * or null fields + $parts = array(); + $fields = array(); + foreach (self::$order as $position) { + $part = $this->getExpression($position); + if (null === $part || '*' === $part) { + continue; + } + $parts[$position] = $part; + $fields[$position] = $this->fieldFactory->getField($position); + } + + // Set a hard limit to bail on an impossible date + for ($i = 0; $i < 1000; $i++) { + + foreach ($parts as $position => $part) { + $satisfied = false; + // Get the field object used to validate this part + $field = $fields[$position]; + // Check if this is singular or a list + if (strpos($part, ',') === false) { + $satisfied = $field->isSatisfiedBy($nextRun, $part); + } else { + foreach (array_map('trim', explode(',', $part)) as $listPart) { + if ($field->isSatisfiedBy($nextRun, $listPart)) { + $satisfied = true; + break; + } + } + } + + // If the field is not satisfied, then start over + if (!$satisfied) { + $field->increment($nextRun, $invert); + continue 2; + } + } + + // Skip this match if needed + if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { + $this->fieldFactory->getField(0)->increment($nextRun, $invert); + continue; + } + + return $nextRun; + } + + // @codeCoverageIgnoreStart + throw new \RuntimeException('Impossible CRON expression'); + // @codeCoverageIgnoreEnd + } +} diff --git a/web/system/vendor/Cron/DayOfMonthField.php b/web/system/vendor/Cron/DayOfMonthField.php new file mode 100644 index 0000000..86129c9 --- /dev/null +++ b/web/system/vendor/Cron/DayOfMonthField.php @@ -0,0 +1,103 @@ + + */ +class DayOfMonthField extends AbstractField +{ + /** + * Get the nearest day of the week for a given day in a month + * + * @param int $currentYear Current year + * @param int $currentMonth Current month + * @param int $targetDay Target day of the month + * + * @return \DateTime Returns the nearest date + */ + private static function getNearestWeekday($currentYear, $currentMonth, $targetDay) + { + $tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT); + $target = \DateTime::createFromFormat('Y-m-d', "$currentYear-$currentMonth-$tday"); + $currentWeekday = (int) $target->format('N'); + + if ($currentWeekday < 6) { + return $target; + } + + $lastDayOfMonth = $target->format('t'); + + foreach (array(-1, 1, -2, 2) as $i) { + $adjusted = $targetDay + $i; + if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) { + $target->setDate($currentYear, $currentMonth, $adjusted); + if ($target->format('N') < 6 && $target->format('m') == $currentMonth) { + return $target; + } + } + } + } + + public function isSatisfiedBy(\DateTime $date, $value) + { + // ? states that the field value is to be skipped + if ($value == '?') { + return true; + } + + $fieldValue = $date->format('d'); + + // Check to see if this is the last day of the month + if ($value == 'L') { + return $fieldValue == $date->format('t'); + } + + // Check to see if this is the nearest weekday to a particular value + if (strpos($value, 'W')) { + // Parse the target day + $targetDay = substr($value, 0, strpos($value, 'W')); + // Find out if the current day is the nearest day of the week + return $date->format('j') == self::getNearestWeekday( + $date->format('Y'), + $date->format('m'), + $targetDay + )->format('j'); + } + + return $this->isSatisfied($date->format('d'), $value); + } + + public function increment(\DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('previous day'); + $date->setTime(23, 59); + } else { + $date->modify('next day'); + $date->setTime(0, 0); + } + + return $this; + } + + public function validate($value) + { + return (bool) preg_match('/^[\*,\/\-\?LW0-9A-Za-z]+$/', $value); + } +} diff --git a/web/system/vendor/Cron/DayOfWeekField.php b/web/system/vendor/Cron/DayOfWeekField.php new file mode 100644 index 0000000..8e33b19 --- /dev/null +++ b/web/system/vendor/Cron/DayOfWeekField.php @@ -0,0 +1,134 @@ +convertLiterals($value); + + $currentYear = $date->format('Y'); + $currentMonth = $date->format('m'); + $lastDayOfMonth = $date->format('t'); + + // Find out if this is the last specific weekday of the month + if (strpos($value, 'L')) { + $weekday = str_replace('7', '0', substr($value, 0, strpos($value, 'L'))); + $tdate = clone $date; + $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth); + while ($tdate->format('w') != $weekday) { + $tdate->setDate($currentYear, $currentMonth, --$lastDayOfMonth); + } + + return $date->format('j') == $lastDayOfMonth; + } + + // Handle # hash tokens + if (strpos($value, '#')) { + list($weekday, $nth) = explode('#', $value); + + // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601 + if ($weekday === '0') { + $weekday = 7; + } + + // Validate the hash fields + if ($weekday < 0 || $weekday > 7) { + throw new \InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given"); + } + if ($nth > 5) { + throw new \InvalidArgumentException('There are never more than 5 of a given weekday in a month'); + } + // The current weekday must match the targeted weekday to proceed + if ($date->format('N') != $weekday) { + return false; + } + + $tdate = clone $date; + $tdate->setDate($currentYear, $currentMonth, 1); + $dayCount = 0; + $currentDay = 1; + while ($currentDay < $lastDayOfMonth + 1) { + if ($tdate->format('N') == $weekday) { + if (++$dayCount >= $nth) { + break; + } + } + $tdate->setDate($currentYear, $currentMonth, ++$currentDay); + } + + return $date->format('j') == $currentDay; + } + + // Handle day of the week values + if (strpos($value, '-')) { + $parts = explode('-', $value); + if ($parts[0] == '7') { + $parts[0] = '0'; + } elseif ($parts[1] == '0') { + $parts[1] = '7'; + } + $value = implode('-', $parts); + } + + // Test to see which Sunday to use -- 0 == 7 == Sunday + $format = in_array(7, str_split($value)) ? 'N' : 'w'; + $fieldValue = $date->format($format); + + return $this->isSatisfied($fieldValue, $value); + } + + public function increment(\DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 day'); + $date->setTime(23, 59, 0); + } else { + $date->modify('+1 day'); + $date->setTime(0, 0, 0); + } + + return $this; + } + + public function validate($value) + { + $value = $this->convertLiterals($value); + + foreach (explode(',', $value) as $expr) { + if (!preg_match('/^(\*|[0-7](L?|#[1-5]))([\/\,\-][0-7]+)*$/', $expr)) { + return false; + } + } + + return true; + } + + private function convertLiterals($string) + { + return str_ireplace( + array('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'), + range(0, 6), + $string + ); + } +} diff --git a/web/system/vendor/Cron/FieldFactory.php b/web/system/vendor/Cron/FieldFactory.php new file mode 100644 index 0000000..5aa86f6 --- /dev/null +++ b/web/system/vendor/Cron/FieldFactory.php @@ -0,0 +1,55 @@ +fields[$position])) { + switch ($position) { + case 0: + $this->fields[$position] = new MinutesField(); + break; + case 1: + $this->fields[$position] = new HoursField(); + break; + case 2: + $this->fields[$position] = new DayOfMonthField(); + break; + case 3: + $this->fields[$position] = new MonthField(); + break; + case 4: + $this->fields[$position] = new DayOfWeekField(); + break; + case 5: + $this->fields[$position] = new YearField(); + break; + default: + throw new \InvalidArgumentException( + $position . ' is not a valid position' + ); + } + } + + return $this->fields[$position]; + } +} diff --git a/web/system/vendor/Cron/FieldInterface.php b/web/system/vendor/Cron/FieldInterface.php new file mode 100644 index 0000000..3823fbf --- /dev/null +++ b/web/system/vendor/Cron/FieldInterface.php @@ -0,0 +1,39 @@ +isSatisfied($date->format('H'), $value); + } + + public function increment(\DateTime $date, $invert = false) + { + // Change timezone to UTC temporarily. This will + // allow us to go back or forwards and hour even + // if DST will be changed between the hours. + $timezone = $date->getTimezone(); + $localMinutes = $date->format('i'); + $date->setTimezone(new \DateTimeZone('UTC')); + // handle timezones with non-hour-offsets + $utcMinutes = $date->format('i'); + $minDiff = $localMinutes - $utcMinutes; + if ($invert) { + $date->modify('-1 hour'); + $date->setTime($date->format('H'), 59 - $minDiff); + } else { + $date->modify('+1 hour'); + $date->setTime($date->format('H'), 0 - $minDiff); + } + $date->setTimezone($timezone); + + return $this; + } + + public function validate($value) + { + return (bool) preg_match('/^[\*,\/\-0-9]+$/', $value); + } +} diff --git a/web/system/vendor/Cron/LICENSE b/web/system/vendor/Cron/LICENSE new file mode 100644 index 0000000..c6d88ac --- /dev/null +++ b/web/system/vendor/Cron/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Michael Dowling and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/web/system/vendor/Cron/MinutesField.php b/web/system/vendor/Cron/MinutesField.php new file mode 100644 index 0000000..cfa2b09 --- /dev/null +++ b/web/system/vendor/Cron/MinutesField.php @@ -0,0 +1,30 @@ +isSatisfied($date->format('i'), $value); + } + + public function increment(\DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 minute'); + } else { + $date->modify('+1 minute'); + } + + return $this; + } + + public function validate($value) + { + return (bool) preg_match('/^[\*,\/\-0-9]+$/', $value); + } +} diff --git a/web/system/vendor/Cron/MonthField.php b/web/system/vendor/Cron/MonthField.php new file mode 100644 index 0000000..0205c17 --- /dev/null +++ b/web/system/vendor/Cron/MonthField.php @@ -0,0 +1,44 @@ +isSatisfied($date->format('m'), $value); + } + + public function increment(DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('last day of previous month'); + $date->setTime(23, 59); + } else { + $date->modify('first day of next month'); + $date->setTime(0, 0); + } + + return $this; + } + + public function validate($value) + { + return (bool) preg_match('/^[\*,\/\-0-9A-Z]+$/', $value); + } +} diff --git a/web/system/vendor/Cron/YearField.php b/web/system/vendor/Cron/YearField.php new file mode 100644 index 0000000..b526dde --- /dev/null +++ b/web/system/vendor/Cron/YearField.php @@ -0,0 +1,34 @@ +isSatisfied($date->format('Y'), $value); + } + + public function increment(\DateTime $date, $invert = false) + { + if ($invert) { + $date->modify('-1 year'); + $date->setDate($date->format('Y'), 12, 31); + $date->setTime(23, 59, 0); + } else { + $date->modify('+1 year'); + $date->setDate($date->format('Y'), 1, 1); + $date->setTime(0, 0, 0); + } + + return $this; + } + + public function validate($value) + { + return (bool) preg_match('/^[\*,\/\-0-9]+$/', $value); + } +} diff --git a/web/system/vendor/cron.php b/web/system/vendor/cron.php new file mode 100644 index 0000000..8cddfb8 --- /dev/null +++ b/web/system/vendor/cron.php @@ -0,0 +1,18 @@ + \ No newline at end of file