5), * which the admin configures freely. */ function getTicketStatusSlaLock($ticket_status_id) { $locked_statuses = [ 1 => 0, // New 2 => 0, // Open 3 => 1, // On Hold 4 => 1, // Resolved 5 => 1, // Closed ]; return $locked_statuses[intval($ticket_status_id)] ?? null; } function getTicketStatusName($ticket_status) { global $mysqli; $status_id = intval($ticket_status); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_status_name FROM ticket_statuses WHERE ticket_status_id = $status_id LIMIT 1")); if (!$row) { // Default return return "Unknown"; } return $row['ticket_status_name']; } /** * Copies a ticket template's tasks onto a ticket. * * Called anywhere a ticket is raised from a template - the ticket add modals, * the bulk asset add, and every recurring ticket run (cron, force, bulk force). * Tasks are a snapshot: editing the template later does not touch tickets that * have already been raised from it. * * @param int $ticket_id The ticket to attach the tasks to. * @param int $ticket_template_id The template to copy tasks from. 0 = no-op. * * @return int The number of tasks created. */ function addTasksFromTicketTemplate($ticket_id, $ticket_template_id) { global $mysqli; $ticket_id = intval($ticket_id); $ticket_template_id = intval($ticket_template_id); if (!$ticket_id || !$ticket_template_id) { return 0; } $sql_task_templates = mysqli_query($mysqli, "SELECT task_template_completion_estimate, task_template_name, task_template_order FROM task_templates WHERE task_template_ticket_template_id = $ticket_template_id ORDER BY task_template_order ASC"); $tasks_added = 0; while ($row = mysqli_fetch_assoc($sql_task_templates)) { $task_order = intval($row['task_template_order']); $task_name = escapeSql($row['task_template_name']); $task_completion_estimate = intval($row['task_template_completion_estimate']); mysqli_query($mysqli, "INSERT INTO tasks SET task_name = '$task_name', task_order = $task_order, task_completion_estimate = $task_completion_estimate, task_ticket_id = $ticket_id"); $tasks_added++; } return $tasks_added; } /** * Copies a recurring ticket's task list onto a ticket it has just raised. * * Recurring tickets own their tasks (see recurring_ticket_tasks) rather than * reading the linked ticket template at run time, so that a schedule's task * list can be edited without touching the template or any other schedule. * * @param int $ticket_id The ticket to attach the tasks to. * @param int $recurring_ticket_id The schedule to copy tasks from. 0 = no-op. * * @return void */ function addTasksFromRecurringTicket($ticket_id, $recurring_ticket_id) { global $mysqli; $ticket_id = intval($ticket_id); $recurring_ticket_id = intval($recurring_ticket_id); if (!$ticket_id || !$recurring_ticket_id) { return; } mysqli_query($mysqli, "INSERT INTO tasks (task_name, task_order, task_completion_estimate, task_ticket_id) SELECT recurring_ticket_task_name, recurring_ticket_task_order, recurring_ticket_task_completion_estimate, $ticket_id FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id ORDER BY recurring_ticket_task_order ASC"); } /** * Reads the editable task rows posted by the ticket and recurring ticket modals. * * The rows submit as parallel tasks[] and task_estimates[] arrays, aligned by * their order in the form. Rows left blank are dropped, and the order is taken * from the surviving rows rather than the raw array index. * * Names come back already escaped for SQL, as every caller inserts them. * * @return array List of ['name' => string, 'order' => int, 'estimate' => int] */ function parseSubmittedTasks() { $tasks = []; if (empty($_POST['tasks']) || !is_array($_POST['tasks'])) { return $tasks; } $estimates = $_POST['task_estimates'] ?? []; $task_order = 0; foreach ($_POST['tasks'] as $index => $task_name) { $task_name = trim($task_name); if ($task_name === '') { continue; } $tasks[] = [ 'name' => escapeSql($task_name), 'order' => $task_order, 'estimate' => intval($estimates[$index] ?? 0) ]; $task_order++; } return $tasks; } /* * Fetches one field from one row by id, and returns it RAW. * * Escaping is the caller's job, the same as any other value read out of the * database - wrap the call in escapeSql() for a query or escapeHtml() for * output. This function used to escape for you via an $escape_method argument, * which meant half its callers wrapped it in escapeSql() anyway and got a * double-escaped value: a client named O'Brien came back as O\'Brien and the * backslash ended up in export filenames, flash messages and, on the user * restore path, written back into the database. * * Table, field and id are still validated here - that is about building a safe * query, not about escaping what comes out of it. */ function getFieldById($table, $id, $field) { global $mysqli; // Use the global MySQLi connection // Validate table and field names to allow only letters, numbers, and underscores if (!preg_match('/^[a-zA-Z0-9_]+$/', $table) || !preg_match('/^[a-zA-Z0-9_]+$/', $field)) { return null; // Invalid table or field name } // Sanitize id as an integer $id = (int)$id; /* * Get the list of columns from the table, to find the primary key and to * confirm the requested field exists. * * The catch is what makes the "table not found" case actually return null: * mysqli throws on an unknown table by default on PHP 8.1+, so this * function's own not-found branch was unreachable and a bad table name * took the whole page down instead. */ try { $columns_result = mysqli_query($mysqli, "SHOW COLUMNS FROM `$table`"); } catch (mysqli_sql_exception $e) { return null; // Table not found } if (!$columns_result || mysqli_num_rows($columns_result) == 0) { return null; // Table not found or has no columns } $columns = []; $id_field = null; while ($row = mysqli_fetch_assoc($columns_result)) { $columns[$row['Field']] = true; if (!$id_field && $row['Key'] === 'PRI') { $id_field = $row['Field']; } } // Fallback: if no primary key is found, use the first column if (!$id_field) { reset($columns); $id_field = key($columns); } // Ensure the requested field exists; if not, default to the id field if (!array_key_exists($field, $columns)) { $field = $id_field; } // Build and execute the query to fetch the specified field value $sql = mysqli_query($mysqli, "SELECT `$field` FROM `$table` WHERE `$id_field` = $id"); if ($sql && mysqli_num_rows($sql) > 0) { $row = mysqli_fetch_assoc($sql); return $row[$field]; } return null; // Return null if no record was found } // Recursive function to display folder options - Used in folders files and documents function displayFolderOptions($parent_folder_id, $client_id, $indent = 0) { global $mysqli; $sql_folders = mysqli_query($mysqli, "SELECT folder_id, folder_name FROM folders WHERE parent_folder = $parent_folder_id AND folder_client_id = $client_id ORDER BY folder_name ASC"); while ($row = mysqli_fetch_assoc($sql_folders)) { $folder_id = intval($row['folder_id']); $folder_name = escapeHtml($row['folder_name']); // Indentation for subfolders $indentation = str_repeat(' ', $indent * 4); // Check if this folder is selected $selected = ''; if ((isset($_GET['folder_id']) && intval($_GET['folder_id']) === $folder_id) || (isset($_POST['folder']) && intval($_POST['folder']) === $folder_id)) { $selected = 'selected'; } echo ""; // Recursively display subfolders displayFolderOptions($folder_id, $client_id, $indent + 1); } } /* * The branch this install tracks. Both setup paths write $repo_branch into config.php, but * an install older than that has no value at all, and every caller puts it into a shell * command - so it is defaulted and escaped here rather than trusted at each call site. */ function getRepoBranch(): string { global $repo_branch; $branch = trim((string) ($repo_branch ?? '')); return $branch === '' ? 'master' : $branch; } /* * Reading git WITHOUT running git. * * Everything below answers from the files in .git, so it works on hosts where exec() and * shell_exec() are disabled and costs a couple of file reads instead of a process. Only * operations that need the network (git fetch) or the object database (a commit list) still * need the binary - see CONTRIBUTING rule 6. * * Returns '' rather than throwing on anything unexpected: a zip-drop install has no .git at * all, and every caller here is reporting, not deciding. */ function gitDir(): string { $root = dirname(__DIR__); $git = $root . '/.git'; if (is_dir($git)) { return $git; } // Submodules and linked worktrees put "gitdir: " in a file instead of a directory if (is_file($git)) { $line = trim((string) @file_get_contents($git)); if (str_starts_with($line, 'gitdir:')) { $path = trim(substr($line, 7)); if ($path !== '' && $path[0] !== '/') { $path = $root . '/' . $path; } if ($path !== '' && is_dir($path)) { return $path; } } } return ''; } /* * Where the refs live. A linked worktree keeps its own HEAD but shares the main repository's * refs, and points at them with a commondir file - resolve a ref against the worktree's own * directory and every lookup comes back empty. */ function gitCommonDir(): string { $dir = gitDir(); if ($dir === '') { return ''; } $commondir = $dir . '/commondir'; if (is_file($commondir)) { $path = trim((string) @file_get_contents($commondir)); if ($path !== '' && $path[0] !== '/') { $path = $dir . '/' . $path; } if ($path !== '' && is_dir($path)) { return rtrim($path, '/'); } } return $dir; } /* * The commit a ref points at, e.g. gitRefCommit('refs/remotes/origin/develop'). * * A ref is either its own file or a line in packed-refs; git writes loose files and moves * them into packed-refs when it tidies up, so both have to be read. Lines starting with ^ in * packed-refs are the peeled target of an annotated tag, not a ref. */ function gitRefCommit(string $ref, int $depth = 0): string { // The ref becomes part of a path, and $repo_branch reaches this from config.php if ($depth > 5 || str_contains($ref, '..') || !preg_match('#^[A-Za-z0-9._/-]+$#', $ref)) { return ''; } $dir = gitCommonDir(); if ($dir === '') { return ''; } $loose = $dir . '/' . $ref; if (is_file($loose)) { $value = trim((string) @file_get_contents($loose)); if (str_starts_with($value, 'ref: ')) { return gitRefCommit(trim(substr($value, 5)), $depth + 1); } return preg_match('/^[0-9a-f]{40}$/', $value) ? $value : ''; } $packed = $dir . '/packed-refs'; if (is_file($packed)) { foreach ((array) @file($packed, FILE_IGNORE_NEW_LINES) as $line) { if ($line === '' || $line[0] === '#' || $line[0] === '^') { continue; } $parts = explode(' ', $line, 2); if (count($parts) === 2 && trim($parts[1]) === $ref) { return preg_match('/^[0-9a-f]{40}$/', $parts[0]) ? $parts[0] : ''; } } } return ''; } /* The branch this working tree is on, or 'HEAD' when it is detached. */ function gitCurrentBranch(): string { $dir = gitDir(); if ($dir === '') { return ''; } $head = trim((string) @file_get_contents($dir . '/HEAD')); if (str_starts_with($head, 'ref: refs/heads/')) { return substr($head, 16); } return $head === '' ? '' : 'HEAD'; } /* The commit this working tree is on - the file-read equivalent of git rev-parse HEAD. */ function gitCurrentCommit(): string { $dir = gitDir(); if ($dir === '') { return ''; } $head = trim((string) @file_get_contents($dir . '/HEAD')); if (str_starts_with($head, 'ref: ')) { return gitRefCommit(trim(substr($head, 5))); } return preg_match('/^[0-9a-f]{40}$/', $head) ? $head : ''; } /* * Where a command lives, or '' if it is not on the path - what `which` was being run for. * * PHP-FPM pools often ship a nearly empty PATH, so the usual locations are checked as well; * a missing hit here would otherwise read as "git is not installed" on a box where it is. */ function commandPath(string $command): string { if (!preg_match('/^[A-Za-z0-9._-]+$/', $command)) { return ''; } $dirs = array_filter(explode(PATH_SEPARATOR, (string) getenv('PATH'))); foreach (['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'] as $fallback) { if (!in_array($fallback, $dirs, true)) { $dirs[] = $fallback; } } foreach ($dirs as $dir) { $candidate = rtrim($dir, '/') . '/' . $command; if (@is_file($candidate) && @is_executable($candidate)) { return $candidate; } } return ''; } /* * Whether this PHP can run external commands at all. Nothing in the web tier does any more - * checkForUpdates() is called from cron/update_check.php alone, and the Update page reads what * that job stored. This is what the job asks before it starts, and what admin/debug.php * reports, so a host with exec()/shell_exec() disabled shows why its checks stopped rather * than failing silently. * * function_exists() already reports a disabled function as missing. disable_functions is * read as well because some hardening extensions leave the function defined and refuse the * call instead, and a fatal on the Update page is a poor way to find that out. */ function shellCommandsAvailable(): bool { $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); foreach (['exec', 'shell_exec'] as $shell_function) { if (!function_exists($shell_function) || in_array($shell_function, $disabled, true)) { return false; } } return true; } /* * Whether a settings column exists yet. * * Maintenance > Update is the page that APPLIES database updates, so it has to keep working * against a schema older than the code it is running - the window between the files being * updated and the database catching up is exactly when somebody opens it. mysqli throws on * an unknown column, so a page that reads a column newer than the oldest schema it might * meet dies before it can render the button that fixes it. * * Anything else that runs before the migrations have caught up has the same problem, which * is why this takes the column name rather than answering one question. */ function settingsColumnExists($mysqli, string $column): bool { $column = escapeSql($column); $result = mysqli_query($mysqli, "SHOW COLUMNS FROM `settings` LIKE '$column'"); return $result && mysqli_num_rows($result) > 0; } function checkForUpdates() { $updates = new stdClass(); // Nothing here can run without a shell. Reported as a failed check rather than left to // fatal, because the nightly job calls this too and one host's php.ini must not take // the whole cron cycle down with it. if (!shellCommandsAvailable()) { $updates->output = ["PHP on this server cannot run external commands, so ITFlow cannot check for updates."]; $updates->result = 127; $updates->current_version = ''; $updates->latest_version = ''; $updates->update_message = "Cannot check for updates"; $updates->pending_commits = []; return $updates; } // Fetch the latest code changes but don't apply them. stderr is merged in because git // reports failures there, and it is the only thing the update page can show when this // breaks - it used to run a second git fetch of its own just to get the message. exec("git fetch 2>&1", $output, $result); // Both sides of the comparison are read out of .git rather than shelled for - the fetch // has already written the remote-tracking ref by the time we get here $latest_version = gitRefCommit("refs/remotes/origin/" . getRepoBranch()); $current_version = gitCurrentCommit(); /* * The commits between here and there. Fields are separated by \x1f rather than letting * git format the row itself, because a subject comes from outside this install and used * to reach the Update page as unescaped HTML. * * The date is %aI (absolute, ISO 8601) rather than %ar. This result is stored and read * back hours later, and a stored "2 hours ago" is wrong the moment it is written - the * relative form is worked out at render time instead. */ $updates->pending_commits = []; $remote_ref = escapeshellarg("origin/" . getRepoBranch()); foreach (explode("\n", trim((string) shell_exec("git log HEAD..$remote_ref --pretty=format:'%h%x1f%aI%x1f%s'"))) as $commit_line) { if ($commit_line === '') { continue; } $commit_fields = explode("\x1f", $commit_line, 3); if (count($commit_fields) === 3) { $updates->pending_commits[] = $commit_fields; } } if ($current_version == $latest_version) { $update_message = "No Updates available"; } else { $update_message = "New Updates are Available [$latest_version]"; } $updates->output = $output; $updates->result = $result; $updates->current_version = $current_version; $updates->latest_version = $latest_version; $updates->update_message = $update_message; return $updates; } function getMonthlyTax($tax_name, $month, $year, $mysqli) { // SQL to calculate monthly tax $sql = "SELECT SUM(item_tax) AS monthly_tax FROM invoice_items LEFT JOIN invoices ON invoice_items.item_invoice_id = invoices.invoice_id LEFT JOIN payments ON invoices.invoice_id = payments.payment_invoice_id WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) = $month AND invoice_items.item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name')"; $result = mysqli_query($mysqli, $sql); $row = mysqli_fetch_assoc($result); return $row['monthly_tax'] ?? 0; } function getQuarterlyTax($tax_name, $quarter, $year, $mysqli) { // Calculate start and end months for the quarter $start_month = ($quarter - 1) * 3 + 1; $end_month = $start_month + 2; // SQL to calculate quarterly tax $sql = "SELECT SUM(item_tax) AS quarterly_tax FROM invoice_items LEFT JOIN invoices ON invoice_items.item_invoice_id = invoices.invoice_id LEFT JOIN payments ON invoices.invoice_id = payments.payment_invoice_id WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) BETWEEN $start_month AND $end_month AND invoice_items.item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name')"; $result = mysqli_query($mysqli, $sql); $row = mysqli_fetch_assoc($result); return $row['quarterly_tax'] ?? 0; } function addToMailQueue($data) { global $mysqli; foreach ($data as $email) { $from = strval($email['from']); $from_name = strval($email['from_name']); $recipient = strval($email['recipient']); $recipient_name = strval($email['recipient_name']); $subject = strval($email['subject']); $body = strval($email['body']); $cal_str = ''; if (isset($email['cal_str'])) { $cal_str = mysqli_escape_string($mysqli, $email['cal_str']); } // Attachments travel as a manifest of app-root-relative paths rather than // file contents, so the queue table stays small. cron/mail_queue.php // re-checks each path is inside uploads/ before attaching it. $attachments = ''; if (!empty($email['attachments']) && is_array($email['attachments'])) { $attachment_manifest = []; foreach ($email['attachments'] as $attachment) { if (empty($attachment['path']) || empty($attachment['name'])) { continue; } $attachment_manifest[] = [ 'path' => strval($attachment['path']), 'name' => strval($attachment['name']) ]; } if ($attachment_manifest) { $attachments = mysqli_escape_string($mysqli, json_encode($attachment_manifest)); } } // Check if 'email_queued_at' is set and not empty if (isset($email['queued_at']) && !empty($email['queued_at'])) { $queued_at = "'" . escapeSql($email['queued_at']) . "'"; } else { // Use the current date and time if 'email_queued_at' is not set or empty $queued_at = 'CURRENT_TIMESTAMP()'; } mysqli_query($mysqli, "INSERT INTO email_queue SET email_recipient = '$recipient', email_recipient_name = '$recipient_name', email_from = '$from', email_from_name = '$from_name', email_subject = '$subject', email_content = '$body', email_queued_at = $queued_at, email_cal_str = '$cal_str', email_attachments = '$attachments'"); } return true; } function getTicketCalendarUid($ticket_id) { // An invite and its later cancellation MUST carry the same UID or the // recipient's calendar client cannot match them up. Derive it from the // ticket so it is stable across both, rather than from the current time. $ticket_id = intval($ticket_id); $host = $_SERVER['SERVER_NAME'] ?? 'itflow'; return "ticket-$ticket_id@$host"; } function createiCalStr($datetime, $title, $description, $location, $uid = null) { require_once "../libs/zapcal/zapcallib.php"; // Create the iCal object $cal_event = new ZCiCal(); $event = new ZCiCalNode("VEVENT", $cal_event->curnode); // Set the method to REQUEST to indicate an invite $event->addNode(new ZCiCalDataNode("METHOD:REQUEST")); $event->addNode(new ZCiCalDataNode("SUMMARY:" . $title)); $event->addNode(new ZCiCalDataNode("DTSTART:" . ZCiCal::fromSqlDateTime($datetime))); // Assuming the end time is the same as start time. // Todo: adjust this for actual duration $event->addNode(new ZCiCalDataNode("DTEND:" . ZCiCal::fromSqlDateTime($datetime))); $event->addNode(new ZCiCalDataNode("DTSTAMP:" . ZCiCal::fromSqlDateTime())); if (empty($uid)) { $uid = date('Y-m-d-H-i-s') . "@" . ($_SERVER['SERVER_NAME'] ?? 'itflow'); } $event->addNode(new ZCiCalDataNode("UID:" . $uid)); $event->addNode(new ZCiCalDataNode("SEQUENCE:0")); $event->addNode(new ZCiCalDataNode("LOCATION:" . $location)); $event->addNode(new ZCiCalDataNode("DESCRIPTION:" . $description)); // Todo: add organizer details // $event->addNode(new ZCiCalDataNode("ORGANIZER;CN=Organizer Name:MAILTO:organizer@example.com")); // Return the iCal string return $cal_event->export(); } function createiCalStrCancel($datetime, $title, $uid) { require_once "../libs/zapcal/zapcallib.php"; // Build the cancellation fresh. There is no stored copy of the original // invite to reopen - the match is made by UID, not by the body. $cal_event = new ZCiCal(); // METHOD belongs on the VCALENDAR, not on the VEVENT $cal_event->tree->data['METHOD'] = new ZCiCalDataNode("METHOD:CANCEL"); $event = new ZCiCalNode("VEVENT", $cal_event->curnode); $event->addNode(new ZCiCalDataNode("UID:" . $uid)); $event->addNode(new ZCiCalDataNode("SUMMARY:" . $title)); if (!empty($datetime)) { $event->addNode(new ZCiCalDataNode("DTSTART:" . ZCiCal::fromSqlDateTime($datetime))); $event->addNode(new ZCiCalDataNode("DTEND:" . ZCiCal::fromSqlDateTime($datetime))); } $event->addNode(new ZCiCalDataNode("DTSTAMP:" . ZCiCal::fromSqlDateTime())); // Must outrank the invite's SEQUENCE:0 or clients ignore the cancellation $event->addNode(new ZCiCalDataNode("SEQUENCE:1")); $event->addNode(new ZCiCalDataNode("STATUS:CANCELLED")); return $cal_event->export(); }