Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a763079e | ||
|
|
35192aaadc | ||
|
|
6609320c83 | ||
|
|
e91f4547b6 | ||
|
|
74544ac5b4 | ||
|
|
863f84f37e | ||
|
|
b73a4c792c | ||
|
|
78ee5fdf48 | ||
|
|
786674abf3 | ||
|
|
dcf9b0cfa1 | ||
|
|
80169de16d | ||
|
|
4aa83ffe58 | ||
|
|
844677bbce | ||
|
|
3fcd1cbf0e | ||
|
|
3ab33d8df2 | ||
|
|
3778a599c3 | ||
|
|
310dcd0840 | ||
|
|
d205a9577a | ||
|
|
5572f0be45 | ||
|
|
1600412a6d | ||
|
|
803c65616b | ||
|
|
0e163f6607 | ||
|
|
e1448d8ea2 | ||
|
|
b6c17096b5 | ||
|
|
6bd1bb082a | ||
|
|
a4828c1b7b | ||
|
|
86ef91abcb | ||
|
|
6d68af40e7 | ||
|
|
aa8173941a | ||
|
|
1b1801696f | ||
|
|
09cea2b388 | ||
|
|
9702aafacd | ||
|
|
66bf82bf46 | ||
|
|
fca0b42726 | ||
|
|
ae12fcd6fd | ||
|
|
5b96e75ff6 | ||
|
|
3db3749c46 | ||
|
|
9e83f8903a | ||
|
|
cabdae35cc | ||
|
|
3266373cdf | ||
|
|
f342e446d3 | ||
|
|
18c213ebd7 | ||
|
|
6adbb29964 | ||
|
|
5a69c41f48 | ||
|
|
6b7e67eee4 | ||
|
|
71bf64c1e2 | ||
|
|
bcc732e605 | ||
|
|
9d8a73c355 | ||
|
|
c78d24154a | ||
|
|
98d30cbc58 | ||
|
|
99a0cf59a8 | ||
|
|
5709c3134f | ||
|
|
60bafae8a0 | ||
|
|
90d798966b | ||
|
|
442cd1d6f6 | ||
|
|
3664719148 | ||
|
|
d7940b1e31 | ||
|
|
fba251b85d | ||
|
|
fd777aa690 | ||
|
|
a9a39adcf8 | ||
|
|
23d94bfae7 | ||
|
|
e39b4f81ea | ||
|
|
3d5adbbfda | ||
|
|
b0765eb7f4 | ||
|
|
d84d8fae58 | ||
|
|
b6d3cc4e70 |
+24
-7
@@ -49,31 +49,48 @@ 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)
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
; LDAP / lldap (for user avatar lookups)
|
; LDAP / lldap (for user avatar lookups). Connects over LDAPS — 6360 is
|
||||||
|
; lldap's default LDAPS port, NOT its plaintext port (3890), since
|
||||||
|
; LDAP_BIND_PW below would otherwise go over the wire unencrypted.
|
||||||
|
; LDAP_HOST must be a hostname matching the LDAPS cert (TLS hostname
|
||||||
|
; verification is not skipped), not a bare IP.
|
||||||
LDAP_ENABLED=true
|
LDAP_ENABLED=true
|
||||||
LDAP_HOST=10.10.10.39
|
LDAP_HOST=ldap.lotusguild.org
|
||||||
LDAP_PORT=3890
|
LDAP_PORT=6360
|
||||||
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
|
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
|
||||||
LDAP_BIND_PW=
|
LDAP_BIND_PW=
|
||||||
LDAP_BASE_DN="dc=example,dc=com"
|
LDAP_BASE_DN="dc=example,dc=com"
|
||||||
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||||
; How long to cache avatar images locally (seconds, default 3600)
|
; How long to cache avatar images locally (seconds, default 3600)
|
||||||
AVATAR_CACHE_TTL=3600
|
AVATAR_CACHE_TTL=3600
|
||||||
|
|
||||||
|
; Session-based rate limits (requests per 60s window). These govern
|
||||||
|
; browser/session traffic on general and API endpoints respectively;
|
||||||
|
; Bearer-key API traffic is rate-limited separately, per API key.
|
||||||
|
RATE_LIMIT_DEFAULT=100
|
||||||
|
RATE_LIMIT_API=60
|
||||||
|
|||||||
+1
-1
@@ -20,6 +20,6 @@
|
|||||||
"no-useless-escape": "warn",
|
"no-useless-escape": "warn",
|
||||||
"no-regex-spaces": "warn",
|
"no-regex-spaces": "warn",
|
||||||
"semi": ["error", "always"],
|
"semi": ["error", "always"],
|
||||||
"eqeqeq": "warn"
|
"eqeqeq": ["warn", "smart"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,12 +97,15 @@ The following features are intentionally **not planned** for this system:
|
|||||||
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
|
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
|
||||||
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
|
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
|
||||||
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
|
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
|
||||||
|
- **Visibility Scope**: keys default to **public-visibility tickets only** — Confidential and Internal tickets are excluded from `/api/tickets_api.php`, same as they'd be for a regular user with no special access. Check **See all visibility** when generating a key only if that specific integration genuinely needs the full queue; this is a deliberate opt-in, not something a key gets by having `read_write` scope or any other setting.
|
||||||
- **Expiration**: Optional expiration dates for keys
|
- **Expiration**: Optional expiration dates for keys
|
||||||
- **Revocation**: Revoke compromised keys instantly
|
- **Revocation**: Revoke compromised keys instantly
|
||||||
|
|
||||||
### Bearer API (automation / triage)
|
### Bearer API (automation / triage)
|
||||||
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
|
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
|
||||||
|
|
||||||
|
`/api/tickets_api.php` filters by ticket visibility according to the key's **Visibility Scope** setting above (public-only by default); every other Bearer endpoint below operates on a single ticket_id supplied by the caller and does not filter a list, so this setting doesn't apply to them.
|
||||||
|
|
||||||
| Endpoint | Method | Scope | Purpose |
|
| Endpoint | Method | Scope | Purpose |
|
||||||
|----------|--------|-------|---------|
|
|----------|--------|-------|---------|
|
||||||
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
|
||||||
@@ -255,6 +258,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
|
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
|
||||||
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
|
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
|
||||||
|
- Numbered migrations on top of the baseline (all idempotent, safe to re-run): `001_widen_bulk_operations_status.sql`, `002_fix_collation_consistency.sql`, `003_fk_on_delete_set_null.sql`, `004_fix_ticket_watchers_type.sql`. A fresh install via `000_baseline.sql` already includes all of these; they only matter for upgrading an existing database.
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
@@ -348,7 +352,9 @@ tinker_tickets/
|
|||||||
│ └── images/
|
│ └── images/
|
||||||
│ └── favicon.png
|
│ └── favicon.png
|
||||||
├── config/
|
├── config/
|
||||||
│ └── config.php # Config + .env loading
|
│ ├── config.php # Config + .env loading
|
||||||
|
│ └── requirements.php # PHP version/extension requirements (single source of
|
||||||
|
│ # truth for scripts/check_requirements.php + api/health.php)
|
||||||
├── controllers/
|
├── controllers/
|
||||||
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
||||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||||
@@ -389,6 +395,10 @@ tinker_tickets/
|
|||||||
│ └── WorkflowModel.php # Status transition workflows
|
│ └── WorkflowModel.php # Status transition workflows
|
||||||
├── migrations/
|
├── migrations/
|
||||||
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
|
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
|
||||||
|
│ ├── 001_widen_bulk_operations_status.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
|
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
|
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
|
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
||||||
@@ -406,6 +416,9 @@ tinker_tickets/
|
|||||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
|
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
|
||||||
|
│ ├── error_403.php # Access-denied error page
|
||||||
|
│ ├── error_404.php # Not-found error page
|
||||||
|
│ ├── error_500.php # Fatal-error page (self-contained, no app-state deps)
|
||||||
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
|
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
|
||||||
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
|
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
|
||||||
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
|
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
|
||||||
@@ -447,6 +460,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
|
||||||
@@ -497,6 +525,9 @@ Add to crontab for recurring tickets and maintenance cleanup:
|
|||||||
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
|
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
|
||||||
# Add --dry-run to preview without deleting.
|
# Add --dry-run to preview without deleting.
|
||||||
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
|
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
|
||||||
|
|
||||||
|
# Retry failed Matrix webhook notifications (exponential backoff, every 5 minutes)
|
||||||
|
*/5 * * * * php /path/to/tinkertickets/cron/retry_failed_notifications.php
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
|
|||||||
+12
-5
@@ -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';
|
||||||
@@ -177,9 +177,16 @@ try {
|
|||||||
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||||
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
||||||
|
|
||||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
|
||||||
if (!empty($mentionedUsers)) {
|
// Only notify mentioned users who actually have access to this ticket;
|
||||||
$mentionedUsernames = array_column($mentionedUsers, 'username');
|
// otherwise a mention would DM them the ticket's title and comment text
|
||||||
|
// even though canUserAccessTicket() would deny them the ticket itself.
|
||||||
|
$accessibleMentionedUsers = array_filter(
|
||||||
|
$mentionedUsers,
|
||||||
|
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
|
||||||
|
);
|
||||||
|
if (!empty($accessibleMentionedUsers)) {
|
||||||
|
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
|
||||||
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||||
if (!empty($mentionedMatrixIds)) {
|
if (!empty($mentionedMatrixIds)) {
|
||||||
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
$ticket['title'] ?? "Ticket #{$ticketId}",
|
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||||
$assigneeName,
|
$assigneeName,
|
||||||
$assigneeMatrix,
|
$assigneeMatrix,
|
||||||
$changedByDisplay
|
$changedByDisplay,
|
||||||
|
$ticket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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';
|
||||||
|
|||||||
+13
-3
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,12 +131,19 @@ if (isset($result['error'])) {
|
|||||||
if ($inaccessibleCount > 0) {
|
if ($inaccessibleCount > 0) {
|
||||||
$message .= " ($inaccessibleCount skipped - no access)";
|
$message .= " ($inaccessibleCount skipped - no access)";
|
||||||
}
|
}
|
||||||
echo json_encode([
|
$response = [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'operation_id' => $operationId,
|
'operation_id' => $operationId,
|
||||||
'processed' => $result['processed'],
|
'processed' => $result['processed'],
|
||||||
'failed' => $result['failed'],
|
'failed' => $result['failed'],
|
||||||
'skipped' => $inaccessibleCount,
|
'skipped' => $inaccessibleCount,
|
||||||
'message' => $message
|
'message' => $message
|
||||||
]);
|
];
|
||||||
|
// Best-effort batches (the default; see processBulkOperation()'s docblock)
|
||||||
|
// can partially fail — surface the per-ticket reasons so the admin isn't
|
||||||
|
// just told a count. The dashboard's bulkResultMessage() already expects this.
|
||||||
|
if (!empty($result['errors'])) {
|
||||||
|
$response['errors'] = $result['errors'];
|
||||||
|
}
|
||||||
|
echo json_encode($response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -94,6 +94,15 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete the generated preview thumbnail alongside the original, if one exists.
|
||||||
|
if (!empty($attachment['thumbnail_filename'])) {
|
||||||
|
$thumbPath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['thumbnail_filename'];
|
||||||
|
$realThumbPath = realpath($thumbPath);
|
||||||
|
if ($realThumbPath !== false && strncmp($realThumbPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) === 0) {
|
||||||
|
@unlink($realThumbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete from database
|
// Delete from database
|
||||||
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
||||||
ResponseHelper::serverError('Failed to delete attachment record');
|
ResponseHelper::serverError('Failed to delete attachment record');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
@@ -66,9 +69,21 @@ try {
|
|||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
|
// Serve the resized preview thumbnail instead of the full-size original
|
||||||
|
// when requested and one was actually generated at upload time; falls
|
||||||
|
// through to the full original otherwise (older attachments predating
|
||||||
|
// thumbnail generation, non-images, or a GD failure at upload time).
|
||||||
|
$wantsThumb = isset($_GET['thumb']) && $_GET['thumb'] === '1';
|
||||||
|
$servedFilename = $attachment['filename'];
|
||||||
|
$servedMimeType = $attachment['mime_type'];
|
||||||
|
if ($wantsThumb && !empty($attachment['thumbnail_filename'])) {
|
||||||
|
$servedFilename = $attachment['thumbnail_filename'];
|
||||||
|
$servedMimeType = 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
// Build file path
|
// Build file path
|
||||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $servedFilename;
|
||||||
|
|
||||||
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
|
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
|
||||||
$realUploadDir = realpath($uploadDir);
|
$realUploadDir = realpath($uploadDir);
|
||||||
@@ -97,17 +112,55 @@ try {
|
|||||||
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
|
$disposition = ($inline && in_array($servedMimeType, $inlineTypes)) ? 'inline' : 'attachment';
|
||||||
|
|
||||||
// Sanitize filename for Content-Disposition
|
// Sanitize filename for Content-Disposition
|
||||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||||
|
|
||||||
header('Content-Type: ' . $attachment['mime_type']);
|
$fileSize = filesize($filePath);
|
||||||
|
|
||||||
|
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
|
||||||
|
// Multi-range requests aren't supported; they fall through to a full 200 response.
|
||||||
|
$rangeStart = 0;
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = false;
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
|
||||||
|
if ($m[1] === '' && $m[2] === '') {
|
||||||
|
// Malformed ("bytes=-") — ignore and serve the full file.
|
||||||
|
} elseif ($m[1] === '') {
|
||||||
|
// Suffix range: last N bytes
|
||||||
|
$suffixLength = (int)$m[2];
|
||||||
|
$rangeStart = max(0, $fileSize - $suffixLength);
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = true;
|
||||||
|
} else {
|
||||||
|
$rangeStart = (int)$m[1];
|
||||||
|
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
|
||||||
|
$isRangeRequest = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
|
||||||
|
http_response_code(416);
|
||||||
|
header('Content-Range: bytes */' . $fileSize);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rangeLength = $rangeEnd - $rangeStart + 1;
|
||||||
|
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
|
header('Content-Type: ' . $servedMimeType);
|
||||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||||
header('Content-Length: ' . $attachment['file_size']);
|
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
if ($isRangeRequest) {
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
|
||||||
|
}
|
||||||
|
header('Content-Length: ' . $rangeLength);
|
||||||
|
|
||||||
// Prevent PHP from timing out on large files
|
// Prevent PHP from timing out on large files
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
|
||||||
@@ -125,9 +178,18 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!feof($handle)) {
|
fseek($handle, $rangeStart);
|
||||||
echo fread($handle, 8192);
|
$remaining = $rangeLength;
|
||||||
|
$chunkSize = 8192;
|
||||||
|
while ($remaining > 0 && !feof($handle)) {
|
||||||
|
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
|
||||||
|
$data = fread($handle, $read);
|
||||||
|
if ($data === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
echo $data;
|
||||||
flush();
|
flush();
|
||||||
|
$remaining -= strlen($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ try {
|
|||||||
$keyName = trim($input['key_name'] ?? '');
|
$keyName = trim($input['key_name'] ?? '');
|
||||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||||
$scope = $input['scope'] ?? 'read_write';
|
$scope = $input['scope'] ?? 'read_write';
|
||||||
|
$seeAllVisibility = !empty($input['see_all_visibility']);
|
||||||
|
|
||||||
if (empty($keyName)) {
|
if (empty($keyName)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -93,7 +101,7 @@ try {
|
|||||||
|
|
||||||
// Generate API key
|
// Generate API key
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
|
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope, $seeAllVisibility);
|
||||||
|
|
||||||
if (!$result['success']) {
|
if (!$result['success']) {
|
||||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||||
@@ -106,7 +114,7 @@ try {
|
|||||||
'create',
|
'create',
|
||||||
'api_key',
|
'api_key',
|
||||||
$result['key_id'],
|
$result['key_id'],
|
||||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
|
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope, 'see_all_visibility' => $seeAllVisibility]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear output buffer
|
// Clear output buffer
|
||||||
@@ -120,6 +128,7 @@ try {
|
|||||||
'key_prefix' => $result['key_prefix'],
|
'key_prefix' => $result['key_prefix'],
|
||||||
'key_id' => $result['key_id'],
|
'key_id' => $result['key_id'],
|
||||||
'scope' => $result['scope'],
|
'scope' => $result['scope'],
|
||||||
|
'see_all_visibility' => $result['see_all_visibility'],
|
||||||
'expires_at' => $result['expires_at']
|
'expires_at' => $result['expires_at']
|
||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,13 +97,39 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$wf_active = (int)($data['is_active'] ?? 1);
|
||||||
|
|
||||||
|
// status_transitions already has a DB-level UNIQUE KEY on
|
||||||
|
// (from_status, to_status) (regardless of is_active), so a
|
||||||
|
// duplicate pair can't actually be inserted — but hitting that
|
||||||
|
// constraint raw surfaces as an opaque "internal error occurred"
|
||||||
|
// to the admin instead of a clear message. Check first so the
|
||||||
|
// common case (an admin re-adding a pair that already exists)
|
||||||
|
// gets a friendly, specific error.
|
||||||
|
$dupCheck = $conn->prepare(
|
||||||
|
"SELECT transition_id FROM status_transitions WHERE from_status = ? AND to_status = ?"
|
||||||
|
);
|
||||||
|
$dupCheck->bind_param('ss', $data['from_status'], $data['to_status']);
|
||||||
|
$dupCheck->execute();
|
||||||
|
if ($dupCheck->get_result()->fetch_assoc()) {
|
||||||
|
$dupCheck->close();
|
||||||
|
http_response_code(409);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A transition already exists for '
|
||||||
|
. $data['from_status'] . ' → ' . $data['to_status']
|
||||||
|
. ' — edit that row instead of creating a duplicate.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$dupCheck->close();
|
||||||
|
|
||||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?)");
|
VALUES (?, ?, ?, ?, ?)");
|
||||||
$wf_from = $data['from_status'];
|
$wf_from = $data['from_status'];
|
||||||
$wf_to = $data['to_status'];
|
$wf_to = $data['to_status'];
|
||||||
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||||
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||||
$wf_active = (int)($data['is_active'] ?? 1);
|
|
||||||
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
|
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
@@ -149,6 +175,28 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$wf_active = (int)($data['is_active'] ?? 1);
|
||||||
|
|
||||||
|
// Same duplicate-pair guard as create, excluding this row itself.
|
||||||
|
$dupCheck = $conn->prepare(
|
||||||
|
"SELECT transition_id FROM status_transitions
|
||||||
|
WHERE from_status = ? AND to_status = ? AND transition_id != ?"
|
||||||
|
);
|
||||||
|
$dupCheck->bind_param('ssi', $data['from_status'], $data['to_status'], $id);
|
||||||
|
$dupCheck->execute();
|
||||||
|
if ($dupCheck->get_result()->fetch_assoc()) {
|
||||||
|
$dupCheck->close();
|
||||||
|
http_response_code(409);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A transition already exists for '
|
||||||
|
. $data['from_status'] . ' → ' . $data['to_status']
|
||||||
|
. ' — edit that row instead of creating a duplicate.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$dupCheck->close();
|
||||||
|
|
||||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||||
WHERE transition_id = ?");
|
WHERE transition_id = ?");
|
||||||
@@ -156,7 +204,6 @@ try {
|
|||||||
$wf_to = $data['to_status'];
|
$wf_to = $data['to_status'];
|
||||||
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||||
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||||
$wf_active = (int)($data['is_active'] ?? 1);
|
|
||||||
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
|
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
|
||||||
|
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
|
|||||||
+41
-2
@@ -15,8 +15,10 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
$prefsModel = new UserPreferencesModel($conn);
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
|
||||||
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
@@ -138,7 +140,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
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
|
||||||
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
|
||||||
WHERE al.action_type = 'update'
|
WHERE al.action_type = 'update'
|
||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
|
|||||||
$all[] = $row;
|
$all[] = $row;
|
||||||
}
|
}
|
||||||
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
$all = array_slice($all, 0, 30);
|
|
||||||
|
// Re-check current ticket visibility before surfacing anything: a
|
||||||
|
// notification's audit_log entry reflects historical activity, but the
|
||||||
|
// ticket's visibility (or the user's group/watcher standing) may have
|
||||||
|
// tightened since. Without this, a notification still discloses the
|
||||||
|
// ticket's title and that activity occurred to someone who currently
|
||||||
|
// shouldn't see it, even though the ticket view's own access check would
|
||||||
|
// correctly reject them from opening it.
|
||||||
|
$candidateTicketIds = [];
|
||||||
|
foreach ($all as $row) {
|
||||||
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
|
? 'comment'
|
||||||
|
: $row['action_type'];
|
||||||
|
$tid = ($actionType === 'comment' || $actionType === 'mention')
|
||||||
|
? ($details['ticket_id'] ?? 0)
|
||||||
|
: $row['entity_id'];
|
||||||
|
if ($tid) {
|
||||||
|
$candidateTicketIds[(string)$tid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ticketsById = !empty($candidateTicketIds)
|
||||||
|
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
|
||||||
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
|
? 'comment'
|
||||||
|
: $row['action_type'];
|
||||||
|
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
|
||||||
|
? ($details['ticket_id'] ?? 0)
|
||||||
|
: $row['entity_id']);
|
||||||
|
$ticket = $ticketsById[$tid] ?? null;
|
||||||
|
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
$all = array_slice(array_values($all), 0, 30);
|
||||||
|
|
||||||
// Format for response
|
// Format for response
|
||||||
$notifications = [];
|
$notifications = [];
|
||||||
|
|||||||
+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');
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save custom field values for a ticket.
|
||||||
|
*
|
||||||
|
* POST { ticket_id, values: { [field_id]: value, ... } }
|
||||||
|
*
|
||||||
|
* Only fields applicable to the ticket's current category (or category-less
|
||||||
|
* fields) are considered; anything else in `values` is ignored rather than
|
||||||
|
* persisted, so a value typed for a field that no longer applies (e.g. the
|
||||||
|
* category changed) can't linger as orphaned/misleading data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
|
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
|
||||||
|
|
||||||
|
if ($ticketId === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
apiRespond(['success' => false, 'error' => 'ticket_id required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldModel = new CustomFieldModel($conn);
|
||||||
|
$definitions = $fieldModel->getAllDefinitions($ticket['category'], true);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$toSave = [];
|
||||||
|
foreach ($definitions as $def) {
|
||||||
|
$fieldId = (int)$def['field_id'];
|
||||||
|
$raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null);
|
||||||
|
|
||||||
|
if ($def['field_type'] === 'checkbox') {
|
||||||
|
$normalized = !empty($raw) ? '1' : '0';
|
||||||
|
} else {
|
||||||
|
$normalized = is_scalar($raw) ? trim((string)$raw) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
|
||||||
|
$errors[] = $def['field_label'] . ' is required';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($def['field_type'] === 'select' && $normalized !== '') {
|
||||||
|
$allowedOptions = $def['field_options']['options'] ?? [];
|
||||||
|
if (!in_array($normalized, $allowedOptions, true)) {
|
||||||
|
$errors[] = $def['field_label'] . ' has an invalid selection';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) {
|
||||||
|
$errors[] = $def['field_label'] . ' must be a number';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$toSave[$fieldId] = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
http_response_code(422);
|
||||||
|
apiRespond(['success' => false, 'error' => implode('; ', $errors)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldModel->setValues($ticketId, $toSave);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [
|
||||||
|
'reason' => 'custom fields updated',
|
||||||
|
]);
|
||||||
|
|
||||||
|
apiRespond(['success' => true]);
|
||||||
@@ -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()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-22
@@ -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');
|
||||||
@@ -126,23 +126,6 @@ if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $co
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post the comment first (per-key label) so a close-with-reason is one call.
|
|
||||||
if ($comment !== '') {
|
|
||||||
$commentModel = new CommentModel($conn);
|
|
||||||
$commentResult = $commentModel->addComment($ticketId, [
|
|
||||||
'user_name' => $keyName,
|
|
||||||
'comment_text' => $comment,
|
|
||||||
'markdown_enabled' => !empty($data['markdown_enabled']),
|
|
||||||
], $createdBy);
|
|
||||||
if (empty($commentResult['success'])) {
|
|
||||||
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
|
|
||||||
. ': ' . ($commentResult['error'] ?? 'unknown'));
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the status change. updateTicket sets updated_by/updated_at and handles
|
// Apply the status change. updateTicket sets updated_by/updated_at and handles
|
||||||
// closed_at (set on close, cleared on reopen) via its own SQL.
|
// closed_at (set on close, cleared on reopen) via its own SQL.
|
||||||
$updateData = [
|
$updateData = [
|
||||||
@@ -155,10 +138,33 @@ $updateData = [
|
|||||||
'priority' => (int)$ticket['priority'],
|
'priority' => (int)$ticket['priority'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Post the comment and apply the status change in one transaction, so a
|
||||||
|
// failure partway through can't leave a "reason" comment persisted with no
|
||||||
|
// matching status change (previously these were two independent writes with
|
||||||
|
// no shared rollback).
|
||||||
|
$conn->begin_transaction();
|
||||||
|
try {
|
||||||
|
if ($comment !== '') {
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$commentResult = $commentModel->addComment($ticketId, [
|
||||||
|
'user_name' => $keyName,
|
||||||
|
'comment_text' => $comment,
|
||||||
|
'markdown_enabled' => !empty($data['markdown_enabled']),
|
||||||
|
], $createdBy);
|
||||||
|
if (empty($commentResult['success'])) {
|
||||||
|
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
|
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
|
||||||
if (empty($updateResult['success'])) {
|
if (empty($updateResult['success'])) {
|
||||||
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
|
throw new Exception($updateResult['error'] ?? 'Failed to update ticket status');
|
||||||
. ': ' . ($updateResult['error'] ?? 'unknown'));
|
}
|
||||||
|
|
||||||
|
$conn->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$conn->rollback();
|
||||||
|
error_log('ticket_status_api: transaction failed for ticket ' . $ticketId . ': ' . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
|
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
|
||||||
exit;
|
exit;
|
||||||
@@ -171,7 +177,8 @@ if ($currentStatus !== $newStatus) {
|
|||||||
$currentStatus,
|
$currentStatus,
|
||||||
$newStatus,
|
$newStatus,
|
||||||
(string)$ticket['title'],
|
(string)$ticket['title'],
|
||||||
$keyName
|
$keyName,
|
||||||
|
$ticket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
NotificationHelper::notifyWatchers(
|
NotificationHelper::notifyWatchers(
|
||||||
$conn,
|
$conn,
|
||||||
|
|||||||
+32
-7
@@ -4,8 +4,10 @@
|
|||||||
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
|
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
|
||||||
*
|
*
|
||||||
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
|
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
|
||||||
* Acts as a trusted automation/server credential: reads return the full queue
|
* By default, a key only sees public-visibility tickets — Confidential and
|
||||||
* (no per-user visibility filtering).
|
* Internal tickets are excluded, the same as they would be for a regular user
|
||||||
|
* with no special access. An admin can mark a specific key see_all_visibility
|
||||||
|
* (API Key Management) when it genuinely needs the full queue.
|
||||||
*
|
*
|
||||||
* GET ?ticket_id=NNN -> {success, ticket, comments}
|
* GET ?ticket_id=NNN -> {success, ticket, comments}
|
||||||
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
|
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
|
||||||
@@ -14,8 +16,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';
|
||||||
@@ -48,6 +50,20 @@ try {
|
|||||||
// Reads only need the 'read' scope.
|
// Reads only need the 'read' scope.
|
||||||
$apiKeyAuth->requireScope('read');
|
$apiKeyAuth->requireScope('read');
|
||||||
|
|
||||||
|
// Keys are public-ticket-only by default (#70) — a key must be explicitly
|
||||||
|
// marked see_all_visibility to bypass Confidential/Internal restrictions.
|
||||||
|
// Reuse TicketModel's existing per-user visibility plumbing with a synthetic
|
||||||
|
// "no special access" user rather than a separate SQL path, so this stays in
|
||||||
|
// lockstep with however visibility rules evolve for real users. user_id is
|
||||||
|
// -1, not 0: canUserAccessTicket() does a PHP-level (int) cast for the
|
||||||
|
// confidential-ticket check, and (int)null === 0, so an unassigned
|
||||||
|
// confidential ticket's assigned_to would otherwise false-positive-match a
|
||||||
|
// synthetic user_id of 0. No real user_id is ever <= 0, so -1 can't collide.
|
||||||
|
$keyContext = $apiKeyAuth->getKeyContext();
|
||||||
|
$visibilityUser = !empty($keyContext['see_all_visibility'])
|
||||||
|
? null
|
||||||
|
: ['user_id' => -1, 'is_admin' => false, 'groups' => ''];
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
|
||||||
@@ -67,6 +83,15 @@ if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A public-only key gets a plain 404 for a non-public ticket — same as a
|
||||||
|
// regular user hitting a ticket they can't see — rather than a 403 that
|
||||||
|
// would confirm the ticket exists.
|
||||||
|
if ($visibilityUser !== null && !$ticketModel->canUserAccessTicket($ticket, $visibilityUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Flat list of comments (newest first) — same fetch the ticket view uses.
|
// Flat list of comments (newest first) — same fetch the ticket view uses.
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
|
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
|
||||||
@@ -114,8 +139,8 @@ if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
|
|||||||
$search = trim((string)$_GET['host']);
|
$search = trim((string)$_GET['host']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// user = null => getAllTickets skips visibility filtering and returns the full
|
// $visibilityUser is null (skip filtering, full queue) only for a key marked
|
||||||
// queue (this is a trusted server credential, not an end user).
|
// see_all_visibility; otherwise it restricts to public tickets (see above).
|
||||||
$result = $ticketModel->getAllTickets(
|
$result = $ticketModel->getAllTickets(
|
||||||
$page,
|
$page,
|
||||||
$limit,
|
$limit,
|
||||||
@@ -126,7 +151,7 @@ $result = $ticketModel->getAllTickets(
|
|||||||
null,
|
null,
|
||||||
$search,
|
$search,
|
||||||
$filters,
|
$filters,
|
||||||
null
|
$visibilityUser
|
||||||
);
|
);
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -104,7 +104,10 @@ try {
|
|||||||
'update',
|
'update',
|
||||||
'comment',
|
'comment',
|
||||||
(string)$commentId,
|
(string)$commentId,
|
||||||
['comment_text_preview' => substr($commentText, 0, 100)]
|
[
|
||||||
|
'ticket_id' => $comment['ticket_id'] ?? null,
|
||||||
|
'comment_text_preview' => substr($commentText, 0, 100),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-18
@@ -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';
|
||||||
@@ -195,8 +195,8 @@ try {
|
|||||||
|
|
||||||
// Enforce requires_comment transitions server-side.
|
// Enforce requires_comment transitions server-side.
|
||||||
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
||||||
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||||
if ($comment === '') {
|
if ($statusChangeComment === '') {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'A comment is required for this status change',
|
'error' => 'A comment is required for this status change',
|
||||||
@@ -207,27 +207,55 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A comment accompanying a status change (required or optional) is
|
||||||
|
// persisted in the SAME transaction as the status update below, so
|
||||||
|
// a failure partway through can't leave an orphaned "reason"
|
||||||
|
// comment attached with no matching status change — the two
|
||||||
|
// previously ran as separate, non-transactional HTTP calls from
|
||||||
|
// the client (add_comment.php then update_ticket.php).
|
||||||
|
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
$this->conn->begin_transaction();
|
||||||
|
try {
|
||||||
|
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
|
||||||
|
$commentResult = $this->commentModel->addComment($id, [
|
||||||
|
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
|
||||||
|
'comment_text' => $statusChangeComment,
|
||||||
|
'markdown_enabled' => !empty($data['markdown_enabled']),
|
||||||
|
], $this->userId);
|
||||||
|
if (empty($commentResult['success'])) {
|
||||||
|
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update ticket with user tracking and optional optimistic locking
|
// Update ticket with user tracking and optional optimistic locking
|
||||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||||
|
|
||||||
// Handle conflict case
|
|
||||||
if (!$result['success']) {
|
if (!$result['success']) {
|
||||||
$response = [
|
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
|
||||||
'success' => false,
|
}
|
||||||
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
|
||||||
];
|
// Handle visibility update if provided (already validated above)
|
||||||
if (!empty($result['conflict'])) {
|
if (isset($data['visibility'])) {
|
||||||
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
|
if (!$visResult) {
|
||||||
|
throw new Exception('Failed to update ticket visibility');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->conn->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->conn->rollback();
|
||||||
|
$response = ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
if (is_array($result) && !empty($result['conflict'])) {
|
||||||
$response['conflict'] = true;
|
$response['conflict'] = true;
|
||||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||||
}
|
}
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle visibility update if provided (already validated above)
|
if (isset($data['visibility']) && $this->userId) {
|
||||||
if (isset($data['visibility'])) {
|
|
||||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
|
||||||
if ($visResult && $this->userId) {
|
|
||||||
$this->auditLog->log(
|
$this->auditLog->log(
|
||||||
$this->userId,
|
$this->userId,
|
||||||
'update',
|
'update',
|
||||||
@@ -241,7 +269,6 @@ try {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Log ticket update to audit log — only the changed fields (delta)
|
// Log ticket update to audit log — only the changed fields (delta)
|
||||||
if ($this->userId) {
|
if ($this->userId) {
|
||||||
@@ -267,7 +294,8 @@ try {
|
|||||||
$currentTicket['status'],
|
$currentTicket['status'],
|
||||||
$updateData['status'],
|
$updateData['status'],
|
||||||
$updateData['title'],
|
$updateData['title'],
|
||||||
$changedBy
|
$changedBy,
|
||||||
|
$currentTicket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
NotificationHelper::notifyWatchers(
|
NotificationHelper::notifyWatchers(
|
||||||
$this->conn,
|
$this->conn,
|
||||||
@@ -275,7 +303,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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
-9
@@ -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';
|
||||||
@@ -96,6 +96,72 @@ function stripImageMetadata(string $path, string $mimeType): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a resized preview thumbnail for an uploaded image, saved as a JPEG
|
||||||
|
* alongside the original regardless of source format (a thumbnail is a small
|
||||||
|
* lossy preview, not an archival copy). Longest side capped at
|
||||||
|
* THUMBNAIL_MAX_DIMENSION; images already at or below that size are still
|
||||||
|
* re-encoded (cheap) rather than skipped, so the thumbnail is guaranteed to
|
||||||
|
* be a JPEG the grid can always request the same way.
|
||||||
|
*
|
||||||
|
* Best-effort like stripImageMetadata(): returns null on any failure
|
||||||
|
* (corrupt image, unsupported format, GD unavailable) rather than blocking
|
||||||
|
* the upload, and the caller falls back to serving the full-size original.
|
||||||
|
*
|
||||||
|
* @return string|null Basename of the generated thumbnail file, or null
|
||||||
|
*/
|
||||||
|
function generateThumbnail(string $path, string $mimeType, string $destDir): ?string
|
||||||
|
{
|
||||||
|
if (!extension_loaded('gd')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same decompression-bomb guard as stripImageMetadata().
|
||||||
|
$dims = @getimagesize($path);
|
||||||
|
if ($dims === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$width, $height] = $dims;
|
||||||
|
if ($width * $height > 40_000_000) { // ~40 MP cap
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$loaders = [
|
||||||
|
'image/jpeg' => 'imagecreatefromjpeg',
|
||||||
|
'image/png' => 'imagecreatefrompng',
|
||||||
|
'image/gif' => 'imagecreatefromgif',
|
||||||
|
'image/webp' => 'imagecreatefromwebp',
|
||||||
|
];
|
||||||
|
$loader = $loaders[$mimeType] ?? null;
|
||||||
|
if ($loader === null || !function_exists($loader)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = @$loader($path);
|
||||||
|
if ($source === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxDimension = 300;
|
||||||
|
$scale = min(1.0, $maxDimension / max($width, $height));
|
||||||
|
$thumbWidth = max(1, (int)round($width * $scale));
|
||||||
|
$thumbHeight = max(1, (int)round($height * $scale));
|
||||||
|
|
||||||
|
$thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
|
||||||
|
// Flatten transparency onto white — thumbnails are always opaque JPEGs.
|
||||||
|
$white = imagecolorallocate($thumb, 255, 255, 255);
|
||||||
|
imagefill($thumb, 0, 0, $white);
|
||||||
|
imagecopyresampled($thumb, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
|
||||||
|
imagedestroy($source);
|
||||||
|
|
||||||
|
$thumbFilename = pathinfo($path, PATHINFO_FILENAME) . '_thumb.jpg';
|
||||||
|
$thumbPath = rtrim($destDir, '/') . '/' . $thumbFilename;
|
||||||
|
$saved = imagejpeg($thumb, $thumbPath, 80);
|
||||||
|
imagedestroy($thumb);
|
||||||
|
|
||||||
|
return $saved ? $thumbFilename : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
ResponseHelper::unauthorized();
|
ResponseHelper::unauthorized();
|
||||||
@@ -114,6 +180,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
|
||||||
|
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
@@ -123,15 +192,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$attachmentModel = new AttachmentModel($conn);
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$total = $attachmentModel->getAttachmentCount($ticketId);
|
||||||
|
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
foreach ($attachments as &$att) {
|
foreach ($attachments as &$att) {
|
||||||
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
||||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||||
|
$att['has_thumbnail'] = !empty($att['thumbnail_filename']);
|
||||||
|
unset($att['thumbnail_filename']); // internal storage detail, not needed by the client
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseHelper::success(['attachments' => $attachments]);
|
ResponseHelper::success([
|
||||||
|
'attachments' => $attachments,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ResponseHelper::serverError('Failed to load attachments');
|
ResponseHelper::serverError('Failed to load attachments');
|
||||||
}
|
}
|
||||||
@@ -145,7 +223,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
|
||||||
@@ -268,9 +346,14 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
|||||||
ResponseHelper::serverError('Failed to move uploaded file');
|
ResponseHelper::serverError('Failed to move uploaded file');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip EXIF/GPS metadata from image uploads before it's ever served back
|
// Strip EXIF/GPS metadata from image uploads before it's ever served back,
|
||||||
|
// then generate a resized preview thumbnail from the (now metadata-stripped)
|
||||||
|
// original so the grid never has to transfer the full-size file just to
|
||||||
|
// render a small preview.
|
||||||
|
$thumbnailFilename = null;
|
||||||
if (str_starts_with($mimeType, 'image/')) {
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
stripImageMetadata($targetPath, $mimeType);
|
stripImageMetadata($targetPath, $mimeType);
|
||||||
|
$thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize original filename
|
// Sanitize original filename
|
||||||
@@ -288,12 +371,16 @@ try {
|
|||||||
$originalFilename,
|
$originalFilename,
|
||||||
$file['size'],
|
$file['size'],
|
||||||
$mimeType,
|
$mimeType,
|
||||||
$_SESSION['user']['user_id']
|
$_SESSION['user']['user_id'],
|
||||||
|
$thumbnailFilename
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$attachmentId) {
|
if (!$attachmentId) {
|
||||||
// Clean up file if database insert fails
|
// Clean up file (and any thumbnail) if database insert fails
|
||||||
unlink($targetPath);
|
unlink($targetPath);
|
||||||
|
if ($thumbnailFilename !== null) {
|
||||||
|
@unlink($ticketDir . '/' . $thumbnailFilename);
|
||||||
|
}
|
||||||
ResponseHelper::serverError('Failed to save attachment record');
|
ResponseHelper::serverError('Failed to save attachment record');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,13 +407,17 @@ try {
|
|||||||
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
||||||
'mime_type' => $mimeType,
|
'mime_type' => $mimeType,
|
||||||
'icon' => AttachmentModel::getFileIcon($mimeType),
|
'icon' => AttachmentModel::getFileIcon($mimeType),
|
||||||
|
'has_thumbnail' => $thumbnailFilename !== null,
|
||||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||||
'uploaded_at' => date('Y-m-d H:i:s')
|
'uploaded_at' => date('Y-m-d H:i:s')
|
||||||
], 'File uploaded successfully');
|
], 'File uploaded successfully');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Clean up file on error
|
// Clean up file (and any thumbnail) on error
|
||||||
if (file_exists($targetPath)) {
|
if (file_exists($targetPath)) {
|
||||||
unlink($targetPath);
|
unlink($targetPath);
|
||||||
}
|
}
|
||||||
|
if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
|
||||||
|
@unlink($ticketDir . '/' . $thumbnailFilename);
|
||||||
|
}
|
||||||
ResponseHelper::serverError('Failed to process attachment');
|
ResponseHelper::serverError('Failed to process attachment');
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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');
|
||||||
@@ -113,7 +113,10 @@ $avatarData = null;
|
|||||||
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
|
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
|
// LDAPS, not plain ldap:// — LDAP_BIND_PW is sent during ldap_bind() below,
|
||||||
|
// and lldap's plaintext port (3890) would put it on the wire unencrypted.
|
||||||
|
// lldap's LDAPS listener defaults to port 6360 (see config.php).
|
||||||
|
$ldap = @ldap_connect("ldaps://$ldapHost:$ldapPort");
|
||||||
if (!$ldap) {
|
if (!$ldap) {
|
||||||
throw new RuntimeException("ldap_connect failed");
|
throw new RuntimeException("ldap_connect failed");
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-14
@@ -9,52 +9,65 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
$ticketId = isset($_GET['ticket_id'])
|
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
|
||||||
? (int)$_GET['ticket_id']
|
|
||||||
: (int)($data['ticket_id'] ?? 0);
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
$ticketIdRaw = $data['ticket_id'] ?? '';
|
||||||
$action = $data['action'] ?? '';
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the canonical ticket_id string from the fetched ticket row, not the
|
||||||
|
// raw request value, so ticket_watchers always stores exactly what's in
|
||||||
|
// tickets.ticket_id.
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
if ($action === 'watch') {
|
if ($action === 'watch') {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
$rowsChanged = $stmt->affected_rows;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
} else {
|
} else {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
$rowsChanged = $stmt->affected_rows;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only log an actual state change — INSERT IGNORE/DELETE are no-ops when
|
||||||
|
// the user was already watching/not watching, and that shouldn't show up
|
||||||
|
// in the ticket's timeline as a new event.
|
||||||
|
if ($rowsChanged > 0) {
|
||||||
|
(new AuditLogModel($conn))->log($userId, $action, 'ticket', $ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
// Return updated state
|
// Return updated state
|
||||||
$countStmt = $conn->prepare(
|
$countStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
@@ -73,7 +86,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ticketId <= 0) {
|
if ($ticketIdRaw === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
exit;
|
exit;
|
||||||
@@ -83,17 +96,19 @@ if ($ticketId <= 0) {
|
|||||||
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||||
// already checks this).
|
// already checks this).
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
$watchingStmt = $conn->prepare(
|
$watchingStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
$watchingStmt->bind_param("si", $ticketId, $userId);
|
||||||
$watchingStmt->execute();
|
$watchingStmt->execute();
|
||||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$watchingStmt->close();
|
$watchingStmt->close();
|
||||||
@@ -107,7 +122,7 @@ $watchersStmt = $conn->prepare(
|
|||||||
ORDER BY tw.created_at ASC
|
ORDER BY tw.created_at ASC
|
||||||
LIMIT 6"
|
LIMIT 6"
|
||||||
);
|
);
|
||||||
$watchersStmt->bind_param("i", $ticketId);
|
$watchersStmt->bind_param("s", $ticketId);
|
||||||
$watchersStmt->execute();
|
$watchersStmt->execute();
|
||||||
$watchersResult = $watchersStmt->get_result();
|
$watchersResult = $watchersStmt->get_result();
|
||||||
$watchers = [];
|
$watchers = [];
|
||||||
@@ -118,7 +133,7 @@ $watchersStmt->close();
|
|||||||
|
|
||||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
// 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 = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
|
|||||||
@@ -61,25 +61,46 @@ function populateCurrentFilters() {
|
|||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
// Search text
|
// Search text
|
||||||
if (urlParams.has('search')) {
|
document.getElementById('adv-search-text').value = urlParams.get('search') || '';
|
||||||
document.getElementById('adv-search-text').value = urlParams.get('search');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
if (urlParams.has('status')) {
|
const statuses = urlParams.has('status') ? urlParams.get('status').split(',') : [];
|
||||||
const statuses = urlParams.get('status').split(',');
|
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
Array.from(statusSelect.options).forEach(option => {
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
option.selected = statuses.includes(option.value);
|
option.selected = statuses.includes(option.value);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// Date ranges
|
||||||
|
document.getElementById('adv-created-from').value = urlParams.get('created_from') || '';
|
||||||
|
document.getElementById('adv-created-to').value = urlParams.get('created_to') || '';
|
||||||
|
document.getElementById('adv-updated-from').value = urlParams.get('updated_from') || '';
|
||||||
|
document.getElementById('adv-updated-to').value = urlParams.get('updated_to') || '';
|
||||||
|
|
||||||
|
// Priority range
|
||||||
|
document.getElementById('adv-priority-min').value = urlParams.get('priority_min') || '';
|
||||||
|
document.getElementById('adv-priority-max').value = urlParams.get('priority_max') || '';
|
||||||
|
|
||||||
|
// Users
|
||||||
|
document.getElementById('adv-created-by').value = urlParams.get('created_by') || '';
|
||||||
|
document.getElementById('adv-assigned-to').value = urlParams.get('assigned_to') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform advanced search
|
// Perform advanced search
|
||||||
function performAdvancedSearch(event) {
|
function performAdvancedSearch(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
// Start from the CURRENT URL's params, not a fresh set, so a filter this
|
||||||
|
// form doesn't represent (e.g. a category/type filter applied via a
|
||||||
|
// dashboard quick-filter pill or stats-widget click) isn't silently
|
||||||
|
// dropped on submit. Only the params this form actually controls are
|
||||||
|
// set/cleared below; everything else passes through untouched.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const advParams = [
|
||||||
|
'search', 'created_from', 'created_to', 'updated_from', 'updated_to',
|
||||||
|
'status', 'priority_min', 'priority_max', 'created_by', 'assigned_to',
|
||||||
|
];
|
||||||
|
advParams.forEach(key => params.delete(key));
|
||||||
|
params.delete('page'); // filters changed — reset to page 1
|
||||||
|
|
||||||
// Search text
|
// Search text
|
||||||
const searchText = document.getElementById('adv-search-text').value.trim();
|
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||||
|
|||||||
+29
-75
@@ -41,6 +41,16 @@
|
|||||||
* 32. Drag & Drop Upload
|
* 32. Drag & Drop Upload
|
||||||
* 33. Intersection Observer
|
* 33. Intersection Observer
|
||||||
* 34. Full Initialisation
|
* 34. Full Initialisation
|
||||||
|
*
|
||||||
|
* NOTE ON EMPTY CATCH BLOCKS: throughout this file, `try { ... } catch (_) {}`
|
||||||
|
* around localStorage/sessionStorage access (persisted tab/theme/column-
|
||||||
|
* visibility state, recent command-palette entries, etc.) and the terminal
|
||||||
|
* beep's AudioContext calls is intentional, not an oversight — these are
|
||||||
|
* best-effort UX affordances that must silently no-op rather than break the
|
||||||
|
* surrounding feature if storage is disabled/full (private browsing, quota)
|
||||||
|
* or audio is blocked (autoplay policy). Swallowing errors from arbitrary
|
||||||
|
* caller-supplied callbacks (e.g. viewport-change listeners) is handled
|
||||||
|
* separately with real logging, since those can hide genuine bugs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function (global) {
|
(function (global) {
|
||||||
@@ -1398,7 +1408,7 @@
|
|||||||
_vpCurrent = bp;
|
_vpCurrent = bp;
|
||||||
if (bp !== prev) {
|
if (bp !== prev) {
|
||||||
const evt = { bp, w, h, prev };
|
const evt = { bp, w, h, prev };
|
||||||
_vpListeners.forEach(cb => { try { cb(evt); } catch (_) {} });
|
_vpListeners.forEach(cb => { try { cb(evt); } catch (e) { console.error('[lt.viewport] listener threw:', e); } });
|
||||||
bus.emit('viewport:change', evt);
|
bus.emit('viewport:change', evt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2801,7 +2811,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
|
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
|
||||||
async function _apiFetchAuth(method, url, body) {
|
async function _apiFetchAuth(method, url, body, retried) {
|
||||||
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
|
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
|
||||||
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
|
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
|
||||||
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
|
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
|
||||||
@@ -2821,6 +2831,15 @@
|
|||||||
// Resync CSRF token from any response body that carries a fresh one
|
// Resync CSRF token from any response body that carries a fresh one
|
||||||
// (bootstrap rotates on success and returns the current token on rejection).
|
// (bootstrap rotates on success and returns the current token on rejection).
|
||||||
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||||
|
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
|
||||||
|
// shorter than the session idle timeout (5h), so this is a routine,
|
||||||
|
// recoverable case (an hour of inactivity, or a write in another tab
|
||||||
|
// rotating the shared token) rather than a real rejection — resyncing
|
||||||
|
// above already has the fresh token, so silently resending once succeeds
|
||||||
|
// transparently instead of surfacing a confusing error on the first try.
|
||||||
|
if (resp.status === 403 && !retried && data && data.csrf_token) {
|
||||||
|
return _apiFetchAuth(method, url, body, true);
|
||||||
|
}
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||||
err.data = data;
|
err.data = data;
|
||||||
@@ -2839,8 +2858,11 @@
|
|||||||
TICKET STATUS CHANGE (comment-aware)
|
TICKET STATUS CHANGE (comment-aware)
|
||||||
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
|
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
|
||||||
Posts /api/update_ticket.php. If the server rejects with
|
Posts /api/update_ticket.php. If the server rejects with
|
||||||
requires_comment, opens a comment modal, persists the comment via
|
requires_comment, opens a comment modal, then retries the update once
|
||||||
/api/add_comment.php, then retries the update once WITH the comment.
|
WITH the comment — update_ticket.php persists it in the same DB
|
||||||
|
transaction as the status change itself, so there's no separate
|
||||||
|
add_comment.php call that could leave an orphaned comment if the
|
||||||
|
status update then failed.
|
||||||
Rejects with err.cancelled === true if the user cancels the modal.
|
Rejects with err.cancelled === true if the user cancels the modal.
|
||||||
================================================================ */
|
================================================================ */
|
||||||
function _statusCommentModal(newStatus) {
|
function _statusCommentModal(newStatus) {
|
||||||
@@ -2903,81 +2925,14 @@
|
|||||||
cancelErr.cancelled = true;
|
cancelErr.cancelled = true;
|
||||||
throw cancelErr;
|
throw cancelErr;
|
||||||
}
|
}
|
||||||
// Persist the comment, then retry the status change with it included.
|
// Retry with the comment included — update_ticket.php persists it
|
||||||
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
|
// transactionally with the status update itself.
|
||||||
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
|
return api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ================================================================
|
|
||||||
MODULE 54 — MARKDOWN RENDERER
|
|
||||||
lt.markdown.render(mdString) → HTML string (sanitized)
|
|
||||||
lt.markdown.init(selector) → renders all matching el's .textContent
|
|
||||||
Uses a built-in micro-renderer (no deps) for common syntax.
|
|
||||||
For full GFM, swap in marked.js: window.marked && marked.parse()
|
|
||||||
================================================================ */
|
|
||||||
const markdown = {
|
|
||||||
render(md) {
|
|
||||||
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
|
|
||||||
// window.marked / window.markdownit: their raw HTML output is not sanitized
|
|
||||||
// here, so delegating would enable stored XSS if such a lib were ever loaded.
|
|
||||||
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
|
|
||||||
let html = escHtml(md)
|
|
||||||
// Fenced code blocks
|
|
||||||
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `<pre class="lt-code-block"><code class="lt-tok tok-${lang || 'plain'}">${code.trim()}</code></pre>`)
|
|
||||||
// Inline code
|
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
||||||
// Headings
|
|
||||||
.replace(/^######\s(.+)$/gm, '<h6>$1</h6>')
|
|
||||||
.replace(/^#####\s(.+)$/gm, '<h5>$1</h5>')
|
|
||||||
.replace(/^####\s(.+)$/gm, '<h4>$1</h4>')
|
|
||||||
.replace(/^###\s(.+)$/gm, '<h3>$1</h3>')
|
|
||||||
.replace(/^##\s(.+)$/gm, '<h2>$1</h2>')
|
|
||||||
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
|
|
||||||
// Bold / italic
|
|
||||||
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
||||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
||||||
// Links — block javascript: and data: URIs
|
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
|
|
||||||
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
|
|
||||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
|
|
||||||
})
|
|
||||||
// Images — block javascript: and data: URIs
|
|
||||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
|
||||||
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
|
|
||||||
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
|
|
||||||
})
|
|
||||||
// Blockquote
|
|
||||||
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
|
||||||
// Horizontal rule
|
|
||||||
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>')
|
|
||||||
// Unordered list items
|
|
||||||
.replace(/^[-*+]\s(.+)$/gm, '<li>$1</li>')
|
|
||||||
.replace(/(<li>[\s\S]+?<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
|
|
||||||
// Ordered list items
|
|
||||||
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
|
|
||||||
// Paragraphs (double newline)
|
|
||||||
.replace(/\n{2,}/g, '</p><p>')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
return `<p>${html}</p>`
|
|
||||||
.replace(/<p>(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1')
|
|
||||||
.replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1');
|
|
||||||
},
|
|
||||||
|
|
||||||
init(selector) {
|
|
||||||
document.querySelectorAll(selector).forEach(el => {
|
|
||||||
const raw = el.getAttribute('data-markdown') || el.textContent;
|
|
||||||
el.innerHTML = markdown.render(raw);
|
|
||||||
el.classList.add('lt-markdown');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
MODULE 55 — PAGINATION
|
MODULE 55 — PAGINATION
|
||||||
lt.pagination.init(navEl, opts)
|
lt.pagination.init(navEl, opts)
|
||||||
@@ -3140,7 +3095,6 @@
|
|||||||
timer,
|
timer,
|
||||||
lightbox,
|
lightbox,
|
||||||
auth,
|
auth,
|
||||||
markdown,
|
|
||||||
ticketStatus,
|
ticketStatus,
|
||||||
pagination,
|
pagination,
|
||||||
sidebarSubmenus: { init: initSidebarSubmenus },
|
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||||
|
|||||||
+31
-20
@@ -140,25 +140,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
break;
|
break;
|
||||||
// Bulk operation perform actions
|
// Bulk operation perform actions
|
||||||
case 'perform-bulk-assign':
|
case 'perform-bulk-assign':
|
||||||
performBulkAssign();
|
performBulkAssign(target);
|
||||||
break;
|
break;
|
||||||
case 'close-bulk-assign-modal':
|
case 'close-bulk-assign-modal':
|
||||||
closeBulkAssignModal();
|
closeBulkAssignModal();
|
||||||
break;
|
break;
|
||||||
case 'perform-bulk-priority':
|
case 'perform-bulk-priority':
|
||||||
performBulkPriority();
|
performBulkPriority(target);
|
||||||
break;
|
break;
|
||||||
case 'close-bulk-priority-modal':
|
case 'close-bulk-priority-modal':
|
||||||
closeBulkPriorityModal();
|
closeBulkPriorityModal();
|
||||||
break;
|
break;
|
||||||
case 'perform-bulk-status':
|
case 'perform-bulk-status':
|
||||||
performBulkStatusChange();
|
performBulkStatusChange(target);
|
||||||
break;
|
break;
|
||||||
case 'close-bulk-status-modal':
|
case 'close-bulk-status-modal':
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
break;
|
break;
|
||||||
case 'perform-bulk-close':
|
case 'perform-bulk-close':
|
||||||
performBulkCloseAction();
|
performBulkCloseAction(undefined, target);
|
||||||
break;
|
break;
|
||||||
case 'close-bulk-close-modal':
|
case 'close-bulk-close-modal':
|
||||||
closeBulkCloseModal();
|
closeBulkCloseModal();
|
||||||
@@ -361,14 +361,9 @@ function initSidebarFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clearFiltersBtn) {
|
if (clearFiltersBtn) {
|
||||||
clearFiltersBtn.addEventListener('click', () => {
|
// Delegate to clearAllFilters() so both controls always clear the same
|
||||||
const params = new URLSearchParams(window.location.search);
|
// complete set of filter params instead of two independently-maintained lists.
|
||||||
['status','category','type',
|
clearFiltersBtn.addEventListener('click', clearAllFilters);
|
||||||
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
|
|
||||||
].forEach(k => params.delete(k));
|
|
||||||
params.set('page', '1');
|
|
||||||
window.location.search = params.toString();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +491,10 @@ function closeBulkCloseModal() {
|
|||||||
if (modal) setTimeout(() => modal.remove(), 300);
|
if (modal) setTimeout(() => modal.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function performBulkCloseAction(ticketIds) {
|
function performBulkCloseAction(ticketIds, btn) {
|
||||||
|
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
ticketIds = ticketIds || getSelectedTicketIds();
|
ticketIds = ticketIds || getSelectedTicketIds();
|
||||||
const commentEl = document.getElementById('bulkCloseComment');
|
const commentEl = document.getElementById('bulkCloseComment');
|
||||||
const comment = commentEl ? commentEl.value.trim() : '';
|
const comment = commentEl ? commentEl.value.trim() : '';
|
||||||
@@ -529,7 +527,8 @@ function performBulkCloseAction(ticketIds) {
|
|||||||
}
|
}
|
||||||
closeBulkCloseModal();
|
closeBulkCloseModal();
|
||||||
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
||||||
});
|
})
|
||||||
|
.finally(() => { if (btn) btn.disabled = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
var _bulkAssignUserId = null;
|
var _bulkAssignUserId = null;
|
||||||
@@ -601,7 +600,8 @@ function closeBulkAssignModal() {
|
|||||||
if (modal) setTimeout(() => modal.remove(), 300);
|
if (modal) setTimeout(() => modal.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function performBulkAssign() {
|
function performBulkAssign(btn) {
|
||||||
|
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
|
||||||
const userId = _bulkAssignUserId;
|
const userId = _bulkAssignUserId;
|
||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
@@ -610,6 +610,8 @@ function performBulkAssign() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_assign',
|
operation_type: 'bulk_assign',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
@@ -630,7 +632,8 @@ function performBulkAssign() {
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
lt.toast.error('Bulk assign failed: ' + error.message, 5000);
|
lt.toast.error('Bulk assign failed: ' + error.message, 5000);
|
||||||
});
|
})
|
||||||
|
.finally(() => { if (btn) btn.disabled = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showBulkPriorityModal() {
|
function showBulkPriorityModal() {
|
||||||
@@ -677,7 +680,8 @@ function closeBulkPriorityModal() {
|
|||||||
if (modal) setTimeout(() => modal.remove(), 300);
|
if (modal) setTimeout(() => modal.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function performBulkPriority() {
|
function performBulkPriority(btn) {
|
||||||
|
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
|
||||||
const priorityEl = document.getElementById('bulkPriority');
|
const priorityEl = document.getElementById('bulkPriority');
|
||||||
if (!priorityEl) return;
|
if (!priorityEl) return;
|
||||||
const priority = priorityEl.value;
|
const priority = priorityEl.value;
|
||||||
@@ -688,6 +692,8 @@ function performBulkPriority() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_priority',
|
operation_type: 'bulk_priority',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
@@ -708,7 +714,8 @@ function performBulkPriority() {
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
|
lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
|
||||||
});
|
})
|
||||||
|
.finally(() => { if (btn) btn.disabled = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make table rows clickable
|
// Make table rows clickable
|
||||||
@@ -791,7 +798,8 @@ function closeBulkStatusModal() {
|
|||||||
if (modal) setTimeout(() => modal.remove(), 300);
|
if (modal) setTimeout(() => modal.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function performBulkStatusChange() {
|
function performBulkStatusChange(btn) {
|
||||||
|
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
|
||||||
const bulkStatusEl = document.getElementById('bulkStatus');
|
const bulkStatusEl = document.getElementById('bulkStatus');
|
||||||
if (!bulkStatusEl) return;
|
if (!bulkStatusEl) return;
|
||||||
const status = bulkStatusEl.value;
|
const status = bulkStatusEl.value;
|
||||||
@@ -805,6 +813,8 @@ function performBulkStatusChange() {
|
|||||||
const commentEl = document.getElementById('bulkStatusComment');
|
const commentEl = document.getElementById('bulkStatusComment');
|
||||||
const comment = commentEl ? commentEl.value.trim() : '';
|
const comment = commentEl ? commentEl.value.trim() : '';
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_status',
|
operation_type: 'bulk_status',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
@@ -834,7 +844,8 @@ function performBulkStatusChange() {
|
|||||||
}
|
}
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
||||||
});
|
})
|
||||||
|
.finally(() => { if (btn) btn.disabled = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -506,6 +506,25 @@ function toolbarHeading(textareaId) {
|
|||||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolbarTable(textareaId) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
// Insert on its own line(s), matching the blank-line-before convention
|
||||||
|
// toolbarList/toolbarHeading rely on the surrounding text for — a table
|
||||||
|
// needs a full line to itself both before and after the separator row.
|
||||||
|
const needsLeadingNewline = start > 0 && text[start - 1] !== '\n';
|
||||||
|
const template = (needsLeadingNewline ? '\n' : '')
|
||||||
|
+ '| Header 1 | Header 2 |\n'
|
||||||
|
+ '| --- | --- |\n'
|
||||||
|
+ '| Cell 1 | Cell 2 |\n';
|
||||||
|
|
||||||
|
insertMarkdownText(textareaId, template);
|
||||||
|
}
|
||||||
|
|
||||||
function toolbarQuote(textareaId) {
|
function toolbarQuote(textareaId) {
|
||||||
const textarea = document.getElementById(textareaId);
|
const textarea = document.getElementById(textareaId);
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
@@ -544,6 +563,7 @@ function createEditorToolbar(textareaId, containerId) {
|
|||||||
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
||||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||||
|
<button type="button" data-toolbar-action="table" data-textarea="${textareaId}" title="Table">▦</button>
|
||||||
<span class="toolbar-separator"></span>
|
<span class="toolbar-separator"></span>
|
||||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||||
`;
|
`;
|
||||||
@@ -563,6 +583,7 @@ function createEditorToolbar(textareaId, containerId) {
|
|||||||
case 'heading': toolbarHeading(targetId); break;
|
case 'heading': toolbarHeading(targetId); break;
|
||||||
case 'list': toolbarList(targetId); break;
|
case 'list': toolbarList(targetId); break;
|
||||||
case 'quote': toolbarQuote(targetId); break;
|
case 'quote': toolbarQuote(targetId); break;
|
||||||
|
case 'table': toolbarTable(targetId); break;
|
||||||
case 'link': toolbarLink(targetId); break;
|
case 'link': toolbarLink(targetId); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -578,6 +599,7 @@ window.toolbarLink = toolbarLink;
|
|||||||
window.toolbarList = toolbarList;
|
window.toolbarList = toolbarList;
|
||||||
window.toolbarHeading = toolbarHeading;
|
window.toolbarHeading = toolbarHeading;
|
||||||
window.toolbarQuote = toolbarQuote;
|
window.toolbarQuote = toolbarQuote;
|
||||||
|
window.toolbarTable = toolbarTable;
|
||||||
window.createEditorToolbar = createEditorToolbar;
|
window.createEditorToolbar = createEditorToolbar;
|
||||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||||
window.insertMarkdownText = insertMarkdownText;
|
window.insertMarkdownText = insertMarkdownText;
|
||||||
|
|||||||
+221
-21
@@ -357,12 +357,17 @@ function togglePreview() {
|
|||||||
|
|
||||||
if (isPreviewEnabled) {
|
if (isPreviewEnabled) {
|
||||||
preview.innerHTML = parseMarkdown(textarea.value);
|
preview.innerHTML = parseMarkdown(textarea.value);
|
||||||
textarea.addEventListener('input', updatePreview);
|
textarea.addEventListener('input', debouncedUpdatePreview);
|
||||||
} else {
|
} else {
|
||||||
textarea.removeEventListener('input', updatePreview);
|
textarea.removeEventListener('input', debouncedUpdatePreview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-running the full markdown parser on every single keystroke is wasted
|
||||||
|
// work while the user is still mid-word; 150ms debounce keeps the preview
|
||||||
|
// feeling live without re-parsing on every keystroke.
|
||||||
|
const debouncedUpdatePreview = window.lt ? lt.debounce(updatePreview, 150) : updatePreview;
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
const textarea = document.getElementById('newComment');
|
const textarea = document.getElementById('newComment');
|
||||||
const previewDiv = document.getElementById('markdownPreview');
|
const previewDiv = document.getElementById('markdownPreview');
|
||||||
@@ -452,6 +457,140 @@ function handleAssignmentChange() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SLA Priority-Alert Banner
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const SLA_TARGET_HOURS = { 1: 8, 2: 24 };
|
||||||
|
const SLA_META = {
|
||||||
|
1: { cls: 'lt-sla-p1', icon: '[ ! ]', label: 'P1 Critical' },
|
||||||
|
2: { cls: 'lt-sla-p2', icon: '[ ~ ]', label: 'P2 High' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let slaTickTimer = null;
|
||||||
|
|
||||||
|
function stopSlaTicker() {
|
||||||
|
if (slaTickTimer) {
|
||||||
|
clearInterval(slaTickTimer);
|
||||||
|
slaTickTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSlaTicker(banner) {
|
||||||
|
stopSlaTicker();
|
||||||
|
|
||||||
|
const createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||||
|
const slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||||
|
const deadline = createdAt + slaMs;
|
||||||
|
const elapsedEl = document.getElementById('slaElapsedTimer');
|
||||||
|
const countdownEl = document.getElementById('slaCountdownTimer');
|
||||||
|
const overrunEl = document.getElementById('slaOverrunTimer');
|
||||||
|
const fillBar = document.getElementById('slaProgressBar');
|
||||||
|
const progressWrap = document.getElementById('slaProgress');
|
||||||
|
|
||||||
|
function fmtHMS(ms) {
|
||||||
|
const s = Math.floor(Math.abs(ms) / 1000);
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
||||||
|
return [h, m, ss].map(n => String(n).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - createdAt;
|
||||||
|
const remaining = deadline - now;
|
||||||
|
const pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||||
|
|
||||||
|
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||||
|
if (fillBar) fillBar.style.width = pct + '%';
|
||||||
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||||
|
} else if (overrunEl) {
|
||||||
|
overrunEl.textContent = fmtHMS(-remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
slaTickTimer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render, update, or remove the SLA priority-alert banner for the given
|
||||||
|
* priority, matching what a fresh page load would show. Called on initial
|
||||||
|
* load and again whenever the ticket's priority changes client-side, so the
|
||||||
|
* banner never goes stale until a reload.
|
||||||
|
*/
|
||||||
|
function renderSlaBanner(priorityNum) {
|
||||||
|
const anchor = document.getElementById('priorityAlertBannerAnchor');
|
||||||
|
const existing = document.getElementById('priorityAlertBanner');
|
||||||
|
const meta = SLA_META[priorityNum];
|
||||||
|
const status = window.ticketData && window.ticketData.status;
|
||||||
|
|
||||||
|
if (!meta || status === 'Closed') {
|
||||||
|
if (existing) {
|
||||||
|
stopSlaTicker();
|
||||||
|
existing.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtSec = window.ticketData && window.ticketData.created_at_ts;
|
||||||
|
if (!createdAtSec || !anchor) return;
|
||||||
|
|
||||||
|
const slaTargetHours = SLA_TARGET_HOURS[priorityNum];
|
||||||
|
const elapsedSeconds = Math.floor(Date.now() / 1000) - createdAtSec;
|
||||||
|
const slaBreached = elapsedSeconds >= slaTargetHours * 3600;
|
||||||
|
const slaPct = Math.min(100, Math.round((elapsedSeconds / (slaTargetHours * 3600)) * 100));
|
||||||
|
const slaId = 'sla-' + window.ticketData.id;
|
||||||
|
|
||||||
|
let dismissed = false;
|
||||||
|
try {
|
||||||
|
dismissed = !!sessionStorage.getItem('lt_sla_dismissed_' + slaId);
|
||||||
|
} catch (e) { /* sessionStorage unavailable */ }
|
||||||
|
|
||||||
|
const banner = existing || document.createElement('div');
|
||||||
|
if (!existing) {
|
||||||
|
banner.id = 'priorityAlertBanner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.setAttribute('aria-live', 'polite');
|
||||||
|
banner.style.marginBottom = '0.75rem';
|
||||||
|
anchor.appendChild(banner);
|
||||||
|
}
|
||||||
|
banner.className = meta.cls;
|
||||||
|
banner.dataset.slaId = slaId;
|
||||||
|
banner.dataset.createdAt = String(createdAtSec);
|
||||||
|
banner.dataset.slaHours = String(slaTargetHours);
|
||||||
|
banner.hidden = dismissed;
|
||||||
|
|
||||||
|
banner.innerHTML =
|
||||||
|
`<span class="lt-sla-icon" aria-hidden="true">${meta.icon}</span>` +
|
||||||
|
'<div class="lt-sla-info">' +
|
||||||
|
`<div class="lt-sla-title">${lt.escHtml(meta.label)} — SLA: <span id="slaElapsedTimer"></span> elapsed of ${slaTargetHours}h limit` +
|
||||||
|
(slaBreached ? ' <span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
`<div class="lt-sla-bar" aria-label="SLA progress ${slaPct}%" id="slaProgress">` +
|
||||||
|
`<div class="lt-sla-fill" id="slaProgressBar" style="width:${slaPct}%"></div>` +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
(slaBreached
|
||||||
|
? `<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer">${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h</span> over</div>`
|
||||||
|
: '<div class="lt-sla-meta" id="slaCountdownTimer"></div>') +
|
||||||
|
'<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>';
|
||||||
|
|
||||||
|
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||||
|
banner.hidden = true;
|
||||||
|
stopSlaTicker();
|
||||||
|
try { sessionStorage.setItem('lt_sla_dismissed_' + slaId, '1'); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dismissed) {
|
||||||
|
stopSlaTicker();
|
||||||
|
} else {
|
||||||
|
startSlaTicker(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle metadata field changes (priority, category, type)
|
* Handle metadata field changes (priority, category, type)
|
||||||
*/
|
*/
|
||||||
@@ -475,10 +614,12 @@ function handleMetadataChanges() {
|
|||||||
// Update window.ticketData
|
// Update window.ticketData
|
||||||
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
||||||
|
|
||||||
// For priority, update the TDS frame border accent
|
// For priority, update the TDS frame border accent and the
|
||||||
|
// SLA banner (which otherwise stays stale until reload)
|
||||||
if (fieldName === 'priority') {
|
if (fieldName === 'priority') {
|
||||||
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
||||||
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
||||||
|
renderSlaBanner(window.ticketData.priority);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -572,12 +713,13 @@ function updateTicketStatus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cleanup(true);
|
cleanup(true);
|
||||||
// Post comment first (persists it), then change status with the same
|
// The comment is sent as part of the status-change request itself
|
||||||
// comment included so the server's requires_comment check passes.
|
// (update_ticket.php persists it in the same DB transaction as the
|
||||||
const ticketId = getTicketIdFromUrl();
|
// status update) rather than as a separate prior add_comment.php
|
||||||
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
|
// call — previously those were two independent, non-transactional
|
||||||
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
|
// writes, so a failure partway through could leave the "reason"
|
||||||
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
|
// comment persisted with no matching status change ever applied.
|
||||||
|
performStatusChange(statusSelect, selectedOption, newStatus, comment);
|
||||||
});
|
});
|
||||||
// Focus textarea on open
|
// Focus textarea on open
|
||||||
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
||||||
@@ -1027,35 +1169,62 @@ function resetUploadUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAttachments() {
|
const ATTACHMENT_PAGE_SIZE = 40;
|
||||||
const ticketId = window.ticketData.id;
|
let attachmentOffset = 0;
|
||||||
const container = document.getElementById('attachmentsList');
|
let attachmentTotal = 0;
|
||||||
|
|
||||||
|
function loadAttachments() {
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
|
attachmentOffset = 0;
|
||||||
|
attachmentTotal = 0;
|
||||||
|
fetchAttachmentsPage(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAttachmentsPage(append) {
|
||||||
|
const ticketId = window.ticketData.id;
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
loadMoreBtn.textContent = 'Loading…';
|
||||||
|
}
|
||||||
|
|
||||||
|
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}&offset=${attachmentOffset}&limit=${ATTACHMENT_PAGE_SIZE}`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
renderAttachments(data.attachments || []);
|
attachmentTotal = data.total;
|
||||||
} else {
|
attachmentOffset += (data.attachments || []).length;
|
||||||
|
renderAttachments(data.attachments || [], append, data.has_more);
|
||||||
|
} else if (!append) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
if (!append) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachments(attachments) {
|
function renderAttachments(attachments, append, hasMore) {
|
||||||
const container = document.getElementById('attachmentsList');
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (attachments.length === 0) {
|
if (!append && attachments.length === 0) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="attachments-grid">';
|
let grid = append ? container.querySelector('.attachments-grid') : null;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
attachments.forEach(att => {
|
attachments.forEach(att => {
|
||||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||||
@@ -1070,9 +1239,14 @@ function renderAttachments(attachments) {
|
|||||||
|
|
||||||
const isImage = /^image\//i.test(att.mime_type || '');
|
const isImage = /^image\//i.test(att.mime_type || '');
|
||||||
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
|
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
|
||||||
|
// Grid preview requests the resized thumbnail (server falls back to the
|
||||||
|
// full-size original for attachments with none, e.g. uploaded before
|
||||||
|
// thumbnail generation existed); the lightbox link stays on the
|
||||||
|
// full-size original since that's what it displays when opened.
|
||||||
|
const thumbUrl = `${imgUrl}&thumb=1`;
|
||||||
const iconHtml = isImage
|
const iconHtml = isImage
|
||||||
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
||||||
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
|
<img src="${thumbUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
|
||||||
</a>`
|
</a>`
|
||||||
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
|
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
|
||||||
|
|
||||||
@@ -1095,8 +1269,34 @@ function renderAttachments(attachments) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
html += '</div>';
|
if (grid) {
|
||||||
container.innerHTML = html;
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
grid.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="attachments-grid">' + html + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = attachmentTotal - attachmentOffset;
|
||||||
|
let loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
|
||||||
|
if (hasMore && remaining > 0) {
|
||||||
|
if (!loadMoreBtn) {
|
||||||
|
loadMoreBtn = document.createElement('button');
|
||||||
|
loadMoreBtn.type = 'button';
|
||||||
|
loadMoreBtn.id = 'attachmentsLoadMoreBtn';
|
||||||
|
loadMoreBtn.className = 'lt-btn lt-btn-sm lt-w-full';
|
||||||
|
loadMoreBtn.style.marginTop = '0.6rem';
|
||||||
|
loadMoreBtn.addEventListener('click', function() { fetchAttachmentsPage(true); });
|
||||||
|
container.appendChild(loadMoreBtn);
|
||||||
|
}
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
loadMoreBtn.textContent = `Load more attachments (${remaining} remaining)`;
|
||||||
|
} else if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize lightbox on image thumbnails
|
// Initialize lightbox on image thumbnails
|
||||||
if (window.lt && lt.lightbox) {
|
if (window.lt && lt.lightbox) {
|
||||||
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
||||||
|
|||||||
+21
-6
@@ -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
|
||||||
|
|
||||||
@@ -136,9 +141,9 @@ $GLOBALS['config'] = [
|
|||||||
],
|
],
|
||||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting (requests per minute; read by RateLimitMiddleware)
|
||||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
|
||||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
|
||||||
|
|
||||||
// Audit log settings
|
// Audit log settings
|
||||||
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||||
@@ -149,9 +154,19 @@ $GLOBALS['config'] = [
|
|||||||
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
||||||
'TIMEZONE_OFFSET' => null, // Will be calculated below
|
'TIMEZONE_OFFSET' => null, // Will be calculated below
|
||||||
|
|
||||||
// LDAP / lldap settings (for user avatar lookups)
|
// LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
|
||||||
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
|
// (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
|
||||||
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
|
// its plaintext port 3890. The bind password must never go over the
|
||||||
|
// wire unencrypted, so this is not configurable back to a plaintext
|
||||||
|
// ldap:// connection.
|
||||||
|
//
|
||||||
|
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
|
||||||
|
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
|
||||||
|
// hostname by default and a mismatch fails the connection. Pi-hole has a
|
||||||
|
// split-horizon override so ldap.lotusguild.org resolves internally to
|
||||||
|
// the real LDAP server IP (its public DNS record points elsewhere).
|
||||||
|
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
|
||||||
|
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
|
||||||
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
|
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
|
||||||
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
|
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
|
||||||
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
|
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
|||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ class TicketController
|
|||||||
private $userModel;
|
private $userModel;
|
||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $templateModel;
|
private $templateModel;
|
||||||
|
private $customFieldModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn)
|
public function __construct($conn)
|
||||||
@@ -29,6 +31,7 @@ class TicketController
|
|||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->templateModel = new TemplateModel($conn);
|
$this->templateModel = new TemplateModel($conn);
|
||||||
|
$this->customFieldModel = new CustomFieldModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view($id)
|
public function view($id)
|
||||||
@@ -60,6 +63,11 @@ class TicketController
|
|||||||
// Get allowed status transitions for this ticket
|
// Get allowed status transitions for this ticket
|
||||||
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
||||||
|
|
||||||
|
// Custom fields applicable to this ticket's category, with any
|
||||||
|
// already-saved values for it
|
||||||
|
$customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true);
|
||||||
|
$customFieldValues = $this->customFieldModel->getValuesForTicket($id);
|
||||||
|
|
||||||
// Make $conn available to view for visibility groups
|
// Make $conn available to view for visibility groups
|
||||||
$conn = $this->conn;
|
$conn = $this->conn;
|
||||||
|
|
||||||
@@ -73,6 +81,12 @@ class TicketController
|
|||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
|
// All active custom field definitions (every category, plus
|
||||||
|
// category-less ones) — the create form renders them all and toggles
|
||||||
|
// visibility client-side as the Category select changes, since the
|
||||||
|
// ticket doesn't exist yet to scope the query to one category.
|
||||||
|
$allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true);
|
||||||
|
|
||||||
// Check if form was submitted
|
// Check if form was submitted
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// Validate CSRF token
|
// Validate CSRF token
|
||||||
@@ -131,6 +145,38 @@ class TicketController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom fields applicable to the submitted category — validate
|
||||||
|
// is_required server-side (the form is novalidate, and a field
|
||||||
|
// hidden by the client-side category toggle must not silently
|
||||||
|
// bypass a requirement that applies to the category actually
|
||||||
|
// submitted).
|
||||||
|
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
|
||||||
|
$applicableFieldDefs = array_filter(
|
||||||
|
$allCustomFieldDefs,
|
||||||
|
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
|
||||||
|
);
|
||||||
|
$customFieldsToSave = [];
|
||||||
|
foreach ($applicableFieldDefs as $def) {
|
||||||
|
$fieldId = (int)$def['field_id'];
|
||||||
|
$raw = $submittedCustomFields[$fieldId] ?? null;
|
||||||
|
$normalized = $def['field_type'] === 'checkbox'
|
||||||
|
? (!empty($raw) ? '1' : '0')
|
||||||
|
: (is_scalar($raw) ? trim((string)$raw) : '');
|
||||||
|
|
||||||
|
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
|
||||||
|
$error = $def['field_label'] . ' is required';
|
||||||
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
$conn = $this->conn;
|
||||||
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalized !== '') {
|
||||||
|
$customFieldsToSave[$fieldId] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create ticket with user tracking
|
// Create ticket with user tracking
|
||||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||||
|
|
||||||
@@ -144,6 +190,12 @@ class TicketController
|
|||||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
(new StatsModel($this->conn))->invalidateCache();
|
(new StatsModel($this->conn))->invalidateCache();
|
||||||
|
|
||||||
|
// Persist custom field values for the fields applicable to
|
||||||
|
// this ticket's category
|
||||||
|
if (!empty($customFieldsToSave)) {
|
||||||
|
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-link as duplicate if requested from create form
|
// Auto-link as duplicate if requested from create form
|
||||||
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
||||||
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||||
|
|||||||
+138
-58
@@ -2,12 +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();
|
||||||
|
|
||||||
// Load environment variables with error check
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||||
$envFile = __DIR__ . '/.env';
|
RateLimitMiddleware::apply('api');
|
||||||
if (!file_exists($envFile)) {
|
|
||||||
|
// Early friendly JSON error if .env is missing, before config.php's own
|
||||||
|
// (plain-text die()) handling would otherwise run — this is a JSON API
|
||||||
|
// endpoint and must always respond with a JSON body.
|
||||||
|
if (!file_exists(__DIR__ . '/.env')) {
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Configuration file not found'
|
'error' => 'Configuration file not found'
|
||||||
@@ -15,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,
|
||||||
@@ -54,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);
|
||||||
@@ -249,8 +232,29 @@ $priority = (int)$priority;
|
|||||||
$ticketHash = generateTicketHash($data);
|
$ticketHash = generateTicketHash($data);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Everything from here through either updating/reopening the matched ticket
|
||||||
|
// or inserting a brand-new one runs inside one transaction with a row lock
|
||||||
|
// on the hash lookup. Without this, two concurrent requests carrying the
|
||||||
|
// same dedup hash (e.g. overlapping monitoring runs) could both read the
|
||||||
|
// same pre-update snapshot and each independently apply an escalation. FOR
|
||||||
|
// UPDATE on this equality lookup against the unique-indexed hash column also
|
||||||
|
// takes a lock on the "gap" where no row currently exists, so two concurrent
|
||||||
|
// requests for a genuinely new hash are still safe from a duplicate row —
|
||||||
|
// but that gap lock is shared, not exclusive, so both can reach the INSERT
|
||||||
|
// below and deadlock with each other rather than one blocking cleanly on the
|
||||||
|
// other's row. See the retry loop and comment near the INSERT's catch block
|
||||||
|
// for how that case is handled.
|
||||||
|
// Retried once if the INSERT below deadlocks with another connection's
|
||||||
|
// concurrent insert into the same not-yet-existing hash (see comment
|
||||||
|
// above the INSERT's catch block) — the retry's own SELECT ... FOR UPDATE
|
||||||
|
// will then find the winner's already-committed row and take the
|
||||||
|
// update/escalate branch instead of erroring out.
|
||||||
|
$maxDedupAttempts = 2;
|
||||||
|
for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
|
||||||
|
$conn->begin_transaction();
|
||||||
|
|
||||||
// Look up any existing ticket with this hash (open OR closed)
|
// Look up any existing ticket with this hash (open OR closed)
|
||||||
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
|
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1 FOR UPDATE");
|
||||||
$checkStmt->bind_param("s", $ticketHash);
|
$checkStmt->bind_param("s", $ticketHash);
|
||||||
$checkStmt->execute();
|
$checkStmt->execute();
|
||||||
$existing = $checkStmt->get_result()->fetch_assoc();
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
||||||
@@ -346,7 +350,8 @@ if ($existing) {
|
|||||||
(new StatsModel($conn))->invalidateCache();
|
(new StatsModel($conn))->invalidateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->close();
|
$conn->commit();
|
||||||
|
Database::close();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $existingId,
|
'ticket_id' => $existingId,
|
||||||
@@ -357,17 +362,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)"
|
||||||
);
|
);
|
||||||
@@ -375,39 +415,64 @@ if ($existing) {
|
|||||||
$commentStmt->execute();
|
$commentStmt->execute();
|
||||||
$commentStmt->close();
|
$commentStmt->close();
|
||||||
|
|
||||||
|
if ($reopenStatus !== null) {
|
||||||
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||||
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
|
||||||
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
'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();
|
$conn->commit();
|
||||||
|
Database::close();
|
||||||
|
|
||||||
|
if ($reopenStatus !== null) {
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
NotificationHelper::sendTicketNotification($existingId, [
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'priority' => $priority,
|
'priority' => $priority,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'status' => 'Open',
|
'status' => $reopenStatus,
|
||||||
], 'automated');
|
], '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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing ticket — create a new one.
|
// No existing ticket — create a new one. Still inside the transaction opened
|
||||||
|
// above, so a concurrent request for the same hash is blocked on its own
|
||||||
|
// SELECT ... FOR UPDATE until this one commits or rolls back (see comment
|
||||||
|
// there) rather than racing this INSERT.
|
||||||
|
//
|
||||||
|
// Note on FOR UPDATE over a not-yet-existing key: InnoDB's gap lock in that
|
||||||
|
// case is a shared lock, not exclusive — two concurrent transactions can
|
||||||
|
// both acquire it and both reach this INSERT. The conflict only surfaces
|
||||||
|
// when they each request the insert-intention lock for the same gap,
|
||||||
|
// which InnoDB resolves as a deadlock (error 1213), not by blocking one
|
||||||
|
// of the SELECTs. The outer loop above retries that case: the loser rolls
|
||||||
|
// back and re-runs its own SELECT ... FOR UPDATE, which by then finds the
|
||||||
|
// winner's committed row and takes the update/escalate branch instead.
|
||||||
|
//
|
||||||
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
|
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
|
||||||
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
|
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
|
||||||
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
|
// way a 1062 on INSERT below can only be the unique_hash (dedup) key — and with
|
||||||
// is correctly reported as a duplicate rather than a dropped hardware alert.
|
// the FOR UPDATE lock above, only in the unlikely case of a hash collision from
|
||||||
|
// two genuinely different reports, not the same-hash race this used to be.
|
||||||
$ticket_id = null;
|
$ticket_id = null;
|
||||||
$maxAttempts = 50;
|
$maxAttempts = 50;
|
||||||
$attempts = 0;
|
$attempts = 0;
|
||||||
@@ -431,6 +496,7 @@ do {
|
|||||||
} while ($ticket_id === null && $attempts < $maxAttempts);
|
} while ($ticket_id === null && $attempts < $maxAttempts);
|
||||||
|
|
||||||
if ($ticket_id === null) {
|
if ($ticket_id === null) {
|
||||||
|
$conn->rollback();
|
||||||
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
@@ -458,8 +524,19 @@ try {
|
|||||||
$inserted = $insertStmt->execute();
|
$inserted = $insertStmt->execute();
|
||||||
} catch (mysqli_sql_exception $e) {
|
} catch (mysqli_sql_exception $e) {
|
||||||
$insertStmt->close();
|
$insertStmt->close();
|
||||||
|
$conn->rollback();
|
||||||
|
if (in_array($e->getCode(), [1213, 1205], true) && $dedupAttempt < $maxDedupAttempts) {
|
||||||
|
// Deadlock (1213) or lock wait timeout (1205) from a concurrent
|
||||||
|
// insert into the same not-yet-existing hash gap — see the note
|
||||||
|
// above. Retry: the next iteration's own SELECT ... FOR UPDATE
|
||||||
|
// will find whichever side won and take the update/escalate path.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($e->getCode() === 1062) {
|
if ($e->getCode() === 1062) {
|
||||||
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
// Should be unreachable in the same-hash race this issue was filed
|
||||||
|
// for now that the SELECT above takes FOR UPDATE — kept as a
|
||||||
|
// defensive fallback in case of a genuine hash collision between two
|
||||||
|
// different reports.
|
||||||
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
||||||
} else {
|
} else {
|
||||||
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
||||||
@@ -481,7 +558,8 @@ if ($inserted) {
|
|||||||
// New ticket created — refresh dashboard stats.
|
// New ticket created — refresh dashboard stats.
|
||||||
(new StatsModel($conn))->invalidateCache();
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
|
||||||
$conn->close();
|
$conn->commit();
|
||||||
|
Database::close();
|
||||||
|
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||||
@@ -498,7 +576,9 @@ if ($inserted) {
|
|||||||
'message' => 'Ticket created successfully',
|
'message' => 'Ticket created successfully',
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
$conn->rollback();
|
||||||
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,38 @@ function logMessage($message)
|
|||||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a recurring-ticket occurrence that was claimed (next_run_at already
|
||||||
|
* advanced to the next future run) but then failed to actually produce a
|
||||||
|
* ticket. That claim-then-fail ordering is deliberate — it stops a failing
|
||||||
|
* creation from re-firing and flooding duplicates on every subsequent cron
|
||||||
|
* tick — but means this specific occurrence has no other record anywhere an
|
||||||
|
* admin would normally look: no audit_log entry (nothing was created), no
|
||||||
|
* Matrix "ticket created" alert, no failure table. Without this, it's simply
|
||||||
|
* gone, silently, forever.
|
||||||
|
*/
|
||||||
|
function recordMissedOccurrence($auditLog, $recurring, $reason)
|
||||||
|
{
|
||||||
|
$auditLog->log(
|
||||||
|
$recurring['created_by'],
|
||||||
|
'error',
|
||||||
|
'recurring_ticket',
|
||||||
|
(string)$recurring['recurring_id'],
|
||||||
|
[
|
||||||
|
'reason' => $reason,
|
||||||
|
'title_template' => $recurring['title_template'],
|
||||||
|
'schedule_type' => $recurring['schedule_type'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
NotificationHelper::sendSystemAlert(
|
||||||
|
"Recurring ticket occurrence lost: schedule #{$recurring['recurring_id']} "
|
||||||
|
. "(\"{$recurring['title_template']}\") was claimed for this run but ticket "
|
||||||
|
. "creation failed, so this occurrence will not be created or retried.",
|
||||||
|
['reason' => $reason, 'recurring_id' => $recurring['recurring_id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logMessage("Starting recurring tickets cron job");
|
logMessage("Starting recurring tickets cron job");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -100,11 +132,17 @@ try {
|
|||||||
|
|
||||||
$created++;
|
$created++;
|
||||||
} else {
|
} else {
|
||||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
$reason = $result['error'] ?? 'Unknown error';
|
||||||
|
logMessage("ERROR: Failed to create ticket - " . $reason);
|
||||||
|
recordMissedOccurrence($auditLog, $recurring, $reason);
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||||
|
// claimForRun() already advanced next_run_at before this point, so
|
||||||
|
// this occurrence is permanently gone unless recorded somewhere an
|
||||||
|
// admin would actually look — a cron log line alone doesn't count.
|
||||||
|
recordMissedOccurrence($auditLog, $recurring, $e->getMessage());
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failed Matrix Notification Retry Cron Job
|
||||||
|
*
|
||||||
|
* NotificationHelper::fire() queues a failed webhook post to
|
||||||
|
* notification_retry_queue instead of just logging and losing it. This
|
||||||
|
* script retries due rows with exponential backoff, up to each row's
|
||||||
|
* max_attempts, then leaves an exhausted row in place (not deleted) so it
|
||||||
|
* remains visible for manual investigation.
|
||||||
|
*
|
||||||
|
* Run this via cron every 5-10 minutes (see README.md's Cron Jobs section
|
||||||
|
* for the exact crontab line — the "every 5 minutes" syntax isn't repeated
|
||||||
|
* here since it would terminate this comment block early).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
|
require_once 'config/config.php';
|
||||||
|
require_once 'helpers/Database.php';
|
||||||
|
require_once 'helpers/NotificationHelper.php';
|
||||||
|
|
||||||
|
function logMessage($message)
|
||||||
|
{
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exponential backoff in minutes: 2, 4, 8, 16, 32, capped at 60. */
|
||||||
|
function nextAttemptDelayMinutes(int $attemptNumber): int
|
||||||
|
{
|
||||||
|
return min(60, 2 ** $attemptNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
|
if (empty($webhookUrl)) {
|
||||||
|
logMessage('MATRIX_WEBHOOK_URL not configured — nothing to retry, exiting.');
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process a bounded batch per run so one cron tick can't run indefinitely if
|
||||||
|
// the queue has backed up.
|
||||||
|
$batchLimit = 50;
|
||||||
|
|
||||||
|
$stmt = $conn->prepare(
|
||||||
|
"SELECT retry_id, payload, attempts, max_attempts
|
||||||
|
FROM notification_retry_queue
|
||||||
|
WHERE next_attempt_at <= NOW() AND attempts < max_attempts
|
||||||
|
ORDER BY retry_id ASC
|
||||||
|
LIMIT ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param('i', $batchLimit);
|
||||||
|
$stmt->execute();
|
||||||
|
$dueRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if (empty($dueRows)) {
|
||||||
|
logMessage('No notifications due for retry.');
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$succeeded = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$exhausted = 0;
|
||||||
|
|
||||||
|
foreach ($dueRows as $row) {
|
||||||
|
$payload = json_decode($row['payload'], true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
// Corrupt row — can't retry something unparseable. Remove it rather
|
||||||
|
// than retrying forever against a row that will never succeed.
|
||||||
|
$del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?");
|
||||||
|
$del->bind_param('i', $row['retry_id']);
|
||||||
|
$del->execute();
|
||||||
|
$del->close();
|
||||||
|
logMessage("Discarded retry #{$row['retry_id']}: payload is not valid JSON");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = NotificationHelper::attemptDelivery($webhookUrl, $payload);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?");
|
||||||
|
$del->bind_param('i', $row['retry_id']);
|
||||||
|
$del->execute();
|
||||||
|
$del->close();
|
||||||
|
$succeeded++;
|
||||||
|
logMessage("Retry #{$row['retry_id']} succeeded (attempt " . ((int)$row['attempts'] + 1) . ')');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newAttempts = (int)$row['attempts'] + 1;
|
||||||
|
if ($newAttempts >= (int)$row['max_attempts']) {
|
||||||
|
// Exhausted: leave the row (attempts is now == max_attempts, so the
|
||||||
|
// WHERE clause above naturally excludes it from future runs) rather
|
||||||
|
// than deleting it, so it stays visible for manual investigation.
|
||||||
|
$upd = $conn->prepare(
|
||||||
|
"UPDATE notification_retry_queue SET attempts = ?, last_error = ? WHERE retry_id = ?"
|
||||||
|
);
|
||||||
|
$upd->bind_param('isi', $newAttempts, $result['error'], $row['retry_id']);
|
||||||
|
$upd->execute();
|
||||||
|
$upd->close();
|
||||||
|
$exhausted++;
|
||||||
|
logMessage("Retry #{$row['retry_id']} exhausted after {$newAttempts} attempts: {$result['error']}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$delayMinutes = nextAttemptDelayMinutes($newAttempts);
|
||||||
|
$upd = $conn->prepare(
|
||||||
|
"UPDATE notification_retry_queue
|
||||||
|
SET attempts = ?, last_error = ?, next_attempt_at = DATE_ADD(NOW(), INTERVAL ? MINUTE)
|
||||||
|
WHERE retry_id = ?"
|
||||||
|
);
|
||||||
|
$upd->bind_param('isii', $newAttempts, $result['error'], $delayMinutes, $row['retry_id']);
|
||||||
|
$upd->execute();
|
||||||
|
$upd->close();
|
||||||
|
$failed++;
|
||||||
|
logMessage("Retry #{$row['retry_id']} failed (attempt {$newAttempts}), next attempt in {$delayMinutes}m: {$result['error']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage("Done: {$succeeded} succeeded, {$failed} rescheduled, {$exhausted} exhausted (of " . count($dueRows) . ' processed)');
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-32
@@ -7,6 +7,68 @@ class NotificationHelper
|
|||||||
{
|
{
|
||||||
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a payload to the configured Matrix webhook and report the raw
|
||||||
|
* result. Shared by fire() (best-effort, queues on failure) and
|
||||||
|
* cron/retry_failed_notifications.php (retries a previously-queued
|
||||||
|
* payload) so both use identical request handling.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, http_code: ?int, error: ?string}
|
||||||
|
*/
|
||||||
|
public static function attemptDelivery(string $webhookUrl, array $payload): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($webhookUrl);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, 1);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
|
||||||
|
// to the full CURLOPT_TIMEOUT per call, and a single request can
|
||||||
|
// trigger more than one notification sequentially (via
|
||||||
|
// notifyWatchers/sendCommentNotification/etc.) — capping just the
|
||||||
|
// connect phase keeps that from stacking into tens of seconds of
|
||||||
|
// added latency.
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
return ['success' => false, 'http_code' => null, 'error' => $curlError];
|
||||||
|
}
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
return ['success' => false, 'http_code' => $httpCode, 'error' => "HTTP {$httpCode}: {$response}"];
|
||||||
|
}
|
||||||
|
return ['success' => true, 'http_code' => $httpCode, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a failed payload for later retry by
|
||||||
|
* cron/retry_failed_notifications.php. Best-effort: a DB failure here
|
||||||
|
* must not throw back into the original (already-failed) notification
|
||||||
|
* attempt — it just means this particular failure isn't retried, no
|
||||||
|
* worse than the pre-existing behavior.
|
||||||
|
*/
|
||||||
|
private static function queueForRetry(array $payload, string $error): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$stmt = $conn->prepare(
|
||||||
|
"INSERT INTO notification_retry_queue (payload, last_error) VALUES (?, ?)"
|
||||||
|
);
|
||||||
|
$payloadJson = json_encode($payload);
|
||||||
|
$stmt->bind_param("ss", $payloadJson, $error);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('NotificationHelper: failed to queue notification for retry: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function fire(array $payload): void
|
private static function fire(array $payload): void
|
||||||
{
|
{
|
||||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
@@ -14,24 +76,14 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ch = curl_init($webhookUrl);
|
$result = self::attemptDelivery($webhookUrl, $payload);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
if ($result['success']) {
|
||||||
curl_setopt($ch, CURLOPT_POST, 1);
|
return;
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
}
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
$curlError = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
$id = $payload['ticket_id'] ?? '?';
|
$id = $payload['ticket_id'] ?? '?';
|
||||||
if ($curlError) {
|
error_log("Matrix webhook failed for ticket #{$id}: {$result['error']}");
|
||||||
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
|
self::queueForRetry($payload, $result['error']);
|
||||||
} elseif ($httpCode < 200 || $httpCode >= 300) {
|
|
||||||
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function notifyUsers(): array
|
private static function notifyUsers(): array
|
||||||
@@ -40,20 +92,57 @@ class NotificationHelper
|
|||||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact a ticket title for the shared Matrix notify list when the
|
||||||
|
* ticket isn't public, matching how sendCommentNotification() and
|
||||||
|
* notifyWatchers() already redact comment/activity previews for the
|
||||||
|
* same list.
|
||||||
|
*/
|
||||||
|
private static function redactedTitle(string $title, string $visibility): string
|
||||||
|
{
|
||||||
|
return $visibility === 'public' ? $title : '(restricted ticket — title hidden)';
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public event methods ─────────────────────────────────────────────────
|
// ─── Public event methods ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic operational alert with no associated ticket (e.g. a recurring
|
||||||
|
* schedule whose ticket creation failed after its next_run_at was
|
||||||
|
* already advanced, so the missed occurrence has no other record an
|
||||||
|
* admin would normally see). Always sent to the shared
|
||||||
|
* MATRIX_NOTIFY_USERS list, regardless of any per-event notify toggle.
|
||||||
|
*/
|
||||||
|
public static function sendSystemAlert(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::fire(array_merge([
|
||||||
|
'event' => 'system_alert',
|
||||||
|
'message' => $message,
|
||||||
|
], $context, [
|
||||||
|
'notify_users' => self::notifyUsers(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* New ticket created (manual or automated/API).
|
* New ticket created (manual or automated/API).
|
||||||
|
*
|
||||||
|
* $ticketData['visibility'] ('public', 'internal', or 'confidential') is
|
||||||
|
* used to redact the title sent to the shared MATRIX_NOTIFY_USERS list
|
||||||
|
* for non-public tickets, same as sendCommentNotification()'s preview
|
||||||
|
* redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon
|
||||||
|
* Bearer-API paths) that never set a non-default visibility.
|
||||||
*/
|
*/
|
||||||
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
||||||
{
|
{
|
||||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
$visibility = $ticketData['visibility'] ?? 'public';
|
||||||
|
$title = $ticketData['title'] ?? 'Untitled';
|
||||||
|
|
||||||
|
preg_match('/^\[([^\]]+)\]/', $title, $m);
|
||||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||||
|
|
||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'ticket_created',
|
'event' => 'ticket_created',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketData['title'] ?? 'Untitled',
|
'title' => self::redactedTitle($title, $visibility),
|
||||||
'priority' => (int)($ticketData['priority'] ?? 4),
|
'priority' => (int)($ticketData['priority'] ?? 4),
|
||||||
'category' => $ticketData['category'] ?? 'General',
|
'category' => $ticketData['category'] ?? 'General',
|
||||||
'type' => $ticketData['type'] ?? 'Issue',
|
'type' => $ticketData['type'] ?? 'Issue',
|
||||||
@@ -73,13 +162,16 @@ class NotificationHelper
|
|||||||
* @param string $newStatus
|
* @param string $newStatus
|
||||||
* @param string $ticketTitle
|
* @param string $ticketTitle
|
||||||
* @param string|null $changedByDisplay Display name of the user who changed status
|
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||||
|
* @param string $visibility Ticket visibility; non-public titles are
|
||||||
|
* redacted before being sent to the shared
|
||||||
|
* notify list, same as sendTicketNotification().
|
||||||
*/
|
*/
|
||||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'status_changed',
|
'event' => 'status_changed',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||||
'old_status' => $oldStatus,
|
'old_status' => $oldStatus,
|
||||||
'new_status' => $newStatus,
|
'new_status' => $newStatus,
|
||||||
'changed_by' => $changedByDisplay,
|
'changed_by' => $changedByDisplay,
|
||||||
@@ -166,11 +258,12 @@ class NotificationHelper
|
|||||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||||
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
||||||
* 'confidential'. notify_users includes the
|
* 'confidential'. The shared notify list may
|
||||||
* shared list, which may contain users without
|
* contain users without access to non-public
|
||||||
* access to non-public tickets, so any comment
|
* tickets, so for those tickets it's excluded
|
||||||
* body preview in $extraData is redacted for
|
* entirely (only actual watchers are notified)
|
||||||
* non-public tickets.
|
* and both the title and any comment/body
|
||||||
|
* preview in $extraData are redacted.
|
||||||
*/
|
*/
|
||||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
@@ -204,9 +297,9 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($excludeUserId !== null) {
|
if ($excludeUserId !== null) {
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
$stmt->bind_param("si", $ticketId, $excludeUserId);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -230,13 +323,17 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the global notify list duplicates and build payload
|
// The shared notify list may include users without access to
|
||||||
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
|
// non-public tickets, so only mix it in for public tickets — for
|
||||||
|
// internal/confidential tickets, notify actual watchers only.
|
||||||
|
$allNotify = $visibility === 'public'
|
||||||
|
? array_unique(array_merge($matrixIds, self::notifyUsers()))
|
||||||
|
: $matrixIds;
|
||||||
|
|
||||||
$payload = array_merge($extraData, [
|
$payload = array_merge($extraData, [
|
||||||
'event' => $event,
|
'event' => $event,
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||||
'url' => UrlHelper::ticketUrl($ticketId),
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
'notify_users' => array_values($allNotify),
|
'notify_users' => array_values($allNotify),
|
||||||
]);
|
]);
|
||||||
@@ -252,8 +349,14 @@ class NotificationHelper
|
|||||||
* @param string|null $assigneeName Display name of new assignee
|
* @param string|null $assigneeName Display name of new assignee
|
||||||
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||||
* @param string|null $changedByDisplay
|
* @param string|null $changedByDisplay
|
||||||
|
* @param string $visibility Ticket visibility; non-public titles are
|
||||||
|
* redacted before being sent to the shared
|
||||||
|
* notify list, same as sendTicketNotification().
|
||||||
|
* The assignee is DMed directly regardless,
|
||||||
|
* since they now have standing access to the
|
||||||
|
* ticket by virtue of being assigned to it.
|
||||||
*/
|
*/
|
||||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
$notifyUsers = self::notifyUsers();
|
$notifyUsers = self::notifyUsers();
|
||||||
// Also notify the assignee directly if we know their Matrix ID
|
// Also notify the assignee directly if we know their Matrix ID
|
||||||
@@ -267,7 +370,7 @@ class NotificationHelper
|
|||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'assigned',
|
'event' => 'assigned',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||||
'assignee' => $assigneeName,
|
'assignee' => $assigneeName,
|
||||||
'changed_by' => $changedByDisplay,
|
'changed_by' => $changedByDisplay,
|
||||||
'url' => UrlHelper::ticketUrl($ticketId),
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -383,9 +388,19 @@ switch (true) {
|
|||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) cm ON u.user_id = cm.user_id
|
) cm ON u.user_id = cm.user_id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT assigned_to, COUNT(*) as tickets_assigned
|
-- Assignment date, not ticket creation date: a ticket created
|
||||||
FROM tickets
|
-- outside the range but assigned within it should count, and
|
||||||
WHERE DATE(created_at) BETWEEN ? AND ?
|
-- one created in-range but assigned later shouldn't (until it
|
||||||
|
-- is). Derived from audit_log's 'assign' events since tickets
|
||||||
|
-- has no assigned_at column; COUNT(DISTINCT ...) so a ticket
|
||||||
|
-- reassigned more than once to the same user in-range still
|
||||||
|
-- counts once.
|
||||||
|
SELECT
|
||||||
|
CAST(JSON_UNQUOTE(JSON_EXTRACT(details, '$.assigned_to')) AS UNSIGNED) as assigned_to,
|
||||||
|
COUNT(DISTINCT entity_id) as tickets_assigned
|
||||||
|
FROM audit_log
|
||||||
|
WHERE action_type = 'assign' AND entity_type = 'ticket'
|
||||||
|
AND DATE(created_at) BETWEEN ? AND ?
|
||||||
GROUP BY assigned_to
|
GROUP BY assigned_to
|
||||||
) ta ON u.user_id = ta.assigned_to
|
) ta ON u.user_id = ta.assigned_to
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -444,5 +459,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ApiKeyAuth
|
|||||||
{
|
{
|
||||||
$this->keyContext = [
|
$this->keyContext = [
|
||||||
'scope' => $keyData['scope'] ?? 'read_write',
|
'scope' => $keyData['scope'] ?? 'read_write',
|
||||||
|
'see_all_visibility' => !empty($keyData['see_all_visibility']),
|
||||||
'key_name' => $keyData['key_name'] ?? null,
|
'key_name' => $keyData['key_name'] ?? null,
|
||||||
'created_by' => $keyData['created_by'] ?? null,
|
'created_by' => $keyData['created_by'] ?? null,
|
||||||
'api_key_id' => $keyData['api_key_id'] ?? null,
|
'api_key_id' => $keyData['api_key_id'] ?? null,
|
||||||
@@ -46,7 +47,7 @@ class ApiKeyAuth
|
|||||||
/**
|
/**
|
||||||
* Get the context of the authenticated API key.
|
* Get the context of the authenticated API key.
|
||||||
*
|
*
|
||||||
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
|
* @return array|null ['scope', 'see_all_visibility', 'key_name', 'created_by', 'api_key_id'] or null
|
||||||
*/
|
*/
|
||||||
public function getKeyContext(): ?array
|
public function getKeyContext(): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,21 +3,34 @@
|
|||||||
/**
|
/**
|
||||||
* Rate Limiting Middleware
|
* Rate Limiting Middleware
|
||||||
*
|
*
|
||||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
* Implements session-based, IP-based, and (for Bearer-authenticated
|
||||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
* requests) API-key-based rate limiting to prevent abuse.
|
||||||
|
* IP-based limiting prevents attackers from bypassing limits by creating new
|
||||||
|
* sessions; API-key-based limiting keeps distinct Bearer clients from
|
||||||
|
* starving each other's shared IP bucket.
|
||||||
*/
|
*/
|
||||||
class RateLimitMiddleware
|
class RateLimitMiddleware
|
||||||
{
|
{
|
||||||
// Default limits
|
// Fallback limits, used only if $GLOBALS['config'] isn't populated
|
||||||
|
// (e.g. very early in bootstrap, or a test harness). Normal requests read
|
||||||
|
// RATE_LIMIT_DEFAULT/RATE_LIMIT_API from config (backed by .env).
|
||||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||||
public const API_LIMIT = 60; // API requests per window (session)
|
public const API_LIMIT = 60; // API requests per window (session)
|
||||||
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||||
|
public const API_KEY_LIMIT = 120; // Per-Bearer-token requests per window
|
||||||
public const WINDOW_SECONDS = 60; // 1 minute window
|
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||||
|
|
||||||
// Directory for IP rate limit storage
|
// Directory for IP rate limit storage
|
||||||
private static ?string $rateLimitDir = null;
|
private static ?string $rateLimitDir = null;
|
||||||
|
|
||||||
|
private static function sessionLimit(string $type): int
|
||||||
|
{
|
||||||
|
$configKey = $type === 'api' ? 'RATE_LIMIT_API' : 'RATE_LIMIT_DEFAULT';
|
||||||
|
$fallback = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||||
|
return (int)($GLOBALS['config'][$configKey] ?? $fallback);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the rate limit storage directory
|
* Get the rate limit storage directory
|
||||||
*
|
*
|
||||||
@@ -69,24 +82,47 @@ class RateLimitMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check IP-based rate limit
|
* Extract the raw Bearer token from the Authorization header, if present.
|
||||||
|
* Deliberately independent of ApiKeyAuth: rate limiting must be cheap and
|
||||||
|
* must not require a DB round-trip to validate the key before counting
|
||||||
|
* the request, and needs to run whether or not the token turns out to be
|
||||||
|
* valid. The raw token string (not the validated api_key_id) is hashed as
|
||||||
|
* the bucket identifier — good enough to isolate distinct keys/clients
|
||||||
|
* from each other without needing to authenticate first.
|
||||||
*
|
*
|
||||||
* @param string $type 'default' or 'api'
|
* @return string|null
|
||||||
* @return bool True if request is allowed, false if rate limited
|
|
||||||
*/
|
*/
|
||||||
private static function checkIpRateLimit(string $type = 'default'): bool
|
private static function getBearerToken(): ?string
|
||||||
{
|
{
|
||||||
$ip = self::getClientIp();
|
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||||
$now = time();
|
?? null;
|
||||||
|
if ($header === null && function_exists('getallheaders')) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$header = $headers['Authorization'] ?? null;
|
||||||
|
}
|
||||||
|
if ($header && preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
/**
|
||||||
$ipHash = hash('sha256', $ip . '_' . $type);
|
* Generic file-based sliding-window counter, shared by the IP-based and
|
||||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
* API-key-based buckets below.
|
||||||
|
*
|
||||||
|
* @param string $bucketKey Stable identifier for this bucket (already hashed)
|
||||||
|
* @param int $limit Max requests allowed per window
|
||||||
|
* @return bool True if this request is within the limit
|
||||||
|
*/
|
||||||
|
private static function checkCounter(string $bucketKey, int $limit): bool
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
||||||
|
|
||||||
// Hold an exclusive lock across the whole read-modify-write so concurrent
|
// Hold an exclusive lock across the whole read-modify-write so concurrent
|
||||||
// requests from the same IP can't both read the same count and each write
|
// requests from the same bucket can't both read the same count and each
|
||||||
// count+1 (which would undercount and let the limit be exceeded).
|
// write count+1 (which would undercount and let the limit be exceeded).
|
||||||
$fh = @fopen($filePath, 'c+');
|
$fh = @fopen($filePath, 'c+');
|
||||||
if ($fh === false) {
|
if ($fh === false) {
|
||||||
// Can't open the counter file — fail open (don't block legitimate traffic).
|
// Can't open the counter file — fail open (don't block legitimate traffic).
|
||||||
@@ -122,10 +158,60 @@ class RateLimitMiddleware
|
|||||||
flock($fh, LOCK_UN);
|
flock($fh, LOCK_UN);
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
|
|
||||||
// Check if over limit
|
|
||||||
return $rateData['count'] <= $limit;
|
return $rateData['count'] <= $limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read (without incrementing) the current state of a counter bucket, for
|
||||||
|
* status/header reporting.
|
||||||
|
*/
|
||||||
|
private static function peekCounter(string $bucketKey, int $limit): array
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
||||||
|
|
||||||
|
$rateData = null;
|
||||||
|
$content = @file_get_contents($filePath);
|
||||||
|
if ($content !== false && $content !== '') {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$rateData = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rateData === null || $now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
|
||||||
|
return ['limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => max(0, $limit - $rateData['count']),
|
||||||
|
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ipBucketKey(string $type): string
|
||||||
|
{
|
||||||
|
return hash('sha256', self::getClientIp() . '_' . $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function apiKeyBucketKey(string $token): string
|
||||||
|
{
|
||||||
|
return hash('sha256', 'apikey_' . $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check IP-based rate limit
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
* @return bool True if request is allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||||
|
{
|
||||||
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||||
|
return self::checkCounter(self::ipBucketKey($type), $limit);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up old rate limit files (call periodically)
|
* Clean up old rate limit files (call periodically)
|
||||||
*
|
*
|
||||||
@@ -185,7 +271,14 @@ class RateLimitMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check rate limit for current request (both session and IP)
|
* Check rate limit for current request.
|
||||||
|
*
|
||||||
|
* Bearer-authenticated requests (Authorization: Bearer ...) are limited
|
||||||
|
* by a per-token bucket instead of a session — a stateless API client
|
||||||
|
* never sends a session cookie back, so the session-based counter never
|
||||||
|
* accumulates and starting a session for it is pure overhead. The
|
||||||
|
* IP-based bucket still applies underneath as defense-in-depth against
|
||||||
|
* volumetric abuse from one network path.
|
||||||
*
|
*
|
||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return bool True if request is allowed, false if rate limited
|
* @return bool True if request is allowed, false if rate limited
|
||||||
@@ -197,12 +290,17 @@ class RateLimitMiddleware
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$token = self::getBearerToken();
|
||||||
|
if ($token !== null) {
|
||||||
|
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
// Then check session-based rate limit
|
// Then check session-based rate limit
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
$limit = self::sessionLimit($type);
|
||||||
$key = 'rate_limit_' . $type;
|
$key = 'rate_limit_' . $type;
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
@@ -270,18 +368,28 @@ class RateLimitMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current rate limit status
|
* Get current rate limit status.
|
||||||
|
*
|
||||||
|
* For a Bearer-authenticated request, reports the per-API-key bucket
|
||||||
|
* (the one that actually governs it) rather than the session-based
|
||||||
|
* counter, which is meaningless for a client that never sends a session
|
||||||
|
* cookie back.
|
||||||
*
|
*
|
||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return array Rate limit status
|
* @return array Rate limit status
|
||||||
*/
|
*/
|
||||||
public static function getStatus(string $type = 'default'): array
|
public static function getStatus(string $type = 'default'): array
|
||||||
{
|
{
|
||||||
|
$token = self::getBearerToken();
|
||||||
|
if ($token !== null) {
|
||||||
|
return self::peekCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
$limit = self::sessionLimit($type);
|
||||||
$key = 'rate_limit_' . $type;
|
$key = 'rate_limit_' . $type;
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
|||||||
|
|
||||||
-- ============ ticket_watchers ============
|
-- ============ ticket_watchers ============
|
||||||
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
||||||
`ticket_id` int(11) NOT NULL,
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
`user_id` int(11) NOT NULL,
|
`user_id` int(11) NOT NULL,
|
||||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||||
PRIMARY KEY (`ticket_id`,`user_id`),
|
PRIMARY KEY (`ticket_id`,`user_id`),
|
||||||
KEY `idx_watcher_user` (`user_id`)
|
KEY `idx_watcher_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ tickets ============
|
-- ============ tickets ============
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
|
||||||
|
--
|
||||||
|
-- ticket_watchers.ticket_id was int(11), while every other satellite table
|
||||||
|
-- (ticket_comments, ticket_attachments, ticket_dependencies,
|
||||||
|
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
|
||||||
|
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
|
||||||
|
-- other satellite table, so orphaned watcher rows could never be caught by
|
||||||
|
-- referential integrity. Ticket IDs are always 9-digit numeric strings
|
||||||
|
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
|
||||||
|
-- conversion below is lossless for real data.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
-- Remove any watcher rows that no longer point at a real ticket (possible
|
||||||
|
-- today precisely because there was no FK to prevent it) before adding the
|
||||||
|
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
|
||||||
|
DELETE tw FROM `ticket_watchers` tw
|
||||||
|
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
|
||||||
|
WHERE t.`ticket_id` IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Add a nullable thumbnail_filename column to ticket_attachments so an image
|
||||||
|
-- upload can store a separately-generated, resized preview alongside the
|
||||||
|
-- full-size original. NULL means no thumbnail exists (non-image, GD
|
||||||
|
-- unavailable at upload time, or an attachment uploaded before this existed)
|
||||||
|
-- and callers fall back to the full-size original.
|
||||||
|
--
|
||||||
|
-- scripts/cleanup_orphan_uploads.php's orphan lookup is updated in the same
|
||||||
|
-- change to also match thumbnail_filename, so generated thumbnails aren't
|
||||||
|
-- swept up as orphans.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_attachments`
|
||||||
|
ADD COLUMN IF NOT EXISTS `thumbnail_filename` varchar(255) DEFAULT NULL AFTER `filename`;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Restrict Bearer API keys to public-visibility tickets by default (#70).
|
||||||
|
--
|
||||||
|
-- Previously any 'read'-scope key bypassed ticket visibility entirely —
|
||||||
|
-- Confidential and Internal tickets were readable by any key, regardless
|
||||||
|
-- of who it was issued to. see_all_visibility is an explicit opt-in an
|
||||||
|
-- admin sets per-key when a key genuinely needs to see non-public tickets;
|
||||||
|
-- it defaults to 0 (public-only) for both new and existing keys, since the
|
||||||
|
-- prior blanket-access behavior is the thing being restricted.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `api_keys`
|
||||||
|
ADD COLUMN IF NOT EXISTS `see_all_visibility` tinyint(1) NOT NULL DEFAULT 0 AFTER `scope`;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Queue for Matrix webhook notifications that failed to send (#78).
|
||||||
|
--
|
||||||
|
-- NotificationHelper::fire() previously logged a failed webhook post via
|
||||||
|
-- error_log() only, with no retry and no persistent record — once a
|
||||||
|
-- notification failed, it was gone. Failed payloads are now queued here and
|
||||||
|
-- retried by cron/retry_failed_notifications.php with exponential backoff.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `notification_retry_queue` (
|
||||||
|
`retry_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`payload`)),
|
||||||
|
`attempts` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`max_attempts` int(11) NOT NULL DEFAULT 6,
|
||||||
|
`next_attempt_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`last_error` varchar(500) DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`retry_id`),
|
||||||
|
KEY `idx_next_attempt` (`next_attempt_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
+68
-11
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
||||||
|
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
||||||
|
// rollback() around a whole file can't actually undo DDL already executed
|
||||||
|
// earlier in that same file. This table lets a re-run after a partial failure
|
||||||
|
// resume from the statement after the last one that succeeded, instead of
|
||||||
|
// re-executing already-applied DDL and wedging on "already exists" errors.
|
||||||
|
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
||||||
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
last_statement_index INT NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)";
|
||||||
|
|
||||||
|
if (!$conn->query($createProgressTable)) {
|
||||||
|
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Get list of completed migrations
|
// Get list of completed migrations
|
||||||
$completed = [];
|
$completed = [];
|
||||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||||
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute migration - handle multiple statements
|
// Execute migration statement-by-statement, tracking progress as we go.
|
||||||
$conn->begin_transaction();
|
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
||||||
|
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
||||||
|
// file would only create the illusion of atomicity while giving no real
|
||||||
|
// protection. Instead, each statement commits immediately (autocommit),
|
||||||
|
// and its index is durably recorded so a later re-run can resume exactly
|
||||||
|
// where a previous run left off rather than re-executing already-applied
|
||||||
|
// DDL.
|
||||||
try {
|
try {
|
||||||
// Split by semicolon but respect statements properly
|
// Split by semicolon but respect statements properly
|
||||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||||
$statements = array_filter(
|
$statements = array_values(array_filter(
|
||||||
array_map('trim', explode(';', $sql)),
|
array_map('trim', explode(';', $sql)),
|
||||||
function($stmt) {
|
function($stmt) {
|
||||||
// Remove comments and check if there's actual SQL
|
// Remove comments and check if there's actual SQL
|
||||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
return !empty(trim($cleaned));
|
return !empty(trim($cleaned));
|
||||||
}
|
}
|
||||||
);
|
));
|
||||||
|
|
||||||
|
$resumeFrom = 0;
|
||||||
|
$progressStmt = $conn->prepare(
|
||||||
|
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
||||||
|
);
|
||||||
|
$progressStmt->bind_param('s', $filename);
|
||||||
|
$progressStmt->execute();
|
||||||
|
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
||||||
|
$progressStmt->close();
|
||||||
|
if ($progressRow) {
|
||||||
|
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
||||||
|
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
||||||
|
. " after a previous partial failure... ";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statements as $index => $statement) {
|
||||||
|
if ($index < $resumeFrom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($statements as $statement) {
|
|
||||||
if (!$conn->query($statement)) {
|
if (!$conn->query($statement)) {
|
||||||
// Some "errors" are acceptable (like "index already exists")
|
// Some "errors" are acceptable (like "index already exists")
|
||||||
$error = $conn->error;
|
$error = $conn->error;
|
||||||
if (strpos($error, 'Duplicate key name') !== false ||
|
if (strpos($error, 'Duplicate key name') !== false ||
|
||||||
strpos($error, 'already exists') !== false) {
|
strpos($error, 'already exists') !== false) {
|
||||||
// Index already exists, that's fine
|
// Index already exists, that's fine
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
throw new Exception($error);
|
throw new Exception($error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the migration
|
// Record progress after every statement so a later run can
|
||||||
|
// resume from here even if a subsequent statement fails.
|
||||||
|
$upsert = $conn->prepare(
|
||||||
|
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
||||||
|
);
|
||||||
|
$upsert->bind_param('si', $filename, $index);
|
||||||
|
$upsert->execute();
|
||||||
|
$upsert->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the migration as fully complete and clear its progress marker
|
||||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||||
$stmt->bind_param('s', $filename);
|
$stmt->bind_param('s', $filename);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception("Could not record migration: " . $conn->error);
|
throw new Exception("Could not record migration: " . $conn->error);
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->commit();
|
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
||||||
|
$clearProgress->bind_param('s', $filename);
|
||||||
|
$clearProgress->execute();
|
||||||
|
$clearProgress->close();
|
||||||
|
|
||||||
echo "OK\n";
|
echo "OK\n";
|
||||||
$success++;
|
$success++;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$conn->rollback();
|
// Nothing to roll back: every statement up to the failure already
|
||||||
|
// committed (DDL implicitly, everything else via autocommit). The
|
||||||
|
// progress marker recorded above reflects exactly how far this file
|
||||||
|
// got, so the next run will resume right after the last success.
|
||||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-4
@@ -19,9 +19,11 @@ class ApiKeyModel
|
|||||||
* @param int $createdBy User ID who created the key
|
* @param int $createdBy User ID who created the key
|
||||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||||
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
|
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
|
||||||
|
* @param bool $seeAllVisibility If true, the key bypasses ticket visibility (Confidential/
|
||||||
|
* Internal included); defaults to false (public tickets only)
|
||||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
|
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
|
||||||
*/
|
*/
|
||||||
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
|
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write', $seeAllVisibility = false)
|
||||||
{
|
{
|
||||||
// Validate the requested scope — only the two known values are allowed
|
// Validate the requested scope — only the two known values are allowed
|
||||||
if (!in_array($scope, ['read', 'read_write'], true)) {
|
if (!in_array($scope, ['read', 'read_write'], true)) {
|
||||||
@@ -47,11 +49,12 @@ class ApiKeyModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert API key into database
|
// Insert API key into database
|
||||||
|
$seeAllVisibilityInt = $seeAllVisibility ? 1 : 0;
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
|
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, see_all_visibility, created_by, expires_at) "
|
||||||
. "VALUES (?, ?, ?, ?, ?, ?)"
|
. "VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
|
$stmt->bind_param("ssssiis", $keyName, $keyHash, $keyPrefix, $scope, $seeAllVisibilityInt, $createdBy, $expiresAt);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
$keyId = $this->conn->insert_id;
|
$keyId = $this->conn->insert_id;
|
||||||
@@ -63,6 +66,7 @@ class ApiKeyModel
|
|||||||
'key_prefix' => $keyPrefix,
|
'key_prefix' => $keyPrefix,
|
||||||
'key_id' => $keyId,
|
'key_id' => $keyId,
|
||||||
'scope' => $scope,
|
'scope' => $scope,
|
||||||
|
'see_all_visibility' => $seeAllVisibility,
|
||||||
'expires_at' => $expiresAt
|
'expires_at' => $expiresAt
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
@@ -114,6 +118,13 @@ class ApiKeyModel
|
|||||||
$keyData['scope'] = 'read_write';
|
$keyData['scope'] = 'read_write';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unlike scope's backward-compatible fallback above, an un-migrated or
|
||||||
|
// null see_all_visibility defaults to the RESTRICTIVE value (public
|
||||||
|
// tickets only) — this column exists specifically to lock down a
|
||||||
|
// previously-unrestricted default, so a missing value must not fall
|
||||||
|
// back to the permissive behavior it's replacing.
|
||||||
|
$keyData['see_all_visibility'] = !empty($keyData['see_all_visibility']);
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if ($keyData['expires_at'] !== null) {
|
if ($keyData['expires_at'] !== null) {
|
||||||
$expiresAt = strtotime($keyData['expires_at']);
|
$expiresAt = strtotime($keyData['expires_at']);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class AttachmentModel
|
|||||||
/**
|
/**
|
||||||
* Get all attachments for a ticket
|
* Get all attachments for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachments($ticketId)
|
public function getAttachments($ticketId, int $limit = 0, int $offset = 0)
|
||||||
{
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
@@ -24,8 +24,16 @@ class AttachmentModel
|
|||||||
WHERE a.ticket_id = ?
|
WHERE a.ticket_id = ?
|
||||||
ORDER BY a.uploaded_at DESC";
|
ORDER BY a.uploaded_at DESC";
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("sii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
$stmt->bind_param("s", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -60,14 +68,19 @@ class AttachmentModel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new attachment record
|
* Add a new attachment record
|
||||||
|
*
|
||||||
|
* @param string|null $thumbnailFilename Stored filename of a generated preview
|
||||||
|
* thumbnail, or null if none was generated
|
||||||
|
* (non-image, GD unavailable, etc.) — callers
|
||||||
|
* fall back to the full-size original.
|
||||||
*/
|
*/
|
||||||
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
|
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy, $thumbnailFilename = null)
|
||||||
{
|
{
|
||||||
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
$stmt->bind_param("ssssisi", $ticketId, $filename, $thumbnailFilename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class AuditLogModel
|
|||||||
private const VALID_ACTION_TYPES = [
|
private const VALID_ACTION_TYPES = [
|
||||||
'create', 'update', 'delete', 'view', 'security_event',
|
'create', 'update', 'delete', 'view', 'security_event',
|
||||||
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
|
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
|
||||||
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update'
|
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update',
|
||||||
|
'watch', 'unwatch'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @var array Allowed entity types for filtering */
|
/** @var array Allowed entity types for filtering */
|
||||||
|
|||||||
+105
-10
@@ -33,6 +33,23 @@ class BulkOperationsModel
|
|||||||
return $this->workflowModel;
|
return $this->workflowModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch a ticket row inside the current transaction with a row lock
|
||||||
|
* (FOR UPDATE), so a concurrent transaction touching the same row blocks
|
||||||
|
* until this one commits or rolls back instead of both validating
|
||||||
|
* against the same stale snapshot. Must be called after
|
||||||
|
* begin_transaction() and before the row is written.
|
||||||
|
*/
|
||||||
|
private function lockTicketForUpdate(string $ticketId): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->conn->prepare("SELECT * FROM tickets WHERE ticket_id = ? FOR UPDATE");
|
||||||
|
$stmt->bind_param('s', $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The status a bulk operation is trying to move tickets into, or null for
|
* The status a bulk operation is trying to move tickets into, or null for
|
||||||
* operations that don't change status.
|
* operations that don't change status.
|
||||||
@@ -89,12 +106,18 @@ class BulkOperationsModel
|
|||||||
/**
|
/**
|
||||||
* Process a bulk operation
|
* Process a bulk operation
|
||||||
*
|
*
|
||||||
* Uses database transaction to ensure atomicity - either all tickets
|
* Runs the whole batch inside one database transaction, but by default
|
||||||
* are updated or none are (on failure, changes are rolled back).
|
* ($atomic = false, which is what api/bulk_operation.php uses) that
|
||||||
|
* transaction is always committed: a per-ticket failure (e.g. a
|
||||||
|
* disallowed workflow transition) is recorded in $failed/$errors and
|
||||||
|
* skipped, while every other ticket in the batch still succeeds. This
|
||||||
|
* is a best-effort batch, not an all-or-nothing one — set $atomic to
|
||||||
|
* true to roll back the entire batch when any ticket fails.
|
||||||
*
|
*
|
||||||
* @param int $operationId Operation ID
|
* @param int $operationId Operation ID
|
||||||
* @param bool $atomic If true, rollback all changes on any failure
|
* @param bool $atomic If true, rollback all changes on any failure
|
||||||
* @return array Result with processed and failed counts
|
* @return array Result with processed/failed counts and an errors[] list of
|
||||||
|
* per-ticket failure reasons (surfaced to the admin by the caller)
|
||||||
*/
|
*/
|
||||||
public function processBulkOperation($operationId, bool $atomic = false)
|
public function processBulkOperation($operationId, bool $atomic = false)
|
||||||
{
|
{
|
||||||
@@ -125,13 +148,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);
|
||||||
@@ -173,13 +206,27 @@ class BulkOperationsModel
|
|||||||
$success = false;
|
$success = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Re-fetch and row-lock the ticket inside the transaction for any
|
||||||
|
// operation that validates against or reads its current fields —
|
||||||
|
// the pre-transaction $ticketsById snapshot (loaded before
|
||||||
|
// begin_transaction()) can be stale by the time we get here if a
|
||||||
|
// concurrent request (a single-ticket edit, or another bulk op)
|
||||||
|
// changed the row in between. Validating a transition against a
|
||||||
|
// stale status, or writing back stale title/description/etc.,
|
||||||
|
// could silently bypass Workflow Designer rules or clobber a
|
||||||
|
// concurrent edit. FOR UPDATE blocks a concurrent transaction
|
||||||
|
// from reading/writing this row until ours commits or rolls back.
|
||||||
|
$needsCurrentTicket = $targetStatus !== null || $operation['operation_type'] === 'bulk_priority';
|
||||||
|
$currentTicket = $needsCurrentTicket
|
||||||
|
? $this->lockTicketForUpdate($ticketId)
|
||||||
|
: ($ticketsById[$ticketId] ?? null);
|
||||||
|
|
||||||
// bulk_status / bulk_close enforce the same Workflow Designer
|
// bulk_status / bulk_close enforce the same Workflow Designer
|
||||||
// rules as the single-ticket path: a transition the designer
|
// rules as the single-ticket path: a transition the designer
|
||||||
// doesn't define is refused, and requires_comment is honoured
|
// doesn't define is refused, and requires_comment is honoured
|
||||||
// (checked up front, above). requires_admin is satisfied because
|
// (checked up front, above). requires_admin is satisfied because
|
||||||
// api/bulk_operation.php already gates the endpoint on admin.
|
// api/bulk_operation.php already gates the endpoint on admin.
|
||||||
if ($targetStatus !== null) {
|
if ($targetStatus !== null) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
|
||||||
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
|
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
|
||||||
// Already in the requested state — nothing to do, and
|
// Already in the requested state — nothing to do, and
|
||||||
// reporting a no-op as a failure would just confuse.
|
// reporting a no-op as a failure would just confuse.
|
||||||
@@ -201,8 +248,7 @@ class BulkOperationsModel
|
|||||||
|
|
||||||
switch ($operation['operation_type']) {
|
switch ($operation['operation_type']) {
|
||||||
case 'bulk_close':
|
case 'bulk_close':
|
||||||
// Get current ticket from pre-loaded batch
|
// $currentTicket is the fresh, row-locked read from above.
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$updateResult = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
@@ -221,8 +267,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;
|
||||||
@@ -244,7 +300,7 @@ class BulkOperationsModel
|
|||||||
|
|
||||||
case 'bulk_priority':
|
case 'bulk_priority':
|
||||||
if (isset($parameters['priority'])) {
|
if (isset($parameters['priority'])) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
// $currentTicket is the fresh, row-locked read from above.
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$updateResult = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
@@ -272,7 +328,7 @@ class BulkOperationsModel
|
|||||||
|
|
||||||
case 'bulk_status':
|
case 'bulk_status':
|
||||||
if (isset($parameters['status'])) {
|
if (isset($parameters['status'])) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
// $currentTicket is the fresh, row-locked read from above.
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$updateResult = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
@@ -291,8 +347,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 +430,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();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class CommentModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
$sql = "SELECT user_id, username, display_name, is_admin, `groups` FROM users WHERE username IN ($placeholders)";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
$types = str_repeat('s', count($usernames));
|
$types = str_repeat('s', count($usernames));
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class CustomFieldModel
|
|||||||
{
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
|
// Must match custom_field_definitions.field_type's enum() in the schema.
|
||||||
|
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
|
||||||
|
|
||||||
public function __construct($conn)
|
public function __construct($conn)
|
||||||
{
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
@@ -87,6 +90,10 @@ class CustomFieldModel
|
|||||||
*/
|
*/
|
||||||
public function createDefinition($data)
|
public function createDefinition($data)
|
||||||
{
|
{
|
||||||
|
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||||
|
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||||
|
}
|
||||||
|
|
||||||
$options = null;
|
$options = null;
|
||||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||||
$options = json_encode($data['field_options']);
|
$options = json_encode($data['field_options']);
|
||||||
@@ -129,6 +136,10 @@ class CustomFieldModel
|
|||||||
*/
|
*/
|
||||||
public function updateDefinition($fieldId, $data)
|
public function updateDefinition($fieldId, $data)
|
||||||
{
|
{
|
||||||
|
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||||
|
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||||
|
}
|
||||||
|
|
||||||
$options = null;
|
$options = null;
|
||||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||||
$options = json_encode($data['field_options']);
|
$options = json_encode($data['field_options']);
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class StatsModel
|
|||||||
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'status' as type, status as label, COUNT(*) as count
|
SELECT 'status' as type, status as label, COUNT(*) as count
|
||||||
FROM tickets t WHERE ($visSQL) GROUP BY status
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY status
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'category' as type, category as label, COUNT(*) as count
|
SELECT 'category' as type, category as label, COUNT(*) as count
|
||||||
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
|
||||||
|
|||||||
+63
-1
@@ -726,7 +726,10 @@ class TicketModel
|
|||||||
$groupConditions = [];
|
$groupConditions = [];
|
||||||
foreach ($userGroups as $group) {
|
foreach ($userGroups as $group) {
|
||||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||||
$params[] = $group;
|
// Strip spaces from the bound value too, matching the REPLACE()
|
||||||
|
// applied to the column, so a group name like "IT Support" is
|
||||||
|
// normalized the same way on both sides of the comparison.
|
||||||
|
$params[] = str_replace(' ', '', $group);
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
}
|
}
|
||||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||||
@@ -770,9 +773,68 @@ class TicketModel
|
|||||||
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups);
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any watchers who no longer qualify for a ticket's access rules
|
||||||
|
* after its visibility was tightened. Without this, a user watching a
|
||||||
|
* ticket that's later made confidential/internal (and who isn't
|
||||||
|
* creator/assignee/admin/in the new visibility_groups) keeps receiving
|
||||||
|
* Matrix notifications about a ticket canUserAccessTicket() would now
|
||||||
|
* reject them from opening directly.
|
||||||
|
*/
|
||||||
|
private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void
|
||||||
|
{
|
||||||
|
$ticket = $this->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// getTicketById() reflects the just-committed UPDATE, but set these
|
||||||
|
// explicitly so pruning is correct even if a caller reorders things.
|
||||||
|
$ticket['visibility'] = $visibility;
|
||||||
|
$ticket['visibility_groups'] = $visibilityGroups;
|
||||||
|
|
||||||
|
$sql = "SELECT tw.user_id, u.is_admin, u.`groups`
|
||||||
|
FROM ticket_watchers tw
|
||||||
|
JOIN users u ON tw.user_id = u.user_id
|
||||||
|
WHERE tw.ticket_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param('s', $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$toRemove = [];
|
||||||
|
foreach ($watchers as $watcher) {
|
||||||
|
$watcherUser = [
|
||||||
|
'user_id' => $watcher['user_id'],
|
||||||
|
'is_admin' => $watcher['is_admin'],
|
||||||
|
'groups' => $watcher['groups'],
|
||||||
|
];
|
||||||
|
if (!$this->canUserAccessTicket($ticket, $watcherUser)) {
|
||||||
|
$toRemove[] = $watcher['user_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($toRemove)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($toRemove), '?'));
|
||||||
|
$delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)";
|
||||||
|
$delStmt = $this->conn->prepare($delSql);
|
||||||
|
$types = 's' . str_repeat('i', count($toRemove));
|
||||||
|
$delStmt->bind_param($types, $ticketId, ...$toRemove);
|
||||||
|
$delStmt->execute();
|
||||||
|
$delStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a ticket and all its associated records.
|
* Delete a ticket and all its associated records.
|
||||||
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||||
|
|||||||
@@ -31,9 +31,15 @@ class WorkflowModel
|
|||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ORDER BY makes which row wins deterministic (most recently created,
|
||||||
|
// by transition_id) in the pathological case where two active rows
|
||||||
|
// exist for the same (from_status, to_status) pair — manage_workflows.php
|
||||||
|
// now rejects creating that duplicate going forward, but this is a
|
||||||
|
// defense-in-depth backstop against any duplicate already in the DB.
|
||||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||||
FROM status_transitions
|
FROM status_transitions
|
||||||
WHERE is_active = TRUE";
|
WHERE is_active = TRUE
|
||||||
|
ORDER BY transition_id ASC";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
|
|||||||
@@ -59,10 +59,11 @@ try {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepared lookup: does any attachment row reference this stored filename?
|
// Prepared lookup: does any attachment row reference this stored filename,
|
||||||
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
|
// either as the original file or as its generated preview thumbnail? Both
|
||||||
// and safe — a match in any ticket means the file is a real attachment.
|
// are globally unique (uniqid-derived), so filename alone is sufficient and
|
||||||
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
|
// safe — a match in any ticket means the file is a real, referenced file.
|
||||||
|
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? OR thumbnail_filename = ? LIMIT 1');
|
||||||
if ($lookup === false) {
|
if ($lookup === false) {
|
||||||
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
|
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the file if any attachment row references it.
|
// Keep the file if any attachment row references it (as the
|
||||||
$lookup->bind_param('s', $filename);
|
// original or as its thumbnail).
|
||||||
|
$lookup->bind_param('ss', $filename, $filename);
|
||||||
$lookup->execute();
|
$lookup->execute();
|
||||||
$hasRow = $lookup->get_result()->num_rows > 0;
|
$hasRow = $lookup->get_result()->num_rows > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
|
|
||||||
<div class="lt-form-group">
|
<div class="lt-form-group">
|
||||||
<label class="lt-label" for="category">Category</label>
|
<label class="lt-label" for="category">Category</label>
|
||||||
<select id="category" name="category" class="lt-select">
|
<select id="category" name="category" class="lt-select" data-action="toggle-custom-fields">
|
||||||
<option value="Hardware">Hardware</option>
|
<option value="Hardware">Hardware</option>
|
||||||
<option value="Software">Software</option>
|
<option value="Software">Software</option>
|
||||||
<option value="Network">Network</option>
|
<option value="Network">Network</option>
|
||||||
@@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($allCustomFieldDefs)) : ?>
|
||||||
|
<!-- ── SECTION 5b: Custom Fields ─────────────────────────── -->
|
||||||
|
<div class="lt-frame lt-mb-md">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Additional Fields</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<?php foreach ($allCustomFieldDefs as $cfDef) : ?>
|
||||||
|
<div class="lt-form-group custom-field-group"
|
||||||
|
data-custom-field-category="<?= htmlspecialchars($cfDef['category'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php
|
||||||
|
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
|
||||||
|
$cfId = 'custom_field_' . (int)$cfDef['field_id'];
|
||||||
|
?>
|
||||||
|
<label class="lt-label" for="<?= $cfId ?>">
|
||||||
|
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
|
||||||
|
</label>
|
||||||
|
<?php if ($cfDef['field_type'] === 'textarea') : ?>
|
||||||
|
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
|
||||||
|
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>></textarea>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'select') : ?>
|
||||||
|
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select"
|
||||||
|
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
|
||||||
|
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1">
|
||||||
|
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</label>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'date') : ?>
|
||||||
|
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||||
|
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'number') : ?>
|
||||||
|
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||||
|
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||||
|
<?php else : ?>
|
||||||
|
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||||
|
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<p class="lt-form-hint">Fields shown depend on the selected Category.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
<!-- ── SECTION 6: Description ───────────────────────────── -->
|
<!-- ── SECTION 6: Description ───────────────────────────── -->
|
||||||
<div class="lt-frame lt-mb-md">
|
<div class="lt-frame lt-mb-md">
|
||||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php';
|
|||||||
.catch(function () { /* silent — duplicate check is non-critical */ });
|
.catch(function () { /* silent — duplicate check is non-critical */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom fields: show only the selected category's fields ──
|
||||||
|
function toggleCustomFields() {
|
||||||
|
var category = document.getElementById('category').value;
|
||||||
|
document.querySelectorAll('.custom-field-group').forEach(function (group) {
|
||||||
|
var fieldCategory = group.getAttribute('data-custom-field-category');
|
||||||
|
group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Visibility groups toggle ──────────────────────────────
|
// ── Visibility groups toggle ──────────────────────────────
|
||||||
var visibilityHints = {
|
var visibilityHints = {
|
||||||
'public': 'Everyone who is logged in can view this ticket.',
|
'public': 'Everyone who is logged in can view this ticket.',
|
||||||
@@ -387,9 +445,11 @@ include __DIR__ . '/layout_header.php';
|
|||||||
switch (target.getAttribute('data-action')) {
|
switch (target.getAttribute('data-action')) {
|
||||||
case 'load-template': loadTemplate(); break;
|
case 'load-template': loadTemplate(); break;
|
||||||
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
|
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
|
||||||
|
case 'toggle-custom-fields': toggleCustomFields(); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toggleCustomFields();
|
||||||
if (window.lt) lt.keys.initDefaults();
|
if (window.lt) lt.keys.initDefaults();
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -280,8 +280,11 @@ include __DIR__ . '/layout_header.php';
|
|||||||
// default: with no `status` param the controller falls back to the viewer's
|
// default: with no `status` param the controller falls back to the viewer's
|
||||||
// default_status_filters preference, which can be anything, so the resulting
|
// default_status_filters preference, which can be anything, so the resulting
|
||||||
// list would not necessarily match what the chart counted. StatsModel builds
|
// list would not necessarily match what the chart counted. StatsModel builds
|
||||||
// by_priority and by_category with `status != 'Closed'`, while by_status spans
|
// by_priority, by_status, and by_category all with `status != 'Closed'` —
|
||||||
// every status — so only the priority and category charts pin the open set.
|
// closed tickets accumulate indefinitely and would otherwise dominate every
|
||||||
|
// breakdown over time — so chartPriority/chartCategory pin the open set
|
||||||
|
// explicitly, while chartStatus's clicked label is itself already one of
|
||||||
|
// the non-Closed statuses.
|
||||||
function openStatuses() {
|
function openStatuses() {
|
||||||
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
|
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
|
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
|
||||||
|
|||||||
+114
-84
@@ -32,6 +32,8 @@ function getEventIcon(string $actionType): string
|
|||||||
'status_change' => '[!]',
|
'status_change' => '[!]',
|
||||||
'attachment' => '[^]',
|
'attachment' => '[^]',
|
||||||
'delete' => '[x]',
|
'delete' => '[x]',
|
||||||
|
'watch' => '[o]',
|
||||||
|
'unwatch' => '[o]',
|
||||||
default => '[*]',
|
default => '[*]',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,10 @@ function formatAction(array $event): string
|
|||||||
return 'uploaded a file';
|
return 'uploaded a file';
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return 'deleted a comment';
|
return 'deleted a comment';
|
||||||
|
case 'watch':
|
||||||
|
return 'started watching this ticket';
|
||||||
|
case 'unwatch':
|
||||||
|
return 'stopped watching this ticket';
|
||||||
case 'assign':
|
case 'assign':
|
||||||
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
||||||
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
||||||
@@ -114,6 +120,7 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
|||||||
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
||||||
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
||||||
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
||||||
|
$json_created_at_ts = json_encode((int)strtotime($ticket['created_at']), JSON_HEX_TAG);
|
||||||
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
||||||
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
||||||
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
||||||
@@ -127,6 +134,7 @@ window.ticketData = {
|
|||||||
category: {$json_category},
|
category: {$json_category},
|
||||||
type: {$json_type},
|
type: {$json_type},
|
||||||
updated_at: {$json_updated_at},
|
updated_at: {$json_updated_at},
|
||||||
|
created_at_ts: {$json_created_at_ts},
|
||||||
totalComments: {$json_total_comments},
|
totalComments: {$json_total_comments},
|
||||||
commentOffset: {$json_comment_page},
|
commentOffset: {$json_comment_page},
|
||||||
commentPageSize:{$json_comment_page},
|
commentPageSize:{$json_comment_page},
|
||||||
@@ -209,95 +217,17 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
<?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
|
||||||
<?php
|
// renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
|
||||||
$slaTargetHours = match ($priorityNum) {
|
// down when priority changes client-side without a page reload. ?>
|
||||||
1 => 8, 2 => 24, default => 72
|
<div id="priorityAlertBannerAnchor"></div>
|
||||||
};
|
|
||||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
|
||||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
|
||||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
|
||||||
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
|
|
||||||
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
|
||||||
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
|
|
||||||
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
|
|
||||||
?>
|
|
||||||
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
|
||||||
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
|
||||||
data-sla-id="<?= $slaId ?>"
|
|
||||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
|
||||||
data-sla-hours="<?= $slaTargetHours ?>"
|
|
||||||
style="margin-bottom:0.75rem">
|
|
||||||
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
|
||||||
<div class="lt-sla-info">
|
|
||||||
<div class="lt-sla-title">
|
|
||||||
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
|
||||||
<?php if ($slaBreached) : ?>
|
|
||||||
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
|
||||||
<?php endif ?>
|
|
||||||
</div>
|
|
||||||
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
|
||||||
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?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>
|
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
|
||||||
var banner = document.getElementById('priorityAlertBanner');
|
|
||||||
var id = banner.dataset.slaId;
|
|
||||||
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) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (banner.hidden) return;
|
if (typeof renderSlaBanner === 'function') {
|
||||||
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
renderSlaBanner(window.ticketData.priority);
|
||||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
|
||||||
var deadline = new Date(createdAt + slaMs);
|
|
||||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
|
||||||
var countdownEl = document.getElementById('slaCountdownTimer');
|
|
||||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
|
||||||
var fillBar = document.getElementById('slaProgressBar');
|
|
||||||
var progressWrap = document.getElementById('slaProgress');
|
|
||||||
|
|
||||||
function fmtHMS(ms) {
|
|
||||||
var s = Math.floor(Math.abs(ms) / 1000);
|
|
||||||
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
|
||||||
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tick() {
|
|
||||||
var now = Date.now();
|
|
||||||
var elapsed = now - createdAt;
|
|
||||||
var remaining = deadline - now;
|
|
||||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
|
||||||
|
|
||||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
|
||||||
if (fillBar) fillBar.style.width = pct + '%';
|
|
||||||
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
|
||||||
|
|
||||||
if (remaining > 0) {
|
|
||||||
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
|
||||||
} else {
|
|
||||||
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tick();
|
|
||||||
setInterval(tick, 1000);
|
|
||||||
});
|
});
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
<?php endif ?>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TICKET DETAIL FRAME
|
TICKET DETAIL FRAME
|
||||||
@@ -473,6 +403,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
|
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
|
||||||
Dependencies
|
Dependencies
|
||||||
</button>
|
</button>
|
||||||
|
<?php if (!empty($customFieldDefs)) : ?>
|
||||||
|
<button type="button" class="lt-tab" id="custom-fields-tab-btn"
|
||||||
|
role="tab" data-tab="custom-fields-panel" aria-selected="false" aria-controls="custom-fields-panel">
|
||||||
|
Custom Fields
|
||||||
|
</button>
|
||||||
|
<?php endif ?>
|
||||||
<button type="button" class="lt-tab" id="activity-tab-btn"
|
<button type="button" class="lt-tab" id="activity-tab-btn"
|
||||||
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
|
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
|
||||||
Activity
|
Activity
|
||||||
@@ -758,6 +694,60 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($customFieldDefs)) : ?>
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
|
TAB PANEL: CUSTOM FIELDS
|
||||||
|
═══════════════════════════════════════════════════════════ -->
|
||||||
|
<div id="custom-fields-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="custom-fields-tab-btn">
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Custom Fields</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div id="customFieldsMsg" class="lt-msg is-hidden lt-mb-md" role="alert" aria-live="polite"></div>
|
||||||
|
<?php foreach ($customFieldDefs as $cfDef) :
|
||||||
|
$cfValue = $customFieldValues[$cfDef['field_name']]['field_value'] ?? '';
|
||||||
|
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
|
||||||
|
$cfId = 'ticket_custom_field_' . (int)$cfDef['field_id'];
|
||||||
|
?>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="<?= $cfId ?>">
|
||||||
|
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
|
||||||
|
</label>
|
||||||
|
<?php if ($cfDef['field_type'] === 'textarea') : ?>
|
||||||
|
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
|
||||||
|
><?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'select') : ?>
|
||||||
|
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select">
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
|
||||||
|
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
<?= $opt === $cfValue ? 'selected' : '' ?>><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1"
|
||||||
|
<?= $cfValue === '1' ? 'checked' : '' ?>>
|
||||||
|
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</label>
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'date') : ?>
|
||||||
|
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||||
|
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php elseif ($cfDef['field_type'] === 'number') : ?>
|
||||||
|
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||||
|
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php else : ?>
|
||||||
|
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||||
|
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<button type="button" id="saveCustomFieldsBtn" class="lt-btn lt-btn-primary lt-btn-sm">SAVE CUSTOM FIELDS</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TAB PANEL: ACTIVITY
|
TAB PANEL: ACTIVITY
|
||||||
═══════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════ -->
|
||||||
@@ -1089,6 +1079,46 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save custom fields button
|
||||||
|
var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn');
|
||||||
|
if (saveCustomFieldsBtn) {
|
||||||
|
saveCustomFieldsBtn.addEventListener('click', function () {
|
||||||
|
var panel = document.getElementById('custom-fields-panel');
|
||||||
|
var msg = document.getElementById('customFieldsMsg');
|
||||||
|
var values = {};
|
||||||
|
panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) {
|
||||||
|
var m = el.name.match(/custom_fields\[(\d+)\]/);
|
||||||
|
if (!m) return;
|
||||||
|
var fieldId = m[1];
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
values[fieldId] = el.checked ? '1' : '0';
|
||||||
|
} else {
|
||||||
|
values[fieldId] = el.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
saveCustomFieldsBtn.disabled = true;
|
||||||
|
msg.classList.add('is-hidden');
|
||||||
|
|
||||||
|
lt.api.post('/api/ticket_custom_fields.php', {
|
||||||
|
ticket_id: window.ticketData.id,
|
||||||
|
values: values
|
||||||
|
}).then(function (data) {
|
||||||
|
saveCustomFieldsBtn.disabled = false;
|
||||||
|
if (data.success) {
|
||||||
|
lt.toast.success('Custom fields saved', 3000);
|
||||||
|
} else {
|
||||||
|
msg.textContent = data.error || 'Failed to save custom fields';
|
||||||
|
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
|
||||||
|
}
|
||||||
|
}).catch(function (error) {
|
||||||
|
saveCustomFieldsBtn.disabled = false;
|
||||||
|
msg.textContent = 'Failed to save custom fields: ' + error.message;
|
||||||
|
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Settings save/cancel
|
// Settings save/cancel
|
||||||
// Load user preference toggles on settings modal open
|
// Load user preference toggles on settings modal open
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@@ -45,10 +45,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<option value="read">read</option>
|
<option value="read">read</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lt-form-group" style="flex:1;margin:0">
|
||||||
|
<label class="lt-label" style="display:flex;align-items:center;gap:0.4rem;cursor:pointer">
|
||||||
|
<input type="checkbox" id="keySeeAllVisibility">
|
||||||
|
See all visibility
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
||||||
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
|
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
|
||||||
|
By default a key only sees <strong>public</strong>-visibility tickets — check
|
||||||
|
<strong>See all visibility</strong> only if this key genuinely needs Confidential/Internal tickets too.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- New key display (hidden by default) -->
|
<!-- New key display (hidden by default) -->
|
||||||
@@ -74,6 +82,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
<th scope="col">Key Prefix</th>
|
<th scope="col">Key Prefix</th>
|
||||||
<th scope="col">Scope</th>
|
<th scope="col">Scope</th>
|
||||||
|
<th scope="col">Visibility</th>
|
||||||
<th scope="col">Created By</th>
|
<th scope="col">Created By</th>
|
||||||
<th scope="col">Created</th>
|
<th scope="col">Created</th>
|
||||||
<th scope="col">Expires</th>
|
<th scope="col">Expires</th>
|
||||||
@@ -86,7 +95,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<?php
|
<?php
|
||||||
$apiKeysList = $apiKeys['keys'] ?? [];
|
$apiKeysList = $apiKeys['keys'] ?? [];
|
||||||
if (empty($apiKeysList)) : ?>
|
if (empty($apiKeysList)) : ?>
|
||||||
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
<tr><td colspan="10" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||||
<?php else :
|
<?php else :
|
||||||
foreach ($apiKeysList as $key) : ?>
|
foreach ($apiKeysList as $key) : ?>
|
||||||
<?php
|
<?php
|
||||||
@@ -103,6 +112,13 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
|
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td data-label="Visibility">
|
||||||
|
<?php if (!empty($key['see_all_visibility'])) : ?>
|
||||||
|
<span class="lt-status lt-status-open" title="Bypasses ticket visibility — sees Confidential/Internal tickets too">all</span>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="lt-status lt-status-closed" title="Only sees public-visibility tickets">public only</span>
|
||||||
|
<?php endif ?>
|
||||||
|
</td>
|
||||||
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
||||||
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
||||||
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
||||||
@@ -242,8 +258,14 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
|
|||||||
var keyName = document.getElementById('keyName').value.trim();
|
var keyName = document.getElementById('keyName').value.trim();
|
||||||
var expiresIn = document.getElementById('expiresIn').value;
|
var expiresIn = document.getElementById('expiresIn').value;
|
||||||
var keyScope = document.getElementById('keyScope').value;
|
var keyScope = document.getElementById('keyScope').value;
|
||||||
|
var seeAllVisibility = document.getElementById('keySeeAllVisibility').checked;
|
||||||
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
|
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
|
||||||
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope })
|
lt.api.post('/api/generate_api_key.php', {
|
||||||
|
key_name: keyName,
|
||||||
|
expires_in_days: expiresIn || null,
|
||||||
|
scope: keyScope,
|
||||||
|
see_all_visibility: seeAllVisibility
|
||||||
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('newKeyValue').value = data.api_key;
|
document.getElementById('newKeyValue').value = data.api_key;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -235,8 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadNotifications() {
|
function loadNotifications() {
|
||||||
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
return lt.api.get('/api/notifications.php')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) { renderNotifications(data); return true; })
|
.then(function(data) { renderNotifications(data); return true; })
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||||
@@ -251,11 +250,7 @@
|
|||||||
|
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.addEventListener('click', function() {
|
clearBtn.addEventListener('click', function() {
|
||||||
fetch('/api/notifications.php', {
|
lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications);
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
|
||||||
body: JSON.stringify({ action: 'mark_read' })
|
|
||||||
}).then(loadNotifications);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user