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
api/bootstrap.php's centralized CSRF handling echoes
CsrfMiddleware::getToken() on a 403 rejection specifically so
lt.api's client-side resync (assets/js/base.js) can recover once
window.CSRF_TOKEN goes stale (token expiry, or a write in another tab
rotating the shared session-scoped token). 12 endpoints duplicate
CsrfMiddleware::validateToken() inline instead of routing through
bootstrap.php, and their 403 body omitted csrf_token entirely —
custom_fields.php, clone_ticket.php, delete_comment.php,
delete_attachment.php, bulk_operation.php, generate_api_key.php,
manage_templates.php, manage_recurring.php, revoke_api_key.php,
manage_workflows.php, ticket_dependencies.php, and
upload_attachment.php.
Once a client's token drifted out of sync, the next write to any of
these 12 endpoints returned a 403 with no way to self-heal — every
subsequent write to any endpoint kept failing until a manual reload,
since the resync mechanism was only wired up on a minority of the
app's write surface. Took the minimal fix the issue names as
sufficient (add 'csrf_token' => CsrfMiddleware::getToken() to each
rejection body) rather than restructuring all 12 through bootstrap.php,
to avoid behavioral risk from rewiring each endpoint's differing
auth/bootstrapping. generate_api_key.php and revoke_api_key.php threw
a generic Exception for this case (swallowed into a plain error-message
response with no room for extra fields), so those two now short-circuit
with a direct JSON response instead, matching the other 10.
Verified end-to-end against real running endpoints with a real
session and real MariaDB: sent a wrong CSRF token to one endpoint of
each response shape (plain json_encode, ResponseHelper::error, and the
formerly exception-based path) and confirmed all three now return the
current valid csrf_token in the 403 body.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
assign_ticket.php: preserve string ticket ID (ctype_digit validation)
instead of (int) cast for consistent audit logging and URL generation.
delete_attachment.php: use string ticket_id from DB for the upload
directory path — (int) cast was stripping leading zeros, causing
the wrong path (/uploads/123456/) instead of /uploads/000123456/.
Also pass raw string to getTicketById() to let TicketModel handle
type coercion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- delete_attachment.php: add realpath() path traversal check before
unlink() — mirrors the defense-in-depth already in download_attachment.php;
also cast ticket_id to int when building the path
- manage_templates.php: add input validation to POST and PUT handlers:
required field checks, max length caps (name 100, title 255, desc 64KB),
allowlist validation for category/type, priority clamped to 1-5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- delete_attachment.php: check canUserAccessTicket() before allowing deletion; return 404 (not 403) for inaccessible tickets to prevent existence leakage
- upload_attachment.php: verify ticket access on both GET (list) and POST (upload) before processing
- update_ticket.php: pass currentUser to controller; add canUserAccessTicket() check before permission check; return 404 for inaccessible tickets instead of leaking existence via 403
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Consolidate all 20 API files to use centralized Database helper
- Add optimistic locking to ticket updates to prevent concurrent conflicts
- Add caching to StatsModel (60s TTL) for dashboard performance
- Add health check endpoint (api/health.php) for monitoring
- Improve rate limit cleanup with cron script and efficient DirectoryIterator
- Enable rate limit response headers (X-RateLimit-*)
- Add audit logging for workflow transitions
- Log Discord webhook failures instead of silencing
- Fix visibility check on export_tickets.php
- Add database migration system with performance indexes
- Fix cron recurring tickets to use assignTicket method
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add session status check
- Remove broken AuditLogModel call without $conn in CSRF check
- Fix AuditLogModel instantiation with proper $conn parameter
- Fix log() call to pass array instead of JSON string for details
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>