Normalize line endings to LF; add .gitattributes and .editorconfig

This commit is contained in:
johnnyq
2026-07-28 17:45:52 -04:00
parent 7f7880a4a7
commit c5ff3e2a3f
10 changed files with 5156 additions and 5032 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# Editor defaults for ITFlow - see CONTRIBUTING.md ("Style")
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
# Vendored - do not reformat
[libs/**]
indent_style = unset
indent_size = unset
trim_trailing_whitespace = false
insert_final_newline = false

44
.gitattributes vendored Normal file
View File

@@ -0,0 +1,44 @@
# ITFlow line-ending policy
#
# Everything ITFlow ships is LF in the repository and LF in the working tree.
# Contributors on Windows get LF too - this is deliberate. ITFlow is deployed
# to Linux/Apache and edited over sftp/ssh as often as it is cloned, so a
# checkout must be byte-identical everywhere.
* text=auto eol=lf
# Explicit for the file types we author, so nothing depends on git's guess.
*.php text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.sql text eol=lf
*.md text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.xsd text eol=lf
*.svg text eol=lf
*.txt text eol=lf
*.ini text eol=lf
.htaccess text eol=lf
# Binary assets: never touched, never diffed as text.
*.png binary
*.gif binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ico binary
*.icc binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.crt binary
*.ser binary
*.z binary
# Vendored third-party code is preserved byte-for-byte as shipped upstream.
# Per CONTRIBUTING.md libs/ is never edited in place - it is replaced wholesale -
# so normalizing it here would create spurious diffs on the next library update.
libs/** -text

View File

@@ -1,103 +1,103 @@
<?php <?php
require_once "../config.php"; require_once "../config.php";
require_once "../functions.php"; require_once "../functions.php";
require_once "../includes/check_login.php"; require_once "../includes/check_login.php";
$settings_mail_path = '/admin/settings_mail.php?tab=oauth'; $settings_mail_path = '/admin/settings_mail.php?tab=oauth';
if (!isset($session_is_admin) || !$session_is_admin) { if (!isset($session_is_admin) || !$session_is_admin) {
flashAlert("Admin access required.", 'error'); flashAlert("Admin access required.", 'error');
redirect($settings_mail_path); redirect($settings_mail_path);
} }
$state = escapeSql($_GET['state'] ?? ''); $state = escapeSql($_GET['state'] ?? '');
$code = $_GET['code'] ?? ''; $code = $_GET['code'] ?? '';
$error = escapeSql($_GET['error'] ?? ''); $error = escapeSql($_GET['error'] ?? '');
$error_description = escapeSql($_GET['error_description'] ?? ''); $error_description = escapeSql($_GET['error_description'] ?? '');
$session_state = $_SESSION['mail_oauth_state'] ?? ''; $session_state = $_SESSION['mail_oauth_state'] ?? '';
$session_state_expires = intval($_SESSION['mail_oauth_state_expires_at'] ?? 0); $session_state_expires = intval($_SESSION['mail_oauth_state_expires_at'] ?? 0);
unset($_SESSION['mail_oauth_state'], $_SESSION['mail_oauth_state_expires_at']); unset($_SESSION['mail_oauth_state'], $_SESSION['mail_oauth_state_expires_at']);
if (!empty($error)) { if (!empty($error)) {
$msg = "Microsoft OAuth authorization failed: $error"; $msg = "Microsoft OAuth authorization failed: $error";
if (!empty($error_description)) { if (!empty($error_description)) {
$msg .= " ($error_description)"; $msg .= " ($error_description)";
} }
flashAlert($msg, 'error'); flashAlert($msg, 'error');
redirect($settings_mail_path); redirect($settings_mail_path);
} }
if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) { if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) {
flashAlert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error'); flashAlert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error');
redirect($settings_mail_path); redirect($settings_mail_path);
} }
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) { if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
flashAlert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error'); flashAlert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error');
redirect($settings_mail_path); redirect($settings_mail_path);
} }
if (defined('BASE_URL') && !empty(BASE_URL)) { if (defined('BASE_URL') && !empty(BASE_URL)) {
$base_url = rtrim((string) BASE_URL, '/'); $base_url = rtrim((string) BASE_URL, '/');
} else { } else {
$base_url = 'https://' . rtrim((string) $config_base_url, '/'); $base_url = 'https://' . rtrim((string) $config_base_url, '/');
} }
$redirect_uri = $base_url . '/admin/oauth_microsoft_mail_callback.php'; $redirect_uri = $base_url . '/admin/oauth_microsoft_mail_callback.php';
$token_url = 'https://login.microsoftonline.com/' . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/token'; $token_url = 'https://login.microsoftonline.com/' . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/token';
$scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send'; $scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send';
$ch = curl_init($token_url); $ch = curl_init($token_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $config_mail_oauth_client_id, 'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret, 'client_secret' => $config_mail_oauth_client_secret,
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
'code' => $code, 'code' => $code,
'redirect_uri' => $redirect_uri, 'redirect_uri' => $redirect_uri,
'scope' => $scope, 'scope' => $scope,
], '', '&')); ], '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20); curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw_body = curl_exec($ch); $raw_body = curl_exec($ch);
$curl_err = curl_error($ch); $curl_err = curl_error($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
if ($raw_body === false || $http_code < 200 || $http_code >= 300) { if ($raw_body === false || $http_code < 200 || $http_code >= 300) {
$reason = !empty($curl_err) ? $curl_err : "HTTP $http_code"; $reason = !empty($curl_err) ? $curl_err : "HTTP $http_code";
flashAlert("Microsoft OAuth token exchange failed: $reason", 'error'); flashAlert("Microsoft OAuth token exchange failed: $reason", 'error');
redirect($settings_mail_path); redirect($settings_mail_path);
} }
$json = json_decode($raw_body, true); $json = json_decode($raw_body, true);
if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) { if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) {
flashAlert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error'); flashAlert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error');
redirect($settings_mail_path); redirect($settings_mail_path);
} }
$refresh_token = (string) $json['refresh_token']; $refresh_token = (string) $json['refresh_token'];
$access_token = (string) $json['access_token']; $access_token = (string) $json['access_token'];
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600)); $expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token); $refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token); $access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at); $expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
mysqli_query($mysqli, "UPDATE settings SET mysqli_query($mysqli, "UPDATE settings SET
config_imap_provider = 'microsoft_oauth', config_imap_provider = 'microsoft_oauth',
config_smtp_provider = 'microsoft_oauth', config_smtp_provider = 'microsoft_oauth',
config_mail_oauth_refresh_token = '$refresh_token_esc', config_mail_oauth_refresh_token = '$refresh_token_esc',
config_mail_oauth_access_token = '$access_token_esc', config_mail_oauth_access_token = '$access_token_esc',
config_mail_oauth_access_token_expires_at = '$expires_at_esc' config_mail_oauth_access_token_expires_at = '$expires_at_esc'
WHERE company_id = 1 WHERE company_id = 1
"); ");
logAudit("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings"); logAudit("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings");
flashAlert("Microsoft OAuth connected successfully. Token expires at $expires_at."); flashAlert("Microsoft OAuth connected successfully. Token expires at $expires_at.");
redirect($settings_mail_path); redirect($settings_mail_path);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,476 +1,476 @@
<?php <?php
// Set working directory to the directory this cron script lives at. // Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__)); chdir(dirname(__FILE__));
// Ensure we're running from command line // Ensure we're running from command line
if (php_sapi_name() !== 'cli') { if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n"); die("This script must be run from the command line.\n");
} }
// Prevent overlapping runs of this script // Prevent overlapping runs of this script
$cron_lock_script = __FILE__; $cron_lock_script = __FILE__;
require_once "../includes/cron_lock.php"; require_once "../includes/cron_lock.php";
require_once "../config.php"; require_once "../config.php";
require_once "../includes/inc_set_timezone.php"; require_once "../includes/inc_set_timezone.php";
require_once "../functions.php"; require_once "../functions.php";
require_once "../libs/vendor/autoload.php"; require_once "../libs/vendor/autoload.php";
// PHP Mailer Libs // PHP Mailer Libs
require_once "../libs/PHPMailer/src/Exception.php"; require_once "../libs/PHPMailer/src/Exception.php";
require_once "../libs/PHPMailer/src/PHPMailer.php"; require_once "../libs/PHPMailer/src/PHPMailer.php";
require_once "../libs/PHPMailer/src/SMTP.php"; require_once "../libs/PHPMailer/src/SMTP.php";
require_once "../libs/PHPMailer/src/OAuthTokenProvider.php"; require_once "../libs/PHPMailer/src/OAuthTokenProvider.php";
require_once "../libs/PHPMailer/src/OAuth.php"; require_once "../libs/PHPMailer/src/OAuth.php";
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception; use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\OAuthTokenProvider; use PHPMailer\PHPMailer\OAuthTokenProvider;
if (!defined('GOOGLE_OAUTH_TOKEN_URL')) { if (!defined('GOOGLE_OAUTH_TOKEN_URL')) {
define('GOOGLE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token'); define('GOOGLE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token');
} }
if (!defined('MICROSOFT_OAUTH_BASE_URL')) { if (!defined('MICROSOFT_OAUTH_BASE_URL')) {
define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/'); define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/');
} }
/** ======================================================================= /** =======================================================================
* XOAUTH2 Token Provider for PHPMailer (simple “static” provider) * XOAUTH2 Token Provider for PHPMailer (simple “static” provider)
* ======================================================================= */ * ======================================================================= */
class StaticTokenProvider implements OAuthTokenProvider { class StaticTokenProvider implements OAuthTokenProvider {
private string $email; private string $email;
private string $accessToken; private string $accessToken;
public function __construct(string $email, string $accessToken) { public function __construct(string $email, string $accessToken) {
$this->email = $email; $this->email = $email;
$this->accessToken = $accessToken; $this->accessToken = $accessToken;
} }
public function getOauth64(): string { public function getOauth64(): string {
$auth = "user={$this->email}\x01auth=Bearer {$this->accessToken}\x01\x01"; $auth = "user={$this->email}\x01auth=Bearer {$this->accessToken}\x01\x01";
return base64_encode($auth); return base64_encode($auth);
} }
} }
/** ======================================================================= /** =======================================================================
* Load settings * Load settings
* ======================================================================= */ * ======================================================================= */
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE company_id = 1"); $sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE company_id = 1");
$row = mysqli_fetch_assoc($sql_settings); $row = mysqli_fetch_assoc($sql_settings);
$config_enable_cron = intval($row['config_enable_cron']); $config_enable_cron = intval($row['config_enable_cron']);
// SMTP baseline // SMTP baseline
$config_smtp_host = $row['config_smtp_host']; $config_smtp_host = $row['config_smtp_host'];
$config_smtp_username = $row['config_smtp_username']; $config_smtp_username = $row['config_smtp_username'];
$config_smtp_password = $row['config_smtp_password']; $config_smtp_password = $row['config_smtp_password'];
$config_smtp_port = intval($row['config_smtp_port']); $config_smtp_port = intval($row['config_smtp_port']);
$config_smtp_encryption = $row['config_smtp_encryption']; $config_smtp_encryption = $row['config_smtp_encryption'];
// SMTP provider + shared OAuth fields // SMTP provider + shared OAuth fields
$config_smtp_provider = $row['config_smtp_provider']; // 'standard_smtp' | 'google_oauth' | 'microsoft_oauth' $config_smtp_provider = $row['config_smtp_provider']; // 'standard_smtp' | 'google_oauth' | 'microsoft_oauth'
$config_mail_oauth_client_id = $row['config_mail_oauth_client_id'] ?? ''; $config_mail_oauth_client_id = $row['config_mail_oauth_client_id'] ?? '';
$config_mail_oauth_client_secret = $row['config_mail_oauth_client_secret'] ?? ''; $config_mail_oauth_client_secret = $row['config_mail_oauth_client_secret'] ?? '';
$config_mail_oauth_tenant_id = $row['config_mail_oauth_tenant_id'] ?? ''; $config_mail_oauth_tenant_id = $row['config_mail_oauth_tenant_id'] ?? '';
$config_mail_oauth_refresh_token = $row['config_mail_oauth_refresh_token'] ?? ''; $config_mail_oauth_refresh_token = $row['config_mail_oauth_refresh_token'] ?? '';
$config_mail_oauth_access_token = $row['config_mail_oauth_access_token'] ?? ''; $config_mail_oauth_access_token = $row['config_mail_oauth_access_token'] ?? '';
$config_mail_oauth_access_token_expires_at = $row['config_mail_oauth_access_token_expires_at'] ?? ''; $config_mail_oauth_access_token_expires_at = $row['config_mail_oauth_access_token_expires_at'] ?? '';
if ($config_enable_cron == 0) { if ($config_enable_cron == 0) {
logApp("Cron-Mail-Queue", "error", "Cron Mail Queue unable to run - cron not enabled in admin settings."); logApp("Cron-Mail-Queue", "error", "Cron Mail Queue unable to run - cron not enabled in admin settings.");
exit("Cron: is not enabled -- Quitting.."); exit("Cron: is not enabled -- Quitting..");
} }
if (empty($config_smtp_provider)) { if (empty($config_smtp_provider)) {
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured."); logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
exit(0); exit(0);
} }
/** ======================================================================= /** =======================================================================
* Mail OAuth helpers + sender function * Mail OAuth helpers + sender function
* ======================================================================= */ * ======================================================================= */
function tokenIsExpired(?string $expires_at): bool { function tokenIsExpired(?string $expires_at): bool {
if (empty($expires_at)) { if (empty($expires_at)) {
return true; return true;
} }
$ts = strtotime($expires_at); $ts = strtotime($expires_at);
if ($ts === false) { if ($ts === false) {
return true; return true;
} }
return ($ts - 60) <= time(); return ($ts - 60) <= time();
} }
function httpFormPost(string $url, array $fields): array { function httpFormPost(string $url, array $fields): array {
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&')); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20); curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw = curl_exec($ch); $raw = curl_exec($ch);
$err = curl_error($ch); $err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
return [ return [
'ok' => ($raw !== false && $code >= 200 && $code < 300), 'ok' => ($raw !== false && $code >= 200 && $code < 300),
'body' => $raw, 'body' => $raw,
'code' => $code, 'code' => $code,
'err' => $err, 'err' => $err,
]; ];
} }
function persistMailOauthTokens(string $access_token, string $expires_at, ?string $refresh_token = null): void { function persistMailOauthTokens(string $access_token, string $expires_at, ?string $refresh_token = null): void {
global $mysqli; global $mysqli;
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token); $access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at); $expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
$refresh_sql = ''; $refresh_sql = '';
if (!empty($refresh_token)) { if (!empty($refresh_token)) {
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token); $refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
$refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'"; $refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'";
} }
mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$access_token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1"); mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$access_token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1");
} }
function refreshMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token): ?array { function refreshMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token): ?array {
$result = null; $result = null;
$response = null; $response = null;
if (!empty($oauth_client_id) && !empty($oauth_client_secret) && !empty($oauth_refresh_token)) { if (!empty($oauth_client_id) && !empty($oauth_client_secret) && !empty($oauth_refresh_token)) {
if ($provider === 'google_oauth') { if ($provider === 'google_oauth') {
$response = httpFormPost(GOOGLE_OAUTH_TOKEN_URL, [ $response = httpFormPost(GOOGLE_OAUTH_TOKEN_URL, [
'client_id' => $oauth_client_id, 'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret, 'client_secret' => $oauth_client_secret,
'refresh_token' => $oauth_refresh_token, 'refresh_token' => $oauth_refresh_token,
'grant_type' => 'refresh_token', 'grant_type' => 'refresh_token',
]); ]);
} elseif ($provider === 'microsoft_oauth' && !empty($oauth_tenant_id)) { } elseif ($provider === 'microsoft_oauth' && !empty($oauth_tenant_id)) {
$token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token"; $token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token";
$response = httpFormPost($token_url, [ $response = httpFormPost($token_url, [
'client_id' => $oauth_client_id, 'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret, 'client_secret' => $oauth_client_secret,
'refresh_token' => $oauth_refresh_token, 'refresh_token' => $oauth_refresh_token,
'grant_type' => 'refresh_token', 'grant_type' => 'refresh_token',
]); ]);
} }
} }
if (is_array($response) && !empty($response['ok'])) { if (is_array($response) && !empty($response['ok'])) {
$json = json_decode($response['body'], true); $json = json_decode($response['body'], true);
if (is_array($json) && !empty($json['access_token'])) { if (is_array($json) && !empty($json['access_token'])) {
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600)); $expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$result = [ $result = [
'access_token' => $json['access_token'], 'access_token' => $json['access_token'],
'expires_at' => $expires_at, 'expires_at' => $expires_at,
'refresh_token' => $json['refresh_token'] ?? null, 'refresh_token' => $json['refresh_token'] ?? null,
]; ];
} }
} }
return $result; return $result;
} }
function resolveMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token, string $oauth_access_token, string $oauth_access_token_expires_at): ?string { function resolveMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token, string $oauth_access_token, string $oauth_access_token_expires_at): ?string {
if (!empty($oauth_access_token) && !tokenIsExpired($oauth_access_token_expires_at)) { if (!empty($oauth_access_token) && !tokenIsExpired($oauth_access_token_expires_at)) {
return $oauth_access_token; return $oauth_access_token;
} }
$tokens = refreshMailOauthAccessToken($provider, $oauth_client_id, $oauth_client_secret, $oauth_tenant_id, $oauth_refresh_token); $tokens = refreshMailOauthAccessToken($provider, $oauth_client_id, $oauth_client_secret, $oauth_tenant_id, $oauth_refresh_token);
if (!is_array($tokens) || empty($tokens['access_token']) || empty($tokens['expires_at'])) { if (!is_array($tokens) || empty($tokens['access_token']) || empty($tokens['expires_at'])) {
return null; return null;
} }
persistMailOauthTokens($tokens['access_token'], $tokens['expires_at'], $tokens['refresh_token'] ?? null); persistMailOauthTokens($tokens['access_token'], $tokens['expires_at'], $tokens['refresh_token'] ?? null);
return $tokens['access_token']; return $tokens['access_token'];
} }
function sendQueueEmail( function sendQueueEmail(
string $provider, string $provider,
string $host, string $host,
int $port, int $port,
string $encryption, string $encryption,
string $username, string $username,
string $password, string $password,
string $from_email, string $from_email,
string $from_name, string $from_name,
string $to_email, string $to_email,
string $to_name, string $to_name,
string $subject, string $subject,
string $html_body, string $html_body,
string $ics_str, string $ics_str,
string $oauth_client_id, string $oauth_client_id,
string $oauth_client_secret, string $oauth_client_secret,
string $oauth_tenant_id, string $oauth_tenant_id,
string $oauth_refresh_token, string $oauth_refresh_token,
string $oauth_access_token, string $oauth_access_token,
string $oauth_access_token_expires_at string $oauth_access_token_expires_at
) { ) {
// Sensible defaults for OAuth providers if fields were left blank // Sensible defaults for OAuth providers if fields were left blank
if ($provider === 'google_oauth') { if ($provider === 'google_oauth') {
if (!$host) $host = 'smtp.gmail.com'; if (!$host) $host = 'smtp.gmail.com';
if (!$port) $port = 587; if (!$port) $port = 587;
if (!$encryption) $encryption = 'tls'; if (!$encryption) $encryption = 'tls';
if (!$username) $username = $from_email; if (!$username) $username = $from_email;
} elseif ($provider === 'microsoft_oauth') { } elseif ($provider === 'microsoft_oauth') {
if (!$host) $host = 'smtp.office365.com'; if (!$host) $host = 'smtp.office365.com';
if (!$port) $port = 587; if (!$port) $port = 587;
if (!$encryption) $encryption = 'tls'; if (!$encryption) $encryption = 'tls';
if (!$username) $username = $from_email; if (!$username) $username = $from_email;
} }
$mail = new PHPMailer(true); $mail = new PHPMailer(true);
$mail->CharSet = "UTF-8"; $mail->CharSet = "UTF-8";
$mail->SMTPDebug = 0; $mail->SMTPDebug = 0;
$mail->isSMTP(); $mail->isSMTP();
$mail->Host = $host; $mail->Host = $host;
$mail->Port = $port; $mail->Port = $port;
// Bound the SMTP conversation. Without this an unresponsive mail server can // Bound the SMTP conversation. Without this an unresponsive mail server can
// hold the cron lock open indefinitely and stall the whole queue. // hold the cron lock open indefinitely and stall the whole queue.
$mail->Timeout = 30; $mail->Timeout = 30;
$enc = strtolower($encryption); $enc = strtolower($encryption);
if ($enc === '' || $enc === 'none') { if ($enc === '' || $enc === 'none') {
$mail->SMTPAutoTLS = false; $mail->SMTPAutoTLS = false;
$mail->SMTPSecure = false; $mail->SMTPSecure = false;
$mail->SMTPOptions = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]; $mail->SMTPOptions = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];
} else { } else {
$mail->SMTPSecure = $enc; // 'tls' | 'ssl' $mail->SMTPSecure = $enc; // 'tls' | 'ssl'
} }
if ($provider === 'google_oauth' || $provider === 'microsoft_oauth') { if ($provider === 'google_oauth' || $provider === 'microsoft_oauth') {
// XOAUTH2 // XOAUTH2
$mail->SMTPAuth = true; $mail->SMTPAuth = true;
$mail->AuthType = 'XOAUTH2'; $mail->AuthType = 'XOAUTH2';
$mail->Username = $username; $mail->Username = $username;
$access_token = resolveMailOauthAccessToken( $access_token = resolveMailOauthAccessToken(
$provider, $provider,
trim($oauth_client_id), trim($oauth_client_id),
trim($oauth_client_secret), trim($oauth_client_secret),
trim($oauth_tenant_id), trim($oauth_tenant_id),
trim($oauth_refresh_token), trim($oauth_refresh_token),
trim($oauth_access_token), trim($oauth_access_token),
trim($oauth_access_token_expires_at) trim($oauth_access_token_expires_at)
); );
if (empty($access_token)) { if (empty($access_token)) {
throw new Exception("Missing OAuth access token for XOAUTH2 SMTP."); throw new Exception("Missing OAuth access token for XOAUTH2 SMTP.");
} }
$mail->setOAuth(new StaticTokenProvider($username, $access_token)); $mail->setOAuth(new StaticTokenProvider($username, $access_token));
} else { } else {
// Standard SMTP (with or without auth) // Standard SMTP (with or without auth)
$mail->SMTPAuth = !empty($username); $mail->SMTPAuth = !empty($username);
$mail->Username = $username ?: ''; $mail->Username = $username ?: '';
$mail->Password = $password ?: ''; $mail->Password = $password ?: '';
} }
// Recipients & content // Recipients & content
$mail->setFrom($from_email, $from_name); $mail->setFrom($from_email, $from_name);
$mail->addAddress($to_email, $to_name); $mail->addAddress($to_email, $to_name);
$mail->isHTML(true); $mail->isHTML(true);
$mail->Subject = $subject; $mail->Subject = $subject;
$mail->Body = $html_body; $mail->Body = $html_body;
if (!empty($ics_str)) { if (!empty($ics_str)) {
$mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar'); $mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar');
} }
$mail->send(); $mail->send();
return true; return true;
} }
/** ======================================================================= /** =======================================================================
* RECOVER: status = 1 (Sending) left behind by a run that died * RECOVER: status = 1 (Sending) left behind by a run that died
* *
* Nothing else in the codebase ever selects status 1, so without this a row * Nothing else in the codebase ever selects status 1, so without this a row
* claimed by a run that was killed mid-send stays 'Sending' forever and is never * claimed by a run that was killed mid-send stays 'Sending' forever and is never
* delivered. The cron lock above guarantees no other run of this script is in * delivered. The cron lock above guarantees no other run of this script is in
* progress, so any row still sitting at status 1 is by definition orphaned and * progress, so any row still sitting at status 1 is by definition orphaned and
* safe to reclaim. It is moved to failed rather than queued so it inherits the * safe to reclaim. It is moved to failed rather than queued so it inherits the
* retry pass's 30 minute backoff and attempt cap instead of retrying instantly. * retry pass's 30 minute backoff and attempt cap instead of retrying instantly.
* *
* This can re-send a message that did go out but died before being marked sent. * This can re-send a message that did go out but died before being marked sent.
* That trade is deliberate: a duplicate is recoverable, an invoice that silently * That trade is deliberate: a duplicate is recoverable, an invoice that silently
* never arrives is not. * never arrives is not.
* ======================================================================= */ * ======================================================================= */
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = email_attempts + 1 WHERE email_status = 1"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = email_attempts + 1 WHERE email_status = 1");
$orphaned_emails = mysqli_affected_rows($mysqli); $orphaned_emails = mysqli_affected_rows($mysqli);
if ($orphaned_emails > 0) { if ($orphaned_emails > 0) {
logApp("Cron-Mail-Queue", "warning", "Recovered $orphaned_emails email(s) left in a sending state by a previous run - queued for retry."); logApp("Cron-Mail-Queue", "warning", "Recovered $orphaned_emails email(s) left in a sending state by a previous run - queued for retry.");
} }
/** ======================================================================= /** =======================================================================
* SEND: status = 0 (Queued) * SEND: status = 0 (Queued)
* ======================================================================= */ * ======================================================================= */
$sql_queue = mysqli_query($mysqli, "SELECT * FROM email_queue WHERE email_status = 0 AND email_queued_at <= NOW()"); $sql_queue = mysqli_query($mysqli, "SELECT * FROM email_queue WHERE email_status = 0 AND email_queued_at <= NOW()");
if (mysqli_num_rows($sql_queue) > 0) { if (mysqli_num_rows($sql_queue) > 0) {
while ($rowq = mysqli_fetch_assoc($sql_queue)) { while ($rowq = mysqli_fetch_assoc($sql_queue)) {
$email_id = (int)$rowq['email_id']; $email_id = (int)$rowq['email_id'];
$email_from = $rowq['email_from']; $email_from = $rowq['email_from'];
$email_from_name = $rowq['email_from_name']; $email_from_name = $rowq['email_from_name'];
$email_recipient = $rowq['email_recipient']; $email_recipient = $rowq['email_recipient'];
$email_recipient_name = $rowq['email_recipient_name']; $email_recipient_name = $rowq['email_recipient_name'];
$email_subject = $rowq['email_subject']; $email_subject = $rowq['email_subject'];
$email_content = $rowq['email_content']; $email_content = $rowq['email_content'];
$email_ics_str = $rowq['email_cal_str']; $email_ics_str = $rowq['email_cal_str'];
// Check sender // Check sender
if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) {
$email_from_logging = escapeSql($rowq['email_from']); $email_from_logging = escapeSql($rowq['email_from']);
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
logApp("Cron-Mail-Queue", "Error", "Failed to send email #$email_id due to invalid sender address: $email_from_logging - check configuration in settings."); logApp("Cron-Mail-Queue", "Error", "Failed to send email #$email_id due to invalid sender address: $email_from_logging - check configuration in settings.");
appNotify("Mail", "Failed to send email #$email_id due to invalid sender address"); appNotify("Mail", "Failed to send email #$email_id due to invalid sender address");
continue; continue;
} }
// Claim the row - the conditional UPDATE is the lock. If another run already took // Claim the row - the conditional UPDATE is the lock. If another run already took
// this email, skip it rather than sending the client a second copy. // this email, skip it rather than sending the client a second copy.
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 0"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 0");
if (mysqli_affected_rows($mysqli) !== 1) { if (mysqli_affected_rows($mysqli) !== 1) {
continue; continue;
} }
// Basic recipient syntax check // Basic recipient syntax check
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
$email_to_logging = escapeSql($email_recipient); $email_to_logging = escapeSql($email_recipient);
$email_subject_logging = escapeSql($rowq['email_subject']); $email_subject_logging = escapeSql($rowq['email_subject']);
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient address. Email subject was: $email_subject_logging"); logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient address. Email subject was: $email_subject_logging");
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient address: Email subject was: $email_subject_logging"); appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient address: Email subject was: $email_subject_logging");
continue; continue;
} }
// More intelligent recipient MX check (if not disabled with --no-mx-validation) // More intelligent recipient MX check (if not disabled with --no-mx-validation)
$domain = escapeSql(substr($email_recipient, strpos($email_recipient, '@') + 1)); $domain = escapeSql(substr($email_recipient, strpos($email_recipient, '@') + 1));
if (!in_array('--no-mx-validation', $argv) && !checkdnsrr($domain, 'MX')) { if (!in_array('--no-mx-validation', $argv) && !checkdnsrr($domain, 'MX')) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
$email_to_logging = escapeSql($email_recipient); $email_to_logging = escapeSql($email_recipient);
$email_subject_logging = escapeSql($rowq['email_subject']); $email_subject_logging = escapeSql($rowq['email_subject']);
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient domain (no MX). Email subject was: $email_subject_logging"); logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient domain (no MX). Email subject was: $email_subject_logging");
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient domain (no MX): Email subject was: $email_subject_logging"); appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient domain (no MX): Email subject was: $email_subject_logging");
continue; continue;
} }
try { try {
sendQueueEmail( sendQueueEmail(
($config_smtp_provider ?: 'standard_smtp'), ($config_smtp_provider ?: 'standard_smtp'),
$config_smtp_host, $config_smtp_host,
(int)$config_smtp_port, (int)$config_smtp_port,
(string)$config_smtp_encryption, (string)$config_smtp_encryption,
(string)$config_smtp_username, (string)$config_smtp_username,
(string)$config_smtp_password, (string)$config_smtp_password,
(string)$email_from, (string)$email_from,
(string)$email_from_name, (string)$email_from_name,
(string)$email_recipient, (string)$email_recipient,
(string)$email_recipient_name, (string)$email_recipient_name,
(string)$email_subject, (string)$email_subject,
(string)$email_content, (string)$email_content,
(string)$email_ics_str, (string)$email_ics_str,
(string)$config_mail_oauth_client_id, (string)$config_mail_oauth_client_id,
(string)$config_mail_oauth_client_secret, (string)$config_mail_oauth_client_secret,
(string)$config_mail_oauth_tenant_id, (string)$config_mail_oauth_tenant_id,
(string)$config_mail_oauth_refresh_token, (string)$config_mail_oauth_refresh_token,
(string)$config_mail_oauth_access_token, (string)$config_mail_oauth_access_token,
(string)$config_mail_oauth_access_token_expires_at (string)$config_mail_oauth_access_token_expires_at
); );
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords // Scrub the body on delivery - it can carry share decryption keys and temporary passwords
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '' WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '' WHERE email_id = $email_id");
} catch (Exception $e) { } catch (Exception $e) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = 1 WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = 1 WHERE email_id = $email_id");
$email_recipient_logging = escapeSql($rowq['email_recipient']); $email_recipient_logging = escapeSql($rowq['email_recipient']);
$email_subject_logging = escapeSql($rowq['email_subject']); $email_subject_logging = escapeSql($rowq['email_subject']);
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "..."; $err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
appNotify("Cron-Mail-Queue", "Failed to send email #$email_id to $email_recipient_logging"); appNotify("Cron-Mail-Queue", "Failed to send email #$email_id to $email_recipient_logging");
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_recipient_logging regarding $email_subject_logging. $err"); logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_recipient_logging regarding $email_subject_logging. $err");
} }
} }
} }
/** ======================================================================= /** =======================================================================
* RETRIES: status = 2 (Failed), attempts < 4, wait 30 min * RETRIES: status = 2 (Failed), attempts < 4, wait 30 min
* NOTE: Backoff is `email_failed_at <= NOW() - INTERVAL 30 MINUTE` * NOTE: Backoff is `email_failed_at <= NOW() - INTERVAL 30 MINUTE`
* ======================================================================= * =======================================================================
*/ */
$sql_failed_queue = mysqli_query( $sql_failed_queue = mysqli_query(
$mysqli, $mysqli,
"SELECT * FROM email_queue "SELECT * FROM email_queue
WHERE email_status = 2 WHERE email_status = 2
AND email_attempts < 4 AND email_attempts < 4
AND email_failed_at <= NOW() - INTERVAL 30 MINUTE" AND email_failed_at <= NOW() - INTERVAL 30 MINUTE"
); );
if (mysqli_num_rows($sql_failed_queue) > 0) { if (mysqli_num_rows($sql_failed_queue) > 0) {
while ($rowf = mysqli_fetch_assoc($sql_failed_queue)) { while ($rowf = mysqli_fetch_assoc($sql_failed_queue)) {
$email_id = (int)$rowf['email_id']; $email_id = (int)$rowf['email_id'];
$email_from = $rowf['email_from']; $email_from = $rowf['email_from'];
$email_from_name = $rowf['email_from_name']; $email_from_name = $rowf['email_from_name'];
$email_recipient = $rowf['email_recipient']; $email_recipient = $rowf['email_recipient'];
$email_recipient_name = $rowf['email_recipient_name']; $email_recipient_name = $rowf['email_recipient_name'];
$email_subject = $rowf['email_subject']; $email_subject = $rowf['email_subject'];
$email_content = $rowf['email_content']; $email_content = $rowf['email_content'];
$email_ics_str = $rowf['email_cal_str']; $email_ics_str = $rowf['email_cal_str'];
$email_attempts = (int)$rowf['email_attempts'] + 1; $email_attempts = (int)$rowf['email_attempts'] + 1;
// Claim the row - same lock as the send path, from the failed state this time. // Claim the row - same lock as the send path, from the failed state this time.
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 2"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 2");
if (mysqli_affected_rows($mysqli) !== 1) { if (mysqli_affected_rows($mysqli) !== 1) {
continue; continue;
} }
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = $email_attempts WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = $email_attempts WHERE email_id = $email_id");
continue; continue;
} }
try { try {
sendQueueEmail( sendQueueEmail(
($config_smtp_provider ?: 'standard_smtp'), ($config_smtp_provider ?: 'standard_smtp'),
$config_smtp_host, $config_smtp_host,
(int)$config_smtp_port, (int)$config_smtp_port,
(string)$config_smtp_encryption, (string)$config_smtp_encryption,
(string)$config_smtp_username, (string)$config_smtp_username,
(string)$config_smtp_password, (string)$config_smtp_password,
(string)$email_from, (string)$email_from,
(string)$email_from_name, (string)$email_from_name,
(string)$email_recipient, (string)$email_recipient,
(string)$email_recipient_name, (string)$email_recipient_name,
(string)$email_subject, (string)$email_subject,
(string)$email_content, (string)$email_content,
(string)$email_ics_str, (string)$email_ics_str,
(string)$config_mail_oauth_client_id, (string)$config_mail_oauth_client_id,
(string)$config_mail_oauth_client_secret, (string)$config_mail_oauth_client_secret,
(string)$config_mail_oauth_tenant_id, (string)$config_mail_oauth_tenant_id,
(string)$config_mail_oauth_refresh_token, (string)$config_mail_oauth_refresh_token,
(string)$config_mail_oauth_access_token, (string)$config_mail_oauth_access_token,
(string)$config_mail_oauth_access_token_expires_at (string)$config_mail_oauth_access_token_expires_at
); );
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords // Scrub the body on delivery - it can carry share decryption keys and temporary passwords
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '' WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '' WHERE email_id = $email_id");
} catch (Exception $e) { } catch (Exception $e) {
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = $email_attempts WHERE email_id = $email_id"); mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = $email_attempts WHERE email_id = $email_id");
$email_recipient_logging = escapeSql($rowf['email_recipient']); $email_recipient_logging = escapeSql($rowf['email_recipient']);
$email_subject_logging = escapeSql($rowf['email_subject']); $email_subject_logging = escapeSql($rowf['email_subject']);
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "..."; $err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
logApp("Cron-Mail-Queue", "Error", "Failed to re-send email #$email_id to $email_recipient_logging regarding $email_subject_logging. $err"); logApp("Cron-Mail-Queue", "Error", "Failed to re-send email #$email_id to $email_recipient_logging regarding $email_subject_logging. $err");
} }
} }
} }

