Compare commits

...

49 Commits
v1.0 ... main

Author SHA1 Message Date
Jeroen De Meerleer c7aaf102f4
BUGFIX: don't call functions twice 2 weeks ago
Jeroen De Meerleer c354f093c1
BUGFIX: updated dependencies 2 weeks ago
Jeroen De Meerleer e874a94fec
BUGFIX: only triggering event listener once 1 month ago
Jeroen De Meerleer cf9d2373c2
Updated dependencies 2 months ago
Jeroen De Meerleer 8e737d80cb
ENHANCEMENT: added visualisation of triggered run 2 months ago
Jeroen De Meerleer 2a7c2a5ca3
NEW FEATURE: added webhooks 2 months ago
Jeroen De Meerleer 12205ad18e
BUGFIX: reboot jobs stopped after triggering initial command 3 months ago
Jeroen De Meerleer c59a84a34f
BUGFIX: deletetempvar without name argument did not remove tempvars 3 months ago
Jeroen De Meerleer ff7044c567
BUGFIX: styling of timepicker to schedule was incorrect 3 months ago
Jeroen De Meerleer a6ce9afa18
Updated dependencies 3 months ago
Jeroen De Meerleer 45ba3c6ce2
BUGFIX: Scheduled run always started 1 second too late 3 months ago
Jeroen De Meerleer 21f65e2480
Added timed scheduled runs 3 months ago
Jeroen De Meerleer f77d487ab2
UPDATED DEPENDENCIES 3 months ago
Jeroen De Meerleer b933b2bad0
BUGFIX: Memory Limit could be too low when running daemon script 3 months ago
Jeroen De Meerleer 83f995c177
UPDATED CHANGELOG 4 months ago
Jeroen De Meerleer 3cec0bd074
UPDATED DEPENDENCIES 4 months ago
Jeroen De Meerleer 8a5eb92897
UPDATED DEPENDENCIES 4 months ago
Jeroen De Meerleer 4e9e9c8d0e
Updated dependencies 5 months ago
Jeroen De Meerleer c57f061805
Added changelog 5 months ago
Jeroen De Meerleer cfab6abbbd
UPDATED DEPENDENCIES 5 months ago
Jeroen De Meerleer 8dfe1118b3
ENHANCEMENT: added return types 6 months ago
Jeroen De Meerleer 66bc095b81
updated translations 6 months ago
Jeroen De Meerleer 24da51ffd8
BUGFIX: resolved deprecations 6 months ago
Jeroen De Meerleer 3c2e2e2a03
Merge pull request #16 from JeroenED/dependabot/composer/twig/twig-3.4.3
Build(deps): Bump twig/twig from 3.4.2 to 3.4.3
6 months ago
dependabot[bot] 8d70d8fb96
Build(deps): Bump twig/twig from 3.4.2 to 3.4.3
Bumps [twig/twig](https://github.com/twigphp/Twig) from 3.4.2 to 3.4.3.
- [Release notes](https://github.com/twigphp/Twig/releases)
- [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG)
- [Commits](https://github.com/twigphp/Twig/compare/v3.4.2...v3.4.3)

---
updated-dependencies:
- dependency-name: twig/twig
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
6 months ago
Jeroen De Meerleer 3a48fd7e52
BUGFIX: when querying the database pointer could be reused 6 months ago
Jeroen De Meerleer 3a95d23d8b
BUGFIX: We need to sleep at least 1 second or we get doubles 6 months ago
Jeroen De Meerleer 159ec33a22
BUGFIX: also remove temp-vars when not manual 6 months ago
Jeroen De Meerleer 53424c50bf
ENHANCEMENT: daemon cannot be checked if run in a different container 6 months ago
Jeroen De Meerleer 9fea685e86
ENHANCEMENT: translating with crowdin 7 months ago
Jeroen De Meerleer 15031f3ff3
BUGFIX: let it work behind a proxy 7 months ago
Jeroen De Meerleer fc03b2f731
BUGFIX: using incorrect translation strings 7 months ago
Jeroen De Meerleer 56bc06e6f0
NEW FEATURE: settings page 7 months ago
Jeroen De Meerleer dc09ba2275
BUGFIX: flashes were not translated 7 months ago
Jeroen De Meerleer d24b84efdf
BUGFIX: relocating runnow translations 7 months ago
Jeroen De Meerleer 1f6a7b57bb
ENHANCEMENT: Saving locale in database 7 months ago
Jeroen De Meerleer e0f5cae8f6
ENHANCEMENT: Adding recipients to mailfailedruns command 7 months ago
Jeroen De Meerleer 4d1909ea59
ENHANCEMENT: updated create user command 7 months ago
Jeroen De Meerleer 0cbcc8308a
ENHANCEMENT: Using buildno in migration 7 months ago
Jeroen De Meerleer abc79023bd
Updated readme 7 months ago
Jeroen De Meerleer dd18843c12
ENHANCEMENT: using doctrine migrations for deployment 7 months ago
Jeroen De Meerleer ba326f4e51
BUGFIX: footer was not correctly responsive 7 months ago
Jeroen De Meerleer 6517c78052
BUGFIX: don't scroll up on selecting details 7 months ago
Jeroen De Meerleer e787007822
BUGFIX: Job details was not translated 7 months ago
Jeroen De Meerleer fc51167c04
ENHANCEMENT: Setting version tag in footer 7 months ago
Jeroen De Meerleer 98b4ce1c94
UPDATED DEPENDENCIES 7 months ago
Jeroen De Meerleer c039004d62
UPDATED README 7 months ago
Jeroen De Meerleer 92ce356af7
UPDATED DEPENDENCIES 7 months ago
Jeroen De Meerleer 1312b7c81c
NEW FEATURE: added version tag 7 months ago

@ -0,0 +1,28 @@
# Changelog
## Version 1.2
### New
* Added timed scheduled runs
## Version 1.1
### New
* User settings page. Eliminating the need to call the helpline for changing your password or language
* Version tag in footer
### Changed
* Docker images are following the docker philosophy (2 containers providing 1 functionality each. Although you can still use the fat image)
* Translations can be contributed via [crowdin](https://crowdin.com/project/webcron-management)
* Data migrations are done using doctrine migrations
* User command now has update action
* Mail-failed-runs now takes an argument with recipients. Eliminating the need for a user account
* Docker images are build for amd64, arm and arm64
* Symfony framework has been updated to version 6.2
### Fixed
* Some flashes were not translated
* Trusted proxies were not parsed
* When running in a different container the health of the daemon could not be checked
## Version 1.0
(Initial release)

@ -3,54 +3,56 @@
Webcron management is an easy-to-use interface to manage cronjobs running on a publicly available http-location.
### Known bugs
* Datepicker ([Tempus dominus v6](https://getdatepicker.com/)) is currently alpha-quality software. Altough [the dev states it is usable](https://jonathanpeterson.com/posts/state-of-my-datetime-picker-part-2.html)
## Deploying
### Requirements for web-server
* php <= 8.0
* ext-openssl
* ext-pcntl (highly recommended)
* MySQL/MariaDB
* Ability to change the webroot directory
* Ability to run a script as daemon (eg. supervisor or systemd units)
## Building
### Requirements for build-server
* php <= 8.0 (incl composer <= 2)
* NodeJS <= 14.0 (incl. npm <= 7)
* php <= 8.1 (incl composer <= 2, ext-pcntl, ext-openssl, ext-intl)
* NodeJS <= 16.0 (incl. npm <= 8)
### Building
Please run following command on the build server
```shell
$ composer install --no-dev --optimize-autoloader
$ npm install
$ npx build prod
$ npm run build
$ rm -rf node_modules # Node modules are only required for building
```
### Configuration
All configuration can be found in .env.sample. Please copy this to file to .env and change its values
## Installation
### Requirements
* php <= 8.1
* ext-openssl
* ext-intl
* ext-pcntl (highly recommended)
* MariaDB
* SSH-access to the server
* Ability to change the webroot directory
* Ability to run a script as daemon (eg. supervisor or systemd units)
### Installation
First follow the build and configuration instructions. If you don't follow them correctly Webcron Management won't work correctly
1. Create your database and import the storage/database.sql file into the database
2. Create a first user by inserting a first record to the users table (Password is hashed using the HASHING_METHOD in your .env)
1. Create a build yourself or download the build from the releases page
2. Upload the build to the webserver.
3. Set up your webhosting to use the `/public` directory as web root
4. Upload the repository to the webserver
5. Set up the daemon script using systemd, supervisord or similar system
* If this is not possible running the daemon using a cronjob is still possible using below gist (Not recommended)
4. Create the .env file by copying .env.sample to .env and change the values
5. Run `php bin/console doctrine:migrations:migrate` to create or migrate the database
6. Create a first user by running `php bin/console webcron:user add`
7. Set up the daemon script using systemd, supervisord or similar system
If this is not possible running the daemon using a cronjob is still possible using below gist (Not recommended)
```shell
0 * * * * cd /path/to/webcron/ && php webcron daemon --time-limit=3600 > /dev/null 1&>2
```
The webcron interface should now work as expected.
## Upgrading
### Requirements
Same requirements and deploying
## Common pitfalls
### Cronjobs are not running
Did you edit the crontab?
### Procedure
1. Remove all files except .env from the webserver
2. Upload the new build to the webserver
3. Run `php bin/console doctrine:migrations:migrate` to migrate the database
## Common pitfalls
### I can't do an automatic system upgrade!
Doing a system upgrade requires sudo which has a certain number security measurements. To enable running anything with sudo (eg. `sudo apt dist-upgrade -y`) the user needs to be able to run sudo without tty and password.

@ -9,4 +9,28 @@ Utils.initTags = () => {
})
}
Utils.timepickerOptions = {
localization:{
locale: 'nl',
format: 'dd/MM/yyyy HH:mm:ss'
},
display: {
icons: {
time: 'icon-clock-o',
date: 'icon-calendar',
up: 'icon-arrow-up',
down: 'icon-arrow-down',
previous: 'icon-chevron-left',
next: 'icon-chevron-right',
today: 'icon-calendar-check-o',
clear: 'icon-delete',
close: 'icon-x',
},
components: {
seconds: true,
useTwentyfourHour: true
}
},
}
export default Utils;

@ -1,7 +1,6 @@
import 'bootstrap';
import moment from 'moment';
import * as tempusDominus from '@eonasdan/tempus-dominus/dist/js/tempus-dominus';
import momentparse from './momentjs-parse';
import {TempusDominus,extend} from "@eonasdan/tempus-dominus";
import customDateFormat from '@eonasdan/tempus-dominus/dist/plugins/customDateFormat';
import Utils from "./Utils";
document.addEventListener("readystatechange", event => {
@ -18,33 +17,11 @@ document.addEventListener("readystatechange", event => {
}
});
const timepickerOptions = {
localization:{
locale: 'nl'
},
display: {
icons: {
time: 'icon-clock-o',
date: 'icon-calendar',
up: 'icon-arrow-up',
down: 'icon-arrow-down',
previous: 'icon-chevron-left',
next: 'icon-chevron-right',
today: 'icon-calendar-check-o',
clear: 'icon-delete',
close: 'icon-x',
},
components: {
seconds: true,
useTwentyfourHour: true
}
},
}
function initDatePickers()
{
tempusDominus.extend(momentparse.load, 'DD/MM/yyyy HH:mm:ss');
new tempusDominus.TempusDominus(document.querySelector('#nextrunselector'), timepickerOptions);
new tempusDominus.TempusDominus(document.querySelector('#lastrunselector'), timepickerOptions);
extend(customDateFormat);
new TempusDominus(document.querySelector('#nextrunselector'), Utils.timepickerOptions);
new TempusDominus(document.querySelector('#lastrunselector'), Utils.timepickerOptions);
}
function initCronType()

@ -2,11 +2,14 @@ import {Modal} from 'bootstrap';
import image from '/assets/images/ajax-loader.gif'
import '/assets/scss/job/index.scss';
import Utils from "./Utils";
import customDateFormat from '@eonasdan/tempus-dominus/dist/plugins/customDateFormat';
import {DateTime,TempusDominus,extend} from "@eonasdan/tempus-dominus";
document.addEventListener("readystatechange", event => {
if(event.target.readyState === 'complete') {
initDeleteButtons();
initRunNowButtons();
initRunButtons();
initTimepicker();
Utils.initTags();
}
});
@ -27,55 +30,106 @@ function initDeleteButtons() {
}));
}
function initRunNowButtons() {
document.querySelectorAll('.runnow').forEach(elem => elem.addEventListener("click", event => {
var selecttimedatepicker;
function initTimepicker() {
extend(customDateFormat);
let modal = document.querySelector('#run_selecttime');
let datepickeroptions = Utils.timepickerOptions;
datepickeroptions.display.inline = true;
datepickeroptions.display.sideBySide = true;
datepickeroptions.restrictions = {
minDate: new Date()
};
selecttimedatepicker = new TempusDominus(document.querySelector('#selecttime_datepicker'), datepickeroptions);
}
function initRunButtons() {
document.querySelectorAll('.run').forEach(elem => elem.addEventListener("click", event => {
let me = event.currentTarget;
let href = me.dataset.href;
let norun = me.closest('tr').classList.contains('norun')
let maxdate = new DateTime(me.dataset.nextrun)
if (maxdate < new DateTime() ) {
if (norun) {
maxdate = undefined;
} else {
console.error('You cannot have to be run jobs in the past');
return;
}
}
selecttimedatepicker.updateOptions({
restrictions: {
maxDate: maxdate
}
})
selecttimedatepicker.viewDate = new DateTime();
var bsModal = new Modal('#run_selecttime');
bsModal.show();
let runnowCnt = document.querySelector('.runnow-content');
if(runnowCnt.querySelector('img') === null) {
let loaderImg = document.createElement('img');
loaderImg.src = image;
runnowCnt.appendChild(loaderImg);
let schedulefn = event => {
bsModal.hide();
let time = Math.floor(selecttimedatepicker.viewDate / 1000);
run(me, time);
}
let runnowfn = event => {
bsModal.hide();
run(me);
}
document.querySelector('.container-fluid').classList.add('blur');
document.querySelector('.runnow-overlay').classList.add('d-block');
document.querySelector('.runnow-overlay').classList.remove('d-none');
let closebtnfn = event => {
bsModal.hide();
document.querySelectorAll('.schedule').forEach(elem => elem.removeEventListener("click", schedulefn));
document.querySelectorAll('.run-now').forEach(elem => elem.removeEventListener("click",runnowfn));
}
document.querySelectorAll('.schedule').forEach(elem => elem.addEventListener("click", schedulefn, { once: true } ));
document.querySelectorAll('.run-now').forEach(elem => elem.addEventListener("click", runnowfn, { once: true } ));
document.querySelectorAll('.btn-close').forEach(elem => elem.addEventListener("click", closebtnfn ));
} ));
}
function run(elem, time = 0) {
let href = elem.dataset.href;
if (time > 0) href = href + '/' + time.toString();
fetch(href, { method: 'GET' })
.then(response => response.json())
.then(data => {
let modal = document.querySelector('#runnow_result');
modal.querySelector('.modal-title').innerHTML = data.title;
if (data.status == 'deferred') {
modal.querySelector('.modal-body').innerHTML = data.message;
me.classList.add('disabled');
let td = me.closest('td');
td.querySelectorAll('.btn').forEach(btn => {
btn.classList.add('btn-outline-success');
btn.classList.remove('btn-outline-primary');
btn.classList.remove('btn-outline-danger');
})
let runCnt = document.querySelector('.run-content');
if(runCnt.querySelector('img') === null) {
let loaderImg = document.createElement('img');
loaderImg.src = image;
runCnt.appendChild(loaderImg);
}
document.querySelector('.container-fluid').classList.add('blur');
document.querySelector('.run-overlay').classList.add('d-block');
document.querySelector('.run-overlay').classList.remove('d-none');
fetch(href, { method: 'GET' })
.then(response => response.json())
.then(data => {
let modal = document.querySelector('#run_result');
modal.querySelector('.modal-title').innerHTML = data.title;
if (data.status == 'deferred') {
modal.querySelector('.modal-body').innerHTML = data.message;
elem.classList.add('disabled');
let td = elem.closest('td');
td.querySelectorAll('.btn').forEach(btn => {
btn.classList.add('btn-outline-success');
btn.classList.remove('btn-outline-primary');
btn.classList.remove('btn-outline-danger');
})
let tr = me.closest('tr');
tr.classList.add('running');
tr.classList.add('text-success');
tr.classList.remove('norun');
tr.classList.remove('text-danger');
} else if (data.status == 'ran') {
let content = '<p>' + data.message + '</p>'
content += '<pre>' + data.output + '</pre>'
modal.querySelector('.modal-body').innerHTML = content;
}
let tr = elem.closest('tr');
tr.classList.add('running');
tr.classList.add('text-success');
tr.classList.remove('norun');
tr.classList.remove('text-danger');
} else if (data.status == 'ran') {
let content = '<p>' + data.message + '</p>'
content += '<pre>' + data.output + '</pre>'
var bsModal = new Modal('#runnow_result').show();
modal.querySelector('.modal-body').innerHTML = content;
}
document.querySelector('.container-fluid').classList.remove('blur');
document.querySelector('.runnow-overlay').classList.remove('d-block');
document.querySelector('.runnow-overlay').classList.add('d-none');
})
var runModal = new Modal('#run_result');
runModal.show();
document.querySelector('.container-fluid').classList.remove('blur');
document.querySelector('.run-overlay').classList.remove('d-block');
document.querySelector('.run-overlay').classList.add('d-none');
})
)
}

@ -0,0 +1,2 @@
import 'bootstrap';
import '/assets/scss/settings.scss';

@ -1,5 +1,7 @@
@import "/assets/scss/base";
@import "/assets/scss/icons";
@import "assets/scss/base";
@import "assets/scss/icons";
@import "/node_modules/@eonasdan/tempus-dominus/dist/css/tempus-dominus.css";
@import "assets/scss/tempus-dominus-dark";
tr.norun td {
background-color: #f8d7da;
@ -33,8 +35,8 @@ td.status-col {
filter: blur(3px);
}
.runnow-overlay {
.runnow-blur {
.run-overlay {
.run-blur {
bottom: 0;
left: 0;
position: fixed;
@ -43,7 +45,7 @@ td.status-col {
z-index: 1500;
}
.runnow-content {
.run-content {
font-size: 10px;
height: 50px;
position: absolute;
@ -54,4 +56,25 @@ td.status-col {
margin-left: -50px;
margin-top: -50px;
}
}
#run_selecttime {
.tempus-dominus-widget {
box-shadow: none;
&.timepicker-sbs {
width: 19em;
.td-row {
flex-direction: column;
.td-half {
width: 19em;
}
}
}
&.dark {
background-color: #2f2f2f;
}
}
}

@ -0,0 +1,4 @@
@import "assets/scss/base";
@import "assets/scss/icons";
@import "/node_modules/@eonasdan/tempus-dominus/dist/css/tempus-dominus.css";
@import "assets/scss/tempus-dominus-dark";

@ -13,22 +13,23 @@
"ext-posix": "*",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.12",
"guzzlehttp/guzzle": "^7.4",
"doctrine/orm": "^2.13",
"guzzlehttp/guzzle": "^7.5",
"phpseclib/phpseclib": "^3.0",
"scienta/doctrine-json-functions": "^5.1",
"symfony/console": "^6.1",
"symfony/dotenv": "^6.1",
"scienta/doctrine-json-functions": "^5.2",
"symfony/console": "^6.2",
"symfony/crowdin-translation-provider": "^6.2",
"symfony/dotenv": "^6.2",
"symfony/flex": "^2.2",
"symfony/framework-bundle": "^6.1",
"symfony/mailer": "^6.1",
"symfony/proxy-manager-bridge": "^6.1",
"symfony/runtime": "^6.1",
"symfony/security-bundle": "^6.1",
"symfony/translation": "^6.1",
"symfony/twig-bundle": "^6.1",
"symfony/webpack-encore-bundle": "^1.15",
"symfony/yaml": "^6.1"
"symfony/framework-bundle": "^6.2",
"symfony/mailer": "^6.2",
"symfony/proxy-manager-bridge": "^6.2",
"symfony/runtime": "^6.2",
"symfony/security-bundle": "^6.2",
"symfony/translation": "^6.2",
"symfony/twig-bundle": "^6.2",
"symfony/webpack-encore-bundle": "^v1.16",
"symfony/yaml": "^6.2"
},
"config": {
"allow-plugins": {
@ -78,14 +79,14 @@
"extra": {
"symfony": {
"allow-contrib": true,
"require": "6.1.*"
"require": "^6.1"
}
},
"require-dev": {
"symfony/debug-bundle": "^6.1",
"symfony/maker-bundle": "^1.45",
"symfony/debug-bundle": "^6.2",
"symfony/maker-bundle": "^v1.48.0",
"symfony/monolog-bundle": "^3.8",
"symfony/stopwatch": "^6.1",
"symfony/web-profiler-bundle": "^6.1"
"symfony/stopwatch": "^6.2",
"symfony/web-profiler-bundle": "^6.2"
}
}

1585
composer.lock generated

File diff suppressed because it is too large Load Diff

@ -12,6 +12,9 @@ framework:
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
trusted_proxies: "%env(resolve:TRUSTED_PROXIES)%"
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix']
#esi: true
#fragments: true
php_errors:

@ -14,7 +14,7 @@ security:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
login:
pattern: ^/?(%enabled_locales%)?/(health)$
pattern: ^/?([a-zA-Z0-9-]+)?/(health)$
security: false
main:
pattern: ^/(.*)
@ -41,5 +41,12 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/?(%enabled_locales%)?/job, roles: ROLE_USER }
- { path: ^/?([a-zA-Z0-9-]+)?/job, roles: ROLE_USER }
# - { path: ^/profile, roles: ROLE_USER }
when@dev:
security:
firewalls:
main:
remember_me:
secure: false

@ -4,9 +4,11 @@ framework:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
# providers:
# crowdin:
# dsn: '%env(CROWDIN_DSN)%'
providers:
crowdin:
dsn: '%env(CROWDIN_DSN)%'
domains: ['messages']
locales: ['en', 'nl', 'leet']
# loco:
# dsn: '%env(LOCO_DSN)%'
# lokalise:

@ -1,5 +1,7 @@
twig:
default_path: '%kernel.project_dir%/templates'
globals:
kernelProjectDir: '%kernel.project_dir%'
when@test:
twig:

@ -1,6 +1,6 @@
default:
path: '/'
controller: App\Controller\SecurityController::loginAction
controller: App\Controller\UserController::loginAction
login_check:
path: '/login_check'
@ -16,13 +16,30 @@ favicon:
path: '/favicon.ico'
controller: App\Controller\SiteController::faviconAction
settings:
path: '/{_locale}/settings'
methods: [ 'GET' ]
controller: App\Controller\UserController::settingsAction
settings_save:
path: '/{_locale}/settings'
methods: [ 'POST' ]
controller: App\Controller\UserController::settingsSaveAction
default_locale:
path: '/{_locale}'
controller: App\Controller\SecurityController::loginAction
controller: App\Controller\UserController::loginAction
login:
path: '/{_locale}/login'
controller: App\Controller\SecurityController::loginAction
controller: App\Controller\UserController::loginAction
webhook:
path: '/hook/{id}/{token}'
controller: App\Controller\JobController::hookAction
requirements:
id: \d+
token: '[A-Za-z0-9]+'
job_index:
path: '/{_locale}/job'
@ -51,11 +68,14 @@ job_edit:
requirements:
id: \d+
job_runnow:
path: '/{_locale}/job/{id}/runnow'
controller: App\Controller\JobController::runNowAction
job_run:
path: '/{_locale}/job/{id}/run/{timestamp}'
controller: App\Controller\JobController::runAction
requirements:
id: \d+
timestamp: \d+
defaults:
timestamp: 0
job_add:
path: '/{_locale}/job/add'

@ -4,7 +4,10 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
enabled_locales: 'en|nl'
enabled_locales:
en: 'English'
nl: 'Nederlands'
leet: 'L33tsp34k'
security:
csp_policy: "default-src 'none'; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; form-action 'self'; require-trusted-types-for 'script'; frame-ancestors 'none'; base-uri 'none'"
referer_policy: "same-origin"

@ -16,8 +16,9 @@
#/
## Dependencies
php=8.0
npm=7.0
php=8.1
npm=8.0
node=16.0
## Globals
script_name=$(basename "${0}")
@ -28,8 +29,11 @@ environment=main
root=/tmp/webcron
APP_ENV="prod"
DATABASE_URL="mysql://root:letmein@127.0.0.1:3306/webcron"
APP_SECRET=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 20 ; echo '')
DATABASE_URL="mysql://root:letmein@127.0.0.1:3306/webcron"
DEMO_MODE="false"
DEMO_USER="example@example.com"
DEMO_PASS="password"
ENCRYPTION_METHOD="AES-256-CBC"
HASHING_METHOD="sha256"
DEBUG=false
@ -95,11 +99,11 @@ CheckDeps() {
CheckDep "PHP" "php --version" "PHP is not available. Exiting" "FAIL" ${php} "echo '<?php echo phpversion();' | php" "PHP version too low. Exiting" "FAIL"
CheckDep "Composer" "composer --version" "Composer is not available. Exiting" "FAIL"
CheckDep "MySQL" "/usr/sbin/mysqld --version" "MySQL is not available. SQLite can be used" "WARNING"
CheckDep "NodeJS" "node --version" "NodeJS is not available. Exiting" "FAIL"
CheckDep "NodeJS" "node --version" "NodeJS is not available. Exiting" "FAIL" ${node} "node --version" "Node version too low. Exiting" "FAIL"
CheckDep "NPM" "npm --version" "NPM is not available. Exiting" "FAIL" ${npm} "npm --version" "NPM version too low. Exiting" "FAIL"
CheckDep "php-pcntl" "php -me | grep pcntl" "php-pcntl extension is not available. Cronjobs will not be running asyncronous" "WARNING"
CheckDep "php-intl" "php -me | grep intl" "php-intl extension is not available. Exiting" "FAIL"
CheckDep "php-xml" "php -me | grep xml" "php-xml extension is not available. Exiting" "FAIL"
CheckDep "php-openssl" "php -me | grep openssl" "php-openssl extension is not available. Exiting" "FAIL"
echo -e "\e[1;32mDependency test OK\e[0m"
}
@ -146,8 +150,11 @@ CreateEnvFile() {
touch .env 1> /dev/null 2>&1
fi
echo "APP_ENV=\"$APP_ENV\"" >> .env
echo "DATABASE_URL=\"$DATABASE_URL\"" >> .env
echo "APP_SECRET=\"$APP_SECRET\"" >> .env
echo "DATABASE_URL=\"$DATABASE_URL\"" >> .env
echo "DEMO_MODE=\"$DEMO_MODE\"" >> .env
echo "DEMO_USER=\"$DEMO_USER\"" >> .env
echo "DEMO_PASS=\"$DEMO_PASS\"" >> .env
echo "ENCRYPTION_METHOD=\"$ENCRYPTION_METHOD\"" >> .env
echo "HASHING_METHOD=\"$HASHING_METHOD\"" >> .env
echo "DEBUG=\"$DEBUG\"" >> .env

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version1000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create initial database';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE job (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, `data` LONGTEXT NOT NULL, `interval` INT NOT NULL, nextrun INT NOT NULL, lastrun INT DEFAULT NULL, running INT NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE run (id INT AUTO_INCREMENT NOT NULL, job_id INT DEFAULT NULL, exitcode VARCHAR(15) NOT NULL, output LONGTEXT NOT NULL, runtime DOUBLE PRECISION NOT NULL, timestamp INT NOT NULL, flags VARCHAR(5) NOT NULL, INDEX IDX_5076A4C0BE04EA9 (job_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(100) NOT NULL, password VARCHAR(60) NOT NULL, sendmail TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE run ADD CONSTRAINT FK_5076A4C0BE04EA9 FOREIGN KEY (job_id) REFERENCES job (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE run DROP FOREIGN KEY FK_5076A4C0BE04EA9');
$this->addSql('DROP TABLE job');
$this->addSql('DROP TABLE run');
$this->addSql('DROP TABLE user');
}
}

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version1001 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP sendmail');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD sendmail TINYINT(1) NOT NULL');
}
}

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version1002 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD locale VARCHAR(15) NOT NULL');
$this->addSql('UPDATE user SET locale = :locale', ['locale' => 'en']);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP locale');
}
}

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Entity\Job;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version1003 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$allJobs = $this->connection->executeQuery('SELECT * FROM job')->fetchAllAssociative();
foreach($allJobs as $job) {
$data = json_decode($job['data'], true);
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$randomString = '';
$length = 32;
for ($i = 0; $i < $length; $i++) {
$index = rand(0, strlen($characters) - 1);
$randomString .= $characters[$index];
}
$data['hooktoken'] = $randomString;
$this->addSql('UPDATE job SET data = "' . addSlashes(json_encode($data)) . '" WHERE id = ' . $job['id']);
}
}
public function down(Schema $schema): void
{
$allJobs = $this->connection->executeQuery('SELECT * FROM job')->fetchAllAssociative();
foreach($allJobs as $job) {
$data = json_decode($job['data'], true);
unset($data['hooktoken']);
$this->addSql('UPDATE job SET data = "' . addSlashes(json_encode($data)) . '" WHERE id = ' . $job['id']);
}
}
}

