Compare commits

...
Author SHA1 Message Date
jared f342e446d3 Merge development into main: bulk-op notification/audit fixes + workflow-validated auto-reopen (#67, #68, #74)
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
- Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
- Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
2026-09-11 12:44:06 -04:00
jaredandClaude Sonnet 5 18c213ebd7 Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 34s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m25s
Lint / Deploy (push) Successful in 8s
create_ticket_api.php's dedup-reopen path wrote status = 'Open' via a
raw SQL UPDATE, completely bypassing TicketModel::updateTicket() and
WorkflowModel::isTransitionAllowed() — the one status-write path in
the app that never consulted the workflow engine at all. If an admin
configured the Workflow Designer to disallow a direct Closed->Open
transition, this automated path still forced it unconditionally.

Now checks isTransitionAllowed('Closed', 'Open', false) first (false
since this is an unattended system account, not admin-elevated). If
not allowed, falls back to any transition the Workflow Designer does
allow from Closed that requires neither a comment nor admin privilege
(both of which this unattended automation can't satisfy), and applies
it via TicketModel::updateTicket() instead of raw SQL. If no such
transition exists at all, the ticket is deliberately left Closed
(rather than forcing an unconfigured state) with a comment and audit
entry explaining why, so the recurrence is still visible to a human
without silently violating workflow rules.

Verified against real MariaDB across all three branches: direct
Closed->Open allowed (reopens to Open), disallowed but Closed->'In
Progress' available unattended (falls back correctly), and no usable
transition configured at all (ticket correctly stays Closed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:46 -04:00
jaredandClaude Sonnet 5 6adbb29964 Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
BulkOperationsModel's bulk_close/bulk_status paths had zero references
to NotificationHelper — the exact same status transition (e.g.
Open->Closed) silently produced no Matrix/watcher notification when
performed via bulk actions, while the single-ticket edit page and
Bearer API both notify on every status change. Separately, their
audit_log entries used a bare ['status' => 'Closed', ...] shape
instead of the {'status': {'from': X, 'to': Y}} shape every other
status-change path uses, which broke two downstream consumers:
TicketView.php's timeline fell back to a generic "updated this
ticket" instead of "updated status", and notifications.php's
$details['status']['from'] on a string produced a broken "? -> ?"
notification title.

Fixed the audit_log shape for both operation types, and added a
notification queue collected during the per-ticket loop and flushed
only after a successful commit (so atomic-mode rollback correctly
sends zero notifications, matching how nothing else about a rolled-
back batch takes effect either). Also fixed an incidental bug found
while matching this to the single-ticket path: update_ticket.php's
notifyWatchers() call never passed the ticket's visibility, silently
defaulting to 'public' and always including the shared notify list
even for confidential/internal tickets — the exact leak #71 fixed
elsewhere in NotificationHelper itself, just never reaching this
call site.

Verified against real MariaDB with a real local webhook-capturing
server: bulk_close correctly fires sendStatusChangeNotification() +
notifyWatchers() with the right old/new status and a redacted title
for a confidential ticket; audit_log rows show the correct {from,to}
shape; and an atomic-mode rollback (one ticket's transition invalid)
sends zero notifications and leaves both tickets unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:37 -04:00
jared 5a69c41f48 Merge development into main: error-handling rollout + session privilege re-sync (#38, #39, #56, #105)
Lint / PHP (phpcs PSR-12) (push) Successful in 44s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m49s
Lint / Deploy (push) Successful in 2s
- Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
- Periodically re-sync session privileges from Authelia (#56)
2026-09-11 12:23:49 -04:00
jaredandClaude Sonnet 5 6b7e67eee4 Periodically re-sync session privileges from Authelia (#56)
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 3s
AuthMiddleware::authenticate() only re-read Remote-User/Remote-Groups
(and thus is_admin, via UserModel::syncUserFromAuthelia) when
$_SESSION['user'] didn't exist yet. Once a session existed, every
subsequent request only checked the idle timer — never re-validating
against current Authelia/LLDAP state. An admin's group membership
revoked in LLDAP, or a logout at the Authelia proxy, left their
already-open session with full access for up to SESSION_TIMEOUT (5h
default), with no way to force early revocation short of clearing the
server-side session store.

Added PRIVILEGE_RESYNC_INTERVAL (default 5 min, matching
UserModel's own cache TTL) and a resyncPrivileges() check on every
already-authenticated request past that interval: re-reads the current
request's forward-auth headers (enforcing the trusted-proxy check
again, same as a fresh login), and either destroys the session and
redirects to re-auth if the user no longer has any required group, or
re-syncs is_admin/groups/display_name/email if they do. Best-effort if
this particular request doesn't carry forward-auth headers at all
(skips silently rather than force-logging out, retried next interval).

UserModel::syncUserFromAuthelia() has its own 5-minute in-process
cache keyed only by username (not by the groups being synced), so a
naive re-call during a resync would have kept returning the
pre-revocation cached result for up to 5 more minutes — invalidated
that cache entry immediately beforehand to guarantee a real re-sync.

Verified against real MariaDB across a fresh login, a same-interval
request confirming no premature resync, an admin-privilege-revocation
mid-session (is_admin flips to false in both session and DB, verified
via a direct query), and a full group-membership revocation (session
destroyed, redirected to re-auth, confirmed the request never reaches
past that point).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:27 -04:00
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
jared bcc732e605 Merge development into main: connection/security hardening batch (#85, #94, #103, #104)
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 43s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 2s
- Route index.php and create_ticket_api.php through Database::getConnection() (#103, #104)
- Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94)
- Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85)
2026-09-11 11:54:36 -04:00
jaredandClaude Sonnet 5 9d8a73c355 Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85)
Lint / PHP (phpcs PSR-12) (push) Successful in 38s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 38s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
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
2026-09-11 11:42:46 -04:00
jaredandClaude Sonnet 5 c78d24154a Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94)
TRUSTED_PROXIES ships empty in .env.example, which disables
AuthMiddleware's reverse-proxy allowlist entirely — a fresh deployment
that doesn't explicitly set it has zero verification that
Remote-User/Remote-Groups headers actually came from the trusted
Authelia proxy. Anything that can reach the app directly (a
misconfigured firewall rule, an exposed container port, SSRF from
another internal service) can set Remote-User: admin and fully
impersonate any user with zero authentication. The enforcement logic
itself was already correct; this was purely a dangerous, easy-to-miss
default.

Added a boxed, unmissable warning around TRUSTED_PROXIES in
.env.example (previously just an inline comment easy to skim past),
added the same warning to README's setup instructions (which didn't
mention this variable at all), and added a Check 8 to api/health.php
that reports a 'warning' status when TRUSTED_PROXIES is empty, so a
deployment that forgets it doesn't go unnoticed after the fact.
Verified against real MariaDB via a running server: the health
endpoint correctly reports 'warning' when empty and 'ok' once set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:29 -04:00
jaredandClaude Sonnet 5 98d30cbc58 Route index.php and create_ticket_api.php through Database::getConnection() (#103, #104)
Both files opened their own raw new mysqli(...) connection instead of
using Database::getConnection(), missing the charset/timezone sync
every other connection gets. index.php's connection serves nearly all
non-API web traffic (dashboard, ticket view, ticket create) — any
NOW()/CURDATE()-based query through it used the DB server's default
session timezone instead of the app's configured TIMEZONE, and no
explicit utf8mb4 charset meant multi-byte characters typed into a
ticket via the non-JS POST fallback could get corrupted at write time.
create_ticket_api.php (the hwmonDaemon Bearer endpoint) had the same
timezone gap, risking created_at landing on the wrong 'day' relative
to every other ticket-creation path.

index.php's raw die("Connection failed: " . $conn->connect_error) also
leaked raw mysqli error text (host/user/failure reason) to any
unauthenticated visitor on a DB outage; it now logs via error_log()
and shows a generic message instead. create_ticket_api.php already
handled this correctly (JSON error + error_log, no leak) and needed no
behavior change there beyond the connection source.

Verified against real MariaDB: the new connection path reports the
configured -04:00 session time_zone and utf8mb4 charset, vs. SYSTEM
tz on the old raw-mysqli path; a simulated connection failure (bad
DB_NAME) confirmed only the generic message reaches the response body
while the raw driver error goes to error_log().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:21 -04:00
32 changed files with 422 additions and 136 deletions
+11 -4
View File
@@ -49,19 +49,26 @@ APP_DOMAIN=
; Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
; ============================================================================
; REQUIRED FOR PRODUCTION -- READ BEFORE DEPLOYING -- TRUSTED_PROXIES
; ============================================================================
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
; trusted when REMOTE_ADDR is in this list.
;
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
; the PHP backend is reachable directly (bypassing the proxy), because a client
; can then spoof those headers and log in as an admin. Only leave it empty when
; network topology guarantees PHP is reachable solely via the trusted proxy.
; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP
; backend is reachable directly -- a misconfigured firewall rule, a container
; network accidentally exposing the port, SSRF from another internal service
; -- ANYONE can set Remote-User: admin themselves and fully impersonate any
; user, including an admin, with ZERO authentication. Only leave it empty when
; network topology guarantees PHP is reachable solely via the trusted proxy
; (e.g. local development), never in a real deployment.
;
; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
; ============================================================================
TRUSTED_PROXIES=
; Timezone (default: America/New_York)
+15
View File
@@ -447,6 +447,21 @@ APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
```
**⚠️ REQUIRED FOR PRODUCTION — `TRUSTED_PROXIES`:** This app trusts Authelia
forward-auth headers (`Remote-User`, `Remote-Groups`, etc.) to identify who's
logged in. `TRUSTED_PROXIES` restricts that trust to requests that actually
came through your reverse proxy — **leaving it empty disables that check
entirely**, and anyone who can reach the PHP backend directly (a
misconfigured firewall rule, an exposed container port, SSRF from another
internal service) can set `Remote-User: admin` themselves and fully
impersonate any user with zero authentication. Set it to your reverse proxy's
IP address(es) before deploying anywhere reachable beyond your own machine:
```env
TRUSTED_PROXIES=10.10.10.27
```
`GET /api/health.php` reports a `warning` on the `trusted_proxies` check if
this is left empty, so it doesn't go unnoticed after deployment.
Matrix notification variables (all optional):
```env
# hookshot generic webhook URL — send events to Matrix room
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -10,8 +10,8 @@
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+4 -1
View File
@@ -1,5 +1,8 @@
<?php
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -25,7 +28,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
header('Content-Type: application/json');
@@ -34,7 +34,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get attachment ID
+3 -3
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -49,7 +49,7 @@ try {
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
+3
View File
@@ -6,6 +6,9 @@
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -8,8 +8,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+10 -3
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403);
throw new Exception("Invalid CSRF token");
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
}
+18
View File
@@ -11,6 +11,9 @@
* - 503 Service Unavailable: System has issues
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
@@ -162,6 +165,21 @@ if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time'
];
}
// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s
// allowlist entirely, meaning anything that can reach this app directly can
// spoof the Authelia forward-auth Remote-* headers and impersonate any user,
// including an admin. Not fatal (a fresh/dev install may not sit behind a
// proxy yet), but should never go unnoticed on a real deployment.
if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) {
$checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured'];
} else {
$checks['trusted_proxies'] = [
'status' => 'warning',
'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; '
. 'anything that can reach this app directly can impersonate any user'
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -42,7 +42,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -39,7 +39,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+10 -3
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403);
throw new Exception("Invalid CSRF token");
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
}
+2 -2
View File
@@ -18,8 +18,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+1 -1
View File
@@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
}
+2 -2
View File
@@ -22,8 +22,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -14,8 +14,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+4 -3
View File
@@ -1,8 +1,8 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -276,7 +276,8 @@ try {
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
+3 -3
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// Verify CSRF token
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get ticket ID
+2 -2
View File
@@ -11,8 +11,8 @@
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+5
View File
@@ -106,6 +106,11 @@ $GLOBALS['config'] = [
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
// How often an already-logged-in session re-validates Remote-User/
// Remote-Groups against current Authelia/LLDAP state (AuthMiddleware).
// Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT.
'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
+89 -64
View File
@@ -2,15 +2,16 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once __DIR__ . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Load environment variables with error check
$envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) {
// Early friendly JSON error if .env is missing, before config.php's own
// (plain-text die()) handling would otherwise run — this is a JSON API
// endpoint and must always respond with a JSON body.
if (!file_exists(__DIR__ . '/.env')) {
echo json_encode([
'success' => false,
'error' => 'Configuration file not found'
@@ -18,37 +19,17 @@ if (!file_exists($envFile)) {
exit;
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if (!$envVars) {
echo json_encode([
'success' => false,
'error' => 'Invalid configuration file'
]);
exit;
}
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
// DB connection below (via Database::getConnection()) gets the same
// charset/timezone sync as every other endpoint instead of a hand-rolled
// second connection.
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/helpers/Database.php';
// Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
// Database connection with detailed error handling
$conn = new mysqli(
$envVars['DB_HOST'],
$envVars['DB_USER'],
$envVars['DB_PASS'],
$envVars['DB_NAME']
);
if ($conn->connect_error) {
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
try {
$conn = Database::getConnection();
} catch (\Throwable $e) {
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'success' => false,
@@ -57,13 +38,12 @@ if ($conn->connect_error) {
exit;
}
// Load application config so UrlHelper can resolve APP_DOMAIN
require_once __DIR__ . '/config/config.php';
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.php';
require_once __DIR__ . '/models/TicketModel.php';
require_once __DIR__ . '/models/WorkflowModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
@@ -349,7 +329,7 @@ if ($existing) {
(new StatsModel($conn))->invalidateCache();
}
$conn->close();
Database::close();
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
@@ -360,17 +340,52 @@ if ($existing) {
exit;
}
// Ticket was closed — reopen it and add a recurrence comment
$reopenStmt = $conn->prepare(
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
);
$reopenStmt->bind_param("is", $userId, $existingId);
$reopenStmt->execute();
$reopenStmt->close();
// Ticket was closed — reopen it and add a recurrence comment. Route
// through the Workflow Designer like every other status-write path in
// the app, rather than forcing status='Open' via raw SQL regardless of
// configured transition rules.
$workflowModel = new WorkflowModel($conn);
$reopenStatus = 'Open';
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
// Direct Closed->Open isn't configured — fall back to any transition
// the Workflow Designer does allow from Closed that this unattended,
// non-admin automation can actually satisfy (no comment prompt, no
// admin elevation). If even that doesn't exist, leave the ticket
// Closed rather than force an unconfigured state.
$reopenStatus = null;
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
$reopenStatus = $transition['to_status'];
break;
}
}
}
if ($reopenStatus !== null) {
$ticketModel = new TicketModel($conn);
$ticketModel->updateTicket([
'ticket_id' => $existingId,
'title' => $title,
'description' => $description,
'category' => $category,
'type' => $type,
'status' => $reopenStatus,
'priority' => $priority,
], $userId);
} else {
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId"
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
}
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred.";
if ($reopenStatus === null) {
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
. "Closed is configured that this automation can perform unattended (no comment/admin "
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
}
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -378,30 +393,40 @@ if ($existing) {
$commentStmt->execute();
$commentStmt->close();
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
if ($reopenStatus !== null) {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
// Ticket reopened — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
} else {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
]);
}
$conn->close();
Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
], 'automated');
if ($reopenStatus !== null) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => $reopenStatus,
], 'automated');
}
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
'message' => $reopenStatus !== null
? 'Existing closed ticket reopened'
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
]);
exit;
}
@@ -484,7 +509,7 @@ if ($inserted) {
// New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
+26 -5
View File
@@ -10,26 +10,36 @@ class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
private static string $responseMode = 'json';
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
* @param bool $displayErrors Whether to display errors (false in production)
* @param string $responseMode 'json' (API endpoints) or 'html' (page views —
* renders views/error_500.php instead of a JSON body)
*/
public static function init(bool $displayErrors = false): void
public static function init(bool $displayErrors = false, string $responseMode = 'json'): void
{
if (self::$initialized) {
return;
}
self::$responseMode = $responseMode;
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Deliberately does NOT override the 'error_log' ini setting: doing so
// used to redirect every error_log() call in the request to a fixed
// /tmp file, silently diverting logs away from wherever the server is
// actually configured to send them (php-fpm's error_log, stdout in a
// container, etc.) the moment this got wired into more than one
// endpoint. self::$logFile / getRecentErrors() are unused (no callers
// app-wide) and exist only as an opt-in helper if something later
// wants a dedicated log file.
// Register handlers
set_error_handler([self::class, 'handleError']);
@@ -151,6 +161,17 @@ class ErrorHandler
{
http_response_code($httpCode);
if (self::$responseMode === 'html') {
if (!headers_sent()) {
header('Content-Type: text/html; charset=utf-8');
}
// Deliberately not passed $message/$exception — see error_500.php's
// docblock on why the fatal-error page must render with zero
// dependency on request-specific state.
include dirname(__DIR__) . '/views/error_500.php';
exit;
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
+15 -10
View File
@@ -1,10 +1,18 @@
<?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();
@@ -17,15 +25,12 @@ $requestPath = strtok($request, '?');
// Create database connection for non-API routes
if (!str_starts_with($requestPath, '/api/')) {
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
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
@@ -444,5 +449,5 @@ switch (true) {
// Close database connection if it was opened
if (isset($conn)) {
$conn->close();
Database::close();
}
+72
View File
@@ -92,6 +92,19 @@ class AuthMiddleware
} else {
// Update last activity time
$_SESSION['last_activity'] = time();
// Periodically re-validate Remote-User/Remote-Groups against
// current Authelia/LLDAP state, so a revoked admin (or anyone
// dropped from the required groups) loses access promptly
// instead of keeping it for up to SESSION_TIMEOUT. Only the
// idle timer was checked above; nothing previously re-read
// these headers once a session already existed.
$resyncInterval = $GLOBALS['config']['PRIVILEGE_RESYNC_INTERVAL'] ?? 300;
$lastSync = $_SESSION['last_privilege_sync'] ?? 0;
if (time() - $lastSync > $resyncInterval) {
$this->resyncPrivileges();
}
return $_SESSION['user'];
}
}
@@ -134,6 +147,7 @@ class AuthMiddleware
// Store user in session
$_SESSION['user'] = $user;
$_SESSION['last_activity'] = time();
$_SESSION['last_privilege_sync'] = time();
// Generate new CSRF token on login
require_once __DIR__ . '/CsrfMiddleware.php';
@@ -142,6 +156,64 @@ class AuthMiddleware
return $user;
}
/**
* Re-validate the current session's Remote-User/Remote-Groups against
* this request's forward-auth headers, and re-sync or revoke access on
* mismatch. Called periodically (PRIVILEGE_RESYNC_INTERVAL) from an
* already-authenticated session — see authenticate().
*
* Best-effort: if this particular request doesn't carry forward-auth
* headers at all (e.g. a proxy hiccup), the session is left as-is rather
* than force-logging the user out, and the check is simply retried on
* the next request past the interval.
*/
private function resyncPrivileges(): void
{
$username = $this->getHeader('HTTP_REMOTE_USER');
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
if (empty($username)) {
return;
}
$this->enforceTrustedProxy();
// A different Remote-User than the session's own means Authelia is
// now asserting a different identity entirely for this proxy path;
// don't silently relabel the session as that other user.
if ($username !== ($_SESSION['user']['username'] ?? null)) {
return;
}
if (!$this->checkGroupAccess($groups)) {
$this->logSecurityEvent('privilege_resync_revoked', [
'username' => $username,
'groups' => $groups ?: 'none',
]);
session_unset();
session_destroy();
$this->redirectToAuth();
exit;
}
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
// Bypass UserModel's 5-minute in-process cache — that cache key isn't
// group-aware, so a stale cached hit here would silently keep serving
// the pre-revocation is_admin value for the rest of the cache's TTL.
UserModel::invalidateCache(null, $username);
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
$wasAdmin = !empty($_SESSION['user']['is_admin']);
if ($wasAdmin && empty($user['is_admin'])) {
$this->logSecurityEvent('privilege_resync_admin_revoked', ['username' => $username]);
}
$_SESSION['user'] = $user;
$_SESSION['last_privilege_sync'] = time();
}
/**
* Reject forward-auth headers that did not arrive via a trusted proxy.
*
+61 -2
View File
@@ -125,13 +125,23 @@ class BulkOperationsModel
$processed = 0;
$failed = 0;
$errors = [];
// Status-change notifications collected during the loop below and
// sent only after a successful commit, matching how the single-ticket
// and Bearer API paths never notify for a change that didn't durably
// land (and how an atomic-mode rollback must not fire any at all).
$notificationQueue = [];
// Load required models
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
$ticketModel = new TicketModel($this->conn);
$auditLogModel = new AuditLogModel($this->conn);
$userModel = new UserModel($this->conn);
$actor = $operation['performed_by'] ? $userModel->getUserById((int)$operation['performed_by']) : null;
$changedByDisplay = $actor['display_name'] ?? $actor['username'] ?? null;
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
@@ -221,8 +231,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => 'Closed'],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => 'Closed',
];
}
}
break;
@@ -291,8 +311,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => $parameters['status']],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => $parameters['status'],
];
}
}
}
@@ -364,6 +394,35 @@ class BulkOperationsModel
@unlink($path);
}
}
// Fire the same Matrix/watcher notifications the single-ticket and
// Bearer API status-change paths send, now that every change in
// this batch is durably committed. Best-effort: a notification
// failure must never turn an otherwise-successful bulk operation
// into an error.
foreach ($notificationQueue as $n) {
try {
NotificationHelper::sendStatusChangeNotification(
$n['ticketId'],
$n['oldStatus'],
$n['newStatus'],
$n['title'],
$changedByDisplay,
$n['visibility']
);
NotificationHelper::notifyWatchers(
$this->conn,
$n['ticketId'],
$n['title'],
'status_changed',
['old_status' => $n['oldStatus'], 'new_status' => $n['newStatus'], 'changed_by' => $changedByDisplay],
(int)$operation['performed_by'],
$n['visibility']
);
} catch (Throwable $e) {
error_log("Bulk operation $operationId: notification failed for ticket {$n['ticketId']}: " . $e->getMessage());
}
}
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Standalone 500/fatal-error page, rendered by ErrorHandler for page-view
* (non-API) requests.
*
* Deliberately self-contained: a genuine fatal can happen before config.php
* finishes loading, mid-session-start, or mid-DB-query, so this view must
* not depend on $GLOBALS['config'], $GLOBALS['currentUser'], a session, or a
* DB connection being available/working. It links the static base.css
* stylesheet (served directly by the webserver, independent of PHP) to
* match the app's look without going through layout_header.php's app-state
* dependent setup.
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 &mdash; Something Went Wrong</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/css/base.css">
</head>
<body>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-danger">[ 500 ] SOMETHING WENT WRONG</div>
<div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">
An unexpected error occurred. It's been logged; please try again shortly.
</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
</body>
</html>