Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e92e326a | ||
|
|
4164f85051 | ||
|
|
b2c19745eb | ||
|
|
b3bc3ab159 | ||
|
|
2b8d593ab0 | ||
|
|
600c46f673 | ||
|
|
5808b93cdb | ||
|
|
597e1b1eea | ||
|
|
35a2b66038 | ||
|
|
b7aea8c683 | ||
|
|
d23bbc4b26 | ||
|
|
132098bee3 | ||
|
|
3a4a13db7b | ||
|
|
6b2d8e4d03 | ||
|
|
7fb60a365e | ||
|
|
fb3b607bd1 | ||
|
|
dad7c24bff |
@@ -24,6 +24,14 @@ 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
|
||||||
|
|
||||||
|
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
|
||||||
|
# STRONGLY RECOMMENDED in production: Authelia forward-auth (Remote-User /
|
||||||
|
# Remote-Groups) and forwarded client IPs are only trusted when REMOTE_ADDR is
|
||||||
|
# in this list. Leaving it empty disables that protection (relies solely on
|
||||||
|
# network topology) and lets anything reaching PHP directly spoof admin login.
|
||||||
|
# Exact IP match only (no CIDR). Example: TRUSTED_PROXIES=10.10.10.27
|
||||||
|
TRUSTED_PROXIES=
|
||||||
|
|
||||||
# Timezone (default: America/New_York)
|
# Timezone (default: America/New_York)
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,27 @@ jobs:
|
|||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: npx eslint assets/js/
|
run: npx eslint assets/js/
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
name: PHP requirements (version + extensions)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install PHP with required extensions
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
# Install the extensions declared in config/requirements.php so the
|
||||||
|
# check verifies they are actually installable + loadable, and so this
|
||||||
|
# build fails if a required extension can't be provided.
|
||||||
|
apt-get install -y -qq php-cli php-ldap php-mysql php-curl php-mbstring
|
||||||
|
|
||||||
|
- name: Verify runtime requirements
|
||||||
|
run: php scripts/check_requirements.php
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy
|
name: Deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [php-lint, js-lint]
|
needs: [php-lint, js-lint, requirements]
|
||||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -77,7 +94,7 @@ jobs:
|
|||||||
notify-failure:
|
notify-failure:
|
||||||
name: Notify on failure
|
name: Notify on failure
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [php-lint, js-lint]
|
needs: [php-lint, js-lint, requirements]
|
||||||
if: failure() && github.event_name == 'push'
|
if: failure() && github.event_name == 'push'
|
||||||
steps:
|
steps:
|
||||||
- name: Send Matrix alert
|
- name: Send Matrix alert
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq python3 python3-pip
|
apt-get install -y -qq python3 python3-pip
|
||||||
pip3 install semgrep
|
# Debian's Python is externally managed (PEP 668); the runner is
|
||||||
|
# ephemeral so installing system-wide is fine here.
|
||||||
|
pip3 install --break-system-packages semgrep
|
||||||
|
|
||||||
- name: Run semgrep
|
- name: Run semgrep
|
||||||
run: semgrep --config=p/php --config=p/owasp-top-ten --error .
|
run: |
|
||||||
|
semgrep --config=p/php --config=p/owasp-top-ten --error \
|
||||||
|
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
|
||||||
|
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
|
||||||
|
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
|
||||||
|
.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
|
||||||
|
|
||||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||||
|
|
||||||
@@ -569,12 +570,13 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
||||||
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
||||||
| `security.yml` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — |
|
| `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
|
||||||
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass |
|
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
|
||||||
|
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
|
||||||
|
|
||||||
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||||
|
|
||||||
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` per directory.
|
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -46,8 +46,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$filters['ip_address'] = $_GET['ip_address'];
|
$filters['ip_address'] = $_GET['ip_address'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all matching logs (no limit for CSV export)
|
// Get all matching logs for export. The forExport flag raises the cap
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
// (model clamps to its export limit) so the CSV isn't silently truncated
|
||||||
|
// to the 1000-row UI page limit.
|
||||||
|
$result = $auditLogModel->getFilteredLogs($filters, PHP_INT_MAX, 0, true);
|
||||||
$logs = $result['logs'];
|
$logs = $result['logs'];
|
||||||
|
|
||||||
// Set CSV headers
|
// Set CSV headers
|
||||||
|
|||||||
@@ -50,12 +50,24 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
|
|||||||
|
|
||||||
$types = "ss" . $visFilter['types'];
|
$types = "ss" . $visFilter['types'];
|
||||||
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||||
$stmt = $conn->prepare($sql);
|
|
||||||
if (!empty($params)) {
|
// Duplicate detection is advisory (it must not block ticket creation), so on any
|
||||||
$stmt->bind_param($types, ...$params);
|
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
|
||||||
|
// mysqli may throw (default exception mode) or return false depending on config.
|
||||||
|
try {
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new RuntimeException('prepare failed: ' . $conn->error);
|
||||||
|
}
|
||||||
|
if (!empty($params)) {
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('check_duplicates: ' . $e->getMessage());
|
||||||
|
ResponseHelper::success(['duplicates' => []]);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
|
||||||
$result = $stmt->get_result();
|
|
||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
// Calculate similarity score
|
// Calculate similarity score
|
||||||
|
|||||||
@@ -95,6 +95,40 @@ if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 5: Required PHP extensions (catches e.g. a PHP upgrade silently
|
||||||
|
// dropping php-ldap, which breaks avatars with no other visible error).
|
||||||
|
$requirements = require dirname(__DIR__) . '/config/requirements.php';
|
||||||
|
$missingExt = array_values(array_filter(
|
||||||
|
$requirements['required_extensions'],
|
||||||
|
fn($ext) => !extension_loaded($ext)
|
||||||
|
));
|
||||||
|
if (empty($missingExt)) {
|
||||||
|
$checks['php_extensions'] = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'All required extensions loaded'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$checks['php_extensions'] = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Missing extensions: ' . implode(', ', $missingExt)
|
||||||
|
];
|
||||||
|
$healthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 6: PHP version meets the declared minimum
|
||||||
|
if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
|
||||||
|
$checks['php_version'] = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => PHP_VERSION
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$checks['php_version'] = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => sprintf('PHP %s < required %s', PHP_VERSION, $requirements['min_php_version'])
|
||||||
|
];
|
||||||
|
$healthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate response time
|
// Calculate response time
|
||||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ try {
|
|||||||
case 'POST':
|
case 'POST':
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$wfValid = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (
|
||||||
|
!in_array($data['from_status'] ?? '', $wfValid, true)
|
||||||
|
|| !in_array($data['to_status'] ?? '', $wfValid, true)
|
||||||
|
) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'from_status and to_status must be valid ticket statuses']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
@@ -125,6 +134,15 @@ try {
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$wfValid = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (
|
||||||
|
!in_array($data['from_status'] ?? '', $wfValid, true)
|
||||||
|
|| !in_array($data['to_status'] ?? '', $wfValid, true)
|
||||||
|
) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'from_status and to_status must be valid ticket statuses']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
|
|||||||
+33
-5
@@ -55,13 +55,18 @@ $assignSql = "SELECT
|
|||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND al.details LIKE ?
|
AND (al.details LIKE ? OR al.details LIKE ?)
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 15";
|
LIMIT 15";
|
||||||
|
|
||||||
$assignLike = '%"assigned_to":' . $userId . '%';
|
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
|
||||||
|
// match 120/123/etc. Single assigns log {"assigned_to":5} (closing brace) while
|
||||||
|
// bulk assigns log {"assigned_to":5,"bulk_operation_id":N} (comma) — match both.
|
||||||
|
$assignId = (int)$userId;
|
||||||
|
$assignEnd = '%"assigned_to":' . $assignId . '}%';
|
||||||
|
$assignMid = '%"assigned_to":' . $assignId . ',%';
|
||||||
$stmt = $conn->prepare($assignSql);
|
$stmt = $conn->prepare($assignSql);
|
||||||
$stmt->bind_param('is', $userId, $assignLike);
|
$stmt->bind_param('iss', $userId, $assignEnd, $assignMid);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -148,10 +153,32 @@ $stmt->execute();
|
|||||||
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// Query 4: @mentions of me (logged by add_comment.php as
|
||||||
|
// action_type='mention', entity_type='user', entity_id=<mentioned user_id>).
|
||||||
|
$mentionSql = "SELECT
|
||||||
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'mention'
|
||||||
|
AND al.entity_type = 'user'
|
||||||
|
AND al.entity_id = ?
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 15";
|
||||||
|
|
||||||
|
$mentionEntityId = (string)$userId;
|
||||||
|
$stmt = $conn->prepare($mentionSql);
|
||||||
|
$stmt->bind_param('si', $mentionEntityId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
// Merge, deduplicate by log_id, sort by created_at desc
|
// Merge, deduplicate by log_id, sort by created_at desc
|
||||||
$all = [];
|
$all = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $row) {
|
||||||
$id = (int)$row['log_id'];
|
$id = (int)$row['log_id'];
|
||||||
if (isset($seen[$id])) {
|
if (isset($seen[$id])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -170,7 +197,7 @@ foreach ($all as $row) {
|
|||||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
? 'comment'
|
? 'comment'
|
||||||
: $row['action_type'];
|
: $row['action_type'];
|
||||||
$ticketId = ($actionType === 'comment')
|
$ticketId = ($actionType === 'comment' || $actionType === 'mention')
|
||||||
? ($details['ticket_id'] ?? 0)
|
? ($details['ticket_id'] ?? 0)
|
||||||
: $row['entity_id'];
|
: $row['entity_id'];
|
||||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
@@ -179,6 +206,7 @@ foreach ($all as $row) {
|
|||||||
$title = match ($actionType) {
|
$title = match ($actionType) {
|
||||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
|
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
|
||||||
'update' => (function () use ($row, $details, $ticketId) {
|
'update' => (function () use ($row, $details, $ticketId) {
|
||||||
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
|||||||
+20
-15
@@ -127,6 +127,25 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate visibility BEFORE any DB write so a bad payload can't leave the
|
||||||
|
// ticket half-updated (core fields committed but request reported as failed).
|
||||||
|
$visibilityGroups = null;
|
||||||
|
if (isset($data['visibility'])) {
|
||||||
|
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||||
|
// Convert array to comma-separated string if needed
|
||||||
|
if (is_array($visibilityGroups)) {
|
||||||
|
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal visibility requires at least one group
|
||||||
|
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Internal visibility requires at least one group to be specified'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate status transition using workflow model
|
// Validate status transition using workflow model
|
||||||
if ($currentTicket['status'] !== $updateData['status']) {
|
if ($currentTicket['status'] !== $updateData['status']) {
|
||||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||||
@@ -160,22 +179,8 @@ try {
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle visibility update if provided
|
// Handle visibility update if provided (already validated above)
|
||||||
if (isset($data['visibility'])) {
|
if (isset($data['visibility'])) {
|
||||||
$visibilityGroups = $data['visibility_groups'] ?? null;
|
|
||||||
// Convert array to comma-separated string if needed
|
|
||||||
if (is_array($visibilityGroups)) {
|
|
||||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate internal visibility requires groups
|
|
||||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Internal visibility requires at least one group to be specified'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
if ($visResult && $this->userId) {
|
if ($visResult && $this->userId) {
|
||||||
$this->auditLog->log(
|
$this->auditLog->log(
|
||||||
|
|||||||
@@ -144,13 +144,18 @@ if (!is_dir($uploadDir)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket subdirectory
|
// Create ticket subdirectory — ticketId is validated as digits-only above
|
||||||
$ticketDir = $uploadDir . '/' . $ticketId;
|
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||||
if (!is_dir($ticketDir)) {
|
if (!is_dir($ticketDir)) {
|
||||||
if (!mkdir($ticketDir, 0755, true)) {
|
if (!mkdir($ticketDir, 0755, true)) {
|
||||||
ResponseHelper::serverError('Failed to create ticket upload directory');
|
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Confirm resolved path stays within the upload root (defence-in-depth)
|
||||||
|
$resolvedTicketDir = realpath($ticketDir);
|
||||||
|
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
|
||||||
|
ResponseHelper::error('Invalid upload path');
|
||||||
|
}
|
||||||
|
|
||||||
// Derive extension from validated MIME type (never from user-supplied filename)
|
// Derive extension from validated MIME type (never from user-supplied filename)
|
||||||
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
||||||
|
|||||||
+18
-7
@@ -56,8 +56,11 @@ if (!is_dir($cacheDir)) {
|
|||||||
mkdir($cacheDir, 0755, true);
|
mkdir($cacheDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
// Build cache paths from the validated integer $userId — no user-supplied strings used
|
||||||
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
|
||||||
|
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
|
||||||
|
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
|
||||||
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||||
|
|
||||||
// Serve from cache if fresh
|
// Serve from cache if fresh
|
||||||
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
@@ -69,7 +72,6 @@ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||||
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
|
||||||
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
exit;
|
exit;
|
||||||
@@ -108,6 +110,7 @@ $safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
|
|||||||
$filter = "(uid=$safeUsername)";
|
$filter = "(uid=$safeUsername)";
|
||||||
|
|
||||||
$avatarData = null;
|
$avatarData = null;
|
||||||
|
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
|
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
|
||||||
@@ -135,20 +138,28 @@ try {
|
|||||||
$avatarData = $entries[0]['avatar'][0];
|
$avatarData = $entries[0]['avatar'][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The query ran to completion — any "no avatar" result is authoritative.
|
||||||
|
$ldapQueryOk = true;
|
||||||
|
|
||||||
ldap_unbind($ldap);
|
ldap_unbind($ldap);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
|
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
|
||||||
// Fall through to 404
|
// Transient LDAP failure: do NOT poison the negative cache. Fall through to
|
||||||
|
// a plain 404 so the avatar is retried on the next request once LDAP recovers.
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($avatarData === null || strlen($avatarData) < 100) {
|
if ($avatarData === null || strlen($avatarData) < 100) {
|
||||||
// Write sentinel so we don't hammer LDAP for users without avatars
|
// Only cache "no avatar" when LDAP actually answered. On an error/timeout we
|
||||||
file_put_contents($noAvatarSentinel, '');
|
// leave no sentinel, so the lookup is retried instead of being stuck for the TTL.
|
||||||
|
if ($ldapQueryOk) {
|
||||||
|
file_put_contents($noAvatarSentinel, '');
|
||||||
|
}
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate it's actually a JPEG (magic bytes FF D8 FF)
|
// Validate it's actually a JPEG (magic bytes FF D8 FF). A successful LDAP read of
|
||||||
|
// non-JPEG data is a genuine "no usable avatar", so the sentinel is appropriate here.
|
||||||
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
|
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
|
||||||
error_log("user_avatar: non-JPEG data for username=$username");
|
error_log("user_avatar: non-JPEG data for username=$username");
|
||||||
file_put_contents($noAvatarSentinel, '');
|
file_put_contents($noAvatarSentinel, '');
|
||||||
|
|||||||
@@ -103,7 +103,13 @@ while ($row = $watchersResult->fetch_assoc()) {
|
|||||||
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
|
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
|
||||||
}
|
}
|
||||||
$watchersStmt->close();
|
$watchersStmt->close();
|
||||||
$count = count($watchers);
|
|
||||||
|
// True watcher count (the list above is capped at 6 for the avatar group)
|
||||||
|
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||||
|
$countStmt->bind_param("i", $ticketId);
|
||||||
|
$countStmt->execute();
|
||||||
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
|
$countStmt->close();
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
+81
-5
@@ -2458,7 +2458,7 @@ select option:checked {
|
|||||||
}
|
}
|
||||||
.lt-progress-bar {
|
.lt-progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent-orange);
|
background: linear-gradient(90deg, var(--accent-orange), #ff8c2b);
|
||||||
box-shadow: var(--glow-orange);
|
box-shadow: var(--glow-orange);
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2471,9 +2471,9 @@ select option:checked {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
|
||||||
}
|
}
|
||||||
.lt-progress--cyan .lt-progress-bar { background: var(--accent-cyan); box-shadow: var(--glow-cyan); }
|
.lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); }
|
||||||
.lt-progress--green .lt-progress-bar { background: var(--accent-green); box-shadow: var(--glow-green); }
|
.lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); }
|
||||||
.lt-progress--red .lt-progress-bar { background: var(--accent-red); box-shadow: var(--glow-red); }
|
.lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); }
|
||||||
.lt-progress--striped .lt-progress-bar {
|
.lt-progress--striped .lt-progress-bar {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
45deg, transparent, transparent 4px,
|
45deg, transparent, transparent 4px,
|
||||||
@@ -4479,7 +4479,83 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
|
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
61. TIMELINE / ACTIVITY FEED
|
61. SLA BANNER
|
||||||
|
----------------------------------------------------------------
|
||||||
|
lt-sla-p1 — pulsing red banner for critical SLA breach
|
||||||
|
lt-sla-p2 — static amber banner for high-priority SLA warning
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.lt-sla-p1,
|
||||||
|
.lt-sla-p2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: 1px solid;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.lt-sla-p1 {
|
||||||
|
border-color: rgba(255,45,85,0.4);
|
||||||
|
background: rgba(255,45,85,0.08);
|
||||||
|
animation: lt-sla-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
.lt-sla-p2 {
|
||||||
|
border-color: rgba(255,179,0,0.4);
|
||||||
|
background: rgba(255,179,0,0.08);
|
||||||
|
}
|
||||||
|
@keyframes lt-sla-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(255,45,85,0.20); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(255,45,85,0.45); }
|
||||||
|
}
|
||||||
|
.lt-sla-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
|
.lt-sla-info { flex: 1; min-width: 0; }
|
||||||
|
.lt-sla-title {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.lt-sla-p1 .lt-sla-title { color: var(--accent-red); text-shadow: var(--glow-red); }
|
||||||
|
.lt-sla-p2 .lt-sla-title { color: var(--accent-amber); text-shadow: var(--glow-amber); }
|
||||||
|
.lt-sla-bar {
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lt-sla-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
.lt-sla-p1 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-red), var(--accent-orange)); box-shadow: 0 0 8px rgba(255,45,85,0.6); }
|
||||||
|
.lt-sla-p2 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-amber), #ffd740); box-shadow: 0 0 8px rgba(255,179,0,0.6); }
|
||||||
|
.lt-sla-meta {
|
||||||
|
font-size: 0.60rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lt-sla-dismiss {
|
||||||
|
font-size: 0.70rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.lt-sla-dismiss:hover { color: var(--text-secondary); }
|
||||||
|
.lt-sla-dismiss:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
|
||||||
|
html[data-theme="light"] .lt-sla-p1 { background: rgba(180,30,50,0.06); border-color: rgba(180,30,50,0.35); }
|
||||||
|
html[data-theme="light"] .lt-sla-p2 { background: rgba(138,90,0,0.06); border-color: rgba(138,90,0,0.35); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
62. TIMELINE / ACTIVITY FEED
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
.lt-timeline {
|
.lt-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+16
-5
@@ -6,6 +6,13 @@
|
|||||||
function parseMarkdown(markdown) {
|
function parseMarkdown(markdown) {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
// Footnote labels are captured before the HTML-escape pass, so they must be
|
||||||
|
// sanitized to a safe slug before being interpolated into id/href attributes
|
||||||
|
// (otherwise a label like `x"><img onerror=...>` breaks out → stored XSS).
|
||||||
|
var fnSlug = function (label) {
|
||||||
|
return String(label).replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
};
|
||||||
|
|
||||||
// Footnotes — collect definitions and mark references with placeholders
|
// Footnotes — collect definitions and mark references with placeholders
|
||||||
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
||||||
const footnotes = {};
|
const footnotes = {};
|
||||||
@@ -25,10 +32,14 @@ function parseMarkdown(markdown) {
|
|||||||
|
|
||||||
let html = markdown;
|
let html = markdown;
|
||||||
|
|
||||||
// Escape HTML first to prevent XSS
|
// Escape HTML first to prevent XSS. Quotes MUST be escaped too: user-controlled
|
||||||
|
// text (e.g. image/link URLs and alt text) is later interpolated into "..."
|
||||||
|
// attributes, so an unescaped " would break out and inject event handlers.
|
||||||
html = html.replace(/&/g, '&')
|
html = html.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>');
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
// Ticket references (#123456789) - convert to clickable links
|
// Ticket references (#123456789) - convert to clickable links
|
||||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||||
@@ -142,7 +153,7 @@ function parseMarkdown(markdown) {
|
|||||||
// Restore footnote reference placeholders
|
// Restore footnote reference placeholders
|
||||||
fnRefs.forEach(function(ref, i) {
|
fnRefs.forEach(function(ref, i) {
|
||||||
html = html.replace('%%FNREF' + i + '%%',
|
html = html.replace('%%FNREF' + i + '%%',
|
||||||
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
|
'<sup class="fn-ref"><a href="#fn-' + fnSlug(ref.label) + '" id="fnref-' + fnSlug(ref.label) + '">[' + ref.n + ']</a></sup>');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap in paragraph if not already wrapped
|
// Wrap in paragraph if not already wrapped
|
||||||
@@ -154,9 +165,9 @@ function parseMarkdown(markdown) {
|
|||||||
if (footnoteOrder.length) {
|
if (footnoteOrder.length) {
|
||||||
html += '<hr class="fn-hr"><ol class="fn-list">';
|
html += '<hr class="fn-hr"><ol class="fn-list">';
|
||||||
footnoteOrder.forEach(function(label, i) {
|
footnoteOrder.forEach(function(label, i) {
|
||||||
html += '<li id="fn-' + label + '" class="fn-item">' +
|
html += '<li id="fn-' + fnSlug(label) + '" class="fn-item">' +
|
||||||
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
||||||
' <a href="#fnref-' + label + '" class="fn-back">↩</a></li>';
|
' <a href="#fnref-' + fnSlug(label) + '" class="fn-back">↩</a></li>';
|
||||||
});
|
});
|
||||||
html += '</ol>';
|
html += '</ol>';
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -735,7 +735,7 @@ function renderDependencies(dependencies) {
|
|||||||
// Insert blocker alert above the frame if not already there
|
// Insert blocker alert above the frame if not already there
|
||||||
const panel = document.getElementById('dependencies-panel');
|
const panel = document.getElementById('dependencies-panel');
|
||||||
if (panel && !panel.querySelector('#blockerAlert')) {
|
if (panel && !panel.querySelector('#blockerAlert')) {
|
||||||
panel.insertAdjacentHTML('afterbegin', alertHtml);
|
panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ $GLOBALS['config'] = [
|
|||||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||||
|
|
||||||
|
// Trusted reverse proxies. Authelia forward-auth (Remote-* headers) is only
|
||||||
|
// honored when REMOTE_ADDR is in this allowlist, so the spoofable identity
|
||||||
|
// headers can't be set by anything that reaches PHP directly. Comma-separated
|
||||||
|
// IPs in .env (e.g. TRUSTED_PROXIES=10.10.10.27). Empty = enforcement OFF
|
||||||
|
// (backward compatible — relies solely on network topology).
|
||||||
|
'TRUSTED_PROXIES' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string)($envVars['TRUSTED_PROXIES'] ?? ''))
|
||||||
|
), fn($ip) => $ip !== '')),
|
||||||
|
|
||||||
// URL settings
|
// URL settings
|
||||||
'BASE_URL' => '', // Empty since we're serving from document root
|
'BASE_URL' => '', // Empty since we're serving from document root
|
||||||
'ASSETS_URL' => '/assets', // Assets URL
|
'ASSETS_URL' => '/assets', // Assets URL
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime requirements — single source of truth.
|
||||||
|
*
|
||||||
|
* Consumed by:
|
||||||
|
* - scripts/check_requirements.php (CI: fails the build if unmet)
|
||||||
|
* - api/health.php (production: surfaces drift to monitoring)
|
||||||
|
*
|
||||||
|
* This exists because a PHP upgrade once silently dropped the ldap extension,
|
||||||
|
* which broke avatars with no visible error. Keep this list in sync with the
|
||||||
|
* extensions the code actually relies on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Minimum supported PHP version (production runs 8.4).
|
||||||
|
'min_php_version' => '8.2',
|
||||||
|
|
||||||
|
// Extensions the application requires to function.
|
||||||
|
'required_extensions' => [
|
||||||
|
'ldap', // api/user_avatar.php — lldap avatar lookups
|
||||||
|
'mysqli', // helpers/Database.php — all data access
|
||||||
|
'curl', // helpers/NotificationHelper.php, SynapseHelper.php — Matrix
|
||||||
|
'mbstring', // multibyte string handling
|
||||||
|
'fileinfo', // api/upload_attachment.php — MIME validation
|
||||||
|
'json', // request/response encoding (bundled, but assert anyway)
|
||||||
|
],
|
||||||
|
];
|
||||||
+49
-20
@@ -45,9 +45,11 @@ $conn = new mysqli(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
if ($conn->connect_error) {
|
||||||
|
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
|
||||||
|
http_response_code(500);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Database connection failed: ' . $conn->connect_error
|
'error' => 'Internal server error'
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,21 @@ function generateTicketHash($data)
|
|||||||
|
|
||||||
if (stripos($title, 'SMART issues') !== false) {
|
if (stripos($title, 'SMART issues') !== false) {
|
||||||
$issueCategory = 'smart';
|
$issueCategory = 'smart';
|
||||||
|
} elseif (stripos($title, 'ZFS pool') !== false) {
|
||||||
|
$issueCategory = 'zfs';
|
||||||
|
// Extract pool name so each pool gets its own ticket
|
||||||
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
||||||
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
||||||
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
||||||
|
$issueSubtype = 'pool_state_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'usage') !== false) {
|
||||||
|
$issueSubtype = 'pool_usage_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'errors') !== false) {
|
||||||
|
$issueSubtype = 'pool_errors_' . $poolName;
|
||||||
|
} else {
|
||||||
|
$issueSubtype = 'pool_' . $poolName;
|
||||||
|
}
|
||||||
|
}
|
||||||
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
||||||
$issueCategory = 'storage';
|
$issueCategory = 'storage';
|
||||||
// Include the LXC container ID so each container gets its own ticket
|
// Include the LXC container ID so each container gets its own ticket
|
||||||
@@ -158,7 +175,7 @@ function generateTicketHash($data)
|
|||||||
$issueSubtype = 'clock_skew';
|
$issueSubtype = 'clock_skew';
|
||||||
} elseif (stripos($title, 'cluster usage') !== false) {
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
||||||
$issueSubtype = 'usage';
|
$issueSubtype = 'usage';
|
||||||
} elseif (stripos($title, 'OSD down') !== false || preg_match('/OSD\s+osd\.\d+\s+is\s+DOWN/i', $title)) {
|
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
|
||||||
// Include the specific OSD ID so each individual OSD gets its own ticket
|
// Include the specific OSD ID so each individual OSD gets its own ticket
|
||||||
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
||||||
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
||||||
@@ -184,6 +201,11 @@ function generateTicketHash($data)
|
|||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Manual tickets should be unique by title (so different software installs don't collide)
|
||||||
|
if ($sourceType === 'manual') {
|
||||||
|
$stableComponents['title'] = $title;
|
||||||
|
}
|
||||||
|
|
||||||
// Include hostname for node-specific issues
|
// Include hostname for node-specific issues
|
||||||
if (!$isClusterWide) {
|
if (!$isClusterWide) {
|
||||||
$stableComponents['hostname'] = $hostname;
|
$stableComponents['hostname'] = $hostname;
|
||||||
@@ -209,6 +231,22 @@ $priority = $data['priority'] ?? '4';
|
|||||||
$category = (string)($data['category'] ?? 'General');
|
$category = (string)($data['category'] ?? 'General');
|
||||||
$type = (string)($data['type'] ?? 'Issue');
|
$type = (string)($data['type'] ?? 'Issue');
|
||||||
|
|
||||||
|
// Validate externally-supplied status and priority. (category/type are free-form
|
||||||
|
// in this schema.) A non-numeric priority would otherwise cast to 0 and escalate
|
||||||
|
// the ticket below P1 on the dedup/update path.
|
||||||
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (!in_array($status, $validStatuses, true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid status']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 5) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid priority (must be 1-5)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$priority = (int)$priority;
|
||||||
|
|
||||||
$ticketHash = generateTicketHash($data);
|
$ticketHash = generateTicketHash($data);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
@@ -228,8 +266,7 @@ if ($existing) {
|
|||||||
|
|
||||||
if ($existingStatus !== 'Closed') {
|
if ($existingStatus !== 'Closed') {
|
||||||
// Ticket is still active — update title, escalate priority, and refresh
|
// Ticket is still active — update title, escalate priority, and refresh
|
||||||
// the description with the latest sensor data if the new report is more severe
|
// description with latest sensor data.
|
||||||
// (lower priority number = higher severity).
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
||||||
$bindTypes = "i";
|
$bindTypes = "i";
|
||||||
@@ -267,20 +304,10 @@ if ($existing) {
|
|||||||
$updStmt->execute();
|
$updStmt->execute();
|
||||||
$updStmt->close();
|
$updStmt->close();
|
||||||
|
|
||||||
// Only add a comment when something meaningful changed (not just a description refresh)
|
// Only post a comment on priority escalation — title and description updates
|
||||||
$meaningfulChanges = array_diff_key($changes, ['description_refreshed' => true]);
|
// are silent (title changes like rising counters would spam a comment every run)
|
||||||
if (!empty($meaningfulChanges)) {
|
if (isset($changes['priority'])) {
|
||||||
$changeLines = [];
|
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
|
||||||
if (isset($changes['title'])) {
|
|
||||||
$changeLines[] = "- **Title updated** to reflect current issue";
|
|
||||||
}
|
|
||||||
if (isset($changes['priority'])) {
|
|
||||||
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
|
|
||||||
}
|
|
||||||
// Wrap description in a fenced code block so ASCII art / box-drawing
|
|
||||||
// characters render correctly instead of collapsing into a paragraph blob
|
|
||||||
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
|
|
||||||
implode("\n", $changeLines) . "\n\nLatest report:\n\n```\n" . $description . "\n```";
|
|
||||||
$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)"
|
||||||
);
|
);
|
||||||
@@ -290,7 +317,7 @@ if ($existing) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||||
$changes,
|
array_diff_key($changes, ['description_refreshed' => true]),
|
||||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -393,7 +420,9 @@ try {
|
|||||||
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
||||||
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
||||||
} else {
|
} else {
|
||||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,10 @@
|
|||||||
*
|
*
|
||||||
* Cleans up expired rate limit files from the temp directory.
|
* Cleans up expired rate limit files from the temp directory.
|
||||||
* Should be run via cron every 5-10 minutes:
|
* Should be run via cron every 5-10 minutes:
|
||||||
* */
|
* 5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
||||||
|
|
||||||
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
|
|
||||||
*
|
*
|
||||||
* This script can also be run manually for immediate cleanup .
|
* This script can also be run manually for immediate cleanup.
|
||||||
* /
|
*/
|
||||||
|
|
||||||
// Prevent web access
|
// Prevent web access
|
||||||
if (php_sapi_name() !== 'cli') {
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
|||||||
@@ -5,19 +5,18 @@
|
|||||||
* Recurring Tickets Cron Job
|
* Recurring Tickets Cron Job
|
||||||
*
|
*
|
||||||
* Run this script via cron to automatically create tickets from recurring schedules.
|
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||||
* Recommended: Run every 5-15 minutes
|
* Recommended: run every 5-15 minutes.
|
||||||
*
|
*
|
||||||
* Example crontab entry:
|
* Example crontab entry (minute 10 of every hour):
|
||||||
* */
|
* 10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||||
|
*/
|
||||||
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
|
|
||||||
* /
|
|
||||||
|
|
||||||
// Change to project root directory
|
// Change to project root directory
|
||||||
chdir(dirname(__DIR__));
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
// Include required files
|
// Include required files
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
|
require_once 'helpers/Database.php';
|
||||||
require_once 'models/RecurringTicketModel.php';
|
require_once 'models/RecurringTicketModel.php';
|
||||||
require_once 'models/TicketModel.php';
|
require_once 'models/TicketModel.php';
|
||||||
require_once 'models/AuditLogModel.php';
|
require_once 'models/AuditLogModel.php';
|
||||||
@@ -31,17 +30,9 @@ function logMessage($message)
|
|||||||
logMessage("Starting recurring tickets cron job");
|
logMessage("Starting recurring tickets cron job");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create database connection
|
// Create database connection (Database::getConnection sets utf8mb4 so
|
||||||
$conn = new mysqli(
|
// non-ASCII titles/descriptions aren't corrupted on insert).
|
||||||
$GLOBALS['config']['DB_HOST'],
|
$conn = Database::getConnection();
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize models
|
// Initialize models
|
||||||
$recurringModel = new RecurringTicketModel($conn);
|
$recurringModel = new RecurringTicketModel($conn);
|
||||||
@@ -59,6 +50,14 @@ try {
|
|||||||
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Claim the schedule FIRST (atomic advance of next_run_at). If another
|
||||||
|
// cron run already claimed it, or it's no longer due, skip it — this
|
||||||
|
// prevents duplicate-ticket floods if a later step throws.
|
||||||
|
if (!$recurringModel->claimForRun($recurring['recurring_id'])) {
|
||||||
|
logMessage("Skipped (already claimed or not due): " . $recurring['recurring_id']);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare ticket data
|
// Prepare ticket data
|
||||||
$ticketData = [
|
$ticketData = [
|
||||||
'title' => processTemplate($recurring['title_template']),
|
'title' => processTemplate($recurring['title_template']),
|
||||||
@@ -76,9 +75,12 @@ try {
|
|||||||
$ticketId = $result['ticket_id'];
|
$ticketId = $result['ticket_id'];
|
||||||
logMessage("Created ticket: " . $ticketId);
|
logMessage("Created ticket: " . $ticketId);
|
||||||
|
|
||||||
// Assign to user if specified
|
// Assign to user if specified. assignTicket() requires a non-null
|
||||||
if ($recurring['assigned_to']) {
|
// "assigned_by"; fall back to the assignee when created_by is null
|
||||||
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
|
// (recurring schedules may have no creator).
|
||||||
|
if (!empty($recurring['assigned_to'])) {
|
||||||
|
$assignedBy = (int)($recurring['created_by'] ?? $recurring['assigned_to']);
|
||||||
|
$ticketModel->assignTicket($ticketId, (int)$recurring['assigned_to'], $assignedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log to audit
|
// Log to audit
|
||||||
@@ -90,9 +92,6 @@ try {
|
|||||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the recurring ticket's next run time
|
|
||||||
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
|
||||||
|
|
||||||
$created++;
|
$created++;
|
||||||
} else {
|
} else {
|
||||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||||
@@ -106,7 +105,7 @@ try {
|
|||||||
|
|
||||||
logMessage("Completed: Created $created tickets, $errors errors");
|
logMessage("Completed: Created $created tickets, $errors errors");
|
||||||
|
|
||||||
$conn->close();
|
Database::close();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||||
exit(1);
|
exit(1);
|
||||||
|
|||||||
@@ -164,23 +164,38 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch watcher usernames, excluding the actor so they don't notify themselves
|
// Fetch watcher usernames, excluding the actor so they don't notify
|
||||||
if ($excludeUserId !== null) {
|
// themselves. Notifications are best-effort: if the watchers table is
|
||||||
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
// absent or the query fails, skip silently rather than fataling the
|
||||||
$stmt = $conn->prepare($sql);
|
// request that already committed its DB change. mysqli may either throw
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
// (default exception mode) or return false, so handle both.
|
||||||
} else {
|
|
||||||
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
|
||||||
$stmt = $conn->prepare($sql);
|
|
||||||
$stmt->bind_param("i", $ticketId);
|
|
||||||
}
|
|
||||||
$stmt->execute();
|
|
||||||
$result = $stmt->get_result();
|
|
||||||
$stmt->close();
|
|
||||||
|
|
||||||
$usernames = [];
|
$usernames = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
try {
|
||||||
$usernames[] = $row['username'];
|
if ($excludeUserId !== null) {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
}
|
||||||
|
if (!$stmt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($excludeUserId !== null) {
|
||||||
|
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$usernames[] = $row['username'];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('NotificationHelper::notifyWatchers watcher lookup failed: ' . $e->getMessage());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($usernames)) {
|
if (empty($usernames)) {
|
||||||
|
|||||||
@@ -278,7 +278,10 @@ switch (true) {
|
|||||||
|
|
||||||
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||||
|
|
||||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
// $where contains only hardcoded SQL fragments with ? placeholders — user values
|
||||||
|
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
|
||||||
|
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||||
|
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt = $conn->prepare($countSql);
|
$stmt = $conn->prepare($countSql);
|
||||||
$stmt->bind_param($types, ...$params);
|
$stmt->bind_param($types, ...$params);
|
||||||
@@ -290,12 +293,13 @@ switch (true) {
|
|||||||
$totalLogs = $countResult->fetch_assoc()['total'];
|
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||||
$totalPages = ceil($totalLogs / $perPage);
|
$totalPages = ceil($totalLogs / $perPage);
|
||||||
|
|
||||||
|
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||||
$sql = "SELECT al.*, u.display_name, u.username
|
$sql = "SELECT al.*, u.display_name, u.username
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
$where
|
" . $where . "
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT $perPage OFFSET $offset";
|
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
|
||||||
|
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ class AuthMiddleware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only honor Authelia forward-auth headers from a trusted reverse proxy.
|
||||||
|
// Without this, anything that can reach PHP directly could spoof
|
||||||
|
// Remote-User / Remote-Groups and log in (as admin). No valid session
|
||||||
|
// exists at this point, so we are about to trust request headers.
|
||||||
|
$this->enforceTrustedProxy();
|
||||||
|
|
||||||
// Read Authelia forward auth headers
|
// Read Authelia forward auth headers
|
||||||
$username = $this->getHeader('HTTP_REMOTE_USER');
|
$username = $this->getHeader('HTTP_REMOTE_USER');
|
||||||
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
||||||
@@ -136,6 +142,33 @@ class AuthMiddleware
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject forward-auth headers that did not arrive via a trusted proxy.
|
||||||
|
*
|
||||||
|
* If TRUSTED_PROXIES is configured and the connecting REMOTE_ADDR is not in
|
||||||
|
* the allowlist, the Remote-* headers cannot be trusted, so we refuse rather
|
||||||
|
* than honor a potentially spoofed identity. Empty allowlist = disabled.
|
||||||
|
*/
|
||||||
|
private function enforceTrustedProxy(): void
|
||||||
|
{
|
||||||
|
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
||||||
|
if (empty($trusted)) {
|
||||||
|
return; // Enforcement disabled (no allowlist configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
if (!in_array($remoteAddr, $trusted, true)) {
|
||||||
|
$this->logSecurityEvent('untrusted_proxy', [
|
||||||
|
'reason' => 'Remote-* auth headers from non-allowlisted source',
|
||||||
|
'remote_addr' => $remoteAddr ?: 'unknown'
|
||||||
|
]);
|
||||||
|
header('HTTP/1.1 403 Forbidden');
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'Forbidden: authentication headers must arrive via a trusted proxy.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get header value from server variables
|
* Get header value from server variables
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -41,19 +41,31 @@ class RateLimitMiddleware
|
|||||||
*/
|
*/
|
||||||
private static function getClientIp(): string
|
private static function getClientIp(): string
|
||||||
{
|
{
|
||||||
// Check for forwarded IP (behind proxy/load balancer)
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
|
||||||
foreach ($headers as $header) {
|
// Forwarded headers are client-controlled, so only believe them when the
|
||||||
if (!empty($_SERVER[$header])) {
|
// request actually came from a trusted reverse proxy. Otherwise a client
|
||||||
// Take the first IP in a comma-separated list
|
// could rotate X-Forwarded-For each request to escape the per-IP limit.
|
||||||
$ips = explode(',', $_SERVER[$header]);
|
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
||||||
$ip = trim($ips[0]);
|
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
|
||||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
return $remoteAddr;
|
||||||
return $ip;
|
}
|
||||||
}
|
|
||||||
|
// The trusted proxy appends the connecting client to X-Forwarded-For, so
|
||||||
|
// the RIGHTMOST entry is the IP it observed (a client-supplied prefix is
|
||||||
|
// not trustworthy). X-Real-IP is set by the proxy itself.
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$ip = trim(end($ips));
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
return $ip;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
if (!empty($_SERVER['HTTP_X_REAL_IP']) && filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
|
||||||
|
return trim($_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remoteAddr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class AuditLogModel
|
|||||||
/** @var int Maximum allowed limit for pagination */
|
/** @var int Maximum allowed limit for pagination */
|
||||||
private const MAX_LIMIT = 1000;
|
private const MAX_LIMIT = 1000;
|
||||||
|
|
||||||
|
/** @var int Maximum rows for a CSV/forensic export (higher than the UI cap) */
|
||||||
|
private const EXPORT_LIMIT = 100000;
|
||||||
|
|
||||||
/** @var int Default limit for pagination */
|
/** @var int Default limit for pagination */
|
||||||
private const DEFAULT_LIMIT = 100;
|
private const DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
@@ -36,12 +39,12 @@ class AuditLogModel
|
|||||||
* @param int $limit Requested limit
|
* @param int $limit Requested limit
|
||||||
* @return int Validated limit
|
* @return int Validated limit
|
||||||
*/
|
*/
|
||||||
private function validateLimit(int $limit): int
|
private function validateLimit(int $limit, int $max = self::MAX_LIMIT): int
|
||||||
{
|
{
|
||||||
if ($limit < 1) {
|
if ($limit < 1) {
|
||||||
return self::DEFAULT_LIMIT;
|
return self::DEFAULT_LIMIT;
|
||||||
}
|
}
|
||||||
return min($limit, self::MAX_LIMIT);
|
return min($limit, $max);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -534,7 +537,7 @@ class AuditLogModel
|
|||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||||
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
|
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
|
||||||
ORDER BY al.created_at DESC"
|
ORDER BY al.created_at DESC"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ss", $ticketId, $ticketId);
|
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||||
@@ -561,10 +564,11 @@ class AuditLogModel
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array containing logs and total count
|
* @return array Array containing logs and total count
|
||||||
*/
|
*/
|
||||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
|
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0, $forExport = false)
|
||||||
{
|
{
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters. Exports allow a much higher cap so a
|
||||||
$limit = $this->validateLimit((int)$limit);
|
// forensic/compliance CSV isn't silently truncated to the UI page limit.
|
||||||
|
$limit = $this->validateLimit((int)$limit, $forExport ? self::EXPORT_LIMIT : self::MAX_LIMIT);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
$whereConditions = [];
|
$whereConditions = [];
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ class BulkOperationsModel
|
|||||||
// Start transaction for data consistency
|
// Start transaction for data consistency
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
|
|
||||||
|
// Attachment files for deleted tickets are removed only AFTER a successful
|
||||||
|
// commit, so a rollback can't leave tickets with their files already gone.
|
||||||
|
$filesToDelete = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
foreach ($ticketIds as $ticketId) {
|
foreach ($ticketIds as $ticketId) {
|
||||||
$ticketId = trim($ticketId);
|
$ticketId = trim($ticketId);
|
||||||
@@ -200,7 +204,7 @@ class BulkOperationsModel
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'bulk_delete':
|
case 'bulk_delete':
|
||||||
$success = $ticketModel->deleteTicket($ticketId);
|
$success = $ticketModel->deleteTicket($ticketId, $filesToDelete);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log(
|
$auditLogModel->log(
|
||||||
$operation['performed_by'],
|
$operation['performed_by'],
|
||||||
@@ -249,6 +253,16 @@ class BulkOperationsModel
|
|||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
$this->conn->commit();
|
$this->conn->commit();
|
||||||
|
|
||||||
|
// Now that the DB delete is durable, remove the physical files. Files
|
||||||
|
// are deleted first; directory entries (no trailing filename) last.
|
||||||
|
foreach ($filesToDelete as $path) {
|
||||||
|
if (is_dir($path)) {
|
||||||
|
@rmdir($path); // only succeeds if empty
|
||||||
|
} elseif (file_exists($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Rollback on any unexpected error
|
// Rollback on any unexpected error
|
||||||
$this->conn->rollback();
|
$this->conn->rollback();
|
||||||
|
|||||||
@@ -151,6 +151,44 @@ class RecurringTicketModel
|
|||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically claim a due schedule for processing.
|
||||||
|
*
|
||||||
|
* Advances next_run_at (and stamps last_run_at) in a single conditional
|
||||||
|
* UPDATE gated on the row still being active and due. Returns true only if
|
||||||
|
* THIS call won the claim. This must be done BEFORE creating the ticket so
|
||||||
|
* that:
|
||||||
|
* - two overlapping cron runs can't both process the same schedule, and
|
||||||
|
* - a failure in a later step (ticket create, assignment, audit) can't
|
||||||
|
* leave next_run_at in the past, which would re-fire — and re-create a
|
||||||
|
* duplicate ticket — on every subsequent cron run.
|
||||||
|
*
|
||||||
|
* @return bool true if the schedule was claimed by this call
|
||||||
|
*/
|
||||||
|
public function claimForRun($recurringId)
|
||||||
|
{
|
||||||
|
$recurring = $this->getById($recurringId);
|
||||||
|
if (!$recurring) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextRun = $this->calculateNextRunTime(
|
||||||
|
$recurring['schedule_type'],
|
||||||
|
$recurring['schedule_day'],
|
||||||
|
$recurring['schedule_time']
|
||||||
|
);
|
||||||
|
|
||||||
|
$sql = "UPDATE recurring_tickets
|
||||||
|
SET last_run_at = NOW(), next_run_at = ?
|
||||||
|
WHERE recurring_id = ? AND is_active = 1 AND next_run_at <= NOW()";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||||
|
$stmt->execute();
|
||||||
|
$claimed = $stmt->affected_rows > 0;
|
||||||
|
$stmt->close();
|
||||||
|
return $claimed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update last run and calculate next run time
|
* Update last run and calculate next run time
|
||||||
*/
|
*/
|
||||||
|
|||||||
+41
-6
@@ -208,6 +208,11 @@ class TicketModel
|
|||||||
ORDER BY $sortExpression $sortDirection
|
ORDER BY $sortExpression $sortDirection
|
||||||
LIMIT ? OFFSET ?";
|
LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
|
// Keep a copy of the filter params (without LIMIT/OFFSET) for the
|
||||||
|
// fallback COUNT below.
|
||||||
|
$countParams = $params;
|
||||||
|
$countParamTypes = $paramTypes;
|
||||||
|
|
||||||
$params[] = $limit;
|
$params[] = $limit;
|
||||||
$params[] = $offset;
|
$params[] = $offset;
|
||||||
$paramTypes .= 'ii';
|
$paramTypes .= 'ii';
|
||||||
@@ -228,6 +233,24 @@ class TicketModel
|
|||||||
}
|
}
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// COUNT(*) OVER() rides on returned rows, so a page past the last row
|
||||||
|
// yields zero rows and a bogus total of 0. Fall back to a direct COUNT
|
||||||
|
// so the total/pages stay correct for stale or over-range page links.
|
||||||
|
if ($totalTickets === 0 && $offset > 0) {
|
||||||
|
$countSql = "SELECT COUNT(*) AS c
|
||||||
|
FROM tickets t
|
||||||
|
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||||
|
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||||
|
$whereClause";
|
||||||
|
$countStmt = $this->conn->prepare($countSql);
|
||||||
|
if (!empty($countParams)) {
|
||||||
|
$countStmt->bind_param($countParamTypes, ...$countParams);
|
||||||
|
}
|
||||||
|
$countStmt->execute();
|
||||||
|
$totalTickets = (int)($countStmt->get_result()->fetch_assoc()['c'] ?? 0);
|
||||||
|
$countStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'total' => $totalTickets,
|
'total' => $totalTickets,
|
||||||
@@ -740,9 +763,13 @@ class TicketModel
|
|||||||
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||||
*
|
*
|
||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
|
* @param array|null &$deferredFiles When provided, attachment file paths to
|
||||||
|
* remove are appended here instead of being unlinked immediately, so a
|
||||||
|
* caller running inside a DB transaction can delete them only AFTER a
|
||||||
|
* successful commit (avoids destroying files for a rolled-back delete).
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deleteTicket(string $ticketId): bool
|
public function deleteTicket(string $ticketId, ?array &$deferredFiles = null): bool
|
||||||
{
|
{
|
||||||
// Collect attachment filenames before deleting DB rows
|
// Collect attachment filenames before deleting DB rows
|
||||||
$attachmentFiles = [];
|
$attachmentFiles = [];
|
||||||
@@ -804,13 +831,21 @@ class TicketModel
|
|||||||
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
|
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
|
||||||
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
|
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
|
||||||
if (is_dir($ticketDir)) {
|
if (is_dir($ticketDir)) {
|
||||||
foreach ($attachmentFiles as $filename) {
|
if ($deferredFiles !== null) {
|
||||||
$file = $ticketDir . '/' . basename($filename);
|
// Defer physical deletion to the caller (post-commit).
|
||||||
if (file_exists($file)) {
|
foreach ($attachmentFiles as $filename) {
|
||||||
@unlink($file);
|
$deferredFiles[] = $ticketDir . '/' . basename($filename);
|
||||||
}
|
}
|
||||||
|
$deferredFiles[] = $ticketDir; // dir removed last, only if empty
|
||||||
|
} else {
|
||||||
|
foreach ($attachmentFiles as $filename) {
|
||||||
|
$file = $ticketDir . '/' . basename($filename);
|
||||||
|
if (file_exists($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($ticketDir); // Remove dir only if empty
|
||||||
}
|
}
|
||||||
@rmdir($ticketDir); // Remove dir only if empty
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class UserModel
|
|||||||
$user = $result->fetch_assoc();
|
$user = $result->fetch_assoc();
|
||||||
|
|
||||||
$updateStmt = $this->conn->prepare(
|
$updateStmt = $this->conn->prepare(
|
||||||
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
"UPDATE users SET display_name = ?, email = ?, `groups` = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
||||||
);
|
);
|
||||||
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
|
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
|
||||||
$updateStmt->execute();
|
$updateStmt->execute();
|
||||||
@@ -100,7 +100,7 @@ class UserModel
|
|||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user
|
||||||
$insertStmt = $this->conn->prepare(
|
$insertStmt = $this->conn->prepare(
|
||||||
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||||
);
|
);
|
||||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||||
$insertStmt->execute();
|
$insertStmt->execute();
|
||||||
@@ -300,7 +300,7 @@ class UserModel
|
|||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
|
$stmt = $this->conn->prepare("SELECT DISTINCT `groups` FROM users WHERE `groups` IS NOT NULL AND `groups` != ''");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the running PHP environment meets the declared runtime requirements.
|
||||||
|
*
|
||||||
|
* Reads config/requirements.php and checks the PHP version and that every
|
||||||
|
* required extension is loaded. Exits non-zero (failing CI) on any miss.
|
||||||
|
*
|
||||||
|
* Usage: php scripts/check_requirements.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$req = require __DIR__ . '/../config/requirements.php';
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// PHP version
|
||||||
|
$minPhp = $req['min_php_version'];
|
||||||
|
if (version_compare(PHP_VERSION, $minPhp, '<')) {
|
||||||
|
$errors[] = sprintf('PHP %s is below the required minimum %s', PHP_VERSION, $minPhp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required extensions
|
||||||
|
foreach ($req['required_extensions'] as $ext) {
|
||||||
|
if (!extension_loaded($ext)) {
|
||||||
|
$errors[] = sprintf('Missing required PHP extension: %s', $ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
fwrite(STDERR, "Requirement check FAILED:\n");
|
||||||
|
foreach ($errors as $err) {
|
||||||
|
fwrite(STDERR, ' - ' . $err . "\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(
|
||||||
|
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
||||||
|
PHP_VERSION,
|
||||||
|
$minPhp,
|
||||||
|
implode(', ', $req['required_extensions'])
|
||||||
|
);
|
||||||
|
exit(0);
|
||||||
@@ -48,14 +48,14 @@ if (!empty($_GET['priority'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!empty($_GET['category'])) {
|
if (!empty($_GET['category'])) {
|
||||||
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . htmlspecialchars($_GET['category'])];
|
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['type'])) {
|
if (!empty($_GET['type'])) {
|
||||||
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['assigned_to'])) {
|
if (!empty($_GET['assigned_to'])) {
|
||||||
$label = match ($_GET['assigned_to']) {
|
$label = match ($_GET['assigned_to']) {
|
||||||
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . htmlspecialchars($_GET['assigned_to'])
|
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . $_GET['assigned_to']
|
||||||
};
|
};
|
||||||
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
||||||
}
|
}
|
||||||
@@ -1342,7 +1342,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
|
|||||||
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
|
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
|
||||||
|
|
||||||
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
|
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
|
||||||
var title = cells[2 + o] ? cells[2 + o].querySelector('.ticket-link')?.textContent.trim() || '' : '';
|
var title = cells[2 + o] ? cells[2 + o].textContent.trim() : '';
|
||||||
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
|
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
|
||||||
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
|
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
|
||||||
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
|
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
|
||||||
|
|||||||
+42
-56
@@ -215,56 +215,58 @@ include __DIR__ . '/layout_header.php';
|
|||||||
1 => 8, 2 => 24, default => 72
|
1 => 8, 2 => 24, default => 72
|
||||||
};
|
};
|
||||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
||||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
|
||||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
|
||||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
|
||||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
||||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
||||||
role="alert" aria-live="polite"
|
data-sla-id="<?= $slaId ?>"
|
||||||
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
|
||||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
||||||
data-sla-hours="<?= $slaTargetHours ?>"
|
data-sla-hours="<?= $slaTargetHours ?>"
|
||||||
style="margin-bottom:0.75rem">
|
style="margin-bottom:0.75rem">
|
||||||
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
|
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
||||||
<div class="lt-alert-body">
|
<div class="lt-sla-info">
|
||||||
<div class="lt-alert-title"><?= $alertLabel ?></div>
|
<div class="lt-sla-title">
|
||||||
<div class="lt-alert-msg">
|
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
||||||
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
<?php if ($slaBreached) : ?>
|
||||||
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
||||||
<?php if (!$slaBreached) : ?>
|
|
||||||
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
|
||||||
<?php else : ?>
|
|
||||||
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
</div>
|
||||||
aria-label="SLA progress <?= $slaPct ?>%">
|
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
||||||
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">✕</button>
|
<?php if (!$slaBreached) : ?>
|
||||||
|
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
|
||||||
|
<?php endif ?>
|
||||||
|
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
(function(){
|
||||||
var banner = document.getElementById('priorityAlertBanner');
|
var banner = document.getElementById('priorityAlertBanner');
|
||||||
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
|
var id = banner.dataset.slaId;
|
||||||
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
|
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
|
||||||
|
|
||||||
|
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||||
|
banner.hidden = true;
|
||||||
|
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
|
||||||
|
});
|
||||||
|
|
||||||
// Live SLA timers — start after base.js initialises lt
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!banner || banner.classList.contains('dismissed')) return;
|
if (banner.hidden) return;
|
||||||
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||||
var deadline = new Date(createdAt + slaMs);
|
var deadline = new Date(createdAt + slaMs);
|
||||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
var elapsedEl = document.getElementById('slaElapsedTimer');
|
||||||
var countdownEl = document.getElementById('slaCountdownTimer');
|
var countdownEl = document.getElementById('slaCountdownTimer');
|
||||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
var overrunEl = document.getElementById('slaOverrunTimer');
|
||||||
var progressBar = document.getElementById('slaProgressBar');
|
var fillBar = document.getElementById('slaProgressBar');
|
||||||
var progressWrap = document.getElementById('slaProgress');
|
var progressWrap = document.getElementById('slaProgress');
|
||||||
|
|
||||||
function fmtHMS(ms) {
|
function fmtHMS(ms) {
|
||||||
@@ -274,35 +276,19 @@ include __DIR__ . '/layout_header.php';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
var elapsed = now - createdAt;
|
var elapsed = now - createdAt;
|
||||||
var remaining = deadline - now;
|
var remaining = deadline - now;
|
||||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||||
|
|
||||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||||
if (progressBar) progressBar.style.width = pct + '%';
|
if (fillBar) fillBar.style.width = pct + '%';
|
||||||
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||||
|
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
// SLA not yet breached
|
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||||
if (countdownEl) {
|
|
||||||
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
|
||||||
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
|
|
||||||
}
|
|
||||||
if (progressWrap && pct >= 75) {
|
|
||||||
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Breached
|
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
|
||||||
if (countdownEl && !overrunEl) {
|
|
||||||
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
|
|
||||||
countdownEl.className = 'lt-text-danger';
|
|
||||||
} else if (overrunEl) {
|
|
||||||
overrunEl.textContent = fmtHMS(-remaining);
|
|
||||||
}
|
|
||||||
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
|
|
||||||
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
class="lt-btn lt-btn-ghost lt-btn-sm"
|
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
title="Command palette (Ctrl+K)"
|
title="Command palette (Ctrl+K)"
|
||||||
aria-label="Open command palette"
|
aria-label="Open command palette"
|
||||||
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()"
|
|
||||||
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">⌕ K</button>
|
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">⌕ K</button>
|
||||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||||
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||||
@@ -258,6 +257,13 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
});
|
});
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
|
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
|
||||||
|
// Bind the header ⌘K trigger here (no inline onclick — CSP blocks inline handlers)
|
||||||
|
var cmdTrigger = document.getElementById('lt-cmd-trigger');
|
||||||
|
if (cmdTrigger) {
|
||||||
|
cmdTrigger.addEventListener('click', function() {
|
||||||
|
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Keyboard shortcut: Ctrl+K / Cmd+K
|
// Keyboard shortcut: Ctrl+K / Cmd+K
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user