4763
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -6,17 +6,17 @@
"license": "AGPL-3.0-or-later",
"private": true,
"dependencies": {
"@eonasdan/tempus-dominus": "latest",
"@eonasdan/tempus-dominus": "^6.2",
"@popperjs/core": "^2.11",
"bootstrap": "^5.1",
"bootstrap": "^5.2",
"bootstrap-dark-5": "^1.1",
"moment": "^2.29"
},
"devDependencies": {
"sass": "^1.5",
"sass-loader": "^13.0",
"@symfony/webpack-encore": "^3.0",
"core-js": "^3.24"
"sass": "^1.56",
"sass-loader": "^13.2",
"@symfony/webpack-encore": "^4.1",
"core-js": "^3.26"
},
"scripts": {
"watch" : "encore dev --watch",

@ -8,15 +8,16 @@ use App\Entity\Run;
use Doctrine\DBAL\Exception;
use App\Repository\RunRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
#[AsCommand(name: 'webcron:cleanup', description: 'Cleanup runs')]
class CleanupCommand extends Command
{
protected static $defaultName = 'webcron:cleanup';
protected $kernel;
protected $doctrine;
@ -30,25 +31,17 @@ class CleanupCommand extends Command
protected function configure()
{
$this
->setDescription('Cleanup runs')
->setHelp('This command cleans the runs table')
->addOption('jobid', 'j', InputOption::VALUE_IS_ARRAY + InputOption::VALUE_REQUIRED, 'The ids of the jobs to clean')
->addOption('maxage', 'm', InputOption::VALUE_REQUIRED, 'The maximum age of the oldest runs');
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output) : int
{
$maxage = $input->getOption('maxage');
$jobs = $input->getOption('jobid');
$runRepo = $this->doctrine->getRepository(Run::class);
try {
$deleted = $runRepo->cleanupRuns($jobs, $maxage);
$output->writeln('Deleted ' . $deleted . ' runs');
return Command::SUCCESS;
} catch(Exception $exception) {
$output->writeln($exception->getMessage());
return Command::FAILURE;
}
$deleted = $runRepo->cleanupRuns($jobs, $maxage);
$output->writeln('Deleted ' . $deleted . ' runs');
return Command::SUCCESS;
}
}

@ -6,17 +6,16 @@ namespace App\Command;
use App\Entity\Job;
use App\Repository\JobRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
#[AsCommand(name: 'webcron:daemon', description: 'The master script of Webcron Management')]
class DaemonCommand extends Command
{
protected static $defaultName = 'webcron:daemon';
protected $kernel;
protected $doctrine;
@ -31,14 +30,14 @@ class DaemonCommand extends Command
protected function configure()
{
$this
->setDescription('The deamon slayer of webcron')
->setHelp('This command is the daemon process of webcron, enabling webcron to actually run jobs on time')
->addOption('time-limit', 't', InputOption::VALUE_REQUIRED, 'Time limit in seconds before stopping the daemon.')
->addOption('async', 'a', InputOption::VALUE_NEGATABLE, 'Time limit in seconds before stopping the daemon.');
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output) : int
{
ini_set('memory_limit', '4G');
$jobRepo = $this->doctrine->getRepository(Job::class);
$timelimit = $input->getOption('time-limit') ?? false;
$async = $input->getOption('async') ?? function_exists('pcntl_fork');
@ -50,21 +49,26 @@ class DaemonCommand extends Command
throw new \InvalidArgumentException('Time limit has incorrect value');
}
$jobRepo->unlockJob();
file_put_contents($this->kernel->getCacheDir() . '/daemon-running.lock', posix_getpid());
file_put_contents($this->kernel->getCacheDir() . '/daemon-running.lock', time());
while(1) {
if($endofscript !== false && time() > $endofscript) break;
$jobsToRun = $jobRepo->getJobsDue();
if(!empty($jobsToRun)) {
foreach($jobsToRun as $job) {
if($job->getData('crontype') == 'reboot') {
$str = @file_get_contents('/proc/uptime');
$num = floatval($str);
foreach($jobsToRun as $key=>$job) {
if ($job->getData('crontype') == 'reboot') {
$str = @file_get_contents('/proc/uptime');
$num = floatval($str);
$rebootedself = ($num < $job->getData('reboot-duration') * 60);
$consolerun = $jobRepo->getTempVar($job, 'consolerun', false);
if($consolerun && !$rebootedself) continue;
if ($consolerun && !$rebootedself) continue;
}
$manual = ($job->getRunning() == 2);
$manual = '';
if($jobRepo->getTempVar($job, 'webhook', false)) {
$manual = 'Webhook';
} elseif($job->getRunning() > 1) {
$manual = 'Manual';
};
$jobRepo->setJobRunning($job, true);
$output->writeln('Running Job ' . $job->getId());
if($async) {
@ -75,22 +79,23 @@ class DaemonCommand extends Command
$jobRepo = $this->doctrine->getRepository(Job::class);
}
if(!$async || $pid == -1) {
$jobRepo->RunJob($job, $manual);
$jobRepo->setJobRunning($job, false);
} elseif ($pid == 0) {
$jobRepo->RunJob($job, $manual);
$jobRepo->setJobRunning($job, false);
exit;
if((!$async || $pid == -1) || $pid == 0) {
$result = $jobRepo->RunJob($job, $manual);
if ($result['status'] == 'ran') $jobRepo->setJobRunning($job, false);
if ($pid == 0) exit;
}
unset($jobsToRun[$key]);
unset($job);
}
}
$this->doctrine->getManager()->clear();
file_put_contents($this->kernel->getCacheDir() . '/daemon-running.lock', time());
$maxwait = time() + 30;
$nextrun = $jobRepo->getTimeOfNextRun();
$nextrun = max($jobRepo->getTimeOfNextRun(), time() + 1);
$sleepuntil = min($maxwait, $nextrun);
if($sleepuntil > time()) time_sleep_until($sleepuntil);
gc_collect_cycles();
}
$output->writeln('Ended after ' . $timelimit . ' seconds');
pcntl_wait($status);

@ -8,6 +8,7 @@ use App\Entity\User;
use App\Repository\RunRepository;
use Doctrine\DBAL\Connection;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@ -15,9 +16,9 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'webcron:demodata', description: 'Install demo data')]
class DemoInstallCommand extends Command
{
protected static $defaultName = 'webcron:demodata';
protected $kernel;
protected $doctrine;
protected $passwordHasher;
@ -33,11 +34,10 @@ class DemoInstallCommand extends Command
protected function configure()
{
$this
->setDescription('Install demo data')
->setHelp('This command installs the demo data');
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output) : int
{
$em = $this->doctrine->getManager();
@ -55,7 +55,7 @@ class DemoInstallCommand extends Command
$user
->setEmail($_ENV['DEMO_USER'])
->setPassword($hashedpassword)
->setSendmail(true);
->setLocale('en');
$em->persist($user);
$em->flush();
@ -86,6 +86,7 @@ class DemoInstallCommand extends Command
false,
]
]);
$job1->addToken();
$job2 = $jobRepo->prepareJob([
'name' => '[Website] Update texts to latest version',
@ -111,6 +112,8 @@ class DemoInstallCommand extends Command
true,
]
]);
$job2->addToken();
$job3 = $jobRepo->prepareJob([
'name' => '[Server][Reboot] Monthly reboot',
'interval' => (60*60*24*30),
@ -136,6 +139,8 @@ class DemoInstallCommand extends Command
'var-issecret' => [
]
]);
$job3->addToken();
$em->persist($job1);
$em->persist($job2);
$em->persist($job3);
@ -220,6 +225,15 @@ rtt min/avg/max/mdev = 101.362/101.362/101.362/0.000 ms')
->setFlags(RunRepository::SUCCESS);
$em->persist($run);
$run = new Run();
$run->setExitcode(200)
->setJob($job2)
->setRuntime(rand(0, 10000) / 1000)
->setOutput(json_encode(['success' => true, 'message' => 'Texts are updated succesfully']))
->setTimestamp(7200 * ceil( time() / 7200) - (3542))
->setFlags(RunRepository::SUCCESS . RunRepository::TRIGGERED);
$em->persist($run);
$run = new Run();
$run->setExitcode(200)
->setJob($job2)

@ -7,7 +7,9 @@ use App\Entity\User;
use App\Repository\JobRepository;
use App\Repository\UserRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
@ -18,9 +20,9 @@ use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Twig\Environment;
#[AsCommand(name: 'webcron:mail-failed-runs', description: 'Sends email about failed runs')]
class MailFailedRunsCommand extends Command
{
protected static $defaultName = 'webcron:mail-failed-runs';
protected $kernel;
protected $doctrine;
protected $templating;
@ -38,8 +40,8 @@ class MailFailedRunsCommand extends Command
protected function configure()
{
$this
->setDescription('Sends email about failed runs')
->setHelp('This command will send emails to the users when jobs are failing');
->setHelp('This command will send emails to the users when jobs are failing')
->addArgument('recipients', InputArgument::REQUIRED + InputArgument::IS_ARRAY, 'Which e-mailaddress should receive the notifications');
}
/**
@ -47,10 +49,8 @@ class MailFailedRunsCommand extends Command
* @throws \Twig\Error\SyntaxError
* @throws \Twig\Error\LoaderError
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output) : int
{
$userRepo = $this->doctrine->getRepository(User::class);
$jobRepo = $this->doctrine->getRepository(Job::class);
$failedJobs = $jobRepo->getFailingJobs();
@ -63,7 +63,7 @@ class MailFailedRunsCommand extends Command
->subject('Some cronjobs are failing')
->html($html);
$recipients = $userRepo->getMailAddresses();
$recipients = $input->getArgument('recipients');
foreach ($recipients as $recipient) {
$email->addTo($recipient);
}

@ -4,15 +4,16 @@ namespace App\Command;
use App\Entity\Job;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
#[AsCommand(name: 'webcron:run', description: 'Run a single cronjob')]
class RunCommand extends Command
{
protected static $defaultName = 'webcron:run';
protected $kernel;
protected $doctrine;
@ -26,12 +27,11 @@ class RunCommand extends Command
protected function configure()
{
$this
->setDescription('Run a single cronjob')
->setHelp('This command runs a single command')
->addArgument('jobid', InputArgument::REQUIRED, 'The id of the job to be run');
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output) : int
{
$jobRepo = $this->doctrine->getRepository(Job::class);
$jobId = (int)$input->getArgument('jobid');
@ -47,14 +47,14 @@ class RunCommand extends Command
}
$jobRepo->setJobRunning($job, true);
$jobRepo->setTempVar($job, 'consolerun', true);
$result = $jobRepo->runNow($job, true);
$result = $jobRepo->run($job, true);
if($job->getData('crontype') == 'reboot') {
$sleeping = true;
while($sleeping) {
if(time() >= $job->getRunning()) $sleeping = false;
sleep(1);
}
$result = $jobRepo->runNow($job, true);
$result = $jobRepo->run($job, true);
}
$jobRepo->setJobRunning($job, false);
$jobRepo->setTempVar($job, 'consolerun', false);

@ -8,6 +8,7 @@ use App\Entity\User;
use App\Repository\RunRepository;
use Doctrine\DBAL\Connection;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
@ -17,41 +18,45 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'webcron:user', description: 'User stuff')]
class UserCommand extends Command
{
protected static $defaultName = 'webcron:user';
protected $kernel;
protected $doctrine;
protected $passwordHasher;
protected $io;
protected KernelInterface $kernel;
protected ManagerRegistry $doctrine;
protected UserPasswordHasherInterface $passwordHasher;
protected SymfonyStyle $io;
protected ParameterBagInterface $params;
private $action;
private $username;
private $password;
private $locale;
private $confirm;
public function __construct(KernelInterface $kernel, ManagerRegistry $doctrine, UserPasswordHasherInterface $passwordHasher)
public function __construct(KernelInterface $kernel, ManagerRegistry $doctrine, UserPasswordHasherInterface $passwordHasher, ParameterBagInterface $params)
{
$this->kernel = $kernel;
$this->doctrine = $doctrine;
$this->passwordHasher = $passwordHasher;
$this->params = $params;
parent::__construct();
}
protected function configure()
{
$this
->setDescription('User stuff')
->setHelp('The command is doing user stuff')
->addArgument('action', InputArgument::REQUIRED, 'What action should be executed? [add, delete, update]', null, ['add', 'update', 'delete'])
->addOption('username', 'u', InputOption::VALUE_OPTIONAL, 'What action should be executed? [add, delete, update]', '')
->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'What action should be executed? [add, delete, update]', '');
}
protected function initialize(InputInterface $input, OutputInterface $output)
protected function initialize(InputInterface $input, OutputInterface $output) : void
{