Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f342e446d3 | ||
|
|
18c213ebd7 | ||
|
|
6adbb29964 | ||
|
|
5a69c41f48 | ||
|
|
6b7e67eee4 | ||
|
|
71bf64c1e2 | ||
|
|
bcc732e605 | ||
|
|
9d8a73c355 | ||
|
|
c78d24154a | ||
|
|
98d30cbc58 |
+11
-4
@@ -49,19 +49,26 @@ APP_DOMAIN=
|
|||||||
; Include all domains that can access this application
|
; Include all domains that can access this application
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
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.
|
; 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
|
; 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
|
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||||
; trusted when REMOTE_ADDR is in this list.
|
; trusted when REMOTE_ADDR is in this list.
|
||||||
;
|
;
|
||||||
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
||||||
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP
|
||||||
; the PHP backend is reachable directly (bypassing the proxy), because a client
|
; backend is reachable directly -- a misconfigured firewall rule, a container
|
||||||
; can then spoof those headers and log in as an admin. Only leave it empty when
|
; network accidentally exposing the port, SSRF from another internal service
|
||||||
; network topology guarantees PHP is reachable solely via the trusted proxy.
|
; -- 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
|
; 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
|
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
||||||
|
; ============================================================================
|
||||||
TRUSTED_PROXIES=
|
TRUSTED_PROXIES=
|
||||||
|
|
||||||
; Timezone (default: America/New_York)
|
; Timezone (default: America/New_York)
|
||||||
|
|||||||
@@ -447,6 +447,21 @@ APP_DOMAIN=your.domain.example
|
|||||||
TIMEZONE=America/New_York
|
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):
|
Matrix notification variables (all optional):
|
||||||
```env
|
```env
|
||||||
# hookshot generic webhook URL — send events to Matrix room
|
# hookshot generic webhook URL — send events to Matrix room
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@
|
|||||||
* // $conn, $currentUser, $userId, $isAdmin are now available
|
* // $conn, $currentUser, $userId, $isAdmin are now available
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Rate limiting (also starts session)
|
// Rate limiting (also starts session)
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
@@ -25,7 +28,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
|
|||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* Creates a copy of an existing ticket with the same properties
|
* Creates a copy of an existing ticket with the same properties
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ try {
|
|||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* CRUD operations for custom field definitions
|
* CRUD operations for custom field definitions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
@@ -40,7 +40,7 @@ try {
|
|||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Capture errors for debugging
|
// Capture errors for debugging
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting (also starts session)
|
// Apply rate limiting (also starts session)
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
@@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Verify CSRF token
|
// Verify CSRF token
|
||||||
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
ResponseHelper::forbidden('Invalid CSRF token');
|
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get attachment ID
|
// Get attachment ID
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
@@ -49,7 +49,7 @@ try {
|
|||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
header('Content-Type: application/json');
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
* Serves file downloads for ticket attachments
|
* Serves file downloads for ticket attachments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// API endpoint for generating API keys (Admin only)
|
// API endpoint for generating API keys (Admin only)
|
||||||
error_reporting(E_ALL);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
@@ -39,8 +39,15 @@ try {
|
|||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
ob_end_clean();
|
||||||
http_response_code(403);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
* - 503 Service Unavailable: System has issues
|
* - 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
|
// Don't apply rate limiting to health checks - they should always respond
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
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
|
// Calculate response time
|
||||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* CRUD operations for recurring_tickets table
|
* CRUD operations for recurring_tickets table
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
@@ -42,7 +42,7 @@ try {
|
|||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* CRUD operations for ticket_templates table
|
* CRUD operations for ticket_templates table
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
@@ -39,7 +39,7 @@ try {
|
|||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* CRUD operations for status_transitions table
|
* CRUD operations for status_transitions table
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
@@ -40,7 +40,7 @@ try {
|
|||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
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;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// API endpoint for revoking API keys (Admin only)
|
// API endpoint for revoking API keys (Admin only)
|
||||||
error_reporting(E_ALL);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
@@ -39,8 +39,15 @@ try {
|
|||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
ob_end_clean();
|
||||||
http_response_code(403);
|
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,8 +18,8 @@
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
|
|||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
ResponseHelper::forbidden('Invalid CSRF token');
|
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Rate limiting (same pattern as the other Bearer API endpoints)
|
// Rate limiting (same pattern as the other Bearer API endpoints)
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Enable error reporting for debugging
|
// Enable error reporting for debugging
|
||||||
error_reporting(E_ALL);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0); // Don't display errors in the response
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
@@ -276,7 +276,8 @@ try {
|
|||||||
$updateData['title'],
|
$updateData['title'],
|
||||||
'status_changed',
|
'status_changed',
|
||||||
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
||||||
(int)$this->userId
|
(int)$this->userId,
|
||||||
|
$currentTicket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Capture errors for debugging
|
// Capture errors for debugging
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Apply rate limiting (also starts session)
|
// Apply rate limiting (also starts session)
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
@@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
// Verify CSRF token
|
// Verify CSRF token
|
||||||
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
ResponseHelper::forbidden('Invalid CSRF token');
|
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ticket ID
|
// Get ticket ID
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
* Returns 404 if the user has no avatar set in lldap.
|
* Returns 404 if the user has no avatar set in lldap.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
error_reporting(E_ALL);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ $GLOBALS['config'] = [
|
|||||||
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
|
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
|
||||||
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
'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 settings
|
||||||
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
||||||
|
|
||||||
|
|||||||
+89
-64
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
require_once __DIR__ . '/helpers/ErrorHandler.php';
|
||||||
ini_set('display_errors', 0);
|
ErrorHandler::init();
|
||||||
|
|
||||||
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Early friendly JSON error if .env is missing, before config.php's own
|
||||||
$envFile = __DIR__ . '/.env';
|
// (plain-text die()) handling would otherwise run — this is a JSON API
|
||||||
if (!file_exists($envFile)) {
|
// endpoint and must always respond with a JSON body.
|
||||||
|
if (!file_exists(__DIR__ . '/.env')) {
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Configuration file not found'
|
'error' => 'Configuration file not found'
|
||||||
@@ -18,37 +19,17 @@ if (!file_exists($envFile)) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
|
||||||
if (!$envVars) {
|
// DB connection below (via Database::getConnection()) gets the same
|
||||||
echo json_encode([
|
// charset/timezone sync as every other endpoint instead of a hand-rolled
|
||||||
'success' => false,
|
// second connection.
|
||||||
'error' => 'Invalid configuration file'
|
require_once __DIR__ . '/config/config.php';
|
||||||
]);
|
require_once __DIR__ . '/helpers/Database.php';
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip quotes from values if present (parse_ini_file may include them)
|
try {
|
||||||
foreach ($envVars as $key => $value) {
|
$conn = Database::getConnection();
|
||||||
if (is_string($value)) {
|
} catch (\Throwable $e) {
|
||||||
if (
|
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
|
||||||
(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);
|
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
@@ -57,13 +38,12 @@ if ($conn->connect_error) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load application config so UrlHelper can resolve APP_DOMAIN
|
|
||||||
require_once __DIR__ . '/config/config.php';
|
|
||||||
|
|
||||||
// Authenticate via API key
|
// Authenticate via API key
|
||||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||||
require_once __DIR__ . '/models/StatsModel.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';
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
||||||
|
|
||||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
@@ -349,7 +329,7 @@ if ($existing) {
|
|||||||
(new StatsModel($conn))->invalidateCache();
|
(new StatsModel($conn))->invalidateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->close();
|
Database::close();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $existingId,
|
'ticket_id' => $existingId,
|
||||||
@@ -360,17 +340,52 @@ if ($existing) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket was closed — reopen it and add a recurrence comment
|
// Ticket was closed — reopen it and add a recurrence comment. Route
|
||||||
$reopenStmt = $conn->prepare(
|
// through the Workflow Designer like every other status-write path in
|
||||||
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
|
// the app, rather than forcing status='Open' via raw SQL regardless of
|
||||||
);
|
// configured transition rules.
|
||||||
$reopenStmt->bind_param("is", $userId, $existingId);
|
$workflowModel = new WorkflowModel($conn);
|
||||||
$reopenStmt->execute();
|
$reopenStatus = 'Open';
|
||||||
$reopenStmt->close();
|
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" .
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||||
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
||||||
. "original report; see this comment's timestamp for when the issue recurred.";
|
. "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(
|
$commentStmt = $conn->prepare(
|
||||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
"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->execute();
|
||||||
$commentStmt->close();
|
$commentStmt->close();
|
||||||
|
|
||||||
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
if ($reopenStatus !== null) {
|
||||||
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||||
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
|
||||||
]);
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
||||||
|
]);
|
||||||
|
|
||||||
// Ticket reopened (Closed → Open) — refresh dashboard stats.
|
// Ticket reopened — refresh dashboard stats.
|
||||||
(new StatsModel($conn))->invalidateCache();
|
(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';
|
if ($reopenStatus !== null) {
|
||||||
NotificationHelper::sendTicketNotification($existingId, [
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
'title' => $title,
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
'priority' => $priority,
|
'title' => $title,
|
||||||
'category' => $category,
|
'priority' => $priority,
|
||||||
'type' => $type,
|
'category' => $category,
|
||||||
'status' => 'Open',
|
'type' => $type,
|
||||||
], 'automated');
|
'status' => $reopenStatus,
|
||||||
|
], 'automated');
|
||||||
|
}
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $existingId,
|
'ticket_id' => $existingId,
|
||||||
'message' => 'Existing closed ticket reopened',
|
'message' => $reopenStatus !== null
|
||||||
'action' => 'reopened',
|
? 'Existing closed ticket reopened'
|
||||||
|
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
|
||||||
|
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -484,7 +509,7 @@ if ($inserted) {
|
|||||||
// New ticket created — refresh dashboard stats.
|
// New ticket created — refresh dashboard stats.
|
||||||
(new StatsModel($conn))->invalidateCache();
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
|
||||||
$conn->close();
|
Database::close();
|
||||||
|
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||||
|
|||||||
@@ -10,26 +10,36 @@ class ErrorHandler
|
|||||||
{
|
{
|
||||||
private static ?string $logFile = null;
|
private static ?string $logFile = null;
|
||||||
private static bool $initialized = false;
|
private static bool $initialized = false;
|
||||||
|
private static string $responseMode = 'json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize error handling
|
* 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) {
|
if (self::$initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::$responseMode = $responseMode;
|
||||||
|
|
||||||
// Set error reporting
|
// Set error reporting
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', $displayErrors ? '1' : '0');
|
ini_set('display_errors', $displayErrors ? '1' : '0');
|
||||||
ini_set('log_errors', '1');
|
ini_set('log_errors', '1');
|
||||||
|
|
||||||
// Set up log file
|
// Deliberately does NOT override the 'error_log' ini setting: doing so
|
||||||
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
|
// used to redirect every error_log() call in the request to a fixed
|
||||||
ini_set('error_log', self::$logFile);
|
// /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
|
// Register handlers
|
||||||
set_error_handler([self::class, 'handleError']);
|
set_error_handler([self::class, 'handleError']);
|
||||||
@@ -151,6 +161,17 @@ class ErrorHandler
|
|||||||
{
|
{
|
||||||
http_response_code($httpCode);
|
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()) {
|
if (!headers_sent()) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Main entry point for the application
|
// 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 'config/config.php';
|
||||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once 'middleware/AuthMiddleware.php';
|
require_once 'middleware/AuthMiddleware.php';
|
||||||
require_once 'models/AuditLogModel.php';
|
require_once 'models/AuditLogModel.php';
|
||||||
|
require_once 'helpers/Database.php';
|
||||||
|
|
||||||
// Apply security headers early
|
// Apply security headers early
|
||||||
SecurityHeadersMiddleware::apply();
|
SecurityHeadersMiddleware::apply();
|
||||||
@@ -17,15 +25,12 @@ $requestPath = strtok($request, '?');
|
|||||||
|
|
||||||
// Create database connection for non-API routes
|
// Create database connection for non-API routes
|
||||||
if (!str_starts_with($requestPath, '/api/')) {
|
if (!str_starts_with($requestPath, '/api/')) {
|
||||||
$conn = new mysqli(
|
try {
|
||||||
$GLOBALS['config']['DB_HOST'],
|
$conn = Database::getConnection();
|
||||||
$GLOBALS['config']['DB_USER'],
|
} catch (\Throwable $e) {
|
||||||
$GLOBALS['config']['DB_PASS'],
|
error_log('index.php: database connection failed: ' . $e->getMessage());
|
||||||
$GLOBALS['config']['DB_NAME']
|
http_response_code(500);
|
||||||
);
|
die('Sorry, something went wrong. Please try again shortly.');
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
die("Connection failed: " . $conn->connect_error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate user via Authelia forward auth
|
// Authenticate user via Authelia forward auth
|
||||||
@@ -444,5 +449,5 @@ switch (true) {
|
|||||||
|
|
||||||
// Close database connection if it was opened
|
// Close database connection if it was opened
|
||||||
if (isset($conn)) {
|
if (isset($conn)) {
|
||||||
$conn->close();
|
Database::close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ class AuthMiddleware
|
|||||||
} else {
|
} else {
|
||||||
// Update last activity time
|
// Update last activity time
|
||||||
$_SESSION['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'];
|
return $_SESSION['user'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +147,7 @@ class AuthMiddleware
|
|||||||
// Store user in session
|
// Store user in session
|
||||||
$_SESSION['user'] = $user;
|
$_SESSION['user'] = $user;
|
||||||
$_SESSION['last_activity'] = time();
|
$_SESSION['last_activity'] = time();
|
||||||
|
$_SESSION['last_privilege_sync'] = time();
|
||||||
|
|
||||||
// Generate new CSRF token on login
|
// Generate new CSRF token on login
|
||||||
require_once __DIR__ . '/CsrfMiddleware.php';
|
require_once __DIR__ . '/CsrfMiddleware.php';
|
||||||
@@ -142,6 +156,64 @@ class AuthMiddleware
|
|||||||
return $user;
|
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.
|
* Reject forward-auth headers that did not arrive via a trusted proxy.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -125,13 +125,23 @@ class BulkOperationsModel
|
|||||||
$processed = 0;
|
$processed = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
$errors = [];
|
$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
|
// Load required models
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.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);
|
$ticketModel = new TicketModel($this->conn);
|
||||||
$auditLogModel = new AuditLogModel($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
|
// Batch load all tickets in one query to eliminate N+1 problem
|
||||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
@@ -221,8 +231,18 @@ class BulkOperationsModel
|
|||||||
'update',
|
'update',
|
||||||
'ticket',
|
'ticket',
|
||||||
$ticketId,
|
$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;
|
break;
|
||||||
@@ -291,8 +311,18 @@ class BulkOperationsModel
|
|||||||
'update',
|
'update',
|
||||||
'ticket',
|
'ticket',
|
||||||
$ticketId,
|
$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);
|
@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) {
|
} catch (Exception $e) {
|
||||||
// Rollback on any unexpected error
|
// Rollback on any unexpected error
|
||||||
$this->conn->rollback();
|
$this->conn->rollback();
|
||||||
|
|||||||
@@ -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 — 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">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user