824
js/app.js
View File

@@ -1,412 +1,412 @@
$(document).ready(function() { $(document).ready(function() {
// Prevents resubmit on forms // Prevents resubmit on forms
if (window.history.replaceState) { if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href); window.history.replaceState(null, null, window.location.href);
} }
// Slide alert up after 4 secs // Slide alert up after 4 secs
$("#alert").fadeTo(5000, 500).slideUp(500, function() { $("#alert").fadeTo(5000, 500).slideUp(500, function() {
$("#alert").slideUp(500); $("#alert").slideUp(500);
}); });
// Initialize Select2 Elements // Initialize Select2 Elements
$('.select2').select2({ $('.select2').select2({
theme: 'bootstrap4', theme: 'bootstrap4',
}); });
// Initialize TinyMCE // Initialize TinyMCE
tinymce.init({ tinymce.init({
selector: '.tinymce-simple', selector: '.tinymce-simple',
browser_spellcheck: true, browser_spellcheck: true,
contextmenu: false, contextmenu: false,
resize: true, resize: true,
min_height: 300, min_height: 300,
max_height: 600, max_height: 600,
promotion: false, promotion: false,
branding: false, branding: false,
menubar: false, menubar: false,
statusbar: false, statusbar: false,
toolbar: [ toolbar: [
{ name: 'styles', items: ['styles'] }, { name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] }, { name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] }, { name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] }, { name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] }, { name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] }, { name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] }, { name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] } { name: 'extra', items: ['code', 'fullscreen'] }
], ],
mobile: { mobile: {
menubar: false, menubar: false,
plugins: 'autosave lists autolink', plugins: 'autosave lists autolink',
toolbar: 'bold italic styles' toolbar: 'bold italic styles'
}, },
convert_urls: false, convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize', plugins: 'link image lists table code codesample fullscreen autoresize',
setup: function (editor) { setup: function (editor) {
editor.on('init', function() { editor.on('init', function() {
window.onbeforeunload = function() { window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn // If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show'); const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) { if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?"; return "You have unsaved changes. Are you sure you want to leave?";
} }
}; };
// When the modal closes, mark editor clean // When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal'); const modal = editor.getContainer()?.closest('.modal');
if (modal) { if (modal) {
modal.addEventListener('hidden.bs.modal', () => { modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear(); editor.undoManager.clear();
editor.setDirty(false); editor.setDirty(false);
}); });
} }
}); });
}, },
license_key: 'gpl' license_key: 'gpl'
}); });
// Initialize TinyMCE with AI // Initialize TinyMCE with AI
tinymce.init({ tinymce.init({
selector: '.tinymce', selector: '.tinymce',
browser_spellcheck: true, browser_spellcheck: true,
contextmenu: false, contextmenu: false,
resize: true, resize: true,
min_height: 300, min_height: 300,
max_height: 600, max_height: 600,
promotion: false, promotion: false,
branding: false, branding: false,
menubar: false, menubar: false,
statusbar: false, statusbar: false,
toolbar: [ toolbar: [
{ name: 'styles', items: ['styles'] }, { name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] }, { name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] }, { name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] }, { name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] }, { name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] }, { name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] }, { name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] }, { name: 'extra', items: ['code', 'fullscreen'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] } { name: 'ai', items: ['reword', 'undo', 'redo'] }
], ],
mobile: { mobile: {
menubar: false, menubar: false,
plugins: 'autosave lists autolink', plugins: 'autosave lists autolink',
toolbar: 'bold italic styles' toolbar: 'bold italic styles'
}, },
convert_urls: false, convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize', plugins: 'link image lists table code codesample fullscreen autoresize',
license_key: 'gpl', license_key: 'gpl',
setup: function(editor) { setup: function(editor) {
editor.on('init', function() { editor.on('init', function() {
window.onbeforeunload = function() { window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn // If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show'); const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) { if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?"; return "You have unsaved changes. Are you sure you want to leave?";
} }
}; };
// When the modal closes, mark editor clean // When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal'); const modal = editor.getContainer()?.closest('.modal');
if (modal) { if (modal) {
modal.addEventListener('hidden.bs.modal', () => { modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear(); editor.undoManager.clear();
editor.setDirty(false); editor.setDirty(false);
}); });
} }
}); });
var rewordButtonApi; var rewordButtonApi;
editor.ui.registry.addButton('reword', { editor.ui.registry.addButton('reword', {
icon: 'ai', icon: 'ai',
tooltip: 'Reword Text', tooltip: 'Reword Text',
onAction: function() { onAction: function() {
var content = editor.getContent(); var content = editor.getContent();
// Disable the Reword button // Disable the Reword button
rewordButtonApi.setEnabled(false); rewordButtonApi.setEnabled(false);
// Show the progress indicator // Show the progress indicator
editor.setProgressState(true); editor.setProgressState(true);
fetch('ajax.php?ai_reword', { fetch('ajax.php?ai_reword', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ text: content }), body: JSON.stringify({ text: content }),
}) })
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok'); throw new Error('Network response was not ok');
} }
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
editor.undoManager.transact(function() { editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.'); editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
}); });
editor.setProgressState(false); editor.setProgressState(false);
rewordButtonApi.setEnabled(true); rewordButtonApi.setEnabled(true);
editor.notificationManager.open({ editor.notificationManager.open({
text: 'Text reworded successfully!', text: 'Text reworded successfully!',
type: 'success', type: 'success',
timeout: 3000 timeout: 3000
}); });
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
editor.setProgressState(false); editor.setProgressState(false);
rewordButtonApi.setEnabled(true); rewordButtonApi.setEnabled(true);
editor.notificationManager.open({ editor.notificationManager.open({
text: 'An error occurred while rewording the text.', text: 'An error occurred while rewording the text.',
type: 'error', type: 'error',
timeout: 5000 timeout: 5000
}); });
}); });
}, },
onSetup: function(buttonApi) { onSetup: function(buttonApi) {
rewordButtonApi = buttonApi; rewordButtonApi = buttonApi;
return function() {}; return function() {};
} }
}); });
} }
}); });
// Initialize TinyMCE AI for Tickets // Initialize TinyMCE AI for Tickets
tinymce.init({ tinymce.init({
selector: '.tinymceTicket', selector: '.tinymceTicket',
browser_spellcheck: true, browser_spellcheck: true,
contextmenu: false, contextmenu: false,
resize: true, resize: true,
min_height: 200, min_height: 200,
max_height: 600, max_height: 600,
promotion: false, promotion: false,
branding: false, branding: false,
menubar: false, menubar: false,
statusbar: false, statusbar: false,
toolbar: [ toolbar: [
{ name: 'styles', items: ['styles'] }, { name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] }, { name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] }, { name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] }, { name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] }, { name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] }, { name: 'ai', items: ['reword', 'undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] }, { name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] }, { name: 'code', items: ['code'] },
], ],
mobile: { mobile: {
menubar: false, menubar: false,
toolbar: [ toolbar: [
{ name: 'styles', items: ['styles'] }, { name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] }, { name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] }, { name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] }, { name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] }, { name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'ai', items: ['reword', 'undo', 'redo'] }, { name: 'ai', items: ['reword', 'undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] }, { name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] }, { name: 'code', items: ['code'] },
], ],
}, },
convert_urls: false, convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize code', plugins: 'link image lists table code codesample fullscreen autoresize code',
license_key: 'gpl', license_key: 'gpl',
setup: function(editor) { setup: function(editor) {
editor.on('init', function() { editor.on('init', function() {
window.onbeforeunload = function() { window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn // If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show'); const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) { if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?"; return "You have unsaved changes. Are you sure you want to leave?";
} }
}; };
// When the modal closes, mark editor clean // When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal'); const modal = editor.getContainer()?.closest('.modal');
if (modal) { if (modal) {
modal.addEventListener('hidden.bs.modal', () => { modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear(); editor.undoManager.clear();
editor.setDirty(false); editor.setDirty(false);
}); });
} }
}); });
var rewordButtonApi; var rewordButtonApi;
editor.ui.registry.addButton('reword', { editor.ui.registry.addButton('reword', {
icon: 'ai', icon: 'ai',
tooltip: 'Reword Text', tooltip: 'Reword Text',
onAction: function() { onAction: function() {
var content = editor.getContent(); var content = editor.getContent();
rewordButtonApi.setEnabled(false); rewordButtonApi.setEnabled(false);
editor.setProgressState(true); editor.setProgressState(true);
fetch('ajax.php?ai_reword', { fetch('ajax.php?ai_reword', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }), body: JSON.stringify({ text: content }),
}) })
.then(response => { .then(response => {
if (!response.ok) throw new Error('Network response was not ok'); if (!response.ok) throw new Error('Network response was not ok');
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
editor.undoManager.transact(function() { editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.'); editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
}); });
editor.setProgressState(false); editor.setProgressState(false);
rewordButtonApi.setEnabled(true); rewordButtonApi.setEnabled(true);
editor.notificationManager.open({ editor.notificationManager.open({
text: 'Text reworded successfully!', text: 'Text reworded successfully!',
type: 'success', type: 'success',
timeout: 3000 timeout: 3000
}); });
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
editor.setProgressState(false); editor.setProgressState(false);
rewordButtonApi.setEnabled(true); rewordButtonApi.setEnabled(true);
editor.notificationManager.open({ editor.notificationManager.open({
text: 'An error occurred while rewording the text.', text: 'An error occurred while rewording the text.',
type: 'error', type: 'error',
timeout: 5000 timeout: 5000
}); });
}); });
}, },
onSetup: function(buttonApi) { onSetup: function(buttonApi) {
rewordButtonApi = buttonApi; rewordButtonApi = buttonApi;
return function() {}; return function() {};
} }
}); });
editor.ui.registry.addButton('redactButton', { editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen', icon: 'permanent-pen',
tooltip: 'Redact Text', tooltip: 'Redact Text',
onAction: function() { onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' }); var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) { if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>'; var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent); editor.selection.setContent(newContent);
} else { } else {
alert('Please select a word to redact'); alert('Please select a word to redact');
} }
} }
}); });
} }
}); });
// Initialize TinyMCE Redact-only // Initialize TinyMCE Redact-only
tinymce.init({ tinymce.init({
selector: '.tinymceRedact', selector: '.tinymceRedact',
browser_spellcheck: true, browser_spellcheck: true,
contextmenu: false, contextmenu: false,
resize: true, resize: true,
min_height: 300, min_height: 300,
max_height: 600, max_height: 600,
promotion: false, promotion: false,
branding: false, branding: false,
menubar: false, menubar: false,
statusbar: false, statusbar: false,
toolbar: 'redactButton', toolbar: 'redactButton',
mobile: { mobile: {
menubar: false, menubar: false,
plugins: 'autosave lists autolink', plugins: 'autosave lists autolink',
toolbar: 'redactButton' toolbar: 'redactButton'
}, },
convert_urls: false, convert_urls: false,
plugins: 'link image lists table code fullscreen autoresize', plugins: 'link image lists table code fullscreen autoresize',
license_key: 'gpl', license_key: 'gpl',
setup: function(editor) { setup: function(editor) {
editor.on('init', function() { editor.on('init', function() {
window.onbeforeunload = function() { window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn // If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show'); const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) { if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?"; return "You have unsaved changes. Are you sure you want to leave?";
} }
}; };
// When the modal closes, mark editor clean // When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal'); const modal = editor.getContainer()?.closest('.modal');
if (modal) { if (modal) {
modal.addEventListener('hidden.bs.modal', () => { modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear(); editor.undoManager.clear();
editor.setDirty(false); editor.setDirty(false);
}); });
} }
}); });
editor.on('keydown', function(e) { editor.on('keydown', function(e) {
e.preventDefault(); e.preventDefault();
}); });
editor.ui.registry.addButton('redactButton', { editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen', icon: 'permanent-pen',
tooltip: 'Redact', tooltip: 'Redact',
text: 'REDACT', text: 'REDACT',
onAction: function() { onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' }); var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) { if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>'; var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent); editor.selection.setContent(newContent);
} else { } else {
alert('Please select a word to redact'); alert('Please select a word to redact');
} }
} }
}); });
} }
}); });
// DateTime // DateTime
$('.datetimepicker').datetimepicker(); $('.datetimepicker').datetimepicker();
// Data Input Mask // Data Input Mask
$('[data-mask]').inputmask(); $('[data-mask]').inputmask();
// ClipboardJS fix for Bootstrap modals // ClipboardJS fix for Bootstrap modals
$.fn.modal.Constructor.prototype._enforceFocus = function() {}; $.fn.modal.Constructor.prototype._enforceFocus = function() {};
// Tooltip // Tooltip
$('button').tooltip({ $('button').tooltip({
trigger: 'click', trigger: 'click',
placement: 'bottom' placement: 'bottom'
}); });
function setTooltip(btn, message) { function setTooltip(btn, message) {
$(btn).tooltip('hide') $(btn).tooltip('hide')
.attr('data-original-title', message) .attr('data-original-title', message)
.tooltip('show'); .tooltip('show');
} }
function hideTooltip(btn) { function hideTooltip(btn) {
setTimeout(function() { setTimeout(function() {
$(btn).tooltip('hide'); $(btn).tooltip('hide');
}, 1000); }, 1000);
} }
// Clipboard // Clipboard
var clipboard = new ClipboardJS('.clipboardjs'); var clipboard = new ClipboardJS('.clipboardjs');
clipboard.on('success', function(e) { clipboard.on('success', function(e) {
setTooltip(e.trigger, 'Copied!'); setTooltip(e.trigger, 'Copied!');
hideTooltip(e.trigger); hideTooltip(e.trigger);
}); });
clipboard.on('error', function(e) { clipboard.on('error', function(e) {
setTooltip(e.trigger, 'Failed!'); setTooltip(e.trigger, 'Failed!');
hideTooltip(e.trigger); hideTooltip(e.trigger);
}); });
// Enable Popovers // Enable Popovers
$(function() { $(function() {
$('[data-toggle="popover"]').popover(); $('[data-toggle="popover"]').popover();
}); });
// Data Tables // Data Tables
new DataTable('.dataTables'); new DataTable('.dataTables');
}); });

