Files
tinker_tickets/index.php
T
jaredandClaude Sonnet 5 71bf64c1e2 Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.

Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.

Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.

For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.

Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:15 -04:00

454 lines
16 KiB
PHP

<?php
// Main entry point for the application
// Registered first, before anything else, so a genuine fatal anywhere below
// (including during config.php's own env parsing) renders the styled 500
// page instead of falling through to PHP's raw default error handling.
require_once 'helpers/ErrorHandler.php';
ErrorHandler::init(false, 'html');
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php';
require_once 'helpers/Database.php';
// Apply security headers early
SecurityHeadersMiddleware::apply();
// Parse the URL - no need to remove base path since we're at document root
$request = $_SERVER['REQUEST_URI'];
// Remove query string for routing (but keep it available)
$requestPath = strtok($request, '?');
// Create database connection for non-API routes
if (!str_starts_with($requestPath, '/api/')) {
try {
$conn = Database::getConnection();
} catch (\Throwable $e) {
error_log('index.php: database connection failed: ' . $e->getMessage());
http_response_code(500);
die('Sorry, something went wrong. Please try again shortly.');
}
// Authenticate user via Authelia forward auth
$authMiddleware = new AuthMiddleware($conn);
$currentUser = $authMiddleware->authenticate();
// Store current user in globals for controllers
$GLOBALS['currentUser'] = $currentUser;
// Initialize audit log model
$GLOBALS['auditLog'] = new AuditLogModel($conn);
// Check if user has a timezone preference and apply it
if ($currentUser && isset($currentUser['user_id'])) {
require_once 'models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
// Override system timezone with user preference (validated against known identifiers)
date_default_timezone_set($userTimezone);
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
$now = new DateTime('now', new DateTimeZone($userTimezone));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60;
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T');
}
}
}
// Helper: require admin or render styled 403 and exit
function requireAdmin(?array $user): void
{
if (!$user || empty($user['is_admin'])) {
http_response_code(403);
include __DIR__ . '/views/error_403.php';
exit;
}
}
// Simple router
switch (true) {
case $requestPath == '/' || $requestPath == '':
require_once 'controllers/DashboardController.php';
$controller = new DashboardController($conn);
$controller->index();
break;
case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches):
require_once 'controllers/TicketController.php';
$controller = new TicketController($conn);
$controller->view($matches[1]);
break;
case $requestPath == '/ticket/create':
require_once 'controllers/TicketController.php';
$controller = new TicketController($conn);
$controller->create();
break;
// API Routes - these handle their own database connections
case $requestPath == '/api/update_ticket.php':
require_once 'api/update_ticket.php';
break;
case $requestPath == '/api/add_comment.php':
require_once 'api/add_comment.php';
break;
case $requestPath == '/api/update_comment.php':
require_once 'api/update_comment.php';
break;
case $requestPath == '/api/delete_comment.php':
require_once 'api/delete_comment.php';
break;
case $requestPath == '/api/ticket_dependencies.php':
require_once 'api/ticket_dependencies.php';
break;
case $requestPath == '/api/upload_attachment.php':
require_once 'api/upload_attachment.php';
break;
case $requestPath == '/api/delete_attachment.php':
require_once 'api/delete_attachment.php';
break;
case $requestPath == '/api/get_users.php':
require_once 'api/get_users.php';
break;
case $requestPath == '/api/get_comments.php':
require_once 'api/get_comments.php';
break;
case $requestPath == '/api/watch_ticket.php':
require_once 'api/watch_ticket.php';
break;
case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php';
break;
case $requestPath == '/api/get_template.php':
require_once 'api/get_template.php';
break;
case $requestPath == '/api/bulk_operation.php':
require_once 'api/bulk_operation.php';
break;
case $requestPath == '/api/export_tickets.php':
require_once 'api/export_tickets.php';
break;
case $requestPath == '/api/generate_api_key.php':
require_once 'api/generate_api_key.php';
break;
case $requestPath == '/api/revoke_api_key.php':
require_once 'api/revoke_api_key.php';
break;
case $requestPath == '/api/manage_templates.php':
require_once 'api/manage_templates.php';
break;
case $requestPath == '/api/manage_workflows.php':
require_once 'api/manage_workflows.php';
break;
case $requestPath == '/api/manage_recurring.php':
require_once 'api/manage_recurring.php';
break;
case $requestPath == '/api/check_duplicates.php':
require_once 'api/check_duplicates.php';
break;
case $requestPath == '/api/custom_fields.php':
require_once 'api/custom_fields.php';
break;
case $requestPath == '/api/saved_filters.php':
require_once 'api/saved_filters.php';
break;
case $requestPath == '/api/audit_log.php':
require_once 'api/audit_log.php';
break;
case $requestPath == '/api/user_preferences.php':
require_once 'api/user_preferences.php';
break;
case $requestPath == '/api/download_attachment.php':
require_once 'api/download_attachment.php';
break;
case $requestPath == '/api/clone_ticket.php':
require_once 'api/clone_ticket.php';
break;
case $requestPath == '/api/health.php':
require_once 'api/health.php';
break;
case $requestPath == '/api/notifications.php':
require_once 'api/notifications.php';
break;
case $requestPath == '/api/user_avatar.php':
require_once 'api/user_avatar.php';
break;
// Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets':
requireAdmin($currentUser);
require_once 'models/RecurringTicketModel.php';
$recurringModel = new RecurringTicketModel($conn);
$recurringTickets = $recurringModel->getAll(true);
include 'views/admin/RecurringTicketsView.php';
break;
case $requestPath == '/admin/custom-fields':
requireAdmin($currentUser);
require_once 'models/CustomFieldModel.php';
$fieldModel = new CustomFieldModel($conn);
$customFields = $fieldModel->getAllDefinitions(null, false);
include 'views/admin/CustomFieldsView.php';
break;
case $requestPath == '/admin/workflow':
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$workflows = [];
while ($row = $result->fetch_assoc()) {
$workflows[] = $row;
}
include 'views/admin/WorkflowDesignerView.php';
break;
case $requestPath == '/admin/templates':
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
$templates[] = $row;
}
include 'views/admin/TemplatesView.php';
break;
case $requestPath == '/admin/audit-log':
requireAdmin($currentUser);
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 50;
$offset = ($page - 1) * $perPage;
$filters = [];
$whereConditions = [];
$params = [];
$types = '';
// Mirrors AuditLogModel::VALID_ACTION_TYPES so every option offered by the
// audit-log filter dropdown is actually accepted here.
$allowedActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
$types .= 's';
$filters['action_type'] = $_GET['action_type'];
}
if (!empty($_GET['user_id'])) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id'];
$types .= 'i';
$filters['user_id'] = (int)$_GET['user_id'];
}
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from'];
$types .= 's';
$filters['date_from'] = $_GET['date_from'];
}
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to'];
$types .= 's';
$filters['date_to'] = $_GET['date_to'];
}
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
// $where contains only hardcoded SQL fragments with ? placeholders — user values
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
if (!empty($params)) {
$stmt = $conn->prepare($countSql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$countResult = $stmt->get_result();
} else {
$countResult = $conn->query($countSql);
}
$totalLogs = $countResult->fetch_assoc()['total'];
$totalPages = ceil($totalLogs / $perPage);
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$sql = "SELECT al.*, u.display_name, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
" . $where . "
ORDER BY al.created_at DESC
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
if (!empty($params)) {
$stmt = $conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $conn->query($sql);
}
$auditLogs = [];
while ($row = $result->fetch_assoc()) {
$auditLogs[] = $row;
}
$usersResult = $conn->query("SELECT user_id, username, display_name FROM users ORDER BY display_name");
$users = [];
while ($row = $usersResult->fetch_assoc()) {
$users[] = $row;
}
include 'views/admin/AuditLogView.php';
break;
case $requestPath == '/admin/api-keys':
requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
// Validate the requested page to a positive int (default 1)
$apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($apiKeysPage < 1) {
$apiKeysPage = 1;
}
$apiKeysPerPage = 20;
$apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage);
include 'views/admin/ApiKeysView.php';
break;
case $requestPath == '/admin/user-activity':
requireAdmin($currentUser);
// Validate date params (YYYY-MM-DD) like the audit-log route; fall back to defaults on garbage
$uaFrom = $_GET['date_from'] ?? '';
$uaTo = $_GET['date_to'] ?? '';
$dateRange = [
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaFrom) ? $uaFrom : date('Y-m-d', strtotime('-30 days')),
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaTo) ? $uaTo : date('Y-m-d')
];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
// This eliminates N+1 query pattern and runs much faster with many users
$sql = "SELECT
u.user_id, u.username, u.display_name, u.is_admin,
COALESCE(tc.tickets_created, 0) as tickets_created,
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
COALESCE(cm.comments_added, 0) as comments_added,
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
al.last_activity
FROM users u
LEFT JOIN (
SELECT created_by, COUNT(*) as tickets_created
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY created_by
) tc ON u.user_id = tc.created_by
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_resolved
FROM tickets
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
GROUP BY assigned_to
) tr ON u.user_id = tr.assigned_to
LEFT JOIN (
SELECT user_id, COUNT(*) as comments_added
FROM ticket_comments
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity
FROM audit_log
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id
) al ON u.user_id = al.user_id
ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql);
$stmt->bind_param(
'ssssssssss',
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to']
);
$stmt->execute();
$result = $stmt->get_result();
$userStats = [];
while ($row = $result->fetch_assoc()) {
$userStats[] = $row;
}
$stmt->close();
include 'views/admin/UserActivityView.php';
break;
// Legacy support for old URLs
case $requestPath == '/dashboard.php':
header("Location: /");
exit;
case preg_match('/^\/ticket\.php$/', $requestPath) && isset($_GET['id']):
$legacyId = (string)$_GET['id'];
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
header("Location: /ticket/" . $legacyId);
} else {
header("Location: /");
}
exit;
default:
http_response_code(404);
include __DIR__ . '/views/error_404.php';
break;
}
// Close database connection if it was opened
if (isset($conn)) {
Database::close();
}