diff --git a/CHANGELOG.md b/CHANGELOG.md index e0233054..89ff0b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ This file documents all notable changes made to ITFlow. Backups are now encrypted, catalogued, schedulable, and restorable from the command line. - **Three types** — Full (database + uploads), Database Only, and Master Key. Every archive is an AES-256 encrypted zip. -- **One encryption key per install**, generated on first use and stored in `config.php` — never in the database and never in the file name. It is shown in Settings > Backup. **Write it down: without it a backup cannot be restored.** Open archives with 7-Zip, WinZip, PeaZip or Keka — `unzip`, Windows Explorer and the macOS Archive Utility do not support AES. +- **One encryption key per install**, generated on first use and stored in `config.php` — never in the database and never in the file name. It is shown in Maintenance > Backup. **Write it down: without it a backup cannot be restored.** Open archives with 7-Zip, WinZip, PeaZip or Keka — `unzip`, Windows Explorer and the macOS Archive Utility do not support AES. - **Backups are built by cron, not by your browser.** The button queues the work and the dispatcher picks it up within the minute, then notifies you. A dump of a real install takes longer than a web request is allowed to live, which is why the old Download Backup button timed out on large instances. -- **Scheduled backups** are a new `backup` cron job, off by default. Turn it on in Settings > Cron. Retention (by age and by count) runs in the nightly job and never deletes the newest backup. +- **Scheduled backups** are a new `backup` cron job, off by default. Turn it on in Maintenance > Cron. Retention (by age and by count) runs in the nightly job and never deletes the newest backup. - **Archives are stored outside the web-served path** under `uploads/backups/` with a deny-all rule, and downloaded through an admin-only handler. Set `$config_backup_path` in `config.php` to keep them off the web root entirely. - **Restore from the command line** with `php scripts/restore_cli.php --file=/path/to/backup.zip`. This is the only restore path with no size limit — the setup wizard's restore is capped by PHP's upload limits, and a full backup is usually larger. Use `--inspect` to check an archive without changing anything. - **Restores validate before they destroy.** The key is checked and the archive unpacked before any table is dropped, and the current database is dumped first and put back automatically if the import fails. @@ -38,14 +38,14 @@ sudo -u www-data php /path/to/itflow/scripts/update_cli.php --update_db ``` It applies every pending version in order and reports each one as it goes. On an install with a lot of ticket history it can take a minute or more, so let it finish. If a step fails it stops there without advancing the recorded version, so you can fix the problem and run it again. The 500s stop as soon as it completes. -5. **Add the new cron entry.** One line runs everything now, and the schedules are managed in ITFlow under Settings > Cron: +5. **Add the new cron entry.** One line runs everything now, and the schedules are managed in ITFlow under Maintenance > Cron: ``` * * * * * www-data php /path/to/itflow/cron/cron.php >/dev/null ``` Drop the `www-data` column if this goes in a user crontab rather than `/etc/cron.d`. 6. **Recreate your API keys.** Every existing key is deleted by this update. Issue new ones and update anything that talks to the ITFlow API. -7. **Check it took.** Open Settings > Cron — the green "Cron last checked in" banner should appear within a couple of minutes and every job should pick up a schedule. +7. **Check it took, and check the master switch.** Open Maintenance > Cron — the green "Cron last checked in" banner should appear within a couple of minutes and every job should pick up a schedule. The **Enable Cron** switch has moved to this page from Settings > Notifications, and it now stops *every* job rather than most of them. If it is off, turn it on here: on earlier releases the ticket email parser and the SLA monitor ran regardless of it, so an install with the switch off may have been parsing mail all along without anyone realising the switch mattered. Only this release needs the command line for the database update. Normal updates go back to running from Settings > Update as usual. ### Breaking Changes and Notes @@ -62,7 +62,13 @@ Only this release needs the command line for the database update. Normal updates ### New Features & Updates - Cron: one entry instead of five, and a page to manage it. Jobs are tracked in a new `cron_jobs` table, so a job whose slot was missed runs at the next opportunity rather than waiting a day, and each job locks for its own run so a slow mailbox or a long nightly pass no longer holds anything else up. -- Cron: new Settings > Cron page listing every job with its schedule, last run, duration, outcome and next due time. Jobs can be disabled, rescheduled, or run on demand — Run Now hands the job to the next dispatch so it starts within a minute and still runs on the command line. The last error is kept until dismissed rather than vanishing behind the next success, and the page says plainly when the crontab entry itself is missing. +- Cron: new Maintenance > Cron page listing every job with its schedule, last run, duration, outcome and next due time. Jobs can be disabled, rescheduled, or run on demand — Run Now hands the job to the next dispatch so it starts within a minute and still runs on the command line. The last error is kept until dismissed rather than vanishing behind the next success, and the page says plainly when the crontab entry itself is missing. +- Cron: the master **Enable Cron** switch has moved from Settings > Notifications to Maintenance > Cron, alongside everything else about cron. It can be turned on and off from that page, and the page now explains what it is for — stopping a restored backup or a staging clone from emailing clients and charging cards, which per-job toggles cannot do on their own. Saving Notification settings no longer touches it. +- Cron: the master switch now stops **every** job. The Ticket Email Parser and Ticket SLA Monitor previously ignored it and kept running while cron was switched off, which was neither documented nor visible anywhere. **If your Enable Cron switch is off but email-to-ticket has been working, turn cron on in Maintenance > Cron after upgrading** — see the upgrade steps above. Both jobs report "Cron: is not enabled" on the Cron page when the switch is off, so the state is now legible instead of silent. +- Settings > Notifications: removed five rows that had no controls in them and no setting behind them — certificate expiry, asset warranty expiry, shared item views, cron execution and ITFlow updates. The cron execution row is now covered properly by the last run, duration and outcome columns on Maintenance > Cron. +- Settings > Ticketing: the email-to-ticket hint no longer names a cron script that has not existed for several releases, and points at Maintenance > Cron instead. +- Setup: the command line installer now prints the crontab entry and the reminder to turn cron on, the same two steps the web installer shows on its finish page. +- Removed the unused `config_invoice_overdue_reminders` setting. It was seeded on install and read in two places but never actually used by anything; the overdue reminder schedule is fixed in the nightly job. - Cron: the nightly run is safe to repeat. Late fees, overdue invoice reminders and autopay retries now apply at most once per invoice per day, so a Run Now after the scheduled pass no longer stacks fees or re-emails clients. Nightly Tasks only accepts the daily schedule. - Ticket SLAs, optional throughout. An SLA sets a response target and an optional resolution target, assigned per client and priority with a global default and an explicit "no SLA" override. Targets are measured against your business hours. Tickets show time remaining and turn yellow at a configurable warning threshold and red on breach, on both the ticket list and the kanban board, and can be filtered by SLA state. Nominated statuses pause the resolution clock for "waiting on customer", preserving the remaining budget. Two new reports, SLA Summary and SLA by Client. With no assignments defined nothing behaves any differently. - Ticket: added an Urgent priority. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91199822..3e076677 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ One crontab entry runs everything: `cron/cron.php` is a dispatcher. It wakes every minute, works out which scripts in `cron/` are due, and requires them into its own process. Adding a job is a new script in `cron/` plus an entry in `includes/cron_jobs.php`. The crontab never changes again. -That registry is the only thing that decides **which** scripts can run, and the schedule in it is only a default: it seeds the job's `cron_jobs` row the first time the dispatcher meets the job, and from then on the row is what runs, because Settings > Cron writes to it. The database therefore holds **when and whether**, never **what** — a row naming a script that is not in the registry is ignored, so nothing that reaches the database can point the dispatcher at an arbitrary file. Keep it that way. +That registry is the only thing that decides **which** scripts can run, and the schedule in it is only a default: it seeds the job's `cron_jobs` row the first time the dispatcher meets the job, and from then on the row is what runs, because Maintenance > Cron writes to it. The database therefore holds **when and whether**, never **what** — a row naming a script that is not in the registry is ignored, so nothing that reaches the database can point the dispatcher at an arbitrary file. Keep it that way. Run Now in the admin UI does not execute anything in the web request: these scripts are CLI-only and some take minutes, so the button sets `cron_job_run_now` and the next dispatch picks it up, through the same lock and claim as a scheduled run. @@ -97,14 +97,24 @@ Because the jobs share one PHP process, job code has three rules: 1. **Never `exit()` or `die()`.** It ends the whole cycle and every job after it. Use `cronJobStop($message, $exit_code)` instead: it exits when the script was run directly and unwinds back to the dispatcher when it wasn't, so both paths behave as they always have. 2. **Never declare a function or class another job might declare.** Two jobs each declaring the same helper is a fatal `Cannot redeclare` the moment they share a process. Shared helpers belong in `functions/`. -3. **Be safe to run twice in one day.** The dispatcher's lock stops overlap, but nothing stops a repeat: an admin presses Run Now after the scheduled pass, or a schedule is misconfigured. Work selected by a date match (`... = CURDATE()`) fires again on every run of that day unless something records that it happened — nightly's late fees and overdue reminders guard on the history rows they write. A job whose work cannot be made repeat-safe declares `'interval_safe' => false` in `includes/cron_jobs.php`, which locks it to the daily schedule in Settings > Cron and in the dispatcher. +3. **Be safe to run twice in one day.** The dispatcher's lock stops overlap, but nothing stops a repeat: an admin presses Run Now after the scheduled pass, or a schedule is misconfigured. Work selected by a date match (`... = CURDATE()`) fires again on every run of that day unless something records that it happened — nightly's late fees and overdue reminders guard on the history rows they write. A job whose work cannot be made repeat-safe declares `'interval_safe' => false` in `includes/cron_jobs.php`, which locks it to the daily schedule in Maintenance > Cron and in the dispatcher. 4. **Set what you read.** One global scope and one set of `require_once` includes are shared across the cycle — a job's own `require_once "../config.php"` is a no-op if an earlier job already loaded it, and any variable an earlier job left behind is still there. Do not rely on the state a fresh process would have given you. -A job can also ship switched off with `'enabled' => 0` in the registry. The row is seeded disabled and stays that way until somebody turns it on in Settings > Cron. Use it for work an install should opt into rather than inherit silently from an upgrade — `backup` ships this way, because a full backup can be gigabytes a night. +A job can also ship switched off with `'enabled' => 0` in the registry. The row is seeded disabled and stays that way until somebody turns it on in Maintenance > Cron. Use it for work an install should opt into rather than inherit silently from an upgrade — `backup` ships this way, because a full backup can be gigabytes a night. + +### The master switch + +`config_enable_cron` is a second, coarser switch that sits above the per-job ones. It is **not** enforced by the dispatcher — every job checks it in its own header and stops itself with `cronJobStop()`. A new job has to make that check too; one that skips it keeps running on an install that believes cron is off, which is exactly the trap `ticket_email_parser` and `ticket_sla` sat in until 26.08. + +Be precise about what it does, because it is easy to oversell. It is **not** a guard on restored data: a full backup dumps the `settings` table, so a restored copy comes back with `config_enable_cron = 1` alongside every enabled `cron_jobs` row, exactly as production had them. What it gives you is one reversible bit — the fastest way to stop an install acting on live data once you have noticed, and the only way to stop everything without editing seven rows. + +That last part is the reason it is not redundant with `cron_job_enabled`. Turning the switch off and back on returns you to exactly the configuration you had. Sweeping all seven rows off and back on does not: `backup` ships `'enabled' => 0`, so the sweep quietly turns on nightly backups nobody asked for, along with anything else that was deliberately disabled. + +It defaults to `0`. That is a weaker guard than it looks — an install with no crontab entry runs nothing whatever the switch says, so the entry is the real gate — but it does mean adding the crontab line to a half-configured install is not enough on its own to start emailing. Both setup paths name the step on the way out. ## Backups -`functions/backup.php` is the whole engine, and all three entry points go through it: Settings > Backup, `cron/backup.php`, and `scripts/restore_cli.php`. Nothing else should dump, zip, or import a database. +`functions/backup.php` is the whole engine, and all three entry points go through it: Maintenance > Backup, `cron/backup.php`, and `scripts/restore_cli.php`. Nothing else should dump, zip, or import a database. Archives are AES-256 encrypted zips. The key is one value per install, generated on first use and appended to `config.php` — **never** the database and **never** the file name. That is the point: a backup that leaks cannot be opened with anything the backup itself contains, and a URL or an access log never carries the key. The 32 random characters in the file name are an unguessable path component, nothing more. Note that `unzip`, Windows Explorer and the macOS Archive Utility cannot read AES zips; 7-Zip, WinZip, PeaZip and Keka can. diff --git a/admin/backup.php b/admin/backup.php index 6740ab1f..574da3bb 100644 --- a/admin/backup.php +++ b/admin/backup.php @@ -13,7 +13,7 @@ $config_backup_retention_days = intval($row['config_backup_retention_days']); $config_backup_retention_count = intval($row['config_backup_retention_count']); $config_backup_cron_type = $row['config_backup_cron_type']; -// Same heartbeat rule as Settings > Cron - archives are built by the dispatcher, so a dead +// Same heartbeat rule as Maintenance > Cron - archives are built by the dispatcher, so a dead // crontab means the buttons below queue work that never runs $cron_is_running = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300; @@ -51,12 +51,12 @@ if (!empty($_SESSION['backup_master_key_reveal'])) {
Cron is not running
Backups are built by the cron dispatcher, not by your browser. Until cron is running, anything you - start here will sit in the queue. See Settings > Cron. + start here will sit in the queue. See Maintenance > Cron.
Cron is switched off in - Settings > Notifications. + Maintenance > Cron.
@@ -241,7 +241,7 @@ if (!empty($_SESSION['backup_master_key_reveal'])) { Scheduled backups are switched off. - Turn them on or change the time in Settings > Cron. + Turn them on or change the time in Maintenance > Cron.

Old backups are removed by the nightly job, never by the backup itself, so a failed nightly diff --git a/admin/cron.php b/admin/cron.php index f57a7536..4bf71af6 100644 --- a/admin/cron.php +++ b/admin/cron.php @@ -54,10 +54,25 @@ while ($job_row = mysqli_fetch_assoc($sql)) {

- Cron is switched off in - Settings > Notifications. The dispatcher is running, but most jobs - stop themselves immediately while this is off. +
+ + Turn cron on + +
+
Cron is switched off
+ The dispatcher is running, but every job below stops itself immediately while this is off - + no mail is sent, no email becomes a ticket, and nothing is invoiced.
+ +

+ + The master switch is on. Turning it off + stops every job at once without touching their schedules, which is what you want on a restored + backup or a staging clone - those come up with every job enabled and will otherwise email clients + and charge cards. Switching back on returns you to exactly this configuration. + Turn cron off. + +

diff --git a/admin/database_updates/2.6.1.php b/admin/database_updates/2.6.1.php index ebda3ead..767ed189 100644 --- a/admin/database_updates/2.6.1.php +++ b/admin/database_updates/2.6.1.php @@ -8,7 +8,7 @@ defined('FROM_DB_UPDATER') || die("Direct file access is not allowed"); // The cron dispatcher's schedule moves out of code and into the database so it can be - // managed from Settings > Cron. The registry in includes/cron_jobs.php still decides + // managed from Maintenance > Cron. The registry in includes/cron_jobs.php still decides // which scripts exist and seeds these columns the first time it meets a job; from then // on the row is what runs. Nothing here can name a script - a row whose job is not in // the registry is ignored. diff --git a/admin/database_updates/2.6.6.php b/admin/database_updates/2.6.6.php new file mode 100644 index 00000000..b27aa7a0 --- /dev/null +++ b/admin/database_updates/2.6.6.php @@ -0,0 +1,19 @@ + Notifications, so it will not start until that is enabled.", 'error'); + flashAlert(backupTypeLabel($type) . " queued, but cron is switched off in Maintenance > Cron, so it will not start until that is enabled.", 'error'); } else { flashAlert(backupTypeLabel($type) . " queued - it will start within a minute and you will be notified when it is ready."); } diff --git a/admin/post/cron.php b/admin/post/cron.php index 62cf2b6b..535b9384 100644 --- a/admin/post/cron.php +++ b/admin/post/cron.php @@ -3,11 +3,32 @@ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); /* - * Settings > Cron. Everything here identifies a job by its row, and every row is checked - * against the registry in includes/cron_jobs.php before anything is written - the database - * decides when and whether a job runs, never which file the dispatcher executes. + * Maintenance > Cron. Apart from the master switch, everything here identifies a job by its + * row, and every row is checked against the registry in includes/cron_jobs.php before + * anything is written - the database decides when and whether a job runs, never which file + * the dispatcher executes. */ +if (isset($_GET['enable_cron']) || isset($_GET['disable_cron'])) { + + validateCSRFToken(); + + // The master switch, config_enable_cron. Most jobs check it themselves and stop; it is + // not a dispatcher-level gate, so the two jobs that do not check it keep running. It + // lived in Settings > Notifications until 26.08, which is nowhere near anything else + // about cron. + $enabled = isset($_GET['enable_cron']) ? 1 : 0; + + mysqli_query($mysqli, "UPDATE settings SET config_enable_cron = $enabled WHERE company_id = 1"); + + logAudit("Cron", "Edit", "$session_name " . ($enabled ? 'enabled' : 'disabled') . " the master cron switch"); + + flashAlert("Cron " . ($enabled ? 'enabled' : 'disabled') . ".", $enabled ? 'success' : 'error'); + + redirect(); + +} + if (isset($_POST['edit_cron_job'])) { validateCSRFToken(); diff --git a/admin/post/settings_notification.php b/admin/post/settings_notification.php index 52543c07..52623ce9 100644 --- a/admin/post/settings_notification.php +++ b/admin/post/settings_notification.php @@ -6,13 +6,15 @@ if (isset($_POST['edit_notification_settings'])) { validateCSRFToken(); - $config_enable_cron = intval($_POST['config_enable_cron'] ?? 0); + // config_enable_cron is NOT set here - the master cron switch moved to Maintenance > Cron + // in 26.08. Leaving it in this UPDATE would switch cron off every time somebody saved + // this form, because the checkbox that fed it is gone from the page. $config_enable_alert_domain_expire = intval($_POST['config_enable_alert_domain_expire'] ?? 0); $config_send_invoice_reminders = intval($_POST['config_send_invoice_reminders'] ?? 0); $config_recurring_auto_send_invoice = intval($_POST['config_recurring_auto_send_invoice'] ?? 0); $config_ticket_client_general_notifications = intval($_POST['config_ticket_client_general_notifications'] ?? 0); - mysqli_query($mysqli,"UPDATE settings SET config_send_invoice_reminders = $config_send_invoice_reminders, config_recurring_auto_send_invoice = $config_recurring_auto_send_invoice, config_enable_cron = $config_enable_cron, config_enable_alert_domain_expire = $config_enable_alert_domain_expire, config_ticket_client_general_notifications = $config_ticket_client_general_notifications WHERE company_id = 1"); + mysqli_query($mysqli,"UPDATE settings SET config_send_invoice_reminders = $config_send_invoice_reminders, config_recurring_auto_send_invoice = $config_recurring_auto_send_invoice, config_enable_alert_domain_expire = $config_enable_alert_domain_expire, config_ticket_client_general_notifications = $config_ticket_client_general_notifications WHERE company_id = 1"); logAudit("Settings", "Edit", "$session_name edited notification settings"); diff --git a/admin/settings_notification.php b/admin/settings_notification.php index 8dc75fd5..21751275 100644 --- a/admin/settings_notification.php +++ b/admin/settings_notification.php @@ -12,12 +12,6 @@ require_once "includes/inc_all_admin.php";
-
-
- value="1" id="enableCronSwitch"> - -
-
@@ -50,32 +44,6 @@ require_once "includes/inc_all_admin.php"; - - - - - - - - - - - - - - @@ -135,39 +103,6 @@ require_once "includes/inc_all_admin.php"; - - - - - - - - - - - - - - - - - - - - -
-
Certificate Expiration Notice
- - (This setting triggers a notification when a certificate is approaching its expiration date, specifically at 1, 7 and 45 days prior to expiry.) - -
-
-
Asset Warranty Expiration Notice
- - (This setting triggers a notification when an asset is approaching its expiration date, specifically at 1, 7 and 45 days prior to expiry.) - -
-
Billing
-
Shared Item View
- (Notify when Shared items are viewed) -
-
-
Cron Execution
- (Notify when the nightly cron job ran) -
-
-
ITFlow Updates
- (Notify when ITFlow has an update) -
-
diff --git a/admin/settings_ticket.php b/admin/settings_ticket.php index 49fd8c87..66cb579a 100644 --- a/admin/settings_ticket.php +++ b/admin/settings_ticket.php @@ -33,7 +33,7 @@ require_once "includes/inc_all_admin.php";
value="1" id="emailToTicketParseSwitch"> - +
diff --git a/cron/backup.php b/cron/backup.php index 0bfd6c7f..a8112553 100644 --- a/cron/backup.php +++ b/cron/backup.php @@ -29,7 +29,7 @@ if ($config_enable_cron == 0) { } /* - * Anything an administrator started from Settings > Backup is built first. Those are + * Anything an administrator started from Maintenance > Backup is built first. Those are * explicit requests and somebody is waiting on the notification. */ $queued = backupRunQueued($mysqli); diff --git a/cron/cron.php b/cron/cron.php index 722aeaba..6fa7cb71 100644 --- a/cron/cron.php +++ b/cron/cron.php @@ -9,7 +9,7 @@ * * It wakes once a minute, works out which jobs are due, and runs them. The jobs themselves * are listed in includes/cron_jobs.php; when and whether each one runs is held in the - * cron_jobs table and edited from Settings > Cron. + * cron_jobs table and edited from Maintenance > Cron. * * WHAT THE JOBS INHERIT * @@ -72,7 +72,7 @@ function cronJobClaim($mysqli, array $job): bool $now = date('Y-m-d H:i:s'); // Register the job the first time it is seen, seeded with the schedule it ships with. - // From here on the row is what runs - Settings > Cron writes to it. + // From here on the row is what runs - Maintenance > Cron writes to it. $default_schedule = escapeSql($job['schedule']); $default_interval = intval($job['interval_minutes'] ?? 1); $default_daily_at = isset($job['daily_at']) ? "'" . escapeSql($job['daily_at']) . ":00'" : 'NULL'; @@ -144,7 +144,7 @@ function cronJobClaim($mysqli, array $job): bool /* * Record how a job ended. The status is the outcome of the run that just happened; the error * is sticky and survives later successes, because the run that failed is usually long gone by - * the time anyone goes looking. Settings > Cron clears it. + * the time anyone goes looking. Maintenance > Cron clears it. */ function cronJobFinished($mysqli, string $job_name, string $status, ?float $duration = null, ?string $error = null): void { @@ -179,7 +179,7 @@ function cronJobFinished($mysqli, string $job_name, string $status, ?float $dura } } -// Proof the crontab is firing, recorded before any job runs. Settings > Cron reads it to tell +// Proof the crontab is firing, recorded before any job runs. Maintenance > Cron reads it to tell // "no job was due" apart from "nothing has run this since the server was rebuilt". mysqli_query($mysqli, "UPDATE settings SET config_cron_last_dispatch_at = '" . date('Y-m-d H:i:s') . "' WHERE company_id = 1"); diff --git a/cron/nightly_tasks.php b/cron/nightly_tasks.php index 107895d9..997da02f 100644 --- a/cron/nightly_tasks.php +++ b/cron/nightly_tasks.php @@ -43,7 +43,6 @@ $company_currency = escapeSql($row['company_currency']); // Company Settings $config_enable_cron = intval($row['config_enable_cron']); -$config_invoice_overdue_reminders = $row['config_invoice_overdue_reminders']; $config_invoice_prefix = escapeSql($row['config_invoice_prefix']); $config_invoice_from_email = escapeSql($row['config_invoice_from_email']); $config_invoice_from_name = escapeSql($row['config_invoice_from_name']); @@ -539,7 +538,6 @@ while ($row = mysqli_fetch_assoc($sql_resolved_tickets_to_close)) { if ($config_send_invoice_reminders == 1) { // PAST DUE INVOICE Notifications - //$invoiceAlertArray = [$config_invoice_overdue_reminders]; $invoiceAlertArray = [1,30,60,90,120,150,180,210,240,270,300,330,360,390,420,450,480,510,540,570,590,620,650,680,710,740]; foreach ($invoiceAlertArray as $day) { diff --git a/cron/ticket_email_parser.php b/cron/ticket_email_parser.php index 84960609..95fc84aa 100644 --- a/cron/ticket_email_parser.php +++ b/cron/ticket_email_parser.php @@ -42,6 +42,12 @@ $row = mysqli_fetch_assoc($sql); $company_name = escapeSql($row['company_name']); $company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code'])); +// Check cron is enabled +if ($config_enable_cron == 0) { + logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - cron not enabled in admin settings."); + cronJobStop("Cron: is not enabled -- Quitting.."); +} + // Check setting enabled if ($config_ticket_email_parse == 0) { logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - not enabled in admin settings."); diff --git a/cron/ticket_sla.php b/cron/ticket_sla.php index ea115422..9e4472b7 100644 --- a/cron/ticket_sla.php +++ b/cron/ticket_sla.php @@ -34,6 +34,19 @@ require_once "../functions.php"; * breach until someone moves them back to a running status. */ +// Read the master switch here rather than trusting a global. Every job shares one process +// and one global scope, so a value an earlier job happened to leave behind is not this +// script's to rely on - see the cron rules in CONTRIBUTING.md. +$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1")); + +$config_enable_cron = intval($row['config_enable_cron']); + +// Check cron is enabled +if ($config_enable_cron == 0) { + logApp("Cron-Ticket-SLA", "error", "Cron Ticket SLA monitor unable to run - cron not enabled in admin settings."); + cronJobStop("Cron: is not enabled -- Quitting.."); +} + $sla_settings = getSlaSettings(); $warning_percent = intval($sla_settings['warning_percent']); diff --git a/db.sql b/db.sql index 4de0496f..c0bcebd7 100644 --- a/db.sql +++ b/db.sql @@ -2276,7 +2276,6 @@ CREATE TABLE `settings` ( `config_recurring_auto_send_invoice` tinyint(1) NOT NULL DEFAULT 1, `config_enable_alert_domain_expire` tinyint(1) NOT NULL DEFAULT 1, `config_send_invoice_reminders` tinyint(1) NOT NULL DEFAULT 1, - `config_invoice_overdue_reminders` varchar(200) DEFAULT NULL, `config_azure_client_id` varchar(200) DEFAULT NULL, `config_azure_client_secret` varchar(200) DEFAULT NULL, `config_module_enable_itdoc` tinyint(1) NOT NULL DEFAULT 1, @@ -3150,4 +3149,4 @@ CREATE TABLE `vendors` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-08-02 16:18:05 +-- Dump completed on 2026-08-02 17:22:01 diff --git a/functions/backup.php b/functions/backup.php index 0955339a..baa6d2df 100644 --- a/functions/backup.php +++ b/functions/backup.php @@ -58,7 +58,7 @@ function backupAppRoot(): string * The encryption key for this install. * * Lives in config.php only. Generated and appended on first use so nobody has to think - * about it, then displayed in Settings > Backup so it can be written down - without it a + * about it, then displayed in Maintenance > Backup so it can be written down - without it a * backup cannot be restored, on this server or any other. * * Returns an empty string if config.php could not be written, which every caller treats @@ -513,7 +513,7 @@ function backupSealArchive(array $entries, string $zip_path, string $key, ?strin * than a web request is allowed to live on most hosts - PHP-FPM's request_terminate_timeout * and the front end's read timeout both cut it off, and neither is affected by * set_time_limit() - so the button records the intent and cron/backup.php does the work - * within the minute. Same shape as the Run Now button on Settings > Cron. + * within the minute. Same shape as the Run Now button on Maintenance > Cron. */ function backupQueue(mysqli $mysqli, string $type, string $created_by, ?string &$error = null): int { diff --git a/includes/cron_jobs.php b/includes/cron_jobs.php index 088e721f..00aea2a3 100644 --- a/includes/cron_jobs.php +++ b/includes/cron_jobs.php @@ -7,7 +7,7 @@ * Adding a job is a script in cron/ and an entry here - the crontab never changes. * * Schedules here are DEFAULTS. They seed the job's row in the cron_jobs table the first - * time the dispatcher sees it, and from then on the row is what runs: Settings > Cron + * time the dispatcher sees it, and from then on the row is what runs: Maintenance > Cron * writes to it. Changing a default in this file therefore only affects installs that have * not met the job yet. * @@ -16,7 +16,7 @@ * so nothing that reaches the database can point the dispatcher at an arbitrary file. * * Loaded from both sides: cron/cron.php requires it on the command line under system cron, - * and Settings > Cron requires it in a web request. Nothing in here may touch $_SERVER, + * and Maintenance > Cron requires it in a web request. Nothing in here may touch $_SERVER, * $_SESSION or any other superglobal - there is no DOCUMENT_ROOT, no session and no request * when cron runs it. * @@ -24,13 +24,17 @@ * only cron loads: the admin pages would otherwise be reaching into the cron directory. * * 'enabled' => 0 ships a job switched off - the row is seeded disabled and stays that way - * until somebody turns it on in Settings > Cron. Used for work an install should opt into + * until somebody turns it on in Maintenance > Cron. Used for work an install should opt into * rather than inherit from an upgrade, like the backup job filling a disk overnight. * * 'interval_safe' => false marks a job whose work repeats if the day repeats - nightly's - * late fees and overdue reminders fire again on a second run of the same day. Settings > + * late fees and overdue reminders fire again on a second run of the same day. Maintenance > * Cron only offers the daily schedule for such a job, and the dispatcher refuses to run * one on an interval whatever its row says. + * + * Every job here checks config_enable_cron in its own header and stops itself when that + * switch is off. It is not a dispatcher-level gate - a new job has to make the check + * itself, and a job that skips it will keep running on an install that thinks cron is off. */ function cronJobRegistry(): array @@ -81,7 +85,7 @@ function cronJobRegistry(): array 'name' => 'backup', 'label' => 'Backup', 'script' => 'backup.php', - 'description' => 'Builds the scheduled backup and anything queued from Settings > Backup. Off by default.', + 'description' => 'Builds the scheduled backup and anything queued from Maintenance > Backup. Off by default.', 'schedule' => 'Daily', 'daily_at' => '02:00', 'enabled' => 0, diff --git a/includes/load_global_settings.php b/includes/load_global_settings.php index 13b8ebf5..6931145a 100644 --- a/includes/load_global_settings.php +++ b/includes/load_global_settings.php @@ -99,7 +99,6 @@ $config_enable_cron = intval($row['config_enable_cron']); $config_recurring_auto_send_invoice = intval($row['config_recurring_auto_send_invoice']); $config_enable_alert_domain_expire = intval($row['config_enable_alert_domain_expire']); $config_send_invoice_reminders = intval($row['config_send_invoice_reminders']); -$config_invoice_overdue_reminders = intval($row['config_invoice_overdue_reminders']); // Modules $config_module_enable_itdoc = intval($row['config_module_enable_itdoc']); diff --git a/scripts/restore_cli.php b/scripts/restore_cli.php index 59e00a83..a4ddc917 100644 --- a/scripts/restore_cli.php +++ b/scripts/restore_cli.php @@ -62,7 +62,7 @@ $key = $options['key'] ?? ($config_backup_key ?? ''); if ($key === '') { fwrite(STDERR, "No encryption key.\n\n"); fwrite(STDERR, "config.php has no \$config_backup_key, so pass the key from the install that made\n"); - fwrite(STDERR, "this backup with --key=... It is shown in Settings > Backup on that install.\n"); + fwrite(STDERR, "this backup with --key=... It is shown in Maintenance > Backup on that install.\n"); exit(1); } @@ -131,6 +131,6 @@ echo "\nNext steps:\n"; echo " 1. Log in with the credentials that were in use when the backup was taken.\n"; echo " 2. If the backup is older than the code in this directory, finish the database update:\n"; echo " php " . realpath(__DIR__) . "/update_cli.php --update_db\n"; -echo " 3. Check Settings > Cron - the schedule came back with the database.\n"; +echo " 3. Check Maintenance > Cron - the schedule came back with the database.\n"; exit(0); diff --git a/scripts/setup_cli.php b/scripts/setup_cli.php index a1fc8eb8..58c43ffb 100644 --- a/scripts/setup_cli.php +++ b/scripts/setup_cli.php @@ -282,7 +282,7 @@ mysqli_query($mysqli,"INSERT INTO companies SET company_name = '$company_name', // Insert default settings and categories $latest_database_version = LATEST_DATABASE_VERSION; -mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_invoice_overdue_reminders = '1,3,7', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'"); +mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'"); // Categories mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Office Supplies', category_type = 'Expense', category_color = 'blue'"); @@ -447,4 +447,15 @@ fclose($myfile); echo "\nSetup complete!\n"; echo "You can now log in with the user you created at: https://$base_url/login.php\n"; +// Whoever ran this is already at a shell on the right host, which makes this the one moment +// they can paste the crontab line without going and finding it. The web setup shows the same +// two steps on its finish page. +$itflow_path = realpath(__DIR__ . '/..'); + +echo "\nTwo things left before ITFlow can send mail, parse tickets or bill anyone:\n\n"; +echo " 1. Add this single entry to the crontab of the user that owns the ITFlow files:\n\n"; +echo " * * * * * php $itflow_path/cron/cron.php >/dev/null\n\n"; +echo " 2. Turn cron on in Maintenance > Cron. It ships off, and every job stops\n"; +echo " itself until it is enabled. Per-job schedules live on the same page.\n\n"; + exit(0); diff --git a/setup/index.php b/setup/index.php index 47a9a2c0..fd48fa91 100644 --- a/setup/index.php +++ b/setup/index.php @@ -204,7 +204,7 @@ if (isset($_POST['restore'])) { } if ($restore_key === '') { - $_SESSION['alert_message'] = "Enter the backup encryption key. It is shown in Settings > Backup on the install that made this archive."; + $_SESSION['alert_message'] = "Enter the backup encryption key. It is shown in Maintenance > Backup on the install that made this archive."; header("Location: ?restore"); exit; } @@ -376,7 +376,7 @@ if (isset($_POST['add_company_settings'])) { } $latest_database_version = LATEST_DATABASE_VERSION; - mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_invoice_overdue_reminders = '1,3,7', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'"); + mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'"); // Create Categories // Expense Categories Examples @@ -1154,7 +1154,7 @@ if (isset($_POST['add_telemetry'])) {
- Shown in Settings > Backup on that install. The archive cannot be opened without it. + Shown in Maintenance > Backup on that install. The archive cannot be opened without it.
@@ -1454,10 +1454,12 @@ if (isset($_POST['add_telemetry'])) {
  • Setup backups
  • Setup cron - ITFlow needs one entry, which runs - every job on the schedule set in Settings > Cron. Add it to the crontab of the user that + every job on the schedule set in Maintenance > Cron. Add it to the crontab of the user that owns the ITFlow files:
    /dev/null") ?>
    - *If installing via the script this is set up for you. + Then turn cron on in Maintenance > Cron - it ships off, and every job + stops itself until it is enabled. + *If installing via the script the crontab entry is set up for you.
  • Star ITFlow on Github :)