30
normalize_eol.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# ITFlow - one-shot CRLF -> LF normalization.
#
# Converts every tracked text file outside libs/ to LF. Vendored libraries are
# left byte-for-byte as shipped upstream (CONTRIBUTING.md: libs/ is replaced
# wholesale, never edited), and binary assets are skipped outright.
#
# Run once, from the repo root, alongside adding .gitattributes. After that
# .gitattributes keeps new files in line and this script should be a no-op.
#
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
BINARY_RE='\.(png|gif|jpg|jpeg|webp|ico|icc|woff|woff2|ttf|eot|crt|ser|z)$'
mapfile -t candidates < <(git ls-files | grep -v '^libs/' | grep -viE "$BINARY_RE")
changed=0
for f in "${candidates[@]}"; do
[ -f "$f" ] || continue
# only touch files that actually contain a CR
if LC_ALL=C grep -qU $'\r' "$f" 2>/dev/null; then
LC_ALL=C sed -i 's/\r$//' "$f"
changed=$((changed + 1))
fi
done
echo "normalized $changed file(s)"

30
scripts/normalize_eol.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# ITFlow - one-shot CRLF -> LF normalization.
#
# Converts every tracked text file outside libs/ to LF. Vendored libraries are
# left byte-for-byte as shipped upstream (CONTRIBUTING.md: libs/ is replaced
# wholesale, never edited), and binary assets are skipped outright.
#
# Run once, from the repo root, alongside adding .gitattributes. After that
# .gitattributes keeps new files in line and this script should be a no-op.
#
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
BINARY_RE='\.(png|gif|jpg|jpeg|webp|ico|icc|woff|woff2|ttf|eot|crt|ser|z)$'
mapfile -t candidates < <(git ls-files | grep -v '^libs/' | grep -viE "$BINARY_RE")
changed=0
for f in "${candidates[@]}"; do
[ -f "$f" ] || continue
# only touch files that actually contain a CR
if LC_ALL=C grep -qU $'\r' "$f" 2>/dev/null; then
LC_ALL=C sed -i 's/\r$//' "$f"
changed=$((changed + 1))
fi
done
echo "normalized $changed file(s)"